mirror of
https://github.com/google/bumble.git
synced 2026-07-09 13:20:49 +00:00
Deployed d8517ce with MkDocs version: 1.5.3
This commit is contained in:
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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 |
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}());
|
||||
@@ -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
@@ -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);
|
||||
Reference in New Issue
Block a user