forked from auracaster/bumble_mirror
@@ -23,9 +23,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
import operator
|
import operator
|
||||||
import platform
|
|
||||||
|
|
||||||
if platform.system() != 'Emscripten':
|
|
||||||
import secrets
|
import secrets
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||||
@@ -36,9 +34,7 @@ if platform.system() != 'Emscripten':
|
|||||||
SECP256R1,
|
SECP256R1,
|
||||||
)
|
)
|
||||||
from cryptography.hazmat.primitives import cmac
|
from cryptography.hazmat.primitives import cmac
|
||||||
else:
|
|
||||||
# TODO: implement stubs
|
|
||||||
pass
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
|
|||||||
@@ -2262,17 +2262,21 @@ class Device(CompositeEventEmitter):
|
|||||||
return keys.ltk_peripheral.value
|
return keys.ltk_peripheral.value
|
||||||
|
|
||||||
async def get_link_key(self, address: Address) -> Optional[bytes]:
|
async def get_link_key(self, address: Address) -> Optional[bytes]:
|
||||||
|
if self.keystore is None:
|
||||||
|
return None
|
||||||
|
|
||||||
# Look for the key in the keystore
|
# Look for the key in the keystore
|
||||||
if self.keystore is not None:
|
|
||||||
keys = await self.keystore.get(str(address))
|
keys = await self.keystore.get(str(address))
|
||||||
if keys is not None:
|
if keys is None:
|
||||||
|
logger.debug(f'no keys found for {address}')
|
||||||
|
return None
|
||||||
|
|
||||||
logger.debug('found keys in the key store')
|
logger.debug('found keys in the key store')
|
||||||
if keys.link_key is None:
|
if keys.link_key is None:
|
||||||
logger.warning('no link key')
|
logger.warning('no link key')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return keys.link_key.value
|
return keys.link_key.value
|
||||||
return None
|
|
||||||
|
|
||||||
# [Classic only]
|
# [Classic only]
|
||||||
async def authenticate(self, connection):
|
async def authenticate(self, connection):
|
||||||
@@ -2391,6 +2395,18 @@ class Device(CompositeEventEmitter):
|
|||||||
'connection_encryption_failure', on_encryption_failure
|
'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]
|
# [Classic only]
|
||||||
async def switch_role(self, connection: Connection, role: int):
|
async def switch_role(self, connection: Connection, role: int):
|
||||||
pending_role_change = asyncio.get_running_loop().create_future()
|
pending_role_change = asyncio.get_running_loop().create_future()
|
||||||
@@ -2485,13 +2501,7 @@ class Device(CompositeEventEmitter):
|
|||||||
value=link_key, authenticated=authenticated
|
value=link_key, authenticated=authenticated
|
||||||
)
|
)
|
||||||
|
|
||||||
async def store_keys():
|
self.abort_on('flush', self.update_keys(str(bd_addr), pairing_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())
|
|
||||||
|
|
||||||
if connection := self.find_connection_by_bd_addr(
|
if connection := self.find_connection_by_bd_addr(
|
||||||
bd_addr, transport=BT_BR_EDR_TRANSPORT
|
bd_addr, transport=BT_BR_EDR_TRANSPORT
|
||||||
|
|||||||
@@ -1832,8 +1832,9 @@ class Manager(EventEmitter):
|
|||||||
) -> None:
|
) -> None:
|
||||||
# Store the keys in the key store
|
# Store the keys in the key store
|
||||||
if self.device.keystore and identity_address is not None:
|
if self.device.keystore and identity_address is not None:
|
||||||
await self.device.keystore.update(str(identity_address), keys)
|
self.device.abort_on(
|
||||||
await self.device.refresh_resolving_list()
|
'flush', self.device.update_keys(str(identity_address), keys)
|
||||||
|
)
|
||||||
|
|
||||||
# Notify the device
|
# Notify the device
|
||||||
self.device.on_pairing(session.connection, identity_address, keys, session.sc)
|
self.device.on_pairing(session.connection, identity_address, keys, session.sc)
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ async def open_transport(name: str) -> Transport:
|
|||||||
* usb
|
* usb
|
||||||
* pyusb
|
* pyusb
|
||||||
* android-emulator
|
* android-emulator
|
||||||
|
* android-netsim
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return _wrap_transport(await _open_transport(name))
|
return _wrap_transport(await _open_transport(name))
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ async def open_ws_server_transport(spec):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
source = ParserSource()
|
source = ParserSource()
|
||||||
sink = PumpedPacketSink(self.send_packet)
|
sink = PumpedPacketSink(self.send_packet)
|
||||||
self.connection = asyncio.get_running_loop().create_future()
|
self.connection = None
|
||||||
self.server = None
|
self.server = None
|
||||||
|
|
||||||
super().__init__(source, sink)
|
super().__init__(source, sink)
|
||||||
@@ -63,7 +63,7 @@ async def open_ws_server_transport(spec):
|
|||||||
f'new connection on {connection.local_address} '
|
f'new connection on {connection.local_address} '
|
||||||
f'from {connection.remote_address}'
|
f'from {connection.remote_address}'
|
||||||
)
|
)
|
||||||
self.connection.set_result(connection)
|
self.connection = connection
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
try:
|
try:
|
||||||
async for packet in connection:
|
async for packet in connection:
|
||||||
@@ -74,12 +74,14 @@ async def open_ws_server_transport(spec):
|
|||||||
except websockets.WebSocketException as error:
|
except websockets.WebSocketException as error:
|
||||||
logger.debug(f'exception while receiving packet: {error}')
|
logger.debug(f'exception while receiving packet: {error}')
|
||||||
|
|
||||||
# Wait for a new connection
|
# We're now disconnected
|
||||||
self.connection = asyncio.get_running_loop().create_future()
|
self.connection = None
|
||||||
|
|
||||||
async def send_packet(self, packet):
|
async def send_packet(self, packet):
|
||||||
connection = await self.connection
|
if self.connection is None:
|
||||||
return await connection.send(packet)
|
logger.debug('no connection, dropping packet')
|
||||||
|
return
|
||||||
|
return await self.connection.send(packet)
|
||||||
|
|
||||||
local_host, local_port = spec.split(':')
|
local_host, local_port = spec.split(':')
|
||||||
transport = WsServerTransport()
|
transport = WsServerTransport()
|
||||||
|
|||||||
12
setup.cfg
12
setup.cfg
@@ -32,17 +32,17 @@ package_dir =
|
|||||||
include_package_data = True
|
include_package_data = True
|
||||||
install_requires =
|
install_requires =
|
||||||
aiohttp ~= 3.8; platform_system!='Emscripten'
|
aiohttp ~= 3.8; platform_system!='Emscripten'
|
||||||
appdirs >= 1.4
|
appdirs >= 1.4; platform_system!='Emscripten'
|
||||||
bt-test-interfaces >= 0.0.2
|
bt-test-interfaces >= 0.0.2; platform_system!='Emscripten'
|
||||||
click == 8.1.3; 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'
|
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'
|
libusb1 >= 2.0.1; platform_system!='Emscripten'
|
||||||
libusb-package == 1.0.26.1; platform_system!='Emscripten'
|
libusb-package == 1.0.26.1; platform_system!='Emscripten'
|
||||||
prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
|
prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
|
||||||
prettytable >= 3.6.0
|
prettytable >= 3.6.0; platform_system!='Emscripten'
|
||||||
protobuf >= 3.12.4
|
protobuf >= 3.12.4; platform_system!='Emscripten'
|
||||||
pyee >= 8.2.2
|
pyee >= 8.2.2
|
||||||
pyserial-asyncio >= 0.5; platform_system!='Emscripten'
|
pyserial-asyncio >= 0.5; platform_system!='Emscripten'
|
||||||
pyserial >= 3.5; platform_system!='Emscripten'
|
pyserial >= 3.5; platform_system!='Emscripten'
|
||||||
|
|||||||
28
speaker.html
28
speaker.html
@@ -1,28 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Audio WAV Player</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Audio WAV Player</h1>
|
|
||||||
<audio id="audioPlayer" controls>
|
|
||||||
<source src="" type="audio/wav">
|
|
||||||
</audio>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const audioPlayer = document.getElementById('audioPlayer');
|
|
||||||
const ws = new WebSocket('ws://localhost:8080');
|
|
||||||
|
|
||||||
let mediaSource = new MediaSource();
|
|
||||||
audioPlayer.src = URL.createObjectURL(mediaSource);
|
|
||||||
|
|
||||||
mediaSource.addEventListener('sourceopen', function(event) {
|
|
||||||
const sourceBuffer = mediaSource.addSourceBuffer('audio/wav');
|
|
||||||
|
|
||||||
ws.onmessage = function(event) {
|
|
||||||
sourceBuffer.appendBuffer(event.data);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
30
tasks.py
30
tasks.py
@@ -177,3 +177,33 @@ project_tasks.add_task(lint)
|
|||||||
project_tasks.add_task(format_code, name="format")
|
project_tasks.add_task(format_code, name="format")
|
||||||
project_tasks.add_task(check_types, name="check-types")
|
project_tasks.add_task(check_types, name="check-types")
|
||||||
project_tasks.add_task(pre_commit)
|
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)
|
||||||
|
|||||||
48
web/README.md
Normal file
48
web/README.md
Normal file
@@ -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.
|
||||||
92
web/bumble.js
Normal file
92
web/bumble.js
Normal file
@@ -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")
|
||||||
|
});
|
||||||
|
}
|
||||||
131
web/index.html
131
web/index.html
@@ -1,131 +0,0 @@
|
|||||||
<html>
|
|
||||||
<head>
|
|
||||||
<script src="https://cdn.jsdelivr.net/pyodide/v0.19.1/full/pyodide.js"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<button onclick="runUSB()">USB</button>
|
|
||||||
<button onclick="runSerial()">Serial</button>
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<div>Output:</div>
|
|
||||||
<textarea id="output" style="width: 100%;" rows="30" disabled></textarea>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function bufferToHex(buffer) {
|
|
||||||
return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = document.getElementById("output");
|
|
||||||
const code = document.getElementById("code");
|
|
||||||
|
|
||||||
function addToOutput(s) {
|
|
||||||
output.value += s + "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
output.value = "Initializing...\n";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
let pyodide = await loadPyodide({
|
|
||||||
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.19.1/full/",
|
|
||||||
})
|
|
||||||
output.value += "Ready!\n"
|
|
||||||
|
|
||||||
return pyodide;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pyodideReadyPromise = main();
|
|
||||||
|
|
||||||
async function readLoop(port, packet_source) {
|
|
||||||
const reader = port.readable.getReader()
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
console.log('@@@ Reading...')
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) {
|
|
||||||
console.log("--- DONE!")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('@@@ Serial data:', bufferToHex(value))
|
|
||||||
if (packet_source.delegate !== undefined) {
|
|
||||||
packet_source.delegate.data_received(value)
|
|
||||||
} else {
|
|
||||||
console.warn('@@@ delegate not set yet, dropping data')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runUSB() {
|
|
||||||
const device = await navigator.usb.requestDevice({
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
classCode: 0xE0,
|
|
||||||
subclassCode: 0x01
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (device.configuration === null) {
|
|
||||||
await device.selectConfiguration(1);
|
|
||||||
}
|
|
||||||
await device.claimInterface(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runSerial() {
|
|
||||||
const ports = await navigator.serial.getPorts()
|
|
||||||
console.log('Paired ports:', ports)
|
|
||||||
|
|
||||||
const port = await navigator.serial.requestPort()
|
|
||||||
await port.open({ baudRate: 1000000 })
|
|
||||||
const writer = port.writable.getWriter()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
|
|
||||||
let pyodide = await pyodideReadyPromise;
|
|
||||||
try {
|
|
||||||
const script = await(await fetch('scanner.py')).text()
|
|
||||||
await pyodide.loadPackage('micropip')
|
|
||||||
await pyodide.runPythonAsync(`
|
|
||||||
import micropip
|
|
||||||
await micropip.install('../dist/bumble-0.0.36.dev0+g3adbfe7.d20210807-py3-none-any.whl')
|
|
||||||
`)
|
|
||||||
let output = await pyodide.runPythonAsync(script)
|
|
||||||
addToOutput(output)
|
|
||||||
|
|
||||||
const pythonMain = pyodide.globals.get('main')
|
|
||||||
const packet_source = {}
|
|
||||||
const packet_sink = {
|
|
||||||
on_packet: (packet) => {
|
|
||||||
// Variant A, with the conversion done in Javascript
|
|
||||||
const buffer = packet.toJs()
|
|
||||||
console.log(`$$$ on_packet: ${bufferToHex(buffer)}`)
|
|
||||||
// TODO: create an sync queue here instead of blindly calling write without awaiting
|
|
||||||
/*await*/ writer.write(buffer)
|
|
||||||
packet.destroy()
|
|
||||||
|
|
||||||
// Variant B, with the conversion `to_js` done at the Python layer
|
|
||||||
// console.log(`$$$ on_packet: ${bufferToHex(packet)}`)
|
|
||||||
// /*await*/ writer.write(packet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
serialLooper = readLoop(port, packet_source)
|
|
||||||
pythonResult = await pythonMain(packet_source, packet_sink)
|
|
||||||
console.log(pythonResult)
|
|
||||||
serialResult = await serialLooper
|
|
||||||
writer.releaseLock()
|
|
||||||
await port.close()
|
|
||||||
console.log('### done')
|
|
||||||
} catch (err) {
|
|
||||||
addToOutput(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
129
web/scanner/scanner.html
Normal file
129
web/scanner/scanner.html
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
</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 { 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>
|
||||||
@@ -15,50 +15,38 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
import time
|
||||||
|
|
||||||
from bumble.device import Device
|
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):
|
class ScannerListener(Device.Listener):
|
||||||
|
def __init__(self, callback):
|
||||||
|
self.callback = callback
|
||||||
|
self.entries = {}
|
||||||
|
|
||||||
def on_advertisement(self, advertisement):
|
def on_advertisement(self, advertisement):
|
||||||
address_type_string = ('P', 'R', 'PI', 'RI')[advertisement.address.address_type]
|
self.entries[advertisement.address] = ScanEntry(advertisement)
|
||||||
print(
|
self.callback(list(self.entries.values()))
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main(host_source, host_sink):
|
async def main(hci_source, hci_sink, callback):
|
||||||
print('### Starting Scanner')
|
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 = 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.power_on()
|
||||||
await device.start_scanning()
|
await device.start_scanning()
|
||||||
|
|
||||||
42
web/speaker/logo.svg
Normal file
42
web/speaker/logo.svg
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!-- Created with Vectornator for iOS (http://vectornator.io/) --><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg height="100%" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" xmlns:vectornator="http://vectornator.io" version="1.1" viewBox="0 0 745 744.634">
|
||||||
|
<metadata>
|
||||||
|
<vectornator:setting key="DimensionsVisible" value="1"/>
|
||||||
|
<vectornator:setting key="PencilOnly" value="0"/>
|
||||||
|
<vectornator:setting key="SnapToPoints" value="0"/>
|
||||||
|
<vectornator:setting key="OutlineMode" value="0"/>
|
||||||
|
<vectornator:setting key="CMYKEnabledKey" value="0"/>
|
||||||
|
<vectornator:setting key="RulersVisible" value="1"/>
|
||||||
|
<vectornator:setting key="SnapToEdges" value="0"/>
|
||||||
|
<vectornator:setting key="GuidesVisible" value="1"/>
|
||||||
|
<vectornator:setting key="DisplayWhiteBackground" value="0"/>
|
||||||
|
<vectornator:setting key="doHistoryDisabled" value="0"/>
|
||||||
|
<vectornator:setting key="SnapToGuides" value="1"/>
|
||||||
|
<vectornator:setting key="TimeLapseWatermarkDisabled" value="0"/>
|
||||||
|
<vectornator:setting key="Units" value="Pixels"/>
|
||||||
|
<vectornator:setting key="DynamicGuides" value="0"/>
|
||||||
|
<vectornator:setting key="IsolateActiveLayer" value="0"/>
|
||||||
|
<vectornator:setting key="SnapToGrid" value="0"/>
|
||||||
|
</metadata>
|
||||||
|
<defs/>
|
||||||
|
<g id="Layer 1" vectornator:layerName="Layer 1">
|
||||||
|
<path stroke="#000000" stroke-width="18.6464" d="M368.753+729.441L58.8847+550.539L58.8848+192.734L368.753+13.8313L678.621+192.734L678.621+550.539L368.753+729.441Z" fill="#0082fc" stroke-linecap="butt" fill-opacity="0.307489" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<g opacity="1">
|
||||||
|
<g opacity="1">
|
||||||
|
<path stroke="#000000" stroke-width="20" d="M292.873+289.256L442.872+289.256L442.872+539.254L292.873+539.254L292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#000000" stroke-width="20" d="M292.873+289.256C292.873+247.835+326.452+214.257+367.873+214.257C409.294+214.257+442.872+247.835+442.872+289.256C442.872+330.677+409.294+364.256+367.873+364.256C326.452+364.256+292.873+330.677+292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#000000" stroke-width="20" d="M292.873+539.254C292.873+497.833+326.452+464.255+367.873+464.255C409.294+464.255+442.872+497.833+442.872+539.254C442.872+580.675+409.294+614.254+367.873+614.254C326.452+614.254+292.873+580.675+292.873+539.254Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#0082fc" stroke-width="0.1" d="M302.873+289.073L432.872+289.073L432.872+539.072L302.873+539.072L302.873+289.073Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<path stroke="#000000" stroke-width="0.1" d="M103.161+309.167L226.956+443.903L366.671+309.604L103.161+309.167Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#000000" stroke-width="0.1" d="M383.411+307.076L508.887+440.112L650.5+307.507L383.411+307.076Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#000000" stroke-width="20" d="M522.045+154.808L229.559+448.882L83.8397+300.104L653.666+302.936L511.759+444.785L223.101+156.114" fill="none" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#000000" stroke-width="61.8698" d="M295.857+418.738L438.9+418.738" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#000000" stroke-width="61.8698" d="M295.857+521.737L438.9+521.737" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<g opacity="1">
|
||||||
|
<path stroke="#0082fc" stroke-width="0.1" d="M367.769+667.024L367.821+616.383L403.677+616.336C383.137+626.447+368.263+638.69+367.769+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#0082fc" stroke-width="0.1" d="M367.836+667.024L367.784+616.383L331.928+616.336C352.468+626.447+367.341+638.69+367.836+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
76
web/speaker/speaker.css
Normal file
76
web/speaker/speaker.css
Normal file
@@ -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; }
|
||||||
34
web/speaker/speaker.html
Normal file
34
web/speaker/speaker.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Bumble Speaker</title>
|
||||||
|
<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">
|
||||||
|
</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>
|
||||||
|
<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">
|
||||||
|
<button id="audioOnButton">Audio On</button>
|
||||||
|
</div>
|
||||||
|
<canvas id="fftCanvas" width="1024", height="300">Audio Frequencies Animation</canvas>
|
||||||
|
<audio id="audio"></audio>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
289
web/speaker/speaker.js
Normal file
289
web/speaker/speaker.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}());
|
||||||
321
web/speaker/speaker.py
Normal file
321
web/speaker/speaker.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user