Compare commits

..

28 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod 2698d4534e Merge pull request #435 from jeru/main
open_tcp_server_transport: allow explicit sock as input.
2024-04-04 19:17:07 -07:00
zxzxwu bbcd64286a Merge pull request #463 from zxzxwu/hfp
Correct HFP AG indicator index
2024-04-04 12:53:19 +08:00
Gilles Boccon-Gibod 9140afbf8c Merge pull request #456 from google/gbg/update-dependencies
update some dependencies
2024-04-03 17:50:18 -06:00
Gilles Boccon-Gibod 90a682c71b bump to avatar 0.0.9 2024-04-03 16:26:07 -07:00
Gilles Boccon-Gibod e8737a8243 update to more recent versions 2024-04-03 10:00:11 -07:00
Gilles Boccon-Gibod 72fceca72e update some dependencies 2024-04-03 10:00:09 -07:00
Gilles Boccon-Gibod 732294abbc Merge pull request #462 from google/gbg/461
fix #461
2024-04-03 10:56:05 -06:00
Josh Wu dc1204531e Correct HFP AG indicator index 2024-04-03 17:58:04 +08:00
Gilles Boccon-Gibod 962114379c fix #461 2024-04-02 23:14:32 -07:00
Gilles Boccon-Gibod e6913a3055 Merge pull request #457 from google/gbg/bench-ascyncio-main
delay creation of runner object
2024-04-02 21:39:37 -06:00
Gilles Boccon-Gibod e21d122aef Merge pull request #458 from google/gbg/update-formatter
update black formatter to version 24
2024-04-02 21:39:24 -06:00
Gilles Boccon-Gibod 58d4ab913a update black formatter to version 24 2024-04-01 14:44:46 -07:00
Gilles Boccon-Gibod 76bca03fe3 format with the project's version of black 2024-04-01 14:39:34 -07:00
Gilles Boccon-Gibod f1e5c9e59e delay creation of runner object 2024-04-01 14:25:38 -07:00
zxzxwu ec82242462 Merge pull request #440 from zxzxwu/hfp
Rework HFP example
2024-03-27 16:54:41 +08:00
zxzxwu a4efdd3f3e Merge pull request #442 from zxzxwu/unicast_ad
Implement Unicast Server Advertising Data
2024-03-27 16:54:06 +08:00
Gilles Boccon-Gibod 69c6643bb8 Merge pull request #452 from marshallpierce/mp/rust-0.2.0
Bumble crate 0.2.0
2024-03-21 17:15:43 -07:00
Marshall Pierce b8214bf948 Bumble crate 0.2.0 2024-03-21 12:36:32 -06:00
Charlie Boutier a9c62c44b3 pandora host: change AdvertisingType
change advertising type from high duty to low duty

Test: python le_host_test.py -c config.yml --test_bed android.bumbles --tests "test_scan('connectable','non_scannable','directed',0)" -v
2024-03-20 11:17:50 -07:00
Charlie Boutier 7d0b4ef4e0 pandora_server: Parse FLAGS into advertising data
Bug: 328089785
2024-03-18 09:20:55 -07:00
Charlie Boutier 313340f1c6 intel driver: check the vendorId and productId 2024-03-15 10:53:33 -07:00
Charlie Boutier e8ed69fb09 pyusb: Collect vendorId and productId as metadata 2024-03-15 10:53:33 -07:00
David Duarte 16d5cf6770 usb: Add usb path moniker
Add a new moniker for usb and pyusb driver allowing
to select the usb device using its bus id and port
path like `usb:3-3.4.1`.
2024-03-15 09:17:39 -07:00
Gilles Boccon-Gibod a2caf1deb2 Merge pull request #448 from BenjaminLawson/bump-avatar
Bump pandora-avatar to 0.0.8
2024-03-14 20:49:28 -07:00
Ben Lawson 01bfdd2c98 Bump pandora-avater to 0.0.8 2024-03-14 14:13:27 -07:00
Cheng Sheng 1ceeccbbc0 open_tcp_server_transport: allow explicit sock as input.
When a user doesn't need an exact port, but cares more about getting
SOME unused port, they can do:
* Create a socket outside with port=None or port=0.
* Use socket.getsockname()[1] to get the allocated port and pass to the
TCP client somehow.
* Use the created socket to create a TCP server transport.

Use-case: unit-testing embedded software that implements a BLE host. The
controller will be a Bumble controller, connected to the host via a TCP
channel.
* The host will have a TCP-client HCI transport for testing.
* The pytest setup code will allocate the TCP server and pass the port
number to the host.

