diff --git a/bumble/crypto.py b/bumble/crypto.py index 757594fc..852c675a 100644 --- a/bumble/crypto.py +++ b/bumble/crypto.py @@ -23,22 +23,18 @@ # ----------------------------------------------------------------------------- import logging import operator -import platform -if platform.system() != 'Emscripten': - import secrets - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - from cryptography.hazmat.primitives.asymmetric.ec import ( - generate_private_key, - ECDH, - EllipticCurvePublicNumbers, - EllipticCurvePrivateNumbers, - SECP256R1, - ) - from cryptography.hazmat.primitives import cmac -else: - # TODO: implement stubs - pass +import secrets +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.asymmetric.ec import ( + generate_private_key, + ECDH, + EllipticCurvePublicNumbers, + EllipticCurvePrivateNumbers, + SECP256R1, +) +from cryptography.hazmat.primitives import cmac + # ----------------------------------------------------------------------------- # Logging diff --git a/bumble/device.py b/bumble/device.py index 63e86ede..f27a7800 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -2262,17 +2262,21 @@ class Device(CompositeEventEmitter): return keys.ltk_peripheral.value async def get_link_key(self, address: Address) -> Optional[bytes]: - # Look for the key in the keystore - if self.keystore is not None: - keys = await self.keystore.get(str(address)) - if keys is not None: - logger.debug('found keys in the key store') - if keys.link_key is None: - logger.warning('no link key') - return None + if self.keystore is None: + return None - return keys.link_key.value - return None + # Look for the key in the keystore + keys = await self.keystore.get(str(address)) + if keys is None: + logger.debug(f'no keys found for {address}') + return None + + logger.debug('found keys in the key store') + if keys.link_key is None: + logger.warning('no link key') + return None + + return keys.link_key.value # [Classic only] async def authenticate(self, connection): @@ -2391,6 +2395,18 @@ class Device(CompositeEventEmitter): 'connection_encryption_failure', on_encryption_failure ) + async def update_keys(self, address: str, keys: PairingKeys) -> None: + if self.keystore is None: + return + + try: + await self.keystore.update(address, keys) + await self.refresh_resolving_list() + except Exception as error: + logger.warning(f'!!! error while storing keys: {error}') + else: + self.emit('key_store_update') + # [Classic only] async def switch_role(self, connection: Connection, role: int): pending_role_change = asyncio.get_running_loop().create_future() @@ -2485,13 +2501,7 @@ class Device(CompositeEventEmitter): value=link_key, authenticated=authenticated ) - async def store_keys(): - try: - await self.keystore.update(str(bd_addr), pairing_keys) - except Exception as error: - logger.warning(f'!!! error while storing keys: {error}') - - self.abort_on('flush', store_keys()) + self.abort_on('flush', self.update_keys(str(bd_addr), pairing_keys)) if connection := self.find_connection_by_bd_addr( bd_addr, transport=BT_BR_EDR_TRANSPORT diff --git a/bumble/smp.py b/bumble/smp.py index 9588a5ac..55b83590 100644 --- a/bumble/smp.py +++ b/bumble/smp.py @@ -1832,8 +1832,9 @@ class Manager(EventEmitter): ) -> None: # Store the keys in the key store if self.device.keystore and identity_address is not None: - await self.device.keystore.update(str(identity_address), keys) - await self.device.refresh_resolving_list() + self.device.abort_on( + 'flush', self.device.update_keys(str(identity_address), keys) + ) # Notify the device self.device.on_pairing(session.connection, identity_address, keys, session.sc) diff --git a/bumble/transport/__init__.py b/bumble/transport/__init__.py index 840b3e59..c7222833 100644 --- a/bumble/transport/__init__.py +++ b/bumble/transport/__init__.py @@ -69,6 +69,7 @@ async def open_transport(name: str) -> Transport: * usb * pyusb * android-emulator + * android-netsim """ return _wrap_transport(await _open_transport(name)) diff --git a/bumble/transport/ws_server.py b/bumble/transport/ws_server.py index c7b7c6e2..ddebef23 100644 --- a/bumble/transport/ws_server.py +++ b/bumble/transport/ws_server.py @@ -43,7 +43,7 @@ async def open_ws_server_transport(spec): def __init__(self): source = ParserSource() sink = PumpedPacketSink(self.send_packet) - self.connection = asyncio.get_running_loop().create_future() + self.connection = None self.server = None super().__init__(source, sink) @@ -63,7 +63,7 @@ async def open_ws_server_transport(spec): f'new connection on {connection.local_address} ' f'from {connection.remote_address}' ) - self.connection.set_result(connection) + self.connection = connection # pylint: disable=no-member try: async for packet in connection: @@ -74,12 +74,14 @@ async def open_ws_server_transport(spec): except websockets.WebSocketException as error: logger.debug(f'exception while receiving packet: {error}') - # Wait for a new connection - self.connection = asyncio.get_running_loop().create_future() + # We're now disconnected + self.connection = None async def send_packet(self, packet): - connection = await self.connection - return await connection.send(packet) + if self.connection is None: + logger.debug('no connection, dropping packet') + return + return await self.connection.send(packet) local_host, local_port = spec.split(':') transport = WsServerTransport() diff --git a/setup.cfg b/setup.cfg index a7a09d63..1072accc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,17 +32,17 @@ package_dir = include_package_data = True install_requires = aiohttp ~= 3.8; platform_system!='Emscripten' - appdirs >= 1.4 - bt-test-interfaces >= 0.0.2 + appdirs >= 1.4; platform_system!='Emscripten' + bt-test-interfaces >= 0.0.2; platform_system!='Emscripten' click == 8.1.3; platform_system!='Emscripten' - cryptography == 35; platform_system!='Emscripten' + cryptography == 39; platform_system!='Emscripten' grpcio == 1.51.1; platform_system!='Emscripten' - humanize >= 4.6.0 + humanize >= 4.6.0; platform_system!='Emscripten' libusb1 >= 2.0.1; platform_system!='Emscripten' libusb-package == 1.0.26.1; platform_system!='Emscripten' prompt_toolkit >= 3.0.16; platform_system!='Emscripten' - prettytable >= 3.6.0 - protobuf >= 3.12.4 + prettytable >= 3.6.0; platform_system!='Emscripten' + protobuf >= 3.12.4; platform_system!='Emscripten' pyee >= 8.2.2 pyserial-asyncio >= 0.5; platform_system!='Emscripten' pyserial >= 3.5; platform_system!='Emscripten' diff --git a/speaker.html b/speaker.html deleted file mode 100644 index 05cc31f8..00000000 --- a/speaker.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - Audio WAV Player - - -

