diff --git a/.vscode/settings.json b/.vscode/settings.json index 57e682a..04a7f40 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -47,6 +47,7 @@ "protobuf", "psms", "pyee", + "Pyodide", "pyusb", "rfcomm", "ROHC", diff --git a/bumble/device.py b/bumble/device.py index 7f11012..45e919d 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -1308,7 +1308,7 @@ class Device(CompositeEventEmitter): self.host.send_command(command, check_result), self.command_timeout ) except asyncio.TimeoutError as error: - logger.warning('!!! Command timed out') + logger.warning(f'!!! Command {command.name} timed out') raise CommandTimeoutError() from error async def power_on(self) -> None: @@ -1409,6 +1409,9 @@ class Device(CompositeEventEmitter): # Done self.powered_on = True + async def reset(self) -> None: + await self.host.reset() + async def power_off(self) -> None: if self.powered_on: await self.host.flush() diff --git a/bumble/profiles/heart_rate_service.py b/bumble/profiles/heart_rate_service.py index c7d3018..fe46cb2 100644 --- a/bumble/profiles/heart_rate_service.py +++ b/bumble/profiles/heart_rate_service.py @@ -42,12 +42,12 @@ class HeartRateService(TemplateService): RESET_ENERGY_EXPENDED = 0x01 class BodySensorLocation(IntEnum): - OTHER = (0,) - CHEST = (1,) - WRIST = (2,) - FINGER = (3,) - HAND = (4,) - EAR_LOBE = (5,) + OTHER = 0 + CHEST = 1 + WRIST = 2 + FINGER = 3 + HAND = 4 + EAR_LOBE = 5 FOOT = 6 class HeartRateMeasurement: diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml index 67bdd7c..84e2515 100644 --- a/docs/mkdocs/mkdocs.yml +++ b/docs/mkdocs/mkdocs.yml @@ -10,7 +10,7 @@ nav: - Contributing: development/contributing.md - Code Style: development/code_style.md - Use Cases: - - Overview: use_cases/index.md + - use_cases/index.md - Use Case 1: use_cases/use_case_1.md - Use Case 2: use_cases/use_case_2.md - Use Case 3: use_cases/use_case_3.md @@ -23,7 +23,7 @@ nav: - GATT: components/gatt.md - Security Manager: components/security_manager.md - Transports: - - Overview: transports/index.md + - transports/index.md - Serial: transports/serial.md - USB: transports/usb.md - PTY: transports/pty.md @@ -37,14 +37,14 @@ nav: - Android Emulator: transports/android_emulator.md - File: transports/file.md - Drivers: - - Overview: drivers/index.md + - drivers/index.md - Realtek: drivers/realtek.md - API: - Guide: api/guide.md - Examples: api/examples.md - Reference: api/reference.md - Apps & Tools: - - Overview: apps_and_tools/index.md + - apps_and_tools/index.md - Console: apps_and_tools/console.md - Bench: apps_and_tools/bench.md - Speaker: apps_and_tools/speaker.md @@ -57,19 +57,24 @@ nav: - USB Probe: apps_and_tools/usb_probe.md - Link Relay: apps_and_tools/link_relay.md - Hardware: - - Overview: hardware/index.md + - hardware/index.md - Platforms: - - Overview: platforms/index.md + - platforms/index.md - macOS: platforms/macos.md - Linux: platforms/linux.md - Windows: platforms/windows.md - Android: platforms/android.md - Zephyr: platforms/zephyr.md - Examples: - - Overview: examples/index.md + - examples/index.md - Extras: - - Overview: extras/index.md + - extras/index.md - Android Remote HCI: extras/android_remote_hci.md + - Hive: + - hive/index.md + - Speaker: hive/web/speaker/speaker.html + - Scanner: hive/web/scanner/scanner.html + - Heart Rate Monitor: hive/web/heart_rate_monitor/heart_rate_monitor.html copyright: Copyright 2021-2023 Google LLC @@ -78,6 +83,8 @@ theme: logo: 'images/logo.png' favicon: 'images/favicon.ico' custom_dir: 'theme' + features: + - navigation.indexes plugins: - mkdocstrings: @@ -102,6 +109,8 @@ markdown_extensions: - pymdownx.emoji: emoji_index: !!python/name:materialx.emoji.twemoji emoji_generator: !!python/name:materialx.emoji.to_svg + - pymdownx.tabbed: + alternate_style: true - codehilite: guess_lang: false - toc: diff --git a/docs/mkdocs/src/hive/index.md b/docs/mkdocs/src/hive/index.md new file mode 100644 index 0000000..0b6ca2c --- /dev/null +++ b/docs/mkdocs/src/hive/index.md @@ -0,0 +1,59 @@ +HIVE +==== + +Welcome to the Bumble Hive. +This is a collection of apps and virtual devices that can run entirely in a browser page. +The code for the apps and devices, as well as the Bumble runtime code, runs via [Pyodide](https://pyodide.org/). +Pyodide is a Python distribution for the browser and Node.js based on WebAssembly. + +The Bumble stack uses a WebSocket to exchange HCI packets with a virtual or physical +Bluetooth controller. + +The apps and devices in the hive can be accessed by following the links below. Each +page has a settings button that may be used to configure the WebSocket URL to use for +the virtual HCI connection. This will typically be the WebSocket URL for a `netsim` +daemon. +There is also a [TOML index](index.toml) that can be used by tools to know at which URL to access +each of the apps and devices, as well as their names and short descriptions. + +!!! tip "Using `netsim`" + When the `netsimd` daemon is running (for example when using the Android Emulator that + is included in Android Studio), the daemon listens for connections on a TCP port. + To find out what this TCP port is, you can read the `netsim.ini` file that `netsimd` + creates, it includes a line with `web.port=` (for example `web.port=7681`). + The location of the `netsim.ini` file is platform-specific. + + === "macOS" + On macOS, the directory where `netsim.ini` is stored is $TMPDIR + ```bash + $ cat $TMPDIR/netsim.ini + ``` + + === "Linux" + On Linux, the directory where `netsim.ini` is stored is $XDG_RUNTIME_DIR + ```bash + $ cat $XDG_RUNTIME_DIR/netsim.ini + ``` + + +!!! tip "Using a local radio" + You can connect the hive virtual apps and devices to a local Bluetooth radio, like, + for example, a USB dongle. + For that, you need to run a local HCI bridge to bridge a local HCI device to a WebSocket + that a web page can connect to. + Use the `bumble-hci-bridge` app, with the host transport set to a WebSocket server on an + available port (ex: `ws-server:_:7682`) and the controller transport set to the transport + name for the radio you want to use (ex: `usb:0` for the first USB dongle) + + +Applications +------------ + + * [Scanner](web/scanner/scanner.html) - Scans for BLE devices. + +Virtual Devices +--------------- + + * [Speaker](web/speaker/speaker.html) - Virtual speaker that plays audio in a browser page. + * [Heart Rate Monitor](web/heart_rate_monitor/heart_rate_monitor.html) - Virtual heart rate monitor. + diff --git a/docs/mkdocs/src/hive/index.toml b/docs/mkdocs/src/hive/index.toml new file mode 100644 index 0000000..5b187e3 --- /dev/null +++ b/docs/mkdocs/src/hive/index.toml @@ -0,0 +1,21 @@ +version = "1.0.0" +base_url = "https://google.github.io/bumble/hive/web" +default_hci_query_param = "hci" + +[[index]] +name = "speaker" +description = "Bumble Virtual Speaker" +type = "Device" +url = "speaker/speaker.html" + +[[index]] +name = "scanner" +description = "Simple Scanner Application" +type = "Application" +url = "scanner/scanner.html" + +[[index]] +name = "heart-rate-monitor" +description = "Virtual Heart Rate Monitor" +type = "Device" +url = "heart_rate_monitor/heart_rate_monitor.html" diff --git a/docs/mkdocs/src/hive/web/bumble.js b/docs/mkdocs/src/hive/web/bumble.js new file mode 120000 index 0000000..237a974 --- /dev/null +++ b/docs/mkdocs/src/hive/web/bumble.js @@ -0,0 +1 @@ +../../../../../web/bumble.js \ No newline at end of file diff --git a/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.html b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.html new file mode 120000 index 0000000..ef6e18a --- /dev/null +++ b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.html @@ -0,0 +1 @@ +../../../../../../web/heart_rate_monitor/heart_rate_monitor.html \ No newline at end of file diff --git a/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.js b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.js new file mode 120000 index 0000000..1d1dc8b --- /dev/null +++ b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.js @@ -0,0 +1 @@ +../../../../../../web/heart_rate_monitor/heart_rate_monitor.js \ No newline at end of file diff --git a/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.py b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.py new file mode 120000 index 0000000..cb0f459 --- /dev/null +++ b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.py @@ -0,0 +1 @@ +../../../../../../web/heart_rate_monitor/heart_rate_monitor.py \ No newline at end of file diff --git a/docs/mkdocs/src/hive/web/scanner/scanner.css b/docs/mkdocs/src/hive/web/scanner/scanner.css new file mode 120000 index 0000000..acc0f9e --- /dev/null +++ b/docs/mkdocs/src/hive/web/scanner/scanner.css @@ -0,0 +1 @@ +../../../../../../web/scanner/scanner.css \ No newline at end of file diff --git a/docs/mkdocs/src/hive/web/scanner/scanner.html b/docs/mkdocs/src/hive/web/scanner/scanner.html new file mode 120000 index 0000000..fda63fc --- /dev/null +++ b/docs/mkdocs/src/hive/web/scanner/scanner.html @@ -0,0 +1 @@ +../../../../../../web/scanner/scanner.html \ No newline at end of file diff --git a/docs/mkdocs/src/hive/web/scanner/scanner.js b/docs/mkdocs/src/hive/web/scanner/scanner.js new file mode 120000 index 0000000..4d270f4 --- /dev/null +++ b/docs/mkdocs/src/hive/web/scanner/scanner.js @@ -0,0 +1 @@ +../../../../../../web/scanner/scanner.js \ No newline at end of file diff --git a/docs/mkdocs/src/hive/web/scanner/scanner.py b/docs/mkdocs/src/hive/web/scanner/scanner.py new file mode 120000 index 0000000..4ae502a --- /dev/null +++ b/docs/mkdocs/src/hive/web/scanner/scanner.py @@ -0,0 +1 @@ +../../../../../../web/scanner/scanner.py \ No newline at end of file diff --git a/docs/mkdocs/src/hive/web/speaker/logo.svg b/docs/mkdocs/src/hive/web/speaker/logo.svg new file mode 120000 index 0000000..0da5d27 --- /dev/null +++ b/docs/mkdocs/src/hive/web/speaker/logo.svg @@ -0,0 +1 @@ +../../../../../../web/speaker/logo.svg \ No newline at end of file diff --git a/docs/mkdocs/src/hive/web/speaker/speaker.css b/docs/mkdocs/src/hive/web/speaker/speaker.css new file mode 120000 index 0000000..046d971 --- /dev/null +++ b/docs/mkdocs/src/hive/web/speaker/speaker.css @@ -0,0 +1 @@ +../../../../../../web/speaker/speaker.css \ No newline at end of file diff --git a/docs/mkdocs/src/hive/web/speaker/speaker.html b/docs/mkdocs/src/hive/web/speaker/speaker.html new file mode 120000 index 0000000..9acaa1d --- /dev/null +++ b/docs/mkdocs/src/hive/web/speaker/speaker.html @@ -0,0 +1 @@ +../../../../../../web/speaker/speaker.html \ No newline at end of file diff --git a/docs/mkdocs/src/hive/web/speaker/speaker.js b/docs/mkdocs/src/hive/web/speaker/speaker.js new file mode 120000 index 0000000..2ebaf50 --- /dev/null +++ b/docs/mkdocs/src/hive/web/speaker/speaker.js @@ -0,0 +1 @@ +../../../../../../web/speaker/speaker.js \ No newline at end of file diff --git a/docs/mkdocs/src/hive/web/speaker/speaker.py b/docs/mkdocs/src/hive/web/speaker/speaker.py new file mode 120000 index 0000000..1d6e95e --- /dev/null +++ b/docs/mkdocs/src/hive/web/speaker/speaker.py @@ -0,0 +1 @@ +../../../../../../web/speaker/speaker.py \ No newline at end of file diff --git a/docs/mkdocs/src/hive/web/ui.js b/docs/mkdocs/src/hive/web/ui.js new file mode 120000 index 0000000..71419c3 --- /dev/null +++ b/docs/mkdocs/src/hive/web/ui.js @@ -0,0 +1 @@ +../../../../../web/ui.js \ No newline at end of file diff --git a/docs/mkdocs/src/index.md b/docs/mkdocs/src/index.md index c81f7ff..aae6e54 100644 --- a/docs/mkdocs/src/index.md +++ b/docs/mkdocs/src/index.md @@ -152,11 +152,23 @@ Some platforms support features that not all platforms support See the [Platforms page](platforms/index.md) for details. + +Hive +---- + +The Hive is a collection of example apps and virtual devices that are implemented using the +Python Bumble API, running entirely in a web page. This is a convenient way to try out some +of the examples without any Python installation, when you have some other virtual Bluetooth +device that you can connect to or from, such as the Android Emulator. + +See the [Bumble Hive](hive/index.md) for details. + Roadmap ------- Future features to be considered include: + * More profiles * More device examples * Add a new type of virtual link (beyond the two existing ones) to allow for link-level simulation (timing, loss, etc) * Bindings for languages other than Python diff --git a/docs/mkdocs/src/transports/android_emulator.md b/docs/mkdocs/src/transports/android_emulator.md index 974ba4f..becff54 100644 --- a/docs/mkdocs/src/transports/android_emulator.md +++ b/docs/mkdocs/src/transports/android_emulator.md @@ -14,7 +14,7 @@ connections. ## Moniker The moniker syntax for an Android Emulator "netsim" transport is: `android-netsim:[:][]`, -where `` is a ','-separated list of `=` pairs`. +where `` is a comma-separated list of `=` pairs. The `mode` parameter name can specify running as a host or a controller, and `:` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator (in "host" mode), or to accept gRPC connections (in "controller" mode). Both the `mode=` and `:` parameters are optional (so the moniker `android-netsim` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the Netsim background process). diff --git a/examples/heart_rate_server.py b/examples/heart_rate_server.py index 32f53b1..fad809f 100644 --- a/examples/heart_rate_server.py +++ b/examples/heart_rate_server.py @@ -29,6 +29,7 @@ from bumble.device import Device from bumble.transport import open_transport_or_link from bumble.profiles.device_information_service import DeviceInformationService from bumble.profiles.heart_rate_service import HeartRateService +from bumble.utils import AsyncRunner # ----------------------------------------------------------------------------- @@ -98,6 +99,17 @@ async def main(): ) ) + # Notify subscribers of the current value as soon as they subscribe + @heart_rate_service.heart_rate_measurement_characteristic.on('subscription') + def on_subscription(connection, notify_enabled, indicate_enabled): + if notify_enabled or indicate_enabled: + AsyncRunner.spawn( + device.notify_subscriber( + connection, + heart_rate_service.heart_rate_measurement_characteristic, + ) + ) + # Go! await device.power_on() await device.start_advertising(auto_restart=True) diff --git a/tasks.py b/tasks.py index 6df5a8b..fab7cf1 100644 --- a/tasks.py +++ b/tasks.py @@ -125,7 +125,7 @@ def lint(ctx, disable='C,R', errors_only=False): print(f">>> Running the linter{qualifier}...") try: ctx.run(f"pylint {' '.join(options)} bumble apps examples tasks.py") - print("The linter is happy. ✅ 😊 🐝'") + print("The linter is happy. ✅ 😊 🐝") except UnexpectedExit as exc: print("Please check your code against the linter messages. ❌") raise Exit(code=1) from exc diff --git a/web/bumble.js b/web/bumble.js index b1243a5..cb807eb 100644 --- a/web/bumble.js +++ b/web/bumble.js @@ -5,11 +5,11 @@ function bufferToHex(buffer) { class PacketSource { constructor(pyodide) { this.parser = pyodide.runPython(` - from bumble.transport.common import PacketParser - class ProxiedPacketParser(PacketParser): - def feed_data(self, js_data): - super().feed_data(bytes(js_data.to_py())) - ProxiedPacketParser() + from bumble.transport.common import PacketParser + class ProxiedPacketParser(PacketParser): + def feed_data(self, js_data): + super().feed_data(bytes(js_data.to_py())) + ProxiedPacketParser() `); } @@ -18,74 +18,171 @@ class PacketSource { } data_received(data) { - console.log(`HCI[controller->host]: ${bufferToHex(data)}`); + //console.log(`HCI[controller->host]: ${bufferToHex(data)}`); this.parser.feed_data(data); } } class PacketSink { - constructor(writer) { - this.writer = writer; - } - on_packet(packet) { + if (!this.writer) { + return; + } const buffer = packet.toJs({create_proxies : false}); packet.destroy(); - console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`); + //console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`); // TODO: create an async queue here instead of blindly calling write without awaiting this.writer(buffer); } } -export async function connectWebSocketTransport(pyodide, hciWsUrl) { - return new Promise((resolve, reject) => { - let resolved = false; - - let ws = new WebSocket(hciWsUrl); - ws.binaryType = "arraybuffer"; - - ws.onopen = () => { - console.log("WebSocket open"); - resolve({ - packet_source, - packet_sink - }); - resolved = true; - } - - ws.onclose = () => { - console.log("WebSocket close"); - if (!resolved) { - reject(`Failed to connect to ${hciWsUrl}`) - } - } - - ws.onmessage = (event) => { - packet_source.data_received(event.data); - } - - const packet_source = new PacketSource(pyodide); - const packet_sink = new PacketSink((packet) => ws.send(packet)); - }) +class LogEvent extends Event { + constructor(message) { + super('log'); + this.message = message; + } } -export async function loadBumble(pyodide, bumblePackage) { - // Load the Bumble module - await pyodide.loadPackage("micropip"); - await pyodide.runPythonAsync(` - import micropip - await micropip.install("${bumblePackage}") - package_list = micropip.list() - print(package_list) - `) +export class Bumble extends EventTarget { + constructor(pyodide) { + super(); + this.pyodide = pyodide; + } - // Mount a filesystem so that we can persist data like the Key Store - let mountDir = "/bumble"; - pyodide.FS.mkdir(mountDir); - pyodide.FS.mount(pyodide.FS.filesystems.IDBFS, { root: "." }, mountDir); + async loadRuntime(bumblePackage) { + // Load pyodide if it isn't provided. + if (this.pyodide === undefined) { + this.log('Loading Pyodide'); + this.pyodide = await loadPyodide(); + } - // Sync previously persisted filesystem data into memory - pyodide.FS.syncfs(true, () => { - console.log("FS synced in") - }); + // Load the Bumble module + bumblePackage ||= 'bumble'; + console.log('Installing micropip'); + this.log(`Installing ${bumblePackage}`) + await this.pyodide.loadPackage('micropip'); + await this.pyodide.runPythonAsync(` + import micropip + await micropip.install('${bumblePackage}') + package_list = micropip.list() + print(package_list) + `) + + // Mount a filesystem so that we can persist data like the Key Store + let mountDir = '/bumble'; + this.pyodide.FS.mkdir(mountDir); + this.pyodide.FS.mount(this.pyodide.FS.filesystems.IDBFS, { root: '.' }, mountDir); + + // Sync previously persisted filesystem data into memory + await new Promise(resolve => { + this.pyodide.FS.syncfs(true, () => { + console.log('FS synced in'); + resolve(); + }); + }) + + // Setup the HCI source and sink + this.packetSource = new PacketSource(this.pyodide); + this.packetSink = new PacketSink(); + } + + log(message) { + this.dispatchEvent(new LogEvent(message)); + } + + async connectWebSocketTransport(hciWsUrl) { + return new Promise((resolve, reject) => { + let resolved = false; + + let ws = new WebSocket(hciWsUrl); + ws.binaryType = 'arraybuffer'; + + ws.onopen = () => { + this.log('WebSocket open'); + resolve(); + resolved = true; + } + + ws.onclose = () => { + this.log('WebSocket close'); + if (!resolved) { + reject(`Failed to connect to ${hciWsUrl}`); + } + } + + ws.onmessage = (event) => { + this.packetSource.data_received(event.data); + } + + this.packetSink.writer = (packet) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(packet); + } + } + this.closeTransport = async () => { + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + } + }) + } + + async loadApp(appUrl) { + this.log('Loading app'); + const script = await (await fetch(appUrl)).text(); + await this.pyodide.runPythonAsync(script); + const pythonMain = this.pyodide.globals.get('main'); + const app = await pythonMain(this.packetSource, this.packetSink); + if (app.on) { + app.on('key_store_update', this.onKeystoreUpdate.bind(this)); + } + this.log('App is ready!'); + return app; + } + + onKeystoreUpdate() { + // Sync the FS + this.pyodide.FS.syncfs(() => { + console.log('FS synced out'); + }); + } +} + +export async function setupSimpleApp(appUrl, bumbleControls, log) { + // Load Bumble + log('Loading Bumble'); + const bumble = new Bumble(); + bumble.addEventListener('log', (event) => { + log(event.message); + }) + const params = (new URL(document.location)).searchParams; + await bumble.loadRuntime(params.get('package')); + + log('Bumble is ready!') + const app = await bumble.loadApp(appUrl); + + bumbleControls.connector = async (hciWsUrl) => { + try { + // Connect the WebSocket HCI transport + await bumble.connectWebSocketTransport(hciWsUrl); + + // Start the app + await app.start(); + + return true; + } catch (err) { + log(err); + return false; + } + } + bumbleControls.stopper = async () => { + // Stop the app + await app.stop(); + + // Close the HCI transport + await bumble.closeTransport(); + } + bumbleControls.onBumbleLoaded(); + + return app; } \ No newline at end of file diff --git a/web/heart_rate_monitor/heart_rate_monitor.html b/web/heart_rate_monitor/heart_rate_monitor.html new file mode 100644 index 0000000..f44470f --- /dev/null +++ b/web/heart_rate_monitor/heart_rate_monitor.html @@ -0,0 +1,29 @@ + + + + + + + + + + + +
+ + cardiology + + 60 +
+ + +
+
+ + diff --git a/web/heart_rate_monitor/heart_rate_monitor.js b/web/heart_rate_monitor/heart_rate_monitor.js new file mode 100644 index 0000000..468e728 --- /dev/null +++ b/web/heart_rate_monitor/heart_rate_monitor.js @@ -0,0 +1,30 @@ +import {setupSimpleApp} from '../bumble.js'; + +const logOutput = document.querySelector('#log-output'); +function logToOutput(message) { + console.log(message); + logOutput.value += message + '\n'; +} + +let heartRate = 60; +const heartRateText = document.querySelector('#hr-value') + +function setHeartRate(newHeartRate) { + heartRate = newHeartRate; + heartRateText.innerHTML = heartRate; + app.set_heart_rate(heartRate); +} + +// Setup the UI +const bumbleControls = document.querySelector('#bumble-controls'); +document.querySelector('#hr-up-button').addEventListener('click', () => { + setHeartRate(heartRate + 1); +}) +document.querySelector('#hr-down-button').addEventListener('click', () => { + setHeartRate(heartRate - 1); +}) + +// Setup the app +const app = await setupSimpleApp('heart_rate_monitor.py', bumbleControls, logToOutput); +logToOutput('Click the Bluetooth button to start'); + diff --git a/web/heart_rate_monitor/heart_rate_monitor.py b/web/heart_rate_monitor/heart_rate_monitor.py new file mode 100644 index 0000000..4a843b4 --- /dev/null +++ b/web/heart_rate_monitor/heart_rate_monitor.py @@ -0,0 +1,119 @@ +# 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 struct + +from bumble.core import AdvertisingData +from bumble.device import Device +from bumble.hci import HCI_Reset_Command +from bumble.profiles.device_information_service import DeviceInformationService +from bumble.profiles.heart_rate_service import HeartRateService +from bumble.utils import AsyncRunner + + +# ----------------------------------------------------------------------------- +class HeartRateMonitor: + def __init__(self, hci_source, hci_sink): + self.heart_rate = 60 + + self.device = Device.with_hci( + 'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink + ) + + device_information_service = DeviceInformationService( + manufacturer_name='ACME', + model_number='HR-102', + serial_number='7654321', + hardware_revision='1.1.3', + software_revision='2.5.6', + system_id=(0x123456, 0x8877665544), + ) + + self.heart_rate_service = HeartRateService( + read_heart_rate_measurement=lambda _: HeartRateService.HeartRateMeasurement( + heart_rate=self.heart_rate, + sensor_contact_detected=True, + ), + body_sensor_location=HeartRateService.BodySensorLocation.WRIST, + reset_energy_expended=self.reset_energy_expended, + ) + + # Notify subscribers of the current value as soon as they subscribe + @self.heart_rate_service.heart_rate_measurement_characteristic.on( + 'subscription' + ) + def on_subscription(_, notify_enabled, indicate_enabled): + if notify_enabled or indicate_enabled: + self.notify_heart_rate() + + self.device.add_services([device_information_service, self.heart_rate_service]) + + self.device.advertising_data = bytes( + AdvertisingData( + [ + ( + AdvertisingData.FLAGS, + bytes( + [ + AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG + | AdvertisingData.BR_EDR_NOT_SUPPORTED_FLAG + ] + ), + ), + ( + AdvertisingData.COMPLETE_LOCAL_NAME, + bytes('Bumble Heart', 'utf-8'), + ), + ( + AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, + bytes(self.heart_rate_service.uuid), + ), + (AdvertisingData.APPEARANCE, struct.pack(' - - - - - -
-
-
Log Output