Also add some unittests with python mock.
2024-03-13 19:34:05 +01:00
Josh Wu 35db4a4c93 Implement Unicast Server Advertising Data 2024-03-08 16:48:37 +08:00
Josh Wu 6205199d7f Rework HFP example 2024-03-05 20:53:28 +08:00
50 changed files with 586 additions and 283 deletions
+2
View File
@@ -6,6 +6,8 @@ dist/
docs/mkdocs/site docs/mkdocs/site
test-results.xml test-results.xml
__pycache__ __pycache__
# Vim
.*.sw*
# generated by setuptools_scm # generated by setuptools_scm
bumble/_version.py bumble/_version.py
.vscode/launch.json .vscode/launch.json
+17 -12
View File
@@ -509,9 +509,11 @@ class Ping:
packet = struct.pack( packet = struct.pack(
'>bbI', '>bbI',
PacketType.SEQUENCE, PacketType.SEQUENCE,
PACKET_FLAG_LAST (
if self.current_packet_index == self.tx_packet_count - 1 PACKET_FLAG_LAST
else 0, if self.current_packet_index == self.tx_packet_count - 1
else 0
),
self.current_packet_index, self.current_packet_index,
) + bytes(self.tx_packet_size - 6) ) + bytes(self.tx_packet_size - 6)
logging.info(color(f'Sending packet {self.current_packet_index}', 'yellow')) logging.info(color(f'Sending packet {self.current_packet_index}', 'yellow'))
@@ -1062,9 +1064,9 @@ class Central(Connection.Listener):
if self.phy not in (None, HCI_LE_1M_PHY): if self.phy not in (None, HCI_LE_1M_PHY):
# Add an connections parameters entry for this PHY. # Add an connections parameters entry for this PHY.
self.connection_parameter_preferences[ self.connection_parameter_preferences[self.phy] = (
self.phy connection_parameter_preferences
] = connection_parameter_preferences )
else: else:
self.connection_parameter_preferences = None self.connection_parameter_preferences = None
@@ -1232,6 +1234,7 @@ class Peripheral(Device.Listener, Connection.Listener):
'cyan', 'cyan',
) )
) )
await self.connected.wait() await self.connected.wait()
logging.info(color('### Connected', 'cyan')) logging.info(color('### Connected', 'cyan'))
@@ -1591,8 +1594,8 @@ def central(
mode_factory = create_mode_factory(ctx, 'gatt-client') mode_factory = create_mode_factory(ctx, 'gatt-client')
classic = ctx.obj['classic'] classic = ctx.obj['classic']
asyncio.run( async def run_central():
Central( await Central(
transport, transport,
peripheral_address, peripheral_address,
classic, classic,
@@ -1604,7 +1607,8 @@ def central(
encrypt or authenticate, encrypt or authenticate,
ctx.obj['extended_data_length'], ctx.obj['extended_data_length'],
).run() ).run()
)
asyncio.run(run_central())
@bench.command() @bench.command()
@@ -1615,15 +1619,16 @@ def peripheral(ctx, transport):
role_factory = create_role_factory(ctx, 'receiver') role_factory = create_role_factory(ctx, 'receiver')
mode_factory = create_mode_factory(ctx, 'gatt-server') mode_factory = create_mode_factory(ctx, 'gatt-server')
asyncio.run( async def run_peripheral():
Peripheral( await Peripheral(
transport, transport,
ctx.obj['classic'], ctx.obj['classic'],
ctx.obj['extended_data_length'], ctx.obj['extended_data_length'],
role_factory, role_factory,
mode_factory, mode_factory,
).run() ).run()
)
asyncio.run(run_peripheral())
def main(): def main():
+1
View File
@@ -76,6 +76,7 @@ logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
DEFAULT_UI_PORT = 7654 DEFAULT_UI_PORT = 7654
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class AudioExtractor: class AudioExtractor:
@staticmethod @staticmethod
+1
View File
@@ -24,6 +24,7 @@ from bumble.device import Device
from bumble.keys import JsonKeyStore from bumble.keys import JsonKeyStore
from bumble.transport import open_transport from bumble.transport import open_transport
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def unbond_with_keystore(keystore, address): async def unbond_with_keystore(keystore, address):
if address is None: if address is None:
+2
View File
@@ -652,7 +652,9 @@ class SbcPacketSource:
# Prepare for next packets # Prepare for next packets
sequence_number += 1 sequence_number += 1
sequence_number &= 0xFFFF
timestamp += sum((frame.sample_count for frame in frames)) timestamp += sum((frame.sample_count for frame in frames))
timestamp &= 0xFFFFFFFF
frames = [frame] frames = [frame]
frames_size = len(frame.payload) frames_size = len(frame.payload)
else: else:
+1 -1
View File
@@ -655,7 +655,7 @@ class ATT_Write_Command(ATT_PDU):
@ATT_PDU.subclass( @ATT_PDU.subclass(
[ [
('attribute_handle', HANDLE_FIELD_SPEC), ('attribute_handle', HANDLE_FIELD_SPEC),
('attribute_value', '*') ('attribute_value', '*'),
# ('authentication_signature', 'TODO') # ('authentication_signature', 'TODO')
] ]
) )
+12 -6
View File
@@ -325,8 +325,8 @@ class MediaPacket:
self.padding = padding self.padding = padding
self.extension = extension self.extension = extension
self.marker = marker self.marker = marker
self.sequence_number = sequence_number self.sequence_number = sequence_number & 0xFFFF
self.timestamp = timestamp self.timestamp = timestamp & 0xFFFFFFFF
self.ssrc = ssrc self.ssrc = ssrc
self.csrc_list = csrc_list self.csrc_list = csrc_list
self.payload_type = payload_type self.payload_type = payload_type
@@ -341,7 +341,12 @@ class MediaPacket:
| len(self.csrc_list), | len(self.csrc_list),
self.marker << 7 | self.payload_type, self.marker << 7 | self.payload_type,
] ]
) + struct.pack('>HII', self.sequence_number, self.timestamp, self.ssrc) ) + struct.pack(
'>HII',
self.sequence_number,
self.timestamp,
self.ssrc,
)
for csrc in self.csrc_list: for csrc in self.csrc_list:
header += struct.pack('>I', csrc) header += struct.pack('>I', csrc)
return header + self.payload return header + self.payload
@@ -1545,9 +1550,10 @@ class Protocol(EventEmitter):
assert False # Should never reach this assert False # Should never reach this
async def get_capabilities( async def get_capabilities(self, seid: int) -> Union[
self, seid: int Get_Capabilities_Response,
) -> Union[Get_Capabilities_Response, Get_All_Capabilities_Response,]: Get_All_Capabilities_Response,
]:
if self.version > (1, 2): if self.version > (1, 2):
return await self.send_command(Get_All_Capabilities_Command(seid)) return await self.send_command(Get_All_Capabilities_Command(seid))
+5 -3
View File
@@ -1745,9 +1745,11 @@ class Protocol(pyee.EventEmitter):
avc.CommandFrame.CommandType.CONTROL, avc.CommandFrame.CommandType.CONTROL,
avc.Frame.SubunitType.PANEL, avc.Frame.SubunitType.PANEL,
0, 0,
avc.PassThroughFrame.StateFlag.PRESSED (
if pressed avc.PassThroughFrame.StateFlag.PRESSED
else avc.PassThroughFrame.StateFlag.RELEASED, if pressed
else avc.PassThroughFrame.StateFlag.RELEASED
),
key, key,
b'', b'',
) )
+9 -9
View File
@@ -134,15 +134,15 @@ class Controller:
self.hci_sink = None self.hci_sink = None
self.link = link self.link = link
self.central_connections: Dict[ self.central_connections: Dict[Address, Connection] = (
Address, Connection {}
] = {} # Connections where this controller is the central ) # Connections where this controller is the central
self.peripheral_connections: Dict[ self.peripheral_connections: Dict[Address, Connection] = (
Address, Connection {}
] = {} # Connections where this controller is the peripheral ) # Connections where this controller is the peripheral
self.classic_connections: Dict[ self.classic_connections: Dict[Address, Connection] = (
Address, Connection {}
] = {} # Connections in BR/EDR ) # Connections in BR/EDR
self.central_cis_links: Dict[int, CisLink] = {} # CIS links by handle self.central_cis_links: Dict[int, CisLink] = {} # CIS links by handle
self.peripheral_cis_links: Dict[int, CisLink] = {} # CIS links by handle self.peripheral_cis_links: Dict[int, CisLink] = {} # CIS links by handle
+28 -30
View File
@@ -276,12 +276,12 @@ class Advertisement:
data_bytes: bytes = b'' data_bytes: bytes = b''
# Constants # Constants
TX_POWER_NOT_AVAILABLE: ClassVar[ TX_POWER_NOT_AVAILABLE: ClassVar[int] = (
int HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
] = HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE )
RSSI_NOT_AVAILABLE: ClassVar[ RSSI_NOT_AVAILABLE: ClassVar[int] = (
int HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE
] = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE )
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.data = AdvertisingData.from_bytes(self.data_bytes) self.data = AdvertisingData.from_bytes(self.data_bytes)
@@ -558,7 +558,9 @@ class AdvertisingParameters:
) )
primary_advertising_interval_min: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL primary_advertising_interval_min: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
primary_advertising_interval_max: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL primary_advertising_interval_max: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
primary_advertising_channel_map: HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap = ( primary_advertising_channel_map: (
HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap
) = (
AdvertisingChannelMap.CHANNEL_37 AdvertisingChannelMap.CHANNEL_37
| AdvertisingChannelMap.CHANNEL_38 | AdvertisingChannelMap.CHANNEL_38
| AdvertisingChannelMap.CHANNEL_39 | AdvertisingChannelMap.CHANNEL_39
@@ -1138,14 +1140,12 @@ class Connection(CompositeEventEmitter):
@overload @overload
async def create_l2cap_channel( async def create_l2cap_channel(
self, spec: l2cap.ClassicChannelSpec self, spec: l2cap.ClassicChannelSpec
) -> l2cap.ClassicChannel: ) -> l2cap.ClassicChannel: ...
...
@overload @overload
async def create_l2cap_channel( async def create_l2cap_channel(
self, spec: l2cap.LeCreditBasedChannelSpec self, spec: l2cap.LeCreditBasedChannelSpec
) -> l2cap.LeCreditBasedChannel: ) -> l2cap.LeCreditBasedChannel: ...
...
async def create_l2cap_channel( async def create_l2cap_channel(
self, spec: Union[l2cap.ClassicChannelSpec, l2cap.LeCreditBasedChannelSpec] self, spec: Union[l2cap.ClassicChannelSpec, l2cap.LeCreditBasedChannelSpec]
@@ -1723,16 +1723,14 @@ class Device(CompositeEventEmitter):
self, self,
connection: Connection, connection: Connection,
spec: l2cap.ClassicChannelSpec, spec: l2cap.ClassicChannelSpec,
) -> l2cap.ClassicChannel: ) -> l2cap.ClassicChannel: ...
...
@overload @overload
async def create_l2cap_channel( async def create_l2cap_channel(
self, self,
connection: Connection, connection: Connection,
spec: l2cap.LeCreditBasedChannelSpec, spec: l2cap.LeCreditBasedChannelSpec,
) -> l2cap.LeCreditBasedChannel: ) -> l2cap.LeCreditBasedChannel: ...
...
async def create_l2cap_channel( async def create_l2cap_channel(
self, self,
@@ -1753,16 +1751,14 @@ class Device(CompositeEventEmitter):
self, self,
spec: l2cap.ClassicChannelSpec, spec: l2cap.ClassicChannelSpec,
handler: Optional[Callable[[l2cap.ClassicChannel], Any]] = None, handler: Optional[Callable[[l2cap.ClassicChannel], Any]] = None,
) -> l2cap.ClassicChannelServer: ) -> l2cap.ClassicChannelServer: ...
...
@overload @overload
def create_l2cap_server( def create_l2cap_server(
self, self,
spec: l2cap.LeCreditBasedChannelSpec, spec: l2cap.LeCreditBasedChannelSpec,
handler: Optional[Callable[[l2cap.LeCreditBasedChannel], Any]] = None, handler: Optional[Callable[[l2cap.LeCreditBasedChannel], Any]] = None,
) -> l2cap.LeCreditBasedChannelServer: ) -> l2cap.LeCreditBasedChannelServer: ...
...
def create_l2cap_server( def create_l2cap_server(
self, self,
@@ -3289,17 +3285,19 @@ class Device(CompositeEventEmitter):
handler = self.on( handler = self.on(
'remote_name', 'remote_name',
lambda address, remote_name: pending_name.set_result(remote_name) lambda address, remote_name: (
if address == peer_address pending_name.set_result(remote_name)
else None, if address == peer_address
else None
),
) )
failure_handler = self.on( failure_handler = self.on(
'remote_name_failure', 'remote_name_failure',
lambda address, error_code: pending_name.set_exception( lambda address, error_code: (
HCI_Error(error_code) pending_name.set_exception(HCI_Error(error_code))
) if address == peer_address
if address == peer_address else None
else None, ),
) )
try: try:
@@ -3475,9 +3473,9 @@ class Device(CompositeEventEmitter):
LE features supported by the remote device. LE features supported by the remote device.
""" """
with closing(EventWatcher()) as watcher: with closing(EventWatcher()) as watcher:
read_feature_future: asyncio.Future[ read_feature_future: asyncio.Future[LeFeatureMask] = (
LeFeatureMask asyncio.get_running_loop().create_future()
] = asyncio.get_running_loop().create_future() )
def on_le_remote_features(handle: int, features: int): def on_le_remote_features(handle: int, features: int):
if handle == connection.handle: if handle == connection.handle:
+38 -6
View File
@@ -29,6 +29,17 @@ from bumble.hci import (
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constant
# -----------------------------------------------------------------------------
INTEL_USB_PRODUCTS = {
# Intel AX210
(0x8087, 0x0032),
# Intel BE200
(0x8087, 0x0036),
}
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# HCI Commands # HCI Commands
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -52,13 +63,34 @@ class Driver(common.Driver):
def __init__(self, host): def __init__(self, host):
self.host = host self.host = host
@classmethod @staticmethod
async def for_host(cls, host): # type: ignore def check(host):
# Only instantiate this driver if explicitly selected driver = host.hci_metadata.get("driver")
if host.hci_metadata.get("driver") == "intel": if driver == "intel":
return cls(host) return True
return None vendor_id = host.hci_metadata.get("vendor_id")
product_id = host.hci_metadata.get("product_id")
if vendor_id is None or product_id is None:
logger.debug("USB metadata not sufficient")
return False
if (vendor_id, product_id) not in INTEL_USB_PRODUCTS:
logger.debug(
f"USB device ({vendor_id:04X}, {product_id:04X}) " "not in known list"
)
return False
return True
@classmethod
async def for_host(cls, host, force=False): # type: ignore
# Only instantiate this driver if explicitly selected
if not force and not cls.check(host):
return None
return cls(host)
async def init_controller(self): async def init_controller(self):
self.host.ready = True self.host.ready = True
+1
View File
@@ -36,6 +36,7 @@ logger = logging.getLogger(__name__)
# Classes # Classes
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class GenericAccessService(Service): class GenericAccessService(Service):
def __init__(self, device_name, appearance=(0, 0)): def __init__(self, device_name, appearance=(0, 0)):
+8 -6
View File
@@ -342,9 +342,11 @@ class Service(Attribute):
uuid = UUID(uuid) uuid = UUID(uuid)
super().__init__( super().__init__(
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE (
if primary GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE
else GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE, if primary
else GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE
),
Attribute.READABLE, Attribute.READABLE,
uuid.to_pdu_bytes(), uuid.to_pdu_bytes(),
) )
@@ -560,9 +562,9 @@ class CharacteristicAdapter:
def __init__(self, characteristic: Union[Characteristic, AttributeProxy]): def __init__(self, characteristic: Union[Characteristic, AttributeProxy]):
self.wrapped_characteristic = characteristic self.wrapped_characteristic = characteristic
self.subscribers: Dict[ self.subscribers: Dict[Callable, Callable] = (
Callable, Callable {}
] = {} # Map from subscriber to proxy subscriber ) # Map from subscriber to proxy subscriber
if isinstance(characteristic, Characteristic): if isinstance(characteristic, Characteristic):
self.read_value = self.read_encoded_value self.read_value = self.read_encoded_value
+1 -3
View File
@@ -352,9 +352,7 @@ class Client:
if c.uuid == uuid if c.uuid == uuid
] ]
def get_attribute_grouping( def get_attribute_grouping(self, attribute_handle: int) -> Optional[
self, attribute_handle: int
) -> Optional[
Union[ Union[
ServiceProxy, ServiceProxy,
Tuple[ServiceProxy, CharacteristicProxy], Tuple[ServiceProxy, CharacteristicProxy],
+3 -3
View File
@@ -445,9 +445,9 @@ class Server(EventEmitter):
assert self.pending_confirmations[connection.handle] is None assert self.pending_confirmations[connection.handle] is None
# Create a future value to hold the eventual response # Create a future value to hold the eventual response
pending_confirmation = self.pending_confirmations[ pending_confirmation = self.pending_confirmations[connection.handle] = (
connection.handle asyncio.get_running_loop().create_future()
] = asyncio.get_running_loop().create_future() )
try: try:
self.send_gatt_pdu(connection.handle, indication.to_bytes()) self.send_gatt_pdu(connection.handle, indication.to_bytes())
+11 -9
View File
@@ -4249,9 +4249,11 @@ class HCI_LE_Set_Extended_Scan_Parameters_Command(HCI_Command):
fields.append( fields.append(
( (
f'{scanning_phy_str}.scan_type: ', f'{scanning_phy_str}.scan_type: ',
'PASSIVE' (
if self.scan_types[i] == self.PASSIVE_SCANNING 'PASSIVE'
else 'ACTIVE', if self.scan_types[i] == self.PASSIVE_SCANNING
else 'ACTIVE'
),
) )
) )
fields.append( fields.append(
@@ -5010,9 +5012,9 @@ class HCI_LE_Advertising_Report_Event(HCI_LE_Meta_Event):
return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}' return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
HCI_LE_Meta_Event.subevent_classes[ HCI_LE_Meta_Event.subevent_classes[HCI_LE_ADVERTISING_REPORT_EVENT] = (
HCI_LE_ADVERTISING_REPORT_EVENT HCI_LE_Advertising_Report_Event
] = HCI_LE_Advertising_Report_Event )
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -5264,9 +5266,9 @@ class HCI_LE_Extended_Advertising_Report_Event(HCI_LE_Meta_Event):
return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}' return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
HCI_LE_Meta_Event.subevent_classes[ HCI_LE_Meta_Event.subevent_classes[HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT] = (
HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT HCI_LE_Extended_Advertising_Report_Event
] = HCI_LE_Extended_Advertising_Report_Event )
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+25 -18
View File
@@ -22,7 +22,8 @@ import dataclasses
import enum import enum
import traceback import traceback
import pyee import pyee
from typing import Dict, List, Union, Set, Any, Optional, TYPE_CHECKING from typing import Dict, List, Union, Set, Any, Optional, Type, TYPE_CHECKING
from typing_extensions import Self
from bumble import at from bumble import at
from bumble import rfcomm from bumble import rfcomm
@@ -417,17 +418,21 @@ class AtResponseType(enum.Enum):
MULTIPLE = 2 MULTIPLE = 2
@dataclasses.dataclass
class AtResponse: class AtResponse:
code: str code: str
parameters: list parameters: list
def __init__(self, response: bytearray): @classmethod
code_and_parameters = response.split(b':') def parse_from(cls: Type[Self], buffer: bytearray) -> Self:
code_and_parameters = buffer.split(b':')
parameters = ( parameters = (
code_and_parameters[1] if len(code_and_parameters) > 1 else bytearray() code_and_parameters[1] if len(code_and_parameters) > 1 else bytearray()
) )
self.code = code_and_parameters[0].decode() return cls(
self.parameters = at.parse_parameters(parameters) code=code_and_parameters[0].decode(),
parameters=at.parse_parameters(parameters),
)
@dataclasses.dataclass @dataclasses.dataclass
@@ -530,7 +535,7 @@ class HfProtocol(pyee.EventEmitter):
# Isolate the AT response code and parameters. # Isolate the AT response code and parameters.
raw_response = self.read_buffer[header + 2 : trailer] raw_response = self.read_buffer[header + 2 : trailer]
response = AtResponse(raw_response) response = AtResponse.parse_from(raw_response)
logger.debug(f"<<< {raw_response.decode()}") logger.debug(f"<<< {raw_response.decode()}")
# Consume the response bytes. # Consume the response bytes.
@@ -815,11 +820,11 @@ class HfProtocol(pyee.EventEmitter):
return calls return calls
async def update_ag_indicator(self, index: int, value: int): async def update_ag_indicator(self, index: int, value: int):
self.ag_indicators[index].current_status = value # CIEV is in 1-index, while ag_indicators is in 0-index.
self.emit('ag_indicator', self.ag_indicators[index]) ag_indicator = self.ag_indicators[index - 1]
logger.info( ag_indicator.current_status = value
f"AG indicator updated: {self.ag_indicators[index].description}, {value}" self.emit('ag_indicator', ag_indicator)
) logger.info(f"AG indicator updated: {ag_indicator.description}, {value}")
async def handle_unsolicited(self): async def handle_unsolicited(self):
"""Handle unsolicited result codes sent by the audio gateway.""" """Handle unsolicited result codes sent by the audio gateway."""
@@ -1006,7 +1011,9 @@ class EscoParameters:
transmit_coding_format: CodingFormat transmit_coding_format: CodingFormat
receive_coding_format: CodingFormat receive_coding_format: CodingFormat
packet_type: HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType packet_type: HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType
retransmission_effort: HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort retransmission_effort: (
HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort
)
max_latency: int max_latency: int
# Common # Common
@@ -1014,12 +1021,12 @@ class EscoParameters:
output_coding_format: CodingFormat = CodingFormat(CodecID.LINEAR_PCM) output_coding_format: CodingFormat = CodingFormat(CodecID.LINEAR_PCM)
input_coded_data_size: int = 16 input_coded_data_size: int = 16
output_coded_data_size: int = 16 output_coded_data_size: int = 16
input_pcm_data_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat = ( input_pcm_data_format: (
HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat
) ) = HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT
output_pcm_data_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat = ( output_pcm_data_format: (
HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat
) ) = HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT
input_pcm_sample_payload_msb_position: int = 0 input_pcm_sample_payload_msb_position: int = 0
output_pcm_sample_payload_msb_position: int = 0 output_pcm_sample_payload_msb_position: int = 0
input_data_path: HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath = ( input_data_path: HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath = (
+1
View File
@@ -48,6 +48,7 @@ HID_INTERRUPT_PSM = 0x0013
class Message: class Message:
message_type: MessageType message_type: MessageType
# Report types # Report types
class ReportType(enum.IntEnum): class ReportType(enum.IntEnum):
OTHER_REPORT = 0x00 OTHER_REPORT = 0x00
+4 -4
View File
@@ -128,10 +128,10 @@ class PairingKeys:
def print(self, prefix=''): def print(self, prefix=''):
keys_dict = self.to_dict() keys_dict = self.to_dict()
for (container_property, value) in keys_dict.items(): for container_property, value in keys_dict.items():
if isinstance(value, dict): if isinstance(value, dict):
print(f'{prefix}{color(container_property, "cyan")}:') print(f'{prefix}{color(container_property, "cyan")}:')
for (key_property, key_value) in value.items(): for key_property, key_value in value.items():
print(f'{prefix} {color(key_property, "green")}: {key_value}') print(f'{prefix} {color(key_property, "green")}: {key_value}')
else: else:
print(f'{prefix}{color(container_property, "cyan")}: {value}') print(f'{prefix}{color(container_property, "cyan")}: {value}')
@@ -158,7 +158,7 @@ class KeyStore:
async def get_resolving_keys(self): async def get_resolving_keys(self):
all_keys = await self.get_all() all_keys = await self.get_all()
resolving_keys = [] resolving_keys = []
for (name, keys) in all_keys: for name, keys in all_keys:
if keys.irk is not None: if keys.irk is not None:
if keys.address_type is None: if keys.address_type is None:
address_type = Address.RANDOM_DEVICE_ADDRESS address_type = Address.RANDOM_DEVICE_ADDRESS
@@ -171,7 +171,7 @@ class KeyStore:
async def print(self, prefix=''): async def print(self, prefix=''):
entries = await self.get_all() entries = await self.get_all()
separator = '' separator = ''
for (name, keys) in entries: for name, keys in entries:
print(separator + prefix + color(name, 'yellow')) print(separator + prefix + color(name, 'yellow'))
keys.print(prefix=prefix + ' ') keys.print(prefix=prefix + ' ')
separator = '\n' separator = '\n'
+37 -24
View File
@@ -54,6 +54,8 @@ from pandora import host_pb2
from pandora.host_pb2 import ( from pandora.host_pb2 import (
NOT_CONNECTABLE, NOT_CONNECTABLE,
NOT_DISCOVERABLE, NOT_DISCOVERABLE,
DISCOVERABLE_LIMITED,
DISCOVERABLE_GENERAL,
PRIMARY_1M, PRIMARY_1M,
PRIMARY_CODED, PRIMARY_CODED,
SECONDARY_1M, SECONDARY_1M,
@@ -69,6 +71,7 @@ from pandora.host_pb2 import (
ConnectResponse, ConnectResponse,
DataTypes, DataTypes,
DisconnectRequest, DisconnectRequest,
DiscoverabilityMode,
InquiryResponse, InquiryResponse,
PrimaryPhy, PrimaryPhy,
ReadLocalAddressResponse, ReadLocalAddressResponse,
@@ -284,9 +287,9 @@ class HostService(HostServicer):
self.log.debug(f"WaitDisconnection: {connection_handle}") self.log.debug(f"WaitDisconnection: {connection_handle}")
if connection := self.device.lookup_connection(connection_handle): if connection := self.device.lookup_connection(connection_handle):
disconnection_future: asyncio.Future[ disconnection_future: asyncio.Future[None] = (
None asyncio.get_running_loop().create_future()
] = asyncio.get_running_loop().create_future() )
def on_disconnection(_: None) -> None: def on_disconnection(_: None) -> None:
disconnection_future.set_result(None) disconnection_future.set_result(None)
@@ -367,9 +370,9 @@ class HostService(HostServicer):
scan_response_data=scan_response_data, scan_response_data=scan_response_data,
) )
pending_connection: asyncio.Future[ pending_connection: asyncio.Future[bumble.device.Connection] = (
bumble.device.Connection asyncio.get_running_loop().create_future()
] = asyncio.get_running_loop().create_future() )
if request.connectable: if request.connectable:
@@ -483,14 +486,10 @@ class HostService(HostServicer):
target_bytes = bytes(reversed(request.target)) target_bytes = bytes(reversed(request.target))
if request.target_variant() == "public": if request.target_variant() == "public":
target = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS) target = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS)
advertising_type = ( advertising_type = AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
) # FIXME: HIGH_DUTY ?
else: else:
target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS) target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
advertising_type = ( advertising_type = AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
) # FIXME: HIGH_DUTY ?
if request.connectable: if request.connectable:
@@ -517,9 +516,9 @@ class HostService(HostServicer):
await asyncio.sleep(1) await asyncio.sleep(1)
continue continue
pending_connection: asyncio.Future[ pending_connection: asyncio.Future[bumble.device.Connection] = (
bumble.device.Connection asyncio.get_running_loop().create_future()
] = asyncio.get_running_loop().create_future() )
self.log.debug('Wait for LE connection...') self.log.debug('Wait for LE connection...')
connection = await pending_connection connection = await pending_connection
@@ -564,12 +563,14 @@ class HostService(HostServicer):
legacy=request.legacy, legacy=request.legacy,
active=not request.passive, active=not request.passive,
own_address_type=request.own_address_type, own_address_type=request.own_address_type,
scan_interval=int(request.interval) scan_interval=(
if request.interval int(request.interval)
else DEVICE_DEFAULT_SCAN_INTERVAL, if request.interval
scan_window=int(request.window) else DEVICE_DEFAULT_SCAN_INTERVAL
if request.window ),
else DEVICE_DEFAULT_SCAN_WINDOW, scan_window=(
int(request.window) if request.window else DEVICE_DEFAULT_SCAN_WINDOW
),
scanning_phys=scanning_phys, scanning_phys=scanning_phys,
) )
@@ -783,9 +784,11 @@ class HostService(HostServicer):
*struct.pack('<H', dt.peripheral_connection_interval_min), *struct.pack('<H', dt.peripheral_connection_interval_min),
*struct.pack( *struct.pack(
'<H', '<H',
dt.peripheral_connection_interval_max (
if dt.peripheral_connection_interval_max dt.peripheral_connection_interval_max
else dt.peripheral_connection_interval_min, if dt.peripheral_connection_interval_max
else dt.peripheral_connection_interval_min
),
), ),
] ]
), ),
@@ -867,6 +870,16 @@ class HostService(HostServicer):
) )
) )
flag_map = {
NOT_DISCOVERABLE: 0x00,
DISCOVERABLE_LIMITED: AdvertisingData.LE_LIMITED_DISCOVERABLE_MODE_FLAG,
DISCOVERABLE_GENERAL: AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG,
}
if dt.le_discoverability_mode:
flags = flag_map[dt.le_discoverability_mode]
ad_structures.append((AdvertisingData.FLAGS, flags.to_bytes(1, 'big')))
return AdvertisingData(ad_structures) return AdvertisingData(ad_structures)
def pack_data_types(self, ad: AdvertisingData) -> DataTypes: def pack_data_types(self, ad: AdvertisingData) -> DataTypes:
+3 -3
View File
@@ -383,9 +383,9 @@ class SecurityService(SecurityServicer):
connection.transport connection.transport
] == request.level_variant() ] == request.level_variant()
wait_for_security: asyncio.Future[ wait_for_security: asyncio.Future[str] = (
str asyncio.get_running_loop().create_future()
] = asyncio.get_running_loop().create_future() )
authenticate_task: Optional[asyncio.Future[None]] = None authenticate_task: Optional[asyncio.Future[None]] = None
pair_task: Optional[asyncio.Future[None]] = None pair_task: Optional[asyncio.Future[None]] = None
+38 -1
View File
@@ -24,8 +24,9 @@ import enum
import struct import struct
import functools import functools
import logging import logging
from typing import Optional, List, Union, Type, Dict, Any, Tuple, cast from typing import Optional, List, Union, Type, Dict, Any, Tuple
from bumble import core
from bumble import colors from bumble import colors
from bumble import device from bumble import device
from bumble import hci from bumble import hci
@@ -228,6 +229,14 @@ class SupportedFrameDuration(enum.IntFlag):
DURATION_10000_US_PREFERRED = 0b0010 DURATION_10000_US_PREFERRED = 0b0010
class AnnouncementType(enum.IntEnum):
'''Basic Audio Profile, 3.5.3. Additional Audio Stream Control Service requirements'''
# fmt: off
GENERAL = 0x00
TARGETED = 0x01
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# ASE Operations # ASE Operations
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -453,6 +462,34 @@ class AudioRole(enum.IntEnum):
SOURCE = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.HOST_TO_CONTROLLER SOURCE = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.HOST_TO_CONTROLLER
@dataclasses.dataclass
class UnicastServerAdvertisingData:
"""Advertising Data for ASCS."""
announcement_type: AnnouncementType = AnnouncementType.TARGETED
available_audio_contexts: ContextType = ContextType.MEDIA
metadata: bytes = b''
def __bytes__(self) -> bytes:
return bytes(
core.AdvertisingData(
[
(
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID,
struct.pack(
'<2sBIB',
gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE.to_bytes(),
self.announcement_type,
self.available_audio_contexts,
len(self.metadata),
)
+ self.metadata,
)
]
)
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Utils # Utils
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -59,7 +59,7 @@ class DeviceInformationService(TemplateService):
firmware_revision: Optional[str] = None, firmware_revision: Optional[str] = None,
software_revision: Optional[str] = None, software_revision: Optional[str] = None,
system_id: Optional[Tuple[int, int]] = None, # (OUI, Manufacturer ID) system_id: Optional[Tuple[int, int]] = None, # (OUI, Manufacturer ID)
ieee_regulatory_certification_data_list: Optional[bytes] = None ieee_regulatory_certification_data_list: Optional[bytes] = None,
# TODO: pnp_id # TODO: pnp_id
): ):
characteristics = [ characteristics = [
@@ -107,7 +107,7 @@ class DeviceInformationServiceProxy(ProfileServiceProxy):
def __init__(self, service_proxy): def __init__(self, service_proxy):
self.service_proxy = service_proxy self.service_proxy = service_proxy
for (field, uuid) in ( for field, uuid in (
('manufacturer_name', GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC), ('manufacturer_name', GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC),
('model_number', GATT_MODEL_NUMBER_STRING_CHARACTERISTIC), ('model_number', GATT_MODEL_NUMBER_STRING_CHARACTERISTIC),
('serial_number', GATT_SERIAL_NUMBER_STRING_CHARACTERISTIC), ('serial_number', GATT_SERIAL_NUMBER_STRING_CHARACTERISTIC),
+12 -8
View File
@@ -825,11 +825,13 @@ class Client:
) )
attribute_id_list = DataElement.sequence( attribute_id_list = DataElement.sequence(
[ [
DataElement.unsigned_integer( (
attribute_id[0], value_size=attribute_id[1] DataElement.unsigned_integer(
attribute_id[0], value_size=attribute_id[1]
)
if isinstance(attribute_id, tuple)
else DataElement.unsigned_integer_16(attribute_id)
) )
if isinstance(attribute_id, tuple)
else DataElement.unsigned_integer_16(attribute_id)
for attribute_id in attribute_ids for attribute_id in attribute_ids
] ]
) )
@@ -881,11 +883,13 @@ class Client:
attribute_id_list = DataElement.sequence( attribute_id_list = DataElement.sequence(
[ [
DataElement.unsigned_integer( (
attribute_id[0], value_size=attribute_id[1] DataElement.unsigned_integer(
attribute_id[0], value_size=attribute_id[1]
)
if isinstance(attribute_id, tuple)
else DataElement.unsigned_integer_16(attribute_id)
) )
if isinstance(attribute_id, tuple)
else DataElement.unsigned_integer_16(attribute_id)
for attribute_id in attribute_ids for attribute_id in attribute_ids
] ]
) )
+3 -3
View File
@@ -737,9 +737,9 @@ class Session:
# Create a future that can be used to wait for the session to complete # Create a future that can be used to wait for the session to complete
if self.is_initiator: if self.is_initiator:
self.pairing_result: Optional[ self.pairing_result: Optional[asyncio.Future[None]] = (
asyncio.Future[None] asyncio.get_running_loop().create_future()
] = asyncio.get_running_loop().create_future() )
else: else:
self.pairing_result = None self.pairing_result = None
+2 -4
View File
@@ -59,15 +59,13 @@ class TransportLostError(Exception):
# Typing Protocols # Typing Protocols
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class TransportSink(Protocol): class TransportSink(Protocol):
def on_packet(self, packet: bytes) -> None: def on_packet(self, packet: bytes) -> None: ...
...
class TransportSource(Protocol): class TransportSource(Protocol):
terminated: asyncio.Future[None] terminated: asyncio.Future[None]
def set_packet_sink(self, sink: TransportSink) -> None: def set_packet_sink(self, sink: TransportSink) -> None: ...
...
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+15 -2
View File
@@ -113,9 +113,10 @@ async def open_pyusb_transport(spec: str) -> Transport:
self.loop.call_soon_threadsafe(self.stop_event.set) self.loop.call_soon_threadsafe(self.stop_event.set)
class UsbPacketSource(asyncio.Protocol, ParserSource): class UsbPacketSource(asyncio.Protocol, ParserSource):
def __init__(self, device, sco_enabled): def __init__(self, device, metadata, sco_enabled):
super().__init__() super().__init__()
self.device = device self.device = device
self.metadata = metadata
self.loop = asyncio.get_running_loop() self.loop = asyncio.get_running_loop()
self.queue = asyncio.Queue() self.queue = asyncio.Queue()
self.dequeue_task = None self.dequeue_task = None
@@ -216,6 +217,15 @@ async def open_pyusb_transport(spec: str) -> Transport:
if ':' in spec: if ':' in spec:
vendor_id, product_id = spec.split(':') vendor_id, product_id = spec.split(':')
device = usb_find(idVendor=int(vendor_id, 16), idProduct=int(product_id, 16)) device = usb_find(idVendor=int(vendor_id, 16), idProduct=int(product_id, 16))
elif '-' in spec:
def device_path(device):
if device.port_numbers:
return f'{device.bus}-{".".join(map(str, device.port_numbers))}'
else:
return str(device.bus)
device = usb_find(custom_match=lambda device: device_path(device) == spec)
else: else:
device_index = int(spec) device_index = int(spec)
devices = list( devices = list(
@@ -235,6 +245,9 @@ async def open_pyusb_transport(spec: str) -> Transport:
raise ValueError('device not found') raise ValueError('device not found')
logger.debug(f'USB Device: {device}') logger.debug(f'USB Device: {device}')
# Collect the metadata
device_metadata = {'vendor_id': device.idVendor, 'product_id': device.idProduct}
# Detach the kernel driver if needed # Detach the kernel driver if needed
if device.is_kernel_driver_active(0): if device.is_kernel_driver_active(0):
logger.debug("detaching kernel driver") logger.debug("detaching kernel driver")
@@ -289,7 +302,7 @@ async def open_pyusb_transport(spec: str) -> Transport:
# except usb.USBError: # except usb.USBError:
# logger.warning('failed to set alternate setting') # logger.warning('failed to set alternate setting')
packet_source = UsbPacketSource(device, sco_enabled) packet_source = UsbPacketSource(device, device_metadata, sco_enabled)
packet_sink = UsbPacketSink(device) packet_sink = UsbPacketSink(device)
packet_source.start() packet_source.start()
packet_sink.start() packet_sink.start()
+24 -5
View File
@@ -18,6 +18,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
import socket
from .common import Transport, StreamPacketSource from .common import Transport, StreamPacketSource
@@ -28,6 +29,12 @@ logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# A pass-through function to ease mock testing.
async def _create_server(*args, **kw_args):
await asyncio.get_running_loop().create_server(*args, **kw_args)
async def open_tcp_server_transport(spec: str) -> Transport: async def open_tcp_server_transport(spec: str) -> Transport:
''' '''
Open a TCP server transport. Open a TCP server transport.
@@ -38,7 +45,22 @@ async def open_tcp_server_transport(spec: str) -> Transport:
Example: _:9001 Example: _:9001
''' '''
local_host, local_port = spec.split(':')
return await _open_tcp_server_transport_impl(
host=local_host if local_host != '_' else None, port=int(local_port)
)
async def open_tcp_server_transport_with_socket(sock: socket.socket) -> Transport:
'''
Open a TCP server transport with an existing socket.
One reason to use this variant is to let python pick an unused port.
'''
return await _open_tcp_server_transport_impl(sock=sock)
async def _open_tcp_server_transport_impl(**kwargs) -> Transport:
class TcpServerTransport(Transport): class TcpServerTransport(Transport):
async def close(self): async def close(self):
await super().close() await super().close()
@@ -77,13 +99,10 @@ async def open_tcp_server_transport(spec: str) -> Transport:
else: else:
logger.debug('no client, dropping packet') logger.debug('no client, dropping packet')
local_host, local_port = spec.split(':')
packet_source = StreamPacketSource() packet_source = StreamPacketSource()
packet_sink = TcpServerPacketSink() packet_sink = TcpServerPacketSink()
await asyncio.get_running_loop().create_server( await _create_server(
lambda: TcpServerProtocol(packet_source, packet_sink), lambda: TcpServerProtocol(packet_source, packet_sink), **kwargs
host=local_host if local_host != '_' else None,
port=int(local_port),
) )
return TcpServerTransport(packet_source, packet_sink) return TcpServerTransport(packet_source, packet_sink)
+11 -1
View File
@@ -396,6 +396,16 @@ async def open_usb_transport(spec: str) -> Transport:
break break
device_index -= 1 device_index -= 1
device.close() device.close()
elif '-' in spec:
def device_path(device):
return f'{device.getBusNumber()}-{".".join(map(str, device.getPortNumberList()))}'
for device in context.getDeviceIterator(skip_on_error=True):
if device_path(device) == spec:
found = device
break
device.close()
else: else:
# Look for a compatible device by index # Look for a compatible device by index
def device_is_bluetooth_hci(device): def device_is_bluetooth_hci(device):
@@ -439,7 +449,7 @@ async def open_usb_transport(spec: str) -> Transport:
# Look for the first interface with the right class and endpoints # Look for the first interface with the right class and endpoints
def find_endpoints(device): def find_endpoints(device):
# pylint: disable-next=too-many-nested-blocks # pylint: disable-next=too-many-nested-blocks
for (configuration_index, configuration) in enumerate(device): for configuration_index, configuration in enumerate(device):
interface = None interface = None
for interface in configuration: for interface in configuration:
setting = None setting = None
+10 -8
View File
@@ -117,12 +117,12 @@ class EventWatcher:
self.handlers = [] self.handlers = []
@overload @overload
def on(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]: def on(
... self, emitter: EventEmitter, event: str
) -> Callable[[_Handler], _Handler]: ...
@overload @overload
def on(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler: def on(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler: ...
...
def on( def on(
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
@@ -144,12 +144,14 @@ class EventWatcher:
return wrapper if handler is None else wrapper(handler) return wrapper if handler is None else wrapper(handler)
@overload @overload
def once(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]: def once(
... self, emitter: EventEmitter, event: str
) -> Callable[[_Handler], _Handler]: ...
@overload @overload
def once(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler: def once(
... self, emitter: EventEmitter, event: str, handler: _Handler
) -> _Handler: ...
def once( def once(
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
+6
View File
@@ -10,6 +10,7 @@ The moniker for a USB transport is either:
* `usb:<vendor>:<product>` * `usb:<vendor>:<product>`
* `usb:<vendor>:<product>/<serial-number>` * `usb:<vendor>:<product>/<serial-number>`
* `usb:<vendor>:<product>#<index>` * `usb:<vendor>:<product>#<index>`
* `usb:<bus>-<port_numbers>`
with `<index>` as a 0-based index (0 being the first one) to select amongst all the matching devices when there are more than one. with `<index>` as a 0-based index (0 being the first one) to select amongst all the matching devices when there are more than one.
In the `usb:<index>` form, matching devices are the ones supporting Bluetooth HCI, as declared by their Class, Subclass and Protocol. In the `usb:<index>` form, matching devices are the ones supporting Bluetooth HCI, as declared by their Class, Subclass and Protocol.
@@ -17,6 +18,8 @@ In the `usb:<vendor>:<product>#<index>` form, matching devices are the ones with
`<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal. `<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal.
with `<port_numbers>` as a list of all port numbers from root separated with dots `.`
In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode: In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
the first USB interface of the device will be used, regardless of the interface class/subclass. the first USB interface of the device will be used, regardless of the interface class/subclass.
This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is. This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
@@ -37,6 +40,9 @@ This may be useful for some devices that use a custom class/subclass but may non
`usb:0B05:17CB!` `usb:0B05:17CB!`
The BT USB dongle vendor=0B05 and product=17CB, in "forced" mode. The BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
`usb:3-3.4.1`
The BT USB dongle on bus 3 on port path 3, 4, 1.
## Alternative ## Alternative
The library includes two different implementations of the USB transport, implemented using different python bindings for `libusb`. The library includes two different implementations of the USB transport, implemented using different python bindings for `libusb`.
+1
View File
@@ -25,6 +25,7 @@ from bumble.utils import AsyncRunner
my_work_queue1 = AsyncRunner.WorkQueue() my_work_queue1 = AsyncRunner.WorkQueue()
my_work_queue2 = AsyncRunner.WorkQueue(create_task=False) my_work_queue2 = AsyncRunner.WorkQueue(create_task=False)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@AsyncRunner.run_in_task() @AsyncRunner.run_in_task()
async def func1(x, y): async def func1(x, y):
+107 -54
View File
@@ -1,79 +1,132 @@
<html> <html data-bs-theme="dark">
<head>
<style>
* {
font-family: sans-serif;
}
label { <head>
display: block; <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
} integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
</head>
<body>
<nav class="navbar navbar-dark bg-primary">
<div class="container">
<span class="navbar-brand mb-0 h1">Bumble Handsfree</span>
</div>
</nav>
<br>
<div class="container">
<label class="form-label">Server Port</label>
<div class="input-group mb-3">
<input type="text" class="form-control" aria-label="Port Number" value="8989" id="port">
<button class="btn btn-primary" type="button" onclick="connect()">Connect</button>
</div>
<label class="form-label">Dial Phone Number</label>
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="Phone Number" aria-label="Phone Number"
id="dial_number">
<button class="btn btn-primary" type="button"
onclick="send_at_command(`ATD${dialNumberInput.value}`)">Dial</button>
</div>
<label class="form-label">Send AT Command</label>
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="AT Command" aria-label="AT command" id="at_command">
<button class="btn btn-primary" type="button"
onclick="send_at_command(document.getElementById('at_command').value)">Send</button>
</div>
<div class="row">
<div class="col-auto">
<label class="form-label">Battery Level</label>
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="0 - 100" aria-label="Battery Level"
id="battery_level">
<button class="btn btn-primary" type="button"
onclick="send_at_command(`AT+BIEV=2,${document.getElementById('battery_level').value}`)">Set</button>
</div>
</div>
<div class="col-auto">
<label class="form-label">Speaker Volume</label>
<div class="input-group mb-3 col-auto">
<input type="text" class="form-control" placeholder="0 - 15" aria-label="Speaker Volume"
id="speaker_volume">
<button class="btn btn-primary" type="button"
onclick="send_at_command(`AT+VGS=${document.getElementById('speaker_volume').value}`)">Set</button>
</div>
</div>
<div class="col-auto">
<label class="form-label">Mic Volume</label>
<div class="input-group mb-3 col-auto">
<input type="text" class="form-control" placeholder="0 - 15" aria-label="Mic Volume"
id="mic_volume">
<button class="btn btn-primary" type="button"
onclick="send_at_command(`AT+VGM=${document.getElementById('mic_volume').value}`)">Set</button>
</div>
</div>
</div>
<button class="btn btn-primary" onclick="send_at_command('ATA')">Answer</button>
<button class="btn btn-primary" onclick="send_at_command('AT+CHUP')">Hang Up</button>
<button class="btn btn-primary" onclick="send_at_command('AT+BLDN')">Redial</button>
<button class="btn btn-primary" onclick="send({ type: 'query_call'})">Get Call Status</button>
<br><br>
<button class="btn btn-primary" onclick="send_at_command('AT+BVRA=1')">Start Voice Assistant</button>
<button class="btn btn-primary" onclick="send_at_command('AT+BVRA=0')">Stop Voice Assistant</button>
input, label {
margin: .4rem 0;
}
</style>
</head>
<body>
Server Port <input id="port" type="text" value="8989"></input> <button onclick="connect()">Connect</button><br>
AT Command <input type="text" id="at_command" required size="10"> <button onclick="send_at_command()">Send</button><br>
Dial Phone Number <input type="text" id="dial_number" required size="10"> <button onclick="dial()">Dial</button><br>
<button onclick="answer()">Answer</button>
<button onclick="hangup()">Hang Up</button>
<button onclick="start_voice_assistant()">Start Voice Assistant</button>
<button onclick="stop_voice_assistant()">Stop Voice Assistant</button>
<hr> <hr>
<div id="socketState"></div>
<script> <div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2">
<h3>Log</h3>
<code id="log" style="white-space: pre-line;"></code>
</div>
</div>
<script>
let portInput = document.getElementById("port") let portInput = document.getElementById("port")
let atCommandInput = document.getElementById("at_command") let atCommandInput = document.getElementById("at_command")
let dialNumberInput = document.getElementById("dial_number") let log = document.getElementById("log")
let socketState = document.getElementById("socketState")
let socket let socket
function connect() { function connect() {
socket = new WebSocket(`ws://localhost:${portInput.value}`); socket = new WebSocket(`ws://localhost:${portInput.value}`);
socket.onopen = _ => { socket.onopen = _ => {
socketState.innerText = 'OPEN' log.textContent += 'OPEN\n'
} }
socket.onclose = _ => { socket.onclose = _ => {
socketState.innerText = 'CLOSED' log.textContent += 'CLOSED\n'
} }
socket.onerror = (error) => { socket.onerror = (error) => {
socketState.innerText = 'ERROR' log.textContent += 'ERROR\n'
console.log(`ERROR: ${error}`) console.log(`ERROR: ${error}`)
} }
socket.onmessage = (event) => {
log.textContent += `<-- ${event.data}\n`
let volume_state = JSON.parse(event.data)
volumeSetting.value = volume_state.volume_setting
changeCounter.value = volume_state.change_counter
muted.checked = volume_state.muted ? true : false
}
} }
function send(message) { function send(message) {
if (socket && socket.readyState == WebSocket.OPEN) { if (socket && socket.readyState == WebSocket.OPEN) {
socket.send(JSON.stringify(message)) let jsonMessage = JSON.stringify(message)
log.textContent += `--> ${jsonMessage}\n`
socket.send(jsonMessage)
} else {
log.textContent += 'NOT CONNECTED\n'
} }
} }
function send_at_command() { function send_at_command(command) {
send({ type:'at_command', command: atCommandInput.value }) send({ type: 'at_command', 'command': command })
} }
</script>
</div>
</body>
function answer() { </html>
send({ type:'at_command', command: 'ATA' })
}
function hangup() {
send({ type:'at_command', command: 'AT+CHUP' })
}
function dial() {
send({ type:'at_command', command: `ATD${dialNumberInput.value}` })
}
function start_voice_assistant() {
send(({ type:'at_command', command: 'AT+BVRA=1' }))
}
function stop_voice_assistant() {
send(({ type:'at_command', command: 'AT+BVRA=0' }))
}
</script>
</body>
</html>
+1
View File
@@ -25,6 +25,7 @@ from bumble.device import Device
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
from bumble.snoop import BtSnooper from bumble.snoop import BtSnooper
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main():
if len(sys.argv) != 3: if len(sys.argv) != 3:
+38 -31
View File
@@ -16,6 +16,7 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import asyncio import asyncio
import contextlib
import sys import sys
import os import os
import logging import logging
@@ -31,39 +32,16 @@ from bumble.transport import open_transport_or_link
from bumble import hfp from bumble import hfp
from bumble.hfp import HfProtocol from bumble.hfp import HfProtocol
ws: Optional[websockets.WebSocketServerProtocol] = None
# ----------------------------------------------------------------------------- hf_protocol: Optional[HfProtocol] = None
class UiServer:
protocol: Optional[HfProtocol] = None
async def start(self):
"""Start a Websocket server to receive events from a web page."""
async def serve(websocket, _path):
while True:
try:
message = await websocket.recv()
print('Received: ', str(message))
parsed = json.loads(message)
message_type = parsed['type']
if message_type == 'at_command':
if self.protocol is not None:
await self.protocol.execute_command(parsed['command'])
except websockets.exceptions.ConnectionClosedOK:
pass
# pylint: disable=no-member
await websockets.serve(serve, 'localhost', 8989)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def on_dlc(dlc: rfcomm.DLC, configuration: hfp.Configuration): def on_dlc(dlc: rfcomm.DLC, configuration: hfp.Configuration):
print('*** DLC connected', dlc) print('*** DLC connected', dlc)
protocol = HfProtocol(dlc, configuration) global hf_protocol
UiServer.protocol = protocol hf_protocol = HfProtocol(dlc, configuration)
asyncio.create_task(protocol.run()) asyncio.create_task(hf_protocol.run())
def on_sco_request(connection: Connection, link_type: int, protocol: HfProtocol): def on_sco_request(connection: Connection, link_type: int, protocol: HfProtocol):
if connection == protocol.dlc.multiplexer.l2cap_channel.connection: if connection == protocol.dlc.multiplexer.l2cap_channel.connection:
@@ -88,7 +66,7 @@ def on_dlc(dlc: rfcomm.DLC, configuration: hfp.Configuration):
), ),
) )
handler = functools.partial(on_sco_request, protocol=protocol) handler = functools.partial(on_sco_request, protocol=hf_protocol)
dlc.multiplexer.l2cap_channel.connection.device.on('sco_request', handler) dlc.multiplexer.l2cap_channel.connection.device.on('sco_request', handler)
dlc.multiplexer.l2cap_channel.once( dlc.multiplexer.l2cap_channel.once(
'close', 'close',
@@ -97,6 +75,13 @@ def on_dlc(dlc: rfcomm.DLC, configuration: hfp.Configuration):
), ),
) )
def on_ag_indicator(indicator):
global ws
if ws:
asyncio.create_task(ws.send(str(indicator)))
hf_protocol.on('ag_indicator', on_ag_indicator)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main():
@@ -154,8 +139,30 @@ async def main():
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
ui_server = UiServer() async def serve(websocket: websockets.WebSocketServerProtocol, _path):
await ui_server.start() global ws
ws = websocket
async for message in websocket:
with contextlib.suppress(websockets.exceptions.ConnectionClosedOK):
print('Received: ', str(message))
parsed = json.loads(message)
message_type = parsed['type']
if message_type == 'at_command':
if hf_protocol is not None:
response = str(
await hf_protocol.execute_command(
parsed['command'],
response_type=hfp.AtResponseType.MULTIPLE,
)
)
await websocket.send(response)
elif message_type == 'query_call':
if hf_protocol:
response = str(await hf_protocol.query_current_calls())
await websocket.send(response)
await websockets.serve(serve, 'localhost', 8989)
await hci_source.wait_for_termination() await hci_source.wait_for_termination()
+2
View File
@@ -229,6 +229,7 @@ HID_REPORT_MAP = bytes( # Text String, 50 Octet Report Descriptor
# Default protocol mode set to report protocol # Default protocol mode set to report protocol
protocol_mode = Message.ProtocolMode.REPORT_PROTOCOL protocol_mode = Message.ProtocolMode.REPORT_PROTOCOL
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def sdp_records(): def sdp_records():
service_record_handle = 0x00010002 service_record_handle = 0x00010002
@@ -427,6 +428,7 @@ class DeviceData:
# Device's live data - Mouse and Keyboard will be stored in this # Device's live data - Mouse and Keyboard will be stored in this
deviceData = DeviceData() deviceData = DeviceData()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def keyboard_device(hid_device): async def keyboard_device(hid_device):
+4 -3
View File
@@ -22,14 +22,14 @@ import os
import struct import struct
import secrets import secrets
from bumble.core import AdvertisingData from bumble.core import AdvertisingData
from bumble.device import Device, CisLink, AdvertisingParameters from bumble.device import Device, CisLink
from bumble.hci import ( from bumble.hci import (
CodecID, CodecID,
CodingFormat, CodingFormat,
OwnAddressType,
HCI_IsoDataPacket, HCI_IsoDataPacket,
) )
from bumble.profiles.bap import ( from bumble.profiles.bap import (
UnicastServerAdvertisingData,
CodecSpecificCapabilities, CodecSpecificCapabilities,
ContextType, ContextType,
AudioLocation, AudioLocation,
@@ -141,6 +141,7 @@ async def main() -> None:
) )
) )
+ csis.get_advertising_data() + csis.get_advertising_data()
+ bytes(UnicastServerAdvertisingData())
) )
subprocess = await asyncio.create_subprocess_shell( subprocess = await asyncio.create_subprocess_shell(
f'dlc3 | ffplay pipe:0', f'dlc3 | ffplay pipe:0',
@@ -178,7 +179,7 @@ async def main() -> None:
device.once('cis_establishment', on_cis) device.once('cis_establishment', on_cis)
advertising_set = await device.create_advertising_set( await device.create_advertising_set(
advertising_data=advertising_data, advertising_data=advertising_data,
) )
+2
View File
@@ -31,6 +31,7 @@ from bumble.hci import (
OwnAddressType, OwnAddressType,
) )
from bumble.profiles.bap import ( from bumble.profiles.bap import (
UnicastServerAdvertisingData,
CodecSpecificCapabilities, CodecSpecificCapabilities,
ContextType, ContextType,
AudioLocation, AudioLocation,
@@ -151,6 +152,7 @@ async def main() -> None:
) )
) )
+ csis.get_advertising_data() + csis.get_advertising_data()
+ bytes(UnicastServerAdvertisingData())
) )
await device.create_advertising_set( await device.create_advertising_set(
+5 -2
View File
@@ -1,7 +1,10 @@
# Next # 0.2.0
- Code-gen company ID table - Code-gen company ID table
- Unstable support for extended advertisements
- CLI tools for downloading Realtek firmware
- PDL-generated types for HCI commands
# 0.1.0 # 0.1.0
- Initial release - Initial release
+1 -1
View File
@@ -182,7 +182,7 @@ dependencies = [
[[package]] [[package]]
name = "bumble" name = "bumble"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "bumble" name = "bumble"
description = "Rust API for the Bumble Bluetooth stack" description = "Rust API for the Bumble Bluetooth stack"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
homepage = "https://google.github.io/bumble/index.html" homepage = "https://google.github.io/bumble/index.html"
+6 -1
View File
@@ -37,6 +37,11 @@ PYTHONPATH=..:[virtualenv site-packages] \
cargo run --features bumble-tools --bin bumble -- --help cargo run --features bumble-tools --bin bumble -- --help
``` ```
Notable subcommands:
- `firmware realtek download`: download Realtek firmware for various chipsets so that it can be automatically loaded when needed
- `usb probe`: show USB devices, highlighting the ones usable for Bluetooth
# Development # Development
Run the tests: Run the tests:
@@ -63,4 +68,4 @@ To regenerate the assigned number tables based on the Python codebase:
``` ```
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features dev-tools PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features dev-tools
``` ```
+1 -1
View File
@@ -35,7 +35,7 @@ impl Controller {
/// module specifies the defaults. Must be called from a thread with a Python event loop, which /// module specifies the defaults. Must be called from a thread with a Python event loop, which
/// should be true on `tokio::main` and `async_std::main`. /// should be true on `tokio::main` and `async_std::main`.
/// ///
/// For more info, see https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars. /// For more info, see <https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars>.
pub async fn new( pub async fn new(
name: &str, name: &str,
host_source: Option<TransportSource>, host_source: Option<TransportSource>,
+1 -1
View File
@@ -149,7 +149,7 @@ impl ToPyObject for Address {
/// An error meaning that the u64 value did not represent a valid BT address. /// An error meaning that the u64 value did not represent a valid BT address.
#[derive(Debug)] #[derive(Debug)]
pub struct InvalidAddress(u64); pub struct InvalidAddress(#[allow(unused)] u64);
impl TryInto<packets::Address> for Address { impl TryInto<packets::Address> for Address {
type Error = ConversionError<InvalidAddress>; type Error = ConversionError<InvalidAddress>;
+1 -1
View File
@@ -71,7 +71,7 @@ impl LeConnectionOrientedChannel {
/// Must be called from a thread with a Python event loop, which should be true on /// Must be called from a thread with a Python event loop, which should be true on
/// `tokio::main` and `async_std::main`. /// `tokio::main` and `async_std::main`.
/// ///
/// For more info, see https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars. /// For more info, see <https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars>.
pub async fn disconnect(&mut self) -> PyResult<()> { pub async fn disconnect(&mut self) -> PyResult<()> {
Python::with_gil(|py| { Python::with_gil(|py| {
self.0 self.0
+9 -9
View File
@@ -33,18 +33,18 @@ include_package_data = True
install_requires = install_requires =
aiohttp ~= 3.8; platform_system!='Emscripten' aiohttp ~= 3.8; platform_system!='Emscripten'
appdirs >= 1.4; platform_system!='Emscripten' appdirs >= 1.4; platform_system!='Emscripten'
bt-test-interfaces >= 0.0.2; platform_system!='Emscripten' bt-test-interfaces >= 0.0.6; platform_system!='Emscripten'
click == 8.1.3; platform_system!='Emscripten' click >= 8.1.3; platform_system!='Emscripten'
cryptography == 39; platform_system!='Emscripten' cryptography == 39; platform_system!='Emscripten'
# Pyodide bundles a version of cryptography that is built for wasm, which may not match the # Pyodide bundles a version of cryptography that is built for wasm, which may not match the
# versions available on PyPI. Relax the version requirement since it's better than being # versions available on PyPI. Relax the version requirement since it's better than being
# completely unable to import the package in case of version mismatch. # completely unable to import the package in case of version mismatch.
cryptography >= 39.0; platform_system=='Emscripten' cryptography >= 39.0; platform_system=='Emscripten'
grpcio == 1.57.0; platform_system!='Emscripten' grpcio >= 1.62.1; platform_system!='Emscripten'
humanize >= 4.6.0; platform_system!='Emscripten' humanize >= 4.6.0; platform_system!='Emscripten'
libusb1 >= 2.0.1; platform_system!='Emscripten' libusb1 >= 2.0.1; platform_system!='Emscripten'
libusb-package == 1.0.26.1; platform_system!='Emscripten' libusb-package == 1.0.26.1; platform_system!='Emscripten'
platformdirs == 3.10.0; platform_system!='Emscripten' platformdirs >= 3.10.0; platform_system!='Emscripten'
prompt_toolkit >= 3.0.16; platform_system!='Emscripten' prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
prettytable >= 3.6.0; platform_system!='Emscripten' prettytable >= 3.6.0; platform_system!='Emscripten'
protobuf >= 3.12.4; platform_system!='Emscripten' protobuf >= 3.12.4; platform_system!='Emscripten'
@@ -83,12 +83,12 @@ build =
build >= 0.7 build >= 0.7
test = test =
pytest >= 8.0 pytest >= 8.0
pytest-asyncio == 0.21.1 pytest-asyncio >= 0.21.1
pytest-html >= 3.2.0 pytest-html >= 3.2.0
coverage >= 6.4 coverage >= 6.4
development = development =
black == 22.10 black == 24.3
grpcio-tools >= 1.57.0 grpcio-tools >= 1.62.1
invoke >= 1.7.3 invoke >= 1.7.3
mypy == 1.8.0 mypy == 1.8.0
nox >= 2022 nox >= 2022
@@ -98,8 +98,8 @@ development =
types-invoke >= 1.7.3 types-invoke >= 1.7.3
types-protobuf >= 4.21.0 types-protobuf >= 4.21.0
avatar = avatar =
pandora-avatar == 0.0.5 pandora-avatar == 0.0.9
rootcanal == 1.9.0 ; python_version>='3.10' rootcanal == 1.10.0 ; python_version>='3.10'
documentation = documentation =
mkdocs >= 1.4.0 mkdocs >= 1.4.0
mkdocs-material >= 8.5.6 mkdocs-material >= 8.5.6
+1
View File
@@ -17,6 +17,7 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from bumble.core import AdvertisingData, UUID, get_dict_key_by_value from bumble.core import AdvertisingData, UUID, get_dict_key_by_value
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def test_ad_data(): def test_ad_data():
data = bytes([2, AdvertisingData.TX_POWER_LEVEL, 123]) data = bytes([2, AdvertisingData.TX_POWER_LEVEL, 123])
+6 -6
View File
@@ -316,13 +316,13 @@ async def _test_self_smp_with_configs(pairing_config1, pairing_config2):
# Set up the pairing configs # Set up the pairing configs
if pairing_config1: if pairing_config1:
two_devices.devices[ two_devices.devices[0].pairing_config_factory = (
0 lambda connection: pairing_config1
].pairing_config_factory = lambda connection: pairing_config1 )
if pairing_config2: if pairing_config2:
two_devices.devices[ two_devices.devices[1].pairing_config_factory = (
1 lambda connection: pairing_config2
].pairing_config_factory = lambda connection: pairing_config2 )
# Pair # Pair
await two_devices.devices[0].pair(connection) await two_devices.devices[0].pair(connection)
+64
View File
@@ -0,0 +1,64 @@
# Copyright 2024 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.
import asyncio
import os
import pytest
import socket
import unittest
from unittest.mock import ANY, patch
from bumble.transport.tcp_server import (
open_tcp_server_transport,
open_tcp_server_transport_with_socket,
)
class OpenTcpServerTransportTests(unittest.TestCase):
def setUp(self):
self.patcher = patch('bumble.transport.tcp_server._create_server')
self.mock_create_server = self.patcher.start()
def tearDown(self):
self.patcher.stop()
def test_open_with_spec(self):
asyncio.run(open_tcp_server_transport('localhost:32100'))
self.mock_create_server.assert_awaited_once_with(
ANY, host='localhost', port=32100
)
def test_open_with_port_only_spec(self):
asyncio.run(open_tcp_server_transport('_:32100'))
self.mock_create_server.assert_awaited_once_with(ANY, host=None, port=32100)
def test_open_with_socket(self):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
asyncio.run(open_tcp_server_transport_with_socket(sock=sock))
self.mock_create_server.assert_awaited_once_with(ANY, sock=sock)
@pytest.mark.skipif(
not os.environ.get('PYTEST_NOSKIP', 0),
reason='''\
Not hermetic. Should only run manually with
$ PYTEST_NOSKIP=1 pytest tests
''',
)
def test_open_with_real_socket():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(('localhost', 0))
port = sock.getsockname()[1]
assert port != 0
asyncio.run(open_tcp_server_transport_with_socket(sock=sock))
+2 -1
View File
@@ -49,6 +49,7 @@ LINUX_FROM_SCRATCH_SOURCE = (
False, False,
) )
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Functions # Functions
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -111,7 +112,7 @@ def main(output_dir, source, single, force, parse):
for driver_info in rtk.Driver.DRIVER_INFOS for driver_info in rtk.Driver.DRIVER_INFOS
] ]
for (fw_name, config_name, config_needed) in images: for fw_name, config_name, config_needed in images:
print(color("---", "yellow")) print(color("---", "yellow"))
fw_image_out = output_dir / fw_name fw_image_out = output_dir / fw_name
if not force and fw_image_out.exists(): if not force and fw_image_out.exists():