Audio WAV Player

- - - - - diff --git a/tasks.py b/tasks.py index 3a3a01a9..6df5a8b7 100644 --- a/tasks.py +++ b/tasks.py @@ -177,3 +177,33 @@ project_tasks.add_task(lint) project_tasks.add_task(format_code, name="format") project_tasks.add_task(check_types, name="check-types") project_tasks.add_task(pre_commit) + + +# ----------------------------------------------------------------------------- +# Web +# ----------------------------------------------------------------------------- +web_tasks = Collection() +ns.add_collection(web_tasks, name="web") + + +# ----------------------------------------------------------------------------- +@task +def serve(ctx, port=8000): + """ + Run a simple HTTP server for the examples under the `web` directory. + """ + import http.server + + address = ("", port) + + class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory="web", **kwargs) + + server = http.server.HTTPServer(address, Handler) + print(f"Now serving on port {port} 🕸️") + server.serve_forever() + + +# ----------------------------------------------------------------------------- +web_tasks.add_task(serve) diff --git a/web/README.md b/web/README.md new file mode 100644 index 00000000..a8cc89c8 --- /dev/null +++ b/web/README.md @@ -0,0 +1,48 @@ +Bumble For Web Browsers +======================= + +Early prototype the consists of running the Bumble stack in a web browser +environment, using [pyodide](https://pyodide.org/) + +Two examples are included here: + + * scanner - a simple scanner + * speaker - a pure-web-based version of the Speaker app + +Both examples rely on the shared code in `bumble.js`. + +Running The Examples +-------------------- + +To run the examples, you will need an HTTP server to serve the HTML and JS files, and +and a WebSocket server serving an HCI transport. + +For HCI over WebSocket, recent versions of the `netsim` virtual controller support it, +or you may use the Bumble HCI Bridge app to bridge a WebSocket server to a virtual +controller using some other transport (ex: `python apps/hci_bridge.py ws-server:_:9999 usb:0`). + +For HTTP, start an HTTP server with the `web` directory as its +root. You can use the invoke task `inv web.serve` for convenience. + +In a browser, open either `scanner/scanner.html` or `speaker/speaker.html`. +You can pass optional query parameters: + + * `package` may be set to point to a local build of Bumble (`.whl` files). + The filename must be URL-encoded of course, and must be located under + the `web` directory (the HTTP server won't serve files not under its + root directory). + * `hci` may be set to specify a non-default WebSocket URL to use as the HCI + transport (the default is: `"ws://localhost:9922/hci`). This also needs + to be URL-encoded. + +Example: + With a local HTTP server running on port 8000, to run the `scanner` example + with a locally-built Bumble package `../bumble-0.0.163.dev5+g6f832b6.d20230812-py3-none-any.whl` + (assuming that `bumble-0.0.163.dev5+g6f832b6.d20230812-py3-none-any.whl` exists under the `web` + directory and the HCI WebSocket transport at `ws://localhost:9999/hci`, the URL with the + URL-encoded query parameters would be: + `http://localhost:8000/scanner/scanner.html?hci=ws%3A%2F%2Flocalhost%3A9999%2Fhci&package=..%2Fbumble-0.0.163.dev5%2Bg6f832b6.d20230812-py3-none-any.whl` + + +NOTE: to get a local build of the Bumble package, use `inv build`, the built `.whl` file can be found in the `dist` directory. +Make a copy of the built `.whl` file in the `web` directory. \ No newline at end of file diff --git a/web/bumble.js b/web/bumble.js new file mode 100644 index 00000000..c5fd6a3b --- /dev/null +++ b/web/bumble.js @@ -0,0 +1,92 @@ +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 { + constructor(writer) { + this.writer = writer; + } + + on_packet(packet) { + 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); + } +} + +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)); + }) +} + +export async function loadBumble(pyodide, bumblePackage) { + // Load the Bumble module + await pyodide.loadPackage("micropip"); + await pyodide.runPythonAsync(` + import micropip + await micropip.install("cryptography") + 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"; + pyodide.FS.mkdir(mountDir); + pyodide.FS.mount(pyodide.FS.filesystems.IDBFS, { root: "." }, mountDir); + + // Sync previously persisted filesystem data into memory + pyodide.FS.syncfs(true, () => { + console.log("FS synced in") + }); +} \ No newline at end of file diff --git a/web/index.html b/web/index.html deleted file mode 100644 index 4374db02..00000000 --- a/web/index.html +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - - - -
-
-
Output:
- - - - - diff --git a/web/scanner/scanner.html b/web/scanner/scanner.html new file mode 100644 index 00000000..12c65dd6 --- /dev/null +++ b/web/scanner/scanner.html @@ -0,0 +1,129 @@ + + + + + + + + + +
+
+
Log Output

