Compare commits

...

31 Commits

Author SHA1 Message Date
zxzxwu
d112901a17 Merge pull request #814 from zxzxwu/hid-fix
Fix wrong HID PSM
2025-11-04 15:20:20 +08:00
Josh Wu
2d74aef0e9 Fix wrong HID PSM 2025-11-04 01:36:07 +08:00
khsiao-google
f06e19e1ca Merge pull request #809 from khsiao-google/update
[Typing] Add controller.py typing
2025-11-03 18:58:13 +08:00
khsiao-google
36aefb280d Merge branch 'main' into update 2025-11-03 09:37:44 +00:00
zxzxwu
227f5cf62e Merge pull request #783 from zxzxwu/avrcp
AVCTP: Change callback packet type to bytes
2025-11-03 15:40:18 +08:00
Gilles Boccon-Gibod
1336cfa42c Merge pull request #813 from XenoKovah/main
Trivial change: Sorting VID/PIDs and adding new values
2025-11-02 19:08:45 +01:00
Xeno Kovah
0ca7b8b322 Sorting VID/PIDs and adding observed values on ZEXMTE (https://zexmtebluetooth.com/#Products) devices 2025-11-02 12:36:46 -05:00
Josh Wu
eef5304a36 AVCTP: Change callback packet type to bytes 2025-11-02 18:03:25 +08:00
khsiao-google
1a2141126c [Typing] Add controller.py typing 2025-11-01 09:30:36 +00:00
zxzxwu
19b7660f88 Merge pull request #812 from markusjellitsch/fix/controller-dict-remove
Fix: RuntimeError in controller.py
2025-11-01 00:05:20 +08:00
zxzxwu
1932f14fb6 Merge pull request #811 from zxzxwu/websockets
Upgrade websockets dependency to 15.0.1+
2025-11-01 00:05:06 +08:00
markus
b70b92097f fix RuntimeError: dictionary change during iteration 2025-10-31 11:56:31 +01:00
Josh Wu
d43f5573a6 Upgrade websockets dependency to 15.0.1+ 2025-10-31 17:35:13 +08:00
zxzxwu
1982168a9f Merge pull request #806 from zxzxwu/avrcp-response
AVRCP: Reply ACCEPTED on set absolute volume
2025-10-28 14:39:26 +08:00
Josh Wu
5e1794a15b AVRCP: Reply ACCEPTED on set absolute volume 2025-10-28 00:05:18 +08:00
Gilles Boccon-Gibod
578f7f054d Merge pull request #804 from graynode/rfcomm-tx-credit-goes-negative-fix
Fixed bug where it's possible for rfcomm tx_credit to go negative resulting in l2cap disconnect from peripheral
2025-10-26 14:25:29 +01:00
graynode
4b25b3581d updated per PR input 2025-10-24 10:09:02 -04:00
graynode
9601c7f287 fixed formatting issue 2025-10-24 09:30:45 -04:00
graynode
dae3ec5cba Fixed bug where it's possible for tx_credit to goe negative 2025-10-23 21:56:00 -04:00
zxzxwu
95225a1774 Merge pull request #803 from zxzxwu/avdtp
AVDTP: Migrate enums
2025-10-23 13:45:48 +08:00
Josh Wu
e54a26393e AVDTP: Add missing type annotations 2025-10-22 20:54:28 +08:00
Josh Wu
5dc76cf7b4 Migrate AVDTP enums 2025-10-22 20:41:51 +08:00
zxzxwu
6c68115660 Merge pull request #799 from zxzxwu/avdtp
Migrate AVDTP packets to dataclasses
2025-10-22 20:01:08 +08:00
zxzxwu
88ef65a4e2 Merge pull request #798 from khsiao-google/update
HFP: Change configuration attribute types to Sequence
2025-10-22 13:52:20 +08:00
zxzxwu
324b26d8f2 Merge pull request #801 from zyanwu-google/feat/intel_ddc
feat(intel): clarify firmware/DDC flow and preserve driver metadata
2025-10-22 13:51:16 +08:00
Josh Wu
a43b403511 Migrate AVDTP packets to dataclasses 2025-10-21 18:54:48 +08:00
zyanwu-google
c657494362 feat(intel): clarify firmware/DDC flow and preserve driver metadata
- Add explanatory comments across intel driver to clarify metadata parsing.
- Ensure driver selection preserves runtime options (e.g. "intel/ddc_override:AABB")
  so driver-specific metadata is passed through to the host and available to
  drivers via host.hci_metadata.
- Ensure transport parsing regex and metadata extraction so transport/source
  metadata is populated and visible to drivers.
- Example usage: passing [driver=intel/ddc_override:AABB] will be preserved and
  can be consumed by the Intel driver to apply a DDC override blob.
2025-10-21 09:00:38 +00:00
khsiao-google
11505f08b7 [Typing] Change to Sequence 2025-10-20 08:47:40 +00:00
khsiao-google
9bf9ed5f59 [Typing] Change list to Iterable 2025-10-10 15:32:06 +00:00
zxzxwu
0fa517a4f6 Merge pull request #793 from zain2983/main
Minor fixes
2025-10-03 15:54:13 +08:00
Z1
a11962a487 Minor fixes 2025-10-02 19:26:30 +00:00
30 changed files with 1358 additions and 934 deletions

View File

@@ -50,7 +50,7 @@ Bumble is easiest to use with a dedicated USB dongle.
This is because internal Bluetooth interfaces tend to be locked down by the operating system.
You can use the [usb_probe](/docs/mkdocs/src/apps_and_tools/usb_probe.md) tool (all platforms) or `lsusb` (Linux or macOS) to list the available USB devices on your system.
See the [USB Transport](/docs/mkdocs/src/transports/usb.md) page for details on how to refer to USB devices. Also, if your are on a mac, see [these instructions](docs/mkdocs/src/platforms/macos.md).
See the [USB Transport](/docs/mkdocs/src/transports/usb.md) page for details on how to refer to USB devices. Also, if you are on a mac, see [these instructions](docs/mkdocs/src/platforms/macos.md).
## License

View File

@@ -21,11 +21,12 @@ import dataclasses
import enum
import logging
import struct
from collections.abc import AsyncGenerator
from typing import Awaitable, Callable
from collections.abc import AsyncGenerator, Awaitable, Callable
from typing import Union
from typing_extensions import ClassVar, Self
from bumble import utils
from bumble.codecs import AacAudioRtpPacket
from bumble.company_ids import COMPANY_IDENTIFIERS
from bumble.core import (
@@ -59,19 +60,18 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# fmt: off
A2DP_SBC_CODEC_TYPE = 0x00
A2DP_MPEG_1_2_AUDIO_CODEC_TYPE = 0x01
A2DP_MPEG_2_4_AAC_CODEC_TYPE = 0x02
A2DP_ATRAC_FAMILY_CODEC_TYPE = 0x03
A2DP_NON_A2DP_CODEC_TYPE = 0xFF
class CodecType(utils.OpenIntEnum):
SBC = 0x00
MPEG_1_2_AUDIO = 0x01
MPEG_2_4_AAC = 0x02
ATRAC_FAMILY = 0x03
NON_A2DP = 0xFF
A2DP_CODEC_TYPE_NAMES = {
A2DP_SBC_CODEC_TYPE: 'A2DP_SBC_CODEC_TYPE',
A2DP_MPEG_1_2_AUDIO_CODEC_TYPE: 'A2DP_MPEG_1_2_AUDIO_CODEC_TYPE',
A2DP_MPEG_2_4_AAC_CODEC_TYPE: 'A2DP_MPEG_2_4_AAC_CODEC_TYPE',
A2DP_ATRAC_FAMILY_CODEC_TYPE: 'A2DP_ATRAC_FAMILY_CODEC_TYPE',
A2DP_NON_A2DP_CODEC_TYPE: 'A2DP_NON_A2DP_CODEC_TYPE'
}
A2DP_SBC_CODEC_TYPE = CodecType.SBC
A2DP_MPEG_1_2_AUDIO_CODEC_TYPE = CodecType.MPEG_1_2_AUDIO
A2DP_MPEG_2_4_AAC_CODEC_TYPE = CodecType.MPEG_2_4_AAC
A2DP_ATRAC_FAMILY_CODEC_TYPE = CodecType.ATRAC_FAMILY
A2DP_NON_A2DP_CODEC_TYPE = CodecType.NON_A2DP
SBC_SYNC_WORD = 0x9C
@@ -259,9 +259,48 @@ def make_audio_sink_service_sdp_records(service_record_handle, version=(1, 3)):
]
# -----------------------------------------------------------------------------
class MediaCodecInformation:
'''Base Media Codec Information.'''
@classmethod
def create(
cls, media_codec_type: int, data: bytes
) -> Union[MediaCodecInformation, bytes]:
if media_codec_type == CodecType.SBC:
return SbcMediaCodecInformation.from_bytes(data)
elif media_codec_type == CodecType.MPEG_2_4_AAC:
return AacMediaCodecInformation.from_bytes(data)
elif media_codec_type == CodecType.NON_A2DP:
vendor_media_codec_information = (
VendorSpecificMediaCodecInformation.from_bytes(data)
)
if (
vendor_class_map := A2DP_VENDOR_MEDIA_CODEC_INFORMATION_CLASSES.get(
vendor_media_codec_information.vendor_id
)
) and (
media_codec_information_class := vendor_class_map.get(
vendor_media_codec_information.codec_id
)
):
return media_codec_information_class.from_bytes(
vendor_media_codec_information.value
)
return vendor_media_codec_information
@classmethod
def from_bytes(cls, data: bytes) -> Self:
del data # Unused.
raise NotImplementedError
def __bytes__(self) -> bytes:
raise NotImplementedError
# -----------------------------------------------------------------------------
@dataclasses.dataclass
class SbcMediaCodecInformation:
class SbcMediaCodecInformation(MediaCodecInformation):
'''
A2DP spec - 4.3.2 Codec Specific Information Elements
'''
@@ -345,7 +384,7 @@ class SbcMediaCodecInformation:
# -----------------------------------------------------------------------------
@dataclasses.dataclass
class AacMediaCodecInformation:
class AacMediaCodecInformation(MediaCodecInformation):
'''
A2DP spec - 4.5.2 Codec Specific Information Elements
'''
@@ -427,7 +466,7 @@ class AacMediaCodecInformation:
@dataclasses.dataclass
# -----------------------------------------------------------------------------
class VendorSpecificMediaCodecInformation:
class VendorSpecificMediaCodecInformation(MediaCodecInformation):
'''
A2DP spec - 4.7.2 Codec Specific Information Elements
'''

View File

@@ -19,10 +19,11 @@ from __future__ import annotations
import logging
import struct
from collections.abc import Callable
from enum import IntEnum
from typing import Callable, Optional, cast
from typing import Optional
from bumble import avc, core, l2cap
from bumble import core, l2cap
from bumble.colors import color
# -----------------------------------------------------------------------------
@@ -144,9 +145,9 @@ class MessageAssembler:
# -----------------------------------------------------------------------------
class Protocol:
CommandHandler = Callable[[int, avc.CommandFrame], None]
CommandHandler = Callable[[int, bytes], None]
command_handlers: dict[int, CommandHandler] # Command handlers, by PID
ResponseHandler = Callable[[int, Optional[avc.ResponseFrame]], None]
ResponseHandler = Callable[[int, Optional[bytes]], None]
response_handlers: dict[int, ResponseHandler] # Response handlers, by PID
next_transaction_label: int
message_assembler: MessageAssembler
@@ -204,20 +205,15 @@ class Protocol:
self.send_ipid(transaction_label, pid)
return
command_frame = cast(avc.CommandFrame, avc.Frame.from_bytes(payload))
self.command_handlers[pid](transaction_label, command_frame)
self.command_handlers[pid](transaction_label, payload)
else:
if pid not in self.response_handlers:
logger.warning(f"no response handler for PID {pid}")
return
# By convention, for an ipid, send a None payload to the response handler.
if ipid:
response_frame = None
else:
response_frame = cast(avc.ResponseFrame, avc.Frame.from_bytes(payload))
self.response_handlers[pid](transaction_label, response_frame)
response_payload = None if ipid else payload
self.response_handlers[pid](transaction_label, response_payload)
def send_message(
self,

File diff suppressed because it is too large Load Diff

View File

@@ -22,21 +22,9 @@ import enum
import functools
import logging
import struct
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from dataclasses import dataclass, field
from typing import (
AsyncIterator,
Awaitable,
Callable,
ClassVar,
Iterable,
List,
Optional,
Sequence,
SupportsBytes,
TypeVar,
Union,
cast,
)
from typing import ClassVar, Optional, SupportsBytes, TypeVar, Union
from bumble import avc, avctp, core, hci, l2cap, utils
from bumble.colors import color
@@ -1762,7 +1750,11 @@ class Protocol(utils.EventEmitter):
),
)
response = self._check_response(response_context, GetCapabilitiesResponse)
return cast(List[EventId], response.capabilities)
return list(
capability
for capability in response.capabilities
if isinstance(capability, EventId)
)
async def get_play_status(self) -> SongAndPlayStatus:
"""Get the play status of the connected peer."""
@@ -2012,9 +2004,12 @@ class Protocol(utils.EventEmitter):
self.emit(self.EVENT_STOP)
def _on_avctp_command(
self, transaction_label: int, command: avc.CommandFrame
) -> None:
def _on_avctp_command(self, transaction_label: int, payload: bytes) -> None:
command = avc.CommandFrame.from_bytes(payload)
if not isinstance(command, avc.CommandFrame):
raise core.InvalidPacketError(
f"{command} is not a valid AV/C Command Frame"
)
logger.debug(
f"<<< AVCTP Command, transaction_label={transaction_label}: " f"{command}"
)
@@ -2073,8 +2068,13 @@ class Protocol(utils.EventEmitter):
self.send_not_implemented_response(transaction_label, command)
def _on_avctp_response(
self, transaction_label: int, response: Optional[avc.ResponseFrame]
self, transaction_label: int, payload: Optional[bytes]
) -> None:
response = avc.ResponseFrame.from_bytes(payload) if payload else None
if not isinstance(response, avc.ResponseFrame):
raise core.InvalidPacketError(
f"{response} is not a valid AV/C Response Frame"
)
logger.debug(
f"<<< AVCTP Response, transaction_label={transaction_label}: {response}"
)
@@ -2391,7 +2391,7 @@ class Protocol(utils.EventEmitter):
effective_volume = await self.delegate.get_absolute_volume()
self.send_avrcp_response(
transaction_label,
avc.ResponseFrame.ResponseCode.IMPLEMENTED_OR_STABLE,
avc.ResponseFrame.ResponseCode.ACCEPTED,
SetAbsoluteVolumeResponse(effective_volume),
)

