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

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