Merge pull request #870 from antipatico/feat_AV53C1

This commit is contained in:
Gilles Boccon-Gibod
2026-01-20 10:46:52 -08:00
5 changed files with 351 additions and 269 deletions

View File

@@ -22,7 +22,7 @@ import click
import bumble.logging
from bumble import data_types
from bumble.colors import color
from bumble.device import Advertisement, Device
from bumble.device import Advertisement, Device, DeviceConfiguration
from bumble.hci import HCI_LE_1M_PHY, HCI_LE_CODED_PHY, Address, HCI_Constant
from bumble.keys import JsonKeyStore
from bumble.smp import AddressResolver
@@ -144,8 +144,14 @@ async def scan(
device_config, hci_source, hci_sink
)
else:
device = Device.with_hci(
'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
device = Device.from_config_with_hci(
DeviceConfiguration(
name='Bumble',
address=Address('F0:F1:F2:F3:F4:F5'),
keystore='JsonKeyStore',
),
hci_source,
hci_sink,
)
await device.power_on()

View File

@@ -3633,10 +3633,9 @@ class Device(utils.CompositeEventEmitter):
page_scan_enabled=self.connectable,
)
async def connect(
async def connect_le(
self,
peer_address: hci.Address | str,
transport: core.PhysicalTransport = PhysicalTransport.LE,
connection_parameters_preferences: (
dict[hci.Phy, ConnectionParametersPreferences] | None
) = None,
@@ -3644,123 +3643,62 @@ class Device(utils.CompositeEventEmitter):
timeout: float | None = DEVICE_DEFAULT_CONNECT_TIMEOUT,
always_resolve: bool = False,
) -> Connection:
'''
Request a connection to a peer.
When the transport is BLE, this method cannot be called if there is already a
pending connection.
Args:
peer_address:
hci.Address or name of the device to connect to.
If a string is passed:
If the string is an address followed by a `@` suffix, the `always_resolve`
argument is implicitly set to True, so the connection is made to the
address after resolution.
If the string is any other address, the connection is made to that
address (with or without address resolution, depending on the
`always_resolve` argument).
For any other string, a scan for devices using that string as their name
is initiated, and a connection to the first matching device's address
is made. In that case, `always_resolve` is ignored.
connection_parameters_preferences:
(BLE only, ignored for BR/EDR)
* None: use the 1M PHY with default parameters
* map: each entry has a PHY as key and a ConnectionParametersPreferences
object as value
own_address_type:
(BLE only, ignored for BR/EDR)
hci.OwnAddressType.RANDOM to use this device's random address, or
hci.OwnAddressType.PUBLIC to use this device's public address.
timeout:
Maximum time to wait for a connection to be established, in seconds.
Pass None for an unlimited time.
always_resolve:
(BLE only, ignored for BR/EDR)
If True, always initiate a scan, resolving addresses, and connect to the
address that resolves to `peer_address`.
'''
# Check parameters
if transport not in (PhysicalTransport.LE, PhysicalTransport.BR_EDR):
raise InvalidArgumentError('invalid transport')
transport = core.PhysicalTransport(transport)
# Adjust the transport automatically if we need to
if transport == PhysicalTransport.LE and not self.le_enabled:
transport = PhysicalTransport.BR_EDR
elif transport == PhysicalTransport.BR_EDR and not self.classic_enabled:
transport = PhysicalTransport.LE
# Check that there isn't already a pending connection
if transport == PhysicalTransport.LE and self.is_le_connecting:
if self.is_le_connecting:
raise InvalidStateError('connection already pending')
do_resolve = always_resolve
if isinstance(peer_address, str):
try:
if transport == PhysicalTransport.LE and peer_address.endswith('@'):
if peer_address.endswith('@'):
peer_address = hci.Address.from_string_for_transport(
peer_address[:-1], transport
peer_address[:-1], PhysicalTransport.LE
)
always_resolve = True
do_resolve = True
logger.debug('forcing address resolution')
else:
peer_address = hci.Address.from_string_for_transport(
peer_address, transport
peer_address, PhysicalTransport.LE
)
except (InvalidArgumentError, ValueError):
# If the address is not parsable, assume it is a name instead
always_resolve = False
do_resolve = False
logger.debug('looking for peer by name')
assert isinstance(peer_address, str)
peer_address = await self.find_peer_by_name(
peer_address, transport
peer_address, PhysicalTransport.LE
) # TODO: timeout
else:
# All BR/EDR addresses should be public addresses
if (
transport == PhysicalTransport.BR_EDR
and peer_address.address_type != hci.Address.PUBLIC_DEVICE_ADDRESS
):
raise InvalidArgumentError('BR/EDR addresses must be PUBLIC')
assert isinstance(peer_address, hci.Address)
if transport == PhysicalTransport.LE and always_resolve:
logger.debug('resolving address')
if (
(peer_address.is_public or peer_address.is_static)
and self.address_resolver is not None
and self.address_resolver.can_resolve_to(peer_address)
):
# If we have an IRK for this address, we should resolve.
do_resolve = True
logger.debug('have IRK for address, will resolve')
if do_resolve and self.address_resolver is not None:
logger.debug('resolving address...')
peer_address = await self.find_peer_by_identity_address(
peer_address
) # TODO: timeout
def on_connection(connection):
if transport == PhysicalTransport.LE or (
# match BR/EDR connection event against peer address
connection.transport == transport
and connection.peer_address == peer_address
):
pending_connection.set_result(connection)
def on_connection_failure(error: core.ConnectionError):
if transport == PhysicalTransport.LE or (
# match BR/EDR connection failure event against peer address
error.transport == transport
and error.peer_address == peer_address
):
pending_connection.set_exception(error)
# Create a future so that we can wait for the connection's result
# Create a future so that we can wait for the connection result
pending_connection = asyncio.get_running_loop().create_future()
self.on(self.EVENT_CONNECTION, on_connection)
self.on(self.EVENT_CONNECTION_FAILURE, on_connection_failure)
try:
# Tell the controller to connect
if transport == PhysicalTransport.LE:
if connection_parameters_preferences is None:
if connection_parameters_preferences is None:
connection_parameters_preferences = {
hci.HCI_LE_1M_PHY: ConnectionParametersPreferences.default
@@ -3790,43 +3728,31 @@ class Device(utils.CompositeEventEmitter):
connection_interval_mins = [
int(
connection_parameters_preferences[
phy
].connection_interval_min
connection_parameters_preferences[phy].connection_interval_min
/ 1.25
)
for phy in phys
]
connection_interval_maxs = [
int(
connection_parameters_preferences[
phy
].connection_interval_max
connection_parameters_preferences[phy].connection_interval_max
/ 1.25
)
for phy in phys
]
max_latencies = [
connection_parameters_preferences[phy].max_latency
for phy in phys
connection_parameters_preferences[phy].max_latency for phy in phys
]
supervision_timeouts = [
int(
connection_parameters_preferences[phy].supervision_timeout
/ 10
)
int(connection_parameters_preferences[phy].supervision_timeout / 10)
for phy in phys
]
min_ce_lengths = [
int(
connection_parameters_preferences[phy].min_ce_length / 0.625
)
int(connection_parameters_preferences[phy].min_ce_length / 0.625)
for phy in phys
]
max_ce_lengths = [
int(
connection_parameters_preferences[phy].max_ce_length / 0.625
)
int(connection_parameters_preferences[phy].max_ce_length / 0.625)
for phy in phys
]
@@ -3841,9 +3767,7 @@ class Device(utils.CompositeEventEmitter):
int(DEVICE_DEFAULT_CONNECT_SCAN_INTERVAL / 0.625),
)
* phy_count,
scan_windows=(
int(DEVICE_DEFAULT_CONNECT_SCAN_WINDOW / 0.625),
)
scan_windows=(int(DEVICE_DEFAULT_CONNECT_SCAN_WINDOW / 0.625),)
* phy_count,
connection_interval_mins=connection_interval_mins,
connection_interval_maxs=connection_interval_maxs,
@@ -3863,9 +3787,7 @@ class Device(utils.CompositeEventEmitter):
le_scan_interval=int(
DEVICE_DEFAULT_CONNECT_SCAN_INTERVAL / 0.625
),
le_scan_window=int(
DEVICE_DEFAULT_CONNECT_SCAN_WINDOW / 0.625
),
le_scan_window=int(DEVICE_DEFAULT_CONNECT_SCAN_WINDOW / 0.625),
initiator_filter_policy=0,
peer_address_type=peer_address.address_type,
peer_address=peer_address,
@@ -3882,7 +3804,82 @@ class Device(utils.CompositeEventEmitter):
max_ce_length=int(prefs.max_ce_length / 0.625),
)
)
# Wait for the connection process to complete
self.le_connecting = True
if timeout is None:
return await utils.cancel_on_event(
self, Device.EVENT_FLUSH, pending_connection
)
try:
return await asyncio.wait_for(
asyncio.shield(pending_connection), timeout
)
except asyncio.TimeoutError:
await self.send_sync_command(
hci.HCI_LE_Create_Connection_Cancel_Command()
)
try:
return await utils.cancel_on_event(
self, Device.EVENT_FLUSH, pending_connection
)
except core.ConnectionError as error:
raise core.TimeoutError() from error
finally:
self.remove_listener(self.EVENT_CONNECTION, on_connection)
self.remove_listener(self.EVENT_CONNECTION_FAILURE, on_connection_failure)
self.le_connecting = False
self.connect_own_address_type = None
async def connect_classic(
self,
peer_address: hci.Address | str,
timeout: float | None = DEVICE_DEFAULT_CONNECT_TIMEOUT,
) -> Connection:
if isinstance(peer_address, str):
try:
peer_address = hci.Address.from_string_for_transport(
peer_address, PhysicalTransport.BR_EDR
)
except (InvalidArgumentError, ValueError):
# If the address is not parsable, assume it is a name instead
logger.debug('looking for peer by name')
assert isinstance(peer_address, str)
peer_address = await self.find_peer_by_name(
peer_address, PhysicalTransport.BR_EDR
) # TODO: timeout
else:
# All BR/EDR addresses should be public addresses
if peer_address.address_type != hci.Address.PUBLIC_DEVICE_ADDRESS:
raise InvalidArgumentError('BR/EDR addresses must be PUBLIC')
assert isinstance(peer_address, hci.Address)
def on_connection(connection):
if (
# match BR/EDR connection event against peer address
connection.transport == PhysicalTransport.BR_EDR
and connection.peer_address == peer_address
):
pending_connection.set_result(connection)
def on_connection_failure(error: core.ConnectionError):
if (
# match BR/EDR connection failure event against peer address
error.transport == PhysicalTransport.BR_EDR
and error.peer_address == peer_address
):
pending_connection.set_exception(error)
# Create a future so that we can wait for the connection result
pending_connection = asyncio.get_running_loop().create_future()
self.on(self.EVENT_CONNECTION, on_connection)
self.on(self.EVENT_CONNECTION_FAILURE, on_connection_failure)
try:
# Save pending connection
self.pending_connections[peer_address] = Connection(
device=self,
@@ -3909,9 +3906,6 @@ class Device(utils.CompositeEventEmitter):
)
# Wait for the connection process to complete
if transport == PhysicalTransport.LE:
self.le_connecting = True
if timeout is None:
return await utils.cancel_on_event(
self, Device.EVENT_FLUSH, pending_connection
@@ -3922,11 +3916,6 @@ class Device(utils.CompositeEventEmitter):
asyncio.shield(pending_connection), timeout
)
except asyncio.TimeoutError:
if transport == PhysicalTransport.LE:
await self.send_sync_command(
hci.HCI_LE_Create_Connection_Cancel_Command()
)
else:
await self.send_sync_command(
hci.HCI_Create_Connection_Cancel_Command(bd_addr=peer_address)
)
@@ -3940,12 +3929,85 @@ class Device(utils.CompositeEventEmitter):
finally:
self.remove_listener(self.EVENT_CONNECTION, on_connection)
self.remove_listener(self.EVENT_CONNECTION_FAILURE, on_connection_failure)
if transport == PhysicalTransport.LE:
self.le_connecting = False
self.connect_own_address_type = None
else:
self.pending_connections.pop(peer_address, None)
async def connect(
self,
peer_address: hci.Address | str,
transport: core.PhysicalTransport = PhysicalTransport.LE,
connection_parameters_preferences: (
dict[hci.Phy, ConnectionParametersPreferences] | None
) = None,
own_address_type: hci.OwnAddressType = hci.OwnAddressType.RANDOM,
timeout: float | None = DEVICE_DEFAULT_CONNECT_TIMEOUT,
always_resolve: bool = False,
) -> Connection:
'''
Request a connection to a peer.
When the transport is BLE, this method cannot be called if there is already a
pending connection.
Args:
peer_address:
hci.Address or name of the device to connect to.
If a string is passed:
[deprecated] If the string is an address followed by a `@` suffix, the
`always_resolve`argument is implicitly set to True, so the connection is
made to the address after resolution.
If the string is any other address, the connection is made to that
address (with or without address resolution, depending on the
`always_resolve` argument).
For any other string, a scan for devices using that string as their name
is initiated, and a connection to the first matching device's address
is made. In that case, `always_resolve` is ignored.
connection_parameters_preferences:
(BLE only, ignored for BR/EDR)
* None: use the 1M PHY with default parameters
* map: each entry has a PHY as key and a ConnectionParametersPreferences
object as value
own_address_type:
(BLE only, ignored for BR/EDR)
hci.OwnAddressType.RANDOM to use this device's random address, or
hci.OwnAddressType.PUBLIC to use this device's public address.
timeout:
Maximum time to wait for a connection to be established, in seconds.
Pass None for an unlimited time.
always_resolve:
[deprecated] (BLE only, ignored for BR/EDR)
If True, always initiate a scan, resolving addresses, and connect to the
address that resolves to `peer_address`.
'''
# Check parameters
if transport not in (PhysicalTransport.LE, PhysicalTransport.BR_EDR):
raise InvalidArgumentError('invalid transport')
# Connect using the appropriate transport
# (auto-correct the transport based on declared capabilities)
if transport == PhysicalTransport.LE or (
self.le_enabled and not self.classic_enabled
):
return await self.connect_le(
peer_address=peer_address,
connection_parameters_preferences=connection_parameters_preferences,
own_address_type=own_address_type,
timeout=timeout,
always_resolve=always_resolve,
)
elif transport == PhysicalTransport.BR_EDR or (
self.classic_enabled and not self.le_enabled
):
return await self.connect_classic(
peer_address=peer_address, timeout=timeout
)
else:
raise InvalidArgumentError('no supported transport for request')
async def accept(
self,
peer_address: hci.Address | str = hci.Address.ANY,
@@ -4311,7 +4373,9 @@ class Device(utils.CompositeEventEmitter):
)
)
async def find_peer_by_name(self, name: str, transport=PhysicalTransport.LE):
async def find_peer_by_name(
self, name: str, transport=PhysicalTransport.LE
) -> hci.Address:
"""
Scan for a peer with a given name and return its address.
"""
@@ -4329,6 +4393,7 @@ class Device(utils.CompositeEventEmitter):
listener: Callable[..., None] | None = None
was_scanning = self.scanning
was_discovering = self.discovering
event_name = None
try:
if transport == PhysicalTransport.LE:
event_name = 'advertisement'
@@ -4354,11 +4419,11 @@ class Device(utils.CompositeEventEmitter):
if not self.discovering:
await self.start_discovery()
else:
return None
raise InvalidArgumentError
return await utils.cancel_on_event(self, Device.EVENT_FLUSH, peer_address)
finally:
if listener is not None:
if listener is not None and event_name is not None:
self.remove_listener(event_name, listener)
if transport == PhysicalTransport.LE and not was_scanning:
@@ -4373,6 +4438,8 @@ class Device(utils.CompositeEventEmitter):
Scan for a peer with a resolvable address that can be resolved to a given
identity address.
"""
if self.address_resolver is None:
raise InvalidStateError('no resolver')
# Create a future to wait for an address to be found
peer_address = asyncio.get_running_loop().create_future()
@@ -4385,6 +4452,7 @@ class Device(utils.CompositeEventEmitter):
return
if address.is_resolvable:
assert self.address_resolver is not None
resolved_address = self.address_resolver.resolve(address)
if resolved_address == identity_address:
if not peer_address.done():

View File

@@ -129,6 +129,7 @@ RTK_USB_PRODUCTS = {
(0x2357, 0x0604),
(0x2550, 0x8761),
(0x2B89, 0x8761),
(0x2C0A, 0x8761),
(0x7392, 0xC611),
# Realtek 8761CUV
(0x0B05, 0x1BF6),

View File

@@ -791,7 +791,9 @@ class Host(utils.EventEmitter):
data=pdu,
)
logger.debug(
'>>> ACL packet enqueue: (Handle=0x%04X) %s', connection_handle, pdu
'>>> ACL packet enqueue: (handle=0x%04X) %s',
connection_handle,
pdu.hex(),
)
packet_queue.enqueue(acl_packet, connection_handle)

View File

@@ -27,7 +27,7 @@ from __future__ import annotations
import asyncio
import enum
import logging
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, ClassVar, TypeVar, cast
@@ -507,10 +507,15 @@ def smp_auth_req(bonding: bool, mitm: bool, sc: bool, keypress: bool, ct2: bool)
# -----------------------------------------------------------------------------
class AddressResolver:
def __init__(self, resolving_keys):
def __init__(self, resolving_keys: Sequence[tuple[bytes, Address]]) -> None:
self.resolving_keys = resolving_keys
def resolve(self, address):
def can_resolve_to(self, address: Address) -> bool:
return any(
resolved_address == address for _, resolved_address in self.resolving_keys
)
def resolve(self, address: Address) -> Address | None:
address_bytes = bytes(address)
hash_part = address_bytes[0:3]
prand = address_bytes[3:6]