Deployed d8517ce with MkDocs version: 1.5.3

This commit is contained in:
Gilles Boccon-Gibod
2023-10-29 09:04:27 -07:00
parent c291e749fd
commit a0e5bbb23f
72 changed files with 19183 additions and 32 deletions
+188
View File
@@ -0,0 +1,188 @@
function bufferToHex(buffer) {
return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
}
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()
`);
}
set_packet_sink(sink) {
this.parser.set_packet_sink(sink);
}
data_received(data) {
//console.log(`HCI[controller->host]: ${bufferToHex(data)}`);
this.parser.feed_data(data);
}
}
class PacketSink {
on_packet(packet) {
if (!this.writer) {
return;
}
const buffer = packet.toJs({create_proxies : false});
packet.destroy();
//console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`);
// TODO: create an async queue here instead of blindly calling write without awaiting
this.writer(buffer);
}
}
class LogEvent extends Event {
constructor(message) {
super('log');
this.message = message;
}
}
export class Bumble extends EventTarget {
constructor(pyodide) {
super();
this.pyodide = pyodide;
}
async loadRuntime(bumblePackage) {
// Load pyodide if it isn't provided.
if (this.pyodide === undefined) {
this.log('Loading Pyodide');
this.pyodide = await loadPyodide();
}
// 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)
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<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.24.1/full/pyodide.js"></script>
<script type="module" src="../ui.js"></script>
<script type="module" src="scanner.js"></script>
</style>
</style>
</head>
<body>
<script type="module">
import {LitElement, html} from 'https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js';
</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>
+72
View File
@@ -0,0 +1,72 @@
# 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
# -----------------------------------------------------------------------------
from bumble.device import Device
from bumble.hci import HCI_Reset_Command
# -----------------------------------------------------------------------------
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)
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.scan_entries[advertisement.address] = self.ScanEntry(advertisement)
self.emit_update()
# -----------------------------------------------------------------------------
def main(hci_source, hci_sink):
return Scanner(hci_source, hci_sink)
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!-- Created with Vectornator for iOS (http://vectornator.io/) --><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg height="100%" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" xmlns:vectornator="http://vectornator.io" version="1.1" viewBox="0 0 745 744.634">
<metadata>
<vectornator:setting key="DimensionsVisible" value="1"/>
<vectornator:setting key="PencilOnly" value="0"/>
<vectornator:setting key="SnapToPoints" value="0"/>
<vectornator:setting key="OutlineMode" value="0"/>
<vectornator:setting key="CMYKEnabledKey" value="0"/>
<vectornator:setting key="RulersVisible" value="1"/>
<vectornator:setting key="SnapToEdges" value="0"/>
<vectornator:setting key="GuidesVisible" value="1"/>
<vectornator:setting key="DisplayWhiteBackground" value="0"/>
<vectornator:setting key="doHistoryDisabled" value="0"/>
<vectornator:setting key="SnapToGuides" value="1"/>
<vectornator:setting key="TimeLapseWatermarkDisabled" value="0"/>
<vectornator:setting key="Units" value="Pixels"/>
<vectornator:setting key="DynamicGuides" value="0"/>
<vectornator:setting key="IsolateActiveLayer" value="0"/>
<vectornator:setting key="SnapToGrid" value="0"/>
</metadata>
<defs/>
<g id="Layer 1" vectornator:layerName="Layer 1">
<path stroke="#000000" stroke-width="18.6464" d="M368.753+729.441L58.8847+550.539L58.8848+192.734L368.753+13.8313L678.621+192.734L678.621+550.539L368.753+729.441Z" fill="#0082fc" stroke-linecap="butt" fill-opacity="0.307489" opacity="1" stroke-linejoin="round"/>
<g opacity="1">
<g opacity="1">
<path stroke="#000000" stroke-width="20" d="M292.873+289.256L442.872+289.256L442.872+539.254L292.873+539.254L292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="20" d="M292.873+289.256C292.873+247.835+326.452+214.257+367.873+214.257C409.294+214.257+442.872+247.835+442.872+289.256C442.872+330.677+409.294+364.256+367.873+364.256C326.452+364.256+292.873+330.677+292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="20" d="M292.873+539.254C292.873+497.833+326.452+464.255+367.873+464.255C409.294+464.255+442.872+497.833+442.872+539.254C442.872+580.675+409.294+614.254+367.873+614.254C326.452+614.254+292.873+580.675+292.873+539.254Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#0082fc" stroke-width="0.1" d="M302.873+289.073L432.872+289.073L432.872+539.072L302.873+539.072L302.873+289.073Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
</g>
<path stroke="#000000" stroke-width="0.1" d="M103.161+309.167L226.956+443.903L366.671+309.604L103.161+309.167Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="0.1" d="M383.411+307.076L508.887+440.112L650.5+307.507L383.411+307.076Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="20" d="M522.045+154.808L229.559+448.882L83.8397+300.104L653.666+302.936L511.759+444.785L223.101+156.114" fill="none" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="61.8698" d="M295.857+418.738L438.9+418.738" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="61.8698" d="M295.857+521.737L438.9+521.737" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<g opacity="1">
<path stroke="#0082fc" stroke-width="0.1" d="M367.769+667.024L367.821+616.383L403.677+616.336C383.137+626.447+368.263+638.69+367.769+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#0082fc" stroke-width="0.1" d="M367.836+667.024L367.784+616.383L331.928+616.336C352.468+626.447+367.341+638.69+367.836+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+85
View File
@@ -0,0 +1,85 @@
body, h1, h2, h3, h4, h5, h6 {
font-family: sans-serif;
}
#controlsDiv {
margin: 6px;
}
#errorText {
background-color: rgb(239, 89, 75);
border: none;
border-radius: 4px;
padding: 8px;
display: none;
margin: 4px;
}
#progressText {
background-color: rgb(179, 208, 146);
border: none;
border-radius: 4px;
padding: 8px;
display: none;
margin: 4px;
}
#startButton {
padding: 4px;
margin: 6px;
}
#fftCanvas {
border-radius: 16px;
margin: 6px;
}
#bandwidthCanvas {
border: grey;
border-style: solid;
border-radius: 8px;
margin: 6px;
}
#streamStateText {
background-color: rgb(93, 165, 93);
border: none;
border-radius: 8px;
padding: 10px 20px;
display: inline-block;
margin: 6px;
}
#connectionStateText {
background-color: rgb(112, 146, 206);
border: none;
border-radius: 8px;
padding: 10px 20px;
display: inline-block;
margin: 6px;
}
#propertiesTable {
border: grey;
border-style: solid;
border-radius: 4px;
padding: 4px;
margin: 6px;
margin-left: 0px;
}
th, td {
padding-left: 6px;
padding-right: 6px;
}
.properties td:nth-child(even) {
background-color: #D6EEEE;
font-family: monospace;
}
.properties td:nth-child(odd) {
font-weight: bold;
}
.properties tr td:nth-child(2) { width: 150px; }
+35
View File
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<title>Bumble Speaker</title>
<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="speaker">
<table><tr>
<td>
<table id="propertiesTable" class="properties">
<tr><td>Codec</td><td><span id="codecText"></span></td></tr>
<tr><td>Packets</td><td><span id="packetsReceivedText"></span></td></tr>
<tr><td>Bytes</td><td><span id="bytesReceivedText"></span></td></tr>
</table>
</td>
<td>
<canvas id="bandwidthCanvas" width="500", height="100">Bandwidth Graph</canvas>
</td>
</tr></table>
<span id="streamStateText">IDLE</span>
<span id="connectionStateText">NOT CONNECTED</span>
<div id="controlsDiv">
<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>
</div>
</body>
</html>
+221
View File
@@ -0,0 +1,221 @@
import {setupSimpleApp} from '../bumble.js';
(function () {
'use strict';
let codecText;
let packetsReceivedText;
let bytesReceivedText;
let streamStateText;
let connectionStateText;
let audioOnButton;
let mediaSource;
let sourceBuffer;
let audioElement;
let audioContext;
let audioAnalyzer;
let audioFrequencyBinCount;
let audioFrequencyData;
let packetsReceived = 0;
let bytesReceived = 0;
let audioState = 'stopped';
let streamState = 'IDLE';
let fftCanvas;
let fftCanvasContext;
let bandwidthCanvas;
let bandwidthCanvasContext;
let bandwidthBinCount;
let bandwidthBins = [];
const FFT_WIDTH = 800;
const FFT_HEIGHT = 256;
const BANDWIDTH_WIDTH = 500;
const BANDWIDTH_HEIGHT = 100;
function init() {
initUI();
initMediaSource();
initAudioElement();
initAnalyzer();
initBumble();
}
function initUI() {
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;
codecText.innerText = 'AAC';
requestAnimationFrame(onAnimationFrame);
}
function initMediaSource() {
mediaSource = new MediaSource();
mediaSource.onsourceopen = onMediaSourceOpen;
mediaSource.onsourceclose = onMediaSourceClose;
mediaSource.onsourceended = onMediaSourceEnd;
}
function initAudioElement() {
audioElement = document.getElementById('audio');
audioElement.src = URL.createObjectURL(mediaSource);
// audioElement.controls = true;
}
function initAnalyzer() {
fftCanvas = document.getElementById('fftCanvas');
fftCanvas.width = FFT_WIDTH
fftCanvas.height = FFT_HEIGHT
fftCanvasContext = fftCanvas.getContext('2d');
fftCanvasContext.fillStyle = 'rgb(0, 0, 0)';
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
bandwidthCanvas = document.getElementById('bandwidthCanvas');
bandwidthCanvas.width = BANDWIDTH_WIDTH
bandwidthCanvas.height = BANDWIDTH_HEIGHT
bandwidthCanvasContext = bandwidthCanvas.getContext('2d');
bandwidthCanvasContext.fillStyle = 'rgb(255, 255, 255)';
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
}
async function initBumble() {
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() {
// FFT
if (audioElement.captureStream !== undefined) {
audioContext = new AudioContext();
audioAnalyzer = audioContext.createAnalyser();
audioAnalyzer.fftSize = 128;
audioFrequencyBinCount = audioAnalyzer.frequencyBinCount;
audioFrequencyData = new Uint8Array(audioFrequencyBinCount);
const stream = audioElement.captureStream();
const source = audioContext.createMediaStreamSource(stream);
source.connect(audioAnalyzer);
}
// Bandwidth
bandwidthBinCount = BANDWIDTH_WIDTH / 2;
bandwidthBins = [];
}
function setStreamState(state) {
streamState = state;
streamStateText.innerText = streamState;
}
function onAnimationFrame() {
// FFT
if (audioAnalyzer !== undefined) {
audioAnalyzer.getByteFrequencyData(audioFrequencyData);
fftCanvasContext.fillStyle = 'rgb(0, 0, 0)';
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
const barCount = audioFrequencyBinCount;
const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1;
for (let bar = 0; bar < barCount; bar++) {
const barHeight = audioFrequencyData[bar];
fftCanvasContext.fillStyle = `rgb(${barHeight / 256 * 200 + 50}, 50, ${50 + 2 * bar})`;
fftCanvasContext.fillRect(bar * (barWidth + 1), FFT_HEIGHT - barHeight, barWidth, barHeight);
}
}
// Bandwidth
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++) {
const lineHeight = (bandwidthBins[t] / 1000) * BANDWIDTH_HEIGHT;
bandwidthCanvasContext.fillRect(t * 2, BANDWIDTH_HEIGHT - lineHeight, 2, lineHeight);
}
// Display again at the next frame
requestAnimationFrame(onAnimationFrame);
}
function onMediaSourceOpen() {
console.log(this.readyState);
sourceBuffer = mediaSource.addSourceBuffer('audio/aac');
}
function onMediaSourceClose() {
console.log(this.readyState);
}
function onMediaSourceEnd() {
console.log(this.readyState);
}
async function startAudio() {
try {
console.log('starting audio...');
audioOnButton.disabled = true;
audioState = 'starting';
await audioElement.play();
console.log('audio started');
audioState = 'playing';
startAnalyzer();
} catch (error) {
console.error(`play failed: ${error}`);
audioState = 'stopped';
audioOnButton.disabled = false;
}
}
function onStart() {
setStreamState('STARTED');
}
function onStop() {
setStreamState('STOPPED');
}
function onSuspend() {
setStreamState('SUSPENDED');
}
function onConnection(params) {
connectionStateText.innerText = `CONNECTED: ${params.get('peer_name')} (${params.get('peer_address')})`;
}
function onDisconnection(params) {
connectionStateText.innerText = 'DISCONNECTED';
}
function onAudio(python_packet) {
const packet = python_packet.toJs({create_proxies : false});
python_packet.destroy();
if (audioState != 'stopped') {
// Queue the audio packet.
sourceBuffer.appendBuffer(packet);
}
packetsReceived += 1;
packetsReceivedText.innerText = packetsReceived;
bytesReceived += packet.byteLength;
bytesReceivedText.innerText = bytesReceived;
bandwidthBins[bandwidthBins.length] = packet.byteLength;
if (bandwidthBins.length > bandwidthBinCount) {
bandwidthBins.shift();
}
}
window.onload = (event) => {
init();
}
}());
+325
View File
@@ -0,0 +1,325 @@
# Copyright 2021-2023 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
# -----------------------------------------------------------------------------
from __future__ import annotations
import enum
import logging
from typing import Dict, List
from bumble.core import BT_BR_EDR_TRANSPORT, CommandTimeoutError
from bumble.device import Device, DeviceConfiguration
from bumble.pairing import PairingConfig
from bumble.sdp import ServiceAttribute
from bumble.avdtp import (
AVDTP_AUDIO_MEDIA_TYPE,
Listener,
MediaCodecCapabilities,
MediaPacket,
Protocol,
)
from bumble.a2dp import (
make_audio_sink_service_sdp_records,
MPEG_2_AAC_LC_OBJECT_TYPE,
A2DP_SBC_CODEC_TYPE,
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
SBC_MONO_CHANNEL_MODE,
SBC_DUAL_CHANNEL_MODE,
SBC_SNR_ALLOCATION_METHOD,
SBC_LOUDNESS_ALLOCATION_METHOD,
SBC_STEREO_CHANNEL_MODE,
SBC_JOINT_STEREO_CHANNEL_MODE,
SbcMediaCodecInformation,
AacMediaCodecInformation,
)
from bumble.utils import AsyncRunner
from bumble.codecs import AacAudioRtpPacket
from bumble.hci import HCI_Reset_Command
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
class AudioExtractor:
@staticmethod
def create(codec: str):
if codec == 'aac':
return AacAudioExtractor()
if codec == 'sbc':
return SbcAudioExtractor()
def extract_audio(self, packet: MediaPacket) -> bytes:
raise NotImplementedError()
# -----------------------------------------------------------------------------
class AacAudioExtractor:
def extract_audio(self, packet: MediaPacket) -> bytes:
return AacAudioRtpPacket(packet.payload).to_adts()
# -----------------------------------------------------------------------------
class SbcAudioExtractor:
def extract_audio(self, packet: MediaPacket) -> bytes:
# header = packet.payload[0]
# fragmented = header >> 7
# start = (header >> 6) & 0x01
# last = (header >> 5) & 0x01
# number_of_frames = header & 0x0F
# TODO: support fragmented payloads
return packet.payload[1:]
# -----------------------------------------------------------------------------
class Speaker:
class StreamState(enum.Enum):
IDLE = 0
STOPPED = 1
STARTED = 2
SUSPENDED = 3
def __init__(self, hci_source, hci_sink, codec):
self.hci_source = hci_source
self.hci_sink = hci_sink
self.js_listeners = {}
self.codec = codec
self.device = None
self.connection = None
self.avdtp_listener = None
self.packets_received = 0
self.bytes_received = 0
self.stream_state = Speaker.StreamState.IDLE
self.audio_extractor = AudioExtractor.create(codec)
def sdp_records(self) -> Dict[int, List[ServiceAttribute]]:
service_record_handle = 0x00010001
return {
service_record_handle: make_audio_sink_service_sdp_records(
service_record_handle
)
}
def codec_capabilities(self) -> MediaCodecCapabilities:
if self.codec == 'aac':
return self.aac_codec_capabilities()
if self.codec == 'sbc':
return self.sbc_codec_capabilities()
raise RuntimeError('unsupported codec')
def aac_codec_capabilities(self) -> MediaCodecCapabilities:
return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
media_codec_information=AacMediaCodecInformation.from_lists(
object_types=[MPEG_2_AAC_LC_OBJECT_TYPE],
sampling_frequencies=[48000, 44100],
channels=[1, 2],
vbr=1,
bitrate=256000,
),
)
def sbc_codec_capabilities(self) -> MediaCodecCapabilities:
return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_SBC_CODEC_TYPE,
media_codec_information=SbcMediaCodecInformation.from_lists(
sampling_frequencies=[48000, 44100, 32000, 16000],
channel_modes=[
SBC_MONO_CHANNEL_MODE,
SBC_DUAL_CHANNEL_MODE,
SBC_STEREO_CHANNEL_MODE,
SBC_JOINT_STEREO_CHANNEL_MODE,
],
block_lengths=[4, 8, 12, 16],
subbands=[4, 8],
allocation_methods=[
SBC_LOUDNESS_ALLOCATION_METHOD,
SBC_SNR_ALLOCATION_METHOD,
],
minimum_bitpool_value=2,
maximum_bitpool_value=53,
),
)
def on_key_store_update(self):
print("Key Store updated")
self.emit('key_store_update')
def on_bluetooth_connection(self, connection):
print(f'Connection: {connection}')
self.connection = connection
connection.on('disconnection', self.on_bluetooth_disconnection)
peer_name = '' if connection.peer_name is None else connection.peer_name
peer_address = connection.peer_address.to_string(False)
self.emit(
'connection', {'peer_name': peer_name, 'peer_address': peer_address}
)
def on_bluetooth_disconnection(self, reason):
print(f'Disconnection ({reason})')
self.connection = None
self.emit('disconnection', None)
def on_avdtp_connection(self, protocol):
print('Audio Stream Open')
# Add a sink endpoint to the server
sink = protocol.add_sink(self.codec_capabilities())
sink.on('start', self.on_sink_start)
sink.on('stop', self.on_sink_stop)
sink.on('suspend', self.on_sink_suspend)
sink.on('configuration', lambda: self.on_sink_configuration(sink.configuration))
sink.on('rtp_packet', self.on_rtp_packet)
sink.on('rtp_channel_open', self.on_rtp_channel_open)
sink.on('rtp_channel_close', self.on_rtp_channel_close)
# Listen for close events
protocol.on('close', self.on_avdtp_close)
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('start', None)
def on_sink_stop(self):
print("Sink Stopped")
self.stream_state = self.StreamState.STOPPED
self.emit('stop', None)
def on_sink_suspend(self):
print("Sink Suspended")
self.stream_state = self.StreamState.SUSPENDED
self.emit('suspend', None)
def on_sink_configuration(self, config):
print("Sink Configuration:")
print('\n'.join([" " + str(capability) for capability in config]))
def on_rtp_channel_open(self):
print("RTP Channel Open")
def on_rtp_channel_close(self):
print("RTP Channel Closed")
self.stream_state = self.StreamState.IDLE
def on_rtp_packet(self, packet):
self.packets_received += 1
self.bytes_received += len(packet.payload)
self.emit("audio", self.audio_extractor.extract_audio(packet))
async def connect(self, address):
# Connect to the source
print(f'=== Connecting to {address}...')
connection = await self.device.connect(address, transport=BT_BR_EDR_TRANSPORT)
print(f'=== Connected to {connection.peer_address}')
# Request authentication
print('*** Authenticating...')
await connection.authenticate()
print('*** Authenticated')
# Enable encryption
print('*** Enabling encryption...')
await connection.encrypt()
print('*** Encryption on')
protocol = await Protocol.connect(connection)
self.avdtp_listener.set_server(connection, protocol)
self.on_avdtp_connection(protocol)
async def discover_remote_endpoints(self, protocol):
endpoints = await protocol.discover_remote_endpoints()
print(f'@@@ Found {len(endpoints)} endpoints')
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()
device_config.name = "Bumble Speaker"
device_config.class_of_device = 0x240414
device_config.keystore = "JsonKeyStore:/bumble/keystore.json"
device_config.classic_enabled = True
device_config.le_enabled = False
self.device = Device.from_config_with_hci(
device_config, self.hci_source, self.hci_sink
)
# Setup the SDP to expose the sink service
self.device.sdp_service_records = self.sdp_records()
# Don't require MITM when pairing.
self.device.pairing_config_factory = lambda connection: PairingConfig(
mitm=False
)
# Start the controller
await self.device.power_on()
# Listen for Bluetooth connections
self.device.on('connection', self.on_bluetooth_connection)
# Listen for changes to the key store
self.device.on('key_store_update', self.on_key_store_update)
# Create a listener to wait for AVDTP connections
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}')
if connect_address:
# Connect to the source
try:
await self.connect(connect_address)
except CommandTimeoutError:
print("Connection timed out")
return
else:
# We'll wait for a connection
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')
# -----------------------------------------------------------------------------
def main(hci_source, hci_sink):
return Speaker(hci_source, hci_sink, "aac")
+102
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} 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 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);