- -
+ - +
+
+ - - \ No newline at end of file + diff --git a/web/scanner/scanner.js b/web/scanner/scanner.js new file mode 100644 index 0000000..34d5784 --- /dev/null +++ b/web/scanner/scanner.js @@ -0,0 +1,68 @@ +import {LitElement, html, css} from 'https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js'; +import {setupSimpleApp} from '../bumble.js'; + + class ScanList extends LitElement { + static properties = { + listItems: {state: true}, + }; + + static styles = css` + table, th, td { + padding: 2px; + white-space: pre; + border: 1px solid black; + border-collapse: collapse; + } + `; + + constructor() { + super(); + this.listItems = []; + } + + render() { + if (this.listItems.length === 0) { + return ''; + } + return html` + + + + ${Object.keys(this.listItems[0]).map(i => html``)} + + + + ${this.listItems.map(i => html` + + ${Object.keys(i).map(key => html``)} + + `)} + +
${i}
${i[key]}
+ `; + } +} +customElements.define('scan-list', ScanList); + +const logOutput = document.querySelector('#log-output'); +function logToOutput(message) { + console.log(message); + logOutput.value += message + '\n'; +} + +function onUpdate(scanResults) { + const items = scanResults.toJs({create_proxies : false}).map(entry => ( + { address: entry.address, address_type: entry.address_type, rssi: entry.rssi, data: entry.data } + )); + scanResults.destroy(); + scanList.listItems = items; +} + +// Setup the UI +const scanList = document.querySelector('#scan-list'); +const bumbleControls = document.querySelector('#bumble-controls'); + +// Setup the app +const app = await setupSimpleApp('scanner.py', bumbleControls, logToOutput); +app.on('update', onUpdate); +logToOutput('Click the Bluetooth button to start'); diff --git a/web/scanner/scanner.py b/web/scanner/scanner.py index c0fc456..9ff6aba 100644 --- a/web/scanner/scanner.py +++ b/web/scanner/scanner.py @@ -15,39 +15,59 @@ # ----------------------------------------------------------------------------- # Imports # ----------------------------------------------------------------------------- -import time - from bumble.device import Device +from bumble.hci import HCI_Reset_Command # ----------------------------------------------------------------------------- -class ScanEntry: - def __init__(self, advertisement): - self.address = advertisement.address.to_string(False) - self.address_type = ('Public', 'Random', 'Public Identity', 'Random Identity')[ - advertisement.address.address_type - ] - self.rssi = advertisement.rssi - self.data = advertisement.data.to_string("\n") +class Scanner: + class ScanEntry: + def __init__(self, advertisement): + self.address = advertisement.address.to_string(False) + self.address_type = ( + 'Public', + 'Random', + 'Public Identity', + 'Random Identity', + )[advertisement.address.address_type] + self.rssi = advertisement.rssi + self.data = advertisement.data.to_string('\n') + def __init__(self, hci_source, hci_sink): + super().__init__() + self.device = Device.with_hci( + 'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink + ) + self.scan_entries = {} + self.listeners = {} + self.device.on('advertisement', self.on_advertisement) -# ----------------------------------------------------------------------------- -class ScannerListener(Device.Listener): - def __init__(self, callback): - self.callback = callback - self.entries = {} + async def start(self): + print('### Starting Scanner') + self.scan_entries = {} + self.emit_update() + await self.device.power_on() + await self.device.start_scanning() + print('### Scanner started') + + async def stop(self): + # TODO: replace this once a proper reset is implemented in the lib. + await self.device.host.send_command(HCI_Reset_Command()) + await self.device.power_off() + print('### Scanner stopped') + + def emit_update(self): + if listener := self.listeners.get('update'): + listener(list(self.scan_entries.values())) + + def on(self, event_name, listener): + self.listeners[event_name] = listener def on_advertisement(self, advertisement): - self.entries[advertisement.address] = ScanEntry(advertisement) - self.callback(list(self.entries.values())) + self.scan_entries[advertisement.address] = self.ScanEntry(advertisement) + self.emit_update() # ----------------------------------------------------------------------------- -async def main(hci_source, hci_sink, callback): - print('### Starting Scanner') - device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink) - device.listener = ScannerListener(callback) - await device.power_on() - await device.start_scanning() - - print('### Scanner started') +def main(hci_source, hci_sink): + return Scanner(hci_source, hci_sink) diff --git a/web/speaker/speaker.css b/web/speaker/speaker.css index 988392a..9586054 100644 --- a/web/speaker/speaker.css +++ b/web/speaker/speaker.css @@ -11,7 +11,16 @@ body, h1, h2, h3, h4, h5, h6 { border: none; border-radius: 4px; padding: 8px; - display: inline-block; + display: none; + margin: 4px; +} + +#progressText { + background-color: rgb(179, 208, 146); + border: none; + border-radius: 4px; + padding: 8px; + display: none; margin: 4px; } diff --git a/web/speaker/speaker.html b/web/speaker/speaker.html index a20f084..1a9183d 100644 --- a/web/speaker/speaker.html +++ b/web/speaker/speaker.html @@ -2,13 +2,14 @@ Bumble Speaker - - + + + +

