mirror of
https://github.com/google/bumble.git
synced 2026-04-16 00:25:31 +00:00
wip (+5 squashed commits)
Squashed commits: [53c6c53] wip [66f482c] wip [b003315] wip [f6f9d9e] wip [4c95c7b] wip
This commit is contained in:
@@ -1308,7 +1308,7 @@ class Device(CompositeEventEmitter):
|
||||
self.host.send_command(command, check_result), self.command_timeout
|
||||
)
|
||||
except asyncio.TimeoutError as error:
|
||||
logger.warning('!!! Command timed out')
|
||||
logger.warning(f'!!! Command {command.name} timed out')
|
||||
raise CommandTimeoutError() from error
|
||||
|
||||
async def power_on(self) -> None:
|
||||
@@ -1409,6 +1409,9 @@ class Device(CompositeEventEmitter):
|
||||
# Done
|
||||
self.powered_on = True
|
||||
|
||||
async def reset(self) -> None:
|
||||
await self.host.reset()
|
||||
|
||||
async def power_off(self) -> None:
|
||||
if self.powered_on:
|
||||
await self.host.flush()
|
||||
|
||||
@@ -42,12 +42,12 @@ class HeartRateService(TemplateService):
|
||||
RESET_ENERGY_EXPENDED = 0x01
|
||||
|
||||
class BodySensorLocation(IntEnum):
|
||||
OTHER = (0,)
|
||||
CHEST = (1,)
|
||||
WRIST = (2,)
|
||||
FINGER = (3,)
|
||||
HAND = (4,)
|
||||
EAR_LOBE = (5,)
|
||||
OTHER = 0
|
||||
CHEST = 1
|
||||
WRIST = 2
|
||||
FINGER = 3
|
||||
HAND = 4
|
||||
EAR_LOBE = 5
|
||||
FOOT = 6
|
||||
|
||||
class HeartRateMeasurement:
|
||||
|
||||
@@ -67,9 +67,16 @@ nav:
|
||||
- Zephyr: platforms/zephyr.md
|
||||
- Examples:
|
||||
- Overview: examples/index.md
|
||||
<<<<<<< HEAD
|
||||
- Extras:
|
||||
- Overview: extras/index.md
|
||||
- Android Remote HCI: extras/android_remote_hci.md
|
||||
=======
|
||||
- Hive:
|
||||
- Overview: hive/index.md
|
||||
- Speaker: hive/web/speaker/speaker.html
|
||||
- Scanner: hive/web/scanner/scanner.html
|
||||
>>>>>>> 2e9fb96 (wip (+5 squashed commits))
|
||||
|
||||
copyright: Copyright 2021-2023 Google LLC
|
||||
|
||||
|
||||
29
docs/mkdocs/src/hive/index.md
Normal file
29
docs/mkdocs/src/hive/index.md
Normal file
@@ -0,0 +1,29 @@
|
||||
HIVE
|
||||
====
|
||||
|
||||
Welcome to the Bumble Hive.
|
||||
This is a collection of apps and virtual devices that can run entirely in a browser page.
|
||||
The code for the apps and devices, as well as the Bumble runtime code, runs via [Pyiodide](https://pyodide.org/).
|
||||
Pyodide is a Python distribution for the browser and Node.js based on WebAssembly.
|
||||
|
||||
The Bumble stack uses a WebSocket to exchange HCI packets with a virtual or physical
|
||||
Bluetooth controller.
|
||||
|
||||
The apps and devices in the hive can be accessed by following the links below. Each
|
||||
page has a settings button that may be used to configure the WebSocket URL to use for
|
||||
the virutal HCI connection. This will typically be the WebSocket URL for a `netsim`
|
||||
daemon.
|
||||
There is also a [TOML index](index.toml) that can be used by tools to know at which URL to access
|
||||
each of the apps and devices, as well as their names and short desciptions.
|
||||
|
||||
|
||||
Applications
|
||||
------------
|
||||
|
||||
* [Scanner](web/scanner/scanner.html) - Scans for BLE devices.
|
||||
|
||||
Virtual Devices
|
||||
---------------
|
||||
|
||||
* [Speaker](web/speaker/speaker.html) - Virtual speaker that plays audio in a browser page.
|
||||
|
||||
15
docs/mkdocs/src/hive/index.toml
Normal file
15
docs/mkdocs/src/hive/index.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
version = "1.0.0"
|
||||
base_url = "https://google.github.io/bumble/hive/web"
|
||||
default_hci_query_param = "hci"
|
||||
|
||||
[[index]]
|
||||
name = "speaker"
|
||||
description = "Bumble Virtual Speaker"
|
||||
type = "Device"
|
||||
url = "speaker/speaker.html"
|
||||
|
||||
[[index]]
|
||||
name = "scanner"
|
||||
description = "Simple Scanner Application"
|
||||
type = "Application"
|
||||
url = "scanner/scanner.html"
|
||||
1
docs/mkdocs/src/hive/web/bumble.js
Symbolic link
1
docs/mkdocs/src/hive/web/bumble.js
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../web/bumble.js
|
||||
1
docs/mkdocs/src/hive/web/scanner/scanner.html
Symbolic link
1
docs/mkdocs/src/hive/web/scanner/scanner.html
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../../web/scanner/scanner.html
|
||||
1
docs/mkdocs/src/hive/web/scanner/scanner.py
Symbolic link
1
docs/mkdocs/src/hive/web/scanner/scanner.py
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../../web/scanner/scanner.py
|
||||
1
docs/mkdocs/src/hive/web/speaker/logo.svg
Symbolic link
1
docs/mkdocs/src/hive/web/speaker/logo.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../../web/speaker/logo.svg
|
||||
1
docs/mkdocs/src/hive/web/speaker/speaker.css
Symbolic link
1
docs/mkdocs/src/hive/web/speaker/speaker.css
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../../web/speaker/speaker.css
|
||||
1
docs/mkdocs/src/hive/web/speaker/speaker.html
Symbolic link
1
docs/mkdocs/src/hive/web/speaker/speaker.html
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../../web/speaker/speaker.html
|
||||
1
docs/mkdocs/src/hive/web/speaker/speaker.js
Symbolic link
1
docs/mkdocs/src/hive/web/speaker/speaker.js
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../../web/speaker/speaker.js
|
||||
1
docs/mkdocs/src/hive/web/speaker/speaker.py
Symbolic link
1
docs/mkdocs/src/hive/web/speaker/speaker.py
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../../web/speaker/speaker.py
|
||||
1
docs/mkdocs/src/hive/web/ui.js
Symbolic link
1
docs/mkdocs/src/hive/web/ui.js
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../web/ui.js
|
||||
@@ -14,7 +14,7 @@ connections.
|
||||
|
||||
## Moniker
|
||||
The moniker syntax for an Android Emulator "netsim" transport is: `android-netsim:[<host>:<port>][<options>]`,
|
||||
where `<options>` is a ','-separated list of `<name>=<value>` pairs`.
|
||||
where `<options>` is a comma-separated list of `<name>=<value>` pairs.
|
||||
The `mode` parameter name can specify running as a host or a controller, and `<hostname>:<port>` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator (in "host" mode), or to accept gRPC connections (in "controller" mode).
|
||||
Both the `mode=<host|controller>` and `<hostname>:<port>` parameters are optional (so the moniker `android-netsim` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the Netsim background process).
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.profiles.device_information_service import DeviceInformationService
|
||||
from bumble.profiles.heart_rate_service import HeartRateService
|
||||
from bumble.utils import AsyncRunner
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -98,6 +99,17 @@ async def main():
|
||||
)
|
||||
)
|
||||
|
||||
# Notify subscribers of the current value as soon as they subscribe
|
||||
@heart_rate_service.heart_rate_measurement_characteristic.on('subscription')
|
||||
def on_subscription(connection, notify_enabled, indicate_enabled):
|
||||
if notify_enabled or indicate_enabled:
|
||||
AsyncRunner.spawn(
|
||||
device.notify_subscriber(
|
||||
connection,
|
||||
heart_rate_service.heart_rate_measurement_characteristic
|
||||
)
|
||||
)
|
||||
|
||||
# Go!
|
||||
await device.power_on()
|
||||
await device.start_advertising(auto_restart=True)
|
||||
|
||||
213
web/bumble.js
213
web/bumble.js
@@ -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;
|
||||
}
|
||||
27
web/heart_rate_monitor/heart_rate_monitor.html
Normal file
27
web/heart_rate_monitor/heart_rate_monitor.html
Normal 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>
|
||||
29
web/heart_rate_monitor/heart_rate_monitor.js
Normal file
29
web/heart_rate_monitor/heart_rate_monitor.js
Normal 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);
|
||||
|
||||
108
web/heart_rate_monitor/heart_rate_monitor.py
Normal file
108
web/heart_rate_monitor/heart_rate_monitor.py
Normal 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
3
web/scanner/scanner.css
Normal file
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
font-family: monospace;
|
||||
}
|
||||
@@ -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
67
web/scanner/scanner.js
Normal 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);
|
||||
@@ -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)
|
||||
|
||||
@@ -11,7 +11,16 @@ body, h1, h2, h3, h4, h5, h6 {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
display: inline-block;
|
||||
display: none;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
#progressText {
|
||||
background-color: rgb(179, 208, 146);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
display: none;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Bumble Speaker</title>
|
||||
<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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}());
|
||||
@@ -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
102
web/ui.js
Normal 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);
|
||||
Reference in New Issue
Block a user