Merge branch 'main' into update

This commit is contained in:
khsiao-google
2025-11-01 17:33:51 +08:00
13 changed files with 143 additions and 116 deletions

View File

@@ -121,6 +121,72 @@ class Connection:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Controller: class Controller:
hci_sink: Optional[TransportSink] = None
central_connections: dict[
Address, Connection
] # Connections where this controller is the central
peripheral_connections: dict[
Address, Connection
] # Connections where this controller is the peripheral
classic_connections: dict[Address, Connection] # Connections in BR/EDR
central_cis_links: dict[int, CisLink] # CIS links by handle
peripheral_cis_links: dict[int, CisLink] # CIS links by handle
hci_version: int = HCI_VERSION_BLUETOOTH_CORE_5_0
hci_revision: int = 0
lmp_version: int = HCI_VERSION_BLUETOOTH_CORE_5_0
lmp_subversion: int = 0
lmp_features: bytes = bytes.fromhex(
'0000000060000000'
) # BR/EDR Not Supported, LE Supported (Controller)
manufacturer_name: int = 0xFFFF
acl_data_packet_length: int = 27
total_num_acl_data_packets: int = 64
le_acl_data_packet_length: int = 27
total_num_le_acl_data_packets: int = 64
iso_data_packet_length: int = 960
total_num_iso_data_packets: int = 64
event_mask: int = 0
event_mask_page_2: int = 0
supported_commands: bytes = bytes.fromhex(
'2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
'30f0f9ff01008004002000000000000000000000000000000000000000000000'
)
le_event_mask: int = 0
advertising_parameters: Optional[hci.HCI_LE_Set_Advertising_Parameters_Command] = (
None
)
le_features: bytes = bytes.fromhex('ff49010000000000')
le_states: bytes = bytes.fromhex('ffff3fffff030000')
advertising_channel_tx_power: int = 0
filter_accept_list_size: int = 8
filter_duplicates: bool = False
resolving_list_size: int = 8
supported_max_tx_octets: int = 27
supported_max_tx_time: int = 10000
supported_max_rx_octets: int = 27
supported_max_rx_time: int = 10000
suggested_max_tx_octets: int = 27
suggested_max_tx_time: int = 0x0148
default_phy: dict[str, int]
le_scan_type: int = 0
le_scan_interval: int = 0x10
le_scan_window: int = 0x10
le_scan_enable: int = 0
le_scan_own_address_type: int = Address.RANDOM_DEVICE_ADDRESS
le_scanning_filter_policy: int = 0
le_scan_response_data: Optional[bytes] = None
le_address_resolution: bool = False
le_rpa_timeout: int = 0
sync_flow_control: bool = False
local_name: str = 'Bumble'
advertising_interval: int = 2000
advertising_data: Optional[bytes] = None
advertising_timer_handle: Optional[asyncio.Handle] = None
_random_address: 'Address' = Address('00:00:00:00:00:00')
def __init__( def __init__(
self, self,
name: str, name: str,
@@ -130,77 +196,18 @@ class Controller:
public_address: Optional[Union[bytes, str, Address]] = None, public_address: Optional[Union[bytes, str, Address]] = None,
) -> None: ) -> None:
self.name = name self.name = name
self.hci_sink: Optional[TransportSink] = None
self.link = link self.link = link
self.central_connections = {}
self.central_connections: dict[Address, Connection] = ( self.peripheral_connections = {}
{} self.classic_connections = {}
) # Connections where this controller is the central self.central_cis_links = {}
self.peripheral_connections: dict[Address, Connection] = ( self.peripheral_cis_links = {}
{}
) # Connections where this controller is the peripheral
self.classic_connections: dict[Address, Connection] = (
{}
) # Connections in BR/EDR
self.central_cis_links: dict[int, CisLink] = {} # CIS links by handle
self.peripheral_cis_links: dict[int, CisLink] = {} # CIS links by handle
self.hci_version = HCI_VERSION_BLUETOOTH_CORE_5_0
self.hci_revision = 0
self.lmp_version = HCI_VERSION_BLUETOOTH_CORE_5_0
self.lmp_subversion = 0
self.lmp_features = bytes.fromhex(
'0000000060000000'
) # BR/EDR Not Supported, LE Supported (Controller)
self.manufacturer_name = 0xFFFF
self.acl_data_packet_length = 27
self.total_num_acl_data_packets = 64
self.le_acl_data_packet_length = 27
self.total_num_le_acl_data_packets = 64
self.iso_data_packet_length = 960
self.total_num_iso_data_packets = 64
self.event_mask = b'\x00'
self.event_mask_page_2 = b'\x00'
self.supported_commands = bytes.fromhex(
'2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
'30f0f9ff01008004002000000000000000000000000000000000000000000000'
)
self.le_event_mask = b'\x00'
self.advertising_parameters = None
self.le_features = bytes.fromhex('ff49010000000000')
self.le_states = bytes.fromhex('ffff3fffff030000')
self.advertising_channel_tx_power = 0
self.filter_accept_list_size = 8
self.filter_duplicates = False
self.resolving_list_size = 8
self.supported_max_tx_octets = 27
self.supported_max_tx_time = 10000 # microseconds
self.supported_max_rx_octets = 27
self.supported_max_rx_time = 10000 # microseconds
self.suggested_max_tx_octets = 27
self.suggested_max_tx_time = 0x0148 # microseconds
self.default_phy = { self.default_phy = {
'all_phys': 0, 'all_phys': 0,
'tx_phys': 0, 'tx_phys': 0,
'rx_phys': 0, 'rx_phys': 0,
} }
self.le_scan_type = 0
self.le_scan_interval = 0x10
self.le_scan_window = 0x10
self.le_scan_enable = 0
self.le_scan_own_address_type = Address.RANDOM_DEVICE_ADDRESS
self.le_scanning_filter_policy = 0
self.le_scan_response_data = None
self.le_address_resolution = False
self.le_rpa_timeout = 0
self.sync_flow_control = False
self.local_name = 'Bumble'
self.advertising_interval = 2000 # Fixed for now
self.advertising_data: Optional[bytes] = None
self.advertising_timer_handle: Optional[asyncio.Handle] = None
self._random_address = Address('00:00:00:00:00:00')
if isinstance(public_address, Address): if isinstance(public_address, Address):
self._public_address = public_address self._public_address = public_address
elif public_address is not None: elif public_address is not None:
@@ -489,8 +496,8 @@ class Controller:
logger.debug( logger.debug(
f'New CENTRAL connection handle: 0x{connection_handle:04X}' f'New CENTRAL connection handle: 0x{connection_handle:04X}'
) )
else: else:
connection = None connection = None
# Say that the connection has completed # Say that the connection has completed
self.send_hci_packet( self.send_hci_packet(
@@ -1117,7 +1124,9 @@ class Controller:
''' '''
See Bluetooth spec Vol 4, Part E - 7.3.1 Set Event Mask Command See Bluetooth spec Vol 4, Part E - 7.3.1 Set Event Mask Command
''' '''
self.event_mask = command.event_mask self.event_mask = int.from_bytes(
command.event_mask, byteorder='little', signed=False
)
return bytes([HCI_SUCCESS]) return bytes([HCI_SUCCESS])
def on_hci_reset_command(self, _command: hci.HCI_Reset_Command) -> Optional[bytes]: def on_hci_reset_command(self, _command: hci.HCI_Reset_Command) -> Optional[bytes]:
@@ -1245,7 +1254,9 @@ class Controller:
''' '''
See Bluetooth spec Vol 4, Part E - 7.3.69 Set Event Mask Page 2 Command See Bluetooth spec Vol 4, Part E - 7.3.69 Set Event Mask Page 2 Command
''' '''
self.event_mask_page_2 = command.event_mask_page_2 self.event_mask_page_2 = int.from_bytes(
command.event_mask_page_2, byteorder='little', signed=False
)
return bytes([HCI_SUCCESS]) return bytes([HCI_SUCCESS])
def on_hci_read_le_host_support_command( def on_hci_read_le_host_support_command(
@@ -1414,7 +1425,9 @@ class Controller:
''' '''
See Bluetooth spec Vol 4, Part E - 7.8.1 LE Set Event Mask Command See Bluetooth spec Vol 4, Part E - 7.8.1 LE Set Event Mask Command
''' '''
self.le_event_mask = command.le_event_mask self.le_event_mask = int.from_bytes(
command.le_event_mask, byteorder='little', signed=False
)
return bytes([HCI_SUCCESS]) return bytes([HCI_SUCCESS])
def on_hci_le_read_buffer_size_command( def on_hci_le_read_buffer_size_command(
@@ -1940,7 +1953,8 @@ class Controller:
''' '''
# Remove old CIG implicitly. # Remove old CIG implicitly.
for handle, cis_link in list(self.central_cis_links.items()): cis_links = list(self.central_cis_links.items())
for handle, cis_link in cis_links:
if cis_link.cig_id == command.cig_id: if cis_link.cig_id == command.cig_id:
self.central_cis_links.pop(handle) self.central_cis_links.pop(handle)
@@ -2004,7 +2018,8 @@ class Controller:
status = HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR status = HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR
for cis_handle, cis_link in list(self.central_cis_links.items()): cis_links = list(self.central_cis_links.items())
for cis_handle, cis_link in cis_links:
if cis_link.cig_id == command.cig_id: if cis_link.cig_id == command.cig_id:
self.central_cis_links.pop(cis_handle) self.central_cis_links.pop(cis_handle)
status = HCI_SUCCESS status = HCI_SUCCESS

View File

@@ -22,6 +22,7 @@ import contextlib
import io import io
import logging import logging
import struct import struct
from collections.abc import Awaitable, Callable
from typing import Any, ContextManager, Optional, Protocol from typing import Any, ContextManager, Optional, Protocol
from bumble import core, hci from bumble import core, hci
@@ -389,15 +390,17 @@ class PumpedPacketSource(ParserSource):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class PumpedPacketSink: class PumpedPacketSink:
def __init__(self, send): pump_task: Optional[asyncio.Task[None]]
def __init__(self, send: Callable[[bytes], Awaitable[Any]]):
self.send_function = send self.send_function = send
self.packet_queue = asyncio.Queue() self.packet_queue = asyncio.Queue[bytes]()
self.pump_task = None self.pump_task = None
def on_packet(self, packet: bytes) -> None: def on_packet(self, packet: bytes) -> None:
self.packet_queue.put_nowait(packet) self.packet_queue.put_nowait(packet)
def start(self): def start(self) -> None:
async def pump_packets(): async def pump_packets():
while True: while True:
try: try:

View File

@@ -17,7 +17,7 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import logging import logging
import websockets.client import websockets.asyncio.client
from bumble.transport.common import ( from bumble.transport.common import (
PumpedPacketSink, PumpedPacketSink,
@@ -42,7 +42,7 @@ async def open_ws_client_transport(spec: str) -> Transport:
Example: ws://localhost:7681/v1/websocket/bt Example: ws://localhost:7681/v1/websocket/bt
''' '''
websocket = await websockets.client.connect(spec) websocket = await websockets.asyncio.client.connect(spec)
class WsTransport(PumpedTransport): class WsTransport(PumpedTransport):
async def close(self): async def close(self):

View File

@@ -16,8 +16,9 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import logging import logging
from typing import Optional
import websockets import websockets.asyncio.server
from bumble.transport.common import ParserSource, PumpedPacketSink, Transport from bumble.transport.common import ParserSource, PumpedPacketSink, Transport
@@ -40,7 +41,12 @@ async def open_ws_server_transport(spec: str) -> Transport:
''' '''
class WsServerTransport(Transport): class WsServerTransport(Transport):
def __init__(self): sink: PumpedPacketSink
source: ParserSource
connection: Optional[websockets.asyncio.server.ServerConnection]
server: Optional[websockets.asyncio.server.Server]
def __init__(self) -> None:
source = ParserSource() source = ParserSource()
sink = PumpedPacketSink(self.send_packet) sink = PumpedPacketSink(self.send_packet)
self.connection = None self.connection = None
@@ -48,17 +54,19 @@ async def open_ws_server_transport(spec: str) -> Transport:
super().__init__(source, sink) super().__init__(source, sink)
async def serve(self, local_host, local_port): async def serve(self, local_host: str, local_port: str) -> None:
self.sink.start() self.sink.start()
# pylint: disable-next=no-member # pylint: disable-next=no-member
self.server = await websockets.serve( self.server = await websockets.asyncio.server.serve(
ws_handler=self.on_connection, handler=self.on_connection,
host=local_host if local_host != '_' else None, host=local_host if local_host != '_' else None,
port=int(local_port), port=int(local_port),
) )
logger.debug(f'websocket server ready on port {local_port}') logger.debug(f'websocket server ready on port {local_port}')
async def on_connection(self, connection): async def on_connection(
self, connection: websockets.asyncio.server.ServerConnection
) -> None:
logger.debug( logger.debug(
f'new connection on {connection.local_address} ' f'new connection on {connection.local_address} '
f'from {connection.remote_address}' f'from {connection.remote_address}'
@@ -77,11 +85,11 @@ async def open_ws_server_transport(spec: str) -> Transport:
# We're now disconnected # We're now disconnected
self.connection = None self.connection = None
async def send_packet(self, packet): async def send_packet(self, packet: bytes) -> None:
if self.connection is None: if self.connection is None:
logger.debug('no connection, dropping packet') logger.debug('no connection, dropping packet')
return return
return await self.connection.send(packet) await self.connection.send(packet)
local_host, local_port = spec.rsplit(':', maxsplit=1) local_host, local_port = spec.rsplit(':', maxsplit=1)
transport = WsServerTransport() transport = WsServerTransport()

View File

@@ -20,7 +20,7 @@ import json
import struct import struct
import sys import sys
import websockets import websockets.asyncio.server
import bumble.logging import bumble.logging
from bumble import data_types from bumble import data_types
@@ -367,7 +367,7 @@ async def keyboard_device(device, command):
if command == 'web': if command == 'web':
# Start a Websocket server to receive events from a web page # Start a Websocket server to receive events from a web page
async def serve(websocket, _path): async def serve(websocket: websockets.asyncio.server.ServerConnection):
while True: while True:
try: try:
message = await websocket.recv() message = await websocket.recv()
@@ -398,7 +398,7 @@ async def keyboard_device(device, command):
pass pass
# pylint: disable-next=no-member # pylint: disable-next=no-member
await websockets.serve(serve, 'localhost', 8989) await websockets.asyncio.server.serve(serve, 'localhost', 8989)
await asyncio.get_event_loop().create_future() await asyncio.get_event_loop().create_future()
else: else:
message = bytes('hello', 'ascii') message = bytes('hello', 'ascii')

View File

@@ -20,7 +20,7 @@ import logging
import sys import sys
from typing import Optional from typing import Optional
import websockets import websockets.asyncio.server
import bumble.logging import bumble.logging
from bumble import data_types, decoder, gatt from bumble import data_types, decoder, gatt
@@ -29,12 +29,11 @@ from bumble.device import AdvertisingParameters, Device
from bumble.profiles import asha from bumble.profiles import asha
from bumble.transport import open_transport from bumble.transport import open_transport
ws_connection: Optional[websockets.WebSocketServerProtocol] = None ws_connection: Optional[websockets.asyncio.server.ServerConnection] = None
g722_decoder = decoder.G722Decoder() g722_decoder = decoder.G722Decoder()
async def ws_server(ws_client: websockets.WebSocketServerProtocol, path: str): async def ws_server(ws_client: websockets.asyncio.server.ServerConnection):
del path
global ws_connection global ws_connection
ws_connection = ws_client ws_connection = ws_client
@@ -100,7 +99,7 @@ async def main() -> None:
), ),
) )
await websockets.serve(ws_server, port=8888) await websockets.asyncio.server.serve(ws_server, port=8888)
await hci_transport.source.terminated await hci_transport.source.terminated

View File

@@ -21,8 +21,9 @@ import asyncio
import json import json
import logging import logging
import sys import sys
from typing import Optional
import websockets import websockets.asyncio.server
import bumble.logging import bumble.logging
from bumble import a2dp, avc, avdtp, avrcp, utils from bumble import a2dp, avc, avdtp, avrcp, utils
@@ -217,6 +218,8 @@ def on_avrcp_start(avrcp_protocol: avrcp.Protocol, websocket_server: WebSocketSe
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class WebSocketServer: class WebSocketServer:
socket: Optional[websockets.asyncio.server.ServerConnection]
def __init__( def __init__(
self, avrcp_protocol: avrcp.Protocol, avrcp_delegate: Delegate self, avrcp_protocol: avrcp.Protocol, avrcp_delegate: Delegate
) -> None: ) -> None:
@@ -227,9 +230,9 @@ class WebSocketServer:
async def start(self) -> None: async def start(self) -> None:
# pylint: disable-next=no-member # pylint: disable-next=no-member
await websockets.serve(self.serve, 'localhost', 8989) # type: ignore await websockets.asyncio.server.serve(self.serve, 'localhost', 8989) # type: ignore
async def serve(self, socket, _path) -> None: async def serve(self, socket: websockets.asyncio.server.ServerConnection) -> None:
print('### WebSocket connected') print('### WebSocket connected')
self.socket = socket self.socket = socket
while True: while True:

View File

@@ -22,7 +22,7 @@ import logging
import sys import sys
from typing import Iterable, Optional from typing import Iterable, Optional
import websockets import websockets.asyncio.server
import bumble.core import bumble.core
import bumble.logging import bumble.logging
@@ -33,7 +33,7 @@ from bumble.transport import open_transport
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ws: Optional[websockets.WebSocketServerProtocol] = None ws: Optional[websockets.asyncio.server.ServerConnection] = None
ag_protocol: Optional[hfp.AgProtocol] = None ag_protocol: Optional[hfp.AgProtocol] = None
source_file: Optional[io.BufferedReader] = None source_file: Optional[io.BufferedReader] = None
@@ -114,8 +114,7 @@ def on_hfp_state_change(connected: bool):
send_message(type='hfp_state_change', connected=connected) send_message(type='hfp_state_change', connected=connected)
async def ws_server(ws_client: websockets.WebSocketServerProtocol, path: str): async def ws_server(ws_client: websockets.asyncio.server.ServerConnection):
del path
global ws global ws
ws = ws_client ws = ws_client
@@ -273,7 +272,7 @@ async def main() -> None:
on_dlc(session) on_dlc(session)
await websockets.serve(ws_server, port=8888) await websockets.asyncio.server.serve(ws_server, port=8888)
if len(sys.argv) >= 5: if len(sys.argv) >= 5:
global source_file global source_file

View File

@@ -22,7 +22,7 @@ import json
import sys import sys
from typing import Optional from typing import Optional
import websockets import websockets.asyncio.server
import bumble.logging import bumble.logging
from bumble import hci, hfp, rfcomm from bumble import hci, hfp, rfcomm
@@ -30,7 +30,7 @@ from bumble.device import Connection, Device
from bumble.hfp import HfProtocol from bumble.hfp import HfProtocol
from bumble.transport import open_transport from bumble.transport import open_transport
ws: Optional[websockets.WebSocketServerProtocol] = None ws: Optional[websockets.asyncio.server.ServerConnection] = None
hf_protocol: Optional[HfProtocol] = None hf_protocol: Optional[HfProtocol] = None
@@ -143,7 +143,7 @@ async def main() -> None:
await device.set_connectable(True) await device.set_connectable(True)
# Start the UI websocket server to offer a few buttons and input boxes # Start the UI websocket server to offer a few buttons and input boxes
async def serve(websocket: websockets.WebSocketServerProtocol, _path): async def serve(websocket: websockets.asyncio.server.ServerConnection):
global ws global ws
ws = websocket ws = websocket
async for message in websocket: async for message in websocket:
@@ -166,7 +166,7 @@ async def main() -> None:
response = str(await hf_protocol.query_current_calls()) response = str(await hf_protocol.query_current_calls())
await websocket.send(response) await websocket.send(response)
await websockets.serve(serve, 'localhost', 8989) await websockets.asyncio.server.serve(serve, 'localhost', 8989)
await hci_transport.source.wait_for_termination() await hci_transport.source.wait_for_termination()

View File

@@ -20,7 +20,7 @@ import json
import struct import struct
import sys import sys
import websockets import websockets.asyncio.server
import bumble.logging import bumble.logging
from bumble.core import ( from bumble.core import (
@@ -425,7 +425,7 @@ deviceData = DeviceData()
async def keyboard_device(hid_device: HID_Device): async def keyboard_device(hid_device: HID_Device):
# Start a Websocket server to receive events from a web page # Start a Websocket server to receive events from a web page
async def serve(websocket, _path): async def serve(websocket: websockets.asyncio.server.ServerConnection):
global deviceData global deviceData
while True: while True:
try: try:
@@ -476,7 +476,7 @@ async def keyboard_device(hid_device: HID_Device):
pass pass
# pylint: disable-next=no-member # pylint: disable-next=no-member
await websockets.serve(serve, 'localhost', 8989) await websockets.asyncio.server.serve(serve, 'localhost', 8989)
await asyncio.get_event_loop().create_future() await asyncio.get_event_loop().create_future()

View File

@@ -20,7 +20,7 @@ import json
import sys import sys
from typing import Optional from typing import Optional
import websockets import websockets.asyncio.server
import bumble.logging import bumble.logging
from bumble import data_types from bumble import data_types
@@ -101,7 +101,7 @@ async def main() -> None:
) )
device.add_service(AudioStreamControlService(device, sink_ase_id=[1])) device.add_service(AudioStreamControlService(device, sink_ase_id=[1]))
ws: Optional[websockets.WebSocketServerProtocol] = None ws: Optional[websockets.asyncio.server.ServerConnection] = None
mcp: Optional[MediaControlServiceProxy] = None mcp: Optional[MediaControlServiceProxy] = None
advertising_data = bytes( advertising_data = bytes(
@@ -162,7 +162,7 @@ async def main() -> None:
device.on('connection', on_connection) device.on('connection', on_connection)
async def serve(websocket: websockets.WebSocketServerProtocol, _path): async def serve(websocket: websockets.asyncio.server.ServerConnection):
nonlocal ws nonlocal ws
ws = websocket ws = websocket
async for message in websocket: async for message in websocket:
@@ -173,7 +173,7 @@ async def main() -> None:
) )
ws = None ws = None
await websockets.serve(serve, 'localhost', 8989) await websockets.asyncio.server.serve(serve, 'localhost', 8989)
await hci_transport.source.terminated await hci_transport.source.terminated

View File

@@ -21,7 +21,7 @@ import secrets
import sys import sys
from typing import Optional from typing import Optional
import websockets import websockets.asyncio.server
import bumble.logging import bumble.logging
from bumble import data_types from bumble import data_types
@@ -110,7 +110,7 @@ async def main() -> None:
vcs = VolumeControlService() vcs = VolumeControlService()
device.add_service(vcs) device.add_service(vcs)
ws: Optional[websockets.WebSocketServerProtocol] = None ws: Optional[websockets.asyncio.server.ServerConnection] = None
def on_volume_state_change(): def on_volume_state_change():
if ws: if ws:
@@ -152,7 +152,7 @@ async def main() -> None:
advertising_data=advertising_data, advertising_data=advertising_data,
) )
async def serve(websocket: websockets.WebSocketServerProtocol, _path): async def serve(websocket: websockets.asyncio.server.ServerConnection):
nonlocal ws nonlocal ws
await websocket.send( await websocket.send(
dumps_volume_state(vcs.volume_setting, vcs.muted, vcs.change_counter) dumps_volume_state(vcs.volume_setting, vcs.muted, vcs.change_counter)
@@ -166,7 +166,7 @@ async def main() -> None:
await device.notify_subscribers(vcs.volume_state) await device.notify_subscribers(vcs.volume_state)
ws = None ws = None
await websockets.serve(serve, 'localhost', 8989) await websockets.asyncio.server.serve(serve, 'localhost', 8989)
await hci_transport.source.terminated await hci_transport.source.terminated

View File

@@ -32,7 +32,7 @@ dependencies = [
"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'",
"pyusb >= 1.2; platform_system!='Emscripten'", "pyusb >= 1.2; platform_system!='Emscripten'",
"websockets == 13.1; platform_system!='Emscripten'", "websockets >= 15.0.1; platform_system!='Emscripten'",
] ]
[project.optional-dependencies] [project.optional-dependencies]