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 @@
-
-
-
-
-
-
-
-
-
-
-