diff --git a/apps/ble_rpa_tool.py b/apps/ble_rpa_tool.py index c8d70d09d..3dee311ae 100644 --- a/apps/ble_rpa_tool.py +++ b/apps/ble_rpa_tool.py @@ -52,11 +52,10 @@ def verify_rpa(irk: str, rpa: str) -> None: print(color("Not Verified", "red")) -def main(): - cli.add_command(gen_irk) - cli.add_command(gen_rpa) - cli.add_command(verify_rpa) - cli() +cli.add_command(gen_irk) +cli.add_command(gen_rpa) +cli.add_command(verify_rpa) +main = cli # ----------------------------------------------------------------------------- diff --git a/apps/console.py b/apps/console.py index 5a66c381b..2fd222600 100644 --- a/apps/console.py +++ b/apps/console.py @@ -20,14 +20,14 @@ # Imports # ----------------------------------------------------------------------------- import asyncio +import datetime import logging import os import re from collections import OrderedDict +from typing import Any import click -import humanize -from prettytable import PrettyTable from prompt_toolkit import Application from prompt_toolkit.completion import Completer, Completion, NestedCompleter from prompt_toolkit.data_structures import Point @@ -121,6 +121,55 @@ def parse_phys(phys): return phy_list +def natural_time(dt: datetime.datetime) -> str: + now = datetime.datetime.now() + if dt > now: + delta = datetime.timedelta(seconds=0) + else: + delta = now - dt + seconds = int(delta.total_seconds()) + if seconds < 1: + return 'now' + if seconds < 60: + return f"{seconds} second{'s' if seconds != 1 else ''} ago" + if seconds < 3600: + minutes = seconds // 60 + return f"{minutes} minute{'s' if minutes != 1 else ''} ago" + if seconds < 86400: + hours = seconds // 3600 + return f"{hours} hour{'s' if hours != 1 else ''} ago" + days = seconds // 86400 + return f"{days} day{'s' if days != 1 else ''} ago" + + +def format_table(field_names: list[str], rows: list[list[Any]]) -> str: + if not field_names: + return '' + widths = [len(str(h)) for h in field_names] + for row in rows: + for i, val in enumerate(row): + if i < len(widths): + widths[i] = max(widths[i], len(str(val))) + else: + widths.append(len(str(val))) + + border = '+' + '+'.join('-' * (w + 2) for w in widths) + '+' + header = ( + '| ' + + ' | '.join(f'{str(h):<{widths[i]}}' for i, h in enumerate(field_names)) + + ' |' + ) + body = [ + '| ' + ' | '.join(f'{str(v):<{widths[i]}}' for i, v in enumerate(row)) + ' |' + for row in rows + ] + lines = [border, header, border] + if body: + lines.extend(body) + lines.append(border) + return '\n'.join(lines) + + # ----------------------------------------------------------------------------- # Console App # ----------------------------------------------------------------------------- @@ -579,7 +628,7 @@ async def rssi_monitor_loop(self): async def command(self, command): try: - (keyword, *params) = command.strip().split(' ') + keyword, *params = command.strip().split(' ') keyword = keyword.replace('-', '_').lower() handler = getattr(self, f'do_{keyword}', None) if handler: @@ -745,7 +794,6 @@ async def do_show(self, params): await asyncio.sleep(1) async def do_show_local_values(self): - prettytable = PrettyTable() field_names = ["Service", "Characteristic", "Descriptor"] # if there's no connections, add a column just for value @@ -756,6 +804,7 @@ async def do_show_local_values(self): for connection in self.device.connections.values(): field_names.append(f"Connection {connection.handle}") + rows = [] for attribute in self.device.gatt_server.attributes: if isinstance(attribute, Characteristic): service = self.device.gatt_server.get_attribute_group( @@ -769,7 +818,7 @@ async def do_show_local_values(self): ] if not values: values = [attribute.read_value(None)] - prettytable.add_row([f"{service.uuid}", attribute.uuid, ""] + values) + rows.append([f"{service.uuid}", attribute.uuid, ""] + values) elif isinstance(attribute, Descriptor): service = self.device.gatt_server.get_attribute_group( @@ -791,25 +840,23 @@ async def do_show_local_values(self): # TODO: future optimization: convert CCCD value to human readable string - prettytable.add_row( + rows.append( [service.uuid, characteristic.uuid, attribute.type] + values ) - prettytable.field_names = field_names - self.local_values_text.text = prettytable.get_string() + self.local_values_text.text = format_table(field_names, rows) self.ui.invalidate() async def do_show_remote_values(self): - prettytable = PrettyTable( - field_names=[ - "Connection", - "Service", - "Characteristic", - "Descriptor", - "Time", - "Value", - ] - ) + field_names = [ + "Connection", + "Service", + "Characteristic", + "Descriptor", + "Time", + "Value", + ] + rows = [] for connection in self.device.connections.values(): for handle, (time, value) in connection.gatt_client.cached_values.items(): row = [connection.handle] @@ -827,10 +874,10 @@ async def do_show_remote_values(self): else: continue - row.extend([humanize.naturaltime(time), value]) - prettytable.add_row(row) + row.extend([natural_time(time), value]) + rows.append(row) - self.remote_values_text.text = prettytable.get_string() + self.remote_values_text.text = format_table(field_names, rows) self.ui.invalidate() async def do_get_phy(self, _): diff --git a/apps/l2cap_bridge.py b/apps/l2cap_bridge.py index bef07c682..2a26be0a1 100644 --- a/apps/l2cap_bridge.py +++ b/apps/l2cap_bridge.py @@ -355,7 +355,11 @@ def client(context, bluetooth_address, tcp_host, tcp_port): asyncio.run(run(context.obj['device_config'], context.obj['hci_transport'], bridge)) -# ----------------------------------------------------------------------------- -if __name__ == '__main__': +def main() -> None: bumble.logging.setup_basic_logging('WARNING') cli(obj={}) # pylint: disable=no-value-for-parameter + + +# ----------------------------------------------------------------------------- +if __name__ == '__main__': + main() diff --git a/apps/lea_unicast/app.py b/apps/lea_unicast/app.py index d821c9a78..d8f55c60f 100644 --- a/apps/lea_unicast/app.py +++ b/apps/lea_unicast/app.py @@ -25,15 +25,16 @@ import pathlib import wave import weakref -from importlib import resources try: import lc3 # type: ignore # pylint: disable=E0401 except ImportError as e: raise ImportError("Try `python -m pip install \".[auracast]\"`.") from e -import aiohttp.web import click +import websockets.asyncio.server +import websockets.exceptions +import websockets.http11 import bumble import bumble.logging @@ -159,70 +160,97 @@ async def lc3_source_task( class UiServer: speaker: weakref.ReferenceType[Speaker] port: int + channel_socket: websockets.asyncio.server.ServerConnection | None + server: websockets.asyncio.server.Server | None def __init__(self, speaker: Speaker, port: int) -> None: self.speaker = weakref.ref(speaker) self.port = port self.channel_socket = None + self.server = None async def start_http(self) -> None: """Start the UI HTTP server.""" - - app = aiohttp.web.Application() - app.add_routes( - [ - aiohttp.web.get('/', self.get_static), - aiohttp.web.get('/index.html', self.get_static), - aiohttp.web.get('/channel', self.get_channel), - ] + self.server = await websockets.asyncio.server.serve( + self.get_channel, + '127.0.0.1', + self.port, + process_request=self.process_request, ) - - runner = aiohttp.web.AppRunner(app) - await runner.setup() - site = aiohttp.web.TCPSite(runner, 'localhost', self.port) + if self.port == 0 and self.server.sockets: + self.port = self.server.sockets[0].getsockname()[1] print('UI HTTP server at ' + color(f'http://127.0.0.1:{self.port}', 'green')) - await site.start() - async def get_static(self, request): + async def close(self) -> None: + if self.server: + self.server.close() + await self.server.wait_closed() + + async def process_request( + self, + connection: websockets.asyncio.server.ServerConnection, + request: websockets.asyncio.server.Request, + ) -> websockets.http11.Response | None: path = request.path - if path == '/': + if path == '/channel': + return None + + if path in ('', '/'): path = '/index.html' + if path.endswith('.html'): - content_type = 'text/html' + content_type = 'text/html; charset=utf-8' elif path.endswith('.js'): - content_type = 'text/javascript' + content_type = 'text/javascript; charset=utf-8' elif path.endswith('.css'): - content_type = 'text/css' + content_type = 'text/css; charset=utf-8' elif path.endswith('.svg'): content_type = 'image/svg+xml' else: - content_type = 'text/plain' - text = ( - resources.files("bumble.apps.lea_unicast") - .joinpath(pathlib.Path(path).relative_to('/')) - .read_text(encoding="utf-8") - ) - return aiohttp.web.Response(text=text, content_type=content_type) + content_type = 'text/plain; charset=utf-8' - async def get_channel(self, request): - ws = aiohttp.web.WebSocketResponse() - await ws.prepare(request) + try: + body = (pathlib.Path(__file__).parent / path.lstrip('/')).read_bytes() + return websockets.http11.Response( + status_code=200, + reason_phrase='OK', + headers=websockets.http11.Headers( + [ + ('Content-Type', content_type), + ('Content-Length', str(len(body))), + ] + ), + body=body, + ) + except Exception: + body = b'Not Found' + return websockets.http11.Response( + status_code=404, + reason_phrase='Not Found', + headers=websockets.http11.Headers( + [ + ('Content-Type', 'text/plain; charset=utf-8'), + ('Content-Length', str(len(body))), + ] + ), + body=body, + ) + async def get_channel(self, ws: websockets.asyncio.server.ServerConnection) -> None: # Process messages until the socket is closed. self.channel_socket = ws - async for message in ws: - if message.type == aiohttp.WSMsgType.TEXT: - logger.debug(f'<<< received message: {message.data}') - await self.on_message(message.data) - elif message.type == aiohttp.WSMsgType.ERROR: - logger.debug( - f'channel connection closed with exception {ws.exception()}' - ) - - self.channel_socket = None - logger.debug('--- channel connection closed') - - return ws + try: + async for message in ws: + if isinstance(message, str): + logger.debug(f'<<< received message: {message}') + await self.on_message(message) + else: + logger.debug(f'<<< received binary message: {len(message)} bytes') + except websockets.exceptions.ConnectionClosed as error: + logger.debug(f'channel connection closed: {error}') + finally: + self.channel_socket = None + logger.debug('--- channel connection closed') async def on_message(self, message_str: str): # Parse the message as JSON @@ -231,18 +259,21 @@ async def on_message(self, message_str: str): # Dispatch the message message_type = message['type'] message_params = message.get('params', {}) - handler = getattr(self, f'on_{message_type}_message') + handler = getattr(self, f'on_{message_type}_message', None) if handler: await handler(**message_params) async def on_hello_message(self): + speaker = self.speaker() + if not speaker: + return await self.send_message( 'hello', bumble_version=bumble.__version__, - codec=self.speaker().codec, - streamState=self.speaker().stream_state.name, + codec=speaker.codec, + streamState=speaker.stream_state.name, ) - if connection := self.speaker().connection: + if connection := speaker.connection: await self.send_message( 'connection', peer_address=connection.peer_address.to_string(False), @@ -254,14 +285,14 @@ async def send_message(self, message_type: str, **kwargs) -> None: return message = {'type': message_type, 'params': kwargs} - await self.channel_socket.send_json(message) + await self.channel_socket.send(json.dumps(message)) async def send_audio(self, data: bytes) -> None: if self.channel_socket is None: return try: - await self.channel_socket.send_bytes(data) + await self.channel_socket.send(data) except Exception as error: logger.warning(f'exception while sending audio packet: {error}') diff --git a/apps/pair.py b/apps/pair.py index 9bfa0a554..c65447027 100644 --- a/apps/pair.py +++ b/apps/pair.py @@ -23,7 +23,6 @@ from typing import ClassVar import click -from prompt_toolkit.shortcuts import PromptSession from bumble import data_types, smp from bumble.a2dp import make_audio_sink_service_sdp_records @@ -104,8 +103,7 @@ async def prompt(self, message): # Wait a bit to allow some of the log lines to print before we prompt await asyncio.sleep(1) - session = PromptSession(message) - response = await session.prompt_async() + response = await asyncio.to_thread(input, message) return response.lower().strip() async def update_peer_name(self): diff --git a/apps/rfcomm_bridge.py b/apps/rfcomm_bridge.py index 6b5f3ef9d..e6cd5ef8e 100644 --- a/apps/rfcomm_bridge.py +++ b/apps/rfcomm_bridge.py @@ -507,7 +507,11 @@ def client(context, bluetooth_address, tcp_host, tcp_port, authenticate, encrypt asyncio.run(run(context.obj["device_config"], context.obj["hci_transport"], bridge)) -# ----------------------------------------------------------------------------- -if __name__ == "__main__": +def main() -> None: bumble.logging.setup_basic_logging("WARNING") cli(obj={}) # pylint: disable=no-value-for-parameter + + +# ----------------------------------------------------------------------------- +if __name__ == "__main__": + main() diff --git a/apps/speaker/speaker.py b/apps/speaker/speaker.py index 4ca973195..42e2de794 100644 --- a/apps/speaker/speaker.py +++ b/apps/speaker/speaker.py @@ -25,11 +25,11 @@ import pathlib import subprocess import weakref -from importlib import resources -import aiohttp import click -from aiohttp import web +import websockets.asyncio.server +import websockets.exceptions +import websockets.http11 import bumble import bumble.logging @@ -291,73 +291,98 @@ async def on_audio_packet(self, packet): class UiServer: speaker: weakref.ReferenceType[Speaker] port: int + channel_socket: websockets.asyncio.server.ServerConnection | None + + server: websockets.asyncio.server.Server | None def __init__(self, speaker: Speaker, port: int) -> None: self.speaker = weakref.ref(speaker) self.port = port self.channel_socket = None + self.server = None async def start_http(self) -> None: """Start the UI HTTP server.""" - - app = web.Application() - app.add_routes( - [ - web.get('/', self.get_static), - web.get('/speaker.html', self.get_static), - web.get('/speaker.js', self.get_static), - web.get('/speaker.css', self.get_static), - web.get('/logo.svg', self.get_static), - web.get('/channel', self.get_channel), - ] + self.server = await websockets.asyncio.server.serve( + self.get_channel, + '127.0.0.1', + self.port, + process_request=self.process_request, ) - - runner = web.AppRunner(app) - await runner.setup() - site = web.TCPSite(runner, 'localhost', self.port) + if self.port == 0 and self.server.sockets: + self.port = self.server.sockets[0].getsockname()[1] print('UI HTTP server at ' + color(f'http://127.0.0.1:{self.port}', 'green')) - await site.start() - async def get_static(self, request): + async def close(self) -> None: + if self.server: + self.server.close() + await self.server.wait_closed() + + async def process_request( + self, + connection: websockets.asyncio.server.ServerConnection, + request: websockets.asyncio.server.Request, + ) -> websockets.http11.Response | None: path = request.path - if path == '/': + if path == '/channel': + return None + + if path in ('', '/'): path = '/speaker.html' + if path.endswith('.html'): - content_type = 'text/html' + content_type = 'text/html; charset=utf-8' elif path.endswith('.js'): - content_type = 'text/javascript' + content_type = 'text/javascript; charset=utf-8' elif path.endswith('.css'): - content_type = 'text/css' + content_type = 'text/css; charset=utf-8' elif path.endswith('.svg'): content_type = 'image/svg+xml' else: - content_type = 'text/plain' - text = ( - resources.files("bumble.apps.speaker") - .joinpath(pathlib.Path(path).relative_to('/')) - .read_text(encoding="utf-8") - ) - return aiohttp.web.Response(text=text, content_type=content_type) + content_type = 'text/plain; charset=utf-8' - async def get_channel(self, request): - ws = web.WebSocketResponse() - await ws.prepare(request) + try: + body = (pathlib.Path(__file__).parent / path.lstrip('/')).read_bytes() + return websockets.http11.Response( + status_code=200, + reason_phrase='OK', + headers=websockets.http11.Headers( + [ + ('Content-Type', content_type), + ('Content-Length', str(len(body))), + ] + ), + body=body, + ) + except Exception: + body = b'Not Found' + return websockets.http11.Response( + status_code=404, + reason_phrase='Not Found', + headers=websockets.http11.Headers( + [ + ('Content-Type', 'text/plain; charset=utf-8'), + ('Content-Length', str(len(body))), + ] + ), + body=body, + ) + async def get_channel(self, ws: websockets.asyncio.server.ServerConnection) -> None: # Process messages until the socket is closed. self.channel_socket = ws - async for message in ws: - if message.type == aiohttp.WSMsgType.TEXT: - logger.debug(f'<<< received message: {message.data}') - await self.on_message(message.data) - elif message.type == aiohttp.WSMsgType.ERROR: - logger.debug( - f'channel connection closed with exception {ws.exception()}' - ) - - self.channel_socket = None - logger.debug('--- channel connection closed') - - return ws + try: + async for message in ws: + if isinstance(message, str): + logger.debug(f'<<< received message: {message}') + await self.on_message(message) + else: + logger.debug(f'<<< received binary message: {len(message)} bytes') + except websockets.exceptions.ConnectionClosed as error: + logger.debug(f'channel connection closed: {error}') + finally: + self.channel_socket = None + logger.debug('--- channel connection closed') async def on_message(self, message_str: str): # Parse the message as JSON @@ -366,18 +391,21 @@ async def on_message(self, message_str: str): # Dispatch the message message_type = message['type'] message_params = message.get('params', {}) - handler = getattr(self, f'on_{message_type}_message') + handler = getattr(self, f'on_{message_type}_message', None) if handler: await handler(**message_params) async def on_hello_message(self): + speaker = self.speaker() + if not speaker: + return await self.send_message( 'hello', bumble_version=bumble.__version__, - codec=self.speaker().codec, - streamState=self.speaker().stream_state.name, + codec=speaker.codec, + streamState=speaker.stream_state.name, ) - if connection := self.speaker().connection: + if connection := speaker.connection: await self.send_message( 'connection', peer_address=connection.peer_address.to_string(False), @@ -389,14 +417,14 @@ async def send_message(self, message_type: str, **kwargs) -> None: return message = {'type': message_type, 'params': kwargs} - await self.channel_socket.send_json(message) + await self.channel_socket.send(json.dumps(message)) async def send_audio(self, data: bytes) -> None: if self.channel_socket is None: return try: - await self.channel_socket.send_bytes(data) + await self.channel_socket.send(data) except Exception as error: logger.warning(f'exception while sending audio packet: {error}') diff --git a/pyproject.toml b/pyproject.toml index cc2fb1af7..f2c1ddff3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,6 @@ license-files = ["LICENSE"] authors = [{ name = "Google", email = "bumble-dev@google.com" }] requires-python = ">=3.10" dependencies = [ - "aiohttp ~= 3.8; platform_system!='Emscripten'", "click >= 8.1.3; platform_system!='Emscripten'", "cryptography >= 44.0.3; platform_system!='Emscripten' and platform_system!='Android'", # Pyodide bundles a version of cryptography that is built for wasm, which may not match the @@ -24,10 +23,8 @@ dependencies = [ # updated. Relax the version requirement since it's better than being completely unable # to import the package in case of version mismatch. "cryptography >= 42.0.8; platform_system=='Android'", - "humanize >= 4.6.0; platform_system!='Emscripten'", "platformdirs >= 3.10.0; platform_system!='Emscripten'", "prompt_toolkit >= 3.0.16; platform_system!='Emscripten'", - "prettytable >= 3.6.0; platform_system!='Emscripten'", "pyee >= 13.0.0", "tomli ~= 2.2.1; platform_system!='Emscripten' and python_version<'3.11'", "websockets >= 15.0.1; platform_system!='Emscripten'", diff --git a/tests/apps_test.py b/tests/apps_test.py new file mode 100644 index 000000000..161557464 --- /dev/null +++ b/tests/apps_test.py @@ -0,0 +1,265 @@ +# Copyright 2026 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. + +"""Tests for Bumble apps.""" + +import asyncio +import datetime +import json +import pathlib +import urllib.request +from typing import Any + +import click.testing +import pytest +import websockets.asyncio.client + +from apps import ( + bench, + ble_rpa_tool, + console, + controller_info, + controller_loopback, + gatt_dump, + l2cap_bridge, + pair, + rfcomm_bridge, + scan, + show, + unbond, + usb_probe, +) +from apps.player import player +from apps.speaker import speaker +from tools import ( + intel_fw_download, + intel_util, + rtk_fw_download, + rtk_util, +) + +lea_unicast_app: Any +try: + from apps.lea_unicast import app as lea_unicast_app +except ImportError: + lea_unicast_app = None + +auracast: Any +try: + from apps import auracast +except ImportError: + auracast = None + +APPS = [ + bench.bench, + ble_rpa_tool.main, + console.main, + controller_info.main, + controller_loopback.main, + gatt_dump.main, + l2cap_bridge.cli, + rfcomm_bridge.cli, + pair.main, + scan.main, + show.main, + unbond.main, + usb_probe.main, + player.player_cli, + speaker.speaker, + intel_util.main, + getattr(intel_fw_download, "main"), + rtk_util.main, + getattr(rtk_fw_download, "main"), +] + +if auracast is not None: + APPS.append(auracast.auracast) + + +@pytest.mark.parametrize( + "app_main", APPS, ids=lambda cmd: getattr(cmd, "name", repr(cmd)) +) +def test_app_help(app_main: Any) -> None: + runner = click.testing.CliRunner() + result = runner.invoke(app_main, ["--help"]) + assert result.exit_code == 0 + assert "Usage:" in result.output + + +def test_ble_rpa_tool() -> None: + runner = click.testing.CliRunner() + main_cmd: Any = ble_rpa_tool.main + + # Test gen-irk + irk_result = runner.invoke(main_cmd, ["gen-irk"]) + assert irk_result.exit_code == 0 + irk = irk_result.output.strip() + assert len(irk) == 32 + + # Test gen-rpa + gen_result = runner.invoke(main_cmd, ["gen-rpa", irk]) + assert gen_result.exit_code == 0 + rpa = gen_result.output.strip() + assert len(rpa.split(":")) == 6 + + # Test verify-rpa (match) + verify_result = runner.invoke(main_cmd, ["verify-rpa", irk, rpa]) + assert verify_result.exit_code == 0 + assert "Verified" in verify_result.output + + # Test verify-rpa (mismatch) + wrong_irk = "ffeeddccbbaa99887766554433221100" + verify_mismatch = runner.invoke(main_cmd, ["verify-rpa", wrong_irk, rpa]) + assert verify_mismatch.exit_code == 0 + assert "Not Verified" in verify_mismatch.output + + +def test_show_tool() -> None: + runner = click.testing.CliRunner() + main_cmd: Any = show.main + hci_data_file = pathlib.Path(__file__).parent / "hci_data_001.bin" + result = runner.invoke(main_cmd, ["--format", "h4", str(hci_data_file)]) + assert result.exit_code == 0 + + +def test_unbond(tmp_path: pathlib.Path) -> None: + runner = click.testing.CliRunner() + main_cmd: Any = unbond.main + keystore_file = tmp_path / "keystore.json" + keystore_file.write_text("{}", encoding="utf-8") + result = runner.invoke(main_cmd, ["--keystore-file", str(keystore_file)]) + assert result.exit_code == 0 + + +def test_console_helpers() -> None: + # Test natural_time + now = datetime.datetime.now() + assert console.natural_time(now) == "now" + assert ( + console.natural_time(now - datetime.timedelta(seconds=10)) == "10 seconds ago" + ) + assert console.natural_time(now - datetime.timedelta(minutes=5)) == "5 minutes ago" + assert console.natural_time(now - datetime.timedelta(hours=2)) == "2 hours ago" + assert console.natural_time(now - datetime.timedelta(days=3)) == "3 days ago" + + # Test format_table + headers = ["Name", "Value"] + rows: list[list[Any]] = [["Key1", "Val1"], ["Key2", "Val2"]] + table_str = console.format_table(headers, rows) + assert "Name" in table_str + assert "Key1" in table_str + assert "Val2" in table_str + assert table_str.startswith("+") + assert table_str.endswith("+") + + +class MockSpeaker: + def __init__(self) -> None: + self.codec = "SBC" + self.connection = None + + class StreamState: + name = "IDLE" + + self.stream_state = StreamState() + + +@pytest.mark.asyncio +async def test_speaker_ui_server() -> None: + mock_speaker = MockSpeaker() + ui_server = speaker.UiServer(mock_speaker, port=0) # type: ignore[arg-type] + await ui_server.start_http() + try: + assert ui_server.port != 0 + + # Test HTTP GET static files + def fetch(path: str) -> tuple[int, str]: + url = f"http://127.0.0.1:{ui_server.port}{path}" + with urllib.request.urlopen(url) as response: + return response.status, response.headers.get("Content-Type", "") + + status, ct = await asyncio.to_thread(fetch, "/") + assert status == 200 + assert "text/html" in ct + + status, ct = await asyncio.to_thread(fetch, "/speaker.js") + assert status == 200 + assert "text/javascript" in ct + + status, ct = await asyncio.to_thread(fetch, "/speaker.css") + assert status == 200 + assert "text/css" in ct + + status, ct = await asyncio.to_thread(fetch, "/logo.svg") + assert status == 200 + assert "image/svg+xml" in ct + + # Test WebSocket + ws_url = f"ws://127.0.0.1:{ui_server.port}/channel" + async with websockets.asyncio.client.connect(ws_url) as ws: + await ws.send(json.dumps({"type": "hello", "params": {}})) + raw_msg = await ws.recv() + assert isinstance(raw_msg, str) + msg = json.loads(raw_msg) + assert msg["type"] == "hello" + assert msg["params"]["codec"] == "SBC" + + # Test send audio bytes + await ui_server.send_audio(b"audio-payload") + audio_bytes = await ws.recv() + assert audio_bytes == b"audio-payload" + finally: + await ui_server.close() + + +@pytest.mark.skipif(lea_unicast_app is None, reason="lc3 is not installed") +@pytest.mark.asyncio +async def test_lea_unicast_ui_server() -> None: + assert lea_unicast_app is not None + mock_speaker = MockSpeaker() + ui_server = lea_unicast_app.UiServer(mock_speaker, port=0) # type: ignore[arg-type] + await ui_server.start_http() + try: + assert ui_server.port != 0 + + # Test HTTP GET static files + def fetch(path: str) -> tuple[int, str]: + url = f"http://127.0.0.1:{ui_server.port}{path}" + with urllib.request.urlopen(url) as response: + return response.status, response.headers.get("Content-Type", "") + + status, ct = await asyncio.to_thread(fetch, "/") + assert status == 200 + assert "text/html" in ct + + status, ct = await asyncio.to_thread(fetch, "/index.html") + assert status == 200 + assert "text/html" in ct + + # Test WebSocket + ws_url = f"ws://127.0.0.1:{ui_server.port}/channel" + async with websockets.asyncio.client.connect(ws_url) as ws: + await ws.send(json.dumps({"type": "hello", "params": {}})) + raw_msg = await ws.recv() + assert isinstance(raw_msg, str) + msg = json.loads(raw_msg) + assert msg["type"] == "hello" + assert msg["params"]["codec"] == "SBC" + + # Test send audio bytes + await ui_server.send_audio(b"audio-payload-lea") + audio_bytes = await ws.recv() + assert audio_bytes == b"audio-payload-lea" + finally: + await ui_server.close() diff --git a/tools/intel_fw_download.py b/tools/intel_fw_download.py index 1bcd133db..64f9bb215 100644 --- a/tools/intel_fw_download.py +++ b/tools/intel_fw_download.py @@ -50,7 +50,7 @@ def download_file(base_url, name): # ----------------------------------------------------------------------------- -@click.command +@click.command() @click.option( "--output-dir", default="", @@ -66,15 +66,15 @@ def download_file(base_url, name): ) @click.option("--single", help="Only download a single image set, by its base name") @click.option("--force", is_flag=True, help="Overwrite files if they already exist") -def main(output_dir, source, single, force): +def main(output_dir: str, source: str, single: str | None, force: bool) -> None: """Download Intel firmware images and configs.""" # Check that the output dir exists if output_dir == '': - output_dir = intel.intel_firmware_dir() + out_dir = intel.intel_firmware_dir() else: - output_dir = pathlib.Path(output_dir) - if not output_dir.is_dir(): + out_dir = pathlib.Path(output_dir) + if not out_dir.is_dir(): print("Output dir does not exist or is not a directory") return @@ -84,7 +84,7 @@ def main(output_dir, source, single, force): print("Downloading") print(color("FROM:", "green"), base_url) - print(color("TO:", "green"), output_dir) + print(color("TO:", "green"), out_dir) if single: images = [(f"{single}.sfi", f"{single}.ddc")] @@ -96,12 +96,12 @@ def main(output_dir, source, single, force): for fw_name, config_name in images: print(color("---", "yellow")) - fw_image_out = output_dir / fw_name + fw_image_out = out_dir / fw_name if not force and fw_image_out.exists(): print(color(f"{fw_image_out} already exists, skipping", "red")) continue if config_name: - config_image_out = output_dir / config_name + config_image_out = out_dir / config_name if not force and config_image_out.exists(): print(color("f{config_image_out} already exists, skipping", "red")) continue diff --git a/tools/rtk_fw_download.py b/tools/rtk_fw_download.py index fbf03dd67..b5ecc5981 100644 --- a/tools/rtk_fw_download.py +++ b/tools/rtk_fw_download.py @@ -21,11 +21,11 @@ import urllib.request import click -from bumble.tools import rtk_util import bumble.logging from bumble.colors import color from bumble.drivers import rtk +from tools import rtk_util # ----------------------------------------------------------------------------- # Logging @@ -67,7 +67,7 @@ def download_file(base_url, name, remove_suffix): # ----------------------------------------------------------------------------- -@click.command +@click.command() @click.option( "--output-dir", default="", @@ -84,16 +84,18 @@ def download_file(base_url, name, remove_suffix): @click.option("--single", help="Only download a single image set, by its base name") @click.option("--force", is_flag=True, help="Overwrite files if they already exist") @click.option("--parse", is_flag=True, help="Parse the FW image after saving") -def main(output_dir, source, single, force, parse): +def main( + output_dir: str, source: str, single: str | None, force: bool, parse: bool +) -> None: """Download RTK firmware images and configs.""" bumble.logging.setup_basic_logging() # Check that the output dir exists if output_dir == '': - output_dir = rtk.rtk_firmware_dir() + out_dir = rtk.rtk_firmware_dir() else: - output_dir = pathlib.Path(output_dir) - if not output_dir.is_dir(): + out_dir = pathlib.Path(output_dir) + if not out_dir.is_dir(): print("Output dir does not exist or is not a directory") return @@ -105,7 +107,7 @@ def main(output_dir, source, single, force, parse): print("Downloading") print(color("FROM:", "green"), base_url) - print(color("TO:", "green"), output_dir) + print(color("TO:", "green"), out_dir) if single: images = [(f"{single}_fw.bin", f"{single}_config.bin", True)] @@ -117,12 +119,12 @@ def main(output_dir, source, single, force, parse): for fw_name, config_name, config_needed in images: print(color("---", "yellow")) - fw_image_out = output_dir / fw_name + fw_image_out = out_dir / fw_name if not force and fw_image_out.exists(): print(color(f"{fw_image_out} already exists, skipping", "red")) continue if config_name: - config_image_out = output_dir / config_name + config_image_out = out_dir / config_name if not force and config_image_out.exists(): print(color("f{config_out} already exists, skipping", "red")) continue