File diff suppressed because it is too large Load Diff

View File

@@ -2263,8 +2263,6 @@ class Device(utils.CompositeEventEmitter):
EVENT_CONNECTION_FAILURE = "connection_failure"
EVENT_SCO_REQUEST = "sco_request"
EVENT_INQUIRY_COMPLETE = "inquiry_complete"
EVENT_REMOTE_NAME = "remote_name"
EVENT_REMOTE_NAME_FAILURE = "remote_name_failure"
EVENT_SCO_CONNECTION = "sco_connection"
EVENT_SCO_CONNECTION_FAILURE = "sco_connection_failure"
EVENT_CIS_REQUEST = "cis_request"

View File

@@ -49,6 +49,10 @@ async def get_driver_for_host(host: Host) -> Optional[Driver]:
driver_classes: dict[str, type[Driver]] = {"rtk": rtk.Driver, "intel": intel.Driver}
probe_list: Iterable[str]
if driver_name := host.hci_metadata.get("driver"):
# The "driver" metadata may include runtime options after a '/' (for example
# "intel/ddc=..."). Keep only the base driver name (the portion before the
# first slash) so it matches a key in driver_classes (e.g. "intel").
driver_name = driver_name.split("/")[0]
# Only probe a single driver
probe_list = [driver_name]
else:

