mirror of
https://github.com/google/bumble.git
synced 2026-06-01 07:37:02 +00:00
Vendored
+1
@@ -47,6 +47,7 @@
|
||||
"protobuf",
|
||||
"psms",
|
||||
"pyee",
|
||||
"Pyodide",
|
||||
"pyusb",
|
||||
"rfcomm",
|
||||
"ROHC",
|
||||
|
||||
+4
-1
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
+17
-8
@@ -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:
|
||||
|
||||
@@ -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=<tcp-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.
|
||||
|
||||
@@ -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"
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../../../../web/bumble.js
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../web/heart_rate_monitor/heart_rate_monitor.html
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../web/heart_rate_monitor/heart_rate_monitor.js
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../web/heart_rate_monitor/heart_rate_monitor.py
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../web/scanner/scanner.css
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../web/scanner/scanner.html
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../web/scanner/scanner.js
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../web/scanner/scanner.py
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../web/speaker/logo.svg
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../web/speaker/speaker.css
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../web/speaker/speaker.html
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../web/speaker/speaker.js
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../web/speaker/speaker.py
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../../../../web/ui.js
|
||||
@@ -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
|
||||
|
||||
@@ -14,7 +14,7 @@ connections.
|
||||
|
||||
## Moniker
|
||||
The moniker syntax for an Android Emulator "netsim" transport is: `android-netsim:[<host>:<port>][<options>]`,
|
||||
where `<options>` is a ','-separated list of `<name>=<value>` pairs`.
|
||||
where `<options>` is a comma-separated list of `<name>=<value>` pairs.
|
||||
The `mode` parameter name can specify running as a host or a controller, and `<hostname>:<port>` 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=<host|controller>` and `<hostname>:<port>` 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).
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+155
-58
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" />
|
||||
<script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script>
|
||||
<script type="module" src="../ui.js"></script>
|
||||
<script type="module" src="heart_rate_monitor.js"></script>
|
||||
<style>
|
||||
#hr-value {
|
||||
font-family: sans-serif;
|
||||
font-size: xx-large;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<bumble-controls id="bumble-controls"></bumble-controls><hr>
|
||||
<span class="material-symbols-outlined">
|
||||
cardiology
|
||||
</span>
|
||||
<span id="hr-value">60</span>
|
||||
<br>
|
||||
<button id="hr-up-button" class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>arrow_upward</button>
|
||||
<button id="hr-down-button" class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>arrow_downward</button>
|
||||
<hr>
|
||||
<textarea id="log-output" style="width: 100%;" rows="10" disabled></textarea><hr>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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');
|
||||
|
||||
@@ -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('<H', 0x0340)),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
async def start(self):
|
||||
print('### Starting Monitor')
|
||||
await self.device.power_on()
|
||||
await self.device.start_advertising(auto_restart=True)
|
||||
print('### Monitor 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('### Monitor stopped')
|
||||
|
||||
def notify_heart_rate(self):
|
||||
AsyncRunner.spawn(
|
||||
self.device.notify_subscribers(
|
||||
self.heart_rate_service.heart_rate_measurement_characteristic
|
||||
)
|
||||
)
|
||||
|
||||
def set_heart_rate(self, heart_rate):
|
||||
self.heart_rate = heart_rate
|
||||
self.notify_heart_rate()
|
||||
|
||||
def reset_energy_expended(self, _):
|
||||
print('<<< Reset Energy Expended')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def main(hci_source, hci_sink):
|
||||
return HeartRateMonitor(hci_source, hci_sink)
|
||||
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
font-family: monospace;
|
||||
}
|
||||
+14
-122
@@ -1,129 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
table, th, td {
|
||||
padding: 2px;
|
||||
white-space: pre;
|
||||
border: 1px solid black;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="scanner.css">
|
||||
<script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script>
|
||||
<script type="module" src="../ui.js"></script>
|
||||
<script type="module" src="scanner.js"></script>
|
||||
</style>
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<button id="connectButton" disabled>Connect</button>
|
||||
<br />
|
||||
<br />
|
||||
<div>Log Output</div><br>
|
||||
<textarea id="output" style="width: 100%;" rows="10" disabled></textarea>
|
||||
<div id="scanTableContainer"><table></table></div>
|
||||
<script type="module">
|
||||
import {LitElement, html} from 'https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js';
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import { loadBumble, connectWebSocketTransport } from "../bumble.js"
|
||||
let pyodide;
|
||||
let output;
|
||||
|
||||
function logToOutput(s) {
|
||||
output.value += s + "\n";
|
||||
console.log(s);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const params = (new URL(document.location)).searchParams;
|
||||
const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci";
|
||||
|
||||
try {
|
||||
// Create a WebSocket HCI transport
|
||||
let transport
|
||||
try {
|
||||
transport = await connectWebSocketTransport(pyodide, hciWsUrl);
|
||||
} catch (error) {
|
||||
logToOutput(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Run the scanner example
|
||||
const script = await (await fetch("scanner.py")).text();
|
||||
await pyodide.runPythonAsync(script);
|
||||
const pythonMain = pyodide.globals.get("main");
|
||||
logToOutput("Starting scanner...");
|
||||
await pythonMain(transport.packet_source, transport.packet_sink, onScanUpdate);
|
||||
logToOutput("Scanner running");
|
||||
} catch (err) {
|
||||
logToOutput(err);
|
||||
}
|
||||
}
|
||||
|
||||
function onScanUpdate(scanEntries) {
|
||||
scanEntries = scanEntries.toJs();
|
||||
|
||||
const scanTable = document.createElement("table");
|
||||
|
||||
const tableHeader = document.createElement("tr");
|
||||
for (const name of ["Address", "Address Type", "RSSI", "Data"]) {
|
||||
const header = document.createElement("th");
|
||||
header.appendChild(document.createTextNode(name));
|
||||
tableHeader.appendChild(header);
|
||||
}
|
||||
scanTable.appendChild(tableHeader);
|
||||
|
||||
scanEntries.forEach(entry => {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const addressCell = document.createElement("td");
|
||||
addressCell.appendChild(document.createTextNode(entry.address));
|
||||
row.appendChild(addressCell);
|
||||
|
||||
const addressTypeCell = document.createElement("td");
|
||||
addressTypeCell.appendChild(document.createTextNode(entry.address_type));
|
||||
row.appendChild(addressTypeCell);
|
||||
|
||||
const rssiCell = document.createElement("td");
|
||||
rssiCell.appendChild(document.createTextNode(entry.rssi));
|
||||
row.appendChild(rssiCell);
|
||||
|
||||
const dataCell = document.createElement("td");
|
||||
dataCell.appendChild(document.createTextNode(entry.data));
|
||||
row.appendChild(dataCell);
|
||||
|
||||
scanTable.appendChild(row);
|
||||
});
|
||||
|
||||
const scanTableContainer = document.getElementById("scanTableContainer");
|
||||
scanTableContainer.replaceChild(scanTable, scanTableContainer.firstChild);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
output = document.getElementById("output");
|
||||
|
||||
// Load pyodide
|
||||
logToOutput("Loading Pyodide");
|
||||
pyodide = await loadPyodide();
|
||||
|
||||
// Load Bumble
|
||||
logToOutput("Loading Bumble");
|
||||
const params = (new URL(document.location)).searchParams;
|
||||
const bumblePackage = params.get("package") || "bumble";
|
||||
await loadBumble(pyodide, bumblePackage);
|
||||
|
||||
logToOutput("Ready!")
|
||||
|
||||
// Enable the Connect button
|
||||
const connectButton = document.getElementById("connectButton");
|
||||
connectButton.disabled = false
|
||||
connectButton.addEventListener("click", run)
|
||||
}
|
||||
|
||||
main();
|
||||
</script>
|
||||
<bumble-controls id="bumble-controls"></bumble-controls><hr>
|
||||
<textarea id="log-output" style="width: 100%;" rows="10" disabled></textarea><hr>
|
||||
<scan-list id="scan-list"></scan-list>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -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`
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
${Object.keys(this.listItems[0]).map(i => html`<th>${i}</th>`)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this.listItems.map(i => html`
|
||||
<tr>
|
||||
${Object.keys(i).map(key => html`<td>${i[key]}</td>`)}
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
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');
|
||||
+45
-25
@@ -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)
|
||||
|
||||
+10
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Bumble Speaker</title>
|
||||
<script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
|
||||
<script type="module" src="speaker.js"></script>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="speaker.css">
|
||||
<script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script>
|
||||
<script type="module" src="speaker.js"></script>
|
||||
<script type="module" src="../ui.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1><img src="logo.svg" width=100 height=100 style="vertical-align:middle" alt=""/>Bumble Virtual Speaker</h1>
|
||||
<div id="errorText"></div>
|
||||
<div id="speaker">
|
||||
<table><tr>
|
||||
<td>
|
||||
@@ -25,7 +26,8 @@
|
||||
<span id="streamStateText">IDLE</span>
|
||||
<span id="connectionStateText">NOT CONNECTED</span>
|
||||
<div id="controlsDiv">
|
||||
<button id="audioOnButton">Audio On</button>
|
||||
<bumble-controls id="bumble-controls"></bumble-controls>
|
||||
<button id="audioOnButton" class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>volume_up</button>
|
||||
</div>
|
||||
<canvas id="fftCanvas" width="1024", height="300">Audio Frequencies Animation</canvas>
|
||||
<audio id="audio"></audio>
|
||||
|
||||
+37
-105
@@ -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();
|
||||
}
|
||||
|
||||
}());
|
||||
+33
-31
@@ -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")
|
||||
|
||||
@@ -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`
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<dialog id="settings-dialog" @close=${this.onSettingsDialogClose} style="font-family:sans-serif">
|
||||
<p>WebSocket URL for HCI transport</p>
|
||||
<form>
|
||||
<input id="settings-hci-url-input" type="text" size="50"></input>
|
||||
<button value="cancel" formmethod="dialog">Cancel</button>
|
||||
<button @click=${this.saveSettings}>Save</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<button @click=${this.openSettingsDialog} class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>settings</button>
|
||||
<button @click=${this.connectBluetooth} ?disabled=${!this.canConnect()} class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>bluetooth</button>
|
||||
<button @click=${this.stop} ?disabled=${!this.connected} class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>stop</button>
|
||||
`
|
||||
}
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user