From 99bc92d53d3d5c82a8bb2c9797700087f3863fc3 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Tue, 19 Sep 2023 13:04:57 -0700 Subject: [PATCH 1/8] wip (+5 squashed commits) Squashed commits: [53c6c53] wip [66f482c] wip [b003315] wip [f6f9d9e] wip [4c95c7b] wip --- bumble/device.py | 5 +- bumble/profiles/heart_rate_service.py | 12 +- docs/mkdocs/mkdocs.yml | 7 + docs/mkdocs/src/hive/index.md | 29 +++ docs/mkdocs/src/hive/index.toml | 15 ++ docs/mkdocs/src/hive/web/bumble.js | 1 + docs/mkdocs/src/hive/web/scanner/scanner.html | 1 + docs/mkdocs/src/hive/web/scanner/scanner.py | 1 + docs/mkdocs/src/hive/web/speaker/logo.svg | 1 + docs/mkdocs/src/hive/web/speaker/speaker.css | 1 + docs/mkdocs/src/hive/web/speaker/speaker.html | 1 + docs/mkdocs/src/hive/web/speaker/speaker.js | 1 + docs/mkdocs/src/hive/web/speaker/speaker.py | 1 + docs/mkdocs/src/hive/web/ui.js | 1 + .../mkdocs/src/transports/android_emulator.md | 2 +- examples/heart_rate_server.py | 12 + web/bumble.js | 213 +++++++++++++----- .../heart_rate_monitor.html | 27 +++ web/heart_rate_monitor/heart_rate_monitor.js | 29 +++ web/heart_rate_monitor/heart_rate_monitor.py | 108 +++++++++ web/scanner/scanner.css | 3 + web/scanner/scanner.html | 132 +---------- web/scanner/scanner.js | 67 ++++++ web/scanner/scanner.py | 71 +++--- web/speaker/speaker.css | 11 +- web/speaker/speaker.html | 8 +- web/speaker/speaker.js | 142 +++--------- web/speaker/speaker.py | 53 +++-- web/ui.js | 102 +++++++++ 29 files changed, 717 insertions(+), 340 deletions(-) create mode 100644 docs/mkdocs/src/hive/index.md create mode 100644 docs/mkdocs/src/hive/index.toml create mode 120000 docs/mkdocs/src/hive/web/bumble.js create mode 120000 docs/mkdocs/src/hive/web/scanner/scanner.html create mode 120000 docs/mkdocs/src/hive/web/scanner/scanner.py create mode 120000 docs/mkdocs/src/hive/web/speaker/logo.svg create mode 120000 docs/mkdocs/src/hive/web/speaker/speaker.css create mode 120000 docs/mkdocs/src/hive/web/speaker/speaker.html create mode 120000 docs/mkdocs/src/hive/web/speaker/speaker.js create mode 120000 docs/mkdocs/src/hive/web/speaker/speaker.py create mode 120000 docs/mkdocs/src/hive/web/ui.js create mode 100644 web/heart_rate_monitor/heart_rate_monitor.html create mode 100644 web/heart_rate_monitor/heart_rate_monitor.js create mode 100644 web/heart_rate_monitor/heart_rate_monitor.py create mode 100644 web/scanner/scanner.css create mode 100644 web/scanner/scanner.js create mode 100644 web/ui.js 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..756f0fb 100644 --- a/docs/mkdocs/mkdocs.yml +++ b/docs/mkdocs/mkdocs.yml @@ -67,9 +67,16 @@ nav: - Zephyr: platforms/zephyr.md - Examples: - Overview: examples/index.md +<<<<<<< HEAD - Extras: - Overview: extras/index.md - Android Remote HCI: extras/android_remote_hci.md +======= + - Hive: + - Overview: hive/index.md + - Speaker: hive/web/speaker/speaker.html + - Scanner: hive/web/scanner/scanner.html +>>>>>>> 2e9fb96 (wip (+5 squashed commits)) copyright: Copyright 2021-2023 Google LLC diff --git a/docs/mkdocs/src/hive/index.md b/docs/mkdocs/src/hive/index.md new file mode 100644 index 0000000..c61defd --- /dev/null +++ b/docs/mkdocs/src/hive/index.md @@ -0,0 +1,29 @@ +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 [Pyiodide](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 virutal 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 desciptions. + + +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. + diff --git a/docs/mkdocs/src/hive/index.toml b/docs/mkdocs/src/hive/index.toml new file mode 100644 index 0000000..82a441d --- /dev/null +++ b/docs/mkdocs/src/hive/index.toml @@ -0,0 +1,15 @@ +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" 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/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.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/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..c4f3dfe 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/web/bumble.js b/web/bumble.js index b1243a5..6f47afd 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 + this.log('Installing micropip'); + await this.pyodide.loadPackage('micropip'); + bumblePackage ||= 'bumble'; + this.log(`Installing ${bumblePackage}`) + 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, () => { + this.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 script'); + 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)); + } + + return app; + } + + onKeystoreUpdate() { + // Sync the FS + this.pyodide.FS.syncfs(() => { + this.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..3f74a9f --- /dev/null +++ b/web/heart_rate_monitor/heart_rate_monitor.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + + +
+
+ + 60 +
+ + + \ No newline at end of file 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..a1729f9 --- /dev/null +++ b/web/heart_rate_monitor/heart_rate_monitor.js @@ -0,0 +1,29 @@ +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); + 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..7affb60 --- /dev/null +++ b/web/heart_rate_monitor/heart_rate_monitor.py @@ -0,0 +1,108 @@ +# 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.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 +
+
+ + \ No newline at end of file diff --git a/web/scanner/scanner.js b/web/scanner/scanner.js new file mode 100644 index 0000000..a10e2c1 --- /dev/null +++ b/web/scanner/scanner.js @@ -0,0 +1,67 @@ +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); diff --git a/web/scanner/scanner.py b/web/scanner/scanner.py index c0fc456..e498f7a 100644 --- a/web/scanner/scanner.py +++ b/web/scanner/scanner.py @@ -15,39 +15,58 @@ # ----------------------------------------------------------------------------- # 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..84093ef 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..96f233b 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,15 @@ 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, discover): 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 +165,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,7 +173,7 @@ 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( + self.emit( 'connection', {'peer_name': peer_name, 'peer_address': peer_address} ) @@ -180,7 +181,7 @@ class Speaker: 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,7 +199,7 @@ class Speaker: # Listen for close events protocol.on('close', self.on_avdtp_close) - # Discover all endpoints on the remote device is requested + # Discoverall endpoints on the remote device is requested if self.discover: AsyncRunner.spawn(self.discover_remote_endpoints(protocol)) @@ -208,17 +209,17 @@ class Speaker: 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,7 +235,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)) + self.emit("audio", self.audio_extractor.extract_audio(packet)) async def advertise(self): await self.device.set_discoverable(True) @@ -257,7 +258,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 +267,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 +304,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}') @@ -310,12 +318,19 @@ class Speaker: return else: # Start being discoverable and connectable - print("Waiting for connection...") await self.advertise() + print("Waiting for connection...") + + 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", False) diff --git a/web/ui.js b/web/ui.js new file mode 100644 index 0000000..0f3ae33 --- /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 a template. + this.settingsHciUrlInput.value = "ws://localhost:XYZW/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); From ad13b114649efce3fc90d88c8ba1365208bb67bd Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Sun, 1 Oct 2023 14:15:17 -0700 Subject: [PATCH 2/8] wip --- docs/mkdocs/mkdocs.yml | 4 +--- docs/mkdocs/src/hive/index.md | 1 + docs/mkdocs/src/hive/index.toml | 6 +++++ web/bumble.js | 12 +++++----- .../heart_rate_monitor.html | 22 ++++++++++--------- web/heart_rate_monitor/heart_rate_monitor.js | 1 + web/scanner/scanner.html | 8 +++---- web/scanner/scanner.js | 1 + web/speaker/speaker.html | 3 +-- web/speaker/speaker.py | 17 +++----------- web/ui.js | 2 +- 11 files changed, 37 insertions(+), 40 deletions(-) diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml index 756f0fb..b67c776 100644 --- a/docs/mkdocs/mkdocs.yml +++ b/docs/mkdocs/mkdocs.yml @@ -67,16 +67,14 @@ nav: - Zephyr: platforms/zephyr.md - Examples: - Overview: examples/index.md -<<<<<<< HEAD - Extras: - Overview: extras/index.md - Android Remote HCI: extras/android_remote_hci.md -======= - Hive: - Overview: hive/index.md - Speaker: hive/web/speaker/speaker.html - Scanner: hive/web/scanner/scanner.html ->>>>>>> 2e9fb96 (wip (+5 squashed commits)) + - Heart Rate Monitor: hive/web/heart_rate_monitor/heart_rate_monitor.html copyright: Copyright 2021-2023 Google LLC diff --git a/docs/mkdocs/src/hive/index.md b/docs/mkdocs/src/hive/index.md index c61defd..de6c966 100644 --- a/docs/mkdocs/src/hive/index.md +++ b/docs/mkdocs/src/hive/index.md @@ -26,4 +26,5 @@ 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 index 82a441d..5b187e3 100644 --- a/docs/mkdocs/src/hive/index.toml +++ b/docs/mkdocs/src/hive/index.toml @@ -13,3 +13,9 @@ 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/web/bumble.js b/web/bumble.js index 6f47afd..cb807eb 100644 --- a/web/bumble.js +++ b/web/bumble.js @@ -57,10 +57,10 @@ export class Bumble extends EventTarget { } // Load the Bumble module - this.log('Installing micropip'); - await this.pyodide.loadPackage('micropip'); 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}') @@ -76,7 +76,7 @@ export class Bumble extends EventTarget { // Sync previously persisted filesystem data into memory await new Promise(resolve => { this.pyodide.FS.syncfs(true, () => { - this.log('FS synced in'); + console.log('FS synced in'); resolve(); }); }) @@ -128,7 +128,7 @@ export class Bumble extends EventTarget { } async loadApp(appUrl) { - this.log('Loading script'); + this.log('Loading app'); const script = await (await fetch(appUrl)).text(); await this.pyodide.runPythonAsync(script); const pythonMain = this.pyodide.globals.get('main'); @@ -136,14 +136,14 @@ export class Bumble extends EventTarget { 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(() => { - this.log('FS synced out'); + console.log('FS synced out'); }); } } diff --git a/web/heart_rate_monitor/heart_rate_monitor.html b/web/heart_rate_monitor/heart_rate_monitor.html index 3f74a9f..f44470f 100644 --- a/web/heart_rate_monitor/heart_rate_monitor.html +++ b/web/heart_rate_monitor/heart_rate_monitor.html @@ -1,10 +1,9 @@ + - - - - + + -
-
- + + cardiology + 60
- - - \ No newline at end of file + + +
+
+ + diff --git a/web/heart_rate_monitor/heart_rate_monitor.js b/web/heart_rate_monitor/heart_rate_monitor.js index a1729f9..468e728 100644 --- a/web/heart_rate_monitor/heart_rate_monitor.js +++ b/web/heart_rate_monitor/heart_rate_monitor.js @@ -26,4 +26,5 @@ document.querySelector('#hr-down-button').addEventListener('click', () => { // Setup the app const app = await setupSimpleApp('heart_rate_monitor.py', bumbleControls, logToOutput); +logToOutput('Click the Bluetooth button to start'); diff --git a/web/scanner/scanner.html b/web/scanner/scanner.html index a500219..f698b01 100644 --- a/web/scanner/scanner.html +++ b/web/scanner/scanner.html @@ -1,15 +1,14 @@ + - - + - + diff --git a/web/speaker/speaker.py b/web/speaker/speaker.py index 96f233b..4157981 100644 --- a/web/speaker/speaker.py +++ b/web/speaker/speaker.py @@ -96,12 +96,11 @@ class Speaker: STARTED = 2 SUSPENDED = 3 - def __init__(self, hci_source, hci_sink, codec, discover): + def __init__(self, hci_source, hci_sink, codec): self.hci_source = hci_source self.hci_sink = hci_sink self.js_listeners = {} self.codec = codec - self.discover = discover self.device = None self.connection = None self.avdtp_listener = None @@ -180,7 +179,6 @@ class Speaker: def on_bluetooth_disconnection(self, reason): print(f'Disconnection ({reason})') self.connection = None - AsyncRunner.spawn(self.advertise()) self.emit('disconnection', None) def on_avdtp_connection(self, protocol): @@ -199,10 +197,6 @@ class Speaker: # Listen for close events protocol.on('close', self.on_avdtp_close) - # Discoverall 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") @@ -237,10 +231,6 @@ class Speaker: self.bytes_received += len(packet.payload) self.emit("audio", self.audio_extractor.extract_audio(packet)) - async def advertise(self): - await self.device.set_discoverable(True) - await self.device.set_connectable(True) - async def connect(self, address): # Connect to the source print(f'=== Connecting to {address}...') @@ -317,8 +307,7 @@ class Speaker: print("Connection timed out") return else: - # Start being discoverable and connectable - await self.advertise() + # We'll wait for a connection print("Waiting for connection...") async def start(self): @@ -333,4 +322,4 @@ class Speaker: # ----------------------------------------------------------------------------- def main(hci_source, hci_sink): - return Speaker(hci_source, hci_sink, "aac", False) + return Speaker(hci_source, hci_sink, "aac") diff --git a/web/ui.js b/web/ui.js index 0f3ae33..a72ab67 100644 --- a/web/ui.js +++ b/web/ui.js @@ -10,7 +10,7 @@ class BumbleControls extends LitElement { render() { return html` - +

WebSocket URL for HCI transport

From d8517ce407a6d4b0445b566cfc11baf356910ce9 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Sun, 1 Oct 2023 18:18:24 -0700 Subject: [PATCH 3/8] add links --- .../web/heart_rate_monitor/heart_rate_monitor.html | 1 + .../web/heart_rate_monitor/heart_rate_monitor.js | 1 + .../web/heart_rate_monitor/heart_rate_monitor.py | 1 + web/heart_rate_monitor/heart_rate_monitor.py | 13 ++++++++++++- 4 files changed, 15 insertions(+), 1 deletion(-) create mode 120000 docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.html create mode 120000 docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.js create mode 120000 docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.py 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/web/heart_rate_monitor/heart_rate_monitor.py b/web/heart_rate_monitor/heart_rate_monitor.py index 7affb60..4a843b4 100644 --- a/web/heart_rate_monitor/heart_rate_monitor.py +++ b/web/heart_rate_monitor/heart_rate_monitor.py @@ -53,7 +53,9 @@ class HeartRateMonitor: ) # Notify subscribers of the current value as soon as they subscribe - @self.heart_rate_service.heart_rate_measurement_characteristic.on('subscription') + @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() @@ -63,6 +65,15 @@ class HeartRateMonitor: 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'), From a63b3351494f16cc489e7a74dcf0930d19de479f Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Sun, 29 Oct 2023 09:36:17 -0700 Subject: [PATCH 4/8] wip --- .vscode/settings.json | 1 + docs/mkdocs/src/hive/index.md | 25 ++++++++++++++++---- docs/mkdocs/src/hive/web/scanner/scanner.css | 1 + docs/mkdocs/src/hive/web/scanner/scanner.js | 1 + web/ui.js | 4 ++-- 5 files changed, 25 insertions(+), 7 deletions(-) create mode 120000 docs/mkdocs/src/hive/web/scanner/scanner.css create mode 120000 docs/mkdocs/src/hive/web/scanner/scanner.js 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/docs/mkdocs/src/hive/index.md b/docs/mkdocs/src/hive/index.md index de6c966..7d6ffed 100644 --- a/docs/mkdocs/src/hive/index.md +++ b/docs/mkdocs/src/hive/index.md @@ -3,18 +3,33 @@ 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 [Pyiodide](https://pyodide.org/). +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 +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 virutal HCI connection. This will typically be the WebSocket URL for a `netsim` +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 desciptions. +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`) + +!!! 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 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.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/web/ui.js b/web/ui.js index a72ab67..6e8d877 100644 --- a/web/ui.js +++ b/web/ui.js @@ -59,8 +59,8 @@ class BumbleControls extends LitElement { if (hciUrl) { this.settingsHciUrlInput.value = hciUrl; } else { - // Start with a template. - this.settingsHciUrlInput.value = "ws://localhost:XYZW/v1/websocket/bt" + // Start with default, assuming port 7681. + this.settingsHciUrlInput.value = "ws://localhost:7681/v1/websocket/bt" } this.settingsDialog.showModal(); } From c75cb0c7b716642df941b8dccddc3cc242bf7eef Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Sun, 29 Oct 2023 09:58:37 -0700 Subject: [PATCH 5/8] fix css --- web/speaker/speaker.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/speaker/speaker.html b/web/speaker/speaker.html index 750df9d..1a9183d 100644 --- a/web/speaker/speaker.html +++ b/web/speaker/speaker.html @@ -2,6 +2,7 @@ Bumble Speaker + @@ -26,7 +27,7 @@ NOT CONNECTED
- +
Audio Frequencies Animation From f7b74c0bcb59527c238e691adfa72053a65302be Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Sun, 29 Oct 2023 10:03:31 -0700 Subject: [PATCH 6/8] add hive to index page --- docs/mkdocs/src/index.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 From 49d32f5b5bf21dc71d2dd93529311e42fcc174f1 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Sun, 29 Oct 2023 10:26:34 -0700 Subject: [PATCH 7/8] add netsim.ini info --- docs/mkdocs/mkdocs.yml | 22 +++++++++++++--------- docs/mkdocs/src/hive/index.md | 16 +++++++++++++++- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml index b67c776..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,21 +57,21 @@ 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: - - Overview: hive/index.md + - 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 @@ -83,6 +83,8 @@ theme: logo: 'images/logo.png' favicon: 'images/favicon.ico' custom_dir: 'theme' + features: + - navigation.indexes plugins: - mkdocstrings: @@ -107,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 index 7d6ffed..0b6ca2c 100644 --- a/docs/mkdocs/src/hive/index.md +++ b/docs/mkdocs/src/hive/index.md @@ -20,7 +20,21 @@ each of the apps and devices, as well as their names and short descriptions. 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`) + 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, From 9d2f3e932aa56db72af318d0fa7be87902c06407 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Sun, 29 Oct 2023 11:32:00 -0700 Subject: [PATCH 8/8] format --- examples/heart_rate_server.py | 2 +- tasks.py | 2 +- web/scanner/scanner.py | 1 + web/speaker/speaker.py | 4 +--- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/examples/heart_rate_server.py b/examples/heart_rate_server.py index c4f3dfe..fad809f 100644 --- a/examples/heart_rate_server.py +++ b/examples/heart_rate_server.py @@ -106,7 +106,7 @@ async def main(): AsyncRunner.spawn( device.notify_subscriber( connection, - heart_rate_service.heart_rate_measurement_characteristic + heart_rate_service.heart_rate_measurement_characteristic, ) ) 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/scanner/scanner.py b/web/scanner/scanner.py index e498f7a..9ff6aba 100644 --- a/web/scanner/scanner.py +++ b/web/scanner/scanner.py @@ -67,6 +67,7 @@ class Scanner: self.scan_entries[advertisement.address] = self.ScanEntry(advertisement) self.emit_update() + # ----------------------------------------------------------------------------- def main(hci_source, hci_sink): return Scanner(hci_source, hci_sink) diff --git a/web/speaker/speaker.py b/web/speaker/speaker.py index 4157981..2b8ce00 100644 --- a/web/speaker/speaker.py +++ b/web/speaker/speaker.py @@ -172,9 +172,7 @@ 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( - '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})')