+ +
+ + + + + \ No newline at end of file diff --git a/web/scanner.py b/web/scanner/scanner.py similarity index 54% rename from web/scanner.py rename to web/scanner/scanner.py index 59eda67e..dd53050b 100644 --- a/web/scanner.py +++ b/web/scanner/scanner.py @@ -15,50 +15,38 @@ # ----------------------------------------------------------------------------- # Imports # ----------------------------------------------------------------------------- +import time + from bumble.device import Device -from bumble.transport.common import PacketParser + + +# ----------------------------------------------------------------------------- +class ScanEntry: + def __init__(self, advertisement): + self.address = str(advertisement.address).replace("/P", "") + 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 ScannerListener(Device.Listener): + def __init__(self, callback): + self.callback = callback + self.entries = {} + def on_advertisement(self, advertisement): - address_type_string = ('P', 'R', 'PI', 'RI')[advertisement.address.address_type] - print( - f'>>> {advertisement.address} [{address_type_string}]: RSSI={advertisement.rssi}, {advertisement.ad_data}' - ) - - -class HciSource: - def __init__(self, host_source): - self.parser = PacketParser() - host_source.delegate = self - - def set_packet_sink(self, sink): - self.parser.set_packet_sink(sink) - - # host source delegation - def data_received(self, data): - print('*** DATA from JS:', data) - buffer = bytes(data.to_py()) - self.parser.feed_data(buffer) - - -# class HciSink: -# def __init__(self, host_sink): -# self.host_sink = host_sink - -# def on_packet(self, packet): -# print(f'>>> PACKET from Python: {packet}') -# self.host_sink.on_packet(packet) + self.entries[advertisement.address] = ScanEntry(advertisement) + self.callback(list(self.entries.values())) # ----------------------------------------------------------------------------- -async def main(host_source, host_sink): +async def main(hci_source, hci_sink, callback): print('### Starting Scanner') - hci_source = HciSource(host_source) - hci_sink = host_sink device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink) - device.listener = ScannerListener() + device.listener = ScannerListener(callback) await device.power_on() await device.start_scanning() diff --git a/web/speaker/logo.svg b/web/speaker/logo.svg new file mode 100644 index 00000000..70ef7a90 --- /dev/null +++ b/web/speaker/logo.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/speaker/speaker.css b/web/speaker/speaker.css new file mode 100644 index 00000000..988392a0 --- /dev/null +++ b/web/speaker/speaker.css @@ -0,0 +1,76 @@ +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: inline-block; + 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; } \ No newline at end of file diff --git a/web/speaker/speaker.html b/web/speaker/speaker.html new file mode 100644 index 00000000..a20f084d --- /dev/null +++ b/web/speaker/speaker.html @@ -0,0 +1,34 @@ + + + + Bumble Speaker + + + + + +

