Compare commits

...

34 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod
b9476be9ad Merge pull request #315 from google/gbg/company-ids
update to latest list of company ids
2023-10-10 22:13:16 -07:00
Gilles Boccon-Gibod
704c60491c Merge pull request #313 from benquike/pair_fix
Allow turning on BLE in classic pairing mode
2023-10-10 21:30:24 -07:00
Gilles Boccon-Gibod
4a8e612c6e update rust list 2023-10-10 21:29:39 -07:00
Gilles Boccon-Gibod
4e71ec5738 remove stale comment 2023-10-10 20:36:48 -07:00
uael
7255a09705 ci: add python avatar tests 2023-10-09 23:37:23 +02:00
zxzxwu
c2bf6b5f13 Merge pull request #289 from zxzxwu/l2cap_refactor
Refactor L2CAP API
2023-10-09 23:27:25 +08:00
Gilles Boccon-Gibod
d8e699b588 use the new yaml file instead of the previous CSV file 2023-10-07 23:10:49 -07:00
zxzxwu
3e4d4705f5 Merge pull request #314 from zxzxwu/sec_pandora
Pandora: Handle exception in WaitSecurity()
2023-10-08 01:42:45 +08:00
Josh Wu
c8b2804446 Pandora: Handle exception in WaitSecurity() 2023-10-07 21:17:01 +08:00
Josh Wu
e732f2589f Refactor L2CAP API 2023-10-07 20:01:15 +08:00
zxzxwu
aec5543081 Merge pull request #310 from zxzxwu/avdtp
Typing AVDTP
2023-10-07 19:50:56 +08:00
Josh Wu
e03d90ca57 Add typing for MediaCodecCapabilities members 2023-10-07 19:32:19 +08:00
Josh Wu
495ce62d9c Typing AVDTP 2023-10-07 19:32:19 +08:00
Hui Peng
fbc3959a5a Allow turning on BLE in classic pairing mode 2023-10-06 19:54:18 -07:00
Gilles Boccon-Gibod
dfa9131192 Merge pull request #311 from zxzxwu/rust
Fix Rust lints
2023-10-06 13:37:47 -07:00
Josh Wu
88c801b4c2 Replace or_insert_with with or_default 2023-10-06 18:02:46 +08:00
Gilles Boccon-Gibod
a1b55b94e0 Merge pull request #301 from whitevegagabriel/simplify-event-loop-copy
Remove unncecesary steps for injecting Python event loop
2023-10-02 12:12:41 -07:00
Gilles Boccon-Gibod
80db9e2e2f Merge pull request #303 from whitevegagabriel/hci-command-rs
Ability to send HCI commands from Rust
2023-10-02 12:12:05 -07:00
Gabriel White-Vega
ce74690420 Update pdl to 0.2.0
- Allows removing impl PartialEq for pdl Error
2023-10-02 11:20:44 -04:00
Gilles Boccon-Gibod
50de4dfb5d Merge pull request #307 from google/gbg/hotfix-001
don't delete advertising prefs on disconnection
2023-09-30 17:46:53 -07:00
Gilles Boccon-Gibod
9bcdf860f4 don't delete advertising prefs on disconnection 2023-09-30 17:41:18 -07:00
Gabriel White-Vega
511ab4b630 Add python async wrapper, move hci non-wrapper to internal, add hci::internal tests 2023-09-29 10:23:19 -04:00
Gilles Boccon-Gibod
6f2b623e3c Merge pull request #290 from google/gbg/netsim-transport-injectable-channels
make grpc channels injectable
2023-09-27 22:16:05 -07:00
Gilles Boccon-Gibod
fa12165cd3 Merge pull request #298 from google/gbg/use-address-to-string
use Address.to_string instead of manual suffix replacement
2023-09-27 21:59:32 -07:00
Gilles Boccon-Gibod
c0c6f3329d minor cleanup 2023-09-27 21:53:54 -07:00
Gilles Boccon-Gibod
406a932467 make grpc channels injectable 2023-09-27 21:37:36 -07:00
Gilles Boccon-Gibod
cc96d4245f address PR comments 2023-09-27 21:25:13 -07:00
Sparkling Diva
c6cdca8923 device: return the psm value from register_l2cap 2023-09-27 16:41:38 -07:00
Gabriel White-Vega
7e331c2944 Ability to send HCI commands from Rust
* Autogenerate packet code in Rust from PDL (packet file copied from rootcanal)
* Implement parsing of packets that have a type header
* Expose Python APIs for sending HCI commands
* Expose Python APIs for instantiating a local controller
2023-09-27 11:17:47 -04:00
Gilles Boccon-Gibod
10347765cb Merge pull request #302 from google/gbg/netsim-with-instance-num
support netsim instance numbers
2023-09-26 09:34:28 -07:00
Gilles Boccon-Gibod
1ad84ad51c fix linter errors 2023-09-22 15:08:10 -07:00
Gilles Boccon-Gibod
64937c3f77 support netsim instance numbers 2023-09-22 14:22:04 -07:00
Gabriel White-Vega
50fd2218fa Remove unncecesary steps for injecting Python event loop
* Context vars can be injected directly into Rust future and spawned with tokio
2023-09-22 15:23:01 -04:00
Gilles Boccon-Gibod
f1777a5bd2 use .to_string instead of a manual suffix replacement 2023-09-21 19:03:54 -07:00
45 changed files with 9639 additions and 849 deletions

43
.github/workflows/python-avatar.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Python Avatar
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
test:
name: Avatar [${{ matrix.shard }}]
runs-on: ubuntu-latest
strategy:
matrix:
shard: [
1/24, 2/24, 3/24, 4/24,
5/24, 6/24, 7/24, 8/24,
9/24, 10/24, 11/24, 12/24,
13/24, 14/24, 15/24, 16/24,
17/24, 18/24, 19/24, 20/24,
21/24, 22/24, 23/24, 24/24,
]
steps:
- uses: actions/checkout@v3
- name: Set Up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.11
- name: Install
run: |
python -m pip install --upgrade pip
python -m pip install .[avatar]
- name: Rootcanal
run: nohup python -m rootcanal > rootcanal.log &
- name: Test
run: |
avatar --list | grep -Ev '^=' > test-names.txt
timeout 5m avatar --test-beds bumble.bumbles --tests $(split test-names.txt -n l/${{ matrix.shard }})
- name: Rootcanal Logs
run: cat rootcanal.log

View File

@@ -39,10 +39,12 @@
"libusb",
"MITM",
"NDIS",
"netsim",
"NONBLOCK",
"NONCONN",
"OXIMETER",
"popleft",
"protobuf",
"psms",
"pyee",
"pyusb",

View File

@@ -1172,7 +1172,7 @@ class ScanResult:
name = ''
# Remove any '/P' qualifier suffix from the address string
address_str = str(self.address).replace('/P', '')
address_str = self.address.to_string(with_type_qualifier=False)
# RSSI bar
bar_string = rssi_bar(self.rssi)

View File

@@ -63,7 +63,8 @@ async def get_classic_info(host):
if command_succeeded(response):
print()
print(
color('Classic Address:', 'yellow'), response.return_parameters.bd_addr
color('Classic Address:', 'yellow'),
response.return_parameters.bd_addr.to_string(False),
)
if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND):

View File