View File

@@ -459,6 +459,10 @@ class Driver(common.Driver):
== ModeOfOperation.OPERATIONAL
):
logger.debug("firmware already loaded")
# If the firmeare is already loaded, still attempt to load any
# device configuration (DDC). DDC can be applied independently of a
# firmware reload and may contain runtime overrides or patches.
await self.load_ddc_if_any()
return
# We only support some platforms and variants.
@@ -598,17 +602,39 @@ class Driver(common.Driver):
await self.reset_complete.wait()
logger.debug("reset complete")
# Load the device config if there is one.
await self.load_ddc_if_any(firmware_base_name)
async def load_ddc_if_any(self, firmware_base_name: Optional[str] = None) -> None:
"""
Check for and load any Device Data Configuration (DDC) blobs.
Args:
firmware_base_name: Base name of the selected firmware (e.g. "ibt-XXXX-YYYY").
If None, don't attempt to look up a .ddc file that
corresponds to the firmware image.
Priority:
1. If a ddc_override was provided via driver metadata, use it (highest priority).
2. Otherwise, if firmware_base_name is provided, attempt to find a .ddc file
that corresponds to the selected firmware image.
3. Finally, if a ddc_addon was provided, append/load it after the primary DDC.
"""
# If an explicit DDC override was supplied, use it and skip file lookup.
if self.ddc_override:
logger.debug("loading overridden DDC")
await self.load_device_config(self.ddc_override)
else:
ddc_name = f"{firmware_base_name}.ddc"
ddc_path = _find_binary_path(ddc_name)
if ddc_path:
logger.debug(f"loading DDC from {ddc_path}")
ddc_data = ddc_path.read_bytes()
await self.load_device_config(ddc_data)
# Only attempt .ddc file lookup if a firmware_base_name was provided.
if firmware_base_name is None:
logger.debug(
"no firmware_base_name provided; skipping .ddc file lookup"
)
else:
ddc_name = f"{firmware_base_name}.ddc"
ddc_path = _find_binary_path(ddc_name)
if ddc_path:
logger.debug(f"loading DDC from {ddc_path}")
ddc_data = ddc_path.read_bytes()
await self.load_device_config(ddc_data)
if self.ddc_addon:
logger.debug("loading DDC addon")
await self.load_device_config(self.ddc_addon)