Bumble Virtual Speaker

+
+
+ + + +
+ + + + +
Codec
Packets
Bytes
+
+ Bandwidth Graph +
+ IDLE + NOT CONNECTED +
+ +
+ Audio Frequencies Animation + +
+ + \ No newline at end of file diff --git a/web/speaker/speaker.js b/web/speaker/speaker.js new file mode 100644 index 00000000..b94180fa --- /dev/null +++ b/web/speaker/speaker.js @@ -0,0 +1,289 @@ +import { loadBumble, connectWebSocketTransport } from "../bumble.js"; + +(function () { + 'use strict'; + + let codecText; + let packetsReceivedText; + let bytesReceivedText; + let streamStateText; + let connectionStateText; + let errorText; + 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 = []; + let pyodide; + + 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"); + errorText = document.getElementById("errorText"); + connectionStateText = document.getElementById("connectionStateText"); + + audioOnButton.onclick = () => startAudio(); + + codecText.innerText = "AAC"; + setErrorText(""); + + 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() { + // 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); + } + } + + 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 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; + } + + 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; + } + } + + 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"); + } + + 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(); + } + } + + 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(); + } + +}()); \ No newline at end of file diff --git a/web/speaker/speaker.py b/web/speaker/speaker.py new file mode 100644 index 00000000..ddc20864 --- /dev/null +++ b/web/speaker/speaker.py @@ -0,0 +1,321 @@ +# 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 + + +# ----------------------------------------------------------------------------- +# 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, emit_event, codec, discover): + self.hci_source = hci_source + self.hci_sink = hci_sink + self.emit_event = emit_event + self.codec = codec + self.discover = discover + self.device = None + self.connection = None + self.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_event('keystoreupdate', None) + + 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 = str(connection.peer_address).replace('/P', '') + self.emit_event( + 'connection', {'peer_name': peer_name, 'peer_address': peer_address} + ) + + def on_bluetooth_disconnection(self, reason): + print(f'Disconnection ({reason})') + self.connection = None + AsyncRunner.spawn(self.advertise()) + self.emit_event('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) + + # Discover all endpoints on the remote device is requested + if self.discover: + AsyncRunner.spawn(self.discover_remote_endpoints(protocol)) + + 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_event('start', None) + + def on_sink_stop(self): + print("Sink Stopped") + self.stream_state = self.StreamState.STOPPED + self.emit_event('stop', None) + + def on_sink_suspend(self): + print("Sink Suspended") + self.stream_state = self.StreamState.SUSPENDED + self.emit_event('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_event("audio", self.audio_extractor.extract_audio(packet)) + + async def advertise(self): + await self.device.set_discoverable(True) + await self.device.set_connectable(True) + + 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.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) + + 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.listener = Listener(Listener.create_registrar(self.device)) + self.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: + # Start being discoverable and connectable + print("Waiting for connection...") + await self.advertise() + + +# ----------------------------------------------------------------------------- +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)