wip (+5 squashed commits)

Squashed commits:
[53c6c53] wip
[66f482c] wip
[b003315] wip
[f6f9d9e] wip
[4c95c7b] wip
This commit is contained in:
Gilles Boccon-Gibod
2023-09-19 13:04:57 -07:00
parent 72199f5615
commit 99bc92d53d
29 changed files with 717 additions and 340 deletions

View File

@@ -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()

View File

@@ -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:

View File

@@ -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

View File

@@ -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.

View File

@@ -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"

View File

@@ -0,0 +1 @@
../../../../../web/bumble.js

View File

@@ -0,0 +1 @@
../../../../../../web/scanner/scanner.html

View File

@@ -0,0 +1 @@
../../../../../../web/scanner/scanner.py

View File

@@ -0,0 +1 @@
../../../../../../web/speaker/logo.svg

View File

@@ -0,0 +1 @@
../../../../../../web/speaker/speaker.css

View File

@@ -0,0 +1 @@
../../../../../../web/speaker/speaker.html

View File

@@ -0,0 +1 @@
../../../../../../web/speaker/speaker.js

View File

@@ -0,0 +1 @@
../../../../../../web/speaker/speaker.py

View File

@@ -0,0 +1 @@
../../../../../web/ui.js

View File

@@ -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).

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -0,0 +1,27 @@
<html>
<head>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.css">
<script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
<script src="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.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>
<textarea id="log-output" style="width: 100%;" rows="10" disabled></textarea><hr>
<span id="hr-value">60</span>
<br>
<button id="hr-up-button">+</button>
<button id="hr-down-button">-</button>
</body>

View File

@@ -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);

View File

@@ -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('<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)

3
web/scanner/scanner.css Normal file
View File

@@ -0,0 +1,3 @@
body {
font-family: monospace;
}

View File

@@ -1,129 +1,21 @@
<html>
<head>
<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.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;
}
<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>
</body>
</html>
<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>

67
web/scanner/scanner.js Normal file
View File

@@ -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`
<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);

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -2,13 +2,14 @@
<html>
<head>
<title>Bumble Speaker</title>
<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.23.2/full/pyodide.js"></script>
<script type="module" src="speaker.js"></script>
<link rel="stylesheet" href="speaker.css">
<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>

View File

@@ -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();
}
}());

View File

@@ -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)

102
web/ui.js Normal file
View File

@@ -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}>
<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 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);