View File

@@ -115,12 +115,14 @@ RTK_USB_PRODUCTS = {
# Realtek 8761BUV
(0x0B05, 0x190E),
(0x0BDA, 0x8771),
(0x0BDA, 0x877B),
(0x0BDA, 0xA728),
(0x0BDA, 0xA729),
(0x2230, 0x0016),
(0x2357, 0x0604),
(0x2550, 0x8761),
(0x2B89, 0x8761),
(0x7392, 0xC611),
(0x0BDA, 0x877B),
# Realtek 8821AE
(0x0B05, 0x17DC),
(0x13D3, 0x3414),

View File

@@ -3441,6 +3441,17 @@ class HCI_Write_Synchronous_Flow_Control_Enable_Command(HCI_Command):
synchronous_flow_control_enable: int = field(metadata=metadata(1))
# -----------------------------------------------------------------------------
@HCI_Command.command
@dataclasses.dataclass
class HCI_Set_Controller_To_Host_Flow_Control_Command(HCI_Command):
'''
See Bluetooth spec @ 7.3.38 Set Controller To Host Flow Control command
'''
flow_control_enable: int = field(metadata=metadata(1))
# -----------------------------------------------------------------------------
@HCI_Command.command
@dataclasses.dataclass
@@ -4338,6 +4349,15 @@ class HCI_LE_Write_Suggested_Default_Data_Length_Command(HCI_Command):
suggested_max_tx_time: int = field(metadata=metadata(2))
# -----------------------------------------------------------------------------
@HCI_Command.command
@dataclasses.dataclass
class HCI_LE_Read_Local_P_256_Public_Key_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.36 LE LE Read Local P-256 Public Key command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command
@dataclasses.dataclass
@@ -4365,6 +4385,15 @@ class HCI_LE_Clear_Resolving_List_Command(HCI_Command):
'''
# -----------------------------------------------------------------------------
@HCI_Command.command
@dataclasses.dataclass
class HCI_LE_Read_Resolving_List_Size_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.41 LE Read Resolving List Size command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command
@dataclasses.dataclass
@@ -5028,6 +5057,15 @@ class HCI_LE_Periodic_Advertising_Terminate_Sync_Command(HCI_Command):
sync_handle: int = field(metadata=metadata(2))
# -----------------------------------------------------------------------------
@HCI_Command.command
@dataclasses.dataclass
class HCI_LE_Read_Transmit_Power_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.74 LE Read Transmit Power command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command
@dataclasses.dataclass

View File

@@ -489,9 +489,9 @@ STATUS_CODES = {
@dataclasses.dataclass
class HfConfiguration:
supported_hf_features: list[HfFeature]
supported_hf_indicators: list[HfIndicator]
supported_audio_codecs: list[AudioCodec]
supported_hf_features: collections.abc.Sequence[HfFeature]
supported_hf_indicators: collections.abc.Sequence[HfIndicator]
supported_audio_codecs: collections.abc.Sequence[AudioCodec]
@dataclasses.dataclass
@@ -753,7 +753,7 @@ class HfProtocol(utils.EventEmitter):
# Build local features.
self.supported_hf_features = sum(configuration.supported_hf_features)
self.supported_audio_codecs = configuration.supported_audio_codecs
self.supported_audio_codecs = list(configuration.supported_audio_codecs)
self.hf_indicators = {
indicator: HfIndicatorState(indicator=indicator)

View File

@@ -246,7 +246,7 @@ class HID(ABC, utils.EventEmitter):
# Create a new L2CAP connection - interrupt channel
try:
channel = await self.connection.create_l2cap_channel(
l2cap.ClassicChannelSpec(HID_CONTROL_PSM)
l2cap.ClassicChannelSpec(HID_INTERRUPT_PSM)
)
channel.sink = self.on_intr_pdu
self.l2cap_intr_channel = channel

View File

@@ -674,10 +674,14 @@ class DLC(utils.EventEmitter):
while (self.tx_buffer and self.tx_credits > 0) or rx_credits_needed > 0:
# Get the next chunk, up to MTU size
if rx_credits_needed > 0:
chunk = bytes([rx_credits_needed]) + self.tx_buffer[: self.mtu - 1]
self.tx_buffer = self.tx_buffer[len(chunk) - 1 :]
chunk = bytes([rx_credits_needed])
self.rx_credits += rx_credits_needed
tx_credit_spent = len(chunk) > 1
if self.tx_buffer and self.tx_credits > 0:
chunk += self.tx_buffer[: self.mtu - 1]
self.tx_buffer = self.tx_buffer[len(chunk) - 1 :]
tx_credit_spent = True
else:
tx_credit_spent = False
else:
chunk = self.tx_buffer[: self.mtu]
self.tx_buffer = self.tx_buffer[len(chunk) :]

View File

@@ -84,7 +84,12 @@ async def open_transport(name: str) -> Transport:
scheme, *tail = name.split(':', 1)
spec = tail[0] if tail else None
metadata = None
if spec and (m := re.search(r'\[(\w+=\w+(?:,\w+=\w+)*,?)\]', spec)):
# If a spec is provided, check for a metadata section in square brackets.
# The regex captures a comma-separated list of key=value pairs (allowing an
# optional trailing comma). The key is matched by \w+ and the value by [^,\]]+,
# meaning the value may contain any character except a comma or a closing
# bracket (']').
if spec and (m := re.search(r'\[(\w+=[^,\]]+(?:,\w+=[^,\]]+)*,?)\]', spec)):
metadata_str = m.group(1)
if m.start() == 0:
# <metadata><spec>

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ import json
import struct
import sys
import websockets
import websockets.asyncio.server
import bumble.logging
from bumble import data_types
@@ -367,7 +367,7 @@ async def keyboard_device(device, command):
if command == 'web':
# 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:
try:
message = await websocket.recv()
@@ -398,7 +398,7 @@ async def keyboard_device(device, command):
pass
# 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()
else:
message = bytes('hello', 'ascii')

View File

@@ -20,7 +20,7 @@ import logging
import sys
from typing import Optional
import websockets
import websockets.asyncio.server
import bumble.logging
from bumble import data_types, decoder, gatt
@@ -29,12 +29,11 @@ from bumble.device import AdvertisingParameters, Device
from bumble.profiles import asha
from bumble.transport import open_transport
ws_connection: Optional[websockets.WebSocketServerProtocol] = None
ws_connection: Optional[websockets.asyncio.server.ServerConnection] = None
g722_decoder = decoder.G722Decoder()
async def ws_server(ws_client: websockets.WebSocketServerProtocol, path: str):
del path
async def ws_server(ws_client: websockets.asyncio.server.ServerConnection):
global ws_connection
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

View File

@@ -21,8 +21,9 @@ import asyncio
import json
import logging
import sys
from typing import Optional
import websockets
import websockets.asyncio.server
import bumble.logging
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:
socket: Optional[websockets.asyncio.server.ServerConnection]
def __init__(
self, avrcp_protocol: avrcp.Protocol, avrcp_delegate: Delegate
) -> None:
@@ -227,9 +230,9 @@ class WebSocketServer:
async def start(self) -> None:
# 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')
self.socket = socket
while True:

View File

@@ -19,6 +19,7 @@ import asyncio
import sys
import bumble.logging
from bumble import hci
from bumble.controller import Controller
from bumble.device import Device
from bumble.gatt import (
@@ -61,7 +62,7 @@ async def main() -> None:
host_sink=hci_transport.sink,
link=link,
)
controller1.random_address = sys.argv[1]
controller1.random_address = hci.Address(sys.argv[1])
# Create a second controller using the same link
controller2 = Controller('C2', link=link)

View File

@@ -22,7 +22,7 @@ import logging
import sys
from typing import Iterable, Optional
import websockets
import websockets.asyncio.server
import bumble.core
import bumble.logging
@@ -33,7 +33,7 @@ from bumble.transport import open_transport
logger = logging.getLogger(__name__)
ws: Optional[websockets.WebSocketServerProtocol] = None
ws: Optional[websockets.asyncio.server.ServerConnection] = None
ag_protocol: Optional[hfp.AgProtocol] = 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)
async def ws_server(ws_client: websockets.WebSocketServerProtocol, path: str):
del path
async def ws_server(ws_client: websockets.asyncio.server.ServerConnection):
global ws
ws = ws_client
@@ -273,7 +272,7 @@ async def main() -> None:
on_dlc(session)
await websockets.serve(ws_server, port=8888)
await websockets.asyncio.server.serve(ws_server, port=8888)
if len(sys.argv) >= 5:
global source_file

View File

@@ -22,7 +22,7 @@ import json
import sys
from typing import Optional
import websockets
import websockets.asyncio.server
import bumble.logging
from bumble import hci, hfp, rfcomm
@@ -30,7 +30,7 @@ from bumble.device import Connection, Device
from bumble.hfp import HfProtocol
from bumble.transport import open_transport
ws: Optional[websockets.WebSocketServerProtocol] = None
ws: Optional[websockets.asyncio.server.ServerConnection] = None
hf_protocol: Optional[HfProtocol] = None
@@ -143,7 +143,7 @@ async def main() -> None:
await device.set_connectable(True)
# 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
ws = websocket
async for message in websocket:
@@ -166,7 +166,7 @@ async def main() -> None:
response = str(await hf_protocol.query_current_calls())
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()

View File

@@ -20,7 +20,7 @@ import json
import struct
import sys
import websockets
import websockets.asyncio.server
import bumble.logging
from bumble.core import (
@@ -425,7 +425,7 @@ deviceData = DeviceData()
async def keyboard_device(hid_device: HID_Device):
# 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
while True:
try:
@@ -476,7 +476,7 @@ async def keyboard_device(hid_device: HID_Device):
pass
# 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()

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ dependencies = [
"pyserial-asyncio >= 0.5; platform_system!='Emscripten'",
"pyserial >= 3.5; 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]

View File

@@ -23,18 +23,7 @@ from typing import Awaitable
import pytest
from bumble import a2dp
from bumble.avdtp import (
A2DP_SBC_CODEC_TYPE,
AVDTP_AUDIO_MEDIA_TYPE,
AVDTP_IDLE_STATE,
AVDTP_STREAMING_STATE,
AVDTP_TSEP_SNK,
Listener,
MediaCodecCapabilities,
MediaPacketPump,
Protocol,
)
from bumble import a2dp, avdtp
from bumble.controller import Controller
from bumble.core import PhysicalTransport
from bumble.device import Device
@@ -135,9 +124,9 @@ async def test_self_connection():
# -----------------------------------------------------------------------------
def source_codec_capabilities():
return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_SBC_CODEC_TYPE,
return avdtp.MediaCodecCapabilities(
media_type=avdtp.MediaType.AUDIO,
media_codec_type=a2dp.CodecType.SBC,
media_codec_information=a2dp.SbcMediaCodecInformation(
sampling_frequency=a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_44100,
channel_mode=a2dp.SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
@@ -152,9 +141,9 @@ def source_codec_capabilities():
# -----------------------------------------------------------------------------
def sink_codec_capabilities():
return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_SBC_CODEC_TYPE,
return avdtp.MediaCodecCapabilities(
media_type=avdtp.MediaType.AUDIO,
media_codec_type=a2dp.CodecType.SBC,
media_codec_information=a2dp.SbcMediaCodecInformation(
sampling_frequency=a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_48000
| a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_44100
@@ -201,7 +190,7 @@ async def test_source_sink_1():
sink.on('rtp_packet', on_rtp_packet)
# Create a listener to wait for AVDTP connections
listener = Listener.for_device(two_devices.devices[1])
listener = avdtp.Listener.for_device(two_devices.devices[1])
listener.on('connection', on_avdtp_connection)
async def make_connection():
@@ -214,13 +203,13 @@ async def test_source_sink_1():
return connections[0]
connection = await make_connection()
client = await Protocol.connect(connection)
client = await avdtp.Protocol.connect(connection)
endpoints = await client.discover_remote_endpoints()
assert len(endpoints) == 1
remote_sink = list(endpoints)[0]
assert remote_sink.in_use == 0
assert remote_sink.media_type == AVDTP_AUDIO_MEDIA_TYPE
assert remote_sink.tsep == AVDTP_TSEP_SNK
assert remote_sink.media_type == avdtp.MediaType.AUDIO
assert remote_sink.tsep == avdtp.StreamEndPointType.SNK
async def generate_packets(packet_count):
sequence_number = 0
@@ -239,24 +228,24 @@ async def test_source_sink_1():
rtp_packets_fully_received = asyncio.get_running_loop().create_future()
rtp_packets_expected = 3
rtp_packets = []
pump = MediaPacketPump(generate_packets(3))
pump = avdtp.MediaPacketPump(generate_packets(3))
source = client.add_source(source_codec_capabilities(), pump)
stream = await client.create_stream(source, remote_sink)
await stream.start()
assert stream.state == AVDTP_STREAMING_STATE
assert stream.state == avdtp.State.STREAMING
assert stream.local_endpoint.in_use == 1
assert stream.rtp_channel is not None
assert sink.in_use == 1
assert sink.stream is not None
assert sink.stream.state == AVDTP_STREAMING_STATE
assert sink.stream.state == avdtp.State.STREAMING
await rtp_packets_fully_received
await stream.close()
assert stream.rtp_channel is None
assert source.in_use == 0
assert source.stream.state == AVDTP_IDLE_STATE
assert source.stream.state == avdtp.State.IDLE
assert sink.in_use == 0
assert sink.stream.state == AVDTP_IDLE_STATE
assert sink.stream.state == avdtp.State.IDLE
# Send packets manually
rtp_packets_fully_received = asyncio.get_running_loop().create_future()
@@ -268,12 +257,12 @@ async def test_source_sink_1():
source = client.add_source(source_codec_capabilities(), None)
stream = await client.create_stream(source, remote_sink)
await stream.start()
assert stream.state == AVDTP_STREAMING_STATE
assert stream.state == avdtp.State.STREAMING
assert stream.local_endpoint.in_use == 1
assert stream.rtp_channel is not None
assert sink.in_use == 1
assert sink.stream is not None
assert sink.stream.state == AVDTP_STREAMING_STATE
assert sink.stream.state == avdtp.State.STREAMING
stream.send_media_packet(source_packets[0])
stream.send_media_packet(source_packets[1])
@@ -283,9 +272,9 @@ async def test_source_sink_1():
assert stream.rtp_channel is None
assert len(rtp_packets) == 3
assert source.in_use == 0
assert source.stream.state == AVDTP_IDLE_STATE
assert source.stream.state == avdtp.State.IDLE
assert sink.in_use == 0
assert sink.stream.state == AVDTP_IDLE_STATE
assert sink.stream.state == avdtp.State.IDLE
# -----------------------------------------------------------------------------

View File

@@ -15,43 +15,108 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import pytest
from bumble import avdtp
from bumble.a2dp import A2DP_SBC_CODEC_TYPE
from bumble.avdtp import (
AVDTP_AUDIO_MEDIA_TYPE,
AVDTP_DELAY_REPORTING_SERVICE_CATEGORY,
AVDTP_GET_CAPABILITIES,
AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY,
AVDTP_SET_CONFIGURATION,
Get_Capabilities_Response,
MediaCodecCapabilities,
Message,
ServiceCapabilities,
Set_Configuration_Command,
)
from bumble.rtp import MediaPacket
# -----------------------------------------------------------------------------
def test_messages():
capabilities = [
ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_SBC_CODEC_TYPE,
media_codec_information=bytes.fromhex('211502fa'),
@pytest.mark.parametrize(
'message',
(
avdtp.Discover_Command(),
avdtp.Discover_Response(
endpoints=[
avdtp.EndPointInfo(
seid=1,
in_use=1,
media_type=avdtp.MediaType.AUDIO,
tsep=avdtp.StreamEndPointType.SNK,
)
]
),
ServiceCapabilities(AVDTP_DELAY_REPORTING_SERVICE_CATEGORY),
]
message = Get_Capabilities_Response(capabilities)
parsed = Message.create(
AVDTP_GET_CAPABILITIES, Message.MessageType.RESPONSE_ACCEPT, message.payload
)
assert message.payload == parsed.payload
message = Set_Configuration_Command(3, 4, capabilities)
parsed = Message.create(
AVDTP_SET_CONFIGURATION, Message.MessageType.COMMAND, message.payload
avdtp.Get_Capabilities_Command(acp_seid=1),
avdtp.Get_Capabilities_Response(
capabilities=[
avdtp.ServiceCapabilities(avdtp.AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
avdtp.MediaCodecCapabilities(
media_type=avdtp.AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_SBC_CODEC_TYPE,
media_codec_information=bytes.fromhex('211502fa'),
),
avdtp.ServiceCapabilities(avdtp.AVDTP_DELAY_REPORTING_SERVICE_CATEGORY),
]
),
avdtp.Get_Capabilities_Reject(error_code=avdtp.AVDTP_BAD_ACP_SEID_ERROR),
avdtp.Get_All_Capabilities_Command(acp_seid=1),
avdtp.Get_All_Capabilities_Response(
capabilities=[
avdtp.ServiceCapabilities(avdtp.AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY)
]
),
avdtp.Get_All_Capabilities_Reject(error_code=avdtp.AVDTP_BAD_ACP_SEID_ERROR),
avdtp.Set_Configuration_Command(
acp_seid=1,
int_seid=2,
capabilities=[
avdtp.ServiceCapabilities(avdtp.AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY)
],
),
avdtp.Set_Configuration_Response(),
avdtp.Set_Configuration_Reject(
service_category=avdtp.AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY,
error_code=avdtp.AVDTP_UNSUPPORTED_CONFIGURATION_ERROR,
),
avdtp.Get_Configuration_Command(acp_seid=1),
avdtp.Get_Configuration_Response(
capabilities=[
avdtp.ServiceCapabilities(avdtp.AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY)
]
),
avdtp.Get_Configuration_Reject(error_code=avdtp.AVDTP_BAD_ACP_SEID_ERROR),
avdtp.Reconfigure_Command(
acp_seid=1,
capabilities=[
avdtp.ServiceCapabilities(avdtp.AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY)
],
),
avdtp.Reconfigure_Response(),
avdtp.Reconfigure_Reject(
service_category=avdtp.AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY,
error_code=avdtp.AVDTP_UNSUPPORTED_CONFIGURATION_ERROR,
),
avdtp.Open_Command(acp_seid=1),
avdtp.Open_Response(),
avdtp.Open_Reject(error_code=avdtp.AVDTP_BAD_ACP_SEID_ERROR),
avdtp.Start_Command(acp_seids=[1, 2]),
avdtp.Start_Response(),
avdtp.Start_Reject(acp_seid=1, error_code=avdtp.AVDTP_BAD_STATE_ERROR),
avdtp.Close_Command(acp_seid=1),
avdtp.Close_Response(),
avdtp.Close_Reject(error_code=avdtp.AVDTP_BAD_ACP_SEID_ERROR),
avdtp.Suspend_Command(acp_seids=[1, 2]),
avdtp.Suspend_Response(),
avdtp.Suspend_Reject(acp_seid=1, error_code=avdtp.AVDTP_BAD_STATE_ERROR),
avdtp.Abort_Command(acp_seid=1),
avdtp.Abort_Response(),
avdtp.Security_Control_Command(acp_seid=1, data=b'foo'),
avdtp.Security_Control_Response(),
avdtp.Security_Control_Reject(error_code=avdtp.AVDTP_BAD_ACP_SEID_ERROR),
avdtp.General_Reject(),
avdtp.DelayReport_Command(acp_seid=1, delay=100),
avdtp.DelayReport_Response(),
avdtp.DelayReport_Reject(error_code=avdtp.AVDTP_BAD_ACP_SEID_ERROR),
),
)
def test_messages(message: avdtp.Message):
parsed = avdtp.Message.create(
signal_identifier=message.signal_identifier,
message_type=message.message_type,
payload=message.payload,
)
assert message == parsed
assert message.payload == parsed.payload
@@ -62,9 +127,3 @@ def test_rtp():
)
media_packet = MediaPacket.from_bytes(packet)
print(media_packet)
# -----------------------------------------------------------------------------
if __name__ == '__main__':
test_messages()
test_rtp()