Snap for 10611805 from a23d1fae6c9e206eb78460a2a59a3c5c49020c10 to mainline-extservices-release
Change-Id: Id0b4b7ca4266177daefd8af8be9c9513030ae68d
diff --git a/.github/workflows/python-build-test.yml b/.github/workflows/python-build-test.yml
index 72c7b43..3f20093 100644
--- a/.github/workflows/python-build-test.yml
+++ b/.github/workflows/python-build-test.yml
@@ -16,7 +16,7 @@
runs-on: ubuntu-latest
strategy:
matrix:
- python-version: ["3.8", "3.9", "3.10"]
+ python-version: ["3.8", "3.9", "3.10", "3.11"]
fail-fast: false
steps:
@@ -41,3 +41,30 @@
run: |
inv build
inv build.mkdocs
+ build-rust:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: [ "3.8", "3.9", "3.10" ]
+ 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
+ - name: Rust Lints
+ run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings
+ - name: Rust Build
+ run: cd rust && cargo build --all-targets
+ - name: Rust Tests
+ run: cd rust && cargo test
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 43b6410..97dc64d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@
# generated by setuptools_scm
bumble/_version.py
.vscode/launch.json
+/.idea
diff --git a/Android.bp b/Android.bp
index 640bded..9cb3d08 100644
--- a/Android.bp
+++ b/Android.bp
@@ -19,6 +19,7 @@
name: "bumble",
srcs: [
"bumble/*.py",
+ "bumble/drivers/*.py",
"bumble/profiles/*.py",
"bumble/transport/*.py",
],
diff --git a/apps/pair.py b/apps/pair.py
index a7844fe..2cc8188 100644
--- a/apps/pair.py
+++ b/apps/pair.py
@@ -157,6 +157,26 @@
self.print(f'### PIN: {number:0{digits}}')
self.print('###-----------------------------------')
+ async def get_string(self, max_length: int):
+ await self.update_peer_name()
+
+ # Prompt a PIN (for legacy pairing in classic)
+ self.print('###-----------------------------------')
+ self.print(f'### Pairing with {self.peer_name}')
+ self.print('###-----------------------------------')
+ count = 0
+ while True:
+ response = await self.prompt('>>> Enter PIN (1-6 chars):')
+ if len(response) == 0:
+ count += 1
+ if count > 3:
+ self.print('too many tries, stopping the pairing')
+ return None
+
+ self.print('no PIN was entered, try again')
+ continue
+ return response
+
# -----------------------------------------------------------------------------
async def get_peer_name(peer, mode):
@@ -207,7 +227,7 @@
# Listen for pairing events
connection.on('pairing_start', on_pairing_start)
- connection.on('pairing', on_pairing)
+ connection.on('pairing', lambda keys: on_pairing(connection.peer_address, keys))
connection.on('pairing_failure', on_pairing_failure)
# Listen for encryption changes
@@ -242,9 +262,9 @@
# -----------------------------------------------------------------------------
-def on_pairing(keys):
+def on_pairing(address, keys):
print(color('***-----------------------------------', 'cyan'))
- print(color('*** Paired!', 'cyan'))
+ print(color(f'*** Paired! (peer identity={address})', 'cyan'))
keys.print(prefix=color('*** ', 'cyan'))
print(color('***-----------------------------------', 'cyan'))
Waiter.instance.terminate()
@@ -283,17 +303,6 @@
# Create a device to manage the host
device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
- # Set a custom keystore if specified on the command line
- if keystore_file:
- device.keystore = JsonKeyStore(namespace=None, filename=keystore_file)
-
- # Print the existing keys before pairing
- if print_keys and device.keystore:
- print(color('@@@-----------------------------------', 'blue'))
- print(color('@@@ Pairing Keys:', 'blue'))
- await device.keystore.print(prefix=color('@@@ ', 'blue'))
- print(color('@@@-----------------------------------', 'blue'))
-
# Expose a GATT characteristic that can be used to trigger pairing by
# responding with an authentication error when read
if mode == 'le':
@@ -323,6 +332,17 @@
# Get things going
await device.power_on()
+ # Set a custom keystore if specified on the command line
+ if keystore_file:
+ device.keystore = JsonKeyStore.from_device(device, filename=keystore_file)
+
+ # Print the existing keys before pairing
+ if print_keys and device.keystore:
+ print(color('@@@-----------------------------------', 'blue'))
+ print(color('@@@ Pairing Keys:', 'blue'))
+ await device.keystore.print(prefix=color('@@@ ', 'blue'))
+ print(color('@@@-----------------------------------', 'blue'))
+
# Set up a pairing config factory
device.pairing_config_factory = lambda connection: PairingConfig(
sc, mitm, bond, Delegate(mode, connection, io, prompt)
diff --git a/apps/scan.py b/apps/scan.py
index dac7a2c..268912f 100644
--- a/apps/scan.py
+++ b/apps/scan.py
@@ -133,15 +133,16 @@
'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
)
+ await device.power_on()
+
if keystore_file:
- keystore = JsonKeyStore(namespace=None, filename=keystore_file)
- device.keystore = keystore
- else:
- resolver = None
+ device.keystore = JsonKeyStore.from_device(device, filename=keystore_file)
if device.keystore:
resolving_keys = await device.keystore.get_resolving_keys()
resolver = AddressResolver(resolving_keys)
+ else:
+ resolver = None
printer = AdvertisementPrinter(min_rssi, resolver)
if raw:
@@ -149,8 +150,6 @@
else:
device.on('advertisement', printer.on_advertisement)
- await device.power_on()
-
if phy is None:
scanning_phys = [HCI_LE_1M_PHY, HCI_LE_CODED_PHY]
else:
diff --git a/apps/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..075068b
--- /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: 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/apps/speaker/speaker.html b/apps/speaker/speaker.html
new file mode 100644
index 0000000..f68abcc
--- /dev/null
+++ b/apps/speaker/speaker.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Bumble Speaker</title>
+ <script type="text/javascript" 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..a2907d4
--- /dev/null
+++ b/apps/speaker/speaker.py
@@ -0,0 +1,747 @@
+# 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 = str(connection.peer_address).replace('/P', '')
+ 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) -> None:
+ super().__init__(AacAudioExtractor())
+ self.subprocess = None
+ self.ffplay_task = None
+
+ async def start(self):
+ if self.started:
+ return
+
+ await super().start()
+
+ self.subprocess = await asyncio.create_subprocess_shell(
+ 'ffplay -acodec aac 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=str(connection.peer_address).replace('/P', ''),
+ 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())
+ 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."""
+
+ # ffplay only works with AAC for now
+ if codec != 'aac' and '@ffplay' in output:
+ print(
+ color(
+ f'{codec} not supported with @ffplay output, '
+ '@ffplay output will be skipped',
+ 'yellow',
+ )
+ )
+ output = list(filter(lambda x: x != '@ffplay', output))
+
+ if '@ffplay' in output:
+ # Check if ffplay is installed
+ try:
+ subprocess.run(['ffplay', '-version'], capture_output=True, check=True)
+ except FileNotFoundError:
+ print(
+ color('ffplay not installed, @ffplay output will be disabled', 'yellow')
+ )
+ output = list(filter(lambda x: x != '@ffplay', output))
+
+ asyncio.run(
+ Speaker(device_config, transport, codec, discover, output, ui_port).run(
+ connect_address
+ )
+ )
+
+
+# -----------------------------------------------------------------------------
+def main():
+ logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
+ speaker()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == "__main__":
+ main() # pylint: disable=no-value-for-parameter
diff --git a/apps/unbond.py b/apps/unbond.py
index 105d9a4..5ffd746 100644
--- a/apps/unbond.py
+++ b/apps/unbond.py
@@ -22,40 +22,58 @@
from bumble.device import Device
from bumble.keys import JsonKeyStore
+from bumble.transport import open_transport
+
+# -----------------------------------------------------------------------------
+async def unbond_with_keystore(keystore, address):
+ if address is None:
+ return await keystore.print()
+
+ try:
+ await keystore.delete(address)
+ except KeyError:
+ print('!!! pairing not found')
# -----------------------------------------------------------------------------
-async def unbond(keystore_file, device_config, address):
- # Create a device to manage the host
- device = Device.from_config_file(device_config)
-
- # Get all entries in the keystore
+async def unbond(keystore_file, device_config, hci_transport, address):
+ # With a keystore file, we can instantiate the keystore directly
if keystore_file:
- keystore = JsonKeyStore(None, keystore_file)
- else:
- keystore = device.keystore
+ return await unbond_with_keystore(JsonKeyStore(None, keystore_file), address)
- if keystore is None:
- print('no keystore')
- return
+ # Without a keystore file, we need to obtain the keystore from the device
+ async with await open_transport(hci_transport) as (hci_source, hci_sink):
+ # Create a device to manage the host
+ device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
- if address is None:
- await keystore.print()
- else:
- try:
- await keystore.delete(address)
- except KeyError:
- print('!!! pairing not found')
+ # Power-on the device to ensure we have a key store
+ await device.power_on()
+
+ return await unbond_with_keystore(device.keystore, address)
# -----------------------------------------------------------------------------
@click.command()
-@click.option('--keystore-file', help='File in which to store the pairing keys')
-@click.argument('device-config')
+@click.option('--keystore-file', help='File in which the pairing keys are stored')
+@click.option('--hci-transport', help='HCI transport for the controller')
+@click.argument('device-config', required=False)
@click.argument('address', required=False)
-def main(keystore_file, device_config, address):
+def main(keystore_file, hci_transport, device_config, address):
+ """
+ Remove pairing keys for a device, given its address.
+
+ If no keystore file is specified, the --hci-transport option must be used to
+ connect to a controller, so that the keystore for that controller can be
+ instantiated.
+ If no address is passed, the existing pairing keys for all addresses are printed.
+ """
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
- asyncio.run(unbond(keystore_file, device_config, address))
+
+ if not keystore_file and not hci_transport:
+ print('either --keystore-file or --hci-transport must be specified.')
+ return
+
+ asyncio.run(unbond(keystore_file, device_config, hci_transport, address))
# -----------------------------------------------------------------------------
diff --git a/bumble/a2dp.py b/bumble/a2dp.py
index 772846a..eeecb1e 100644
--- a/bumble/a2dp.py
+++ b/bumble/a2dp.py
@@ -432,6 +432,7 @@
cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies
),
channels=sum(cls.CHANNELS_BITS[x] for x in channels),
+ rfa=0,
vbr=vbr,
bitrate=bitrate,
)
diff --git a/bumble/avdtp.py b/bumble/avdtp.py
index 238036d..3988f30 100644
--- a/bumble/avdtp.py
+++ b/bumble/avdtp.py
@@ -1207,7 +1207,7 @@
# -----------------------------------------------------------------------------
-class Protocol:
+class Protocol(EventEmitter):
SINGLE_PACKET = 0
START_PACKET = 1
CONTINUE_PACKET = 2
@@ -1234,6 +1234,7 @@
return protocol
def __init__(self, l2cap_channel, version=(1, 3)):
+ super().__init__()
self.l2cap_channel = l2cap_channel
self.version = version
self.rtx_sig_timer = AVDTP_DEFAULT_RTX_SIG_TIMER
@@ -1250,6 +1251,7 @@
# Register to receive PDUs from the channel
l2cap_channel.sink = self.on_pdu
l2cap_channel.on('open', self.on_l2cap_channel_open)
+ l2cap_channel.on('close', self.on_l2cap_channel_close)
def get_local_endpoint_by_seid(self, seid):
if 0 < seid <= len(self.local_endpoints):
@@ -1392,11 +1394,18 @@
def on_l2cap_connection(self, channel):
# Forward the channel to the endpoint that's expecting it
- if self.channel_acceptor:
- self.channel_acceptor.on_l2cap_connection(channel)
+ if self.channel_acceptor is None:
+ logger.warning(color('!!! l2cap connection with no acceptor', 'red'))
+ return
+ self.channel_acceptor.on_l2cap_connection(channel)
def on_l2cap_channel_open(self):
logger.debug(color('<<< L2CAP channel open', 'magenta'))
+ self.emit('open')
+
+ def on_l2cap_channel_close(self):
+ logger.debug(color('<<< L2CAP channel close', 'magenta'))
+ self.emit('close')
def send_message(self, transaction_label, message):
logger.debug(
@@ -1651,6 +1660,10 @@
def set_server(self, connection, server):
self.servers[connection.handle] = server
+ def remove_server(self, connection):
+ if connection.handle in self.servers:
+ del self.servers[connection.handle]
+
def __init__(self, registrar, version=(1, 3)):
super().__init__()
self.version = version
@@ -1669,11 +1682,17 @@
else:
# This is a new command/response channel
def on_channel_open():
+ logger.debug('setting up new Protocol for the connection')
server = Protocol(channel, self.version)
self.set_server(channel.connection, server)
self.emit('connection', server)
+ def on_channel_close():
+ logger.debug('removing Protocol for the connection')
+ self.remove_server(channel.connection)
+
channel.on('open', on_channel_open)
+ channel.on('close', on_channel_close)
# -----------------------------------------------------------------------------
@@ -1967,11 +1986,12 @@
# -----------------------------------------------------------------------------
-class LocalStreamEndPoint(StreamEndPoint):
+class LocalStreamEndPoint(StreamEndPoint, EventEmitter):
def __init__(
self, protocol, seid, media_type, tsep, capabilities, configuration=None
):
- super().__init__(seid, media_type, tsep, 0, capabilities)
+ StreamEndPoint.__init__(self, seid, media_type, tsep, 0, capabilities)
+ EventEmitter.__init__(self)
self.protocol = protocol
self.configuration = configuration if configuration is not None else []
self.stream = None
@@ -1988,40 +2008,47 @@
def on_reconfigure_command(self, command):
pass
+ def on_set_configuration_command(self, configuration):
+ logger.debug(
+ '<<< received configuration: '
+ f'{",".join([str(capability) for capability in configuration])}'
+ )
+ self.configuration = configuration
+ self.emit('configuration')
+
def on_get_configuration_command(self):
return Get_Configuration_Response(self.configuration)
def on_open_command(self):
- pass
+ self.emit('open')
def on_start_command(self):
- pass
+ self.emit('start')
def on_suspend_command(self):
- pass
+ self.emit('suspend')
def on_close_command(self):
- pass
+ self.emit('close')
def on_abort_command(self):
- pass
+ self.emit('abort')
def on_rtp_channel_open(self):
- pass
+ self.emit('rtp_channel_open')
def on_rtp_channel_close(self):
- pass
+ self.emit('rtp_channel_close')
# -----------------------------------------------------------------------------
-class LocalSource(LocalStreamEndPoint, EventEmitter):
+class LocalSource(LocalStreamEndPoint):
def __init__(self, protocol, seid, codec_capabilities, packet_pump):
capabilities = [
ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
codec_capabilities,
]
- LocalStreamEndPoint.__init__(
- self,
+ super().__init__(
protocol,
seid,
codec_capabilities.media_type,
@@ -2029,14 +2056,13 @@
capabilities,
capabilities,
)
- EventEmitter.__init__(self)
self.packet_pump = packet_pump
async def start(self):
if self.packet_pump:
return await self.packet_pump.start(self.stream.rtp_channel)
- self.emit('start', self.stream.rtp_channel)
+ self.emit('start')
async def stop(self):
if self.packet_pump:
@@ -2044,11 +2070,6 @@
self.emit('stop')
- def on_set_configuration_command(self, configuration):
- # For now, blindly accept the configuration
- logger.debug(f'<<< received source configuration: {configuration}')
- self.configuration = configuration
-
def on_start_command(self):
asyncio.create_task(self.start())
@@ -2057,30 +2078,28 @@
# -----------------------------------------------------------------------------
-class LocalSink(LocalStreamEndPoint, EventEmitter):
+class LocalSink(LocalStreamEndPoint):
def __init__(self, protocol, seid, codec_capabilities):
capabilities = [
ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
codec_capabilities,
]
- LocalStreamEndPoint.__init__(
- self,
+ super().__init__(
protocol,
seid,
codec_capabilities.media_type,
AVDTP_TSEP_SNK,
capabilities,
)
- EventEmitter.__init__(self)
-
- def on_set_configuration_command(self, configuration):
- # For now, blindly accept the configuration
- logger.debug(f'<<< received sink configuration: {configuration}')
- self.configuration = configuration
def on_rtp_channel_open(self):
logger.debug(color('<<< RTP channel open', 'magenta'))
self.stream.rtp_channel.sink = self.on_avdtp_packet
+ super().on_rtp_channel_open()
+
+ def on_rtp_channel_close(self):
+ logger.debug(color('<<< RTP channel close', 'magenta'))
+ super().on_rtp_channel_close()
def on_avdtp_packet(self, packet):
rtp_packet = MediaPacket.from_bytes(packet)
diff --git a/bumble/codecs.py b/bumble/codecs.py
new file mode 100644
index 0000000..1d7ae82
--- /dev/null
+++ b/bumble/codecs.py
@@ -0,0 +1,381 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+from dataclasses import dataclass
+
+
+# -----------------------------------------------------------------------------
+class BitReader:
+ """Simple but not optimized bit stream reader."""
+
+ data: bytes
+ bytes_position: int
+ bit_position: int
+ cache: int
+ bits_cached: int
+
+ def __init__(self, data: bytes):
+ self.data = data
+ self.byte_position = 0
+ self.bit_position = 0
+ self.cache = 0
+ self.bits_cached = 0
+
+ def read(self, bits: int) -> int:
+ """ "Read up to 32 bits."""
+
+ if bits > 32:
+ raise ValueError('maximum read size is 32')
+
+ if self.bits_cached >= bits:
+ # We have enough bits.
+ self.bits_cached -= bits
+ self.bit_position += bits
+ return (self.cache >> self.bits_cached) & ((1 << bits) - 1)
+
+ # Read more cache, up to 32 bits
+ feed_bytes = self.data[self.byte_position : self.byte_position + 4]
+ feed_size = len(feed_bytes)
+ feed_int = int.from_bytes(feed_bytes, byteorder='big')
+ if 8 * feed_size + self.bits_cached < bits:
+ raise ValueError('trying to read past the data')
+ self.byte_position += feed_size
+
+ # Combine the new cache and the old cache
+ cache = self.cache & ((1 << self.bits_cached) - 1)
+ new_bits = bits - self.bits_cached
+ self.bits_cached = 8 * feed_size - new_bits
+ result = (feed_int >> self.bits_cached) | (cache << new_bits)
+ self.cache = feed_int
+
+ self.bit_position += bits
+ return result
+
+ def read_bytes(self, count: int):
+ if self.bit_position + 8 * count > 8 * len(self.data):
+ raise ValueError('not enough data')
+
+ if self.bit_position % 8:
+ # Not byte aligned
+ result = bytearray(count)
+ for i in range(count):
+ result[i] = self.read(8)
+ return bytes(result)
+
+ # Byte aligned
+ self.byte_position = self.bit_position // 8
+ self.bits_cached = 0
+ self.cache = 0
+ offset = self.bit_position // 8
+ self.bit_position += 8 * count
+ return self.data[offset : offset + count]
+
+ def bits_left(self) -> int:
+ return (8 * len(self.data)) - self.bit_position
+
+ def skip(self, bits: int) -> None:
+ # Slow, but simple...
+ while bits:
+ if bits > 32:
+ self.read(32)
+ bits -= 32
+ else:
+ self.read(bits)
+ break
+
+
+# -----------------------------------------------------------------------------
+class AacAudioRtpPacket:
+ """AAC payload encapsulated in an RTP packet payload"""
+
+ @staticmethod
+ def latm_value(reader: BitReader) -> int:
+ bytes_for_value = reader.read(2)
+ value = 0
+ for _ in range(bytes_for_value + 1):
+ value = value * 256 + reader.read(8)
+ return value
+
+ @staticmethod
+ def program_config_element(reader: BitReader):
+ raise ValueError('program_config_element not supported')
+
+ @dataclass
+ class GASpecificConfig:
+ def __init__(
+ self, reader: BitReader, channel_configuration: int, audio_object_type: int
+ ) -> None:
+ # GASpecificConfig - ISO/EIC 14496-3 Table 4.1
+ frame_length_flag = reader.read(1)
+ depends_on_core_coder = reader.read(1)
+ if depends_on_core_coder:
+ self.core_coder_delay = reader.read(14)
+ extension_flag = reader.read(1)
+ if not channel_configuration:
+ AacAudioRtpPacket.program_config_element(reader)
+ if audio_object_type in (6, 20):
+ self.layer_nr = reader.read(3)
+ if extension_flag:
+ if audio_object_type == 22:
+ num_of_sub_frame = reader.read(5)
+ layer_length = reader.read(11)
+ if audio_object_type in (17, 19, 20, 23):
+ aac_section_data_resilience_flags = reader.read(1)
+ aac_scale_factor_data_resilience_flags = reader.read(1)
+ aac_spectral_data_resilience_flags = reader.read(1)
+ extension_flag_3 = reader.read(1)
+ if extension_flag_3 == 1:
+ raise ValueError('extensionFlag3 == 1 not supported')
+
+ @staticmethod
+ def audio_object_type(reader: BitReader):
+ # GetAudioObjectType - ISO/EIC 14496-3 Table 1.16
+ audio_object_type = reader.read(5)
+ if audio_object_type == 31:
+ audio_object_type = 32 + reader.read(6)
+
+ return audio_object_type
+
+ @dataclass
+ class AudioSpecificConfig:
+ audio_object_type: int
+ sampling_frequency_index: int
+ sampling_frequency: int
+ channel_configuration: int
+ sbr_present_flag: int
+ ps_present_flag: int
+ extension_audio_object_type: int
+ extension_sampling_frequency_index: int
+ extension_sampling_frequency: int
+ extension_channel_configuration: int
+
+ SAMPLING_FREQUENCIES = [
+ 96000,
+ 88200,
+ 64000,
+ 48000,
+ 44100,
+ 32000,
+ 24000,
+ 22050,
+ 16000,
+ 12000,
+ 11025,
+ 8000,
+ 7350,
+ ]
+
+ def __init__(self, reader: BitReader) -> None:
+ # AudioSpecificConfig - ISO/EIC 14496-3 Table 1.15
+ self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
+ self.sampling_frequency_index = reader.read(4)
+ if self.sampling_frequency_index == 0xF:
+ self.sampling_frequency = reader.read(24)
+ else:
+ self.sampling_frequency = self.SAMPLING_FREQUENCIES[
+ self.sampling_frequency_index
+ ]
+ self.channel_configuration = reader.read(4)
+ self.sbr_present_flag = -1
+ self.ps_present_flag = -1
+ if self.audio_object_type in (5, 29):
+ self.extension_audio_object_type = 5
+ self.sbc_present_flag = 1
+ if self.audio_object_type == 29:
+ self.ps_present_flag = 1
+ self.extension_sampling_frequency_index = reader.read(4)
+ if self.extension_sampling_frequency_index == 0xF:
+ self.extension_sampling_frequency = reader.read(24)
+ else:
+ self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[
+ self.extension_sampling_frequency_index
+ ]
+ self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
+ if self.audio_object_type == 22:
+ self.extension_channel_configuration = reader.read(4)
+ else:
+ self.extension_audio_object_type = 0
+
+ if self.audio_object_type in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23):
+ ga_specific_config = AacAudioRtpPacket.GASpecificConfig(
+ reader, self.channel_configuration, self.audio_object_type
+ )
+ else:
+ raise ValueError(
+ f'audioObjectType {self.audio_object_type} not supported'
+ )
+
+ # if self.extension_audio_object_type != 5 and bits_to_decode >= 16:
+ # sync_extension_type = reader.read(11)
+ # if sync_extension_type == 0x2B7:
+ # self.extension_audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
+ # if self.extension_audio_object_type == 5:
+ # self.sbr_present_flag = reader.read(1)
+ # if self.sbr_present_flag:
+ # self.extension_sampling_frequency_index = reader.read(4)
+ # if self.extension_sampling_frequency_index == 0xF:
+ # self.extension_sampling_frequency = reader.read(24)
+ # else:
+ # self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index]
+ # if bits_to_decode >= 12:
+ # sync_extension_type = reader.read(11)
+ # if sync_extension_type == 0x548:
+ # self.ps_present_flag = reader.read(1)
+ # elif self.extension_audio_object_type == 22:
+ # self.sbr_present_flag = reader.read(1)
+ # if self.sbr_present_flag:
+ # self.extension_sampling_frequency_index = reader.read(4)
+ # if self.extension_sampling_frequency_index == 0xF:
+ # self.extension_sampling_frequency = reader.read(24)
+ # else:
+ # self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index]
+ # self.extension_channel_configuration = reader.read(4)
+
+ @dataclass
+ class StreamMuxConfig:
+ other_data_present: int
+ other_data_len_bits: int
+ audio_specific_config: AacAudioRtpPacket.AudioSpecificConfig
+
+ def __init__(self, reader: BitReader) -> None:
+ # StreamMuxConfig - ISO/EIC 14496-3 Table 1.42
+ audio_mux_version = reader.read(1)
+ if audio_mux_version == 1:
+ audio_mux_version_a = reader.read(1)
+ else:
+ audio_mux_version_a = 0
+ if audio_mux_version_a != 0:
+ raise ValueError('audioMuxVersionA != 0 not supported')
+ if audio_mux_version == 1:
+ tara_buffer_fullness = AacAudioRtpPacket.latm_value(reader)
+ stream_cnt = 0
+ all_streams_same_time_framing = reader.read(1)
+ num_sub_frames = reader.read(6)
+ num_program = reader.read(4)
+ if num_program != 0:
+ raise ValueError('num_program != 0 not supported')
+ num_layer = reader.read(3)
+ if num_layer != 0:
+ raise ValueError('num_layer != 0 not supported')
+ if audio_mux_version == 0:
+ self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
+ reader
+ )
+ else:
+ asc_len = AacAudioRtpPacket.latm_value(reader)
+ marker = reader.bit_position
+ self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
+ reader
+ )
+ audio_specific_config_len = reader.bit_position - marker
+ if asc_len < audio_specific_config_len:
+ raise ValueError('audio_specific_config_len > asc_len')
+ asc_len -= audio_specific_config_len
+ reader.skip(asc_len)
+ frame_length_type = reader.read(3)
+ if frame_length_type == 0:
+ latm_buffer_fullness = reader.read(8)
+ elif frame_length_type == 1:
+ frame_length = reader.read(9)
+ else:
+ raise ValueError(f'frame_length_type {frame_length_type} not supported')
+
+ self.other_data_present = reader.read(1)
+ if self.other_data_present:
+ if audio_mux_version == 1:
+ self.other_data_len_bits = AacAudioRtpPacket.latm_value(reader)
+ else:
+ self.other_data_len_bits = 0
+ while True:
+ self.other_data_len_bits *= 256
+ other_data_len_esc = reader.read(1)
+ self.other_data_len_bits += reader.read(8)
+ if other_data_len_esc == 0:
+ break
+ crc_check_present = reader.read(1)
+ if crc_check_present:
+ crc_checksum = reader.read(8)
+
+ @dataclass
+ class AudioMuxElement:
+ payload: bytes
+ stream_mux_config: AacAudioRtpPacket.StreamMuxConfig
+
+ def __init__(self, reader: BitReader, mux_config_present: int):
+ if mux_config_present == 0:
+ raise ValueError('muxConfigPresent == 0 not supported')
+
+ # AudioMuxElement - ISO/EIC 14496-3 Table 1.41
+ use_same_stream_mux = reader.read(1)
+ if use_same_stream_mux:
+ raise ValueError('useSameStreamMux == 1 not supported')
+ self.stream_mux_config = AacAudioRtpPacket.StreamMuxConfig(reader)
+
+ # We only support:
+ # allStreamsSameTimeFraming == 1
+ # audioMuxVersionA == 0,
+ # numProgram == 0
+ # numSubFrames == 0
+ # numLayer == 0
+
+ mux_slot_length_bytes = 0
+ while True:
+ tmp = reader.read(8)
+ mux_slot_length_bytes += tmp
+ if tmp != 255:
+ break
+
+ self.payload = reader.read_bytes(mux_slot_length_bytes)
+
+ if self.stream_mux_config.other_data_present:
+ reader.skip(self.stream_mux_config.other_data_len_bits)
+
+ # ByteAlign
+ while reader.bit_position % 8:
+ reader.read(1)
+
+ def __init__(self, data: bytes) -> None:
+ # Parse the bit stream
+ reader = BitReader(data)
+ self.audio_mux_element = self.AudioMuxElement(reader, mux_config_present=1)
+
+ def to_adts(self):
+ # pylint: disable=line-too-long
+ sampling_frequency_index = (
+ self.audio_mux_element.stream_mux_config.audio_specific_config.sampling_frequency_index
+ )
+ channel_configuration = (
+ self.audio_mux_element.stream_mux_config.audio_specific_config.channel_configuration
+ )
+ frame_size = len(self.audio_mux_element.payload)
+ return (
+ bytes(
+ [
+ 0xFF,
+ 0xF1, # 0xF9 (MPEG2)
+ 0x40
+ | (sampling_frequency_index << 2)
+ | (channel_configuration >> 2),
+ ((channel_configuration & 0x3) << 6) | ((frame_size + 7) >> 11),
+ ((frame_size + 7) >> 3) & 0xFF,
+ (((frame_size + 7) << 5) & 0xFF) | 0x1F,
+ 0xFC,
+ ]
+ )
+ + self.audio_mux_element.payload
+ )
diff --git a/bumble/controller.py b/bumble/controller.py
index cd7de3d..688fcd7 100644
--- a/bumble/controller.py
+++ b/bumble/controller.py
@@ -654,7 +654,7 @@
def on_hci_create_connection_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.1.5 Create Connection command
+ See Bluetooth spec Vol 4, Part E - 7.1.5 Create Connection command
'''
if self.link is None:
@@ -685,7 +685,7 @@
def on_hci_disconnect_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.1.6 Disconnect Command
+ See Bluetooth spec Vol 4, Part E - 7.1.6 Disconnect Command
'''
# First, say that the disconnection is pending
self.send_hci_packet(
@@ -719,7 +719,7 @@
def on_hci_accept_connection_request_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.1.8 Accept Connection Request command
+ See Bluetooth spec Vol 4, Part E - 7.1.8 Accept Connection Request command
'''
if self.link is None:
@@ -735,7 +735,7 @@
def on_hci_switch_role_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.2.8 Switch Role command
+ See Bluetooth spec Vol 4, Part E - 7.2.8 Switch Role command
'''
if self.link is None:
@@ -751,21 +751,21 @@
def on_hci_set_event_mask_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.3.1 Set Event Mask Command
+ See Bluetooth spec Vol 4, Part E - 7.3.1 Set Event Mask Command
'''
self.event_mask = command.event_mask
return bytes([HCI_SUCCESS])
def on_hci_reset_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.3.2 Reset Command
+ See Bluetooth spec Vol 4, Part E - 7.3.2 Reset Command
'''
# TODO: cleanup what needs to be reset
return bytes([HCI_SUCCESS])
def on_hci_write_local_name_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.3.11 Write Local Name Command
+ See Bluetooth spec Vol 4, Part E - 7.3.11 Write Local Name Command
'''
local_name = command.local_name
if len(local_name):
@@ -780,7 +780,7 @@
def on_hci_read_local_name_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.3.12 Read Local Name Command
+ See Bluetooth spec Vol 4, Part E - 7.3.12 Read Local Name Command
'''
local_name = bytes(self.local_name, 'utf-8')[:248]
if len(local_name) < 248:
@@ -790,19 +790,19 @@
def on_hci_read_class_of_device_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.3.25 Read Class of Device Command
+ See Bluetooth spec Vol 4, Part E - 7.3.25 Read Class of Device Command
'''
return bytes([HCI_SUCCESS, 0, 0, 0])
def on_hci_write_class_of_device_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.3.26 Write Class of Device Command
+ See Bluetooth spec Vol 4, Part E - 7.3.26 Write Class of Device Command
'''
return bytes([HCI_SUCCESS])
def on_hci_read_synchronous_flow_control_enable_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.3.36 Read Synchronous Flow Control Enable
+ See Bluetooth spec Vol 4, Part E - 7.3.36 Read Synchronous Flow Control Enable
Command
'''
if self.sync_flow_control:
@@ -813,7 +813,7 @@
def on_hci_write_synchronous_flow_control_enable_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.3.37 Write Synchronous Flow Control Enable
+ See Bluetooth spec Vol 4, Part E - 7.3.37 Write Synchronous Flow Control Enable
Command
'''
ret = HCI_SUCCESS
@@ -825,41 +825,59 @@
ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR
return bytes([ret])
+ def on_hci_set_controller_to_host_flow_control_command(self, _command):
+ '''
+ See Bluetooth spec Vol 4, Part E - 7.3.38 Set Controller To Host Flow Control
+ Command
+ '''
+ # For now we just accept the command but ignore the values.
+ # TODO: respect the passed in values.
+ return bytes([HCI_SUCCESS])
+
+ def on_hci_host_buffer_size_command(self, _command):
+ '''
+ See Bluetooth spec Vol 4, Part E - 7.3.39 Host Buffer Size Command
+ '''
+ # For now we just accept the command but ignore the values.
+ # TODO: respect the passed in values.
+ return bytes([HCI_SUCCESS])
+
def on_hci_write_extended_inquiry_response_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.3.59 Write Simple Pairing Mode Command
+ See Bluetooth spec Vol 4, Part E - 7.3.56 Write Extended Inquiry Response
+ Command
'''
return bytes([HCI_SUCCESS])
def on_hci_write_simple_pairing_mode_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.3.59 Write Simple Pairing Mode Command
+ See Bluetooth spec Vol 4, Part E - 7.3.59 Write Simple Pairing Mode Command
'''
return bytes([HCI_SUCCESS])
def on_hci_set_event_mask_page_2_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.3.69 Set Event Mask Page 2 Command
+ See Bluetooth spec Vol 4, Part E - 7.3.69 Set Event Mask Page 2 Command
'''
self.event_mask_page_2 = command.event_mask_page_2
return bytes([HCI_SUCCESS])
def on_hci_read_le_host_support_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.3.78 Write LE Host Support Command
+ See Bluetooth spec Vol 4, Part E - 7.3.78 Write LE Host Support Command
'''
return bytes([HCI_SUCCESS, 1, 0])
def on_hci_write_le_host_support_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.3.79 Write LE Host Support Command
+ See Bluetooth spec Vol 4, Part E - 7.3.79 Write LE Host Support Command
'''
# TODO / Just ignore for now
return bytes([HCI_SUCCESS])
def on_hci_write_authenticated_payload_timeout_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.3.94 Write Authenticated Payload Timeout
+ See Bluetooth spec Vol 4, Part E - 7.3.94 Write Authenticated Payload Timeout
Command
'''
# TODO
@@ -867,7 +885,7 @@
def on_hci_read_local_version_information_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.4.1 Read Local Version Information Command
+ See Bluetooth spec Vol 4, Part E - 7.4.1 Read Local Version Information Command
'''
return struct.pack(
'<BBHBHH',
@@ -881,19 +899,19 @@
def on_hci_read_local_supported_commands_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.4.2 Read Local Supported Commands Command
+ See Bluetooth spec Vol 4, Part E - 7.4.2 Read Local Supported Commands Command
'''
return bytes([HCI_SUCCESS]) + self.supported_commands
def on_hci_read_local_supported_features_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.4.3 Read Local Supported Features Command
+ See Bluetooth spec Vol 4, Part E - 7.4.3 Read Local Supported Features Command
'''
return bytes([HCI_SUCCESS]) + self.lmp_features
def on_hci_read_bd_addr_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.4.6 Read BD_ADDR Command
+ See Bluetooth spec Vol 4, Part E - 7.4.6 Read BD_ADDR Command
'''
bd_addr = (
self._public_address.to_bytes()
@@ -904,14 +922,14 @@
def on_hci_le_set_event_mask_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.1 LE Set Event Mask Command
+ See Bluetooth spec Vol 4, Part E - 7.8.1 LE Set Event Mask Command
'''
self.le_event_mask = command.le_event_mask
return bytes([HCI_SUCCESS])
def on_hci_le_read_buffer_size_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.2 LE Read Buffer Size Command
+ See Bluetooth spec Vol 4, Part E - 7.8.2 LE Read Buffer Size Command
'''
return struct.pack(
'<BHB',
@@ -922,49 +940,49 @@
def on_hci_le_read_local_supported_features_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.3 LE Read Local Supported Features
+ See Bluetooth spec Vol 4, Part E - 7.8.3 LE Read Local Supported Features
Command
'''
return bytes([HCI_SUCCESS]) + self.le_features
def on_hci_le_set_random_address_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.4 LE Set Random Address Command
+ See Bluetooth spec Vol 4, Part E - 7.8.4 LE Set Random Address Command
'''
self.random_address = command.random_address
return bytes([HCI_SUCCESS])
def on_hci_le_set_advertising_parameters_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.5 LE Set Advertising Parameters Command
+ See Bluetooth spec Vol 4, Part E - 7.8.5 LE Set Advertising Parameters Command
'''
self.advertising_parameters = command
return bytes([HCI_SUCCESS])
def on_hci_le_read_advertising_physical_channel_tx_power_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.6 LE Read Advertising Physical Channel
+ See Bluetooth spec Vol 4, Part E - 7.8.6 LE Read Advertising Physical Channel
Tx Power Command
'''
return bytes([HCI_SUCCESS, self.advertising_channel_tx_power])
def on_hci_le_set_advertising_data_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.7 LE Set Advertising Data Command
+ See Bluetooth spec Vol 4, Part E - 7.8.7 LE Set Advertising Data Command
'''
self.advertising_data = command.advertising_data
return bytes([HCI_SUCCESS])
def on_hci_le_set_scan_response_data_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.8 LE Set Scan Response Data Command
+ See Bluetooth spec Vol 4, Part E - 7.8.8 LE Set Scan Response Data Command
'''
self.le_scan_response_data = command.scan_response_data
return bytes([HCI_SUCCESS])
def on_hci_le_set_advertising_enable_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.9 LE Set Advertising Enable Command
+ See Bluetooth spec Vol 4, Part E - 7.8.9 LE Set Advertising Enable Command
'''
if command.advertising_enable:
self.start_advertising()
@@ -975,7 +993,7 @@
def on_hci_le_set_scan_parameters_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.10 LE Set Scan Parameters Command
+ See Bluetooth spec Vol 4, Part E - 7.8.10 LE Set Scan Parameters Command
'''
self.le_scan_type = command.le_scan_type
self.le_scan_interval = command.le_scan_interval
@@ -986,7 +1004,7 @@
def on_hci_le_set_scan_enable_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.11 LE Set Scan Enable Command
+ See Bluetooth spec Vol 4, Part E - 7.8.11 LE Set Scan Enable Command
'''
self.le_scan_enable = command.le_scan_enable
self.filter_duplicates = command.filter_duplicates
@@ -994,7 +1012,7 @@
def on_hci_le_create_connection_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.12 LE Create Connection Command
+ See Bluetooth spec Vol 4, Part E - 7.8.12 LE Create Connection Command
'''
if not self.link:
@@ -1027,40 +1045,40 @@
def on_hci_le_create_connection_cancel_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.13 LE Create Connection Cancel Command
+ See Bluetooth spec Vol 4, Part E - 7.8.13 LE Create Connection Cancel Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_read_filter_accept_list_size_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.14 LE Read Filter Accept List Size
+ See Bluetooth spec Vol 4, Part E - 7.8.14 LE Read Filter Accept List Size
Command
'''
return bytes([HCI_SUCCESS, self.filter_accept_list_size])
def on_hci_le_clear_filter_accept_list_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.15 LE Clear Filter Accept List Command
+ See Bluetooth spec Vol 4, Part E - 7.8.15 LE Clear Filter Accept List Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_add_device_to_filter_accept_list_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.16 LE Add Device To Filter Accept List
+ See Bluetooth spec Vol 4, Part E - 7.8.16 LE Add Device To Filter Accept List
Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_remove_device_from_filter_accept_list_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.17 LE Remove Device From Filter Accept
+ See Bluetooth spec Vol 4, Part E - 7.8.17 LE Remove Device From Filter Accept
List Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_read_remote_features_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.21 LE Read Remote Features Command
+ See Bluetooth spec Vol 4, Part E - 7.8.21 LE Read Remote Features Command
'''
# First, say that the command is pending
@@ -1083,13 +1101,13 @@
def on_hci_le_rand_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.23 LE Rand Command
+ See Bluetooth spec Vol 4, Part E - 7.8.23 LE Rand Command
'''
return bytes([HCI_SUCCESS]) + struct.pack('Q', random.randint(0, 1 << 64))
def on_hci_le_enable_encryption_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.24 LE Enable Encryption Command
+ See Bluetooth spec Vol 4, Part E - 7.8.24 LE Enable Encryption Command
'''
# Check the parameters
@@ -1122,13 +1140,13 @@
def on_hci_le_read_supported_states_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.27 LE Read Supported States Command
+ See Bluetooth spec Vol 4, Part E - 7.8.27 LE Read Supported States Command
'''
return bytes([HCI_SUCCESS]) + self.le_states
def on_hci_le_read_suggested_default_data_length_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.34 LE Read Suggested Default Data Length
+ See Bluetooth spec Vol 4, Part E - 7.8.34 LE Read Suggested Default Data Length
Command
'''
return struct.pack(
@@ -1140,7 +1158,7 @@
def on_hci_le_write_suggested_default_data_length_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.35 LE Write Suggested Default Data Length
+ See Bluetooth spec Vol 4, Part E - 7.8.35 LE Write Suggested Default Data Length
Command
'''
self.suggested_max_tx_octets, self.suggested_max_tx_time = struct.unpack(
@@ -1150,33 +1168,33 @@
def on_hci_le_read_local_p_256_public_key_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.36 LE Read P-256 Public Key Command
+ See Bluetooth spec Vol 4, Part E - 7.8.36 LE Read P-256 Public Key Command
'''
# TODO create key and send HCI_LE_Read_Local_P-256_Public_Key_Complete event
return bytes([HCI_SUCCESS])
def on_hci_le_add_device_to_resolving_list_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.38 LE Add Device To Resolving List
+ See Bluetooth spec Vol 4, Part E - 7.8.38 LE Add Device To Resolving List
Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_clear_resolving_list_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.40 LE Clear Resolving List Command
+ See Bluetooth spec Vol 4, Part E - 7.8.40 LE Clear Resolving List Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_read_resolving_list_size_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.41 LE Read Resolving List Size Command
+ See Bluetooth spec Vol 4, Part E - 7.8.41 LE Read Resolving List Size Command
'''
return bytes([HCI_SUCCESS, self.resolving_list_size])
def on_hci_le_set_address_resolution_enable_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.44 LE Set Address Resolution Enable
+ See Bluetooth spec Vol 4, Part E - 7.8.44 LE Set Address Resolution Enable
Command
'''
ret = HCI_SUCCESS
@@ -1190,7 +1208,7 @@
def on_hci_le_set_resolvable_private_address_timeout_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.45 LE Set Resolvable Private Address
+ See Bluetooth spec Vol 4, Part E - 7.8.45 LE Set Resolvable Private Address
Timeout Command
'''
self.le_rpa_timeout = command.rpa_timeout
@@ -1198,7 +1216,7 @@
def on_hci_le_read_maximum_data_length_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.46 LE Read Maximum Data Length Command
+ See Bluetooth spec Vol 4, Part E - 7.8.46 LE Read Maximum Data Length Command
'''
return struct.pack(
'<BHHHH',
@@ -1211,7 +1229,7 @@
def on_hci_le_read_phy_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.47 LE Read PHY Command
+ See Bluetooth spec Vol 4, Part E - 7.8.47 LE Read PHY Command
'''
return struct.pack(
'<BHBB',
@@ -1223,7 +1241,7 @@
def on_hci_le_set_default_phy_command(self, command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.48 LE Set Default PHY Command
+ See Bluetooth spec Vol 4, Part E - 7.8.48 LE Set Default PHY Command
'''
self.default_phy = {
'all_phys': command.all_phys,
@@ -1234,6 +1252,6 @@
def on_hci_le_read_transmit_power_command(self, _command):
'''
- See Bluetooth spec Vol 2, Part E - 7.8.74 LE Read Transmit Power Command
+ See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command
'''
return struct.pack('<BBB', HCI_SUCCESS, 0, 0)
diff --git a/bumble/core.py b/bumble/core.py
index 1cc10ec..2e3f4af 100644
--- a/bumble/core.py
+++ b/bumble/core.py
@@ -17,7 +17,7 @@
# -----------------------------------------------------------------------------
from __future__ import annotations
import struct
-from typing import List, Optional, Tuple, Union, cast
+from typing import List, Optional, Tuple, Union, cast, Dict
from .company_ids import COMPANY_IDENTIFIERS
@@ -53,7 +53,7 @@
return names
-def name_or_number(dictionary, number, width=2):
+def name_or_number(dictionary: Dict[int, str], number: int, width: int = 2) -> str:
name = dictionary.get(number)
if name is not None:
return name
@@ -562,11 +562,82 @@
PERIPHERAL_HANDHELD_GESTURAL_INPUT_DEVICE_MINOR_DEVICE_CLASS: 'Handheld gestural input device'
}
+ WEARABLE_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00
+ WEARABLE_WRISTWATCH_MINOR_DEVICE_CLASS = 0x01
+ WEARABLE_PAGER_MINOR_DEVICE_CLASS = 0x02
+ WEARABLE_JACKET_MINOR_DEVICE_CLASS = 0x03
+ WEARABLE_HELMET_MINOR_DEVICE_CLASS = 0x04
+ WEARABLE_GLASSES_MINOR_DEVICE_CLASS = 0x05
+
+ WEARABLE_MINOR_DEVICE_CLASS_NAMES = {
+ WEARABLE_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized',
+ WEARABLE_WRISTWATCH_MINOR_DEVICE_CLASS: 'Wristwatch',
+ WEARABLE_PAGER_MINOR_DEVICE_CLASS: 'Pager',
+ WEARABLE_JACKET_MINOR_DEVICE_CLASS: 'Jacket',
+ WEARABLE_HELMET_MINOR_DEVICE_CLASS: 'Helmet',
+ WEARABLE_GLASSES_MINOR_DEVICE_CLASS: 'Glasses',
+ }
+
+ TOY_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00
+ TOY_ROBOT_MINOR_DEVICE_CLASS = 0x01
+ TOY_VEHICLE_MINOR_DEVICE_CLASS = 0x02
+ TOY_DOLL_ACTION_FIGURE_MINOR_DEVICE_CLASS = 0x03
+ TOY_CONTROLLER_MINOR_DEVICE_CLASS = 0x04
+ TOY_GAME_MINOR_DEVICE_CLASS = 0x05
+
+ TOY_MINOR_DEVICE_CLASS_NAMES = {
+ TOY_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized',
+ TOY_ROBOT_MINOR_DEVICE_CLASS: 'Robot',
+ TOY_VEHICLE_MINOR_DEVICE_CLASS: 'Vehicle',
+ TOY_DOLL_ACTION_FIGURE_MINOR_DEVICE_CLASS: 'Doll/Action figure',
+ TOY_CONTROLLER_MINOR_DEVICE_CLASS: 'Controller',
+ TOY_GAME_MINOR_DEVICE_CLASS: 'Game',
+ }
+
+ HEALTH_UNDEFINED_MINOR_DEVICE_CLASS = 0x00
+ HEALTH_BLOOD_PRESSURE_MONITOR_MINOR_DEVICE_CLASS = 0x01
+ HEALTH_THERMOMETER_MINOR_DEVICE_CLASS = 0x02
+ HEALTH_WEIGHING_SCALE_MINOR_DEVICE_CLASS = 0x03
+ HEALTH_GLUCOSE_METER_MINOR_DEVICE_CLASS = 0x04
+ HEALTH_PULSE_OXIMETER_MINOR_DEVICE_CLASS = 0x05
+ HEALTH_HEART_PULSE_RATE_MONITOR_MINOR_DEVICE_CLASS = 0x06
+ HEALTH_HEALTH_DATA_DISPLAY_MINOR_DEVICE_CLASS = 0x07
+ HEALTH_STEP_COUNTER_MINOR_DEVICE_CLASS = 0x08
+ HEALTH_BODY_COMPOSITION_ANALYZER_MINOR_DEVICE_CLASS = 0x09
+ HEALTH_PEAK_FLOW_MONITOR_MINOR_DEVICE_CLASS = 0x0A
+ HEALTH_MEDICATION_MONITOR_MINOR_DEVICE_CLASS = 0x0B
+ HEALTH_KNEE_PROSTHESIS_MINOR_DEVICE_CLASS = 0x0C
+ HEALTH_ANKLE_PROSTHESIS_MINOR_DEVICE_CLASS = 0x0D
+ HEALTH_GENERIC_HEALTH_MANAGER_MINOR_DEVICE_CLASS = 0x0E
+ HEALTH_PERSONAL_MOBILITY_DEVICE_MINOR_DEVICE_CLASS = 0x0F
+
+ HEALTH_MINOR_DEVICE_CLASS_NAMES = {
+ HEALTH_UNDEFINED_MINOR_DEVICE_CLASS: 'Undefined',
+ HEALTH_BLOOD_PRESSURE_MONITOR_MINOR_DEVICE_CLASS: 'Blood Pressure Monitor',
+ HEALTH_THERMOMETER_MINOR_DEVICE_CLASS: 'Thermometer',
+ HEALTH_WEIGHING_SCALE_MINOR_DEVICE_CLASS: 'Weighing Scale',
+ HEALTH_GLUCOSE_METER_MINOR_DEVICE_CLASS: 'Glucose Meter',
+ HEALTH_PULSE_OXIMETER_MINOR_DEVICE_CLASS: 'Pulse Oximeter',
+ HEALTH_HEART_PULSE_RATE_MONITOR_MINOR_DEVICE_CLASS: 'Heart/Pulse Rate Monitor',
+ HEALTH_HEALTH_DATA_DISPLAY_MINOR_DEVICE_CLASS: 'Health Data Display',
+ HEALTH_STEP_COUNTER_MINOR_DEVICE_CLASS: 'Step Counter',
+ HEALTH_BODY_COMPOSITION_ANALYZER_MINOR_DEVICE_CLASS: 'Body Composition Analyzer',
+ HEALTH_PEAK_FLOW_MONITOR_MINOR_DEVICE_CLASS: 'Peak Flow Monitor',
+ HEALTH_MEDICATION_MONITOR_MINOR_DEVICE_CLASS: 'Medication Monitor',
+ HEALTH_KNEE_PROSTHESIS_MINOR_DEVICE_CLASS: 'Knee Prosthesis',
+ HEALTH_ANKLE_PROSTHESIS_MINOR_DEVICE_CLASS: 'Ankle Prosthesis',
+ HEALTH_GENERIC_HEALTH_MANAGER_MINOR_DEVICE_CLASS: 'Generic Health Manager',
+ HEALTH_PERSONAL_MOBILITY_DEVICE_MINOR_DEVICE_CLASS: 'Personal Mobility Device',
+ }
+
MINOR_DEVICE_CLASS_NAMES = {
COMPUTER_MAJOR_DEVICE_CLASS: COMPUTER_MINOR_DEVICE_CLASS_NAMES,
PHONE_MAJOR_DEVICE_CLASS: PHONE_MINOR_DEVICE_CLASS_NAMES,
AUDIO_VIDEO_MAJOR_DEVICE_CLASS: AUDIO_VIDEO_MINOR_DEVICE_CLASS_NAMES,
- PERIPHERAL_MAJOR_DEVICE_CLASS: PERIPHERAL_MINOR_DEVICE_CLASS_NAMES
+ PERIPHERAL_MAJOR_DEVICE_CLASS: PERIPHERAL_MINOR_DEVICE_CLASS_NAMES,
+ WEARABLE_MAJOR_DEVICE_CLASS: WEARABLE_MINOR_DEVICE_CLASS_NAMES,
+ TOY_MAJOR_DEVICE_CLASS: TOY_MINOR_DEVICE_CLASS_NAMES,
+ HEALTH_MAJOR_DEVICE_CLASS: HEALTH_MINOR_DEVICE_CLASS_NAMES,
}
# fmt: on
diff --git a/bumble/device.py b/bumble/device.py
index 258a43d..031c071 100644
--- a/bumble/device.py
+++ b/bumble/device.py
@@ -826,6 +826,12 @@
advertising_data = config.get('advertising_data')
if advertising_data:
self.advertising_data = bytes.fromhex(advertising_data)
+ elif config.get('name') is not None:
+ self.advertising_data = bytes(
+ AdvertisingData(
+ [(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))]
+ )
+ )
def load_from_file(self, filename):
with open(filename, 'r', encoding='utf-8') as file:
@@ -949,11 +955,15 @@
return cls(config=config)
@classmethod
+ def from_config_with_hci(cls, config, hci_source, hci_sink):
+ 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, hci_source, hci_sink):
config = DeviceConfiguration()
config.load_from_file(filename)
- host = Host(controller_source=hci_source, controller_sink=hci_sink)
- return cls(config=config, host=host)
+ return cls.from_config_with_hci(config, hci_source, hci_sink)
def __init__(
self,
@@ -2435,7 +2445,7 @@
if result.status != HCI_COMMAND_STATUS_PENDING:
logger.warning(
- 'HCI_Set_Connection_Encryption_Command failed: '
+ 'HCI_Remote_Name_Request_Command failed: '
f'{HCI_Constant.error_name(result.status)}'
)
raise HCI_StatusError(result)
@@ -2841,18 +2851,22 @@
method = methods[peer_io_capability][io_capability]
async def reply() -> None:
- if await connection.abort_on('disconnection', method()):
- await self.host.send_command(
- HCI_User_Confirmation_Request_Reply_Command( # type: ignore[call-arg]
- bd_addr=connection.peer_address
+ try:
+ if await connection.abort_on('disconnection', method()):
+ await self.host.send_command(
+ HCI_User_Confirmation_Request_Reply_Command( # type: ignore[call-arg]
+ bd_addr=connection.peer_address
+ )
)
+ return
+ except Exception as error:
+ logger.warning(f'exception while confirming: {error}')
+
+ await self.host.send_command(
+ HCI_User_Confirmation_Request_Negative_Reply_Command( # type: ignore[call-arg]
+ bd_addr=connection.peer_address
)
- else:
- await self.host.send_command(
- HCI_User_Confirmation_Request_Negative_Reply_Command( # type: ignore[call-arg]
- bd_addr=connection.peer_address
- )
- )
+ )
AsyncRunner.spawn(reply())
@@ -2864,21 +2878,25 @@
pairing_config = self.pairing_config_factory(connection)
async def reply() -> None:
- number = await connection.abort_on(
- 'disconnection', pairing_config.delegate.get_number()
+ try:
+ number = await connection.abort_on(
+ 'disconnection', pairing_config.delegate.get_number()
+ )
+ if number is not None:
+ await self.host.send_command(
+ HCI_User_Passkey_Request_Reply_Command( # type: ignore[call-arg]
+ bd_addr=connection.peer_address, numeric_value=number
+ )
+ )
+ return
+ except Exception as error:
+ logger.warning(f'exception while asking for pass-key: {error}')
+
+ await self.host.send_command(
+ HCI_User_Passkey_Request_Negative_Reply_Command( # type: ignore[call-arg]
+ bd_addr=connection.peer_address
+ )
)
- if number is not None:
- await self.host.send_command(
- HCI_User_Passkey_Request_Reply_Command( # type: ignore[call-arg]
- bd_addr=connection.peer_address, numeric_value=number
- )
- )
- else:
- await self.host.send_command(
- HCI_User_Passkey_Request_Negative_Reply_Command( # type: ignore[call-arg]
- bd_addr=connection.peer_address
- )
- )
AsyncRunner.spawn(reply())
@@ -3088,7 +3106,16 @@
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)
diff --git a/bumble/drivers/__init__.py b/bumble/drivers/__init__.py
new file mode 100644
index 0000000..2decab7
--- /dev/null
+++ b/bumble/drivers/__init__.py
@@ -0,0 +1,68 @@
+# 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
+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
diff --git a/bumble/drivers/rtk.py b/bumble/drivers/rtk.py
new file mode 100644
index 0000000..c0bccc9
--- /dev/null
+++ b/bumble/drivers/rtk.py
@@ -0,0 +1,647 @@
+# 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_command_op_code,
+ STATUS_SPEC,
+ HCI_SUCCESS,
+ HCI_COMMAND_NAMES,
+ 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),
+ # 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_command_op_code(0x3F, 0x6D)
+HCI_COMMAND_NAMES[HCI_RTK_READ_ROM_VERSION_COMMAND] = "HCI_RTK_READ_ROM_VERSION_COMMAND"
+
+
+@HCI_Command.command(return_parameters_fields=[("status", STATUS_SPEC), ("version", 1)])
+class HCI_RTK_Read_ROM_Version_Command(HCI_Command):
+ pass
+
+
+HCI_RTK_DOWNLOAD_COMMAND = hci_command_op_code(0x3F, 0x20)
+HCI_COMMAND_NAMES[HCI_RTK_DOWNLOAD_COMMAND] = "HCI_RTK_DOWNLOAD_COMMAND"
+
+
+@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_RTK_DROP_FIRMWARE_COMMAND = hci_command_op_code(0x3F, 0x66)
+HCI_COMMAND_NAMES[HCI_RTK_DROP_FIRMWARE_COMMAND] = "HCI_RTK_DROP_FIRMWARE_COMMAND"
+
+
+@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 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}")
diff --git a/bumble/gatt.py b/bumble/gatt.py
index ea2b690..067f31d 100644
--- a/bumble/gatt.py
+++ b/bumble/gatt.py
@@ -283,8 +283,7 @@
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 +308,33 @@
AUTHENTICATED_SIGNED_WRITES = 0x40
EXTENDED_PROPERTIES = 0x80
- @staticmethod
- def from_string(properties_str: str) -> Characteristic.Properties:
- property_names: List[str] = []
- for property in Characteristic.Properties:
- if property.name is None:
- raise TypeError()
- property_names.append(property.name)
-
- def string_to_property(property_string) -> Characteristic.Properties:
- for property in zip(Characteristic.Properties, property_names):
- if property_string == property[1]:
- return property[0]
- raise TypeError(f"Unable to convert {property_string} to Property")
-
+ @classmethod
+ def from_string(cls, properties_str: str) -> Characteristic.Properties:
try:
return functools.reduce(
- lambda x, y: x | string_to_property(y),
- properties_str.split(","),
+ lambda x, y: x | cls[y],
+ properties_str.replace("|", ",").split(","),
Characteristic.Properties(0),
)
- except TypeError:
+ except (TypeError, KeyError):
+ # The check for `p.name is not None` here is needed because for InFlag
+ # enums, the .name property can be None, when the enum value is 0,
+ # so the type hint for .name is Optional[str].
+ enum_list: List[str] = [p.name for p in cls if p.name is not None]
+ enum_list_str = ",".join(enum_list)
raise TypeError(
- f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by commas: {','.join(property_names)}\nGot: {properties_str}"
+ f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by , or |: {enum_list_str}\nGot: {properties_str}"
)
+ def __str__(self):
+ # 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
@@ -373,7 +374,7 @@
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})'
)
@@ -401,7 +402,7 @@
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})'
)
diff --git a/bumble/hci.py b/bumble/hci.py
index 9b5793d..0dbb127 100644
--- a/bumble/hci.py
+++ b/bumble/hci.py
@@ -62,7 +62,7 @@
try:
terminator = utf8_bytes.find(0)
if terminator < 0:
- return utf8_bytes
+ terminator = len(utf8_bytes)
return utf8_bytes[0:terminator].decode('utf8')
except UnicodeDecodeError:
return utf8_bytes
@@ -185,7 +185,7 @@
HCI_IO_CAPABILITY_RESPONSE_EVENT = 0x32
HCI_USER_CONFIRMATION_REQUEST_EVENT = 0x33
HCI_USER_PASSKEY_REQUEST_EVENT = 0x34
-HCI_REMOTE_OOB_DATA_REQUEST = 0x35
+HCI_REMOTE_OOB_DATA_REQUEST_EVENT = 0x35
HCI_SIMPLE_PAIRING_COMPLETE_EVENT = 0x36
HCI_LINK_SUPERVISION_TIMEOUT_CHANGED_EVENT = 0x38
HCI_ENHANCED_FLUSH_COMPLETE_EVENT = 0x39
@@ -1445,8 +1445,14 @@
@staticmethod
def init_from_fields(hci_object, fields, values):
if isinstance(values, dict):
- for field_name, _ in fields:
- setattr(hci_object, field_name, values[field_name])
+ for field in fields:
+ if isinstance(field, list):
+ # The field is an array, up-level the array field names
+ for sub_field_name, _ in field:
+ setattr(hci_object, sub_field_name, values[sub_field_name])
+ else:
+ field_name = field[0]
+ setattr(hci_object, field_name, values[field_name])
else:
for field_name, field_value in zip(fields, values):
setattr(hci_object, field_name, field_value)
@@ -1457,132 +1463,160 @@
HCI_Object.init_from_fields(hci_object, parsed.keys(), parsed.values())
@staticmethod
+ def parse_field(data, offset, field_type):
+ # The field_type may be a dictionary with a mapper, parser, and/or size
+ if isinstance(field_type, dict):
+ if 'size' in field_type:
+ field_type = field_type['size']
+ elif 'parser' in field_type:
+ field_type = field_type['parser']
+
+ # Parse the field
+ if field_type == '*':
+ # The rest of the bytes
+ field_value = data[offset:]
+ return (field_value, len(field_value))
+ if field_type == 1:
+ # 8-bit unsigned
+ return (data[offset], 1)
+ if field_type == -1:
+ # 8-bit signed
+ return (struct.unpack_from('b', data, offset)[0], 1)
+ if field_type == 2:
+ # 16-bit unsigned
+ return (struct.unpack_from('<H', data, offset)[0], 2)
+ if field_type == '>2':
+ # 16-bit unsigned big-endian
+ return (struct.unpack_from('>H', data, offset)[0], 2)
+ if field_type == -2:
+ # 16-bit signed
+ return (struct.unpack_from('<h', data, offset)[0], 2)
+ if field_type == 3:
+ # 24-bit unsigned
+ padded = data[offset : offset + 3] + bytes([0])
+ return (struct.unpack('<I', padded)[0], 3)
+ if field_type == 4:
+ # 32-bit unsigned
+ return (struct.unpack_from('<I', data, offset)[0], 4)
+ if field_type == '>4':
+ # 32-bit unsigned big-endian
+ return (struct.unpack_from('>I', data, offset)[0], 4)
+ if isinstance(field_type, int) and 4 < field_type <= 256:
+ # Byte array (from 5 up to 256 bytes)
+ return (data[offset : offset + field_type], field_type)
+ if callable(field_type):
+ new_offset, field_value = field_type(data, offset)
+ return (field_value, new_offset - offset)
+
+ raise ValueError(f'unknown field type {field_type}')
+
+ @staticmethod
def dict_from_bytes(data, offset, fields):
result = collections.OrderedDict()
- for (field_name, field_type) in fields:
- # The field_type may be a dictionary with a mapper, parser, and/or size
- if isinstance(field_type, dict):
- if 'size' in field_type:
- field_type = field_type['size']
- elif 'parser' in field_type:
- field_type = field_type['parser']
-
- # Parse the field
- if field_type == '*':
- # The rest of the bytes
- field_value = data[offset:]
- offset += len(field_value)
- elif field_type == 1:
- # 8-bit unsigned
- field_value = data[offset]
+ for field in fields:
+ if isinstance(field, list):
+ # This is an array field, starting with a 1-byte item count.
+ item_count = data[offset]
offset += 1
- elif field_type == -1:
- # 8-bit signed
- field_value = struct.unpack_from('b', data, offset)[0]
- offset += 1
- elif field_type == 2:
- # 16-bit unsigned
- field_value = struct.unpack_from('<H', data, offset)[0]
- offset += 2
- elif field_type == '>2':
- # 16-bit unsigned big-endian
- field_value = struct.unpack_from('>H', data, offset)[0]
- offset += 2
- elif field_type == -2:
- # 16-bit signed
- field_value = struct.unpack_from('<h', data, offset)[0]
- offset += 2
- elif field_type == 3:
- # 24-bit unsigned
- padded = data[offset : offset + 3] + bytes([0])
- field_value = struct.unpack('<I', padded)[0]
- offset += 3
- elif field_type == 4:
- # 32-bit unsigned
- field_value = struct.unpack_from('<I', data, offset)[0]
- offset += 4
- elif field_type == '>4':
- # 32-bit unsigned big-endian
- field_value = struct.unpack_from('>I', data, offset)[0]
- offset += 4
- elif isinstance(field_type, int) and 4 < field_type <= 256:
- # Byte array (from 5 up to 256 bytes)
- field_value = data[offset : offset + field_type]
- offset += field_type
- elif callable(field_type):
- offset, field_value = field_type(data, offset)
- else:
- raise ValueError(f'unknown field type {field_type}')
+ for _ in range(item_count):
+ for sub_field_name, sub_field_type in field:
+ value, size = HCI_Object.parse_field(
+ data, offset, sub_field_type
+ )
+ result.setdefault(sub_field_name, []).append(value)
+ offset += size
+ continue
+ field_name, field_type = field
+ field_value, field_size = HCI_Object.parse_field(data, offset, field_type)
result[field_name] = field_value
+ offset += field_size
return result
@staticmethod
+ def serialize_field(field_value, field_type):
+ # The field_type may be a dictionary with a mapper, parser, serializer,
+ # and/or size
+ serializer = None
+ if isinstance(field_type, dict):
+ if 'serializer' in field_type:
+ serializer = field_type['serializer']
+ if 'size' in field_type:
+ field_type = field_type['size']
+
+ # Serialize the field
+ if serializer:
+ field_bytes = serializer(field_value)
+ elif field_type == 1:
+ # 8-bit unsigned
+ field_bytes = bytes([field_value])
+ elif field_type == -1:
+ # 8-bit signed
+ field_bytes = struct.pack('b', field_value)
+ elif field_type == 2:
+ # 16-bit unsigned
+ field_bytes = struct.pack('<H', field_value)
+ elif field_type == '>2':
+ # 16-bit unsigned big-endian
+ field_bytes = struct.pack('>H', field_value)
+ elif field_type == -2:
+ # 16-bit signed
+ field_bytes = struct.pack('<h', field_value)
+ elif field_type == 3:
+ # 24-bit unsigned
+ field_bytes = struct.pack('<I', field_value)[0:3]
+ elif field_type == 4:
+ # 32-bit unsigned
+ field_bytes = struct.pack('<I', field_value)
+ elif field_type == '>4':
+ # 32-bit unsigned big-endian
+ field_bytes = struct.pack('>I', field_value)
+ elif field_type == '*':
+ if isinstance(field_value, int):
+ if 0 <= field_value <= 255:
+ field_bytes = bytes([field_value])
+ else:
+ raise ValueError('value too large for *-typed field')
+ else:
+ field_bytes = bytes(field_value)
+ elif isinstance(field_value, (bytes, bytearray)) or hasattr(
+ field_value, 'to_bytes'
+ ):
+ field_bytes = bytes(field_value)
+ if isinstance(field_type, int) and 4 < field_type <= 256:
+ # Truncate or pad with zeros if the field is too long or too short
+ if len(field_bytes) < field_type:
+ field_bytes += bytes(field_type - len(field_bytes))
+ elif len(field_bytes) > field_type:
+ field_bytes = field_bytes[:field_type]
+ else:
+ raise ValueError(f"don't know how to serialize type {type(field_value)}")
+
+ return field_bytes
+
+ @staticmethod
def dict_to_bytes(hci_object, fields):
result = bytearray()
- for (field_name, field_type) in fields:
- # The field_type may be a dictionary with a mapper, parser, serializer,
- # and/or size
- serializer = None
- if isinstance(field_type, dict):
- if 'serializer' in field_type:
- serializer = field_type['serializer']
- if 'size' in field_type:
- field_type = field_type['size']
-
- # Serialize the field
- field_value = hci_object[field_name]
- if serializer:
- field_bytes = serializer(field_value)
- elif field_type == 1:
- # 8-bit unsigned
- field_bytes = bytes([field_value])
- elif field_type == -1:
- # 8-bit signed
- field_bytes = struct.pack('b', field_value)
- elif field_type == 2:
- # 16-bit unsigned
- field_bytes = struct.pack('<H', field_value)
- elif field_type == '>2':
- # 16-bit unsigned big-endian
- field_bytes = struct.pack('>H', field_value)
- elif field_type == -2:
- # 16-bit signed
- field_bytes = struct.pack('<h', field_value)
- elif field_type == 3:
- # 24-bit unsigned
- field_bytes = struct.pack('<I', field_value)[0:3]
- elif field_type == 4:
- # 32-bit unsigned
- field_bytes = struct.pack('<I', field_value)
- elif field_type == '>4':
- # 32-bit unsigned big-endian
- field_bytes = struct.pack('>I', field_value)
- elif field_type == '*':
- if isinstance(field_value, int):
- if 0 <= field_value <= 255:
- field_bytes = bytes([field_value])
- else:
- raise ValueError('value too large for *-typed field')
- else:
- field_bytes = bytes(field_value)
- elif isinstance(field_value, (bytes, bytearray)) or hasattr(
- field_value, 'to_bytes'
- ):
- field_bytes = bytes(field_value)
- if isinstance(field_type, int) and 4 < field_type <= 256:
- # Truncate or Pad with zeros if the field is too long or too short
- if len(field_bytes) < field_type:
- field_bytes += bytes(field_type - len(field_bytes))
- elif len(field_bytes) > field_type:
- field_bytes = field_bytes[:field_type]
- else:
- raise ValueError(
- f"don't know how to serialize type {type(field_value)}"
+ for field in fields:
+ if isinstance(field, list):
+ # The field is an array. The serialized form starts with a 1-byte
+ # item count. We use the length of the first array field as the
+ # array count, since all array fields have the same number of items.
+ item_count = len(hci_object[field[0][0]])
+ result += bytes([item_count]) + b''.join(
+ b''.join(
+ HCI_Object.serialize_field(
+ hci_object[sub_field_name][i], sub_field_type
+ )
+ for sub_field_name, sub_field_type in field
+ )
+ for i in range(item_count)
)
+ continue
- result += field_bytes
+ (field_name, field_type) = field
+ result += HCI_Object.serialize_field(hci_object[field_name], field_type)
return bytes(result)
@@ -1617,46 +1651,73 @@
return str(value)
@staticmethod
- def format_fields(hci_object, keys, indentation='', value_mappers=None):
- if not keys:
- return ''
+ def stringify_field(
+ field_name, field_type, field_value, indentation, value_mappers
+ ):
+ value_mapper = None
+ if isinstance(field_type, dict):
+ # Get the value mapper from the specifier
+ value_mapper = field_type.get('mapper')
- # Measure the widest field name
- max_field_name_length = max(
- (len(key[0] if isinstance(key, tuple) else key) for key in keys)
+ # Check if there's a matching mapper passed
+ if value_mappers:
+ value_mapper = value_mappers.get(field_name, value_mapper)
+
+ # Map the value if we have a mapper
+ if value_mapper is not None:
+ field_value = value_mapper(field_value)
+
+ # Get the string representation of the value
+ return HCI_Object.format_field_value(
+ field_value, indentation=indentation + ' '
)
+ @staticmethod
+ def format_fields(hci_object, fields, indentation='', value_mappers=None):
+ if not fields:
+ return ''
+
# Build array of formatted key:value pairs
- fields = []
- for key in keys:
- value_mapper = None
- if isinstance(key, tuple):
- # The key has an associated specifier
- key, specifier = key
+ field_strings = []
+ for field in fields:
+ if isinstance(field, list):
+ for sub_field in field:
+ sub_field_name, sub_field_type = sub_field
+ item_count = len(hci_object[sub_field_name])
+ for i in range(item_count):
+ field_strings.append(
+ (
+ f'{sub_field_name}[{i}]',
+ HCI_Object.stringify_field(
+ sub_field_name,
+ sub_field_type,
+ hci_object[sub_field_name][i],
+ indentation,
+ value_mappers,
+ ),
+ ),
+ )
+ continue
- # Get the value mapper from the specifier
- if isinstance(specifier, dict):
- value_mapper = specifier.get('mapper')
-
- # Get the value for the field
- value = hci_object[key]
-
- # Map the value if needed
- if value_mappers:
- value_mapper = value_mappers.get(key, value_mapper)
- if value_mapper is not None:
- value = value_mapper(value)
-
- # Get the string representation of the value
- value_str = HCI_Object.format_field_value(
- value, indentation=indentation + ' '
+ field_name, field_type = field
+ field_value = hci_object[field_name]
+ field_strings.append(
+ (
+ field_name,
+ HCI_Object.stringify_field(
+ field_name, field_type, field_value, indentation, value_mappers
+ ),
+ ),
)
- # Add the field to the formatted result
- key_str = color(f'{key + ":":{1 + max_field_name_length}}', 'cyan')
- fields.append(f'{indentation}{key_str} {value_str}')
-
- return '\n'.join(fields)
+ # Measure the widest field name
+ max_field_name_length = max(len(s[0]) for s in field_strings)
+ sep = ':'
+ return '\n'.join(
+ f'{indentation}'
+ f'{color(f"{field_name + sep:{1 + max_field_name_length}}", "cyan")} {field_value}'
+ for field_name, field_value in field_strings
+ )
def __bytes__(self):
return self.to_bytes()
@@ -1795,6 +1856,16 @@
def to_bytes(self):
return self.address_bytes
+ def to_string(self, with_type_qualifier=True):
+ '''
+ String representation of the address, MSB first, with an optional type
+ qualifier.
+ '''
+ result = ':'.join([f'{x:02X}' for x in reversed(self.address_bytes)])
+ if not with_type_qualifier or not self.is_public:
+ return result
+ return result + '/P'
+
def __bytes__(self):
return self.to_bytes()
@@ -1808,13 +1879,7 @@
)
def __str__(self):
- '''
- String representation of the address, MSB first
- '''
- result = ':'.join([f'{x:02X}' for x in reversed(self.address_bytes)])
- if not self.is_public:
- return result
- return result + '/P'
+ return self.to_string()
# Predefined address values
@@ -2284,6 +2349,55 @@
# -----------------------------------------------------------------------------
@HCI_Command.command(
+ fields=[
+ ('bd_addr', Address.parse_address),
+ ('c', 16),
+ ('r', 16),
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('bd_addr', Address.parse_address),
+ ],
+)
+class HCI_Remote_OOB_Data_Request_Reply_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.1.34 Remote OOB Data Request Reply Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[('bd_addr', Address.parse_address)],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('bd_addr', Address.parse_address),
+ ],
+)
+class HCI_Remote_OOB_Data_Request_Negative_Reply_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.1.35 Remote OOB Data Request Negative Reply Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ ('bd_addr', Address.parse_address),
+ ('reason', 1),
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('bd_addr', Address.parse_address),
+ ],
+)
+class HCI_IO_Capability_Request_Negative_Reply_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.1.36 IO Capability Request Negative Reply Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
[
('connection_handle', 2),
('transmit_bandwidth', 4),
@@ -2320,6 +2434,161 @@
# -----------------------------------------------------------------------------
@HCI_Command.command(
[
+ ('bd_addr', Address.parse_address),
+ ('transmit_bandwidth', 4),
+ ('receive_bandwidth', 4),
+ ('transmit_coding_format', 5),
+ ('receive_coding_format', 5),
+ ('transmit_codec_frame_size', 2),
+ ('receive_codec_frame_size', 2),
+ ('input_bandwidth', 4),
+ ('output_bandwidth', 4),
+ ('input_coding_format', 5),
+ ('output_coding_format', 5),
+ ('input_coded_data_size', 2),
+ ('output_coded_data_size', 2),
+ ('input_pcm_data_format', 1),
+ ('output_pcm_data_format', 1),
+ ('input_pcm_sample_payload_msb_position', 1),
+ ('output_pcm_sample_payload_msb_position', 1),
+ ('input_data_path', 1),
+ ('output_data_path', 1),
+ ('input_transport_unit_size', 1),
+ ('output_transport_unit_size', 1),
+ ('max_latency', 2),
+ ('packet_type', 2),
+ ('retransmission_effort', 1),
+ ]
+)
+class HCI_Enhanced_Accept_Synchronous_Connection_Request_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.1.46 Enhanced Accept Synchronous Connection Request Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ ('bd_addr', Address.parse_address),
+ ('page_scan_repetition_mode', 1),
+ ('clock_offset', 2),
+ ]
+)
+class HCI_Truncated_Page_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.1.47 Truncated Page Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[('bd_addr', Address.parse_address)],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('bd_addr', Address.parse_address),
+ ],
+)
+class HCI_Truncated_Page_Cancel_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.1.48 Truncated Page Cancel Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ ('enable', 1),
+ ('lt_addr', 1),
+ ('lpo_allowed', 1),
+ ('packet_type', 2),
+ ('interval_min', 2),
+ ('interval_max', 2),
+ ('supervision_timeout', 2),
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('lt_addr', 1),
+ ('interval', 2),
+ ],
+)
+class HCI_Set_Connectionless_Peripheral_Broadcast_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.1.49 Set Connectionless Peripheral Broadcast Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ ('enable', 1),
+ ('bd_addr', Address.parse_address),
+ ('lt_addr', 1),
+ ('interval', 2),
+ ('clock_offset', 4),
+ ('next_connectionless_peripheral_broadcast_clock', 4),
+ ('supervision_timeout', 2),
+ ('remote_timing_accuracy', 1),
+ ('skip', 1),
+ ('packet_type', 2),
+ ('afh_channel_map', 10),
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('bd_addr', Address.parse_address),
+ ('lt_addr', 1),
+ ],
+)
+class HCI_Set_Connectionless_Peripheral_Broadcast_Receive_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.1.50 Set Connectionless Peripheral Broadcast Receive Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+class HCI_Start_Synchronization_Train_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.1.51 Start Synchronization Train Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ ('bd_addr', Address.parse_address),
+ ('sync_scan_timeout', 2),
+ ('sync_scan_window', 2),
+ ('sync_scan_interval', 2),
+ ],
+)
+class HCI_Receive_Synchronization_Train_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.1.52 Receive Synchronization Train Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ ('bd_addr', Address.parse_address),
+ ('c_192', 16),
+ ('r_192', 16),
+ ('c_256', 16),
+ ('r_256', 16),
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('bd_addr', Address.parse_address),
+ ],
+)
+class HCI_Remote_OOB_Extended_Data_Request_Reply_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.1.53 Remote OOB Extended Data Request Reply Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ [
('connection_handle', 2),
('sniff_max_interval', 2),
('sniff_min_interval', 2),
@@ -2685,6 +2954,20 @@
# -----------------------------------------------------------------------------
@HCI_Command.command(
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('c', 16),
+ ('r', 16),
+ ]
+)
+class HCI_Read_Local_OOB_Data_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.3.60 Read Local OOB Data Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
return_parameters_fields=[('status', STATUS_SPEC), ('tx_power', -1)]
)
class HCI_Read_Inquiry_Response_Transmit_Power_Level_Command(HCI_Command):
@@ -2747,6 +3030,22 @@
@HCI_Command.command(
return_parameters_fields=[
('status', STATUS_SPEC),
+ ('c_192', 16),
+ ('r_192', 16),
+ ('c_256', 16),
+ ('r_256', 16),
+ ]
+)
+class HCI_Read_Local_OOB_Extended_Data_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.3.95 Read Local OOB Extended Data Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
('hci_version', 1),
('hci_subversion', 2),
('lmp_version', 1),
@@ -3529,9 +3828,7 @@
'advertising_data',
{
'parser': HCI_Object.parse_length_prefixed_bytes,
- 'serializer': functools.partial(
- HCI_Object.serialize_length_prefixed_bytes
- ),
+ 'serializer': HCI_Object.serialize_length_prefixed_bytes,
},
),
]
@@ -3579,9 +3876,7 @@
'scan_response_data',
{
'parser': HCI_Object.parse_length_prefixed_bytes,
- 'serializer': functools.partial(
- HCI_Object.serialize_length_prefixed_bytes
- ),
+ 'serializer': HCI_Object.serialize_length_prefixed_bytes,
},
),
]
@@ -3609,73 +3904,21 @@
# -----------------------------------------------------------------------------
-@HCI_Command.command(fields=None)
+@HCI_Command.command(
+ [
+ ('enable', 1),
+ [
+ ('advertising_handles', 1),
+ ('durations', 2),
+ ('max_extended_advertising_events', 1),
+ ],
+ ]
+)
class HCI_LE_Set_Extended_Advertising_Enable_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.56 LE Set Extended Advertising Enable Command
'''
- @classmethod
- def from_parameters(cls, parameters):
- enable = parameters[0]
- num_sets = parameters[1]
- advertising_handles = []
- durations = []
- max_extended_advertising_events = []
- offset = 2
- for _ in range(num_sets):
- advertising_handles.append(parameters[offset])
- durations.append(struct.unpack_from('<H', parameters, offset + 1)[0])
- max_extended_advertising_events.append(parameters[offset + 3])
- offset += 4
-
- return cls(
- enable, advertising_handles, durations, max_extended_advertising_events
- )
-
- def __init__(
- self, enable, advertising_handles, durations, max_extended_advertising_events
- ):
- super().__init__(HCI_LE_SET_EXTENDED_ADVERTISING_ENABLE_COMMAND)
- self.enable = enable
- self.advertising_handles = advertising_handles
- self.durations = durations
- self.max_extended_advertising_events = max_extended_advertising_events
-
- self.parameters = bytes([enable, len(advertising_handles)]) + b''.join(
- [
- struct.pack(
- '<BHB',
- advertising_handles[i],
- durations[i],
- max_extended_advertising_events[i],
- )
- for i in range(len(advertising_handles))
- ]
- )
-
- def __str__(self):
- fields = [('enable:', self.enable)]
- for i, advertising_handle in enumerate(self.advertising_handles):
- fields.append(
- (f'advertising_handle[{i}]: ', advertising_handle)
- )
- fields.append((f'duration[{i}]: ', self.durations[i]))
- fields.append(
- (
- f'max_extended_advertising_events[{i}]:',
- self.max_extended_advertising_events[i],
- )
- )
-
- return (
- color(self.name, 'green')
- + ':\n'
- + '\n'.join(
- [color(field[0], 'cyan') + ' ' + str(field[1]) for field in fields]
- )
- )
-
# -----------------------------------------------------------------------------
@HCI_Command.command(
@@ -3826,7 +4069,10 @@
color(self.name, 'green')
+ ':\n'
+ '\n'.join(
- [color(field[0], 'cyan') + ' ' + str(field[1]) for field in fields]
+ [
+ color(' ' + field[0], 'cyan') + ' ' + str(field[1])
+ for field in fields
+ ]
)
)
@@ -4002,7 +4248,10 @@
color(self.name, 'green')
+ ':\n'
+ '\n'.join(
- [color(field[0], 'cyan') + ' ' + str(field[1]) for field in fields]
+ [
+ color(' ' + field[0], 'cyan') + ' ' + str(field[1])
+ for field in fields
+ ]
)
)
@@ -4965,7 +5214,7 @@
def __str__(self):
lines = [
color(self.name, 'magenta') + ':',
- color(' number_of_handles: ', 'cyan')
+ color(' number_of_handles: ', 'cyan')
+ f'{len(self.connection_handles)}',
]
for i, connection_handle in enumerate(self.connection_handles):
@@ -5300,6 +5549,14 @@
# -----------------------------------------------------------------------------
+@HCI_Event.event([('bd_addr', Address.parse_address)])
+class HCI_Remote_OOB_Data_Request_Event(HCI_Event):
+ '''
+ See Bluetooth spec @ 7.7.44 Remote OOB Data Request Event
+ '''
+
+
+# -----------------------------------------------------------------------------
@HCI_Event.event([('status', STATUS_SPEC), ('bd_addr', Address.parse_address)])
class HCI_Simple_Pairing_Complete_Event(HCI_Event):
'''
@@ -5316,6 +5573,14 @@
# -----------------------------------------------------------------------------
+@HCI_Event.event([('handle', 2)])
+class HCI_Enhanced_Flush_Complete_Event(HCI_Event):
+ '''
+ See Bluetooth spec @ 7.7.47 Enhanced Flush Complete Event
+ '''
+
+
+# -----------------------------------------------------------------------------
@HCI_Event.event([('bd_addr', Address.parse_address), ('passkey', 4)])
class HCI_User_Passkey_Notification_Event(HCI_Event):
'''
@@ -5324,6 +5589,14 @@
# -----------------------------------------------------------------------------
+@HCI_Event.event([('bd_addr', Address.parse_address), ('notification_type', 1)])
+class HCI_Keypress_Notification_Event(HCI_Event):
+ '''
+ See Bluetooth spec @ 7.7.49 Keypress Notification Event
+ '''
+
+
+# -----------------------------------------------------------------------------
@HCI_Event.event([('bd_addr', Address.parse_address), ('host_supported_features', 8)])
class HCI_Remote_Host_Supported_Features_Notification_Event(HCI_Event):
'''
@@ -5373,7 +5646,7 @@
def __str__(self):
return (
f'{color("ACL", "blue")}: '
- f'handle=0x{self.connection_handle:04x}'
+ f'handle=0x{self.connection_handle:04x}, '
f'pb={self.pb_flag}, bc={self.bc_flag}, '
f'data_total_length={self.data_total_length}, '
f'data={self.data.hex()}'
diff --git a/bumble/hfp.py b/bumble/hfp.py
index 7bb9f08..9080a55 100644
--- a/bumble/hfp.py
+++ b/bumble/hfp.py
@@ -18,10 +18,11 @@
import logging
import asyncio
import collections
+from typing import Union
+from . import rfcomm
from .colors import color
-
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -34,7 +35,12 @@
# -----------------------------------------------------------------------------
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:
self.dlc = dlc
self.buffer = ''
self.lines = collections.deque()
@@ -42,7 +48,7 @@
dlc.sink = self.feed
- def feed(self, data):
+ def feed(self, data: Union[bytes, str]) -> None:
# Convert the data to a string if needed
if isinstance(data, bytes):
data = data.decode('utf-8')
@@ -57,19 +63,19 @@
if len(line) > 0:
self.on_line(line)
- def on_line(self, line):
+ def on_line(self, line: str) -> None:
self.lines.append(line)
self.lines_available.set()
- def send_command_line(self, line):
+ def send_command_line(self, line: str) -> None:
logger.debug(color(f'>>> {line}', 'yellow'))
self.dlc.write(line + '\r')
- def send_response_line(self, line):
+ def send_response_line(self, line: str) -> None:
logger.debug(color(f'>>> {line}', 'yellow'))
self.dlc.write('\r\n' + line + '\r\n')
- async def next_line(self):
+ async def next_line(self) -> str:
await self.lines_available.wait()
line = self.lines.popleft()
if not self.lines:
@@ -77,7 +83,7 @@
logger.debug(color(f'<<< {line}', 'green'))
return line
- async def initialize_service(self):
+ async def initialize_service(self) -> None:
# Perform Service Level Connection Initialization
self.send_command_line('AT+BRSF=2072') # Retrieve Supported Features
await (self.next_line())
diff --git a/bumble/host.py b/bumble/host.py
index afde2ee..e41fd02 100644
--- a/bumble/host.py
+++ b/bumble/host.py
@@ -23,6 +23,7 @@
from bumble.colors import color
from bumble.l2cap import L2CAP_PDU
from bumble.snoop import Snooper
+from bumble import drivers
from typing import Optional
@@ -62,6 +63,7 @@
HCI_Read_Local_Version_Information_Command,
HCI_Reset_Command,
HCI_Set_Event_Mask_Command,
+ map_null_terminated_utf8_string,
)
from .core import (
BT_BR_EDR_TRANSPORT,
@@ -115,6 +117,7 @@
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 +143,9 @@
# Connect to the source and sink if specified
if controller_source:
controller_source.set_packet_sink(self)
+ self.hci_metadata = getattr(
+ controller_source, 'metadata', self.hci_metadata
+ )
if controller_sink:
self.set_packet_sink(controller_sink)
@@ -169,7 +175,7 @@
self.emit('flush')
self.command_semaphore.release()
- async def reset(self):
+ async def reset(self, driver_factory=drivers.get_driver_for_host):
if self.ready:
self.ready = False
await self.flush()
@@ -177,6 +183,15 @@
await self.send_command(HCI_Reset_Command(), check_result=True)
self.ready = True
+ # Instantiate and init a driver for the host if needed.
+ # NOTE: we don't keep a reference to the driver here, because we don't
+ # currently have a need for the driver later on. But if the driver interface
+ # evolves, it may be required, then, to store a reference to the driver in
+ # an object property.
+ if driver_factory is not None:
+ if driver := await driver_factory(self):
+ await driver.init_controller()
+
response = await self.send_command(
HCI_Read_Local_Supported_Commands_Command(), check_result=True
)
@@ -297,7 +312,7 @@
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}')
@@ -349,7 +364,7 @@
asyncio.create_task(send_command(command))
def send_l2cap_pdu(self, connection_handle, cid, pdu):
- l2cap_pdu = L2CAP_PDU(cid, pdu).to_bytes()
+ l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
# Send the data to the controller via ACL packets
bytes_remaining = len(l2cap_pdu)
@@ -887,7 +902,12 @@
if event.status != HCI_SUCCESS:
self.emit('remote_name_failure', event.bd_addr, event.status)
else:
- self.emit('remote_name', event.bd_addr, event.remote_name)
+ utf8_name = event.remote_name
+ terminator = utf8_name.find(0)
+ if terminator >= 0:
+ utf8_name = utf8_name[0:terminator]
+
+ self.emit('remote_name', event.bd_addr, utf8_name)
def on_hci_remote_host_supported_features_notification_event(self, event):
self.emit(
diff --git a/bumble/keys.py b/bumble/keys.py
index a30e753..198d5c4 100644
--- a/bumble/keys.py
+++ b/bumble/keys.py
@@ -190,10 +190,44 @@
# -----------------------------------------------------------------------------
class JsonKeyStore(KeyStore):
+ """
+ KeyStore implementation that is backed by a JSON file.
+
+ This implementation supports storing a hierarchy of key sets in a single file.
+ A key set is a representation of a PairingKeys object. Each key set is stored
+ in a map, with the address of paired peer as the key. Maps are themselves grouped
+ into namespaces, grouping pairing keys by controller addresses.
+ The JSON object model looks like:
+ {
+ "<namespace>": {
+ "peer-address": {
+ "address_type": <n>,
+ "irk" : {
+ "authenticated": <true/false>,
+ "value": "hex-encoded-key"
+ },
+ ... other keys ...
+ },
+ ... other peers ...
+ }
+ ... other namespaces ...
+ }
+
+ A namespace is typically the BD_ADDR of a controller, since that is a convenient
+ unique identifier, but it may be something else.
+ A special namespace, called the "default" namespace, is used when instantiating this
+ class without a namespace. With the default namespace, reading from a file will
+ load an existing namespace if there is only one, which may be convenient for reading
+ from a file with a single key set and for which the namespace isn't known. If the
+ file does not include any existing key set, or if there are more than one and none
+ has the default name, a new one will be created with the name "__DEFAULT__".
+ """
+
APP_NAME = 'Bumble'
APP_AUTHOR = 'Google'
KEYS_DIR = 'Pairing'
DEFAULT_NAMESPACE = '__DEFAULT__'
+ DEFAULT_BASE_NAME = "keys"
def __init__(self, namespace, filename=None):
self.namespace = namespace if namespace is not None else self.DEFAULT_NAMESPACE
@@ -208,8 +242,9 @@
self.directory_name = os.path.join(
appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR), self.KEYS_DIR
)
+ base_name = self.DEFAULT_BASE_NAME if namespace is None else self.namespace
json_filename = (
- f'{self.namespace}.json'.lower().replace(':', '-').replace('/p', '-p')
+ f'{base_name}.json'.lower().replace(':', '-').replace('/p', '-p')
)
self.filename = os.path.join(self.directory_name, json_filename)
else:
@@ -219,11 +254,13 @@
logger.debug(f'JSON keystore: {self.filename}')
@staticmethod
- def from_device(device: Device) -> Optional[JsonKeyStore]:
- if not device.config.keystore:
- return None
-
- params = device.config.keystore.split(':', 1)[1:]
+ def from_device(device: Device, filename=None) -> Optional[JsonKeyStore]:
+ if not filename:
+ # Extract the filename from the config if there is one
+ if device.config.keystore is not None:
+ params = device.config.keystore.split(':', 1)[1:]
+ if params:
+ filename = params[0]
# Use a namespace based on the device address
if device.public_address not in (Address.ANY, Address.ANY_RANDOM):
@@ -232,19 +269,31 @@
namespace = str(device.random_address)
else:
namespace = JsonKeyStore.DEFAULT_NAMESPACE
- if params:
- filename = params[0]
- else:
- filename = None
return JsonKeyStore(namespace, filename)
async def load(self):
+ # Try to open the file, without failing. If the file does not exist, it
+ # will be created upon saving.
try:
with open(self.filename, 'r', encoding='utf-8') as json_file:
- return json.load(json_file)
+ db = json.load(json_file)
except FileNotFoundError:
- return {}
+ db = {}
+
+ # First, look for a namespace match
+ if self.namespace in db:
+ return (db, db[self.namespace])
+
+ # Then, if the namespace is the default namespace, and there's
+ # only one entry in the db, use that
+ if self.namespace == self.DEFAULT_NAMESPACE and len(db) == 1:
+ return next(iter(db.items()))
+
+ # Finally, just create an empty key map for the namespace
+ key_map = {}
+ db[self.namespace] = key_map
+ return (db, key_map)
async def save(self, db):
# Create the directory if it doesn't exist
@@ -260,53 +309,30 @@
os.replace(temp_filename, self.filename)
async def delete(self, name: str) -> None:
- db = await self.load()
-
- namespace = db.get(self.namespace)
- if namespace is None:
- raise KeyError(name)
-
- del namespace[name]
+ db, key_map = await self.load()
+ del key_map[name]
await self.save(db)
async def update(self, name, keys):
- db = await self.load()
-
- namespace = db.setdefault(self.namespace, {})
- namespace.setdefault(name, {}).update(keys.to_dict())
-
+ db, key_map = await self.load()
+ key_map.setdefault(name, {}).update(keys.to_dict())
await self.save(db)
async def get_all(self):
- db = await self.load()
-
- namespace = db.get(self.namespace)
- if namespace is None:
- return []
-
- return [
- (name, PairingKeys.from_dict(keys)) for (name, keys) in namespace.items()
- ]
+ _, key_map = await self.load()
+ return [(name, PairingKeys.from_dict(keys)) for (name, keys) in key_map.items()]
async def delete_all(self):
- db = await self.load()
-
- db.pop(self.namespace, None)
-
+ db, key_map = await self.load()
+ key_map.clear()
await self.save(db)
async def get(self, name: str) -> Optional[PairingKeys]:
- db = await self.load()
-
- namespace = db.get(self.namespace)
- if namespace is None:
+ _, key_map = await self.load()
+ if name not in key_map:
return None
- keys = namespace.get(name)
- if keys is None:
- return None
-
- return PairingKeys.from_dict(keys)
+ return PairingKeys.from_dict(key_map[name])
# -----------------------------------------------------------------------------
diff --git a/bumble/l2cap.py b/bumble/l2cap.py
index ef7fdab..4464afc 100644
--- a/bumble/l2cap.py
+++ b/bumble/l2cap.py
@@ -22,7 +22,19 @@
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,
+ TYPE_CHECKING,
+)
from .colors import color
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
@@ -33,6 +45,9 @@
name_or_number,
)
+if TYPE_CHECKING:
+ from bumble.device import Connection
+
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -155,7 +170,7 @@
'''
@staticmethod
- def from_bytes(data):
+ def from_bytes(data: bytes) -> L2CAP_PDU:
# Sanity check
if len(data) < 4:
raise ValueError('not enough data for L2CAP header')
@@ -165,18 +180,18 @@
return L2CAP_PDU(l2cap_pdu_cid, l2cap_pdu_payload)
- def to_bytes(self):
+ def to_bytes(self) -> bytes:
header = struct.pack('<HH', len(self.payload), self.cid)
return header + self.payload
- def __init__(self, cid, payload):
+ def __init__(self, cid: int, payload: bytes) -> None:
self.cid = cid
self.payload = payload
- def __bytes__(self):
+ def __bytes__(self) -> bytes:
return self.to_bytes()
- def __str__(self):
+ def __str__(self) -> str:
return f'{color("L2CAP", "green")} [CID={self.cid}]: {self.payload.hex()}'
@@ -188,10 +203,10 @@
classes: Dict[int, Type[L2CAP_Control_Frame]] = {}
code = 0
- name = None
+ name: str
@staticmethod
- def from_bytes(pdu):
+ def from_bytes(pdu: bytes) -> L2CAP_Control_Frame:
code = pdu[0]
cls = L2CAP_Control_Frame.classes.get(code)
@@ -216,11 +231,11 @@
return self
@staticmethod
- def code_name(code):
+ def code_name(code: int) -> str:
return name_or_number(L2CAP_CONTROL_FRAME_NAMES, code)
@staticmethod
- def decode_configuration_options(data):
+ def decode_configuration_options(data: bytes) -> List[Tuple[int, bytes]]:
options = []
while len(data) >= 2:
value_type = data[0]
@@ -232,7 +247,7 @@
return options
@staticmethod
- def encode_configuration_options(options):
+ def encode_configuration_options(options: List[Tuple[int, bytes]]) -> bytes:
return b''.join(
[bytes([option[0], len(option[1])]) + option[1] for option in options]
)
@@ -256,29 +271,30 @@
return inner
- def __init__(self, pdu=None, **kwargs):
+ def __init__(self, pdu=None, **kwargs) -> None:
self.identifier = kwargs.get('identifier', 0)
- if hasattr(self, 'fields') and kwargs:
- HCI_Object.init_from_fields(self, self.fields, kwargs)
- if pdu is None:
- data = HCI_Object.dict_to_bytes(kwargs, self.fields)
- pdu = (
- bytes([self.code, self.identifier])
- + struct.pack('<H', len(data))
- + data
- )
+ if hasattr(self, 'fields'):
+ if kwargs:
+ HCI_Object.init_from_fields(self, self.fields, kwargs)
+ if pdu is None:
+ data = HCI_Object.dict_to_bytes(kwargs, self.fields)
+ pdu = (
+ bytes([self.code, self.identifier])
+ + struct.pack('<H', len(data))
+ + data
+ )
self.pdu = pdu
def init_from_bytes(self, pdu, offset):
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
- def to_bytes(self):
+ def to_bytes(self) -> bytes:
return self.pdu
- def __bytes__(self):
+ def __bytes__(self) -> bytes:
return self.to_bytes()
- def __str__(self):
+ def __str__(self) -> str:
result = f'{color(self.name, "yellow")} [ID={self.identifier}]'
if fields := getattr(self, 'fields', None):
result += ':\n' + HCI_Object.format_fields(self.__dict__, fields, ' ')
@@ -315,7 +331,7 @@
}
@staticmethod
- def reason_name(reason):
+ def reason_name(reason: int) -> str:
return name_or_number(L2CAP_Command_Reject.REASON_NAMES, reason)
@@ -343,7 +359,7 @@
'''
@staticmethod
- def parse_psm(data, offset=0):
+ def parse_psm(data: bytes, offset: int = 0) -> Tuple[int, int]:
psm_length = 2
psm = data[offset] | data[offset + 1] << 8
@@ -355,7 +371,7 @@
return offset + psm_length, psm
@staticmethod
- def serialize_psm(psm):
+ def serialize_psm(psm: int) -> bytes:
serialized = struct.pack('<H', psm & 0xFFFF)
psm >>= 16
while psm:
@@ -405,7 +421,7 @@
}
@staticmethod
- def result_name(result):
+ def result_name(result: int) -> str:
return name_or_number(L2CAP_Connection_Response.RESULT_NAMES, result)
@@ -452,7 +468,7 @@
}
@staticmethod
- def result_name(result):
+ def result_name(result: int) -> str:
return name_or_number(L2CAP_Configure_Response.RESULT_NAMES, result)
@@ -529,7 +545,7 @@
}
@staticmethod
- def info_type_name(info_type):
+ def info_type_name(info_type: int) -> str:
return name_or_number(L2CAP_Information_Request.INFO_TYPE_NAMES, info_type)
@@ -556,7 +572,7 @@
RESULT_NAMES = {SUCCESS: 'SUCCESS', NOT_SUPPORTED: 'NOT_SUPPORTED'}
@staticmethod
- def result_name(result):
+ def result_name(result: int) -> str:
return name_or_number(L2CAP_Information_Response.RESULT_NAMES, result)
@@ -588,6 +604,8 @@
(CODE 0x14)
'''
+ source_cid: int
+
# -----------------------------------------------------------------------------
@L2CAP_Control_Frame.subclass(
@@ -640,7 +658,7 @@
}
@staticmethod
- def result_name(result):
+ def result_name(result: int) -> str:
return name_or_number(
L2CAP_LE_Credit_Based_Connection_Response.RESULT_NAMES, result
)
@@ -701,7 +719,22 @@
WAIT_CONTROL_IND: 'WAIT_CONTROL_IND',
}
- def __init__(self, manager, connection, signaling_cid, psm, source_cid, mtu):
+ connection_result: Optional[asyncio.Future[None]]
+ disconnection_result: Optional[asyncio.Future[None]]
+ response: Optional[asyncio.Future[bytes]]
+ sink: Optional[Callable[[bytes], Any]]
+ state: int
+ connection: Connection
+
+ 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
@@ -716,19 +749,19 @@
self.disconnection_result = None
self.sink = None
- def change_state(self, new_state):
+ def change_state(self, new_state: int) -> None:
logger.debug(
f'{self} state change -> {color(Channel.STATE_NAMES[new_state], "cyan")}'
)
self.state = new_state
- def send_pdu(self, pdu):
+ def send_pdu(self, pdu) -> None:
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
- def send_control_frame(self, frame):
+ def send_control_frame(self, 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) -> bytes:
# Check that there isn't already a request pending
if self.response:
raise InvalidStateError('request already pending')
@@ -739,7 +772,7 @@
self.send_pdu(request)
return await self.response
- def on_pdu(self, pdu):
+ def on_pdu(self, pdu) -> None:
if self.response:
self.response.set_result(pdu)
self.response = None
@@ -751,7 +784,7 @@
color('received pdu without a pending request or sink', 'red')
)
- async def connect(self):
+ async def connect(self) -> None:
if self.state != Channel.CLOSED:
raise InvalidStateError('invalid state')
@@ -778,7 +811,7 @@
finally:
self.connection_result = None
- async def disconnect(self):
+ async def disconnect(self) -> None:
if self.state != Channel.OPEN:
raise InvalidStateError('invalid state')
@@ -796,12 +829,12 @@
self.disconnection_result = asyncio.get_running_loop().create_future()
return await self.disconnection_result
- def abort(self):
+ def abort(self) -> None:
if self.state == self.OPEN:
self.change_state(self.CLOSED)
self.emit('close')
- def send_configure_request(self):
+ def send_configure_request(self) -> None:
options = L2CAP_Control_Frame.encode_configuration_options(
[
(
@@ -819,7 +852,7 @@
)
)
- 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.send_control_frame(
@@ -858,7 +891,7 @@
)
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,
@@ -896,7 +929,7 @@
elif self.state == Channel.WAIT_CONFIG_REQ_RSP:
self.change_state(Channel.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)
@@ -930,7 +963,7 @@
)
# TODO: decide how to fail gracefully
- def on_disconnection_request(self, request):
+ def on_disconnection_request(self, request) -> None:
if self.state in (Channel.OPEN, Channel.WAIT_DISCONNECT):
self.send_control_frame(
L2CAP_Disconnection_Response(
@@ -945,7 +978,7 @@
else:
logger.warning(color('invalid state', 'red'))
- def on_disconnection_response(self, response):
+ def on_disconnection_response(self, response) -> None:
if self.state != Channel.WAIT_DISCONNECT:
logger.warning(color('invalid state', 'red'))
return
@@ -964,7 +997,7 @@
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}, '
@@ -995,25 +1028,32 @@
CONNECTION_ERROR: 'CONNECTION_ERROR',
}
+ out_queue: Deque[bytes]
+ connection_result: Optional[asyncio.Future[LeConnectionOrientedChannel]]
+ disconnection_result: Optional[asyncio.Future[None]]
+ out_sdu: Optional[bytes]
+ state: int
+ connection: Connection
+
@staticmethod
- def state_name(state):
+ def state_name(state: int) -> str:
return name_or_number(LeConnectionOrientedChannel.STATE_NAMES, state)
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
@@ -1045,7 +1085,7 @@
else:
self.state = LeConnectionOrientedChannel.INIT
- def change_state(self, new_state):
+ def change_state(self, new_state: int) -> None:
logger.debug(
f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
)
@@ -1056,13 +1096,13 @@
elif new_state == self.DISCONNECTED:
self.emit('close')
- def send_pdu(self, pdu):
+ def send_pdu(self, pdu) -> None:
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
- def send_control_frame(self, frame):
+ def send_control_frame(self, 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:
raise InvalidStateError('not in a connectable state')
@@ -1090,7 +1130,7 @@
# 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:
raise InvalidStateError('not connected')
@@ -1110,11 +1150,11 @@
self.disconnection_result = asyncio.get_running_loop().create_future()
return await self.disconnection_result
- def abort(self):
+ def abort(self) -> None:
if self.state == self.CONNECTED:
self.change_state(self.DISCONNECTED)
- def on_pdu(self, pdu):
+ def on_pdu(self, pdu) -> None:
if self.sink is None:
logger.warning('received pdu without a sink')
return
@@ -1180,7 +1220,7 @@
self.in_sdu = None
self.in_sdu_length = 0
- def on_connection_response(self, response):
+ def on_connection_response(self, response) -> None:
# Look for a matching pending response result
if self.connection_result is None:
logger.warning(
@@ -1214,14 +1254,14 @@
# 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,
@@ -1232,7 +1272,7 @@
self.change_state(self.DISCONNECTED)
self.flush_output()
- def on_disconnection_response(self, response):
+ def on_disconnection_response(self, response) -> None:
if self.state != self.DISCONNECTING:
logger.warning(color('invalid state', 'red'))
return
@@ -1249,11 +1289,11 @@
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,7 +1336,7 @@
self.drained.set()
return
- def write(self, data):
+ def write(self, data: bytes) -> None:
if self.state != self.CONNECTED:
logger.warning('not connected, dropping data')
return
@@ -1311,18 +1351,18 @@
# 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)}, '
@@ -1335,9 +1375,21 @@
# -----------------------------------------------------------------------------
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]]]
+
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
@@ -1366,20 +1418,20 @@
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 +1444,7 @@
raise RuntimeError('no free CID available')
@staticmethod
- def find_free_le_cid(channels):
+ def find_free_le_cid(channels: Iterable[int]) -> int:
# Pick the smallest valid CID that's not already in the list
# (not necessarily the most efficient algorithm, but the list of CID is
# very small in practice)
@@ -1405,7 +1457,7 @@
raise RuntimeError('no free CID')
@staticmethod
- def check_le_coc_parameters(max_credits, mtu, mps):
+ def check_le_coc_parameters(max_credits: int, mtu: int, mps: int) -> None:
if (
max_credits < 1
or max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
@@ -1419,19 +1471,21 @@
):
raise ValueError('MPS out of range')
- def next_identifier(self, connection):
+ def next_identifier(self, connection: Connection) -> int:
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
self.identifiers[connection.handle] = identifier
return identifier
- def register_fixed_channel(self, cid, handler):
+ def register_fixed_channel(
+ self, cid: int, handler: Callable[[int, bytes], Any]
+ ) -> None:
self.fixed_channels[cid] = handler
- def deregister_fixed_channel(self, cid):
+ def deregister_fixed_channel(self, cid: int) -> None:
if cid in self.fixed_channels:
del self.fixed_channels[cid]
- def register_server(self, psm, server):
+ def register_server(self, psm: int, server: Callable[[Channel], Any]) -> int:
if psm == 0:
# Find a free PSM
for candidate in range(
@@ -1465,12 +1519,12 @@
def register_le_coc_server(
self,
- psm,
- server,
- max_credits=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
- mtu=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
- mps=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
- ):
+ psm: int,
+ server: Callable[[LeConnectionOrientedChannel], Any],
+ max_credits: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
+ mtu: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
+ mps: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
+ ) -> int:
self.check_le_coc_parameters(max_credits, mtu, mps)
if psm == 0:
@@ -1498,7 +1552,7 @@
return psm
- def on_disconnection(self, connection_handle, _reason):
+ def on_disconnection(self, connection_handle: int, _reason: int) -> None:
logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
if connection_handle in self.channels:
for _, channel in self.channels[connection_handle].items():
@@ -1511,7 +1565,7 @@
if connection_handle in self.identifiers:
del self.identifiers[connection_handle]
- def send_pdu(self, connection, cid, pdu):
+ def send_pdu(self, connection, cid: int, pdu) -> None:
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
logger.debug(
f'{color(">>> Sending L2CAP PDU", "blue")} '
@@ -1520,14 +1574,16 @@
)
self.host.send_l2cap_pdu(connection.handle, cid, bytes(pdu))
- def on_pdu(self, connection, cid, pdu):
+ def on_pdu(self, connection: Connection, cid: int, pdu) -> 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 +1595,9 @@
channel.on_pdu(pdu)
- def send_control_frame(self, connection, cid, control_frame):
+ def send_control_frame(
+ self, connection: Connection, cid: int, control_frame
+ ) -> None:
logger.debug(
f'{color(">>> Sending L2CAP Signaling Control Frame", "blue")} '
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
@@ -1547,7 +1605,7 @@
)
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) -> None:
logger.debug(
f'{color("<<< Received L2CAP Signaling Control Frame", "green")} '
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
@@ -1584,10 +1642,14 @@
),
)
- def on_l2cap_command_reject(self, _connection, _cid, packet):
+ def on_l2cap_command_reject(
+ self, _connection: Connection, _cid: int, packet
+ ) -> None:
logger.warning(f'{color("!!! Command rejected:", "red")} {packet.reason}')
- def on_l2cap_connection_request(self, connection, cid, request):
+ def on_l2cap_connection_request(
+ self, connection: Connection, cid: int, request
+ ) -> None:
# Check if there's a server for this PSM
server = self.servers.get(request.psm)
if server:
@@ -1639,7 +1701,9 @@
),
)
- def on_l2cap_connection_response(self, connection, cid, response):
+ def on_l2cap_connection_response(
+ self, connection: Connection, cid: int, response
+ ) -> None:
if (
channel := self.find_channel(connection.handle, response.source_cid)
) is None:
@@ -1654,7 +1718,9 @@
channel.on_connection_response(response)
- def on_l2cap_configure_request(self, connection, cid, request):
+ def on_l2cap_configure_request(
+ self, connection: Connection, cid: int, request
+ ) -> None:
if (
channel := self.find_channel(connection.handle, request.destination_cid)
) is None:
@@ -1669,7 +1735,9 @@
channel.on_configure_request(request)
- def on_l2cap_configure_response(self, connection, cid, response):
+ def on_l2cap_configure_response(
+ self, connection: Connection, cid: int, response
+ ) -> None:
if (
channel := self.find_channel(connection.handle, response.source_cid)
) is None:
@@ -1684,7 +1752,9 @@
channel.on_configure_response(response)
- def on_l2cap_disconnection_request(self, connection, cid, request):
+ def on_l2cap_disconnection_request(
+ self, connection: Connection, cid: int, request
+ ) -> None:
if (
channel := self.find_channel(connection.handle, request.destination_cid)
) is None:
@@ -1699,7 +1769,9 @@
channel.on_disconnection_request(request)
- def on_l2cap_disconnection_response(self, connection, cid, response):
+ def on_l2cap_disconnection_response(
+ self, connection: Connection, cid: int, response
+ ) -> None:
if (
channel := self.find_channel(connection.handle, response.source_cid)
) is None:
@@ -1714,7 +1786,7 @@
channel.on_disconnection_response(response)
- def on_l2cap_echo_request(self, connection, cid, request):
+ def on_l2cap_echo_request(self, connection: Connection, cid: int, request) -> None:
logger.debug(f'<<< Echo request: data={request.data.hex()}')
self.send_control_frame(
connection,
@@ -1722,11 +1794,15 @@
L2CAP_Echo_Response(identifier=request.identifier, data=request.data),
)
- def on_l2cap_echo_response(self, _connection, _cid, response):
+ def on_l2cap_echo_response(
+ self, _connection: Connection, _cid: int, response
+ ) -> None:
logger.debug(f'<<< Echo response: data={response.data.hex()}')
# TODO notify listeners
- def on_l2cap_information_request(self, connection, cid, request):
+ def on_l2cap_information_request(
+ self, connection: Connection, cid: int, request
+ ) -> None:
if request.info_type == L2CAP_Information_Request.CONNECTIONLESS_MTU:
result = L2CAP_Information_Response.SUCCESS
data = self.connectionless_mtu.to_bytes(2, 'little')
@@ -1750,7 +1826,9 @@
),
)
- def on_l2cap_connection_parameter_update_request(self, connection, cid, request):
+ def on_l2cap_connection_parameter_update_request(
+ self, connection: Connection, cid: int, request
+ ):
if connection.role == BT_CENTRAL_ROLE:
self.send_control_frame(
connection,
@@ -1769,7 +1847,7 @@
supervision_timeout=request.timeout,
min_ce_length=0,
max_ce_length=0,
- )
+ ) # type: ignore[call-arg]
)
else:
self.send_control_frame(
@@ -1781,11 +1859,15 @@
),
)
- def on_l2cap_connection_parameter_update_response(self, connection, cid, response):
+ def on_l2cap_connection_parameter_update_response(
+ self, connection: Connection, cid: int, response
+ ) -> None:
# TODO: check response
pass
- 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 +1969,9 @@
),
)
- def on_l2cap_le_credit_based_connection_response(self, connection, _cid, response):
+ def on_l2cap_le_credit_based_connection_response(
+ self, connection: Connection, _cid: int, response
+ ) -> None:
# Find the pending request by identifier
request = self.le_coc_requests.get(response.identifier)
if request is None:
@@ -1910,7 +1994,9 @@
# Process the response
channel.on_connection_response(response)
- def on_l2cap_le_flow_control_credit(self, connection, _cid, credit):
+ def on_l2cap_le_flow_control_credit(
+ self, connection: Connection, _cid: int, credit
+ ) -> None:
channel = self.find_le_coc_channel(connection.handle, credit.cid)
if channel is None:
logger.warning(f'received credits for an unknown channel (cid={credit.cid}')
@@ -1918,13 +2004,15 @@
channel.on_credits(credit.credits)
- def on_channel_closed(self, channel):
+ def on_channel_closed(self, channel: Channel) -> None:
connection_channels = self.channels.get(channel.connection.handle)
if connection_channels:
if channel.source_cid in connection_channels:
del connection_channels[channel.source_cid]
- async def open_le_coc(self, connection, psm, max_credits, mtu, mps):
+ async def open_le_coc(
+ self, connection: Connection, psm: int, max_credits: int, mtu: int, mps: int
+ ) -> LeConnectionOrientedChannel:
self.check_le_coc_parameters(max_credits, mtu, mps)
# Find a free CID for the new channel
@@ -1965,7 +2053,7 @@
return channel
- async def connect(self, connection, psm):
+ async def connect(self, connection: Connection, psm: int) -> Channel:
# NOTE: this implementation hard-codes BR/EDR
# Find a free CID for a new channel
diff --git a/bumble/pairing.py b/bumble/pairing.py
index ab356ee..877b739 100644
--- a/bumble/pairing.py
+++ b/bumble/pairing.py
@@ -19,6 +19,7 @@
from typing import Optional, Tuple
from .hci import (
+ Address,
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
HCI_DISPLAY_ONLY_IO_CAPABILITY,
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
@@ -168,21 +169,28 @@
class PairingConfig:
"""Configuration for the Pairing protocol."""
+ class AddressType(enum.IntEnum):
+ PUBLIC = Address.PUBLIC_DEVICE_ADDRESS
+ RANDOM = Address.RANDOM_DEVICE_ADDRESS
+
def __init__(
self,
sc: bool = True,
mitm: bool = True,
bonding: bool = True,
delegate: Optional[PairingDelegate] = None,
+ identity_address_type: Optional[AddressType] = None,
) -> None:
self.sc = sc
self.mitm = mitm
self.bonding = bonding
self.delegate = delegate or PairingDelegate()
+ self.identity_address_type = identity_address_type
def __str__(self) -> str:
return (
f'PairingConfig(sc={self.sc}, '
f'mitm={self.mitm}, bonding={self.bonding}, '
+ f'identity_address_type={self.identity_address_type}, '
f'delegate[{self.delegate.io_capability}])'
)
diff --git a/bumble/pandora/device.py b/bumble/pandora/device.py
index a4403b6..9173900 100644
--- a/bumble/pandora/device.py
+++ b/bumble/pandora/device.py
@@ -34,6 +34,10 @@
from typing import Any, Dict, List, Optional
+# Default rootcanal HCI TCP address
+ROOTCANAL_HCI_ADDRESS = "localhost:6402"
+
+
class PandoraDevice:
"""
Small wrapper around a Bumble device and it's HCI transport.
@@ -53,7 +57,9 @@
def __init__(self, config: Dict[str, Any]) -> None:
self.config = config
self.device = _make_device(config)
- self._hci_name = config.get('transport', '')
+ self._hci_name = config.get(
+ 'transport', f"tcp-client:{config.get('tcp', ROOTCANAL_HCI_ADDRESS)}"
+ )
self._hci = None
@property
diff --git a/bumble/pandora/host.py b/bumble/pandora/host.py
index 63b295d..9e6e4b5 100644
--- a/bumble/pandora/host.py
+++ b/bumble/pandora/host.py
@@ -43,7 +43,8 @@
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
Address,
)
-from google.protobuf import any_pb2, empty_pb2 # pytype: disable=pyi-error
+from google.protobuf import any_pb2 # pytype: disable=pyi-error
+from google.protobuf import empty_pb2 # pytype: disable=pyi-error
from pandora.host_grpc_aio import HostServicer
from pandora.host_pb2 import (
NOT_CONNECTABLE,
@@ -111,7 +112,7 @@
async def FactoryReset(
self, request: empty_pb2.Empty, context: grpc.ServicerContext
) -> empty_pb2.Empty:
- self.log.info('FactoryReset')
+ self.log.debug('FactoryReset')
# delete all bonds
if self.device.keystore is not None:
@@ -125,7 +126,7 @@
async def Reset(
self, request: empty_pb2.Empty, context: grpc.ServicerContext
) -> empty_pb2.Empty:
- self.log.info('Reset')
+ self.log.debug('Reset')
# clear service.
self.waited_connections.clear()
@@ -138,7 +139,7 @@
async def ReadLocalAddress(
self, request: empty_pb2.Empty, context: grpc.ServicerContext
) -> ReadLocalAddressResponse:
- self.log.info('ReadLocalAddress')
+ self.log.debug('ReadLocalAddress')
return ReadLocalAddressResponse(
address=bytes(reversed(bytes(self.device.public_address)))
)
@@ -151,7 +152,7 @@
address = Address(
bytes(reversed(request.address)), address_type=Address.PUBLIC_DEVICE_ADDRESS
)
- self.log.info(f"Connect to {address}")
+ self.log.debug(f"Connect to {address}")
try:
connection = await self.device.connect(
@@ -166,7 +167,7 @@
return ConnectResponse(connection_already_exists=empty_pb2.Empty())
raise e
- self.log.info(f"Connect to {address} done (handle={connection.handle})")
+ self.log.debug(f"Connect to {address} done (handle={connection.handle})")
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
return ConnectResponse(connection=Connection(cookie=cookie))
@@ -185,7 +186,7 @@
if address in (Address.NIL, Address.ANY):
raise ValueError('Invalid address')
- self.log.info(f"WaitConnection from {address}...")
+ self.log.debug(f"WaitConnection from {address}...")
connection = self.device.find_connection_by_bd_addr(
address, transport=BT_BR_EDR_TRANSPORT
@@ -200,7 +201,7 @@
# save connection has waited and respond.
self.waited_connections.add(id(connection))
- self.log.info(
+ self.log.debug(
f"WaitConnection from {address} done (handle={connection.handle})"
)
@@ -215,7 +216,7 @@
if address in (Address.NIL, Address.ANY):
raise ValueError('Invalid address')
- self.log.info(f"ConnectLE to {address}...")
+ self.log.debug(f"ConnectLE to {address}...")
try:
connection = await self.device.connect(
@@ -232,7 +233,7 @@
return ConnectLEResponse(connection_already_exists=empty_pb2.Empty())
raise e
- self.log.info(f"ConnectLE to {address} done (handle={connection.handle})")
+ self.log.debug(f"ConnectLE to {address} done (handle={connection.handle})")
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
return ConnectLEResponse(connection=Connection(cookie=cookie))
@@ -242,12 +243,12 @@
self, request: DisconnectRequest, context: grpc.ServicerContext
) -> empty_pb2.Empty:
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
- self.log.info(f"Disconnect: {connection_handle}")
+ self.log.debug(f"Disconnect: {connection_handle}")
- self.log.info("Disconnecting...")
+ self.log.debug("Disconnecting...")
if connection := self.device.lookup_connection(connection_handle):
await connection.disconnect(HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR)
- self.log.info("Disconnected")
+ self.log.debug("Disconnected")
return empty_pb2.Empty()
@@ -256,7 +257,7 @@
self, request: WaitDisconnectionRequest, context: grpc.ServicerContext
) -> empty_pb2.Empty:
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
- self.log.info(f"WaitDisconnection: {connection_handle}")
+ self.log.debug(f"WaitDisconnection: {connection_handle}")
if connection := self.device.lookup_connection(connection_handle):
disconnection_future: asyncio.Future[
@@ -269,7 +270,7 @@
connection.on('disconnection', on_disconnection)
try:
await disconnection_future
- self.log.info("Disconnected")
+ self.log.debug("Disconnected")
finally:
connection.remove_listener('disconnection', on_disconnection) # type: ignore
@@ -377,7 +378,7 @@
try:
while True:
if not self.device.is_advertising:
- self.log.info('Advertise')
+ self.log.debug('Advertise')
await self.device.start_advertising(
target=target,
advertising_type=advertising_type,
@@ -392,10 +393,10 @@
bumble.device.Connection
] = asyncio.get_running_loop().create_future()
- self.log.info('Wait for LE connection...')
+ self.log.debug('Wait for LE connection...')
connection = await pending_connection
- self.log.info(
+ self.log.debug(
f"Advertise: Connected to {connection.peer_address} (handle={connection.handle})"
)
@@ -409,7 +410,7 @@
self.device.remove_listener('connection', on_connection) # type: ignore
try:
- self.log.info('Stop advertising')
+ self.log.debug('Stop advertising')
await self.device.abort_on('flush', self.device.stop_advertising())
except:
pass
@@ -422,7 +423,7 @@
if request.phys:
raise NotImplementedError("TODO: add support for `request.phys`")
- self.log.info('Scan')
+ self.log.debug('Scan')
scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue()
handler = self.device.on('advertisement', scan_queue.put_nowait)
@@ -469,7 +470,7 @@
finally:
self.device.remove_listener('advertisement', handler) # type: ignore
try:
- self.log.info('Stop scanning')
+ self.log.debug('Stop scanning')
await self.device.abort_on('flush', self.device.stop_scanning())
except:
pass
@@ -478,7 +479,7 @@
async def Inquiry(
self, request: empty_pb2.Empty, context: grpc.ServicerContext
) -> AsyncGenerator[InquiryResponse, None]:
- self.log.info('Inquiry')
+ self.log.debug('Inquiry')
inquiry_queue: asyncio.Queue[
Optional[Tuple[Address, int, AdvertisingData, int]]
@@ -509,7 +510,7 @@
self.device.remove_listener('inquiry_complete', complete_handler) # type: ignore
self.device.remove_listener('inquiry_result', result_handler) # type: ignore
try:
- self.log.info('Stop inquiry')
+ self.log.debug('Stop inquiry')
await self.device.abort_on('flush', self.device.stop_discovery())
except:
pass
@@ -518,7 +519,7 @@
async def SetDiscoverabilityMode(
self, request: SetDiscoverabilityModeRequest, context: grpc.ServicerContext
) -> empty_pb2.Empty:
- self.log.info("SetDiscoverabilityMode")
+ self.log.debug("SetDiscoverabilityMode")
await self.device.set_discoverable(request.mode != NOT_DISCOVERABLE)
return empty_pb2.Empty()
@@ -526,7 +527,7 @@
async def SetConnectabilityMode(
self, request: SetConnectabilityModeRequest, context: grpc.ServicerContext
) -> empty_pb2.Empty:
- self.log.info("SetConnectabilityMode")
+ self.log.debug("SetConnectabilityMode")
await self.device.set_connectable(request.mode != NOT_CONNECTABLE)
return empty_pb2.Empty()
diff --git a/bumble/pandora/security.py b/bumble/pandora/security.py
index fee1b7a..9f98f3f 100644
--- a/bumble/pandora/security.py
+++ b/bumble/pandora/security.py
@@ -29,12 +29,9 @@
from bumble.hci import HCI_Error
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 +99,7 @@
return ev
async def confirm(self, auto: bool = False) -> bool:
- self.log.info(
+ self.log.debug(
f"Pairing event: `just_works` (io_capability: {self.io_capability})"
)
@@ -117,7 +114,7 @@
return answer.confirm
async def compare_numbers(self, number: int, digits: int = 6) -> bool:
- self.log.info(
+ self.log.debug(
f"Pairing event: `numeric_comparison` (io_capability: {self.io_capability})"
)
@@ -132,7 +129,7 @@
return answer.confirm
async def get_number(self) -> Optional[int]:
- self.log.info(
+ self.log.debug(
f"Pairing event: `passkey_entry_request` (io_capability: {self.io_capability})"
)
@@ -149,7 +146,7 @@
return answer.passkey
async def get_string(self, max_length: int) -> Optional[str]:
- self.log.info(
+ self.log.debug(
f"Pairing event: `pin_code_request` (io_capability: {self.io_capability})"
)
@@ -180,7 +177,7 @@
):
return
- self.log.info(
+ self.log.debug(
f"Pairing event: `passkey_entry_notification` (io_capability: {self.io_capability})"
)
@@ -250,7 +247,7 @@
async def OnPairing(
self, request: AsyncIterator[PairingEventAnswer], context: grpc.ServicerContext
) -> AsyncGenerator[PairingEvent, None]:
- self.log.info('OnPairing')
+ self.log.debug('OnPairing')
if self.event_queue is not None:
raise RuntimeError('already streaming pairing events')
@@ -276,7 +273,7 @@
self, request: SecureRequest, context: grpc.ServicerContext
) -> SecureResponse:
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
- self.log.info(f"Secure: {connection_handle}")
+ self.log.debug(f"Secure: {connection_handle}")
connection = self.device.lookup_connection(connection_handle)
assert connection
@@ -294,7 +291,7 @@
# trigger pairing if needed
if self.need_pairing(connection, level):
try:
- self.log.info('Pair...')
+ self.log.debug('Pair...')
if (
connection.transport == BT_LE_TRANSPORT
@@ -312,7 +309,7 @@
else:
await connection.pair()
- self.log.info('Paired')
+ self.log.debug('Paired')
except asyncio.CancelledError:
self.log.warning("Connection died during encryption")
return SecureResponse(connection_died=empty_pb2.Empty())
@@ -323,9 +320,9 @@
# trigger authentication if needed
if self.need_authentication(connection, level):
try:
- self.log.info('Authenticate...')
+ self.log.debug('Authenticate...')
await connection.authenticate()
- self.log.info('Authenticated')
+ self.log.debug('Authenticated')
except asyncio.CancelledError:
self.log.warning("Connection died during authentication")
return SecureResponse(connection_died=empty_pb2.Empty())
@@ -336,9 +333,9 @@
# trigger encryption if needed
if self.need_encryption(connection, level):
try:
- self.log.info('Encrypt...')
+ self.log.debug('Encrypt...')
await connection.encrypt()
- self.log.info('Encrypted')
+ self.log.debug('Encrypted')
except asyncio.CancelledError:
self.log.warning("Connection died during encryption")
return SecureResponse(connection_died=empty_pb2.Empty())
@@ -356,7 +353,7 @@
self, request: WaitSecurityRequest, context: grpc.ServicerContext
) -> WaitSecurityResponse:
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
- self.log.info(f"WaitSecurity: {connection_handle}")
+ self.log.debug(f"WaitSecurity: {connection_handle}")
connection = self.device.lookup_connection(connection_handle)
assert connection
@@ -393,7 +390,7 @@
def set_failure(name: str) -> Callable[..., None]:
def wrapper(*args: Any) -> None:
- self.log.info(f'Wait for security: error `{name}`: {args}')
+ self.log.debug(f'Wait for security: error `{name}`: {args}')
wait_for_security.set_result(name)
return wrapper
@@ -401,13 +398,13 @@
def try_set_success(*_: Any) -> None:
assert connection
if self.reached_security_level(connection, level):
- self.log.info('Wait for security: done')
+ self.log.debug('Wait for security: done')
wait_for_security.set_result('success')
def on_encryption_change(*_: Any) -> None:
assert connection
if self.reached_security_level(connection, level):
- self.log.info('Wait for security: done')
+ self.log.debug('Wait for security: done')
wait_for_security.set_result('success')
elif (
connection.transport == BT_BR_EDR_TRANSPORT
@@ -435,7 +432,7 @@
if self.reached_security_level(connection, level):
return WaitSecurityResponse(success=empty_pb2.Empty())
- self.log.info('Wait for security...')
+ self.log.debug('Wait for security...')
kwargs = {}
kwargs[await wait_for_security] = empty_pb2.Empty()
@@ -445,12 +442,12 @@
# 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')
return WaitSecurityResponse(**kwargs)
@@ -506,21 +503,21 @@
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):
diff --git a/bumble/rfcomm.py b/bumble/rfcomm.py
index 71be8dc..0176a78 100644
--- a/bumble/rfcomm.py
+++ b/bumble/rfcomm.py
@@ -19,8 +19,9 @@
import asyncio
from pyee import EventEmitter
+from typing import Optional, Tuple, Callable, Dict, Union
-from . import core
+from . import core, l2cap
from .colors import color
from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError
@@ -105,7 +106,7 @@
# -----------------------------------------------------------------------------
-def compute_fcs(buffer):
+def compute_fcs(buffer: bytes) -> int:
result = 0xFF
for byte in buffer:
result = CRC_TABLE[result ^ byte]
@@ -114,7 +115,15 @@
# -----------------------------------------------------------------------------
class RFCOMM_Frame:
- def __init__(self, frame_type, c_r, dlci, p_f, information=b'', with_credits=False):
+ def __init__(
+ self,
+ frame_type: int,
+ c_r: int,
+ dlci: int,
+ p_f: int,
+ information: bytes = b'',
+ with_credits: bool = False,
+ ) -> None:
self.type = frame_type
self.c_r = c_r
self.dlci = dlci
@@ -136,11 +145,11 @@
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, int, bytes]:
mcc_type = data[0] >> 2
c_r = (data[0] >> 1) & 1
length = data[1]
@@ -154,36 +163,36 @@
return (mcc_type, c_r, value)
@staticmethod
- def make_mcc(mcc_type, c_r, data):
+ def make_mcc(mcc_type: int, c_r: int, data: bytes) -> bytes:
return (
bytes([(mcc_type << 2 | c_r << 1 | 1) & 0xFF, (len(data) & 0x7F) << 1 | 1])
+ data
)
@staticmethod
- def sabm(c_r, dlci):
+ def sabm(c_r: int, dlci: int):
return RFCOMM_Frame(RFCOMM_SABM_FRAME, c_r, dlci, 1)
@staticmethod
- def ua(c_r, dlci):
+ def ua(c_r: int, dlci: int):
return RFCOMM_Frame(RFCOMM_UA_FRAME, c_r, dlci, 1)
@staticmethod
- def dm(c_r, dlci):
+ def dm(c_r: int, dlci: int):
return RFCOMM_Frame(RFCOMM_DM_FRAME, c_r, dlci, 1)
@staticmethod
- def disc(c_r, dlci):
+ def disc(c_r: int, dlci: int):
return RFCOMM_Frame(RFCOMM_DISC_FRAME, c_r, dlci, 1)
@staticmethod
- def uih(c_r, dlci, information, p_f=0):
+ def uih(c_r: int, dlci: int, information: bytes, p_f: int = 0):
return RFCOMM_Frame(
RFCOMM_UIH_FRAME, c_r, dlci, p_f, information, with_credits=(p_f == 1)
)
@staticmethod
- def from_bytes(data):
+ def from_bytes(data: bytes):
# Extract fields
dlci = (data[0] >> 2) & 0x3F
c_r = (data[0] >> 1) & 0x01
@@ -227,15 +236,23 @@
# -----------------------------------------------------------------------------
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,
):
self.dlci = dlci
self.cl = cl
@@ -246,7 +263,7 @@
self.window_size = window_size
@staticmethod
- def from_bytes(data):
+ def from_bytes(data: bytes):
return RFCOMM_MCC_PN(
dlci=data[0],
cl=data[1],
@@ -285,7 +302,14 @@
# -----------------------------------------------------------------------------
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):
self.dlci = dlci
self.fc = fc
self.rtc = rtc
@@ -294,7 +318,7 @@
self.dv = dv
@staticmethod
- def from_bytes(data):
+ def from_bytes(data: bytes):
return RFCOMM_MCC_MSC(
dlci=data[0] >> 2,
fc=data[1] >> 1 & 1,
@@ -347,7 +371,12 @@
RESET: 'RESET',
}
- def __init__(self, multiplexer, dlci, max_frame_size, initial_tx_credits):
+ connection_result: Optional[asyncio.Future]
+ sink: Optional[Callable[[bytes], None]]
+
+ def __init__(
+ self, multiplexer, dlci: int, max_frame_size: int, initial_tx_credits: int
+ ):
super().__init__()
self.multiplexer = multiplexer
self.dlci = dlci
@@ -368,23 +397,23 @@
)
@staticmethod
- def state_name(state):
+ def state_name(state: int) -> str:
return DLC.STATE_NAMES[state]
- def change_state(self, new_state):
+ def change_state(self, new_state: int) -> None:
logger.debug(
f'{self} state change -> {color(self.state_name(new_state), "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):
+ def on_sabm_frame(self, _frame) -> None:
if self.state != DLC.CONNECTING:
logger.warning(
color('!!! received SABM when not in CONNECTING state', 'red')
@@ -404,7 +433,7 @@
self.change_state(DLC.CONNECTED)
self.emit('open')
- def on_ua_frame(self, _frame):
+ def on_ua_frame(self, _frame) -> None:
if self.state != DLC.CONNECTING:
logger.warning(
color('!!! received SABM when not in CONNECTING state', 'red')
@@ -422,15 +451,15 @@
self.change_state(DLC.CONNECTED)
self.multiplexer.on_dlc_open_complete(self)
- def on_dm_frame(self, frame):
+ def on_dm_frame(self, frame) -> None:
# TODO: handle all states
pass
- def on_disc_frame(self, _frame):
+ def on_disc_frame(self, _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 +489,10 @@
# Check if there's anything to send (including credits)
self.process_tx()
- def on_ui_frame(self, frame):
+ def on_ui_frame(self, frame) -> None:
pass
- def on_mcc_msc(self, c_r, msc):
+ def on_mcc_msc(self, c_r, msc) -> None:
if c_r:
# Command
logger.debug(f'<<< MCC MSC Command: {msc}')
@@ -477,7 +506,7 @@
# Response
logger.debug(f'<<< MCC MSC Response: {msc}')
- def connect(self):
+ def connect(self) -> None:
if self.state != DLC.INIT:
raise InvalidStateError('invalid state')
@@ -485,7 +514,7 @@
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):
+ def accept(self) -> None:
if self.state != DLC.INIT:
raise InvalidStateError('invalid state')
@@ -503,13 +532,13 @@
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
self.change_state(DLC.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 +576,7 @@
rx_credits_needed = 0
# Stream protocol
- def write(self, data):
+ def write(self, data: Union[bytes, str]) -> None:
# We can only send bytes
if not isinstance(data, bytes):
if isinstance(data, str):
@@ -559,7 +588,7 @@
self.tx_buffer += data
self.process_tx()
- def drain(self):
+ def drain(self) -> None:
# TODO
pass
@@ -592,7 +621,13 @@
RESET: 'RESET',
}
- def __init__(self, l2cap_channel, role):
+ 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: int) -> None:
super().__init__()
self.role = role
self.l2cap_channel = l2cap_channel
@@ -607,20 +642,20 @@
l2cap_channel.sink = self.on_pdu
@staticmethod
- def state_name(state):
+ def state_name(state: int):
return Multiplexer.STATE_NAMES[state]
- def change_state(self, new_state):
+ def change_state(self, new_state: int) -> None:
logger.debug(
f'{self} state change -> {color(self.state_name(new_state), "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,18 +675,18 @@
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):
+ def on_sabm_frame(self, _frame) -> None:
if self.state != Multiplexer.INIT:
logger.debug('not in INIT state, ignoring SABM')
return
self.change_state(Multiplexer.CONNECTED)
self.send_frame(RFCOMM_Frame.ua(c_r=1, dlci=0))
- def on_ua_frame(self, _frame):
+ def on_ua_frame(self, _frame) -> None:
if self.state == Multiplexer.CONNECTING:
self.change_state(Multiplexer.CONNECTED)
if self.connection_result:
@@ -663,7 +698,7 @@
self.disconnection_result.set_result(None)
self.disconnection_result = None
- def on_dm_frame(self, _frame):
+ def on_dm_frame(self, _frame) -> None:
if self.state == Multiplexer.OPENING:
self.change_state(Multiplexer.CONNECTED)
if self.open_result:
@@ -678,13 +713,13 @@
else:
logger.warning(f'unexpected state for DM: {self}')
- def on_disc_frame(self, _frame):
+ def on_disc_frame(self, _frame) -> None:
self.change_state(Multiplexer.DISCONNECTED)
self.send_frame(
RFCOMM_Frame.ua(c_r=0 if self.role == Multiplexer.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,10 +729,10 @@
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) -> None:
pass
- def on_mcc_pn(self, c_r, pn):
+ def on_mcc_pn(self, c_r, pn) -> None:
if c_r == 1:
# Command
logger.debug(f'<<< PN Command: {pn}')
@@ -736,14 +771,14 @@
else:
logger.warning('ignoring PN response')
- def on_mcc_msc(self, c_r, msc):
+ def on_mcc_msc(self, c_r, 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):
+ async def connect(self) -> None:
if self.state != Multiplexer.INIT:
raise InvalidStateError('invalid state')
@@ -752,7 +787,7 @@
self.send_frame(RFCOMM_Frame.sabm(c_r=1, dlci=0))
return await self.connection_result
- async def disconnect(self):
+ async def disconnect(self) -> None:
if self.state != Multiplexer.CONNECTED:
return
@@ -765,7 +800,7 @@
)
await self.disconnection_result
- async def open_dlc(self, channel):
+ async def open_dlc(self, channel: int) -> DLC:
if self.state != Multiplexer.CONNECTED:
if self.state == Multiplexer.OPENING:
raise InvalidStateError('open already in progress')
@@ -796,7 +831,7 @@
self.open_result = None
return result
- def on_dlc_open_complete(self, dlc):
+ def on_dlc_open_complete(self, dlc: DLC):
logger.debug(f'DLC [{dlc.dlci}] open complete')
self.change_state(Multiplexer.CONNECTED)
if self.open_result:
@@ -808,13 +843,16 @@
# -----------------------------------------------------------------------------
class Client:
- def __init__(self, device, connection):
+ multiplexer: Optional[Multiplexer]
+ l2cap_channel: Optional[l2cap.Channel]
+
+ def __init__(self, device, 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,6 +862,7 @@
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)
@@ -832,7 +871,9 @@
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 +884,9 @@
# -----------------------------------------------------------------------------
class Server(EventEmitter):
- def __init__(self, device):
+ acceptors: Dict[int, Callable[[DLC], None]]
+
+ def __init__(self, device) -> None:
super().__init__()
self.device = device
self.multiplexer = None
@@ -852,7 +895,7 @@
# Register ourselves with the L2CAP channel manager
device.register_l2cap_server(RFCOMM_PSM, self.on_connection)
- def listen(self, acceptor, channel=0):
+ def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int:
if channel:
if channel in self.acceptors:
# Busy
@@ -874,11 +917,11 @@
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
@@ -889,10 +932,10 @@
# 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/smp.py b/bumble/smp.py
index f3fbf27..c93ee9c 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,
@@ -553,20 +554,16 @@
# -----------------------------------------------------------------------------
-class Session:
- # Pairing methods
+class PairingMethod(enum.IntEnum):
JUST_WORKS = 0
NUMERIC_COMPARISON = 1
PASSKEY = 2
OOB = 3
+ CTKD_OVER_CLASSIC = 4
- PAIRING_METHOD_NAMES = {
- JUST_WORKS: 'JUST_WORKS',
- NUMERIC_COMPARISON: 'NUMERIC_COMPARISON',
- PASSKEY: 'PASSKEY',
- OOB: 'OOB',
- }
+# -----------------------------------------------------------------------------
+class Session:
# I/O Capability to pairing method decision matrix
#
# See Bluetooth spec @ Vol 3, part H - Table 2.8: Mapping of IO Capabilities to Key
@@ -581,47 +578,50 @@
# (False).
PAIRING_METHODS = {
SMP_DISPLAY_ONLY_IO_CAPABILITY: {
- SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
- SMP_DISPLAY_YES_NO_IO_CAPABILITY: JUST_WORKS,
- SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
- SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
- SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PASSKEY, True, False),
+ SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+ SMP_DISPLAY_YES_NO_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+ SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
+ SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+ SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
},
SMP_DISPLAY_YES_NO_IO_CAPABILITY: {
- SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
- SMP_DISPLAY_YES_NO_IO_CAPABILITY: (JUST_WORKS, NUMERIC_COMPARISON),
- SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
- SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
+ SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+ SMP_DISPLAY_YES_NO_IO_CAPABILITY: (
+ PairingMethod.JUST_WORKS,
+ PairingMethod.NUMERIC_COMPARISON,
+ ),
+ SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
+ SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (
- (PASSKEY, True, False),
- NUMERIC_COMPARISON,
+ (PairingMethod.PASSKEY, True, False),
+ PairingMethod.NUMERIC_COMPARISON,
),
},
SMP_KEYBOARD_ONLY_IO_CAPABILITY: {
- SMP_DISPLAY_ONLY_IO_CAPABILITY: (PASSKEY, False, True),
- SMP_DISPLAY_YES_NO_IO_CAPABILITY: (PASSKEY, False, True),
- SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, False, False),
- SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
- SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PASSKEY, False, True),
+ SMP_DISPLAY_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
+ SMP_DISPLAY_YES_NO_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
+ SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, False),
+ SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+ SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
},
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: {
- SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
- SMP_DISPLAY_YES_NO_IO_CAPABILITY: JUST_WORKS,
- SMP_KEYBOARD_ONLY_IO_CAPABILITY: JUST_WORKS,
- SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
- SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: JUST_WORKS,
+ SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+ SMP_DISPLAY_YES_NO_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+ SMP_KEYBOARD_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+ SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
+ SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
},
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: {
- SMP_DISPLAY_ONLY_IO_CAPABILITY: (PASSKEY, False, True),
+ SMP_DISPLAY_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (
- (PASSKEY, False, True),
- NUMERIC_COMPARISON,
+ (PairingMethod.PASSKEY, False, True),
+ PairingMethod.NUMERIC_COMPARISON,
),
- SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
- SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
+ SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
+ SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (
- (PASSKEY, True, False),
- NUMERIC_COMPARISON,
+ (PairingMethod.PASSKEY, True, False),
+ PairingMethod.NUMERIC_COMPARISON,
),
},
}
@@ -664,7 +664,7 @@
self.passkey_ready = asyncio.Event()
self.passkey_step = 0
self.passkey_display = False
- self.pairing_method = 0
+ self.pairing_method: PairingMethod = PairingMethod.JUST_WORKS
self.pairing_config = pairing_config
self.wait_before_continuing: Optional[asyncio.Future[None]] = None
self.completed = False
@@ -769,19 +769,23 @@
def decide_pairing_method(
self, auth_req: int, initiator_io_capability: int, responder_io_capability: int
) -> None:
+ if self.connection.transport == BT_BR_EDR_TRANSPORT:
+ self.pairing_method = PairingMethod.CTKD_OVER_CLASSIC
+ return
if (not self.mitm) and (auth_req & SMP_MITM_AUTHREQ == 0):
- self.pairing_method = self.JUST_WORKS
+ self.pairing_method = PairingMethod.JUST_WORKS
return
details = self.PAIRING_METHODS[initiator_io_capability][responder_io_capability] # type: ignore[index]
if isinstance(details, tuple) and len(details) == 2:
# One entry for legacy pairing and one for secure connections
details = details[1 if self.sc else 0]
- if isinstance(details, int):
+ if isinstance(details, PairingMethod):
# Just a method ID
self.pairing_method = details
else:
# PASSKEY method, with a method ID and display/input flags
+ assert isinstance(details[0], PairingMethod)
self.pairing_method = details[0]
self.passkey_display = details[1 if self.is_initiator else 2]
@@ -858,10 +862,13 @@
self.tk = self.passkey.to_bytes(16, byteorder='little')
logger.debug(f'TK from passkey = {self.tk.hex()}')
- self.connection.abort_on(
- 'disconnection',
- self.pairing_config.delegate.display_number(self.passkey, digits=6),
- )
+ try:
+ self.connection.abort_on(
+ 'disconnection',
+ self.pairing_config.delegate.display_number(self.passkey, digits=6),
+ )
+ except Exception as error:
+ logger.warning(f'exception while displaying number: {error}')
def input_passkey(self, next_steps: Optional[Callable[[], None]] = None) -> None:
# Prompt the user for the passkey displayed on the peer
@@ -929,9 +936,12 @@
if self.sc:
async def next_steps() -> None:
- if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
+ if self.pairing_method in (
+ PairingMethod.JUST_WORKS,
+ PairingMethod.NUMERIC_COMPARISON,
+ ):
z = 0
- elif self.pairing_method == self.PASSKEY:
+ elif self.pairing_method == PairingMethod.PASSKEY:
# We need a passkey
await self.passkey_ready.wait()
assert self.passkey
@@ -983,6 +993,19 @@
)
)
+ def send_identity_address_command(self) -> None:
+ identity_address = {
+ None: self.connection.self_address,
+ Address.PUBLIC_DEVICE_ADDRESS: self.manager.device.public_address,
+ Address.RANDOM_DEVICE_ADDRESS: self.manager.device.random_address,
+ }[self.pairing_config.identity_address_type]
+ self.send_command(
+ SMP_Identity_Address_Information_Command(
+ addr_type=identity_address.address_type,
+ bd_addr=identity_address,
+ )
+ )
+
def start_encryption(self, key: bytes) -> None:
# We can now encrypt the connection with the short term key, so that we can
# distribute the long term and/or other keys over an encrypted connection
@@ -1006,6 +1029,7 @@
self.ltk = crypto.h6(ilk, b'brle')
def distribute_keys(self) -> None:
+
# Distribute the keys as required
if self.is_initiator:
# CTKD: Derive LTK from LinkKey
@@ -1035,12 +1059,7 @@
identity_resolving_key=self.manager.device.irk
)
)
- self.send_command(
- SMP_Identity_Address_Information_Command(
- addr_type=self.connection.self_address.address_type,
- bd_addr=self.connection.self_address,
- )
- )
+ self.send_identity_address_command()
# Distribute CSRK
csrk = bytes(16) # FIXME: testing
@@ -1084,12 +1103,7 @@
identity_resolving_key=self.manager.device.irk
)
)
- self.send_command(
- SMP_Identity_Address_Information_Command(
- addr_type=self.connection.self_address.address_type,
- bd_addr=self.connection.self_address,
- )
- )
+ self.send_identity_address_command()
# Distribute CSRK
csrk = bytes(16) # FIXME: testing
@@ -1224,7 +1238,7 @@
# Create an object to hold the keys
keys = PairingKeys()
keys.address_type = peer_address.address_type
- authenticated = self.pairing_method != self.JUST_WORKS
+ authenticated = self.pairing_method != PairingMethod.JUST_WORKS
if self.sc or self.connection.transport == BT_BR_EDR_TRANSPORT:
keys.ltk = PairingKeys.Key(value=self.ltk, authenticated=authenticated)
else:
@@ -1300,7 +1314,11 @@
self, command: SMP_Pairing_Request_Command
) -> None:
# Check if the request should proceed
- accepted = await self.pairing_config.delegate.accept()
+ try:
+ accepted = await self.pairing_config.delegate.accept()
+ except Exception as error:
+ logger.warning(f'exception while accepting: {error}')
+ accepted = False
if not accepted:
logger.debug('pairing rejected by delegate')
self.send_pairing_failed(SMP_PAIRING_NOT_SUPPORTED_ERROR)
@@ -1323,9 +1341,7 @@
self.decide_pairing_method(
command.auth_req, command.io_capability, self.io_capability
)
- logger.debug(
- f'pairing method: {self.PAIRING_METHOD_NAMES[self.pairing_method]}'
- )
+ logger.debug(f'pairing method: {self.pairing_method.name}')
# Key distribution
(
@@ -1341,7 +1357,7 @@
# Display a passkey if we need to
if not self.sc:
- if self.pairing_method == self.PASSKEY and self.passkey_display:
+ if self.pairing_method == PairingMethod.PASSKEY and self.passkey_display:
self.display_passkey()
# Respond
@@ -1382,9 +1398,7 @@
self.decide_pairing_method(
command.auth_req, self.io_capability, command.io_capability
)
- logger.debug(
- f'pairing method: {self.PAIRING_METHOD_NAMES[self.pairing_method]}'
- )
+ logger.debug(f'pairing method: {self.pairing_method.name}')
# Key distribution
if (
@@ -1400,13 +1414,16 @@
self.compute_peer_expected_distributions(self.responder_key_distribution)
# Start phase 2
- if self.sc:
- if self.pairing_method == self.PASSKEY:
+ if self.pairing_method == PairingMethod.CTKD_OVER_CLASSIC:
+ # Authentication is already done in SMP, so remote shall start keys distribution immediately
+ return
+ elif self.sc:
+ if self.pairing_method == PairingMethod.PASSKEY:
self.display_or_input_passkey()
self.send_public_key_command()
else:
- if self.pairing_method == self.PASSKEY:
+ if self.pairing_method == PairingMethod.PASSKEY:
self.display_or_input_passkey(self.send_pairing_confirm_command)
else:
self.send_pairing_confirm_command()
@@ -1418,7 +1435,10 @@
self.send_pairing_random_command()
else:
# If the method is PASSKEY, now is the time to input the code
- if self.pairing_method == self.PASSKEY and not self.passkey_display:
+ if (
+ self.pairing_method == PairingMethod.PASSKEY
+ and not self.passkey_display
+ ):
self.input_passkey(self.send_pairing_confirm_command)
else:
self.send_pairing_confirm_command()
@@ -1426,11 +1446,14 @@
def on_smp_pairing_confirm_command_secure_connections(
self, _: SMP_Pairing_Confirm_Command
) -> None:
- if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
+ if self.pairing_method in (
+ PairingMethod.JUST_WORKS,
+ PairingMethod.NUMERIC_COMPARISON,
+ ):
if self.is_initiator:
self.r = crypto.r()
self.send_pairing_random_command()
- elif self.pairing_method == self.PASSKEY:
+ elif self.pairing_method == PairingMethod.PASSKEY:
if self.is_initiator:
self.send_pairing_random_command()
else:
@@ -1486,13 +1509,16 @@
def on_smp_pairing_random_command_secure_connections(
self, command: SMP_Pairing_Random_Command
) -> None:
- if self.pairing_method == self.PASSKEY and self.passkey is None:
+ if self.pairing_method == PairingMethod.PASSKEY and self.passkey is None:
logger.warning('no passkey entered, ignoring command')
return
# pylint: disable=too-many-return-statements
if self.is_initiator:
- if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
+ if self.pairing_method in (
+ PairingMethod.JUST_WORKS,
+ PairingMethod.NUMERIC_COMPARISON,
+ ):
assert self.confirm_value
# Check that the random value matches what was committed to earlier
confirm_verifier = crypto.f4(
@@ -1502,7 +1528,7 @@
self.confirm_value, confirm_verifier, SMP_CONFIRM_VALUE_FAILED_ERROR
):
return
- elif self.pairing_method == self.PASSKEY:
+ elif self.pairing_method == PairingMethod.PASSKEY:
assert self.passkey and self.confirm_value
# Check that the random value matches what was committed to earlier
confirm_verifier = crypto.f4(
@@ -1525,9 +1551,12 @@
else:
return
else:
- if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
+ if self.pairing_method in (
+ PairingMethod.JUST_WORKS,
+ PairingMethod.NUMERIC_COMPARISON,
+ ):
self.send_pairing_random_command()
- elif self.pairing_method == self.PASSKEY:
+ elif self.pairing_method == PairingMethod.PASSKEY:
assert self.passkey and self.confirm_value
# Check that the random value matches what was committed to earlier
confirm_verifier = crypto.f4(
@@ -1558,10 +1587,13 @@
(mac_key, self.ltk) = crypto.f5(self.dh_key, self.na, self.nb, a, b)
# Compute the DH Key checks
- if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
+ if self.pairing_method in (
+ PairingMethod.JUST_WORKS,
+ PairingMethod.NUMERIC_COMPARISON,
+ ):
ra = bytes(16)
rb = ra
- elif self.pairing_method == self.PASSKEY:
+ elif self.pairing_method == PairingMethod.PASSKEY:
assert self.passkey
ra = self.passkey.to_bytes(16, byteorder='little')
rb = ra
@@ -1585,13 +1617,16 @@
self.wait_before_continuing.set_result(None)
# Prompt the user for confirmation if needed
- if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
+ if self.pairing_method in (
+ PairingMethod.JUST_WORKS,
+ PairingMethod.NUMERIC_COMPARISON,
+ ):
# Compute the 6-digit code
code = crypto.g2(self.pka, self.pkb, self.na, self.nb) % 1000000
# Ask for user confirmation
self.wait_before_continuing = asyncio.get_running_loop().create_future()
- if self.pairing_method == self.JUST_WORKS:
+ if self.pairing_method == PairingMethod.JUST_WORKS:
self.prompt_user_for_confirmation(next_steps)
else:
self.prompt_user_for_numeric_comparison(code, next_steps)
@@ -1628,13 +1663,16 @@
if self.is_initiator:
self.send_pairing_confirm_command()
else:
- if self.pairing_method == self.PASSKEY:
+ if self.pairing_method == PairingMethod.PASSKEY:
self.display_or_input_passkey()
# Send our public key back to the initiator
self.send_public_key_command()
- if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
+ if self.pairing_method in (
+ PairingMethod.JUST_WORKS,
+ PairingMethod.NUMERIC_COMPARISON,
+ ):
# We can now send the confirmation value
self.send_pairing_confirm_command()
@@ -1805,7 +1843,7 @@
self.device.abort_on('flush', store_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/usb.py b/bumble/transport/usb.py
index 68c5a6f..13cad60 100644
--- a/bumble/transport/usb.py
+++ b/bumble/transport/usb.py
@@ -206,10 +206,11 @@
logger.debug('OUT transfer likely already completed')
class UsbPacketSource(asyncio.Protocol, ParserSource):
- def __init__(self, context, device, acl_in, events_in):
+ def __init__(self, context, device, metadata, acl_in, events_in):
super().__init__()
self.context = context
self.device = device
+ self.metadata = metadata
self.acl_in = acl_in
self.events_in = events_in
self.loop = asyncio.get_running_loop()
@@ -510,6 +511,10 @@
f'events_in=0x{events_in:02X}, '
)
+ device_metadata = {
+ 'vendor_id': found.getVendorID(),
+ 'product_id': found.getProductID(),
+ }
device = found.open()
# Auto-detach the kernel driver if supported
@@ -535,7 +540,7 @@
except usb1.USBError:
logger.warning('failed to set configuration')
- source = UsbPacketSource(context, device, acl_in, events_in)
+ source = UsbPacketSource(context, device, device_metadata, acl_in, events_in)
sink = UsbPacketSink(device, acl_out)
return UsbTransport(context, device, interface, setting, source, sink)
except usb1.USBError as error:
diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml
index 0ddc982..0cf65f1 100644
--- a/docs/mkdocs/mkdocs.yml
+++ b/docs/mkdocs/mkdocs.yml
@@ -36,6 +36,9 @@
- HCI Socket: transports/hci_socket.md
- Android Emulator: transports/android_emulator.md
- File: transports/file.md
+ - Drivers:
+ - Overview: drivers/index.md
+ - Realtek: drivers/realtek.md
- API:
- Guide: api/guide.md
- Examples: api/examples.md
@@ -44,6 +47,7 @@
- Overview: apps_and_tools/index.md
- Console: apps_and_tools/console.md
- Bench: apps_and_tools/bench.md
+ - Speaker: apps_and_tools/speaker.md
- HCI Bridge: apps_and_tools/hci_bridge.md
- Golden Gate Bridge: apps_and_tools/gg_bridge.md
- Show: apps_and_tools/show.md
diff --git a/docs/mkdocs/src/apps_and_tools/index.md b/docs/mkdocs/src/apps_and_tools/index.md
index fe7af56..0c2b4d5 100644
--- a/docs/mkdocs/src/apps_and_tools/index.md
+++ b/docs/mkdocs/src/apps_and_tools/index.md
@@ -11,4 +11,5 @@
* [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets
* [Golden Gate Bridge](gg_bridge.md) - a bridge between GATT and UDP to use with the Golden Gate "stack tool"
* [Show](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form
+ * [Speaker](speaker.md) - Virtual Bluetooth speaker, with a command line and browser-based UI.
* [Link Relay](link_relay.md) - WebSocket relay for virtual RemoteLink instances to communicate with each other.
diff --git a/docs/mkdocs/src/apps_and_tools/speaker.md b/docs/mkdocs/src/apps_and_tools/speaker.md
new file mode 100644
index 0000000..5569b9d
--- /dev/null
+++ b/docs/mkdocs/src/apps_and_tools/speaker.md
@@ -0,0 +1,86 @@
+SPEAKER APP
+===========
+
+![logo](../images/speaker_screenshot.png){ width=400 height=320 }
+
+The Speaker app is virtual Bluetooth speaker (A2DP sink).
+The app runs as a command-line executable, but also offers an optional simple
+web-browser-based user interface.
+
+# General Usage
+You can invoke the app either as `bumble-speaker` when installed as command
+from `pip`, or `python3 apps/speaker/speaker.py` when running from a source
+distribution.
+
+```
+Usage: speaker.py [OPTIONS] TRANSPORT
+
+ Run the speaker.
+
+Options:
+ --codec [sbc|aac] [default: aac]
+ --discover Discover remote endpoints once connected
+ --output NAME Send audio to this named output (may be used more
+ than once for multiple outputs)
+ --ui-port HTTP_PORT HTTP port for the UI server [default: 7654]
+ --connect ADDRESS_OR_NAME Address or name to connect to
+ --device-config FILENAME Device configuration file
+ --help Show this message and exit.
+```
+
+# Connection
+By default, the virtual speaker will wait for another device (like a phone or
+computer) to connect to it (and possibly pair). Alternatively, the speaker can
+be told to initiate a connection to a remote device, using the `--connect`
+option.
+
+# Outputs
+The speaker can have one or more outputs. By default, the only output is a text
+display on the console, as well as a browser-based user interface if connected.
+In addition, a file output can be used, in which case the received audio data is
+saved to a specified file.
+Finally, if the host computer on which your are running the application has `ffplay`
+as an available command line executable, the `@ffplay` output can be selected, in
+which case the received audio will be played on the computer's builtin speakers via
+a pipe to `ffplay`. (see the [ffplay documentation](https://www.ffmpeg.org/ffplay.html)
+for details)
+
+# Web User Interface
+When the speaker app starts, it prints out on the console the local URL at which you
+may point a browser (Chrome recommended for full functionality). The console line
+specifying the local UI URL will look like:
+```
+UI HTTP server at http://127.0.0.1:7654
+```
+
+By default, the web UI will show the status of the connection, as well as a realtime
+graph of the received audio bandwidth.
+In order to also hear the received audio, you need to click the `Audio on` button
+(this is due to the fact that most browsers will require some user interface with the
+page before granting access to the audio output APIs).
+
+# Examples
+
+In the following examples, we use a single USB Bluetooth controllers `usb:0`. Other
+transports can be used of course.
+
+!!! example "Start the speaker and wait for a connection"
+ ```
+ $ bumble-speaker usb:0
+ ```
+
+!!! example "Start the speaker and save the AAC audio to a file named `audio.aac`."
+ ```
+ $ bumble-speaker --output audio.aac usb:0
+ ```
+
+!!! example "Start the speaker and save the SBC audio to a file named `audio.sbc`."
+ ```
+ $ bumble-speaker --codec sbc --output audio.sbc usb:0
+ ```
+
+!!! example "Start the speaker and connect it to a phone at address `B8:7B:C5:05:57:ED`."
+ ```
+ $ bumble-speaker --connect B8:7B:C5:05:57:ED usb:0
+ ```
+
diff --git a/docs/mkdocs/src/drivers/index.md b/docs/mkdocs/src/drivers/index.md
new file mode 100644
index 0000000..a904e00
--- /dev/null
+++ b/docs/mkdocs/src/drivers/index.md
@@ -0,0 +1,10 @@
+DRIVERS
+=======
+
+Some Bluetooth controllers require a driver to function properly.
+This may include, for instance, loading a Firmware image or patch,
+loading a configuration.
+
+Drivers included in the module are:
+
+ * [Realtek](realtek.md): Loading of Firmware and Config for Realtek USB dongles.
\ No newline at end of file
diff --git a/docs/mkdocs/src/drivers/realtek.md b/docs/mkdocs/src/drivers/realtek.md
new file mode 100644
index 0000000..acbce49
--- /dev/null
+++ b/docs/mkdocs/src/drivers/realtek.md
@@ -0,0 +1,62 @@
+REALTEK DRIVER
+==============
+
+This driver supports loading firmware images and optional config data to
+USB dongles with a Realtek chipset.
+A number of USB dongles are supported, but likely not all.
+When using a USB dongle, the USB product ID and manufacturer ID are used
+to find whether a matching set of firmware image and config data
+is needed for that specific model. If a match exists, the driver will try
+load the firmware image and, if needed, config data.
+The driver will look for those files by name, in order, in:
+
+ * The directory specified by the environment variable `BUMBLE_RTK_FIRMWARE_DIR`
+ if set.
+ * The directory `<package-dir>/drivers/rtk_fw` where `<package-dir>` is the directory
+ where the `bumble` package is installed.
+ * The current directory.
+
+
+Obtaining Firmware Images and Config Data
+-----------------------------------------
+
+Firmware images and config data may be obtained from a variety of online
+sources.
+To facilitate finding a downloading the, the utility program `bumble-rtk-fw-download`
+may be used.
+
+```
+Usage: bumble-rtk-fw-download [OPTIONS]
+
+ Download RTK firmware images and configs.
+
+Options:
+ --output-dir TEXT Output directory where the files will be
+ saved [default: .]
+ --source [linux-kernel|realtek-opensource|linux-from-scratch]
+ [default: linux-kernel]
+ --single TEXT Only download a single image set, by its
+ base name
+ --force Overwrite files if they already exist
+ --parse Parse the FW image after saving
+ --help Show this message and exit.
+```
+
+Utility
+-------
+
+The `bumble-rtk-util` utility may be used to interact with a Realtek USB dongle
+and/or firmware images.
+
+```
+Usage: bumble-rtk-util [OPTIONS] COMMAND [ARGS]...
+
+Options:
+ --help Show this message and exit.
+
+Commands:
+ drop Drop a firmware image from the USB dongle.
+ info Get the firmware info from a USB dongle.
+ load Load a firmware image into the USB dongle.
+ parse Parse a firmware image.
+```
\ No newline at end of file
diff --git a/docs/mkdocs/src/images/speaker_screenshot.png b/docs/mkdocs/src/images/speaker_screenshot.png
new file mode 100644
index 0000000..fd34880
--- /dev/null
+++ b/docs/mkdocs/src/images/speaker_screenshot.png
Binary files differ
diff --git a/docs/mkdocs/src/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/examples/run_classic_connect.py b/examples/run_classic_connect.py
index bb46bf7..3ae6ed8 100644
--- a/examples/run_classic_connect.py
+++ b/examples/run_classic_connect.py
@@ -23,7 +23,7 @@
from bumble.device import Device
from bumble.transport import open_transport_or_link
-from bumble.core import BT_BR_EDR_TRANSPORT, BT_L2CAP_PROTOCOL_ID
+from bumble.core import BT_BR_EDR_TRANSPORT, BT_L2CAP_PROTOCOL_ID, CommandTimeoutError
from bumble.sdp import (
Client as SDP_Client,
SDP_PUBLIC_BROWSE_ROOT,
@@ -48,62 +48,70 @@
# Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
device.classic_enabled = True
+ device.le_enabled = False
await device.power_on()
- async def connect(target_address):
- print(f'=== Connecting to {target_address}...')
- connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
- print(f'=== Connected to {connection.peer_address}!')
-
- # Connect to the SDP Server
- sdp_client = SDP_Client(device)
- await sdp_client.connect(connection)
-
- # List all services in the root browse group
- service_record_handles = await sdp_client.search_services(
- [SDP_PUBLIC_BROWSE_ROOT]
- )
- print(color('\n==================================', 'blue'))
- print(color('SERVICES:', 'yellow'), service_record_handles)
-
- # For each service in the root browse group, get all its attributes
- for service_record_handle in service_record_handles:
- attributes = await sdp_client.get_attributes(
- service_record_handle, [SDP_ALL_ATTRIBUTES_RANGE]
- )
- print(color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow'))
- for attribute in attributes:
- print(' ', attribute.to_string(with_colors=True))
-
- # Search for services with an L2CAP service attribute
- search_result = await sdp_client.search_attributes(
- [BT_L2CAP_PROTOCOL_ID], [SDP_ALL_ATTRIBUTES_RANGE]
- )
- print(color('\n==================================', 'blue'))
- print(color('SEARCH RESULTS:', 'yellow'))
- for attribute_list in search_result:
- print(color('SERVICE:', 'green'))
- print(
- ' '
- + '\n '.join(
- [
- attribute.to_string(with_colors=True)
- for attribute in attribute_list
- ]
+ async def connect(target_address):
+ print(f'=== Connecting to {target_address}...')
+ try:
+ connection = await device.connect(
+ target_address, transport=BT_BR_EDR_TRANSPORT
)
+ except CommandTimeoutError:
+ print('!!! Connection timed out')
+ return
+ print(f'=== Connected to {connection.peer_address}!')
+
+ # Connect to the SDP Server
+ sdp_client = SDP_Client(device)
+ await sdp_client.connect(connection)
+
+ # List all services in the root browse group
+ service_record_handles = await sdp_client.search_services(
+ [SDP_PUBLIC_BROWSE_ROOT]
)
+ print(color('\n==================================', 'blue'))
+ print(color('SERVICES:', 'yellow'), service_record_handles)
- await sdp_client.disconnect()
- await hci_source.wait_for_termination()
+ # For each service in the root browse group, get all its attributes
+ for service_record_handle in service_record_handles:
+ attributes = await sdp_client.get_attributes(
+ service_record_handle, [SDP_ALL_ATTRIBUTES_RANGE]
+ )
+ print(
+ color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow')
+ )
+ for attribute in attributes:
+ print(' ', attribute.to_string(with_colors=True))
- # Connect to a peer
- target_addresses = sys.argv[3:]
- await asyncio.wait(
- [
- asyncio.create_task(connect(target_address))
- for target_address in target_addresses
- ]
- )
+ # Search for services with an L2CAP service attribute
+ search_result = await sdp_client.search_attributes(
+ [BT_L2CAP_PROTOCOL_ID], [SDP_ALL_ATTRIBUTES_RANGE]
+ )
+ print(color('\n==================================', 'blue'))
+ print(color('SEARCH RESULTS:', 'yellow'))
+ for attribute_list in search_result:
+ print(color('SERVICE:', 'green'))
+ print(
+ ' '
+ + '\n '.join(
+ [
+ attribute.to_string(with_colors=True)
+ for attribute in attribute_list
+ ]
+ )
+ )
+
+ await sdp_client.disconnect()
+
+ # Connect to a peer
+ target_addresses = sys.argv[3:]
+ await asyncio.wait(
+ [
+ asyncio.create_task(connect(target_address))
+ for target_address in target_addresses
+ ]
+ )
# -----------------------------------------------------------------------------
diff --git a/examples/run_scanner.py b/examples/run_scanner.py
index bdd7fba..4a094b9 100644
--- a/examples/run_scanner.py
+++ b/examples/run_scanner.py
@@ -62,7 +62,7 @@
print(
f'>>> {color(advertisement.address, address_color)} '
f'[{color(address_type_string, type_color)}]'
- f'{address_qualifier}:{separator}RSSI:{advertisement.rssi}'
+ f'{address_qualifier}:{separator}RSSI: {advertisement.rssi}'
f'{separator}'
f'{advertisement.data.to_string(separator)}'
)
diff --git a/examples/speaker.json b/examples/speaker.json
new file mode 100644
index 0000000..61ce80d
--- /dev/null
+++ b/examples/speaker.json
@@ -0,0 +1,5 @@
+{
+ "name": "Bumble Speaker",
+ "class_of_device": 2360324,
+ "keystore": "JsonKeyStore"
+}
diff --git a/rust/.gitignore b/rust/.gitignore
new file mode 100644
index 0000000..40d9aca
--- /dev/null
+++ b/rust/.gitignore
@@ -0,0 +1,2 @@
+/target
+/.idea
\ No newline at end of file
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
new file mode 100644
index 0000000..1492cfb
--- /dev/null
+++ b/rust/Cargo.lock
@@ -0,0 +1,1235 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "addr2line"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4fa78e18c64fce05e902adecd7a5eed15a5e0a3439f7b0e169f0252214865e3"
+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.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is-terminal",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd"
+
+[[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 = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188"
+dependencies = [
+ "anstyle",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.72"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854"
+
+[[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.68"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4319208da049c43661739c5fade2ba182f09d1dc2299b32298d3a31692b17e12"
+dependencies = [
+ "addr2line",
+ "cc",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+]
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42"
+
+[[package]]
+name = "bumble"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap 4.3.17",
+ "env_logger",
+ "hex",
+ "itertools",
+ "lazy_static",
+ "log",
+ "nix",
+ "nom",
+ "owo-colors",
+ "pyo3",
+ "pyo3-asyncio",
+ "rand",
+ "rusb",
+ "strum",
+ "strum_macros",
+ "tempfile",
+ "thiserror",
+ "tokio",
+]
+
+[[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.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f"
+
+[[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.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b0827b011f6f8ab38590295339817b0d26f344aa4932c3ced71b45b0c54b4a9"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+ "once_cell",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9441b403be87be858db6a23edb493e7f694761acdc3343d5a0fcaafd304cbc9e"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex 0.5.0",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54a9bb5758fc5dfe728d1019941681eccaf0cf8a4189b692a0ee2f2ecf90a050"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.26",
+]
+
+[[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.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
+
+[[package]]
+name = "either"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91"
+
+[[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.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a"
+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 = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
+dependencies = [
+ "instant",
+]
+
+[[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.26",
+]
+
+[[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.27.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e"
+
+[[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 = "humantime"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
+
+[[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 = "instant"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "inventory"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25b1d6b4b9fb75fc419bdef998b689df5080a32931cb3395b86202046b56a9ea"
+
+[[package]]
+name = "io-lifetimes"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2"
+dependencies = [
+ "hermit-abi 0.3.2",
+ "libc",
+ "windows-sys",
+]
+
+[[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 0.38.4",
+ "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 = "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 = "linux-raw-sys"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0"
+
+[[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.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4"
+
+[[package]]
+name = "memchr"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
+
+[[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 = "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 = "nix"
+version = "0.26.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfdda3d196821d6af13126e40375cdf7da646a96114af134d5f417a9a1dc8e1a"
+dependencies = [
+ "bitflags 1.3.2",
+ "cfg-if",
+ "libc",
+ "memoffset 0.7.1",
+ "pin-utils",
+ "static_assertions",
+]
+
+[[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.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bda667d9f2b5051b8833f59f3bf748b28ef54f850f4fcb389a252aa383866d1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
+
+[[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",
+ "smallvec",
+ "windows-targets",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c40d25201921e5ff0c862a505c6557ea88568a4e3ace775ab55e93f2f4f9d57"
+
+[[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.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fe8a65d69dd0808184ebb5f836ab526bb259db23c657efa38711b1072ee47f0"
+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.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "regex"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39354c10dd07468c2e73926b23bb9c2caca74c5501e38a35da70406f1d923310"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2"
+
+[[package]]
+name = "rusb"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44a8c36914f9b1a3be712c1dfa48c9b397131f9a75707e570a391735f785c5d1"
+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.37.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d69718bf81c6127a49dc64e44a742e8bb9213c0ff8869a22c308f84c1d4ab06"
+dependencies = [
+ "bitflags 1.3.2",
+ "errno",
+ "io-lifetimes",
+ "libc",
+ "linux-raw-sys 0.3.8",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a962918ea88d644592894bc6dc55acc6c0956488adcebbfb6e273506b7fd6e5"
+dependencies = [
+ "bitflags 2.3.3",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.3",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[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.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d"
+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 = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[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.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6069ca09d878a33f883cc06aaa9718ede171841d3832450354410b718b097232"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 2.0.26",
+]
+
+[[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.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45c3457aacde3c65315de5031ec191ce46604304d2446e803d71ade03308d970"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d2faeef5759ab89935255b1a4cd98e0baf99d1085e37d36599c625dac49ae8e"
+
+[[package]]
+name = "tempfile"
+version = "3.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31c0432476357e58790aaa47a8efb0c5138f137343f3b5f23bd36a27e3b0a6d6"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "fastrand",
+ "redox_syscall",
+ "rustix 0.37.23",
+ "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.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a35fc5b8971143ca348fa6df4f024d4d55264f3468c71ad1c2f365b0a4d58c42"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "463fe12d7993d3b327787537ce8dd4dfa058de32fc2b195ef3cde03dc4771e8f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.26",
+]
+
+[[package]]
+name = "tokio"
+version = "1.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "532826ff75199d5833b9d2c5fe410f29235e25704ee5f0ef599fb51c21f4a4da"
+dependencies = [
+ "autocfg",
+ "backtrace",
+ "bytes",
+ "libc",
+ "mio",
+ "num_cpus",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "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.26",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c"
+
+[[package]]
+name = "unindent"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c"
+
+[[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 = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[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.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f"
+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.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a"
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
new file mode 100644
index 0000000..8b7c723
--- /dev/null
+++ b/rust/Cargo.toml
@@ -0,0 +1,49 @@
+[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.69.0"
+
+[dependencies]
+pyo3 = { version = "0.18.3", features = ["macros"] }
+pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime"] }
+tokio = { version = "1.28.2" }
+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-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"] }
+clap = { version = "4.3.3", features = ["derive"] }
+owo-colors = "3.5.0"
+log = "0.4.19"
+env_logger = "0.10.0"
+rusb = "0.9.2"
+rand = "0.8.5"
+
+# 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"]
\ No newline at end of file
diff --git a/rust/README.md b/rust/README.md
new file mode 100644
index 0000000..fd591c7
--- /dev/null
+++ b/rust/README.md
@@ -0,0 +1,42 @@
+# 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 `examples` directory for usage.
+
+# 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.
+
+# Development
+
+Run the tests:
+
+```
+PYTHONPATH=.. cargo test
+```
+
+Check lints:
+
+```
+cargo clippy --all-targets
+```
\ 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/examples/usb_probe.rs b/rust/examples/usb_probe.rs
new file mode 100644
index 0000000..3ba3b61
--- /dev/null
+++ b/rust/examples/usb_probe.rs
@@ -0,0 +1,342 @@
+// 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 clap::Parser as _;
+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;
+
+fn main() -> anyhow::Result<()> {
+ let cli = Cli::parse();
+
+ 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 cli.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(),
+ )
+ }
+}
+
+#[derive(clap::Parser)]
+#[command(author, version, about, long_about = None)]
+struct Cli {
+ /// Show additional info for each USB device
+ #[arg(long, default_value_t = false)]
+ verbose: bool,
+}
diff --git a/rust/pytests/pytests.rs b/rust/pytests/pytests.rs
new file mode 100644
index 0000000..da331f3
--- /dev/null
+++ b/rust/pytests/pytests.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.
+
+#[pyo3_asyncio::tokio::main]
+async fn main() -> pyo3::PyResult<()> {
+ pyo3_asyncio::testing::main().await
+}
+
+mod wrapper;
diff --git a/rust/pytests/wrapper.rs b/rust/pytests/wrapper.rs
new file mode 100644
index 0000000..1c1f9d0
--- /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, wrapper::transport::Transport};
+use nix::sys::stat::Mode;
+use pyo3::prelude::*;
+
+#[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 company_ids() -> PyResult<()> {
+ assert!(wrapper::assigned_numbers::COMPANY_IDS.len() > 2000);
+ Ok(())
+}
diff --git a/rust/src/adv.rs b/rust/src/adv.rs
new file mode 100644
index 0000000..8a4c979
--- /dev/null
+++ b/rust/src/adv.rs
@@ -0,0 +1,446 @@
+//! 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/lib.rs b/rust/src/lib.rs
new file mode 100644
index 0000000..73001e6
--- /dev/null
+++ b/rust/src/lib.rs
@@ -0,0 +1,31 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! 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;
diff --git a/rust/src/wrapper/assigned_numbers/mod.rs b/rust/src/wrapper/assigned_numbers/mod.rs
new file mode 100644
index 0000000..becdc11
--- /dev/null
+++ b/rust/src/wrapper/assigned_numbers/mod.rs
@@ -0,0 +1,53 @@
+// 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.
+
+use crate::wrapper::core::Uuid16;
+use lazy_static::lazy_static;
+use pyo3::{
+ intern,
+ types::{PyDict, PyModule},
+ PyResult, Python,
+};
+use std::collections;
+
+mod services;
+
+pub use services::SERVICE_IDS;
+
+lazy_static! {
+ /// Assigned company IDs
+ pub static ref COMPANY_IDS: collections::HashMap<Uuid16, String> = load_company_ids()
+ .expect("Could not load company ids -- are Bumble's Python sources available?");
+
+}
+
+fn load_company_ids() -> PyResult<collections::HashMap<Uuid16, String>> {
+ // this takes about 4ms on a fast machine -- slower than constructing in rust, but not slow
+ // enough to worry about
+ 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<_, _>>>()
+ })
+}
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..a55760d
--- /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)]
+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..d635754
--- /dev/null
+++ b/rust/src/wrapper/device.rs
@@ -0,0 +1,248 @@
+// 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,
+ transport::{Sink, Source},
+ ClosureCallback,
+ },
+};
+use pyo3::types::PyDict;
+use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject};
+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| pyo3_asyncio::tokio::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| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+ })?
+ .await
+ .map(Connection)
+ }
+
+ /// 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| pyo3_asyncio::tokio::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(|_| ())
+ }
+
+ /// 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| pyo3_asyncio::tokio::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| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+ })?
+ .await
+ .map(|_| ())
+ }
+}
+
+/// A connection to a remote device.
+pub struct Connection(PyObject);
+
+/// 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| pyo3_asyncio::tokio::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| {
+ if obj.is_none(py) {
+ None
+ } else {
+ Some(P::wrap(obj))
+ }
+ })
+ })
+ }
+}
+
+/// 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/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..48f7dc1
--- /dev/null
+++ b/rust/src/wrapper/hci.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.
+
+//! HCI
+
+use itertools::Itertools as _;
+use pyo3::{exceptions::PyException, intern, types::PyModule, PyErr, PyObject, PyResult, Python};
+
+/// 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/logging.rs b/rust/src/wrapper/logging.rs
new file mode 100644
index 0000000..141cc04
--- /dev/null
+++ b/rust/src/wrapper/logging.rs
@@ -0,0 +1,27 @@
+//! 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..2ab71c3
--- /dev/null
+++ b/rust/src/wrapper/mod.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.
+
+//! 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 gatt_client;
+pub mod hci;
+pub mod logging;
+pub mod profile;
+pub mod transport;
+
+/// Convenience extensions to [PyObject]
+pub trait PyObjectExt {
+ /// 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>())
+ }
+}
+
+impl PyObjectExt for PyObject {
+ fn gil_ref<'py>(&'py self, py: Python<'py>) -> &'py PyAny {
+ self.as_ref(py)
+ }
+}
+
+/// 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..854ba80
--- /dev/null
+++ b/rust/src/wrapper/profile.rs
@@ -0,0 +1,47 @@
+// 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};
+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| {
+ if level.is_none(py) {
+ None
+ } else {
+ Some(CharacteristicProxy(level))
+ }
+ })
+ })
+ }
+}
+
+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/setup.cfg b/setup.cfg
index 45c7264..a7a09d6 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -24,28 +24,30 @@
[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 =
+ aiohttp ~= 3.8; platform_system!='Emscripten'
appdirs >= 1.4
- click >= 7.1.2; platform_system!='Emscripten'
+ bt-test-interfaces >= 0.0.2
+ click == 8.1.3; platform_system!='Emscripten'
cryptography == 35; platform_system!='Emscripten'
grpcio == 1.51.1; platform_system!='Emscripten'
+ humanize >= 4.6.0
libusb1 >= 2.0.1; platform_system!='Emscripten'
libusb-package == 1.0.26.1; platform_system!='Emscripten'
prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
+ prettytable >= 3.6.0
protobuf >= 3.12.4
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 +63,10 @@
bumble-usb-probe = bumble.apps.usb_probe:main
bumble-link-relay = bumble.apps.link_relay.link_relay:main
bumble-bench = bumble.apps.bench:main
+ bumble-speaker = bumble.apps.speaker.speaker:main
bumble-pandora-server = bumble.apps.pandora_server:main
+ bumble-rtk-util = bumble.tools.rtk_util:main
+ bumble-rtk-fw-download = bumble.tools.rtk_fw_download:main
[options.package_data]
* = py.typed, *.pyi
diff --git a/speaker.html b/speaker.html
new file mode 100644
index 0000000..05cc31f
--- /dev/null
+++ b/speaker.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Audio WAV Player</title>
+ </head>
+ <body>
+ <h1>Audio WAV Player</h1>
+ <audio id="audioPlayer" controls>
+ <source src="" type="audio/wav">
+ </audio>
+
+ <script>
+ const audioPlayer = document.getElementById('audioPlayer');
+ const ws = new WebSocket('ws://localhost:8080');
+
+ let mediaSource = new MediaSource();
+ audioPlayer.src = URL.createObjectURL(mediaSource);
+
+ mediaSource.addEventListener('sourceopen', function(event) {
+ const sourceBuffer = mediaSource.addSourceBuffer('audio/wav');
+
+ ws.onmessage = function(event) {
+ sourceBuffer.appendBuffer(event.data);
+ };
+ });
+ </script>
+ </body>
+</html>
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..dd0277e 100644
--- a/tests/gatt_test.py
+++ b/tests/gatt_test.py
@@ -803,14 +803,14 @@
# -----------------------------------------------------------------------------
def test_char_property_to_string():
# single
- assert str(Characteristic.Properties(0x01)) == "Properties.BROADCAST"
- assert str(Characteristic.Properties.BROADCAST) == "Properties.BROADCAST"
+ assert str(Characteristic.Properties(0x01)) == "BROADCAST"
+ assert str(Characteristic.Properties.BROADCAST) == "BROADCAST"
# double
- assert str(Characteristic.Properties(0x03)) == "Properties.READ|BROADCAST"
+ assert str(Characteristic.Properties(0x03)) == "BROADCAST|READ"
assert (
str(Characteristic.Properties.BROADCAST | Characteristic.Properties.READ)
- == "Properties.READ|BROADCAST"
+ == "BROADCAST|READ"
)
@@ -831,6 +831,10 @@
Characteristic.Properties.from_string("READ,BROADCAST")
== Characteristic.Properties.BROADCAST | Characteristic.Properties.READ
)
+ assert (
+ Characteristic.Properties.from_string("BROADCAST|READ")
+ == Characteristic.Properties.BROADCAST | Characteristic.Properties.READ
+ )
# -----------------------------------------------------------------------------
@@ -841,7 +845,7 @@
assert (
str(e_info.value)
== """Characteristic.Properties::from_string() error:
-Expected a string containing any of the keys, separated by commas: BROADCAST,READ,WRITE_WITHOUT_RESPONSE,WRITE,NOTIFY,INDICATE,AUTHENTICATED_SIGNED_WRITES,EXTENDED_PROPERTIES
+Expected a string containing any of the keys, separated by , or |: BROADCAST,READ,WRITE_WITHOUT_RESPONSE,WRITE,NOTIFY,INDICATE,AUTHENTICATED_SIGNED_WRITES,EXTENDED_PROPERTIES
Got: BROADCAST,HELLO"""
)
@@ -866,13 +870,13 @@
assert (
str(server.gatt_server)
== """Service(handle=0x0001, end=0x0005, uuid=UUID-16:1800 (Generic Access))
-CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ)
-Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ)
-CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ)
-Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ)
+CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), READ)
+Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), READ)
+CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), READ)
+Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), READ)
Service(handle=0x0006, end=0x0009, uuid=3A657F47-D34F-46B3-B1EC-698E29B6B829)
-CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ)
-Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ)
+CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY)
+Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY)
Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)"""
)
diff --git a/tests/hci_test.py b/tests/hci_test.py
index af68e86..c648592 100644
--- a/tests/hci_test.py
+++ b/tests/hci_test.py
@@ -46,6 +46,7 @@
HCI_LE_Set_Advertising_Parameters_Command,
HCI_LE_Set_Default_PHY_Command,
HCI_LE_Set_Event_Mask_Command,
+ HCI_LE_Set_Extended_Advertising_Enable_Command,
HCI_LE_Set_Extended_Scan_Parameters_Command,
HCI_LE_Set_Random_Address_Command,
HCI_LE_Set_Scan_Enable_Command,
@@ -423,6 +424,25 @@
# -----------------------------------------------------------------------------
+def test_HCI_LE_Set_Extended_Advertising_Enable_Command():
+ command = HCI_Packet.from_bytes(
+ bytes.fromhex('0139200e010301050008020600090307000a')
+ )
+ assert command.enable == 1
+ assert command.advertising_handles == [1, 2, 3]
+ assert command.durations == [5, 6, 7]
+ assert command.max_extended_advertising_events == [8, 9, 10]
+
+ command = HCI_LE_Set_Extended_Advertising_Enable_Command(
+ enable=1,
+ advertising_handles=[1, 2, 3],
+ durations=[5, 6, 7],
+ max_extended_advertising_events=[8, 9, 10],
+ )
+ basic_check(command)
+
+
+# -----------------------------------------------------------------------------
def test_address():
a = Address('C4:F2:17:1A:1D:BB')
assert not a.is_public
@@ -478,6 +498,7 @@
test_HCI_LE_Read_Remote_Features_Command()
test_HCI_LE_Set_Default_PHY_Command()
test_HCI_LE_Set_Extended_Scan_Parameters_Command()
+ test_HCI_LE_Set_Extended_Advertising_Enable_Command()
# -----------------------------------------------------------------------------
diff --git a/tests/keystore_test.py b/tests/keystore_test.py
new file mode 100644
index 0000000..2e73039
--- /dev/null
+++ b/tests/keystore_test.py
@@ -0,0 +1,179 @@
+# 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 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"
+ }
+ }
+ }
+ }
+ """
+
+
+# -----------------------------------------------------------------------------
+async def test_basic():
+ with tempfile.NamedTemporaryFile(mode="r+", encoding='utf-8') as file:
+ keystore = JsonKeyStore('my_namespace', file.name)
+ file.write("{}")
+ file.flush()
+
+ 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
+
+ file.flush()
+ 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():
+ with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
+ keystore = JsonKeyStore('my_namespace', file.name)
+ file.write(JSON1)
+ file.flush()
+
+ 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():
+ with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
+ keystore = JsonKeyStore(None, file.name)
+ file.write(JSON1)
+ file.flush()
+
+ 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 tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
+ keystore = JsonKeyStore(None, file.name)
+ file.write(JSON2)
+ file.flush()
+
+ 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)
+ file.flush()
+ 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 tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
+ keystore = JsonKeyStore(None, file.name)
+ file.write(JSON3)
+ file.flush()
+
+ 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/self_test.py b/tests/self_test.py
index 1a1a474..4c35045 100644
--- a/tests/self_test.py
+++ b/tests/self_test.py
@@ -21,6 +21,8 @@
import os
import pytest
+from unittest.mock import MagicMock, patch
+
from bumble.controller import Controller
from bumble.core import BT_BR_EDR_TRANSPORT, BT_PERIPHERAL_ROLE, BT_CENTRAL_ROLE
from bumble.link import LocalLink
@@ -34,6 +36,8 @@
SMP_CONFIRM_VALUE_FAILED_ERROR,
)
from bumble.core import ProtocolError
+from bumble.hci import HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE
+from bumble.keys import PairingKeys
# -----------------------------------------------------------------------------
@@ -474,6 +478,105 @@
# -----------------------------------------------------------------------------
+@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
+
+ paired = [
+ asyncio.get_event_loop().create_future(),
+ asyncio.get_event_loop().create_future(),
+ ]
+
+ def on_pairing(which: int, keys: PairingKeys):
+ paired[which].set_result(keys)
+
+ two_devices.connections[0].on('pairing', lambda keys: on_pairing(0, keys))
+ two_devices.connections[1].on('pairing', lambda keys: on_pairing(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(*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 +584,8 @@
await test_self_smp()
await test_self_smp_reject()
await test_self_smp_wrong_pin()
+ await test_self_smp_over_classic()
+ await test_self_smp_public_address()
# -----------------------------------------------------------------------------
diff --git a/tools/__init__.py b/tools/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tools/__init__.py
diff --git a/utils/generate_company_id_list.py b/tools/generate_company_id_list.py
similarity index 100%
rename from utils/generate_company_id_list.py
rename to tools/generate_company_id_list.py
diff --git a/tools/rtk_fw_download.py b/tools/rtk_fw_download.py
new file mode 100644
index 0000000..b027141
--- /dev/null
+++ b/tools/rtk_fw_download.py
@@ -0,0 +1,149 @@
+# 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",
+ 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
+ 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..7452915
--- /dev/null
+++ b/tools/rtk_util.py
@@ -0,0 +1,161 @@
+# 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:
+ if not force:
+ 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()