@@ -306,6 +306,7 @@ async def pair(
# Expose a GATT characteristic that can be used to trigger pairing by
# responding with an authentication error when read
if mode == 'le':
device.le_enabled = True
device.add_service(
Service(
'50DB505C-8AC4-4738-8448-3B1D9CC09CC5',
@@ -326,7 +327,6 @@ async def pair(
# Select LE or Classic
if mode == 'classic':
device.classic_enabled = True
device.le_enabled = False
device.classic_smp_enabled = ctkd
# Get things going

View File

@@ -195,7 +195,7 @@ class WebSocketOutput(QueuedOutput):
except HCI_StatusError:
pass
peer_name = '' if connection.peer_name is None else connection.peer_name
peer_address = str(connection.peer_address).replace('/P', '')
peer_address = connection.peer_address.to_string(False)
await self.send_message(
'connection',
peer_address=peer_address,
@@ -376,7 +376,7 @@ class UiServer:
if connection := self.speaker().connection:
await self.send_message(
'connection',
peer_address=str(connection.peer_address).replace('/P', ''),
peer_address=connection.peer_address.to_string(False),
peer_name=connection.peer_name,
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,8 @@ from typing import (
Tuple,
Type,
Union,
cast,
overload,
TYPE_CHECKING,
)
@@ -151,6 +153,7 @@ from .utils import (
CompositeEventEmitter,
setup_event_forwarding,
composite_listener,
deprecated,
)
from .keys import (
KeyStore,
@@ -670,9 +673,7 @@ class Connection(CompositeEventEmitter):
def send_l2cap_pdu(self, cid: int, pdu: bytes) -> None:
self.device.send_l2cap_pdu(self.handle, cid, pdu)
def create_l2cap_connector(self, psm):
return self.device.create_l2cap_connector(self, psm)
@deprecated("Please use create_l2cap_channel()")
async def open_l2cap_channel(
self,
psm,
@@ -682,6 +683,23 @@ class Connection(CompositeEventEmitter):
):
return await self.device.open_l2cap_channel(self, psm, max_credits, mtu, mps)
@overload
async def create_l2cap_channel(
self, spec: l2cap.ClassicChannelSpec
) -> l2cap.ClassicChannel:
...
@overload
async def create_l2cap_channel(
self, spec: l2cap.LeCreditBasedChannelSpec
) -> l2cap.LeCreditBasedChannel:
...
async def create_l2cap_channel(
self, spec: Union[l2cap.ClassicChannelSpec, l2cap.LeCreditBasedChannelSpec]
) -> Union[l2cap.ClassicChannel, l2cap.LeCreditBasedChannel]:
return await self.device.create_l2cap_channel(connection=self, spec=spec)
async def disconnect(
self, reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR
) -> None:
@@ -1180,15 +1198,11 @@ class Device(CompositeEventEmitter):
return None
def create_l2cap_connector(self, connection, psm):
return lambda: self.l2cap_channel_manager.connect(connection, psm)
def create_l2cap_registrar(self, psm):
return lambda handler: self.register_l2cap_server(psm, handler)
def register_l2cap_server(self, psm, server):
self.l2cap_channel_manager.register_server(psm, server)
@deprecated("Please use create_l2cap_server()")
def register_l2cap_server(self, psm, server) -> int:
return self.l2cap_channel_manager.register_server(psm, server)
@deprecated("Please use create_l2cap_server()")
def register_l2cap_channel_server(
self,
psm,
@@ -1201,6 +1215,7 @@ class Device(CompositeEventEmitter):
psm, server, max_credits, mtu, mps
)
@deprecated("Please use create_l2cap_channel()")
async def open_l2cap_channel(
self,
connection,
@@ -1213,6 +1228,74 @@ class Device(CompositeEventEmitter):
connection, psm, max_credits, mtu, mps
)
@overload
async def create_l2cap_channel(
self,
connection: Connection,
spec: l2cap.ClassicChannelSpec,
) -> l2cap.ClassicChannel:
...
@overload
async def create_l2cap_channel(
self,
connection: Connection,
spec: l2cap.LeCreditBasedChannelSpec,
) -> l2cap.LeCreditBasedChannel:
...
async def create_l2cap_channel(
self,
connection: Connection,
spec: Union[l2cap.ClassicChannelSpec, l2cap.LeCreditBasedChannelSpec],
) -> Union[l2cap.ClassicChannel, l2cap.LeCreditBasedChannel]:
if isinstance(spec, l2cap.ClassicChannelSpec):
return await self.l2cap_channel_manager.create_classic_channel(
connection=connection, spec=spec
)
if isinstance(spec, l2cap.LeCreditBasedChannelSpec):
return await self.l2cap_channel_manager.create_le_credit_based_channel(
connection=connection, spec=spec
)
@overload
def create_l2cap_server(
self,
spec: l2cap.ClassicChannelSpec,
handler: Optional[Callable[[l2cap.ClassicChannel], Any]] = None,
) -> l2cap.ClassicChannelServer:
...
@overload
def create_l2cap_server(
self,
spec: l2cap.LeCreditBasedChannelSpec,
handler: Optional[Callable[[l2cap.LeCreditBasedChannel], Any]] = None,
) -> l2cap.LeCreditBasedChannelServer:
...
def create_l2cap_server(
self,
spec: Union[l2cap.ClassicChannelSpec, l2cap.LeCreditBasedChannelSpec],
handler: Union[
Callable[[l2cap.ClassicChannel], Any],
Callable[[l2cap.LeCreditBasedChannel], Any],
None,
] = None,
) -> Union[l2cap.ClassicChannelServer, l2cap.LeCreditBasedChannelServer]:
if isinstance(spec, l2cap.ClassicChannelSpec):
return self.l2cap_channel_manager.create_classic_server(
spec=spec,
handler=cast(Callable[[l2cap.ClassicChannel], Any], handler),
)
elif isinstance(spec, l2cap.LeCreditBasedChannelSpec):
return self.l2cap_channel_manager.create_le_credit_based_server(
handler=cast(Callable[[l2cap.LeCreditBasedChannel], Any], handler),
spec=spec,
)
else:
raise ValueError(f'Unexpected mode {spec}')
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
self.host.send_l2cap_pdu(connection_handle, cid, pdu)
@@ -1425,10 +1508,10 @@ class Device(CompositeEventEmitter):
check_result=True,
)
self.advertising_own_address_type = own_address_type
self.auto_restart_advertising = auto_restart
self.advertising_type = advertising_type
self.advertising_own_address_type = own_address_type
self.advertising = True
self.auto_restart_advertising = auto_restart
async def stop_advertising(self) -> None:
# Disable advertising
@@ -1438,9 +1521,9 @@ class Device(CompositeEventEmitter):
check_result=True,
)
self.advertising_type = None
self.advertising_own_address_type = None
self.advertising = False
self.advertising_type = None
self.auto_restart_advertising = False
@property
@@ -2630,7 +2713,6 @@ class Device(CompositeEventEmitter):
own_address_type = self.advertising_own_address_type
# We are no longer advertising
self.advertising_own_address_type = None
self.advertising = False
if own_address_type in (
@@ -2687,7 +2769,6 @@ class Device(CompositeEventEmitter):
and self.advertising
and self.advertising_type.is_directed
):
self.advertising_own_address_type = None
self.advertising = False
# Notify listeners

View File

@@ -17,6 +17,7 @@
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import dataclasses
import enum
import logging
import struct
@@ -38,6 +39,7 @@ from typing import (
TYPE_CHECKING,
)
from .utils import deprecated
from .colors import color
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
from .hci import (
@@ -167,6 +169,34 @@ L2CAP_MTU_CONFIGURATION_PARAMETER_TYPE = 0x01
# pylint: disable=invalid-name
@dataclasses.dataclass
class ClassicChannelSpec:
psm: Optional[int] = None
mtu: int = L2CAP_MIN_BR_EDR_MTU
@dataclasses.dataclass
class LeCreditBasedChannelSpec:
psm: Optional[int] = None
mtu: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU
mps: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS
max_credits: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS
def __post_init__(self):
if (
self.max_credits < 1
or self.max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
):
raise ValueError('max credits out of range')
if self.mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU:
raise ValueError('MTU too small')
if (
self.mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
or self.mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
):
raise ValueError('MPS out of range')
class L2CAP_PDU:
'''
See Bluetooth spec @ Vol 3, Part A - 3 DATA PACKET FORMAT
@@ -676,7 +706,7 @@ class L2CAP_LE_Flow_Control_Credit(L2CAP_Control_Frame):
# -----------------------------------------------------------------------------
class Channel(EventEmitter):
class ClassicChannel(EventEmitter):
class State(enum.IntEnum):
# States
CLOSED = 0x00
@@ -990,7 +1020,7 @@ class Channel(EventEmitter):
# -----------------------------------------------------------------------------
class LeConnectionOrientedChannel(EventEmitter):
class LeCreditBasedChannel(EventEmitter):
"""
LE Credit-based Connection Oriented Channel
"""
@@ -1004,7 +1034,7 @@ class LeConnectionOrientedChannel(EventEmitter):
CONNECTION_ERROR = 5
out_queue: Deque[bytes]
connection_result: Optional[asyncio.Future[LeConnectionOrientedChannel]]
connection_result: Optional[asyncio.Future[LeCreditBasedChannel]]
disconnection_result: Optional[asyncio.Future[None]]
out_sdu: Optional[bytes]
state: State
@@ -1071,7 +1101,7 @@ class LeConnectionOrientedChannel(EventEmitter):
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
async def connect(self) -> LeConnectionOrientedChannel:
async def connect(self) -> LeCreditBasedChannel:
# Check that we're in the right state
if self.state != self.State.INIT:
raise InvalidStateError('not in a connectable state')
@@ -1342,15 +1372,67 @@ class LeConnectionOrientedChannel(EventEmitter):
)
# -----------------------------------------------------------------------------
class ClassicChannelServer(EventEmitter):
def __init__(
self,
manager: ChannelManager,
psm: int,
handler: Optional[Callable[[ClassicChannel], Any]],
mtu: int,
) -> None:
super().__init__()
self.manager = manager
self.handler = handler
self.psm = psm
self.mtu = mtu
def on_connection(self, channel: ClassicChannel) -> None:
self.emit('connection', channel)
if self.handler:
self.handler(channel)
def close(self) -> None:
if self.psm in self.manager.servers:
del self.manager.servers[self.psm]
# -----------------------------------------------------------------------------
class LeCreditBasedChannelServer(EventEmitter):
def __init__(
self,
manager: ChannelManager,
psm: int,
handler: Optional[Callable[[LeCreditBasedChannel], Any]],
max_credits: int,
mtu: int,
mps: int,
) -> None:
super().__init__()
self.manager = manager
self.handler = handler
self.psm = psm
self.max_credits = max_credits
self.mtu = mtu
self.mps = mps
def on_connection(self, channel: LeCreditBasedChannel) -> None:
self.emit('connection', channel)
if self.handler:
self.handler(channel)
def close(self) -> None:
if self.psm in self.manager.le_coc_servers:
del self.manager.le_coc_servers[self.psm]
# -----------------------------------------------------------------------------
class ChannelManager:
identifiers: Dict[int, int]
channels: Dict[int, Dict[int, Union[Channel, LeConnectionOrientedChannel]]]
servers: Dict[int, Callable[[Channel], Any]]
le_coc_channels: Dict[int, Dict[int, LeConnectionOrientedChannel]]
le_coc_servers: Dict[
int, Tuple[Callable[[LeConnectionOrientedChannel], Any], int, int, int]
]
channels: Dict[int, Dict[int, Union[ClassicChannel, LeCreditBasedChannel]]]
servers: Dict[int, ClassicChannelServer]
le_coc_channels: Dict[int, Dict[int, LeCreditBasedChannel]]
le_coc_servers: Dict[int, LeCreditBasedChannelServer]
le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request]
fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]]
_host: Optional[Host]
@@ -1429,21 +1511,6 @@ class ChannelManager:
raise RuntimeError('no free CID')
@staticmethod
def check_le_coc_parameters(max_credits: int, mtu: int, mps: int) -> None:
if (
max_credits < 1
or max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
):
raise ValueError('max credits out of range')
if mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU:
raise ValueError('MTU too small')
if (
mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
or mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
):
raise ValueError('MPS out of range')
def next_identifier(self, connection: Connection) -> int:
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
self.identifiers[connection.handle] = identifier
@@ -1458,8 +1525,22 @@ class ChannelManager:
if cid in self.fixed_channels:
del self.fixed_channels[cid]
def register_server(self, psm: int, server: Callable[[Channel], Any]) -> int:
if psm == 0:
@deprecated("Please use create_classic_channel_server")
def register_server(
self,
psm: int,
server: Callable[[ClassicChannel], Any],
) -> int:
return self.create_classic_server(
handler=server, spec=ClassicChannelSpec(psm=psm)
).psm
def create_classic_server(
self,
spec: ClassicChannelSpec,
handler: Optional[Callable[[ClassicChannel], Any]] = None,
) -> ClassicChannelServer:
if spec.psm is None:
# Find a free PSM
for candidate in range(
L2CAP_PSM_DYNAMIC_RANGE_START, L2CAP_PSM_DYNAMIC_RANGE_END + 1, 2
@@ -1468,62 +1549,75 @@ class ChannelManager:
continue
if candidate in self.servers:
continue
psm = candidate
spec.psm = candidate
break
else:
raise InvalidStateError('no free PSM')
else:
# Check that the PSM isn't already in use
if psm in self.servers:
if spec.psm in self.servers:
raise ValueError('PSM already in use')
# Check that the PSM is valid
if psm % 2 == 0:
if spec.psm % 2 == 0:
raise ValueError('invalid PSM (not odd)')
check = psm >> 8
check = spec.psm >> 8
while check:
if check % 2 != 0:
raise ValueError('invalid PSM')
check >>= 8
self.servers[psm] = server
self.servers[spec.psm] = ClassicChannelServer(self, spec.psm, handler, spec.mtu)
return psm
return self.servers[spec.psm]
@deprecated("Please use create_le_credit_based_server()")
def register_le_coc_server(
self,
psm: int,
server: Callable[[LeConnectionOrientedChannel], Any],
max_credits: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
mtu: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
mps: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
server: Callable[[LeCreditBasedChannel], Any],
max_credits: int,
mtu: int,
mps: int,
) -> int:
self.check_le_coc_parameters(max_credits, mtu, mps)
return self.create_le_credit_based_server(
spec=LeCreditBasedChannelSpec(
psm=None if psm == 0 else psm, mtu=mtu, mps=mps, max_credits=max_credits
),
handler=server,
).psm
if psm == 0:
def create_le_credit_based_server(
self,
spec: LeCreditBasedChannelSpec,
handler: Optional[Callable[[LeCreditBasedChannel], Any]] = None,
) -> LeCreditBasedChannelServer:
if spec.psm is None:
# Find a free PSM
for candidate in range(
L2CAP_LE_PSM_DYNAMIC_RANGE_START, L2CAP_LE_PSM_DYNAMIC_RANGE_END + 1
):
if candidate in self.le_coc_servers:
continue
psm = candidate
spec.psm = candidate
break
else:
raise InvalidStateError('no free PSM')
else:
# Check that the PSM isn't already in use
if psm in self.le_coc_servers:
if spec.psm in self.le_coc_servers:
raise ValueError('PSM already in use')
self.le_coc_servers[psm] = (
server,
max_credits or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
mtu or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
mps or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
self.le_coc_servers[spec.psm] = LeCreditBasedChannelServer(
self,
spec.psm,
handler,
max_credits=spec.max_credits,
mtu=spec.mtu,
mps=spec.mps,
)
return psm
return self.le_coc_servers[spec.psm]
def on_disconnection(self, connection_handle: int, _reason: int) -> None:
logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
@@ -1650,13 +1744,13 @@ class ChannelManager:
logger.debug(
f'creating server channel with cid={source_cid} for psm {request.psm}'
)
channel = Channel(
self, connection, cid, request.psm, source_cid, L2CAP_MIN_BR_EDR_MTU
channel = ClassicChannel(
self, connection, cid, request.psm, source_cid, server.mtu
)
connection_channels[source_cid] = channel
# Notify
server(channel)
server.on_connection(channel)
channel.on_connection_request(request)
else:
logger.warning(
@@ -1878,7 +1972,7 @@ class ChannelManager:
self, connection: Connection, cid: int, request
) -> None:
if request.le_psm in self.le_coc_servers:
(server, max_credits, mtu, mps) = self.le_coc_servers[request.le_psm]
server = self.le_coc_servers[request.le_psm]
# Check that the CID isn't already used
le_connection_channels = self.le_coc_channels.setdefault(
@@ -1892,8 +1986,8 @@ class ChannelManager:
L2CAP_LE_Credit_Based_Connection_Response(
identifier=request.identifier,
destination_cid=0,
mtu=mtu,
mps=mps,
mtu=server.mtu,
mps=server.mps,
initial_credits=0,
# pylint: disable=line-too-long
result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_SOURCE_CID_ALREADY_ALLOCATED,
@@ -1911,8 +2005,8 @@ class ChannelManager:
L2CAP_LE_Credit_Based_Connection_Response(
identifier=request.identifier,
destination_cid=0,
mtu=mtu,
mps=mps,
mtu=server.mtu,
mps=server.mps,
initial_credits=0,
# pylint: disable=line-too-long
result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE,
@@ -1925,18 +2019,18 @@ class ChannelManager:
f'creating LE CoC server channel with cid={source_cid} for psm '
f'{request.le_psm}'
)
channel = LeConnectionOrientedChannel(
channel = LeCreditBasedChannel(
self,
connection,
request.le_psm,
source_cid,
request.source_cid,
mtu,
mps,
server.mtu,
server.mps,
request.initial_credits,
request.mtu,
request.mps,
max_credits,
server.max_credits,
True,
)
connection_channels[source_cid] = channel
@@ -1949,16 +2043,16 @@ class ChannelManager:
L2CAP_LE_Credit_Based_Connection_Response(
identifier=request.identifier,
destination_cid=source_cid,
mtu=mtu,
mps=mps,
initial_credits=max_credits,
mtu=server.mtu,
mps=server.mps,
initial_credits=server.max_credits,
# pylint: disable=line-too-long
result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_SUCCESSFUL,
),
)
# Notify
server(channel)
server.on_connection(channel)
else:
logger.info(
f'No LE server for connection 0x{connection.handle:04X} '
@@ -2013,37 +2107,51 @@ class ChannelManager:
channel.on_credits(credit.credits)
def on_channel_closed(self, channel: Channel) -> None:
def on_channel_closed(self, channel: ClassicChannel) -> None:
connection_channels = self.channels.get(channel.connection.handle)
if connection_channels:
if channel.source_cid in connection_channels:
del connection_channels[channel.source_cid]
@deprecated("Please use create_le_credit_based_channel()")
async def open_le_coc(
self, connection: Connection, psm: int, max_credits: int, mtu: int, mps: int
) -> LeConnectionOrientedChannel:
self.check_le_coc_parameters(max_credits, mtu, mps)
) -> LeCreditBasedChannel:
return await self.create_le_credit_based_channel(
connection=connection,
spec=LeCreditBasedChannelSpec(
psm=psm, max_credits=max_credits, mtu=mtu, mps=mps
),
)
async def create_le_credit_based_channel(
self,
connection: Connection,
spec: LeCreditBasedChannelSpec,
) -> LeCreditBasedChannel:
# Find a free CID for the new channel
connection_channels = self.channels.setdefault(connection.handle, {})
source_cid = self.find_free_le_cid(connection_channels)
if source_cid is None: # Should never happen!
raise RuntimeError('all CIDs already in use')
if spec.psm is None:
raise ValueError('PSM cannot be None')
# Create the channel
logger.debug(f'creating coc channel with cid={source_cid} for psm {psm}')
channel = LeConnectionOrientedChannel(
logger.debug(f'creating coc channel with cid={source_cid} for psm {spec.psm}')
channel = LeCreditBasedChannel(
manager=self,
connection=connection,
le_psm=psm,
le_psm=spec.psm,
source_cid=source_cid,
destination_cid=0,
mtu=mtu,
mps=mps,
mtu=spec.mtu,
mps=spec.mps,
credits=0,
peer_mtu=0,
peer_mps=0,
peer_credits=max_credits,
peer_credits=spec.max_credits,
connected=False,
)
connection_channels[source_cid] = channel
@@ -2062,7 +2170,15 @@ class ChannelManager:
return channel
async def connect(self, connection: Connection, psm: int) -> Channel:
@deprecated("Please use create_classic_channel()")
async def connect(self, connection: Connection, psm: int) -> ClassicChannel:
return await self.create_classic_channel(
connection=connection, spec=ClassicChannelSpec(psm=psm)
)
async def create_classic_channel(
self, connection: Connection, spec: ClassicChannelSpec
) -> ClassicChannel:
# NOTE: this implementation hard-codes BR/EDR
# Find a free CID for a new channel
@@ -2071,10 +2187,20 @@ class ChannelManager:
if source_cid is None: # Should never happen!
raise RuntimeError('all CIDs already in use')
if spec.psm is None:
raise ValueError('PSM cannot be None')
# Create the channel
logger.debug(f'creating client channel with cid={source_cid} for psm {psm}')
channel = Channel(
self, connection, L2CAP_SIGNALING_CID, psm, source_cid, L2CAP_MIN_BR_EDR_MTU
logger.debug(
f'creating client channel with cid={source_cid} for psm {spec.psm}'
)
channel = ClassicChannel(
self,
connection,
L2CAP_SIGNALING_CID,
spec.psm,
source_cid,
spec.mtu,
)
connection_channels[source_cid] = channel
@@ -2086,3 +2212,20 @@ class ChannelManager:
raise e
return channel
# -----------------------------------------------------------------------------
# Deprecated Classes
# -----------------------------------------------------------------------------
class Channel(ClassicChannel):
@deprecated("Please use ClassicChannel")
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
class LeConnectionOrientedChannel(LeCreditBasedChannel):
@deprecated("Please use LeCreditBasedChannel")
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)

View File

@@ -450,21 +450,18 @@ class SecurityService(SecurityServicer):
'security_request': pair,
}
# register event handlers
for event, listener in listeners.items():
connection.on(event, listener)
with contextlib.closing(EventWatcher()) as watcher:
# register event handlers
for event, listener in listeners.items():
watcher.on(connection, event, listener)
# security level already reached
if self.reached_security_level(connection, level):
return WaitSecurityResponse(success=empty_pb2.Empty())
# security level already reached
if self.reached_security_level(connection, level):
return WaitSecurityResponse(success=empty_pb2.Empty())
self.log.debug('Wait for security...')
kwargs = {}
kwargs[await wait_for_security] = empty_pb2.Empty()
# remove event handlers
for event, listener in listeners.items():
connection.remove_listener(event, listener) # type: ignore
self.log.debug('Wait for security...')
kwargs = {}
kwargs[await wait_for_security] = empty_pb2.Empty()
# wait for `authenticate` to finish if any
if authenticate_task is not None:

View File

@@ -674,7 +674,7 @@ class Multiplexer(EventEmitter):
acceptor: Optional[Callable[[int], bool]]
dlcs: Dict[int, DLC]
def __init__(self, l2cap_channel: l2cap.Channel, role: Role) -> None:
def __init__(self, l2cap_channel: l2cap.ClassicChannel, role: Role) -> None:
super().__init__()
self.role = role
self.l2cap_channel = l2cap_channel
@@ -887,7 +887,7 @@ class Multiplexer(EventEmitter):
# -----------------------------------------------------------------------------
class Client:
multiplexer: Optional[Multiplexer]
l2cap_channel: Optional[l2cap.Channel]
l2cap_channel: Optional[l2cap.ClassicChannel]
def __init__(self, device: Device, connection: Connection) -> None:
self.device = device
@@ -960,11 +960,11 @@ class Server(EventEmitter):
self.acceptors[channel] = acceptor
return channel
def on_connection(self, l2cap_channel: l2cap.Channel) -> None:
def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug(f'+++ new L2CAP connection: {l2cap_channel}')
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel))
def on_l2cap_channel_open(self, l2cap_channel: l2cap.Channel) -> None:
def on_l2cap_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
# Create a new multiplexer for the channel

View File

@@ -758,7 +758,7 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU):
# -----------------------------------------------------------------------------
class Client:
channel: Optional[l2cap.Channel]
channel: Optional[l2cap.ClassicChannel]
def __init__(self, device: Device) -> None:
self.device = device
@@ -921,7 +921,7 @@ class Client:
# -----------------------------------------------------------------------------
class Server:
CONTINUATION_STATE = bytes([0x01, 0x43])
channel: Optional[l2cap.Channel]
channel: Optional[l2cap.ClassicChannel]
Service = NewType('Service', List[ServiceAttribute])
service_records: Dict[int, Service]
current_response: Union[None, bytes, Tuple[int, List[int]]]

View File

@@ -97,10 +97,13 @@ async def open_android_emulator_transport(spec: Optional[str]) -> Transport:
raise ValueError('invalid mode')
# Create the transport object
transport = PumpedTransport(
PumpedPacketSource(hci_device.read),
PumpedPacketSink(hci_device.write),
channel.close,
class EmulatorTransport(PumpedTransport):
async def close(self):
await super().close()
await channel.close()
transport = EmulatorTransport(
PumpedPacketSource(hci_device.read), PumpedPacketSink(hci_device.write)
)
transport.start()

View File

@@ -18,11 +18,12 @@
import asyncio
import atexit
import logging
import grpc.aio
import os
import pathlib
import sys
from typing import Optional
from typing import Dict, Optional
import grpc.aio
from .common import (
ParserSource,
@@ -33,8 +34,8 @@ from .common import (
)
# pylint: disable=no-name-in-module
from .grpc_protobuf.packet_streamer_pb2_grpc import PacketStreamerStub
from .grpc_protobuf.packet_streamer_pb2_grpc import (
PacketStreamerStub,
PacketStreamerServicer,
add_PacketStreamerServicer_to_server,
)
@@ -43,6 +44,7 @@ from .grpc_protobuf.hci_packet_pb2 import HCIPacket
from .grpc_protobuf.startup_pb2 import Chip, ChipInfo
from .grpc_protobuf.common_pb2 import ChipKind
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -74,14 +76,20 @@ def get_ini_dir() -> Optional[pathlib.Path]:
# -----------------------------------------------------------------------------
def find_grpc_port() -> int:
def ini_file_name(instance_number: int) -> str:
suffix = f'_{instance_number}' if instance_number > 0 else ''
return f'netsim{suffix}.ini'
# -----------------------------------------------------------------------------
def find_grpc_port(instance_number: int) -> int:
if not (ini_dir := get_ini_dir()):
logger.debug('no known directory for .ini file')
return 0
ini_file = ini_dir / 'netsim.ini'
ini_file = ini_dir / ini_file_name(instance_number)
logger.debug(f'Looking for .ini file at {ini_file}')
if ini_file.is_file():
logger.debug(f'Found .ini file at {ini_file}')
with open(ini_file, 'r') as ini_file_data:
for line in ini_file_data.readlines():
if '=' in line:
@@ -90,12 +98,14 @@ def find_grpc_port() -> int:
logger.debug(f'gRPC port = {value}')
return int(value)
logger.debug('no grpc.port property found in .ini file')
# Not found
return 0
# -----------------------------------------------------------------------------
def publish_grpc_port(grpc_port) -> bool:
def publish_grpc_port(grpc_port: int, instance_number: int) -> bool:
if not (ini_dir := get_ini_dir()):
logger.debug('no known directory for .ini file')
return False
@@ -104,7 +114,7 @@ def publish_grpc_port(grpc_port) -> bool:
logger.debug('ini directory does not exist')
return False
ini_file = ini_dir / 'netsim.ini'
ini_file = ini_dir / ini_file_name(instance_number)
try:
ini_file.write_text(f'grpc.port={grpc_port}\n')
logger.debug(f"published gRPC port at {ini_file}")
@@ -122,14 +132,15 @@ def publish_grpc_port(grpc_port) -> bool:
# -----------------------------------------------------------------------------
async def open_android_netsim_controller_transport(
server_host: Optional[str], server_port: int
server_host: Optional[str], server_port: int, options: Dict[str, str]
) -> Transport:
if not server_port:
raise ValueError('invalid port')
if server_host == '_' or not server_host:
server_host = 'localhost'
if not publish_grpc_port(server_port):
instance_number = int(options.get('instance', "0"))
if not publish_grpc_port(server_port, instance_number):
logger.warning("unable to publish gRPC port")
class HciDevice:
@@ -186,15 +197,12 @@ async def open_android_netsim_controller_transport(
logger.debug(f'<<< PACKET: {data.hex()}')
self.on_data_received(data)
def send_packet(self, data):
async def send():
await self.context.write(
PacketResponse(
hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
)
async def send_packet(self, data):
return await self.context.write(
PacketResponse(
hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
)
self.loop.create_task(send())
)
def terminate(self):
self.task.cancel()
@@ -228,17 +236,17 @@ async def open_android_netsim_controller_transport(
logger.debug('gRPC server cancelled')
await self.grpc_server.stop(None)
def on_packet(self, packet):
async def send_packet(self, packet):
if not self.device:
logger.debug('no device, dropping packet')
return
self.device.send_packet(packet)
return await self.device.send_packet(packet)
async def StreamPackets(self, _request_iterator, context):
logger.debug('StreamPackets request')
# Check that we won't already have a device
# Check that we don't already have a device
if self.device:
logger.debug('busy, already serving a device')
return PacketResponse(error='Busy')
@@ -261,15 +269,42 @@ async def open_android_netsim_controller_transport(
await server.start()
asyncio.get_running_loop().create_task(server.serve())
class GrpcServerTransport(Transport):
async def close(self):
await super().close()
return GrpcServerTransport(server, server)
sink = PumpedPacketSink(server.send_packet)
sink.start()
return Transport(server, sink)
# -----------------------------------------------------------------------------
async def open_android_netsim_host_transport(server_host, server_port, options):
async def open_android_netsim_host_transport_with_address(
server_host: Optional[str],
server_port: int,
options: Optional[Dict[str, str]] = None,
):
if server_host == '_' or not server_host:
server_host = 'localhost'
if not server_port:
# Look for the gRPC config in a .ini file
instance_number = 0 if options is None else int(options.get('instance', '0'))
server_port = find_grpc_port(instance_number)
if not server_port:
raise RuntimeError('gRPC server port not found')
# Connect to the gRPC server
server_address = f'{server_host}:{server_port}'
logger.debug(f'Connecting to gRPC server at {server_address}')
channel = grpc.aio.insecure_channel(server_address)
return await open_android_netsim_host_transport_with_channel(
channel,
options,
)
# -----------------------------------------------------------------------------
async def open_android_netsim_host_transport_with_channel(
channel, options: Optional[Dict[str, str]] = None
):
# Wrapper for I/O operations
class HciDevice:
def __init__(self, name, manufacturer, hci_device):
@@ -288,10 +323,12 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
async def read(self):
response = await self.hci_device.read()
response_type = response.WhichOneof('response_type')
if response_type == 'error':
logger.warning(f'received error: {response.error}')
raise RuntimeError(response.error)
elif response_type == 'hci_packet':
if response_type == 'hci_packet':
return (
bytes([response.hci_packet.packet_type])
+ response.hci_packet.packet
@@ -306,24 +343,9 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
)
)
name = options.get('name', DEFAULT_NAME)
name = DEFAULT_NAME if options is None else options.get('name', DEFAULT_NAME)
manufacturer = DEFAULT_MANUFACTURER
if server_host == '_' or not server_host:
server_host = 'localhost'
if not server_port:
# Look for the gRPC config in a .ini file
server_host = 'localhost'
server_port = find_grpc_port()
if not server_port:
raise RuntimeError('gRPC server port not found')
# Connect to the gRPC server
server_address = f'{server_host}:{server_port}'
logger.debug(f'Connecting to gRPC server at {server_address}')
channel = grpc.aio.insecure_channel(server_address)
# Connect as a host
service = PacketStreamerStub(channel)
hci_device = HciDevice(
@@ -334,10 +356,14 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
await hci_device.start()
# Create the transport object
transport = PumpedTransport(
class GrpcTransport(PumpedTransport):
async def close(self):
await super().close()
await channel.close()
transport = GrpcTransport(
PumpedPacketSource(hci_device.read),
PumpedPacketSink(hci_device.write),
channel.close,
)
transport.start()
@@ -345,7 +371,7 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
# -----------------------------------------------------------------------------
async def open_android_netsim_transport(spec):
async def open_android_netsim_transport(spec: Optional[str]) -> Transport:
'''
Open a transport connection as a client or server, implementing Android's `netsim`
simulator protocol over gRPC.
@@ -359,6 +385,11 @@ async def open_android_netsim_transport(spec):
to connect *to* a netsim server (netsim is the controller), or accept
connections *as* a netsim-compatible server.
instance=<n>
Specifies an instance number, with <n> > 0. This is used to determine which
.init file to use. In `host` mode, it is ignored when the <host>:<port>
specifier is present, since in that case no .ini file is used.
In `host` mode:
The <host>:<port> part is optional. When not specified, the transport
looks for a netsim .ini file, from which it will read the `grpc.backend.port`
@@ -387,14 +418,15 @@ async def open_android_netsim_transport(spec):
params = spec.split(',') if spec else []
if params and ':' in params[0]:
# Explicit <host>:<port>
host, port = params[0].split(':')
host, port_str = params[0].split(':')
port = int(port_str)
params_offset = 1
else:
host = None
port = 0
params_offset = 0
options = {}
options: Dict[str, str] = {}
for param in params[params_offset:]:
if '=' not in param:
raise ValueError('invalid parameter, expected <name>=<value>')
@@ -403,10 +435,12 @@ async def open_android_netsim_transport(spec):
mode = options.get('mode', 'host')
if mode == 'host':
return await open_android_netsim_host_transport(host, port, options)
return await open_android_netsim_host_transport_with_address(
host, port, options
)
if mode == 'controller':
if host is None:
raise ValueError('<host>:<port> missing')
return await open_android_netsim_controller_transport(host, port)
return await open_android_netsim_controller_transport(host, port, options)
raise ValueError('invalid mode option')

View File

@@ -339,8 +339,9 @@ class PumpedPacketSource(ParserSource):
try:
packet = await self.receive_function()
self.parser.feed_data(packet)
except asyncio.exceptions.CancelledError:
except asyncio.CancelledError:
logger.debug('source pump task done')
self.terminated.set_result(None)
break
except Exception as error:
logger.warning(f'exception while waiting for packet: {error}')
@@ -370,7 +371,7 @@ class PumpedPacketSink:
try:
packet = await self.packet_queue.get()
await self.send_function(packet)
except asyncio.exceptions.CancelledError:
except asyncio.CancelledError:
logger.debug('sink pump task done')
break
except Exception as error:
@@ -393,19 +394,13 @@ class PumpedTransport(Transport):
self,
source: PumpedPacketSource,
sink: PumpedPacketSink,
close_function,
) -> None:
super().__init__(source, sink)
self.close_function = close_function
def start(self) -> None:
self.source.start()
self.sink.start()
async def close(self) -> None:
await super().close()
await self.close_function()
# -----------------------------------------------------------------------------
class SnoopingTransport(Transport):

View File

@@ -38,10 +38,14 @@ async def open_ws_client_transport(spec: str) -> Transport:
websocket = await websockets.client.connect(spec)
transport = PumpedTransport(
class WsTransport(PumpedTransport):
async def close(self):
await super().close()
await websocket.close()
transport = WsTransport(
PumpedPacketSource(websocket.recv),
PumpedPacketSink(websocket.send),
websocket.close,
)
transport.start()
return transport

View File

@@ -21,6 +21,7 @@ import logging
import traceback
import collections
import sys
import warnings
from typing import (
Awaitable,
Set,
@@ -33,7 +34,7 @@ from typing import (
Union,
overload,
)
from functools import wraps
from functools import wraps, partial
from pyee import EventEmitter
from .colors import color
@@ -410,3 +411,36 @@ class FlowControlAsyncPipe:
self.resume_source()
self.check_pump()
async def async_call(function, *args, **kwargs):
"""
Immediately calls the function with provided args and kwargs, wrapping it in an async function.
Rust's `pyo3_asyncio` library needs functions to be marked async to properly inject a running loop.
result = await async_call(some_function, ...)
"""
return function(*args, **kwargs)
def wrap_async(function):
"""
Wraps the provided function in an async function.
"""
return partial(async_call, function)
def deprecated(msg: str):
"""
Throw deprecation warning before execution
"""
def wrapper(function):
@wraps(function)
def inner(*args, **kwargs):
warnings.warn(msg, DeprecationWarning)
return function(*args, **kwargs)
return inner
return wrapper

229
rust/Cargo.lock generated
View File

@@ -80,6 +80,37 @@ version = "1.0.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
[[package]]
name = "argh"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7af5ba06967ff7214ce4c7419c7d185be7ecd6cc4965a8f6e1d8ce0398aad219"
dependencies = [
"argh_derive",
"argh_shared",
]
[[package]]
name = "argh_derive"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56df0aeedf6b7a2fc67d06db35b09684c3e8da0c95f8f27685cb17e08413d87a"
dependencies = [
"argh_shared",
"proc-macro2",
"quote",
"syn 2.0.29",
]
[[package]]
name = "argh_shared"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5693f39141bda5760ecc4111ab08da40565d1771038c4a0250f03457ec707531"
dependencies = [
"serde",
]
[[package]]
name = "atty"
version = "0.2.14"
@@ -130,6 +161,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bstr"
version = "1.6.2"
@@ -145,6 +185,7 @@ name = "bumble"
version = "0.1.0"
dependencies = [
"anyhow",
"bytes",
"clap 4.4.1",
"directories",
"env_logger",
@@ -158,6 +199,8 @@ dependencies = [
"nix",
"nom",
"owo-colors",
"pdl-derive",
"pdl-runtime",
"pyo3",
"pyo3-asyncio",
"rand",
@@ -178,9 +221,9 @@ checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1"
[[package]]
name = "bytes"
version = "1.4.0"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be"
checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
[[package]]
name = "cc"
@@ -262,6 +305,16 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961"
[[package]]
name = "codespan-reporting"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
dependencies = [
"termcolor",
"unicode-width",
]
[[package]]
name = "colorchoice"
version = "1.0.0"
@@ -284,6 +337,15 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
[[package]]
name = "cpufeatures"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1"
dependencies = [
"libc",
]
[[package]]
name = "crossbeam"
version = "0.8.2"
@@ -351,6 +413,26 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "directories"
version = "5.0.1"
@@ -559,6 +641,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.10"
@@ -1064,12 +1156,100 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "pdl-compiler"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee66995739fb9ddd9155767990a54aadd226ee32408a94f99f94883ff445ceba"
dependencies = [
"argh",
"codespan-reporting",
"heck",
"pest",
"pest_derive",
"prettyplease",
"proc-macro2",
"quote",
"serde",
"serde_json",
"syn 2.0.29",
]
[[package]]
name = "pdl-derive"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "113e4a1215c407466b36d2c2f6a6318819d6b22ccdd3acb7bb35e27a68806034"
dependencies = [
"codespan-reporting",
"pdl-compiler",
"proc-macro2",
"quote",
"syn 2.0.29",
"termcolor",
]
[[package]]
name = "pdl-runtime"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d36c2f9799613babe78eb5cd9a353d527daaba6c3d1f39a1175657a35790732"
dependencies = [
"bytes",
"thiserror",
]
[[package]]
name = "percent-encoding"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
[[package]]
name = "pest"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c022f1e7b65d6a24c0dbbd5fb344c66881bc01f3e5ae74a1c8100f2f985d98a4"
dependencies = [
"memchr",
"thiserror",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35513f630d46400a977c4cb58f78e1bfbe01434316e60c37d27b9ad6139c66d8"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc9fc1b9e7057baba189b5c626e2d6f40681ae5b6eb064dc7c7834101ec8123a"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.29",
]
[[package]]
name = "pest_meta"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1df74e9e7ec4053ceb980e7c0c8bd3594e977fde1af91daba9c928e8e8c6708d"
dependencies = [
"once_cell",
"pest",
"sha2",
]
[[package]]
name = "pin-project-lite"
version = "0.2.13"
@@ -1094,6 +1274,16 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
[[package]]
name = "prettyplease"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c64d9ba0963cdcea2e1b2230fbae2bab30eb25a174be395c41e764bfb65dd62"
dependencies = [
"proc-macro2",
"syn 2.0.29",
]
[[package]]
name = "proc-macro2"
version = "1.0.66"
@@ -1465,6 +1655,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.10.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.1"
@@ -1711,6 +1912,18 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed"
[[package]]
name = "typenum"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "ucd-trie"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9"
[[package]]
name = "unicode-bidi"
version = "0.3.13"
@@ -1738,6 +1951,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
[[package]]
name = "unicode-width"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85"
[[package]]
name = "unindent"
version = "0.1.11"
@@ -1767,6 +1986,12 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "walkdir"
version = "2.4.0"

View File

@@ -23,6 +23,9 @@ hex = "0.4.3"
itertools = "0.11.0"
lazy_static = "1.4.0"
thiserror = "1.0.41"
bytes = "1.5.0"
pdl-derive = "0.2.0"
pdl-runtime = "0.2.0"
# Dev tools
file-header = { version = "0.1.2", optional = true }

View File

@@ -20,7 +20,8 @@
use bumble::{
adv::CommonDataType,
wrapper::{
core::AdvertisementDataUnit, device::Device, hci::AddressType, transport::Transport,
core::AdvertisementDataUnit, device::Device, hci::packets::AddressType,
transport::Transport,
},
};
use clap::Parser as _;
@@ -69,9 +70,7 @@ async fn main() -> PyResult<()> {
let mut seen_adv_cache = seen_adv_clone.lock().unwrap();
let expiry_duration = time::Duration::from_secs(cli.dedup_expiry_secs);
let advs_from_addr = seen_adv_cache
.entry(addr_bytes)
.or_insert_with(collections::HashMap::new);
let advs_from_addr = seen_adv_cache.entry(addr_bytes).or_default();
// we expect cache hits to be the norm, so we do a separate lookup to avoid cloning
// on every lookup with entry()
let show = if let Some(prev) = advs_from_addr.get_mut(&data_units) {
@@ -102,7 +101,9 @@ async fn main() -> PyResult<()> {
};
let (type_style, qualifier) = match adv.address()?.address_type()? {
AddressType::PublicIdentity | AddressType::PublicDevice => (Style::new().cyan(), ""),
AddressType::PublicIdentityAddress | AddressType::PublicDeviceAddress => {
(Style::new().cyan(), "")
}
_ => {
if addr.is_static()? {
(Style::new().green(), "(static)")

View File

@@ -12,9 +12,26 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use bumble::wrapper::{drivers::rtk::DriverInfo, transport::Transport};
use bumble::wrapper::{
controller::Controller,
device::Device,
drivers::rtk::DriverInfo,
hci::{
packets::{
AddressType, ErrorCode, ReadLocalVersionInformationBuilder,
ReadLocalVersionInformationComplete,
},
Address, Error,
},
host::Host,
link::Link,
transport::Transport,
};
use nix::sys::stat::Mode;
use pyo3::PyResult;
use pyo3::{
exceptions::PyException,
{PyErr, PyResult},
};
#[pyo3_asyncio::tokio::test]
async fn fifo_transport_can_open() -> PyResult<()> {
@@ -35,3 +52,26 @@ async fn realtek_driver_info_all_drivers() -> PyResult<()> {
assert_eq!(12, DriverInfo::all_drivers()?.len());
Ok(())
}
#[pyo3_asyncio::tokio::test]
async fn hci_command_wrapper_has_correct_methods() -> PyResult<()> {
let address = Address::new("F0:F1:F2:F3:F4:F5", &AddressType::RandomDeviceAddress)?;
let link = Link::new_local_link()?;
let controller = Controller::new("C1", None, None, Some(link), Some(address.clone())).await?;
let host = Host::new(controller.clone().into(), controller.into()).await?;
let device = Device::new(None, Some(address), None, Some(host), None)?;
device.power_on().await?;
// Send some simple command. A successful response means [HciCommandWrapper] has the minimum
// required interface for the Python code to think its an [HCI_Command] object.
let command = ReadLocalVersionInformationBuilder {};
let event: ReadLocalVersionInformationComplete = device
.send_command(&command.into(), true)
.await?
.try_into()
.map_err(|e: Error| PyErr::new::<PyException, _>(e.to_string()))?;
assert_eq!(ErrorCode::Success, event.get_status());
Ok(())
}

View File

@@ -179,7 +179,7 @@ pub(crate) fn parse(firmware_path: &path::Path) -> PyResult<()> {
pub(crate) async fn info(transport: &str, force: bool) -> PyResult<()> {
let transport = Transport::open(transport).await?;
let mut host = Host::new(transport.source()?, transport.sink()?)?;
let mut host = Host::new(transport.source()?, transport.sink()?).await?;
host.reset(DriverFactory::None).await?;
if !force && !Driver::check(&host).await? {
@@ -203,7 +203,7 @@ pub(crate) async fn info(transport: &str, force: bool) -> PyResult<()> {
pub(crate) async fn load(transport: &str, force: bool) -> PyResult<()> {
let transport = Transport::open(transport).await?;
let mut host = Host::new(transport.source()?, transport.sink()?)?;
let mut host = Host::new(transport.source()?, transport.sink()?).await?;
host.reset(DriverFactory::None).await?;
match Driver::for_host(&host, force).await? {
@@ -219,7 +219,7 @@ pub(crate) async fn load(transport: &str, force: bool) -> PyResult<()> {
pub(crate) async fn drop(transport: &str) -> PyResult<()> {
let transport = Transport::open(transport).await?;
let mut host = Host::new(transport.source()?, transport.sink()?)?;
let mut host = Host::new(transport.source()?, transport.sink()?).await?;
host.reset(DriverFactory::None).await?;
Driver::drop_firmware(&mut host).await?;

View File

@@ -21,8 +21,7 @@
/// TCP client to connect.
/// When the L2CAP CoC channel is closed, the TCP connection is closed as well.
use crate::cli::l2cap::{
proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
BridgeData,
inject_py_event_loop, proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, BridgeData,
};
use bumble::wrapper::{
device::{Connection, Device},
@@ -85,11 +84,12 @@ pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
let mtu = args.mtu;
let mps = args.mps;
let ble_connection = Arc::new(Mutex::new(ble_connection));
// Ensure Python event loop is available to l2cap `disconnect`
let _ = run_future_with_current_task_locals(async move {
// spawn thread to handle incoming tcp connections
tokio::spawn(inject_py_event_loop(async move {
while let Ok((tcp_stream, addr)) = listener.accept().await {
let ble_connection = ble_connection.clone();
let _ = run_future_with_current_task_locals(proxy_data_between_tcp_and_l2cap(
// spawn thread to handle this specific tcp connection
if let Ok(future) = inject_py_event_loop(proxy_data_between_tcp_and_l2cap(
ble_connection,
tcp_stream,
addr,
@@ -97,10 +97,11 @@ pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
max_credits,
mtu,
mps,
));
)) {
tokio::spawn(future);
}
}
Ok(())
});
})?);
Ok(())
}

View File

@@ -18,7 +18,7 @@ use crate::L2cap;
use anyhow::anyhow;
use bumble::wrapper::{device::Device, l2cap::LeConnectionOrientedChannel, transport::Transport};
use owo_colors::{colors::css::Orange, OwoColorize};
use pyo3::{PyObject, PyResult, Python};
use pyo3::{PyResult, Python};
use std::{future::Future, path::PathBuf, sync::Arc};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
@@ -170,21 +170,12 @@ async fn proxy_tcp_rx_to_l2cap_tx(
}
}
/// Copies the current thread's TaskLocals into a Python "awaitable" and encapsulates it in a Rust
/// future, running it as a Python Task.
/// `TaskLocals` stores the current event loop, and allows the user to copy the current Python
/// context if necessary. In this case, the python event loop is used when calling `disconnect` on
/// an l2cap connection, or else the call will fail.
pub fn run_future_with_current_task_locals<F>(
fut: F,
) -> PyResult<impl Future<Output = PyResult<PyObject>> + Send>
/// Copies the current thread's Python even loop (contained in `TaskLocals`) into the given future.
/// Useful when sending work to another thread that calls Python code which calls `get_running_loop()`.
pub fn inject_py_event_loop<F, R>(fut: F) -> PyResult<impl Future<Output = R>>
where
F: Future<Output = PyResult<()>> + Send + 'static,
F: Future<Output = R> + Send + 'static,
{
Python::with_gil(|py| {
let locals = pyo3_asyncio::tokio::get_current_locals(py)?;
let future = pyo3_asyncio::tokio::scope(locals.clone(), fut);
pyo3_asyncio::tokio::future_into_py_with_locals(py, locals, future)
.and_then(pyo3_asyncio::tokio::into_future)
})
let locals = Python::with_gil(pyo3_asyncio::tokio::get_current_locals)?;
Ok(pyo3_asyncio::tokio::scope(locals, fut))
}

View File

@@ -19,10 +19,7 @@
/// When the L2CAP CoC channel is closed, the bridge disconnects the TCP socket
/// and waits for a new L2CAP CoC channel to be connected.
/// When the TCP connection is closed by the TCP server, the L2CAP connection is closed as well.
use crate::cli::l2cap::{
proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
BridgeData,
};
use crate::cli::l2cap::{proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, BridgeData};
use bumble::wrapper::{device::Device, hci::HciConstant, l2cap::LeConnectionOrientedChannel};
use futures::executor::block_on;
use owo_colors::OwoColorize;
@@ -49,19 +46,19 @@ pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
let port = args.tcp_port;
device.register_l2cap_channel_server(
args.psm,
move |_py, l2cap_channel| {
move |py, l2cap_channel| {
let channel_info = l2cap_channel
.debug_string()
.unwrap_or_else(|e| format!("failed to get l2cap channel info ({e})"));
println!("{} {channel_info}", "*** L2CAP channel:".cyan());
let host = host.clone();
// Ensure Python event loop is available to l2cap `disconnect`
let _ = run_future_with_current_task_locals(proxy_data_between_l2cap_and_tcp(
l2cap_channel,
host,
port,
));
// Handles setting up a tokio runtime that runs this future to completion while also
// containing the necessary context vars.
pyo3_asyncio::tokio::future_into_py(
py,
proxy_data_between_l2cap_and_tcp(l2cap_channel, host, port),
)?;
Ok(())
},
args.max_credits,

View File

@@ -143,10 +143,7 @@ pub(crate) fn probe(verbose: bool) -> anyhow::Result<()> {
);
if let Some(s) = serial {
println!("{:26}{}", " Serial:".green(), s);
device_serials_by_id
.entry(device_id)
.or_insert(HashSet::new())
.insert(s);
device_serials_by_id.entry(device_id).or_default().insert(s);
}
if let Some(m) = mfg {
println!("{:26}{}", " Manufacturer:".green(), m);
@@ -314,7 +311,7 @@ impl ClassInfo {
self.protocol,
self.protocol_name()
.map(|s| format!(" [{}]", s))
.unwrap_or_else(String::new)
.unwrap_or_default()
)
}
}

View File

@@ -0,0 +1,161 @@
// Copyright 2023 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
//
// http://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.
pub use pdl_runtime::{Error, Packet};
use crate::internal::hci::packets::{Acl, Command, Event, Sco};
use pdl_derive::pdl;
#[allow(missing_docs, warnings, clippy::all)]
#[pdl("src/internal/hci/packets.pdl")]
pub mod packets {}
#[cfg(test)]
mod tests;
/// HCI Packet type, prepended to the packet.
/// Rootcanal's PDL declaration excludes this from ser/deser and instead is implemented in code.
/// To maintain the ability to easily use future versions of their packet PDL, packet type is
/// implemented here.
#[derive(Debug, PartialEq)]
pub(crate) enum PacketType {
Command = 0x01,
Acl = 0x02,
Sco = 0x03,
Event = 0x04,
}
impl TryFrom<u8> for PacketType {
type Error = PacketTypeParseError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0x01 => Ok(PacketType::Command),
0x02 => Ok(PacketType::Acl),
0x03 => Ok(PacketType::Sco),
0x04 => Ok(PacketType::Event),
_ => Err(PacketTypeParseError::InvalidPacketType { value }),
}
}
}
impl From<PacketType> for u8 {
fn from(packet_type: PacketType) -> Self {
match packet_type {
PacketType::Command => 0x01,
PacketType::Acl => 0x02,
PacketType::Sco => 0x03,
PacketType::Event => 0x04,
}
}
}
/// Allows for smoother interoperability between a [Packet] and a bytes representation of it that
/// includes its type as a header
pub(crate) trait WithPacketType<T: Packet> {
/// Converts the [Packet] into bytes, prefixed with its type
fn to_vec_with_packet_type(self) -> Vec<u8>;
/// Parses a [Packet] out of bytes that are prefixed with the packet's type
fn parse_with_packet_type(bytes: &[u8]) -> Result<T, PacketTypeParseError>;
}
/// Errors that may arise when parsing a packet that is prefixed with its type
#[derive(Debug, PartialEq, thiserror::Error)]
pub(crate) enum PacketTypeParseError {
#[error("The slice being parsed was empty")]
EmptySlice,
#[error("Packet type ({value:#X}) is invalid")]
InvalidPacketType { value: u8 },
#[error("Expected packet type: {expected:?}, but got: {actual:?}")]
PacketTypeMismatch {
expected: PacketType,
actual: PacketType,
},
#[error("Failed to parse packet after header: {error}")]
PacketParse { error: Error },
}
impl From<Error> for PacketTypeParseError {
fn from(error: Error) -> Self {
Self::PacketParse { error }
}
}
impl WithPacketType<Self> for Command {
fn to_vec_with_packet_type(self) -> Vec<u8> {
prepend_packet_type(PacketType::Command, self.to_vec())
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
parse_with_expected_packet_type(Command::parse, PacketType::Command, bytes)
}
}
impl WithPacketType<Self> for Acl {
fn to_vec_with_packet_type(self) -> Vec<u8> {
prepend_packet_type(PacketType::Acl, self.to_vec())
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
parse_with_expected_packet_type(Acl::parse, PacketType::Acl, bytes)
}
}
impl WithPacketType<Self> for Sco {
fn to_vec_with_packet_type(self) -> Vec<u8> {
prepend_packet_type(PacketType::Sco, self.to_vec())
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
parse_with_expected_packet_type(Sco::parse, PacketType::Sco, bytes)
}
}
impl WithPacketType<Self> for Event {
fn to_vec_with_packet_type(self) -> Vec<u8> {
prepend_packet_type(PacketType::Event, self.to_vec())
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
parse_with_expected_packet_type(Event::parse, PacketType::Event, bytes)
}
}
fn prepend_packet_type(packet_type: PacketType, mut packet_bytes: Vec<u8>) -> Vec<u8> {
packet_bytes.insert(0, packet_type.into());
packet_bytes
}
fn parse_with_expected_packet_type<T: Packet, F, E>(
parser: F,
expected_packet_type: PacketType,
bytes: &[u8],
) -> Result<T, PacketTypeParseError>
where
F: Fn(&[u8]) -> Result<T, E>,
PacketTypeParseError: From<E>,
{
let (first_byte, packet_bytes) = bytes
.split_first()
.ok_or(PacketTypeParseError::EmptySlice)?;
let actual_packet_type = PacketType::try_from(*first_byte)?;
if actual_packet_type == expected_packet_type {
Ok(parser(packet_bytes)?)
} else {
Err(PacketTypeParseError::PacketTypeMismatch {
expected: expected_packet_type,
actual: actual_packet_type,
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
// Copyright 2023 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
//
// http://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.
use crate::internal::hci::{
packets::{Event, EventBuilder, EventCode, Sco},
parse_with_expected_packet_type, prepend_packet_type, Error, Packet, PacketType,
PacketTypeParseError, WithPacketType,
};
use bytes::Bytes;
#[test]
fn prepends_packet_type() {
let packet_type = PacketType::Event;
let packet_bytes = vec![0x00, 0x00, 0x00, 0x00];
let actual = prepend_packet_type(packet_type, packet_bytes);
assert_eq!(vec![0x04, 0x00, 0x00, 0x00, 0x00], actual);
}
#[test]
fn parse_empty_slice_should_error() {
let actual = parse_with_expected_packet_type(FakePacket::parse, PacketType::Event, &[]);
assert_eq!(Err(PacketTypeParseError::EmptySlice), actual);
}
#[test]
fn parse_invalid_packet_type_should_error() {
let actual = parse_with_expected_packet_type(FakePacket::parse, PacketType::Event, &[0xFF]);
assert_eq!(
Err(PacketTypeParseError::InvalidPacketType { value: 0xFF }),
actual
);
}
#[test]
fn parse_mismatched_packet_type_should_error() {
let actual = parse_with_expected_packet_type(FakePacket::parse, PacketType::Acl, &[0x01]);
assert_eq!(
Err(PacketTypeParseError::PacketTypeMismatch {
expected: PacketType::Acl,
actual: PacketType::Command
}),
actual
);
}
#[test]
fn parse_invalid_packet_should_error() {
let actual = parse_with_expected_packet_type(Sco::parse, PacketType::Sco, &[0x03]);
assert!(actual.is_err());
}
#[test]
fn test_packet_roundtrip_with_type() {
let event_packet = EventBuilder {
event_code: EventCode::InquiryComplete,
payload: None,
}
.build();
let event_packet_bytes = event_packet.clone().to_vec_with_packet_type();
let actual =
parse_with_expected_packet_type(Event::parse, PacketType::Event, &event_packet_bytes)
.unwrap();
assert_eq!(event_packet, actual);
}
#[derive(Debug, PartialEq)]
struct FakePacket;
impl FakePacket {
fn parse(_bytes: &[u8]) -> Result<Self, Error> {
Ok(Self)
}
}
impl Packet for FakePacket {
fn to_bytes(self) -> Bytes {
Bytes::new()
}
fn to_vec(self) -> Vec<u8> {
Vec::new()
}
}

View File

@@ -18,3 +18,4 @@
//! to discover.
pub(crate) mod drivers;
pub(crate) mod hci;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
// Copyright 2023 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
//
// http://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.
//! Shared resources found under bumble's common.py
use pyo3::{PyObject, Python, ToPyObject};
/// Represents the sink for some transport mechanism
pub struct TransportSink(pub(crate) PyObject);
impl ToPyObject for TransportSink {
fn to_object(&self, _py: Python<'_>) -> PyObject {
self.0.clone()
}
}
/// Represents the source for some transport mechanism
pub struct TransportSource(pub(crate) PyObject);
impl ToPyObject for TransportSource {
fn to_object(&self, _py: Python<'_>) -> PyObject {
self.0.clone()
}
}

View File

@@ -0,0 +1,66 @@
// Copyright 2023 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
//
// http://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.
//! Controller components
use crate::wrapper::{
common::{TransportSink, TransportSource},
hci::Address,
link::Link,
wrap_python_async, PyDictExt,
};
use pyo3::{
intern,
types::{PyDict, PyModule},
PyObject, PyResult, Python,
};
use pyo3_asyncio::tokio::into_future;
/// A controller that can send and receive HCI frames via some link
#[derive(Clone)]
pub struct Controller(pub(crate) PyObject);
impl Controller {
/// Creates a new [Controller] object. When optional arguments are not specified, the Python
/// 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`.
///
/// For more info, see https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars.
pub async fn new(
name: &str,
host_source: Option<TransportSource>,
host_sink: Option<TransportSink>,
link: Option<Link>,
public_address: Option<Address>,
) -> PyResult<Self> {
Python::with_gil(|py| {
let controller_ctr = PyModule::import(py, intern!(py, "bumble.controller"))?
.getattr(intern!(py, "Controller"))?;
let kwargs = PyDict::new(py);
kwargs.set_item("name", name)?;
kwargs.set_opt_item("host_source", host_source)?;
kwargs.set_opt_item("host_sink", host_sink)?;
kwargs.set_opt_item("link", link)?;
kwargs.set_opt_item("public_address", public_address)?;
// Controller constructor (`__init__`) is not (and can't be) marked async, but calls
// `get_running_loop`, and thus needs wrapped in an async function.
wrap_python_async(py, controller_ctr)?
.call((), Some(kwargs))
.and_then(into_future)
})?
.await
.map(Self)
}
}

View File

@@ -14,12 +14,16 @@
//! Devices and connections to them
use crate::internal::hci::WithPacketType;
use crate::{
adv::AdvertisementDataBuilder,
wrapper::{
core::AdvertisingData,
gatt_client::{ProfileServiceProxy, ServiceProxy},
hci::{Address, HciErrorCode},
hci::{
packets::{Command, ErrorCode, Event},
Address, HciCommandWrapper,
},
host::Host,
l2cap::LeConnectionOrientedChannel,
transport::{Sink, Source},
@@ -27,18 +31,73 @@ use crate::{
},
};
use pyo3::{
exceptions::PyException,
intern,
types::{PyDict, PyModule},
IntoPy, PyObject, PyResult, Python, ToPyObject,
IntoPy, PyErr, PyObject, PyResult, Python, ToPyObject,
};
use pyo3_asyncio::tokio::into_future;
use std::path;
/// Represents the various properties of some device
pub struct DeviceConfiguration(PyObject);
impl DeviceConfiguration {
/// Creates a new configuration, letting the internal Python object set all the defaults
pub fn new() -> PyResult<DeviceConfiguration> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.device"))?
.getattr(intern!(py, "DeviceConfiguration"))?
.call0()
.map(|any| Self(any.into()))
})
}
/// Creates a new configuration from the specified file
pub fn load_from_file(&mut self, device_config: &path::Path) -> PyResult<()> {
Python::with_gil(|py| {
self.0
.call_method1(py, intern!(py, "load_from_file"), (device_config,))
})
.map(|_| ())
}
}
impl ToPyObject for DeviceConfiguration {
fn to_object(&self, _py: Python<'_>) -> PyObject {
self.0.clone()
}
}
/// A device that can send/receive HCI frames.
#[derive(Clone)]
pub struct Device(PyObject);
impl Device {
/// Creates a Device. When optional arguments are not specified, the Python object specifies the
/// defaults.
pub fn new(
name: Option<&str>,
address: Option<Address>,
config: Option<DeviceConfiguration>,
host: Option<Host>,
generic_access_service: Option<bool>,
) -> PyResult<Self> {
Python::with_gil(|py| {
let kwargs = PyDict::new(py);
kwargs.set_opt_item("name", name)?;
kwargs.set_opt_item("address", address)?;
kwargs.set_opt_item("config", config)?;
kwargs.set_opt_item("host", host)?;
kwargs.set_opt_item("generic_access_service", generic_access_service)?;
PyModule::import(py, intern!(py, "bumble.device"))?
.getattr(intern!(py, "Device"))?
.call((), Some(kwargs))
.map(|any| Self(any.into()))
})
}
/// Create a Device per the provided file configured to communicate with a controller through an HCI source/sink
pub fn from_config_file_with_hci(
device_config: &path::Path,
@@ -66,6 +125,29 @@ impl Device {
})
}
/// Sends an HCI command on this Device, returning the command's event result.
pub async fn send_command(&self, command: &Command, check_result: bool) -> PyResult<Event> {
Python::with_gil(|py| {
self.0
.call_method1(
py,
intern!(py, "send_command"),
(HciCommandWrapper(command.clone()), check_result),
)
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.and_then(|event| {
Python::with_gil(|py| {
let py_bytes = event.call_method0(py, intern!(py, "__bytes__"))?;
let bytes: &[u8] = py_bytes.extract(py)?;
let event = Event::parse_with_packet_type(bytes)
.map_err(|e| PyErr::new::<PyException, _>(e.to_string()))?;
Ok(event)
})
})
}
/// Turn the device on
pub async fn power_on(&self) -> PyResult<()> {
Python::with_gil(|py| {
@@ -236,7 +318,7 @@ impl Connection {
kwargs.set_opt_item("mps", mps)?;
self.0
.call_method(py, intern!(py, "open_l2cap_channel"), (), Some(kwargs))
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.map(LeConnectionOrientedChannel::from)
@@ -244,13 +326,13 @@ impl Connection {
/// Disconnect from device with provided reason. When optional arguments are not specified, the
/// Python module specifies the defaults.
pub async fn disconnect(&mut self, reason: Option<HciErrorCode>) -> PyResult<()> {
pub async fn disconnect(&mut self, reason: Option<ErrorCode>) -> PyResult<()> {
Python::with_gil(|py| {
let kwargs = PyDict::new(py);
kwargs.set_opt_item("reason", reason)?;
self.0
.call_method(py, intern!(py, "disconnect"), (), Some(kwargs))
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
@@ -259,7 +341,7 @@ impl Connection {
/// Register a callback to be called on disconnection.
pub fn on_disconnection(
&mut self,
callback: impl Fn(Python, HciErrorCode) -> PyResult<()> + Send + 'static,
callback: impl Fn(Python, ErrorCode) -> PyResult<()> + Send + 'static,
) -> PyResult<()> {
let boxed = ClosureCallback::new(move |py, args, _kwargs| {
callback(py, args.get_item(0)?.extract()?)

View File

@@ -14,84 +14,62 @@
//! HCI
pub use crate::internal::hci::{packets, Error, Packet};
use crate::{
internal::hci::WithPacketType,
wrapper::hci::packets::{AddressType, Command, ErrorCode},
};
use itertools::Itertools as _;
use pyo3::{
exceptions::PyException, intern, types::PyModule, FromPyObject, PyAny, PyErr, PyObject,
PyResult, Python, ToPyObject,
exceptions::PyException,
intern, pyclass, pymethods,
types::{PyBytes, PyModule},
FromPyObject, IntoPy, PyAny, PyErr, PyObject, PyResult, Python, ToPyObject,
};
/// HCI error code.
pub struct HciErrorCode(u8);
impl<'source> FromPyObject<'source> for HciErrorCode {
fn extract(ob: &'source PyAny) -> PyResult<Self> {
Ok(HciErrorCode(ob.extract()?))
}
}
impl ToPyObject for HciErrorCode {
fn to_object(&self, py: Python<'_>) -> PyObject {
self.0.to_object(py)
}
}
/// Provides helpers for interacting with HCI
pub struct HciConstant;
impl HciConstant {
/// Human-readable error name
pub fn error_name(status: HciErrorCode) -> PyResult<String> {
pub fn error_name(status: ErrorCode) -> PyResult<String> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.hci"))?
.getattr(intern!(py, "HCI_Constant"))?
.call_method1(intern!(py, "error_name"), (status.0,))?
.call_method1(intern!(py, "error_name"), (status.to_object(py),))?
.extract()
})
}
}
/// A Bluetooth address
#[derive(Clone)]
pub struct Address(pub(crate) PyObject);
impl Address {
/// Creates a new [Address] object
pub fn new(address: &str, address_type: &AddressType) -> PyResult<Self> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.device"))?
.getattr(intern!(py, "Address"))?
.call1((address, address_type.to_object(py)))
.map(|any| Self(any.into()))
})
}
/// The type of address
pub fn address_type(&self) -> PyResult<AddressType> {
Python::with_gil(|py| {
let addr_type = self
.0
self.0
.getattr(py, intern!(py, "address_type"))?
.extract::<u32>(py)?;
let module = PyModule::import(py, intern!(py, "bumble.hci"))?;
let klass = module.getattr(intern!(py, "Address"))?;
if addr_type
== klass
.getattr(intern!(py, "PUBLIC_DEVICE_ADDRESS"))?
.extract::<u32>()?
{
Ok(AddressType::PublicDevice)
} else if addr_type
== klass
.getattr(intern!(py, "RANDOM_DEVICE_ADDRESS"))?
.extract::<u32>()?
{
Ok(AddressType::RandomDevice)
} else if addr_type
== klass
.getattr(intern!(py, "PUBLIC_IDENTITY_ADDRESS"))?
.extract::<u32>()?
{
Ok(AddressType::PublicIdentity)
} else if addr_type
== klass
.getattr(intern!(py, "RANDOM_IDENTITY_ADDRESS"))?
.extract::<u32>()?
{
Ok(AddressType::RandomIdentity)
} else {
Err(PyErr::new::<PyException, _>("Invalid address type"))
}
.extract::<u8>(py)?
.try_into()
.map_err(|addr_type| {
PyErr::new::<PyException, _>(format!(
"Failed to convert {addr_type} to AddressType"
))
})
})
}
@@ -134,12 +112,45 @@ impl Address {
}
}
/// BT address types
#[allow(missing_docs)]
#[derive(PartialEq, Eq, Debug)]
pub enum AddressType {
PublicDevice,
RandomDevice,
PublicIdentity,
RandomIdentity,
impl ToPyObject for Address {
fn to_object(&self, _py: Python<'_>) -> PyObject {
self.0.clone()
}
}
/// Implements minimum necessary interface to be treated as bumble's [HCI_Command].
/// While pyo3's macros do not support generics, this could probably be refactored to allow multiple
/// implementations of the HCI_Command methods in the future, if needed.
#[pyclass]
pub(crate) struct HciCommandWrapper(pub(crate) Command);
#[pymethods]
impl HciCommandWrapper {
fn __bytes__(&self, py: Python) -> PyResult<PyObject> {
let bytes = PyBytes::new(py, &self.0.clone().to_vec_with_packet_type());
Ok(bytes.into_py(py))
}
#[getter]
fn op_code(&self) -> u16 {
self.0.get_op_code().into()
}
}
impl ToPyObject for AddressType {
fn to_object(&self, py: Python<'_>) -> PyObject {
u8::from(self).to_object(py)
}
}
impl<'source> FromPyObject<'source> for ErrorCode {
fn extract(ob: &'source PyAny) -> PyResult<Self> {
ob.extract()
}
}
impl ToPyObject for ErrorCode {
fn to_object(&self, py: Python<'_>) -> PyObject {
u8::from(self).to_object(py)
}
}

View File

@@ -14,8 +14,12 @@
//! Host-side types
use crate::wrapper::transport::{Sink, Source};
use pyo3::{intern, prelude::PyModule, types::PyDict, PyObject, PyResult, Python};
use crate::wrapper::{
transport::{Sink, Source},
wrap_python_async,
};
use pyo3::{intern, prelude::PyModule, types::PyDict, PyObject, PyResult, Python, ToPyObject};
use pyo3_asyncio::tokio::into_future;
/// Host HCI commands
pub struct Host {
@@ -29,13 +33,23 @@ impl Host {
}
/// Create a new Host
pub fn new(source: Source, sink: Sink) -> PyResult<Self> {
pub async fn new(source: Source, sink: Sink) -> PyResult<Self> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.host"))?
.getattr(intern!(py, "Host"))?
.call((source.0, sink.0), None)
.map(|any| Self { obj: any.into() })
})
let host_ctr =
PyModule::import(py, intern!(py, "bumble.host"))?.getattr(intern!(py, "Host"))?;
let kwargs = PyDict::new(py);
kwargs.set_item("controller_source", source.0)?;
kwargs.set_item("controller_sink", sink.0)?;
// Needed for Python 3.8-3.9, in which the Semaphore object, when constructed, calls
// `get_event_loop`.
wrap_python_async(py, host_ctr)?
.call((), Some(kwargs))
.and_then(into_future)
})?
.await
.map(|any| Self { obj: any })
}
/// Send a reset command and perform other reset tasks.
@@ -61,6 +75,12 @@ impl Host {
}
}
impl ToPyObject for Host {
fn to_object(&self, _py: Python<'_>) -> PyObject {
self.obj.clone()
}
}
/// Driver factory to use when initializing a host
#[derive(Debug, Clone)]
pub enum DriverFactory {

38
rust/src/wrapper/link.rs Normal file
View File

@@ -0,0 +1,38 @@
// Copyright 2023 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
//
// http://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.
//! Link components
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject};
/// Link bus for controllers to communicate with each other
#[derive(Clone)]
pub struct Link(pub(crate) PyObject);
impl Link {
/// Creates a [Link] object that transports messages locally
pub fn new_local_link() -> PyResult<Self> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.link"))?
.getattr(intern!(py, "LocalLink"))?
.call0()
.map(|any| Self(any.into()))
})
}
}
impl ToPyObject for Link {
fn to_object(&self, _py: Python<'_>) -> PyObject {
self.0.clone()
}
}

View File

@@ -22,13 +22,17 @@
// Re-exported to make it easy for users to depend on the same `PyObject`, etc
pub use pyo3;
pub use pyo3_asyncio;
use pyo3::{
intern,
prelude::*,
types::{PyDict, PyTuple},
};
pub use pyo3_asyncio;
pub mod assigned_numbers;
pub mod common;
pub mod controller;
pub mod core;
pub mod device;
pub mod drivers;
@@ -36,6 +40,7 @@ pub mod gatt_client;
pub mod hci;
pub mod host;
pub mod l2cap;
pub mod link;
pub mod logging;
pub mod profile;
pub mod transport;
@@ -119,3 +124,11 @@ impl ClosureCallback {
(self.callback)(py, args, kwargs).map(|_| py.None())
}
}
/// Wraps the Python function in a Python async function. `pyo3_asyncio` needs functions to be
/// marked async to properly inject a running loop.
pub(crate) fn wrap_python_async<'a>(py: Python<'a>, function: &'a PyAny) -> PyResult<&'a PyAny> {
PyModule::import(py, intern!(py, "bumble.utils"))?
.getattr(intern!(py, "wrap_async"))?
.call1((function,))
}

View File

@@ -14,6 +14,7 @@
//! HCI packet transport
use crate::wrapper::controller::Controller;
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python};
/// A source/sink pair for HCI packet I/O.
@@ -67,6 +68,18 @@ impl Drop for Transport {
#[derive(Clone)]
pub struct Source(pub(crate) PyObject);
impl From<Controller> for Source {
fn from(value: Controller) -> Self {
Self(value.0)
}
}
/// The sink side of a [Transport].
#[derive(Clone)]
pub struct Sink(pub(crate) PyObject);
impl From<Controller> for Sink {
fn from(value: Controller) -> Self {
Self(value.0)
}
}

View File

@@ -91,9 +91,13 @@ development =
mypy == 1.5.0
nox >= 2022
pylint == 2.15.8
pyyaml >= 6.0
types-appdirs >= 1.4.3
types-invoke >= 1.7.3
types-protobuf >= 4.21.0
avatar =
pandora-avatar == 0.0.5
rootcanal == 1.3.0 ; python_version>='3.10'
documentation =
mkdocs >= 1.4.0
mkdocs-material >= 8.5.6

View File

@@ -45,12 +45,14 @@ def test_messages():
]
message = Get_Capabilities_Response(capabilities)
parsed = Message.create(
AVDTP_GET_CAPABILITIES, Message.RESPONSE_ACCEPT, message.payload
AVDTP_GET_CAPABILITIES, Message.MessageType.RESPONSE_ACCEPT, message.payload
)
assert message.payload == parsed.payload
message = Set_Configuration_Command(3, 4, capabilities)
parsed = Message.create(AVDTP_SET_CONFIGURATION, Message.COMMAND, message.payload)
parsed = Message.create(
AVDTP_SET_CONFIGURATION, Message.MessageType.COMMAND, message.payload
)
assert message.payload == parsed.payload

View File

@@ -14,25 +14,25 @@
# -----------------------------------------------------------------------------
# This script generates a python-syntax list of dictionary entries for the
# company IDs listed at: https://www.bluetooth.com/specifications/assigned-numbers/company-identifiers/
# The input to this script is the CSV file that can be obtained at that URL
# company IDs listed at:
# https://bitbucket.org/bluetooth-SIG/public/src/main/assigned_numbers/company_identifiers/company_identifiers.yaml
# The input to this script is the YAML file that can be obtained at that URL
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import sys
import csv
import yaml
# -----------------------------------------------------------------------------
with open(sys.argv[1], newline='') as csvfile:
reader = csv.reader(csvfile, delimiter=',', quotechar='"')
lines = []
for row in reader:
if len(row) == 3 and row[1].startswith('0x'):
company_id = row[1]
company_name = row[2]
escaped_company_name = company_name.replace('"', '\\"')
lines.append(f' {company_id}: "{escaped_company_name}"')
with open(sys.argv[1], "r") as yaml_file:
root = yaml.safe_load(yaml_file)
companies = {}
for company in root["company_identifiers"]:
companies[company["value"]] = company["name"]
print(',\n'.join(reversed(lines)))
for company_id in sorted(companies.keys()):
company_name = companies[company_id]
escaped_company_name = company_name.replace('"', '\\"')
print(f' 0x{company_id:04X}: "{escaped_company_name}",')

View File

@@ -23,7 +23,7 @@ from bumble.device import Device
# -----------------------------------------------------------------------------
class ScanEntry:
def __init__(self, advertisement):
self.address = str(advertisement.address).replace("/P", "")
self.address = advertisement.address.to_string(False)
self.address_type = ('Public', 'Random', 'Public Identity', 'Random Identity')[
advertisement.address.address_type
]

View File

@@ -171,7 +171,7 @@ class Speaker:
self.connection = connection
connection.on('disconnection', self.on_bluetooth_disconnection)
peer_name = '' if connection.peer_name is None else connection.peer_name
peer_address = str(connection.peer_address).replace('/P', '')
peer_address = connection.peer_address.to_string(False)
self.emit_event(
'connection', {'peer_name': peer_name, 'peer_address': peer_address}
)