Bumble Virtual Speaker

-
@@ -25,7 +26,8 @@ IDLE NOT CONNECTED
- + +
Audio Frequencies Animation diff --git a/web/speaker/speaker.js b/web/speaker/speaker.js index b94180f..12189a4 100644 --- a/web/speaker/speaker.js +++ b/web/speaker/speaker.js @@ -1,4 +1,4 @@ -import { loadBumble, connectWebSocketTransport } from "../bumble.js"; +import {setupSimpleApp} from '../bumble.js'; (function () { 'use strict'; @@ -8,7 +8,6 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js"; let bytesReceivedText; let streamStateText; let connectionStateText; - let errorText; let audioOnButton; let mediaSource; let sourceBuffer; @@ -19,15 +18,14 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js"; let audioFrequencyData; let packetsReceived = 0; let bytesReceived = 0; - let audioState = "stopped"; - let streamState = "IDLE"; + let audioState = 'stopped'; + let streamState = 'IDLE'; let fftCanvas; let fftCanvasContext; let bandwidthCanvas; let bandwidthCanvasContext; let bandwidthBinCount; let bandwidthBins = []; - let pyodide; const FFT_WIDTH = 800; const FFT_HEIGHT = 256; @@ -44,18 +42,16 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js"; } function initUI() { - audioOnButton = document.getElementById("audioOnButton"); - codecText = document.getElementById("codecText"); - packetsReceivedText = document.getElementById("packetsReceivedText"); - bytesReceivedText = document.getElementById("bytesReceivedText"); - streamStateText = document.getElementById("streamStateText"); - errorText = document.getElementById("errorText"); - connectionStateText = document.getElementById("connectionStateText"); + audioOnButton = document.getElementById('audioOnButton'); + codecText = document.getElementById('codecText'); + packetsReceivedText = document.getElementById('packetsReceivedText'); + bytesReceivedText = document.getElementById('bytesReceivedText'); + streamStateText = document.getElementById('streamStateText'); + connectionStateText = document.getElementById('connectionStateText'); - audioOnButton.onclick = () => startAudio(); + audioOnButton.onclick = startAudio; - codecText.innerText = "AAC"; - setErrorText(""); + codecText.innerText = 'AAC'; requestAnimationFrame(onAnimationFrame); } @@ -68,62 +64,36 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js"; } function initAudioElement() { - audioElement = document.getElementById("audio"); + audioElement = document.getElementById('audio'); audioElement.src = URL.createObjectURL(mediaSource); // audioElement.controls = true; } function initAnalyzer() { - fftCanvas = document.getElementById("fftCanvas"); + fftCanvas = document.getElementById('fftCanvas'); fftCanvas.width = FFT_WIDTH fftCanvas.height = FFT_HEIGHT fftCanvasContext = fftCanvas.getContext('2d'); - fftCanvasContext.fillStyle = "rgb(0, 0, 0)"; + fftCanvasContext.fillStyle = 'rgb(0, 0, 0)'; fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT); - bandwidthCanvas = document.getElementById("bandwidthCanvas"); + bandwidthCanvas = document.getElementById('bandwidthCanvas'); bandwidthCanvas.width = BANDWIDTH_WIDTH bandwidthCanvas.height = BANDWIDTH_HEIGHT bandwidthCanvasContext = bandwidthCanvas.getContext('2d'); - bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)"; + bandwidthCanvasContext.fillStyle = 'rgb(255, 255, 255)'; bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT); } async function initBumble() { - // Load pyodide - console.log("Loading Pyodide"); - pyodide = await loadPyodide(); - - // Load Bumble - console.log("Loading Bumble"); - const params = (new URL(document.location)).searchParams; - const bumblePackage = params.get("package") || "bumble"; - await loadBumble(pyodide, bumblePackage); - - console.log("Ready!") - - const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci"; - try { - // Create a WebSocket HCI transport - let transport - try { - transport = await connectWebSocketTransport(pyodide, hciWsUrl); - } catch (error) { - console.error(error); - setErrorText(error); - return; - } - - // Run the scanner example - const script = await (await fetch("speaker.py")).text(); - await pyodide.runPythonAsync(script); - const pythonMain = pyodide.globals.get("main"); - console.log("Starting speaker..."); - await pythonMain(transport.packet_source, transport.packet_sink, onEvent); - console.log("Speaker running"); - } catch (err) { - console.log(err); - } + const bumbleControls = document.querySelector('#bumble-controls'); + const app = await setupSimpleApp('speaker.py', bumbleControls, console.log); + app.on('start', onStart); + app.on('stop', onStop); + app.on('suspend', onSuspend); + app.on('connection', onConnection); + app.on('disconnection', onDisconnection); + app.on('audio', onAudio); } function startAnalyzer() { @@ -144,15 +114,6 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js"; bandwidthBins = []; } - function setErrorText(message) { - errorText.innerText = message; - if (message.length == 0) { - errorText.style.display = "none"; - } else { - errorText.style.display = "inline-block"; - } - } - function setStreamState(state) { streamState = state; streamStateText.innerText = streamState; @@ -162,7 +123,7 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js"; // FFT if (audioAnalyzer !== undefined) { audioAnalyzer.getByteFrequencyData(audioFrequencyData); - fftCanvasContext.fillStyle = "rgb(0, 0, 0)"; + fftCanvasContext.fillStyle = 'rgb(0, 0, 0)'; fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT); const barCount = audioFrequencyBinCount; const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1; @@ -174,7 +135,7 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js"; } // Bandwidth - bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)"; + 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++) { @@ -188,7 +149,7 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js"; function onMediaSourceOpen() { console.log(this.readyState); - sourceBuffer = mediaSource.addSourceBuffer("audio/aac"); + sourceBuffer = mediaSource.addSourceBuffer('audio/aac'); } function onMediaSourceClose() { @@ -201,41 +162,30 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js"; async function startAudio() { try { - console.log("starting audio..."); + console.log('starting audio...'); audioOnButton.disabled = true; - audioState = "starting"; + audioState = 'starting'; await audioElement.play(); - console.log("audio started"); - audioState = "playing"; + console.log('audio started'); + audioState = 'playing'; startAnalyzer(); } catch (error) { console.error(`play failed: ${error}`); - audioState = "stopped"; + audioState = 'stopped'; audioOnButton.disabled = false; } } - async function onEvent(name, params) { - // Dispatch the message. - const handlerName = `on${name.charAt(0).toUpperCase()}${name.slice(1)}` - const handler = eventHandlers[handlerName]; - if (handler !== undefined) { - handler(params); - } else { - console.warn(`unhandled event: ${name}`) - } - } - function onStart() { - setStreamState("STARTED"); + setStreamState('STARTED'); } function onStop() { - setStreamState("STOPPED"); + setStreamState('STOPPED'); } function onSuspend() { - setStreamState("SUSPENDED"); + setStreamState('SUSPENDED'); } function onConnection(params) { @@ -243,13 +193,13 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js"; } function onDisconnection(params) { - connectionStateText.innerText = "DISCONNECTED"; + connectionStateText.innerText = 'DISCONNECTED'; } function onAudio(python_packet) { const packet = python_packet.toJs({create_proxies : false}); python_packet.destroy(); - if (audioState != "stopped") { + if (audioState != 'stopped') { // Queue the audio packet. sourceBuffer.appendBuffer(packet); } @@ -265,25 +215,7 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js"; } } - function onKeystoreupdate() { - // Sync the FS - pyodide.FS.syncfs(() => { - console.log("FS synced out") - }); - } - - const eventHandlers = { - onStart, - onStop, - onSuspend, - onConnection, - onDisconnection, - onAudio, - onKeystoreupdate - } - window.onload = (event) => { init(); } - }()); \ No newline at end of file diff --git a/web/speaker/speaker.py b/web/speaker/speaker.py index d9488ce..2b8ce00 100644 --- a/web/speaker/speaker.py +++ b/web/speaker/speaker.py @@ -47,6 +47,7 @@ from bumble.a2dp import ( ) from bumble.utils import AsyncRunner from bumble.codecs import AacAudioRtpPacket +from bumble.hci import HCI_Reset_Command # ----------------------------------------------------------------------------- @@ -95,15 +96,14 @@ class Speaker: STARTED = 2 SUSPENDED = 3 - def __init__(self, hci_source, hci_sink, emit_event, codec, discover): + def __init__(self, hci_source, hci_sink, codec): self.hci_source = hci_source self.hci_sink = hci_sink - self.emit_event = emit_event + self.js_listeners = {} self.codec = codec - self.discover = discover self.device = None self.connection = None - self.listener = None + self.avdtp_listener = None self.packets_received = 0 self.bytes_received = 0 self.stream_state = Speaker.StreamState.IDLE @@ -164,7 +164,7 @@ class Speaker: def on_key_store_update(self): print("Key Store updated") - self.emit_event('keystoreupdate', None) + self.emit('key_store_update') def on_bluetooth_connection(self, connection): print(f'Connection: {connection}') @@ -172,15 +172,12 @@ class Speaker: connection.on('disconnection', self.on_bluetooth_disconnection) peer_name = '' if connection.peer_name is None else connection.peer_name peer_address = connection.peer_address.to_string(False) - self.emit_event( - 'connection', {'peer_name': peer_name, 'peer_address': peer_address} - ) + self.emit('connection', {'peer_name': peer_name, 'peer_address': peer_address}) def on_bluetooth_disconnection(self, reason): print(f'Disconnection ({reason})') self.connection = None - AsyncRunner.spawn(self.advertise()) - self.emit_event('disconnection', None) + self.emit('disconnection', None) def on_avdtp_connection(self, protocol): print('Audio Stream Open') @@ -198,27 +195,23 @@ class Speaker: # Listen for close events protocol.on('close', self.on_avdtp_close) - # Discover all endpoints on the remote device is requested - if self.discover: - AsyncRunner.spawn(self.discover_remote_endpoints(protocol)) - def on_avdtp_close(self): print("Audio Stream Closed") def on_sink_start(self): print("Sink Started") self.stream_state = self.StreamState.STARTED - self.emit_event('start', None) + self.emit('start', None) def on_sink_stop(self): print("Sink Stopped") self.stream_state = self.StreamState.STOPPED - self.emit_event('stop', None) + self.emit('stop', None) def on_sink_suspend(self): print("Sink Suspended") self.stream_state = self.StreamState.SUSPENDED - self.emit_event('suspend', None) + self.emit('suspend', None) def on_sink_configuration(self, config): print("Sink Configuration:") @@ -234,11 +227,7 @@ class Speaker: def on_rtp_packet(self, packet): self.packets_received += 1 self.bytes_received += len(packet.payload) - self.emit_event("audio", self.audio_extractor.extract_audio(packet)) - - async def advertise(self): - await self.device.set_discoverable(True) - await self.device.set_connectable(True) + self.emit("audio", self.audio_extractor.extract_audio(packet)) async def connect(self, address): # Connect to the source @@ -257,7 +246,7 @@ class Speaker: print('*** Encryption on') protocol = await Protocol.connect(connection) - self.listener.set_server(connection, protocol) + self.avdtp_listener.set_server(connection, protocol) self.on_avdtp_connection(protocol) async def discover_remote_endpoints(self, protocol): @@ -266,6 +255,13 @@ class Speaker: for endpoint in endpoints: print('@@@', endpoint) + def on(self, event_name, listener): + self.js_listeners[event_name] = listener + + def emit(self, event_name, event=None): + if listener := self.js_listeners.get(event_name): + listener(event) + async def run(self, connect_address): # Create a device device_config = DeviceConfiguration() @@ -296,8 +292,8 @@ class Speaker: self.device.on('key_store_update', self.on_key_store_update) # Create a listener to wait for AVDTP connections - self.listener = Listener.for_device(self.device) - self.listener.on('connection', self.on_avdtp_connection) + self.avdtp_listener = Listener.for_device(self.device) + self.avdtp_listener.on('connection', self.on_avdtp_connection) print(f'Speaker ready to play, codec={self.codec}') @@ -309,13 +305,19 @@ class Speaker: print("Connection timed out") return else: - # Start being discoverable and connectable + # We'll wait for a connection print("Waiting for connection...") - await self.advertise() + + async def start(self): + await self.run(None) + + async def stop(self): + # TODO: replace this once a proper reset is implemented in the lib. + await self.device.host.send_command(HCI_Reset_Command()) + await self.device.power_off() + print('Speaker stopped') # ----------------------------------------------------------------------------- -async def main(hci_source, hci_sink, emit_event): - # logging.basicConfig(level='DEBUG') - speaker = Speaker(hci_source, hci_sink, emit_event, "aac", False) - await speaker.run(None) +def main(hci_source, hci_sink): + return Speaker(hci_source, hci_sink, "aac") diff --git a/web/ui.js b/web/ui.js new file mode 100644 index 0000000..6e8d877 --- /dev/null +++ b/web/ui.js @@ -0,0 +1,102 @@ +import {LitElement, html} from 'https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js'; + +class BumbleControls extends LitElement { + constructor() { + super(); + this.bumbleLoaded = false; + this.connected = false; + } + + render() { + return html` + + +

WebSocket URL for HCI transport

+
+ + + +
+
+ + + + ` + } + + get settingsHciUrlInput() { + return this.renderRoot.querySelector('#settings-hci-url-input'); + } + + get settingsDialog() { + return this.renderRoot.querySelector('#settings-dialog'); + } + + canConnect() { + return this.bumbleLoaded && !this.connected && this.getHciUrl(); + } + + getHciUrl() { + // Look for a URL parameter setting first. + const params = (new URL(document.location)).searchParams; + let hciWsUrl = params.get("hci"); + if (hciWsUrl) { + return hciWsUrl; + } + + // Try to load the setting from storage. + hciWsUrl = localStorage.getItem("hciWsUrl"); + if (hciWsUrl) { + return hciWsUrl; + } + + // Finally, default to nothing. + return null; + } + + openSettingsDialog() { + const hciUrl = this.getHciUrl(); + if (hciUrl) { + this.settingsHciUrlInput.value = hciUrl; + } else { + // Start with default, assuming port 7681. + this.settingsHciUrlInput.value = "ws://localhost:7681/v1/websocket/bt" + } + this.settingsDialog.showModal(); + } + + onSettingsDialogClose() { + if (this.settingsDialog.returnValue === "cancel") { + return; + } + if (this.settingsHciUrlInput.value) { + localStorage.setItem("hciWsUrl", this.settingsHciUrlInput.value); + } else { + localStorage.removeItem("hciWsUrl"); + } + + this.requestUpdate(); + } + + saveSettings(event) { + event.preventDefault(); + this.settingsDialog.close(this.settingsHciUrlInput.value); + } + + async connectBluetooth() { + this.connected = await this.connector(this.getHciUrl()); + this.requestUpdate(); + } + + async stop() { + await this.stopper(); + this.connected = false; + this.requestUpdate(); + } + + onBumbleLoaded() { + this.bumbleLoaded = true; + this.requestUpdate(); + } +} +customElements.define('bumble-controls', BumbleControls);