Compare commits

..

86 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod e1d10bc482 add rfcomm disconnect test 2024-06-05 10:03:27 -07:00
Gilles Boccon-Gibod f5baf51132 improve DLC parameters 2024-06-03 18:11:13 -07:00
Gilles Boccon-Gibod f2dc8bd84e wip (+2 squashed commits)
Squashed commits:
[451a295] wip
[ed7b5b6] wip (+1 squashed commit)
Squashed commits:
[9d938c8] wip

wip

wip
2024-05-30 14:59:22 -07:00
zxzxwu 090309302f Merge pull request #372 from zxzxwu/source
ASCS Source Implementation
2024-05-29 13:17:51 +08:00
Charlie Boutier 28e6229b24 Fix: Preserve transport metadata
Preserve transport metadata when wrapping with SnoopingTransport
2024-05-28 09:20:53 -07:00
Josh Wu 1b66f03dbe ASCS: Add Source ASE operations 2024-05-27 14:48:23 +08:00
Gilles Boccon-Gibod e34f6b5fd3 Merge pull request #484 from google/gbg/quick-fix-002
fix incorrect var reference
2024-05-13 16:11:42 -07:00
Gilles Boccon-Gibod 8a0482c947 Merge pull request #485 from google/gbg/gh-action-py312
add python 3.12 to GH actions
2024-05-13 16:11:25 -07:00
zxzxwu 938a189f3f Merge pull request #478 from zxzxwu/config
Make DeviceConfiguration dataclass
2024-05-13 16:57:15 +08:00
Gilles Boccon-Gibod 2005b4a11b python 3.12 compatibility 2024-05-12 12:54:52 -07:00
Gilles Boccon-Gibod 951fdc8bdd add python 3.12 to GH actions 2024-05-12 12:07:05 -07:00
Gilles Boccon-Gibod 12af7a526c fix incorrect var reference 2024-05-12 11:59:05 -07:00
zxzxwu 8781943646 Merge pull request #483 from zxzxwu/rfc
RFCOMM: Handle packets received before DLC sink set
2024-05-10 16:34:57 +08:00
Gilles Boccon-Gibod 7fbfdb634c Merge pull request #481 from google/gbg/command-status-fix
allow checking results for HCI_Command_Status_Event
2024-05-09 19:50:10 -07:00
Josh Wu 9682077f6b RFCOMM: Avoid receive packets before DLC sink set 2024-05-09 17:57:13 +08:00
Gilles Boccon-Gibod 22eb405fde Merge pull request #482 from servusdei2018/main
bumble.js(PacketSink): Implement asynchronous packet processing
2024-05-08 20:16:04 -07:00
zxzxwu 593c61973f Merge pull request #480 from zxzxwu/hfp-ag
HFP: Add AG example and fix errors
2024-05-07 17:50:01 +08:00
Josh Wu ccff32102f HFP: Add example and fix AG errors 2024-05-07 00:36:52 +08:00
Nate 851d62c6c9 bumble.js(PacketSink): Implement asynchronous packet processing 2024-05-05 15:03:22 -04:00
Josh Wu a5ac5f26e2 Make DeviceConfiguration dataclass 2024-05-05 17:25:01 +08:00
Gilles Boccon-Gibod 090158820f allow checking results for HCI_Command_Status_Event 2024-05-04 12:17:05 -07:00
zxzxwu 26e6650038 Merge pull request #477 from zxzxwu/hfp-ag
Fix HFP query call status
2024-05-02 01:17:17 +08:00
Josh Wu c48568aabe Fix HFP query call status 2024-04-30 03:13:38 +00:00
zxzxwu 1b33c9eb74 Merge pull request #475 from zxzxwu/hfp-ag
Add more HFP command suppport
2024-04-26 12:01:20 +08:00
zxzxwu 6633228975 Add more HFP command suppport
* Support all Call Hold Operation
* Support CLI Presentation
* Support Voice Recognition
* Support RING and Volume Changes
* [AG] Support Enhanced Call Status
* Minor fixes
2024-04-24 15:29:48 +00:00
Gilles Boccon-Gibod e9cba788a4 Merge pull request #473 from google/barbibulle-patch-2
quick fix: revert to protobuf 3.12.4
2024-04-22 11:46:04 +02:00
Gilles Boccon-Gibod 98822cfc6b quick fix: revert to protobuf 3.12.4
The upgrade to 4.x wasn't really needed, and breaks some users.
2024-04-18 21:20:18 -07:00
Gilles Boccon-Gibod 97ad7e5741 Merge pull request #472 from google/gbg/update-pandora-deps
update protobuf dep and make pandora install optional
2024-04-18 11:21:29 -07:00
Charlie Boutier 71df062e07 pyusb: power_cycle if '!' is present at the start of the transport 2024-04-17 14:12:55 -07:00
Charlie Boutier 049f9021e9 pyusb: powercycle the dongle 2024-04-17 14:12:55 -07:00
Gilles Boccon-Gibod 50eae2ef54 add pandora to code-check action 2024-04-17 13:19:07 -07:00
Gilles Boccon-Gibod c8883a7d0f update protobuf dep and make pandora install optional 2024-04-17 13:14:21 -07:00
zxzxwu 51321caf5b Merge pull request #470 from zxzxwu/examples
Type hint all examples
2024-04-16 02:56:08 +08:00
zxzxwu 51a94288e2 Type hint all examples 2024-04-15 12:48:21 +00:00
zxzxwu 8758856e8c Merge pull request #465 from zxzxwu/hfp-ag
HFP AG implementation
2024-04-12 22:15:25 +08:00
Josh Wu deba181857 HFP AG implementation 2024-04-10 09:51:37 +00:00
zxzxwu c65188dcbf Merge pull request #466 from zxzxwu/format
Fix format presubmit error
2024-04-09 02:59:36 +08:00
Josh Wu 21d607898d Fix format presubmit error 2024-04-09 01:44:04 +08:00
Gilles Boccon-Gibod 2698d4534e Merge pull request #435 from jeru/main
open_tcp_server_transport: allow explicit sock as input.
2024-04-04 19:17:07 -07:00
zxzxwu bbcd64286a Merge pull request #463 from zxzxwu/hfp
Correct HFP AG indicator index
2024-04-04 12:53:19 +08:00
Gilles Boccon-Gibod 9140afbf8c Merge pull request #456 from google/gbg/update-dependencies
update some dependencies
2024-04-03 17:50:18 -06:00
Gilles Boccon-Gibod 90a682c71b bump to avatar 0.0.9 2024-04-03 16:26:07 -07:00
Gilles Boccon-Gibod e8737a8243 update to more recent versions 2024-04-03 10:00:11 -07:00
Gilles Boccon-Gibod 72fceca72e update some dependencies 2024-04-03 10:00:09 -07:00
Gilles Boccon-Gibod 732294abbc Merge pull request #462 from google/gbg/461
fix #461
2024-04-03 10:56:05 -06:00
Josh Wu dc1204531e Correct HFP AG indicator index 2024-04-03 17:58:04 +08:00
Gilles Boccon-Gibod 962114379c fix #461 2024-04-02 23:14:32 -07:00
Gilles Boccon-Gibod e6913a3055 Merge pull request #457 from google/gbg/bench-ascyncio-main
delay creation of runner object
2024-04-02 21:39:37 -06:00
Gilles Boccon-Gibod e21d122aef Merge pull request #458 from google/gbg/update-formatter
update black formatter to version 24
2024-04-02 21:39:24 -06:00
Gilles Boccon-Gibod 58d4ab913a update black formatter to version 24 2024-04-01 14:44:46 -07:00
Gilles Boccon-Gibod 76bca03fe3 format with the project's version of black 2024-04-01 14:39:34 -07:00
Gilles Boccon-Gibod f1e5c9e59e delay creation of runner object 2024-04-01 14:25:38 -07:00
zxzxwu ec82242462 Merge pull request #440 from zxzxwu/hfp
Rework HFP example
2024-03-27 16:54:41 +08:00
zxzxwu a4efdd3f3e Merge pull request #442 from zxzxwu/unicast_ad
Implement Unicast Server Advertising Data
2024-03-27 16:54:06 +08:00
Gilles Boccon-Gibod 69c6643bb8 Merge pull request #452 from marshallpierce/mp/rust-0.2.0
Bumble crate 0.2.0
2024-03-21 17:15:43 -07:00
Marshall Pierce b8214bf948 Bumble crate 0.2.0 2024-03-21 12:36:32 -06:00
Charlie Boutier a9c62c44b3 pandora host: change AdvertisingType
change advertising type from high duty to low duty

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

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

Also add some unittests with python mock.
2024-03-13 19:34:05 +01:00
Gilles Boccon-Gibod 44c51c13ac Merge pull request #445 from google/gbg/driver-probe-fix
fix intel driver probe
2024-03-12 12:51:08 -07:00
Gilles Boccon-Gibod 7507be1eab update metadata when setting the host controller directly 2024-03-12 11:50:47 -07:00
Gilles Boccon-Gibod cbe9446dcf fix intel driver probe 2024-03-12 09:54:20 -07:00
Charlie Boutier 174930399a intel: send vsc INTEL_DDC_CONFIG_WRITE
This VSC enable host-initiated role-switching after connection.

Implement this VSC in a driver fashion.

Test: avatar security_test with the Bluetooth Dongle Intel BE200
2024-03-11 09:15:18 -07:00
Josh Wu 35db4a4c93 Implement Unicast Server Advertising Data 2024-03-08 16:48:37 +08:00
Gilles Boccon-Gibod 1f3aee5566 Merge pull request #438 from BenjaminLawson/pandora-extended-advertising
Implement Pandora extended advertising
2024-03-07 20:36:56 -08:00
Ben Lawson 256044a789 Implement Pandora extended advertising
Support setting the PHY of Pandora scans.
2024-03-07 16:18:49 -08:00
Josh Wu 6205199d7f Rework HFP example 2024-03-05 20:53:28 +08:00
Gilles Boccon-Gibod e554bd1033 Merge pull request #434 from google/gbg/show-timestamps
show timestamps from snoop logs
2024-02-29 11:44:23 -08:00
Gilles Boccon-Gibod 38981cefa1 pad index field 2024-02-28 11:46:35 -08:00
Gilles Boccon-Gibod f2d601f411 show timestamps from snoop logs 2024-02-27 16:40:37 -08:00
zxzxwu 6e7c64c1de Merge pull request #431 from zxzxwu/rust
Bump Rust to 1.76.0
2024-02-23 15:14:30 +08:00
Josh Wu 565d51f4db Bump Rust to 1.76.0
```
error: failed to compile `cargo-all-features v1.10.0`, intermediate artifacts can be found at `/tmp/cargo-installshCmAG`

Caused by:
  package `clap v4.5.1` cannot be built because it requires rustc 1.74 or newer, while the currently active rustc version is 1.70.0
  Try re-running cargo install with `--locked`

```
2024-02-22 15:22:20 +08:00
Gilles Boccon-Gibod de8f3d9c1e Merge pull request #426 from akuker/patch-1
Add clarification to short circuit list feature
2024-02-12 21:22:14 -08:00
Tony Kuker cde6d48690 Add clarification to short circuit list feature 2024-02-12 12:22:36 -06:00
zxzxwu 02180088b3 Merge pull request #425 from zxzxwu/command
Refactor command supporting list
2024-02-07 21:45:52 +08:00
zxzxwu 90f49267d1 Merge pull request #424 from zxzxwu/adv
Fix double-disable legacy advertising set
2024-02-06 16:13:51 +08:00
Josh Wu 0e6d69cd7b Refactor command supporting list 2024-02-06 12:06:00 +08:00
Josh Wu 9eccc583d5 Fix double-disable legacy advertising set
When legacy advertising set is disabled passively(by set termination),
the legacy advertising set won't be released, and the next
stop_advertising() call will try to disable it again and cause an error.
2024-02-06 12:00:30 +08:00
Gilles Boccon-Gibod f4aeaa6eb3 Merge pull request #422 from google/gbg/bench-rfcomm-params
add rfcomm options and fix l2cap mtu negotiation
2024-02-05 09:14:16 -08:00
103 changed files with 5498 additions and 1596 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: ["3.8", "3.9", "3.10", "3.11"] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
fail-fast: false fail-fast: false
steps: steps:
@@ -33,7 +33,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
python -m pip install ".[build,test,development]" python -m pip install ".[build,test,development,pandora]"
- name: Check - name: Check
run: | run: |
invoke project.pre-commit invoke project.pre-commit
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
- name: Install - name: Install
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
python -m pip install .[avatar] python -m pip install .[avatar,pandora]
- name: Rootcanal - name: Rootcanal
run: nohup python -m rootcanal > rootcanal.log & run: nohup python -m rootcanal > rootcanal.log &
- name: Test - name: Test
+3 -3
View File
@@ -16,7 +16,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: ['ubuntu-latest', 'macos-latest', 'windows-latest'] os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
python-version: ["3.8", "3.9", "3.10", "3.11"] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
fail-fast: false fail-fast: false
steps: steps:
@@ -46,8 +46,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [ "3.8", "3.9", "3.10", "3.11" ] python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
rust-version: [ "1.70.0", "stable" ] rust-version: [ "1.76.0", "stable" ]
fail-fast: false fail-fast: false
steps: steps:
- name: Check out from Git - name: Check out from Git
+5
View File
@@ -6,9 +6,14 @@ dist/
docs/mkdocs/site docs/mkdocs/site
test-results.xml test-results.xml
__pycache__ __pycache__
# Vim
.*.sw*
# generated by setuptools_scm # generated by setuptools_scm
bumble/_version.py bumble/_version.py
.vscode/launch.json .vscode/launch.json
.vscode/settings.json
/.idea /.idea
venv/ venv/
.venv/ .venv/
# snoop logs
out/
+4
View File
@@ -1,6 +1,7 @@
{ {
"cSpell.words": [ "cSpell.words": [
"Abortable", "Abortable",
"aiohttp",
"altsetting", "altsetting",
"ansiblue", "ansiblue",
"ansicyan", "ansicyan",
@@ -9,6 +10,7 @@
"ansired", "ansired",
"ansiyellow", "ansiyellow",
"appendleft", "appendleft",
"ascs",
"ASHA", "ASHA",
"asyncio", "asyncio",
"ATRAC", "ATRAC",
@@ -43,6 +45,7 @@
"keyup", "keyup",
"levelname", "levelname",
"libc", "libc",
"liblc",
"libusb", "libusb",
"MITM", "MITM",
"MSBC", "MSBC",
@@ -78,6 +81,7 @@
"unmuted", "unmuted",
"usbmodem", "usbmodem",
"vhci", "vhci",
"wasmtime",
"websockets", "websockets",
"xcursor", "xcursor",
"ycursor" "ycursor"
+86 -24
View File
@@ -509,9 +509,11 @@ class Ping:
packet = struct.pack( packet = struct.pack(
'>bbI', '>bbI',
PacketType.SEQUENCE, PacketType.SEQUENCE,
PACKET_FLAG_LAST (
if self.current_packet_index == self.tx_packet_count - 1 PACKET_FLAG_LAST
else 0, if self.current_packet_index == self.tx_packet_count - 1
else 0
),
self.current_packet_index, self.current_packet_index,
) + bytes(self.tx_packet_size - 6) ) + bytes(self.tx_packet_size - 6)
logging.info(color(f'Sending packet {self.current_packet_index}', 'yellow')) logging.info(color(f'Sending packet {self.current_packet_index}', 'yellow'))
@@ -897,14 +899,26 @@ class L2capServer(StreamedPacketIO):
# RfcommClient # RfcommClient
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class RfcommClient(StreamedPacketIO): class RfcommClient(StreamedPacketIO):
def __init__(self, device, channel, uuid, l2cap_mtu, max_frame_size, window_size): def __init__(
self,
device,
channel,
uuid,
l2cap_mtu,
max_frame_size,
initial_credits,
max_credits,
credits_threshold,
):
super().__init__() super().__init__()
self.device = device self.device = device
self.channel = channel self.channel = channel
self.uuid = uuid self.uuid = uuid
self.l2cap_mtu = l2cap_mtu self.l2cap_mtu = l2cap_mtu
self.max_frame_size = max_frame_size self.max_frame_size = max_frame_size
self.window_size = window_size self.initial_credits = initial_credits
self.max_credits = max_credits
self.credits_threshold = credits_threshold
self.rfcomm_session = None self.rfcomm_session = None
self.ready = asyncio.Event() self.ready = asyncio.Event()
@@ -938,12 +952,17 @@ class RfcommClient(StreamedPacketIO):
logging.info(color(f'### Opening session for channel {channel}...', 'yellow')) logging.info(color(f'### Opening session for channel {channel}...', 'yellow'))
try: try:
dlc_options = {} dlc_options = {}
if self.max_frame_size: if self.max_frame_size is not None:
dlc_options['max_frame_size'] = self.max_frame_size dlc_options['max_frame_size'] = self.max_frame_size
if self.window_size: if self.initial_credits is not None:
dlc_options['window_size'] = self.window_size dlc_options['initial_credits'] = self.initial_credits
rfcomm_session = await rfcomm_mux.open_dlc(channel, **dlc_options) rfcomm_session = await rfcomm_mux.open_dlc(channel, **dlc_options)
logging.info(color(f'### Session open: {rfcomm_session}', 'yellow')) logging.info(color(f'### Session open: {rfcomm_session}', 'yellow'))
if self.max_credits is not None:
rfcomm_session.rx_max_credits = self.max_credits
if self.credits_threshold is not None:
rfcomm_session.rx_credits_threshold = self.credits_threshold
except bumble.core.ConnectionError as error: except bumble.core.ConnectionError as error:
logging.info(color(f'!!! Session open failed: {error}', 'red')) logging.info(color(f'!!! Session open failed: {error}', 'red'))
await rfcomm_mux.disconnect() await rfcomm_mux.disconnect()
@@ -967,8 +986,19 @@ class RfcommClient(StreamedPacketIO):
# RfcommServer # RfcommServer
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class RfcommServer(StreamedPacketIO): class RfcommServer(StreamedPacketIO):
def __init__(self, device, channel, l2cap_mtu): def __init__(
self,
device,
channel,
l2cap_mtu,
max_frame_size,
initial_credits,
max_credits,
credits_threshold,
):
super().__init__() super().__init__()
self.max_credits = max_credits
self.credits_threshold = credits_threshold
self.dlc = None self.dlc = None
self.ready = asyncio.Event() self.ready = asyncio.Event()
@@ -979,7 +1009,12 @@ class RfcommServer(StreamedPacketIO):
rfcomm_server = bumble.rfcomm.Server(device, **server_options) rfcomm_server = bumble.rfcomm.Server(device, **server_options)
# Listen for incoming DLC connections # Listen for incoming DLC connections
channel_number = rfcomm_server.listen(self.on_dlc, channel) dlc_options = {}
if max_frame_size is not None:
dlc_options['max_frame_size'] = max_frame_size
if initial_credits is not None:
dlc_options['initial_credits'] = initial_credits
channel_number = rfcomm_server.listen(self.on_dlc, channel, **dlc_options)
# Setup the SDP to advertise this channel # Setup the SDP to advertise this channel
device.sdp_service_records = make_sdp_records(channel_number) device.sdp_service_records = make_sdp_records(channel_number)
@@ -1002,6 +1037,10 @@ class RfcommServer(StreamedPacketIO):
dlc.sink = self.on_packet dlc.sink = self.on_packet
self.io_sink = dlc.write self.io_sink = dlc.write
self.dlc = dlc self.dlc = dlc
if self.max_credits is not None:
dlc.rx_max_credits = self.max_credits
if self.credits_threshold is not None:
dlc.rx_credits_threshold = self.credits_threshold
async def drain(self): async def drain(self):
assert self.dlc assert self.dlc
@@ -1062,9 +1101,9 @@ class Central(Connection.Listener):
if self.phy not in (None, HCI_LE_1M_PHY): if self.phy not in (None, HCI_LE_1M_PHY):
# Add an connections parameters entry for this PHY. # Add an connections parameters entry for this PHY.
self.connection_parameter_preferences[ self.connection_parameter_preferences[self.phy] = (
self.phy connection_parameter_preferences
] = connection_parameter_preferences )
else: else:
self.connection_parameter_preferences = None self.connection_parameter_preferences = None
@@ -1232,6 +1271,7 @@ class Peripheral(Device.Listener, Connection.Listener):
'cyan', 'cyan',
) )
) )
await self.connected.wait() await self.connected.wait()
logging.info(color('### Connected', 'cyan')) logging.info(color('### Connected', 'cyan'))
@@ -1318,7 +1358,9 @@ def create_mode_factory(ctx, default_mode):
uuid=ctx.obj['rfcomm_uuid'], uuid=ctx.obj['rfcomm_uuid'],
l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'], l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
max_frame_size=ctx.obj['rfcomm_max_frame_size'], max_frame_size=ctx.obj['rfcomm_max_frame_size'],
window_size=ctx.obj['rfcomm_window_size'], initial_credits=ctx.obj['rfcomm_initial_credits'],
max_credits=ctx.obj['rfcomm_max_credits'],
credits_threshold=ctx.obj['rfcomm_credits_threshold'],
) )
if mode == 'rfcomm-server': if mode == 'rfcomm-server':
@@ -1326,6 +1368,10 @@ def create_mode_factory(ctx, default_mode):
device, device,
channel=ctx.obj['rfcomm_channel'], channel=ctx.obj['rfcomm_channel'],
l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'], l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
max_frame_size=ctx.obj['rfcomm_max_frame_size'],
initial_credits=ctx.obj['rfcomm_initial_credits'],
max_credits=ctx.obj['rfcomm_max_credits'],
credits_threshold=ctx.obj['rfcomm_credits_threshold'],
) )
raise ValueError('invalid mode') raise ValueError('invalid mode')
@@ -1424,9 +1470,19 @@ def create_role_factory(ctx, default_role):
help='RFComm maximum frame size', help='RFComm maximum frame size',
) )
@click.option( @click.option(
'--rfcomm-window-size', '--rfcomm-initial-credits',
type=int, type=int,
help='RFComm window size', help='RFComm initial credits',
)
@click.option(
'--rfcomm-max-credits',
type=int,
help='RFComm max credits',
)
@click.option(
'--rfcomm-credits-threshold',
type=int,
help='RFComm credits threshold',
) )
@click.option( @click.option(
'--l2cap-psm', '--l2cap-psm',
@@ -1527,7 +1583,9 @@ def bench(
rfcomm_uuid, rfcomm_uuid,
rfcomm_l2cap_mtu, rfcomm_l2cap_mtu,
rfcomm_max_frame_size, rfcomm_max_frame_size,
rfcomm_window_size, rfcomm_initial_credits,
rfcomm_max_credits,
rfcomm_credits_threshold,
l2cap_psm, l2cap_psm,
l2cap_mtu, l2cap_mtu,
l2cap_mps, l2cap_mps,
@@ -1542,7 +1600,9 @@ def bench(
ctx.obj['rfcomm_uuid'] = rfcomm_uuid ctx.obj['rfcomm_uuid'] = rfcomm_uuid
ctx.obj['rfcomm_l2cap_mtu'] = rfcomm_l2cap_mtu ctx.obj['rfcomm_l2cap_mtu'] = rfcomm_l2cap_mtu
ctx.obj['rfcomm_max_frame_size'] = rfcomm_max_frame_size ctx.obj['rfcomm_max_frame_size'] = rfcomm_max_frame_size
ctx.obj['rfcomm_window_size'] = rfcomm_window_size ctx.obj['rfcomm_initial_credits'] = rfcomm_initial_credits
ctx.obj['rfcomm_max_credits'] = rfcomm_max_credits
ctx.obj['rfcomm_credits_threshold'] = rfcomm_credits_threshold
ctx.obj['l2cap_psm'] = l2cap_psm ctx.obj['l2cap_psm'] = l2cap_psm
ctx.obj['l2cap_mtu'] = l2cap_mtu ctx.obj['l2cap_mtu'] = l2cap_mtu
ctx.obj['l2cap_mps'] = l2cap_mps ctx.obj['l2cap_mps'] = l2cap_mps
@@ -1591,8 +1651,8 @@ def central(
mode_factory = create_mode_factory(ctx, 'gatt-client') mode_factory = create_mode_factory(ctx, 'gatt-client')
classic = ctx.obj['classic'] classic = ctx.obj['classic']
asyncio.run( async def run_central():
Central( await Central(
transport, transport,
peripheral_address, peripheral_address,
classic, classic,
@@ -1604,7 +1664,8 @@ def central(
encrypt or authenticate, encrypt or authenticate,
ctx.obj['extended_data_length'], ctx.obj['extended_data_length'],
).run() ).run()
)
asyncio.run(run_central())
@bench.command() @bench.command()
@@ -1615,15 +1676,16 @@ def peripheral(ctx, transport):
role_factory = create_role_factory(ctx, 'receiver') role_factory = create_role_factory(ctx, 'receiver')
mode_factory = create_mode_factory(ctx, 'gatt-server') mode_factory = create_mode_factory(ctx, 'gatt-server')
asyncio.run( async def run_peripheral():
Peripheral( await Peripheral(
transport, transport,
ctx.obj['classic'], ctx.obj['classic'],
ctx.obj['extended_data_length'], ctx.obj['extended_data_length'],
role_factory, role_factory,
mode_factory, mode_factory,
).run() ).run()
)
asyncio.run(run_peripheral())
def main(): def main():
+577
View File
@@ -0,0 +1,577 @@
# Copyright 2021-2024 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import datetime
import enum
import functools
from importlib import resources
import json
import os
import logging
import pathlib
from typing import Optional, List, cast
import weakref
import struct
import ctypes
import wasmtime
import wasmtime.loader
import liblc3 # type: ignore
import logging
import click
import aiohttp.web
import bumble
from bumble.core import AdvertisingData
from bumble.colors import color
from bumble.device import Device, DeviceConfiguration, AdvertisingParameters
from bumble.transport import open_transport
from bumble.profiles import bap
from bumble.hci import Address, CodecID, CodingFormat, HCI_IsoDataPacket
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
DEFAULT_UI_PORT = 7654
def _sink_pac_record() -> bap.PacRecord:
return bap.PacRecord(
coding_format=CodingFormat(CodecID.LC3),
codec_specific_capabilities=bap.CodecSpecificCapabilities(
supported_sampling_frequencies=(
bap.SupportedSamplingFrequency.FREQ_8000
| bap.SupportedSamplingFrequency.FREQ_16000
| bap.SupportedSamplingFrequency.FREQ_24000
| bap.SupportedSamplingFrequency.FREQ_32000
| bap.SupportedSamplingFrequency.FREQ_48000
),
supported_frame_durations=(
bap.SupportedFrameDuration.DURATION_10000_US_SUPPORTED
),
supported_audio_channel_count=[1, 2],
min_octets_per_codec_frame=26,
max_octets_per_codec_frame=240,
supported_max_codec_frames_per_sdu=2,
),
)
def _source_pac_record() -> bap.PacRecord:
return bap.PacRecord(
coding_format=CodingFormat(CodecID.LC3),
codec_specific_capabilities=bap.CodecSpecificCapabilities(
supported_sampling_frequencies=(
bap.SupportedSamplingFrequency.FREQ_8000
| bap.SupportedSamplingFrequency.FREQ_16000
| bap.SupportedSamplingFrequency.FREQ_24000
| bap.SupportedSamplingFrequency.FREQ_32000
| bap.SupportedSamplingFrequency.FREQ_48000
),
supported_frame_durations=(
bap.SupportedFrameDuration.DURATION_10000_US_SUPPORTED
),
supported_audio_channel_count=[1],
min_octets_per_codec_frame=30,
max_octets_per_codec_frame=100,
supported_max_codec_frames_per_sdu=1,
),
)
# -----------------------------------------------------------------------------
# WASM - liblc3
# -----------------------------------------------------------------------------
store = wasmtime.loader.store
_memory = cast(wasmtime.Memory, liblc3.memory)
STACK_POINTER = _memory.data_len(store)
_memory.grow(store, 1)
# Mapping wasmtime memory to linear address
memory = (ctypes.c_ubyte * _memory.data_len(store)).from_address(
ctypes.addressof(_memory.data_ptr(store).contents) # type: ignore
)
class Liblc3PcmFormat(enum.IntEnum):
S16 = 0
S24 = 1
S24_3LE = 2
FLOAT = 3
MAX_DECODER_SIZE = liblc3.lc3_decoder_size(10000, 48000)
MAX_ENCODER_SIZE = liblc3.lc3_encoder_size(10000, 48000)
DECODER_STACK_POINTER = STACK_POINTER
ENCODER_STACK_POINTER = DECODER_STACK_POINTER + MAX_DECODER_SIZE * 2
DECODE_BUFFER_STACK_POINTER = ENCODER_STACK_POINTER + MAX_ENCODER_SIZE * 2
ENCODE_BUFFER_STACK_POINTER = DECODE_BUFFER_STACK_POINTER + 8192
DEFAULT_PCM_SAMPLE_RATE = 48000
DEFAULT_PCM_FORMAT = Liblc3PcmFormat.S16
DEFAULT_PCM_BYTES_PER_SAMPLE = 2
encoders: List[int] = []
decoders: List[int] = []
def setup_encoders(
sample_rate_hz: int, frame_duration_us: int, num_channels: int
) -> None:
logger.info(
f"setup_encoders {sample_rate_hz}Hz {frame_duration_us}us {num_channels}channels"
)
encoders[:num_channels] = [
liblc3.lc3_setup_encoder(
frame_duration_us,
sample_rate_hz,
DEFAULT_PCM_SAMPLE_RATE, # Input sample rate
ENCODER_STACK_POINTER + MAX_ENCODER_SIZE * i,
)
for i in range(num_channels)
]
def setup_decoders(
sample_rate_hz: int, frame_duration_us: int, num_channels: int
) -> None:
logger.info(
f"setup_decoders {sample_rate_hz}Hz {frame_duration_us}us {num_channels}channels"
)
decoders[:num_channels] = [
liblc3.lc3_setup_decoder(
frame_duration_us,
sample_rate_hz,
DEFAULT_PCM_SAMPLE_RATE, # Output sample rate
DECODER_STACK_POINTER + MAX_DECODER_SIZE * i,
)
for i in range(num_channels)
]
def decode(
frame_duration_us: int,
num_channels: int,
input_bytes: bytes,
) -> bytes:
if not input_bytes:
return b''
input_buffer_offset = DECODE_BUFFER_STACK_POINTER
input_buffer_size = len(input_bytes)
input_bytes_per_frame = input_buffer_size // num_channels
# Copy into wasm
memory[input_buffer_offset : input_buffer_offset + input_buffer_size] = input_bytes # type: ignore
output_buffer_offset = input_buffer_offset + input_buffer_size
output_buffer_size = (
liblc3.lc3_frame_samples(frame_duration_us, DEFAULT_PCM_SAMPLE_RATE)
* DEFAULT_PCM_BYTES_PER_SAMPLE
* num_channels
)
for i in range(num_channels):
res = liblc3.lc3_decode(
decoders[i],
input_buffer_offset + input_bytes_per_frame * i,
input_bytes_per_frame,
DEFAULT_PCM_FORMAT,
output_buffer_offset + i * DEFAULT_PCM_BYTES_PER_SAMPLE,
num_channels, # Stride
)
if res != 0:
logging.error(f"Parsing failed, res={res}")
# Extract decoded data from the output buffer
return bytes(
memory[output_buffer_offset : output_buffer_offset + output_buffer_size]
)
def encode(
sdu_length: int,
num_channels: int,
stride: int,
input_bytes: bytes,
) -> bytes:
if not input_bytes:
return b''
input_buffer_offset = ENCODE_BUFFER_STACK_POINTER
input_buffer_size = len(input_bytes)
# Copy into wasm
memory[input_buffer_offset : input_buffer_offset + input_buffer_size] = input_bytes # type: ignore
output_buffer_offset = input_buffer_offset + input_buffer_size
output_buffer_size = sdu_length
output_frame_size = output_buffer_size // num_channels
for i in range(num_channels):
res = liblc3.lc3_encode(
encoders[i],
DEFAULT_PCM_FORMAT,
input_buffer_offset + DEFAULT_PCM_BYTES_PER_SAMPLE * i,
stride,
output_frame_size,
output_buffer_offset + output_frame_size * i,
)
if res != 0:
logging.error(f"Parsing failed, res={res}")
# Extract decoded data from the output buffer
return bytes(
memory[output_buffer_offset : output_buffer_offset + output_buffer_size]
)
async def lc3_source_task(
filename: str,
sdu_length: int,
frame_duration_us: int,
device: Device,
cis_handle: int,
) -> None:
with open(filename, 'rb') as f:
header = f.read(44)
assert header[8:12] == b'WAVE'
pcm_num_channel, pcm_sample_rate, _byte_rate, _block_align, bits_per_sample = (
struct.unpack("<HIIHH", header[22:36])
)
assert pcm_sample_rate == DEFAULT_PCM_SAMPLE_RATE
assert bits_per_sample == DEFAULT_PCM_BYTES_PER_SAMPLE * 8
frame_bytes = (
liblc3.lc3_frame_samples(frame_duration_us, DEFAULT_PCM_SAMPLE_RATE)
* DEFAULT_PCM_BYTES_PER_SAMPLE
)
packet_sequence_number = 0
while True:
next_round = datetime.datetime.now() + datetime.timedelta(
microseconds=frame_duration_us
)
pcm_data = f.read(frame_bytes)
sdu = encode(sdu_length, pcm_num_channel, pcm_num_channel, pcm_data)
iso_packet = HCI_IsoDataPacket(
connection_handle=cis_handle,
data_total_length=sdu_length + 4,
packet_sequence_number=packet_sequence_number,
pb_flag=0b10,
packet_status_flag=0,
iso_sdu_length=sdu_length,
iso_sdu_fragment=sdu,
)
device.host.send_hci_packet(iso_packet)
packet_sequence_number += 1
sleep_time = next_round - datetime.datetime.now()
await asyncio.sleep(sleep_time.total_seconds())
# -----------------------------------------------------------------------------
class UiServer:
speaker: weakref.ReferenceType[Speaker]
port: int
def __init__(self, speaker: Speaker, port: int) -> None:
self.speaker = weakref.ref(speaker)
self.port = port
self.channel_socket = None
async def start_http(self) -> None:
"""Start the UI HTTP server."""
app = aiohttp.web.Application()
app.add_routes(
[
aiohttp.web.get('/', self.get_static),
aiohttp.web.get('/index.html', self.get_static),
aiohttp.web.get('/channel', self.get_channel),
]
)
runner = aiohttp.web.AppRunner(app)
await runner.setup()
site = aiohttp.web.TCPSite(runner, 'localhost', self.port)
print('UI HTTP server at ' + color(f'http://127.0.0.1:{self.port}', 'green'))
await site.start()
async def get_static(self, request):
path = request.path
if path == '/':
path = '/index.html'
if path.endswith('.html'):
content_type = 'text/html'
elif path.endswith('.js'):
content_type = 'text/javascript'
elif path.endswith('.css'):
content_type = 'text/css'
elif path.endswith('.svg'):
content_type = 'image/svg+xml'
else:
content_type = 'text/plain'
text = (
resources.files("bumble.apps.lea_unicast")
.joinpath(pathlib.Path(path).relative_to('/'))
.read_text(encoding="utf-8")
)
return aiohttp.web.Response(text=text, content_type=content_type)
async def get_channel(self, request):
ws = aiohttp.web.WebSocketResponse()
await ws.prepare(request)
# Process messages until the socket is closed.
self.channel_socket = ws
async for message in ws:
if message.type == aiohttp.WSMsgType.TEXT:
logger.debug(f'<<< received message: {message.data}')
await self.on_message(message.data)
elif message.type == aiohttp.WSMsgType.ERROR:
logger.debug(
f'channel connection closed with exception {ws.exception()}'
)
self.channel_socket = None
logger.debug('--- channel connection closed')
return ws
async def on_message(self, message_str: str):
# Parse the message as JSON
message = json.loads(message_str)
# Dispatch the message
message_type = message['type']
message_params = message.get('params', {})
handler = getattr(self, f'on_{message_type}_message')
if handler:
await handler(**message_params)
async def on_hello_message(self):
await self.send_message(
'hello',
bumble_version=bumble.__version__,
codec=self.speaker().codec,
streamState=self.speaker().stream_state.name,
)
if connection := self.speaker().connection:
await self.send_message(
'connection',
peer_address=connection.peer_address.to_string(False),
peer_name=connection.peer_name,
)
async def send_message(self, message_type: str, **kwargs) -> None:
if self.channel_socket is None:
return
message = {'type': message_type, 'params': kwargs}
await self.channel_socket.send_json(message)
async def send_audio(self, data: bytes) -> None:
if self.channel_socket is None:
return
try:
await self.channel_socket.send_bytes(data)
except Exception as error:
logger.warning(f'exception while sending audio packet: {error}')
# -----------------------------------------------------------------------------
class Speaker:
def __init__(
self,
device_config_path: Optional[str],
ui_port: int,
transport: str,
lc3_input_file_path: str,
):
self.device_config_path = device_config_path
self.transport = transport
self.lc3_input_file_path = lc3_input_file_path
# Create an HTTP server for the UI
self.ui_server = UiServer(speaker=self, port=ui_port)
async def run(self) -> None:
await self.ui_server.start_http()
async with await open_transport(self.transport) as hci_transport:
# Create a device
if self.device_config_path:
device_config = DeviceConfiguration.from_file(self.device_config_path)
else:
device_config = DeviceConfiguration(
name="Bumble LE Headphone",
class_of_device=0x244418,
keystore="JsonKeyStore",
advertising_interval_min=25,
advertising_interval_max=25,
address=Address('F1:F2:F3:F4:F5:F6'),
)
device_config.le_enabled = True
device_config.cis_enabled = True
self.device = Device.from_config_with_hci(
device_config, hci_transport.source, hci_transport.sink
)
self.device.add_service(
bap.PublishedAudioCapabilitiesService(
supported_source_context=bap.ContextType(0xFFFF),
available_source_context=bap.ContextType(0xFFFF),
supported_sink_context=bap.ContextType(0xFFFF), # All context types
available_sink_context=bap.ContextType(0xFFFF), # All context types
sink_audio_locations=(
bap.AudioLocation.FRONT_LEFT | bap.AudioLocation.FRONT_RIGHT
),
sink_pac=[_sink_pac_record()],
source_audio_locations=bap.AudioLocation.FRONT_LEFT,
source_pac=[_source_pac_record()],
)
)
ascs = bap.AudioStreamControlService(
self.device, sink_ase_id=[1], source_ase_id=[2]
)
self.device.add_service(ascs)
advertising_data = bytes(
AdvertisingData(
[
(
AdvertisingData.COMPLETE_LOCAL_NAME,
bytes(device_config.name, 'utf-8'),
),
(
AdvertisingData.FLAGS,
bytes([AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG]),
),
(
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
bytes(bap.PublishedAudioCapabilitiesService.UUID),
),
]
)
) + bytes(bap.UnicastServerAdvertisingData())
def on_pdu(pdu: HCI_IsoDataPacket, ase: bap.AseStateMachine):
codec_config = ase.codec_specific_configuration
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
pcm = decode(
codec_config.frame_duration.us,
codec_config.audio_channel_allocation.channel_count,
pdu.iso_sdu_fragment,
)
self.device.abort_on('disconnection', self.ui_server.send_audio(pcm))
def on_ase_state_change(ase: bap.AseStateMachine) -> None:
if ase.state == bap.AseStateMachine.State.STREAMING:
codec_config = ase.codec_specific_configuration
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
assert ase.cis_link
if ase.role == bap.AudioRole.SOURCE:
ase.cis_link.abort_on(
'disconnection',
lc3_source_task(
filename=self.lc3_input_file_path,
sdu_length=(
codec_config.codec_frames_per_sdu
* codec_config.octets_per_codec_frame
),
frame_duration_us=codec_config.frame_duration.us,
device=self.device,
cis_handle=ase.cis_link.handle,
),
)
else:
ase.cis_link.sink = functools.partial(on_pdu, ase=ase)
elif ase.state == bap.AseStateMachine.State.CODEC_CONFIGURED:
codec_config = ase.codec_specific_configuration
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
if ase.role == bap.AudioRole.SOURCE:
setup_encoders(
codec_config.sampling_frequency.hz,
codec_config.frame_duration.us,
codec_config.audio_channel_allocation.channel_count,
)
else:
setup_decoders(
codec_config.sampling_frequency.hz,
codec_config.frame_duration.us,
codec_config.audio_channel_allocation.channel_count,
)
for ase in ascs.ase_state_machines.values():
ase.on('state_change', functools.partial(on_ase_state_change, ase=ase))
await self.device.power_on()
await self.device.create_advertising_set(
advertising_data=advertising_data,
auto_restart=True,
advertising_parameters=AdvertisingParameters(
primary_advertising_interval_min=100,
primary_advertising_interval_max=100,
),
)
await hci_transport.source.terminated
@click.command()
@click.option(
'--ui-port',
'ui_port',
metavar='HTTP_PORT',
default=DEFAULT_UI_PORT,
show_default=True,
help='HTTP port for the UI server',
)
@click.option('--device-config', metavar='FILENAME', help='Device configuration file')
@click.argument('transport')
@click.argument('lc3_file')
def speaker(ui_port: int, device_config: str, transport: str, lc3_file: str) -> None:
"""Run the speaker."""
asyncio.run(Speaker(device_config, ui_port, transport, lc3_file).run())
# -----------------------------------------------------------------------------
def main():
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
speaker()
# -----------------------------------------------------------------------------
if __name__ == "__main__":
main() # pylint: disable=no-value-for-parameter
+68
View File
@@ -0,0 +1,68 @@
<html data-bs-theme="dark">
<head>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<script src="https://unpkg.com/pcm-player"></script>
</head>
<body>
<nav class="navbar navbar-dark bg-primary">
<div class="container">
<span class="navbar-brand mb-0 h1">Bumble Unicast Server</span>
</div>
</nav>
<br>
<div class="container">
<button type="button" class="btn btn-danger" id="connect-audio" onclick="connectAudio()">Connect Audio</button>
<button class="btn btn-primary" type="button" disabled>
<span class="spinner-border spinner-border-sm" id="ws-status-spinner" aria-hidden="true"></span>
<span role="status" id="ws-status">WebSocket Connecting...</span>
</button>
</div>
<script>
let player = null;
const wsStatus = document.getElementById("ws-status");
const wsStatusSpinner = document.getElementById("ws-status-spinner");
const socket = new WebSocket('ws://127.0.0.1:7654/channel');
socket.binaryType = "arraybuffer";
socket.onmessage = function (message) {
if (typeof message.data === 'string' || message.data instanceof String) {
console.log(`channel MESSAGE: ${message.data}`);
} else {
console.log(typeof (message.data))
// BINARY audio data.
if (player == null) return;
player.feed(message.data);
}
};
socket.onopen = (message) => {
wsStatusSpinner.remove();
wsStatus.textContent = "WebSocket Connected";
}
socket.onclose = (message) => {
wsStatus.textContent = "WebSocket Disconnected";
}
function connectAudio() {
player = new PCMPlayer({
inputCodec: 'Int16',
channels: 2,
sampleRate: 48000,
flushTime: 10,
});
const button = document.getElementById("connect-audio")
button.disabled = true;
button.textContent = "Audio Connected";
}
</script>
</div>
</body>
</html>
BIN
View File
Binary file not shown.
+511
View File
@@ -0,0 +1,511 @@
# Copyright 2024 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import asyncio
import logging
import os
import time
from typing import Optional
import click
from bumble.colors import color
from bumble.device import Device, DeviceConfiguration, Connection
from bumble import core
from bumble import hci
from bumble import rfcomm
from bumble import transport
from bumble import utils
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
DEFAULT_RFCOMM_UUID = "E6D55659-C8B4-4B85-96BB-B1143AF6D3AE"
DEFAULT_MTU = 4096
DEFAULT_CLIENT_TCP_PORT = 9544
DEFAULT_SERVER_TCP_PORT = 9545
TRACE_MAX_SIZE = 48
# -----------------------------------------------------------------------------
class Tracer:
"""
Trace data buffers transmitted from one endpoint to another, with stats.
"""
def __init__(self, channel_name: str) -> None:
self.channel_name = channel_name
self.last_ts: float = 0.0
def trace_data(self, data: bytes) -> None:
now = time.time()
elapsed_s = now - self.last_ts if self.last_ts else 0
elapsed_ms = int(elapsed_s * 1000)
instant_throughput_kbps = ((len(data) / elapsed_s) / 1000) if elapsed_s else 0.0
hex_str = data[:TRACE_MAX_SIZE].hex() + (
"..." if len(data) > TRACE_MAX_SIZE else ""
)
print(
f"[{self.channel_name}] {len(data):4} bytes "
f"(+{elapsed_ms:4}ms, {instant_throughput_kbps: 7.2f}kB/s) "
f" {hex_str}"
)
self.last_ts = now
# -----------------------------------------------------------------------------
class ServerBridge:
"""
RFCOMM server bridge: waits for a peer to connect an RFCOMM channel.
The RFCOMM channel may be associated with a UUID published in an SDP service
description, or simply be on a system-assigned channel number.
When the connection is made, the bridge connects a TCP socket to a remote host and
bridges the data in both directions, with flow control.
When the RFCOMM channel is closed, the bridge disconnects the TCP socket
and waits for a new channel to be connected.
"""
READ_CHUNK_SIZE = 4096
def __init__(
self, channel: int, uuid: str, trace: bool, tcp_host: str, tcp_port: int
) -> None:
self.device: Optional[Device] = None
self.channel = channel
self.uuid = uuid
self.tcp_host = tcp_host
self.tcp_port = tcp_port
self.rfcomm_channel: Optional[rfcomm.DLC] = None
self.tcp_tracer: Optional[Tracer]
self.rfcomm_tracer: Optional[Tracer]
if trace:
self.tcp_tracer = Tracer(color("RFCOMM->TCP", "cyan"))
self.rfcomm_tracer = Tracer(color("TCP->RFCOMM", "magenta"))
else:
self.rfcomm_tracer = None
self.tcp_tracer = None
async def start(self, device: Device) -> None:
self.device = device
# Create and register a server
rfcomm_server = rfcomm.Server(self.device)
# Listen for incoming DLC connections
self.channel = rfcomm_server.listen(self.on_rfcomm_channel, self.channel)
# Setup the SDP to advertise this channel
service_record_handle = 0x00010001
self.device.sdp_service_records = {
service_record_handle: rfcomm.make_service_sdp_records(
service_record_handle, self.channel, core.UUID(self.uuid)
)
}
# We're ready for a connection
self.device.on("connection", self.on_connection)
await self.set_available(True)
print(
color(
(
f"### Listening for RFCOMM connection on {device.public_address}, "
f"channel {self.channel}"
),
"yellow",
)
)
async def set_available(self, available: bool):
# Become discoverable and connectable
assert self.device
await self.device.set_connectable(available)
await self.device.set_discoverable(available)
def on_connection(self, connection):
print(color(f"@@@ Bluetooth connection: {connection}", "blue"))
connection.on("disconnection", self.on_disconnection)
# Don't accept new connections until we're disconnected
utils.AsyncRunner.spawn(self.set_available(False))
def on_disconnection(self, reason: int):
print(
color("@@@ Bluetooth disconnection:", "red"),
hci.HCI_Constant.error_name(reason),
)
# We're ready for a new connection
utils.AsyncRunner.spawn(self.set_available(True))
# Called when an RFCOMM channel is established
@utils.AsyncRunner.run_in_task()
async def on_rfcomm_channel(self, rfcomm_channel):
print(color("*** RFCOMM channel:", "cyan"), rfcomm_channel)
# Connect to the TCP server
print(
color(
f"### Connecting to TCP {self.tcp_host}:{self.tcp_port}",
"yellow",
)
)
try:
reader, writer = await asyncio.open_connection(self.tcp_host, self.tcp_port)
except OSError:
print(color("!!! Connection failed", "red"))
await rfcomm_channel.disconnect()
return
# Pipe data from RFCOMM to TCP
def on_rfcomm_channel_closed():
print(color("*** RFCOMM channel closed", "cyan"))
writer.close()
def write_rfcomm_data(data):
if self.rfcomm_tracer:
self.rfcomm_tracer.trace_data(data)
writer.write(data)
rfcomm_channel.sink = write_rfcomm_data
rfcomm_channel.on("close", on_rfcomm_channel_closed)
# Pipe data from TCP to RFCOMM
while True:
try:
data = await reader.read(self.READ_CHUNK_SIZE)
if len(data) == 0:
print(color("### TCP end of stream", "yellow"))
if rfcomm_channel.state == rfcomm.DLC.State.CONNECTED:
await rfcomm_channel.disconnect()
return
if self.tcp_tracer:
self.tcp_tracer.trace_data(data)
rfcomm_channel.write(data)
await rfcomm_channel.drain()
except Exception as error:
print(f"!!! Exception: {error}")
break
writer.close()
await writer.wait_closed()
print(color("~~~ Bye bye", "magenta"))
# -----------------------------------------------------------------------------
class ClientBridge:
"""
RFCOMM client bridge: connects to a BR/EDR device, then waits for an inbound
TCP connection on a specified port number. When a TCP client connects, an
RFCOMM connection to the device is established, and the data is bridged in both
directions, with flow control.
When the TCP connection is closed by the client, the RFCOMM channel is
disconnected, but the connection to the device remains, ready for a new TCP client
to connect.
"""
READ_CHUNK_SIZE = 4096
def __init__(
self,
channel: int,
uuid: str,
trace: bool,
address: str,
tcp_host: str,
tcp_port: int,
encrypt: bool,
):
self.channel = channel
self.uuid = uuid
self.trace = trace
self.address = address
self.tcp_host = tcp_host
self.tcp_port = tcp_port
self.encrypt = encrypt
self.device: Optional[Device] = None
self.connection: Optional[Connection] = None
self.rfcomm_client: Optional[rfcomm.Client]
self.rfcomm_mux: Optional[rfcomm.Multiplexer]
self.tcp_connected: bool = False
self.tcp_tracer: Optional[Tracer]
self.rfcomm_tracer: Optional[Tracer]
if trace:
self.tcp_tracer = Tracer(color("RFCOMM->TCP", "cyan"))
self.rfcomm_tracer = Tracer(color("TCP->RFCOMM", "magenta"))
else:
self.rfcomm_tracer = None
self.tcp_tracer = None
async def connect(self) -> None:
if self.connection:
return
print(color(f"@@@ Connecting to Bluetooth {self.address}", "blue"))
assert self.device
self.connection = await self.device.connect(
self.address, transport=core.BT_BR_EDR_TRANSPORT
)
print(color(f"@@@ Bluetooth connection: {self.connection}", "blue"))
self.connection.on("disconnection", self.on_disconnection)
if self.encrypt:
print(color("@@@ Encrypting Bluetooth connection", "blue"))
await self.connection.encrypt()
print(color("@@@ Bluetooth connection encrypted", "blue"))
self.rfcomm_client = rfcomm.Client(self.connection)
try:
self.rfcomm_mux = await self.rfcomm_client.start()
except BaseException as e:
print(color("!!! Failed to setup RFCOMM connection", "red"), e)
raise
async def start(self, device: Device) -> None:
self.device = device
await device.set_connectable(False)
await device.set_discoverable(False)
# Called when a TCP connection is established
async def on_tcp_connection(reader, writer):
print(color("<<< TCP connection", "magenta"))
if self.tcp_connected:
print(
color("!!! TCP connection already active, rejecting new one", "red")
)
writer.close()
return
self.tcp_connected = True
try:
await self.pipe(reader, writer)
except BaseException as error:
print(color("!!! Exception while piping data:", "red"), error)
return
finally:
writer.close()
await writer.wait_closed()
self.tcp_connected = False
await asyncio.start_server(
on_tcp_connection,
host=self.tcp_host if self.tcp_host != "_" else None,
port=self.tcp_port,
)
print(
color(
f"### Listening for TCP connections on port {self.tcp_port}", "magenta"
)
)
async def pipe(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
# Resolve the channel number from the UUID if needed
if self.channel == 0:
await self.connect()
assert self.connection
channel = await rfcomm.find_rfcomm_channel_with_uuid(
self.connection, self.uuid
)
if channel:
print(color(f"### Found RFCOMM channel {channel}", "yellow"))
else:
print(color(f"!!! RFCOMM channel with UUID {self.uuid} not found"))
return
else:
channel = self.channel
# Connect a new RFCOMM channel
await self.connect()
assert self.rfcomm_mux
print(color(f"*** Opening RFCOMM channel {channel}", "green"))
try:
rfcomm_channel = await self.rfcomm_mux.open_dlc(channel)
print(color(f"*** RFCOMM channel open: {rfcomm_channel}", "green"))
except Exception as error:
print(color(f"!!! RFCOMM open failed: {error}", "red"))
return
# Pipe data from RFCOMM to TCP
def on_rfcomm_channel_closed():
print(color("*** RFCOMM channel closed", "green"))
def write_rfcomm_data(data):
if self.trace:
self.rfcomm_tracer.trace_data(data)
writer.write(data)
rfcomm_channel.on("close", on_rfcomm_channel_closed)
rfcomm_channel.sink = write_rfcomm_data
# Pipe data from TCP to RFCOMM
while True:
try:
data = await reader.read(self.READ_CHUNK_SIZE)
if len(data) == 0:
print(color("### TCP end of stream", "yellow"))
if rfcomm_channel.state == rfcomm.DLC.State.CONNECTED:
await rfcomm_channel.disconnect()
self.tcp_connected = False
return
if self.tcp_tracer:
self.tcp_tracer.trace_data(data)
rfcomm_channel.write(data)
await rfcomm_channel.drain()
except Exception as error:
print(f"!!! Exception: {error}")
break
print(color("~~~ Bye bye", "magenta"))
def on_disconnection(self, reason: int) -> None:
print(
color("@@@ Bluetooth disconnection:", "red"),
hci.HCI_Constant.error_name(reason),
)
self.connection = None
# -----------------------------------------------------------------------------
async def run(device_config, hci_transport, bridge):
print("<<< connecting to HCI...")
async with await transport.open_transport_or_link(hci_transport) as (
hci_source,
hci_sink,
):
print("<<< connected")
if device_config:
device = Device.from_config_file_with_hci(
device_config, hci_source, hci_sink
)
else:
device = Device.from_config_with_hci(
DeviceConfiguration(), hci_source, hci_sink
)
device.classic_enabled = True
# Let's go
await device.power_on()
try:
await bridge.start(device)
# Wait until the transport terminates
await hci_source.wait_for_termination()
except core.ConnectionError as error:
print(color(f"!!! Bluetooth connection failed: {error}", "red"))
except Exception as error:
print(f"Exception while running bridge: {error}")
# -----------------------------------------------------------------------------
@click.group()
@click.pass_context
@click.option(
"--device-config",
metavar="CONFIG_FILE",
help="Device configuration file",
)
@click.option(
"--hci-transport", metavar="TRANSPORT_NAME", help="HCI transport", required=True
)
@click.option("--trace", is_flag=True, help="Trace bridged data to stdout")
@click.option(
"--channel",
metavar="CHANNEL_NUMER",
help="RFCOMM channel number",
type=int,
default=0,
)
@click.option(
"--uuid",
metavar="UUID",
help="UUID for the RFCOMM channel",
default=DEFAULT_RFCOMM_UUID,
)
def cli(
context,
device_config,
hci_transport,
trace,
channel,
uuid,
):
context.ensure_object(dict)
context.obj["device_config"] = device_config
context.obj["hci_transport"] = hci_transport
context.obj["trace"] = trace
context.obj["channel"] = channel
context.obj["uuid"] = uuid
# -----------------------------------------------------------------------------
@cli.command()
@click.pass_context
@click.option("--tcp-host", help="TCP host", default="localhost")
@click.option("--tcp-port", help="TCP port", default=DEFAULT_SERVER_TCP_PORT)
def server(context, tcp_host, tcp_port):
bridge = ServerBridge(
context.obj["channel"],
context.obj["uuid"],
context.obj["trace"],
tcp_host,
tcp_port,
)
asyncio.run(run(context.obj["device_config"], context.obj["hci_transport"], bridge))
# -----------------------------------------------------------------------------
@cli.command()
@click.pass_context
@click.argument("bluetooth-address")
@click.option("--tcp-host", help="TCP host", default="_")
@click.option("--tcp-port", help="TCP port", default=DEFAULT_CLIENT_TCP_PORT)
@click.option("--encrypt", is_flag=True, help="Encrypt the connection")
def client(context, bluetooth_address, tcp_host, tcp_port, encrypt):
bridge = ClientBridge(
context.obj["channel"],
context.obj["uuid"],
context.obj["trace"],
bluetooth_address,
tcp_host,
tcp_port,
encrypt,
)
asyncio.run(run(context.obj["device_config"], context.obj["hci_transport"], bridge))
# -----------------------------------------------------------------------------
logging.basicConfig(level=os.environ.get("BUMBLE_LOGLEVEL", "WARNING").upper())
if __name__ == "__main__":
cli(obj={}) # pylint: disable=no-value-for-parameter
+60 -15
View File
@@ -15,7 +15,11 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import datetime
import logging
import os
import struct import struct
import click import click
from bumble.colors import color from bumble.colors import color
@@ -24,6 +28,14 @@ from bumble.transport.common import PacketReader
from bumble.helpers import PacketTracer from bumble.helpers import PacketTracer
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Classes
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class SnoopPacketReader: class SnoopPacketReader:
''' '''
@@ -36,12 +48,18 @@ class SnoopPacketReader:
DATALINK_BSCP = 1003 DATALINK_BSCP = 1003
DATALINK_H5 = 1004 DATALINK_H5 = 1004
IDENTIFICATION_PATTERN = b'btsnoop\0'
TIMESTAMP_ANCHOR = datetime.datetime(2000, 1, 1)
TIMESTAMP_DELTA = 0x00E03AB44A676000
ONE_MICROSECOND = datetime.timedelta(microseconds=1)
def __init__(self, source): def __init__(self, source):
self.source = source self.source = source
self.at_end = False
# Read the header # Read the header
identification_pattern = source.read(8) identification_pattern = source.read(8)
if identification_pattern.hex().lower() != '6274736e6f6f7000': if identification_pattern != self.IDENTIFICATION_PATTERN:
raise ValueError( raise ValueError(
'not a valid snoop file, unexpected identification pattern' 'not a valid snoop file, unexpected identification pattern'
) )
@@ -55,19 +73,32 @@ class SnoopPacketReader:
# Read the record header # Read the record header
header = self.source.read(24) header = self.source.read(24)
if len(header) < 24: if len(header) < 24:
return (0, None) self.at_end = True
return (None, 0, None)
# Parse the header
( (
original_length, original_length,
included_length, included_length,
packet_flags, packet_flags,
_cumulative_drops, _cumulative_drops,
_timestamp_seconds, timestamp,
_timestamp_microsecond, ) = struct.unpack('>IIIIQ', header)
) = struct.unpack('>IIIIII', header)
# Abort on truncated packets # Skip truncated packets
if original_length != included_length: if original_length != included_length:
return (0, None) print(
color(
f"!!! truncated packet ({included_length}/{original_length})", "red"
)
)
self.source.read(included_length)
return (None, 0, None)
# Convert the timestamp to a datetime object.
ts_dt = self.TIMESTAMP_ANCHOR + datetime.timedelta(
microseconds=timestamp - self.TIMESTAMP_DELTA
)
if self.data_link_type == self.DATALINK_H1: if self.data_link_type == self.DATALINK_H1:
# The packet is un-encapsulated, look at the flags to figure out its type # The packet is un-encapsulated, look at the flags to figure out its type
@@ -89,7 +120,17 @@ class SnoopPacketReader:
bytes([packet_type]) + self.source.read(included_length), bytes([packet_type]) + self.source.read(included_length),
) )
return (packet_flags & 1, self.source.read(included_length)) return (ts_dt, packet_flags & 1, self.source.read(included_length))
# -----------------------------------------------------------------------------
class Printer:
def __init__(self):
self.index = 0
def print(self, message: str) -> None:
self.index += 1
print(f"[{self.index:8}]{message}")
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -122,24 +163,28 @@ def main(format, vendors, filename):
packet_reader = PacketReader(input) packet_reader = PacketReader(input)
def read_next_packet(): def read_next_packet():
return (0, packet_reader.next_packet()) return (None, 0, packet_reader.next_packet())
else: else:
packet_reader = SnoopPacketReader(input) packet_reader = SnoopPacketReader(input)
read_next_packet = packet_reader.next_packet read_next_packet = packet_reader.next_packet
tracer = PacketTracer(emit_message=print) printer = Printer()
tracer = PacketTracer(emit_message=printer.print)
while True: while not packet_reader.at_end:
try: try:
(direction, packet) = read_next_packet() (timestamp, direction, packet) = read_next_packet()
if packet is None: if packet:
break tracer.trace(hci.HCI_Packet.from_bytes(packet), direction, timestamp)
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction) else:
printer.print(color("[TRUNCATED]", "red"))
except Exception as error: except Exception as error:
logger.exception()
print(color(f'!!! {error}', 'red')) print(color(f'!!! {error}', 'red'))
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
if __name__ == '__main__': if __name__ == '__main__':
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
main() # pylint: disable=no-value-for-parameter main() # pylint: disable=no-value-for-parameter
+1
View File
@@ -76,6 +76,7 @@ logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
DEFAULT_UI_PORT = 7654 DEFAULT_UI_PORT = 7654
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class AudioExtractor: class AudioExtractor:
@staticmethod @staticmethod
+1
View File
@@ -24,6 +24,7 @@ from bumble.device import Device
from bumble.keys import JsonKeyStore from bumble.keys import JsonKeyStore
from bumble.transport import open_transport from bumble.transport import open_transport
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def unbond_with_keystore(keystore, address): async def unbond_with_keystore(keystore, address):
if address is None: if address is None:
+2
View File
@@ -652,7 +652,9 @@ class SbcPacketSource:
# Prepare for next packets # Prepare for next packets
sequence_number += 1 sequence_number += 1
sequence_number &= 0xFFFF
timestamp += sum((frame.sample_count for frame in frames)) timestamp += sum((frame.sample_count for frame in frames))
timestamp &= 0xFFFFFFFF
frames = [frame] frames = [frame]
frames_size = len(frame.payload) frames_size = len(frame.payload)
else: else:
+1 -1
View File
@@ -655,7 +655,7 @@ class ATT_Write_Command(ATT_PDU):
@ATT_PDU.subclass( @ATT_PDU.subclass(
[ [
('attribute_handle', HANDLE_FIELD_SPEC), ('attribute_handle', HANDLE_FIELD_SPEC),
('attribute_value', '*') ('attribute_value', '*'),
# ('authentication_signature', 'TODO') # ('authentication_signature', 'TODO')
] ]
) )
+12 -6
View File
@@ -325,8 +325,8 @@ class MediaPacket:
self.padding = padding self.padding = padding
self.extension = extension self.extension = extension
self.marker = marker self.marker = marker
self.sequence_number = sequence_number self.sequence_number = sequence_number & 0xFFFF
self.timestamp = timestamp self.timestamp = timestamp & 0xFFFFFFFF
self.ssrc = ssrc self.ssrc = ssrc
self.csrc_list = csrc_list self.csrc_list = csrc_list
self.payload_type = payload_type self.payload_type = payload_type
@@ -341,7 +341,12 @@ class MediaPacket:
| len(self.csrc_list), | len(self.csrc_list),
self.marker << 7 | self.payload_type, self.marker << 7 | self.payload_type,
] ]
) + struct.pack('>HII', self.sequence_number, self.timestamp, self.ssrc) ) + struct.pack(
'>HII',
self.sequence_number,
self.timestamp,
self.ssrc,
)
for csrc in self.csrc_list: for csrc in self.csrc_list:
header += struct.pack('>I', csrc) header += struct.pack('>I', csrc)
return header + self.payload return header + self.payload
@@ -1545,9 +1550,10 @@ class Protocol(EventEmitter):
assert False # Should never reach this assert False # Should never reach this
async def get_capabilities( async def get_capabilities(self, seid: int) -> Union[
self, seid: int Get_Capabilities_Response,
) -> Union[Get_Capabilities_Response, Get_All_Capabilities_Response,]: Get_All_Capabilities_Response,
]:
if self.version > (1, 2): if self.version > (1, 2):
return await self.send_command(Get_All_Capabilities_Command(seid)) return await self.send_command(Get_All_Capabilities_Command(seid))
+5 -3
View File
@@ -1745,9 +1745,11 @@ class Protocol(pyee.EventEmitter):
avc.CommandFrame.CommandType.CONTROL, avc.CommandFrame.CommandType.CONTROL,
avc.Frame.SubunitType.PANEL, avc.Frame.SubunitType.PANEL,
0, 0,
avc.PassThroughFrame.StateFlag.PRESSED (
if pressed avc.PassThroughFrame.StateFlag.PRESSED
else avc.PassThroughFrame.StateFlag.RELEASED, if pressed
else avc.PassThroughFrame.StateFlag.RELEASED
),
key, key,
b'', b'',
) )
+9 -9
View File
@@ -134,15 +134,15 @@ class Controller:
self.hci_sink = None self.hci_sink = None
self.link = link self.link = link
self.central_connections: Dict[ self.central_connections: Dict[Address, Connection] = (
Address, Connection {}
] = {} # Connections where this controller is the central ) # Connections where this controller is the central
self.peripheral_connections: Dict[ self.peripheral_connections: Dict[Address, Connection] = (
Address, Connection {}
] = {} # Connections where this controller is the peripheral ) # Connections where this controller is the peripheral
self.classic_connections: Dict[ self.classic_connections: Dict[Address, Connection] = (
Address, Connection {}
] = {} # Connections in BR/EDR ) # Connections in BR/EDR
self.central_cis_links: Dict[int, CisLink] = {} # CIS links by handle self.central_cis_links: Dict[int, CisLink] = {} # CIS links by handle
self.peripheral_cis_links: Dict[int, CisLink] = {} # CIS links by handle self.peripheral_cis_links: Dict[int, CisLink] = {} # CIS links by handle
+197 -151
View File
@@ -17,12 +17,19 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations from __future__ import annotations
from enum import IntEnum from enum import IntEnum
import copy
import functools import functools
import json import json
import asyncio import asyncio
import logging import logging
import secrets import secrets
from contextlib import asynccontextmanager, AsyncExitStack, closing import sys
from contextlib import (
asynccontextmanager,
AsyncExitStack,
closing,
AbstractAsyncContextManager,
)
from dataclasses import dataclass, field from dataclasses import dataclass, field
from collections.abc import Iterable from collections.abc import Iterable
from typing import ( from typing import (
@@ -40,6 +47,7 @@ from typing import (
overload, overload,
TYPE_CHECKING, TYPE_CHECKING,
) )
from typing_extensions import Self
from pyee import EventEmitter from pyee import EventEmitter
@@ -276,12 +284,12 @@ class Advertisement:
data_bytes: bytes = b'' data_bytes: bytes = b''
# Constants # Constants
TX_POWER_NOT_AVAILABLE: ClassVar[ TX_POWER_NOT_AVAILABLE: ClassVar[int] = (
int HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
] = HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE )
RSSI_NOT_AVAILABLE: ClassVar[ RSSI_NOT_AVAILABLE: ClassVar[int] = (
int HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE
] = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE )
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.data = AdvertisingData.from_bytes(self.data_bytes) self.data = AdvertisingData.from_bytes(self.data_bytes)
@@ -558,7 +566,9 @@ class AdvertisingParameters:
) )
primary_advertising_interval_min: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL primary_advertising_interval_min: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
primary_advertising_interval_max: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL primary_advertising_interval_max: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
primary_advertising_channel_map: HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap = ( primary_advertising_channel_map: (
HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap
) = (
AdvertisingChannelMap.CHANNEL_37 AdvertisingChannelMap.CHANNEL_37
| AdvertisingChannelMap.CHANNEL_38 | AdvertisingChannelMap.CHANNEL_38
| AdvertisingChannelMap.CHANNEL_39 | AdvertisingChannelMap.CHANNEL_39
@@ -957,8 +967,9 @@ class ScoLink(CompositeEventEmitter):
acl_connection: Connection acl_connection: Connection
handle: int handle: int
link_type: int link_type: int
sink: Optional[Callable[[HCI_SynchronousDataPacket], Any]] = None
def __post_init__(self): def __post_init__(self) -> None:
super().__init__() super().__init__()
async def disconnect( async def disconnect(
@@ -980,8 +991,9 @@ class CisLink(CompositeEventEmitter):
cis_id: int # CIS ID assigned by Central device cis_id: int # CIS ID assigned by Central device
cig_id: int # CIG ID assigned by Central device cig_id: int # CIG ID assigned by Central device
state: State = State.PENDING state: State = State.PENDING
sink: Optional[Callable[[HCI_IsoDataPacket], Any]] = None
def __post_init__(self): def __post_init__(self) -> None:
super().__init__() super().__init__()
async def disconnect( async def disconnect(
@@ -1138,14 +1150,12 @@ class Connection(CompositeEventEmitter):
@overload @overload
async def create_l2cap_channel( async def create_l2cap_channel(
self, spec: l2cap.ClassicChannelSpec self, spec: l2cap.ClassicChannelSpec
) -> l2cap.ClassicChannel: ) -> l2cap.ClassicChannel: ...
...
@overload @overload
async def create_l2cap_channel( async def create_l2cap_channel(
self, spec: l2cap.LeCreditBasedChannelSpec self, spec: l2cap.LeCreditBasedChannelSpec
) -> l2cap.LeCreditBasedChannel: ) -> l2cap.LeCreditBasedChannel: ...
...
async def create_l2cap_channel( async def create_l2cap_channel(
self, spec: Union[l2cap.ClassicChannelSpec, l2cap.LeCreditBasedChannelSpec] self, spec: Union[l2cap.ClassicChannelSpec, l2cap.LeCreditBasedChannelSpec]
@@ -1252,75 +1262,47 @@ class Connection(CompositeEventEmitter):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@dataclass
class DeviceConfiguration: class DeviceConfiguration:
def __init__(self) -> None: # Setup defaults
# Setup defaults name: str = DEVICE_DEFAULT_NAME
self.name = DEVICE_DEFAULT_NAME address: Address = Address(DEVICE_DEFAULT_ADDRESS)
self.address = Address(DEVICE_DEFAULT_ADDRESS) class_of_device: int = DEVICE_DEFAULT_CLASS_OF_DEVICE
self.class_of_device = DEVICE_DEFAULT_CLASS_OF_DEVICE scan_response_data: bytes = DEVICE_DEFAULT_SCAN_RESPONSE_DATA
self.scan_response_data = DEVICE_DEFAULT_SCAN_RESPONSE_DATA advertising_interval_min: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
self.advertising_interval_min = DEVICE_DEFAULT_ADVERTISING_INTERVAL advertising_interval_max: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
self.advertising_interval_max = DEVICE_DEFAULT_ADVERTISING_INTERVAL le_enabled: bool = True
self.le_enabled = True # LE host enable 2nd parameter
# LE host enable 2nd parameter le_simultaneous_enabled: bool = False
self.le_simultaneous_enabled = False classic_enabled: bool = False
self.classic_enabled = False classic_sc_enabled: bool = True
self.classic_sc_enabled = True classic_ssp_enabled: bool = True
self.classic_ssp_enabled = True classic_smp_enabled: bool = True
self.classic_smp_enabled = True classic_accept_any: bool = True
self.classic_accept_any = True connectable: bool = True
self.connectable = True discoverable: bool = True
self.discoverable = True advertising_data: bytes = bytes(
self.advertising_data = bytes( AdvertisingData(
AdvertisingData( [(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(DEVICE_DEFAULT_NAME, 'utf-8'))]
[(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))]
)
) )
self.irk = bytes(16) # This really must be changed for any level of security )
self.keystore = None irk: bytes = bytes(16) # This really must be changed for any level of security
keystore: Optional[str] = None
address_resolution_offload: bool = False
cis_enabled: bool = False
def __post_init__(self) -> None:
self.gatt_services: List[Dict[str, Any]] = [] self.gatt_services: List[Dict[str, Any]] = []
self.address_resolution_offload = False
self.cis_enabled = False
def load_from_dict(self, config: Dict[str, Any]) -> None: def load_from_dict(self, config: Dict[str, Any]) -> None:
config = copy.deepcopy(config)
# Load simple properties # Load simple properties
self.name = config.get('name', self.name) if address := config.pop('address', None):
if address := config.get('address', None):
self.address = Address(address) self.address = Address(address)
self.class_of_device = config.get('class_of_device', self.class_of_device)
self.advertising_interval_min = config.get(
'advertising_interval', self.advertising_interval_min
)
self.advertising_interval_max = self.advertising_interval_min
self.keystore = config.get('keystore')
self.le_enabled = config.get('le_enabled', self.le_enabled)
self.le_simultaneous_enabled = config.get(
'le_simultaneous_enabled', self.le_simultaneous_enabled
)
self.classic_enabled = config.get('classic_enabled', self.classic_enabled)
self.classic_sc_enabled = config.get(
'classic_sc_enabled', self.classic_sc_enabled
)
self.classic_ssp_enabled = config.get(
'classic_ssp_enabled', self.classic_ssp_enabled
)
self.classic_smp_enabled = config.get(
'classic_smp_enabled', self.classic_smp_enabled
)
self.classic_accept_any = config.get(
'classic_accept_any', self.classic_accept_any
)
self.connectable = config.get('connectable', self.connectable)
self.discoverable = config.get('discoverable', self.discoverable)
self.gatt_services = config.get('gatt_services', self.gatt_services)
self.address_resolution_offload = config.get(
'address_resolution_offload', self.address_resolution_offload
)
self.cis_enabled = config.get('cis_enabled', self.cis_enabled)
# Load or synthesize an IRK # Load or synthesize an IRK
irk = config.get('irk') if irk := config.pop('irk', None):
if irk:
self.irk = bytes.fromhex(irk) self.irk = bytes.fromhex(irk)
elif self.address != Address(DEVICE_DEFAULT_ADDRESS): elif self.address != Address(DEVICE_DEFAULT_ADDRESS):
# Construct an IRK from the address bytes # Construct an IRK from the address bytes
@@ -1332,21 +1314,53 @@ class DeviceConfiguration:
# Fallback - when both IRK and address are not set, randomly generate an IRK. # Fallback - when both IRK and address are not set, randomly generate an IRK.
self.irk = secrets.token_bytes(16) self.irk = secrets.token_bytes(16)
if (name := config.pop('name', None)) is not None:
self.name = name
# Load advertising data # Load advertising data
advertising_data = config.get('advertising_data') if advertising_data := config.pop('advertising_data', None):
if advertising_data:
self.advertising_data = bytes.fromhex(advertising_data) self.advertising_data = bytes.fromhex(advertising_data)
elif config.get('name') is not None: elif name is not None:
self.advertising_data = bytes( self.advertising_data = bytes(
AdvertisingData( AdvertisingData(
[(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))] [(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))]
) )
) )
def load_from_file(self, filename): # Load advertising interval (for backward compatibility)
if advertising_interval := config.pop('advertising_interval', None):
self.advertising_interval_min = advertising_interval
self.advertising_interval_max = advertising_interval
if (
'advertising_interval_max' in config
or 'advertising_interval_min' in config
):
logger.warning(
'Trying to set both advertising_interval and '
'advertising_interval_min/max, advertising_interval will be'
'ignored.'
)
# Load data in primitive types.
for key, value in config.items():
setattr(self, key, value)
def load_from_file(self, filename: str) -> None:
with open(filename, 'r', encoding='utf-8') as file: with open(filename, 'r', encoding='utf-8') as file:
self.load_from_dict(json.load(file)) self.load_from_dict(json.load(file))
@classmethod
def from_file(cls: Type[Self], filename: str) -> Self:
config = cls()
config.load_from_file(filename)
return config
@classmethod
def from_dict(cls: Type[Self], config: Dict[str, Any]) -> Self:
device_config = cls()
device_config.load_from_dict(config)
return device_config
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Decorators used with the following Device class # Decorators used with the following Device class
@@ -1470,8 +1484,7 @@ class Device(CompositeEventEmitter):
@classmethod @classmethod
def from_config_file(cls, filename: str) -> Device: def from_config_file(cls, filename: str) -> Device:
config = DeviceConfiguration() config = DeviceConfiguration.from_file(filename)
config.load_from_file(filename)
return cls(config=config) return cls(config=config)
@classmethod @classmethod
@@ -1488,8 +1501,7 @@ class Device(CompositeEventEmitter):
def from_config_file_with_hci( def from_config_file_with_hci(
cls, filename: str, hci_source: TransportSource, hci_sink: TransportSink cls, filename: str, hci_source: TransportSource, hci_sink: TransportSink
) -> Device: ) -> Device:
config = DeviceConfiguration() config = DeviceConfiguration.from_file(filename)
config.load_from_file(filename)
return cls.from_config_with_hci(config, hci_source, hci_sink) return cls.from_config_with_hci(config, hci_source, hci_sink)
def __init__( def __init__(
@@ -1529,6 +1541,12 @@ class Device(CompositeEventEmitter):
Address.ANY: [] Address.ANY: []
} # Futures, by BD address OR [Futures] for Address.ANY } # Futures, by BD address OR [Futures] for Address.ANY
# In Python <= 3.9 + Rust Runtime, asyncio.Lock cannot be properly initiated.
if sys.version_info >= (3, 10):
self._cis_lock = asyncio.Lock()
else:
self._cis_lock = AsyncExitStack()
# Own address type cache # Own address type cache
self.connect_own_address_type = None self.connect_own_address_type = None
@@ -1723,16 +1741,14 @@ class Device(CompositeEventEmitter):
self, self,
connection: Connection, connection: Connection,
spec: l2cap.ClassicChannelSpec, spec: l2cap.ClassicChannelSpec,
) -> l2cap.ClassicChannel: ) -> l2cap.ClassicChannel: ...
...
@overload @overload
async def create_l2cap_channel( async def create_l2cap_channel(
self, self,
connection: Connection, connection: Connection,
spec: l2cap.LeCreditBasedChannelSpec, spec: l2cap.LeCreditBasedChannelSpec,
) -> l2cap.LeCreditBasedChannel: ) -> l2cap.LeCreditBasedChannel: ...
...
async def create_l2cap_channel( async def create_l2cap_channel(
self, self,
@@ -1753,16 +1769,14 @@ class Device(CompositeEventEmitter):
self, self,
spec: l2cap.ClassicChannelSpec, spec: l2cap.ClassicChannelSpec,
handler: Optional[Callable[[l2cap.ClassicChannel], Any]] = None, handler: Optional[Callable[[l2cap.ClassicChannel], Any]] = None,
) -> l2cap.ClassicChannelServer: ) -> l2cap.ClassicChannelServer: ...
...
@overload @overload
def create_l2cap_server( def create_l2cap_server(
self, self,
spec: l2cap.LeCreditBasedChannelSpec, spec: l2cap.LeCreditBasedChannelSpec,
handler: Optional[Callable[[l2cap.LeCreditBasedChannel], Any]] = None, handler: Optional[Callable[[l2cap.LeCreditBasedChannel], Any]] = None,
) -> l2cap.LeCreditBasedChannelServer: ) -> l2cap.LeCreditBasedChannelServer: ...
...
def create_l2cap_server( def create_l2cap_server(
self, self,
@@ -2065,7 +2079,9 @@ class Device(CompositeEventEmitter):
"""Stop legacy advertising.""" """Stop legacy advertising."""
# Disable advertising # Disable advertising
if self.legacy_advertising_set: if self.legacy_advertising_set:
await self.legacy_advertising_set.stop() if self.legacy_advertising_set.enabled:
await self.legacy_advertising_set.stop()
await self.legacy_advertising_set.remove()
self.legacy_advertising_set = None self.legacy_advertising_set = None
elif self.legacy_advertiser: elif self.legacy_advertiser:
await self.legacy_advertiser.stop() await self.legacy_advertiser.stop()
@@ -2110,6 +2126,20 @@ class Device(CompositeEventEmitter):
Returns: Returns:
An AdvertisingSet instance. An AdvertisingSet instance.
""" """
# Instantiate default values
if advertising_parameters is None:
advertising_parameters = AdvertisingParameters()
if (
not advertising_parameters.advertising_event_properties.is_legacy
and advertising_data
and scan_response_data
):
raise ValueError(
"Extended advertisements can't have both data and scan \
response data"
)
# Allocate a new handle # Allocate a new handle
try: try:
advertising_handle = next( advertising_handle = next(
@@ -2123,10 +2153,6 @@ class Device(CompositeEventEmitter):
except StopIteration as exc: except StopIteration as exc:
raise RuntimeError("all valid advertising handles already in use") from exc raise RuntimeError("all valid advertising handles already in use") from exc
# Instantiate default values
if advertising_parameters is None:
advertising_parameters = AdvertisingParameters()
# Use the device's random address if a random address is needed but none was # Use the device's random address if a random address is needed but none was
# provided. # provided.
if ( if (
@@ -2176,7 +2202,7 @@ class Device(CompositeEventEmitter):
# controller. # controller.
await self.send_command( await self.send_command(
HCI_LE_Remove_Advertising_Set_Command( HCI_LE_Remove_Advertising_Set_Command(
advertising_handle=advertising_data advertising_handle=advertising_handle
), ),
check_result=False, check_result=False,
) )
@@ -2207,9 +2233,6 @@ class Device(CompositeEventEmitter):
if self.legacy_advertiser: if self.legacy_advertiser:
return True return True
if self.legacy_advertising_set and self.legacy_advertising_set.enabled:
return True
return any( return any(
advertising_set.enabled advertising_set.enabled
for advertising_set in self.extended_advertising_sets.values() for advertising_set in self.extended_advertising_sets.values()
@@ -2223,7 +2246,7 @@ class Device(CompositeEventEmitter):
scan_window: int = DEVICE_DEFAULT_SCAN_WINDOW, # Scan window in ms scan_window: int = DEVICE_DEFAULT_SCAN_WINDOW, # Scan window in ms
own_address_type: int = OwnAddressType.RANDOM, own_address_type: int = OwnAddressType.RANDOM,
filter_duplicates: bool = False, filter_duplicates: bool = False,
scanning_phys: Tuple[int, int] = (HCI_LE_1M_PHY, HCI_LE_CODED_PHY), scanning_phys: List[int] = [HCI_LE_1M_PHY, HCI_LE_CODED_PHY],
) -> None: ) -> None:
# Check that the arguments are legal # Check that the arguments are legal
if scan_interval < scan_window: if scan_interval < scan_window:
@@ -3280,17 +3303,19 @@ class Device(CompositeEventEmitter):
handler = self.on( handler = self.on(
'remote_name', 'remote_name',
lambda address, remote_name: pending_name.set_result(remote_name) lambda address, remote_name: (
if address == peer_address pending_name.set_result(remote_name)
else None, if address == peer_address
else None
),
) )
failure_handler = self.on( failure_handler = self.on(
'remote_name_failure', 'remote_name_failure',
lambda address, error_code: pending_name.set_exception( lambda address, error_code: (
HCI_Error(error_code) pending_name.set_exception(HCI_Error(error_code))
) if address == peer_address
if address == peer_address else None
else None, ),
) )
try: try:
@@ -3395,49 +3420,71 @@ class Device(CompositeEventEmitter):
for cis_handle, _ in cis_acl_pairs for cis_handle, _ in cis_acl_pairs
} }
@watcher.on(self, 'cis_establishment')
def on_cis_establishment(cis_link: CisLink) -> None: def on_cis_establishment(cis_link: CisLink) -> None:
if pending_future := pending_cis_establishments.get(cis_link.handle): if pending_future := pending_cis_establishments.get(cis_link.handle):
pending_future.set_result(cis_link) pending_future.set_result(cis_link)
result = await self.send_command( def on_cis_establishment_failure(cis_handle: int, status: int) -> None:
if pending_future := pending_cis_establishments.get(cis_handle):
pending_future.set_exception(HCI_Error(status))
watcher.on(self, 'cis_establishment', on_cis_establishment)
watcher.on(self, 'cis_establishment_failure', on_cis_establishment_failure)
await self.send_command(
HCI_LE_Create_CIS_Command( HCI_LE_Create_CIS_Command(
cis_connection_handle=[p[0] for p in cis_acl_pairs], cis_connection_handle=[p[0] for p in cis_acl_pairs],
acl_connection_handle=[p[1] for p in cis_acl_pairs], acl_connection_handle=[p[1] for p in cis_acl_pairs],
), ),
check_result=True,
) )
if result.status != HCI_COMMAND_STATUS_PENDING:
logger.warning(
'HCI_LE_Create_CIS_Command failed: '
f'{HCI_Constant.error_name(result.status)}'
)
raise HCI_StatusError(result)
return await asyncio.gather(*pending_cis_establishments.values()) return await asyncio.gather(*pending_cis_establishments.values())
# [LE only] # [LE only]
@experimental('Only for testing.') @experimental('Only for testing.')
async def accept_cis_request(self, handle: int) -> CisLink: async def accept_cis_request(self, handle: int) -> CisLink:
result = await self.send_command( """[LE Only] Accepts an incoming CIS request.
HCI_LE_Accept_CIS_Request_Command(connection_handle=handle),
)
if result.status != HCI_COMMAND_STATUS_PENDING:
logger.warning(
'HCI_LE_Accept_CIS_Request_Command failed: '
f'{HCI_Constant.error_name(result.status)}'
)
raise HCI_StatusError(result)
pending_cis_establishment = asyncio.get_running_loop().create_future() When the specified CIS handle is already created, this method returns the
existed CIS link object immediately.
with closing(EventWatcher()) as watcher: Args:
handle: CIS handle to accept.
@watcher.on(self, 'cis_establishment') Returns:
def on_cis_establishment(cis_link: CisLink) -> None: CIS link object on the given handle.
if cis_link.handle == handle: """
pending_cis_establishment.set_result(cis_link) if not (cis_link := self.cis_links.get(handle)):
raise InvalidStateError(f'No pending CIS request of handle {handle}')
return await pending_cis_establishment # There might be multiple ASE sharing a CIS channel.
# If one of them has accepted the request, the others should just leverage it.
async with self._cis_lock:
if cis_link.state == CisLink.State.ESTABLISHED:
return cis_link
with closing(EventWatcher()) as watcher:
pending_establishment = asyncio.get_running_loop().create_future()
def on_establishment() -> None:
pending_establishment.set_result(None)
def on_establishment_failure(status: int) -> None:
pending_establishment.set_exception(HCI_Error(status))
watcher.on(cis_link, 'establishment', on_establishment)
watcher.on(cis_link, 'establishment_failure', on_establishment_failure)
await self.send_command(
HCI_LE_Accept_CIS_Request_Command(connection_handle=handle),
check_result=True,
)
await pending_establishment
return cis_link
# Mypy believes this is reachable when context is an ExitStack.
raise InvalidStateError('Unreachable')
# [LE only] # [LE only]
@experimental('Only for testing.') @experimental('Only for testing.')
@@ -3446,15 +3493,10 @@ class Device(CompositeEventEmitter):
handle: int, handle: int,
reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR, reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
) -> None: ) -> None:
result = await self.send_command( await self.send_command(
HCI_LE_Reject_CIS_Request_Command(connection_handle=handle, reason=reason), HCI_LE_Reject_CIS_Request_Command(connection_handle=handle, reason=reason),
check_result=True,
) )
if result.status != HCI_COMMAND_STATUS_PENDING:
logger.warning(
'HCI_LE_Reject_CIS_Request_Command failed: '
f'{HCI_Constant.error_name(result.status)}'
)
raise HCI_StatusError(result)
async def get_remote_le_features(self, connection: Connection) -> LeFeatureMask: async def get_remote_le_features(self, connection: Connection) -> LeFeatureMask:
"""[LE Only] Reads remote LE supported features. """[LE Only] Reads remote LE supported features.
@@ -3466,19 +3508,25 @@ class Device(CompositeEventEmitter):
LE features supported by the remote device. LE features supported by the remote device.
""" """
with closing(EventWatcher()) as watcher: with closing(EventWatcher()) as watcher:
read_feature_future: asyncio.Future[ read_feature_future: asyncio.Future[LeFeatureMask] = (
LeFeatureMask asyncio.get_running_loop().create_future()
] = asyncio.get_running_loop().create_future() )
def on_le_remote_features(handle: int, features: int): def on_le_remote_features(handle: int, features: int):
if handle == connection.handle: if handle == connection.handle:
read_feature_future.set_result(LeFeatureMask(features)) read_feature_future.set_result(LeFeatureMask(features))
def on_failure(handle: int, status: int):
if handle == connection.handle:
read_feature_future.set_exception(HCI_Error(status))
watcher.on(self.host, 'le_remote_features', on_le_remote_features) watcher.on(self.host, 'le_remote_features', on_le_remote_features)
watcher.on(self.host, 'le_remote_features_failure', on_failure)
await self.send_command( await self.send_command(
HCI_LE_Read_Remote_Features_Command( HCI_LE_Read_Remote_Features_Command(
connection_handle=connection.handle connection_handle=connection.handle
), ),
check_result=True,
) )
return await read_feature_future return await read_feature_future
@@ -3541,11 +3589,9 @@ class Device(CompositeEventEmitter):
connection_handle, connection_handle,
number_of_completed_extended_advertising_events, number_of_completed_extended_advertising_events,
): ):
# Legacy advertising set is also one of extended advertising sets.
if not ( if not (
advertising_set := ( advertising_set := self.extended_advertising_sets.get(advertising_handle)
self.extended_advertising_sets.get(advertising_handle)
or self.legacy_advertising_set
)
): ):
logger.warning(f'advertising set {advertising_handle} not found') logger.warning(f'advertising set {advertising_handle} not found')
return return
@@ -4102,8 +4148,8 @@ class Device(CompositeEventEmitter):
@host_event_handler @host_event_handler
@experimental('Only for testing') @experimental('Only for testing')
def on_sco_packet(self, sco_handle: int, packet: HCI_SynchronousDataPacket) -> None: def on_sco_packet(self, sco_handle: int, packet: HCI_SynchronousDataPacket) -> None:
if sco_link := self.sco_links.get(sco_handle): if (sco_link := self.sco_links.get(sco_handle)) and sco_link.sink:
sco_link.emit('pdu', packet) sco_link.sink(packet)
# [LE only] # [LE only]
@host_event_handler @host_event_handler
@@ -4159,15 +4205,15 @@ class Device(CompositeEventEmitter):
def on_cis_establishment_failure(self, cis_handle: int, status: int) -> None: def on_cis_establishment_failure(self, cis_handle: int, status: int) -> None:
logger.debug(f'*** CIS Establishment Failure: cis=[0x{cis_handle:04X}] ***') logger.debug(f'*** CIS Establishment Failure: cis=[0x{cis_handle:04X}] ***')
if cis_link := self.cis_links.pop(cis_handle): if cis_link := self.cis_links.pop(cis_handle):
cis_link.emit('establishment_failure') cis_link.emit('establishment_failure', status)
self.emit('cis_establishment_failure', cis_handle, status) self.emit('cis_establishment_failure', cis_handle, status)
# [LE only] # [LE only]
@host_event_handler @host_event_handler
@experimental('Only for testing') @experimental('Only for testing')
def on_iso_packet(self, handle: int, packet: HCI_IsoDataPacket) -> None: def on_iso_packet(self, handle: int, packet: HCI_IsoDataPacket) -> None:
if cis_link := self.cis_links.get(handle): if (cis_link := self.cis_links.get(handle)) and cis_link.sink:
cis_link.emit('pdu', packet) cis_link.sink(packet)
@host_event_handler @host_event_handler
@with_connection_from_handle @with_connection_from_handle
+2 -2
View File
@@ -25,7 +25,7 @@ import pathlib
import platform import platform
from typing import Dict, Iterable, Optional, Type, TYPE_CHECKING from typing import Dict, Iterable, Optional, Type, TYPE_CHECKING
from . import rtk from . import rtk, intel
from .common import Driver from .common import Driver
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -45,7 +45,7 @@ async def get_driver_for_host(host: Host) -> Optional[Driver]:
found. found.
If a "driver" HCI metadata entry is present, only that driver class will be probed. If a "driver" HCI metadata entry is present, only that driver class will be probed.
""" """
driver_classes: Dict[str, Type[Driver]] = {"rtk": rtk.Driver} driver_classes: Dict[str, Type[Driver]] = {"rtk": rtk.Driver, "intel": intel.Driver}
probe_list: Iterable[str] probe_list: Iterable[str]
if driver_name := host.hci_metadata.get("driver"): if driver_name := host.hci_metadata.get("driver"):
# Only probe a single driver # Only probe a single driver
+102
View File
@@ -0,0 +1,102 @@
# Copyright 2024 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import logging
from bumble.drivers import common
from bumble.hci import (
hci_vendor_command_op_code, # type: ignore
HCI_Command,
HCI_Reset_Command,
)
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constant
# -----------------------------------------------------------------------------
INTEL_USB_PRODUCTS = {
# Intel AX210
(0x8087, 0x0032),
# Intel BE200
(0x8087, 0x0036),
}
# -----------------------------------------------------------------------------
# HCI Commands
# -----------------------------------------------------------------------------
HCI_INTEL_DDC_CONFIG_WRITE_COMMAND = hci_vendor_command_op_code(0xFC8B) # type: ignore
HCI_INTEL_DDC_CONFIG_WRITE_PAYLOAD = [0x03, 0xE4, 0x02, 0x00]
HCI_Command.register_commands(globals())
@HCI_Command.command( # type: ignore
fields=[("params", "*")],
return_parameters_fields=[
("params", "*"),
],
)
class Hci_Intel_DDC_Config_Write_Command(HCI_Command):
pass
class Driver(common.Driver):
def __init__(self, host):
self.host = host
@staticmethod
def check(host):
driver = host.hci_metadata.get("driver")
if driver == "intel":
return True
vendor_id = host.hci_metadata.get("vendor_id")
product_id = host.hci_metadata.get("product_id")
if vendor_id is None or product_id is None:
logger.debug("USB metadata not sufficient")
return False
if (vendor_id, product_id) not in INTEL_USB_PRODUCTS:
logger.debug(
f"USB device ({vendor_id:04X}, {product_id:04X}) " "not in known list"
)
return False
return True
@classmethod
async def for_host(cls, host, force=False): # type: ignore
# Only instantiate this driver if explicitly selected
if not force and not cls.check(host):
return None
return cls(host)
async def init_controller(self):
self.host.ready = True
await self.host.send_command(HCI_Reset_Command(), check_result=True)
await self.host.send_command(
Hci_Intel_DDC_Config_Write_Command(
params=HCI_INTEL_DDC_CONFIG_WRITE_PAYLOAD
)
)
+1
View File
@@ -36,6 +36,7 @@ logger = logging.getLogger(__name__)
# Classes # Classes
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class GenericAccessService(Service): class GenericAccessService(Service):
def __init__(self, device_name, appearance=(0, 0)): def __init__(self, device_name, appearance=(0, 0)):
+8 -6
View File
@@ -342,9 +342,11 @@ class Service(Attribute):
uuid = UUID(uuid) uuid = UUID(uuid)
super().__init__( super().__init__(
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE (
if primary GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE
else GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE, if primary
else GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE
),
Attribute.READABLE, Attribute.READABLE,
uuid.to_pdu_bytes(), uuid.to_pdu_bytes(),
) )
@@ -560,9 +562,9 @@ class CharacteristicAdapter:
def __init__(self, characteristic: Union[Characteristic, AttributeProxy]): def __init__(self, characteristic: Union[Characteristic, AttributeProxy]):
self.wrapped_characteristic = characteristic self.wrapped_characteristic = characteristic
self.subscribers: Dict[ self.subscribers: Dict[Callable, Callable] = (
Callable, Callable {}
] = {} # Map from subscriber to proxy subscriber ) # Map from subscriber to proxy subscriber
if isinstance(characteristic, Characteristic): if isinstance(characteristic, Characteristic):
self.read_value = self.read_encoded_value self.read_value = self.read_encoded_value
+17 -3
View File
@@ -90,6 +90,22 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Utils
# -----------------------------------------------------------------------------
def show_services(services: Iterable[ServiceProxy]) -> None:
for service in services:
print(color(str(service), 'cyan'))
for characteristic in service.characteristics:
print(color(' ' + str(characteristic), 'magenta'))
for descriptor in characteristic.descriptors:
print(color(' ' + str(descriptor), 'green'))
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Proxies # Proxies
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -352,9 +368,7 @@ class Client:
if c.uuid == uuid if c.uuid == uuid
] ]
def get_attribute_grouping( def get_attribute_grouping(self, attribute_handle: int) -> Optional[
self, attribute_handle: int
) -> Optional[
Union[ Union[
ServiceProxy, ServiceProxy,
Tuple[ServiceProxy, CharacteristicProxy], Tuple[ServiceProxy, CharacteristicProxy],
+3 -3
View File
@@ -445,9 +445,9 @@ class Server(EventEmitter):
assert self.pending_confirmations[connection.handle] is None assert self.pending_confirmations[connection.handle] is None
# Create a future value to hold the eventual response # Create a future value to hold the eventual response
pending_confirmation = self.pending_confirmations[ pending_confirmation = self.pending_confirmations[connection.handle] = (
connection.handle asyncio.get_running_loop().create_future()
] = asyncio.get_running_loop().create_future() )
try: try:
self.send_gatt_pdu(connection.handle, indication.to_bytes()) self.send_gatt_pdu(connection.handle, indication.to_bytes())
+335 -565
View File
@@ -23,7 +23,7 @@ import functools
import logging import logging
import secrets import secrets
import struct import struct
from typing import Any, Callable, Dict, Iterable, List, Optional, Type, Union from typing import Any, Callable, Dict, Iterable, List, Optional, Type, Union, ClassVar
from bumble import crypto from bumble import crypto
from .colors import color from .colors import color
@@ -790,538 +790,317 @@ HCI_RANDOM_DEVICE_ADDRESS_TYPE = 0x01
HCI_PUBLIC_IDENTITY_ADDRESS_TYPE = 0x02 HCI_PUBLIC_IDENTITY_ADDRESS_TYPE = 0x02
HCI_RANDOM_IDENTITY_ADDRESS_TYPE = 0x03 HCI_RANDOM_IDENTITY_ADDRESS_TYPE = 0x03
# Supported Commands Flags # Supported Commands Masks
# See Bluetooth spec @ 6.27 SUPPORTED COMMANDS # See Bluetooth spec @ 6.27 SUPPORTED COMMANDS
HCI_SUPPORTED_COMMANDS_FLAGS = ( HCI_SUPPORTED_COMMANDS_MASKS = {
# Octet 0 HCI_INQUIRY_COMMAND : 1 << (0*8+0),
( HCI_INQUIRY_CANCEL_COMMAND : 1 << (0*8+1),
HCI_INQUIRY_COMMAND, HCI_PERIODIC_INQUIRY_MODE_COMMAND : 1 << (0*8+2),
HCI_INQUIRY_CANCEL_COMMAND, HCI_EXIT_PERIODIC_INQUIRY_MODE_COMMAND : 1 << (0*8+3),
HCI_PERIODIC_INQUIRY_MODE_COMMAND, HCI_CREATE_CONNECTION_COMMAND : 1 << (0*8+4),
HCI_EXIT_PERIODIC_INQUIRY_MODE_COMMAND, HCI_DISCONNECT_COMMAND : 1 << (0*8+5),
HCI_CREATE_CONNECTION_COMMAND, HCI_CREATE_CONNECTION_CANCEL_COMMAND : 1 << (0*8+7),
HCI_DISCONNECT_COMMAND, HCI_ACCEPT_CONNECTION_REQUEST_COMMAND : 1 << (1*8+0),
None, HCI_REJECT_CONNECTION_REQUEST_COMMAND : 1 << (1*8+1),
HCI_CREATE_CONNECTION_CANCEL_COMMAND HCI_LINK_KEY_REQUEST_REPLY_COMMAND : 1 << (1*8+2),
), HCI_LINK_KEY_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (1*8+3),
# Octet 1 HCI_PIN_CODE_REQUEST_REPLY_COMMAND : 1 << (1*8+4),
( HCI_PIN_CODE_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (1*8+5),
HCI_ACCEPT_CONNECTION_REQUEST_COMMAND, HCI_CHANGE_CONNECTION_PACKET_TYPE_COMMAND : 1 << (1*8+6),
HCI_REJECT_CONNECTION_REQUEST_COMMAND, HCI_AUTHENTICATION_REQUESTED_COMMAND : 1 << (1*8+7),
HCI_LINK_KEY_REQUEST_REPLY_COMMAND, HCI_SET_CONNECTION_ENCRYPTION_COMMAND : 1 << (2*8+0),
HCI_LINK_KEY_REQUEST_NEGATIVE_REPLY_COMMAND, HCI_CHANGE_CONNECTION_LINK_KEY_COMMAND : 1 << (2*8+1),
HCI_PIN_CODE_REQUEST_REPLY_COMMAND, HCI_LINK_KEY_SELECTION_COMMAND : 1 << (2*8+2),
HCI_PIN_CODE_REQUEST_NEGATIVE_REPLY_COMMAND, HCI_REMOTE_NAME_REQUEST_COMMAND : 1 << (2*8+3),
HCI_CHANGE_CONNECTION_PACKET_TYPE_COMMAND, HCI_REMOTE_NAME_REQUEST_CANCEL_COMMAND : 1 << (2*8+4),
HCI_AUTHENTICATION_REQUESTED_COMMAND HCI_READ_REMOTE_SUPPORTED_FEATURES_COMMAND : 1 << (2*8+5),
), HCI_READ_REMOTE_EXTENDED_FEATURES_COMMAND : 1 << (2*8+6),
# Octet 2 HCI_READ_REMOTE_VERSION_INFORMATION_COMMAND : 1 << (2*8+7),
( HCI_READ_CLOCK_OFFSET_COMMAND : 1 << (3*8+0),
HCI_SET_CONNECTION_ENCRYPTION_COMMAND, HCI_READ_LMP_HANDLE_COMMAND : 1 << (3*8+1),
HCI_CHANGE_CONNECTION_LINK_KEY_COMMAND, HCI_HOLD_MODE_COMMAND : 1 << (4*8+1),
HCI_LINK_KEY_SELECTION_COMMAND, HCI_SNIFF_MODE_COMMAND : 1 << (4*8+2),
HCI_REMOTE_NAME_REQUEST_COMMAND, HCI_EXIT_SNIFF_MODE_COMMAND : 1 << (4*8+3),
HCI_REMOTE_NAME_REQUEST_CANCEL_COMMAND, HCI_QOS_SETUP_COMMAND : 1 << (4*8+6),
HCI_READ_REMOTE_SUPPORTED_FEATURES_COMMAND, HCI_ROLE_DISCOVERY_COMMAND : 1 << (4*8+7),
HCI_READ_REMOTE_EXTENDED_FEATURES_COMMAND, HCI_SWITCH_ROLE_COMMAND : 1 << (5*8+0),
HCI_READ_REMOTE_VERSION_INFORMATION_COMMAND HCI_READ_LINK_POLICY_SETTINGS_COMMAND : 1 << (5*8+1),
), HCI_WRITE_LINK_POLICY_SETTINGS_COMMAND : 1 << (5*8+2),
# Octet 3 HCI_READ_DEFAULT_LINK_POLICY_SETTINGS_COMMAND : 1 << (5*8+3),
( HCI_WRITE_DEFAULT_LINK_POLICY_SETTINGS_COMMAND : 1 << (5*8+4),
HCI_READ_CLOCK_OFFSET_COMMAND, HCI_FLOW_SPECIFICATION_COMMAND : 1 << (5*8+5),
HCI_READ_LMP_HANDLE_COMMAND, HCI_SET_EVENT_MASK_COMMAND : 1 << (5*8+6),
None, HCI_RESET_COMMAND : 1 << (5*8+7),
None, HCI_SET_EVENT_FILTER_COMMAND : 1 << (6*8+0),
None, HCI_FLUSH_COMMAND : 1 << (6*8+1),
None, HCI_READ_PIN_TYPE_COMMAND : 1 << (6*8+2),
None, HCI_WRITE_PIN_TYPE_COMMAND : 1 << (6*8+3),
None HCI_READ_STORED_LINK_KEY_COMMAND : 1 << (6*8+5),
), HCI_WRITE_STORED_LINK_KEY_COMMAND : 1 << (6*8+6),
# Octet 4 HCI_DELETE_STORED_LINK_KEY_COMMAND : 1 << (6*8+7),
( HCI_WRITE_LOCAL_NAME_COMMAND : 1 << (7*8+0),
None, HCI_READ_LOCAL_NAME_COMMAND : 1 << (7*8+1),
HCI_HOLD_MODE_COMMAND, HCI_READ_CONNECTION_ACCEPT_TIMEOUT_COMMAND : 1 << (7*8+2),
HCI_SNIFF_MODE_COMMAND, HCI_WRITE_CONNECTION_ACCEPT_TIMEOUT_COMMAND : 1 << (7*8+3),
HCI_EXIT_SNIFF_MODE_COMMAND, HCI_READ_PAGE_TIMEOUT_COMMAND : 1 << (7*8+4),
None, HCI_WRITE_PAGE_TIMEOUT_COMMAND : 1 << (7*8+5),
None, HCI_READ_SCAN_ENABLE_COMMAND : 1 << (7*8+6),
HCI_QOS_SETUP_COMMAND, HCI_WRITE_SCAN_ENABLE_COMMAND : 1 << (7*8+7),
HCI_ROLE_DISCOVERY_COMMAND HCI_READ_PAGE_SCAN_ACTIVITY_COMMAND : 1 << (8*8+0),
), HCI_WRITE_PAGE_SCAN_ACTIVITY_COMMAND : 1 << (8*8+1),
# Octet 5 HCI_READ_INQUIRY_SCAN_ACTIVITY_COMMAND : 1 << (8*8+2),
( HCI_WRITE_INQUIRY_SCAN_ACTIVITY_COMMAND : 1 << (8*8+3),
HCI_SWITCH_ROLE_COMMAND, HCI_READ_AUTHENTICATION_ENABLE_COMMAND : 1 << (8*8+4),
HCI_READ_LINK_POLICY_SETTINGS_COMMAND, HCI_WRITE_AUTHENTICATION_ENABLE_COMMAND : 1 << (8*8+5),
HCI_WRITE_LINK_POLICY_SETTINGS_COMMAND, HCI_READ_CLASS_OF_DEVICE_COMMAND : 1 << (9*8+0),
HCI_READ_DEFAULT_LINK_POLICY_SETTINGS_COMMAND, HCI_WRITE_CLASS_OF_DEVICE_COMMAND : 1 << (9*8+1),
HCI_WRITE_DEFAULT_LINK_POLICY_SETTINGS_COMMAND, HCI_READ_VOICE_SETTING_COMMAND : 1 << (9*8+2),
HCI_FLOW_SPECIFICATION_COMMAND, HCI_WRITE_VOICE_SETTING_COMMAND : 1 << (9*8+3),
HCI_SET_EVENT_MASK_COMMAND, HCI_READ_AUTOMATIC_FLUSH_TIMEOUT_COMMAND : 1 << (9*8+4),
HCI_RESET_COMMAND HCI_WRITE_AUTOMATIC_FLUSH_TIMEOUT_COMMAND : 1 << (9*8+5),
), HCI_READ_NUM_BROADCAST_RETRANSMISSIONS_COMMAND : 1 << (9*8+6),
# Octet 6 HCI_WRITE_NUM_BROADCAST_RETRANSMISSIONS_COMMAND : 1 << (9*8+7),
( HCI_READ_HOLD_MODE_ACTIVITY_COMMAND : 1 << (10*8+0),
HCI_SET_EVENT_FILTER_COMMAND, HCI_WRITE_HOLD_MODE_ACTIVITY_COMMAND : 1 << (10*8+1),
HCI_FLUSH_COMMAND, HCI_READ_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (10*8+2),
HCI_READ_PIN_TYPE_COMMAND, HCI_READ_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND : 1 << (10*8+3),
HCI_WRITE_PIN_TYPE_COMMAND, HCI_WRITE_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND : 1 << (10*8+4),
None, HCI_SET_CONTROLLER_TO_HOST_FLOW_CONTROL_COMMAND : 1 << (10*8+5),
HCI_READ_STORED_LINK_KEY_COMMAND, HCI_HOST_BUFFER_SIZE_COMMAND : 1 << (10*8+6),
HCI_WRITE_STORED_LINK_KEY_COMMAND, HCI_HOST_NUMBER_OF_COMPLETED_PACKETS_COMMAND : 1 << (10*8+7),
HCI_DELETE_STORED_LINK_KEY_COMMAND HCI_READ_LINK_SUPERVISION_TIMEOUT_COMMAND : 1 << (11*8+0),
), HCI_WRITE_LINK_SUPERVISION_TIMEOUT_COMMAND : 1 << (11*8+1),
# Octet 7 HCI_READ_NUMBER_OF_SUPPORTED_IAC_COMMAND : 1 << (11*8+2),
( HCI_READ_CURRENT_IAC_LAP_COMMAND : 1 << (11*8+3),
HCI_WRITE_LOCAL_NAME_COMMAND, HCI_WRITE_CURRENT_IAC_LAP_COMMAND : 1 << (11*8+4),
HCI_READ_LOCAL_NAME_COMMAND, HCI_SET_AFH_HOST_CHANNEL_CLASSIFICATION_COMMAND : 1 << (12*8+1),
HCI_READ_CONNECTION_ACCEPT_TIMEOUT_COMMAND, HCI_READ_INQUIRY_SCAN_TYPE_COMMAND : 1 << (12*8+4),
HCI_WRITE_CONNECTION_ACCEPT_TIMEOUT_COMMAND, HCI_WRITE_INQUIRY_SCAN_TYPE_COMMAND : 1 << (12*8+5),
HCI_READ_PAGE_TIMEOUT_COMMAND, HCI_READ_INQUIRY_MODE_COMMAND : 1 << (12*8+6),
HCI_WRITE_PAGE_TIMEOUT_COMMAND, HCI_WRITE_INQUIRY_MODE_COMMAND : 1 << (12*8+7),
HCI_READ_SCAN_ENABLE_COMMAND, HCI_READ_PAGE_SCAN_TYPE_COMMAND : 1 << (13*8+0),
HCI_WRITE_SCAN_ENABLE_COMMAND HCI_WRITE_PAGE_SCAN_TYPE_COMMAND : 1 << (13*8+1),
), HCI_READ_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND : 1 << (13*8+2),
# Octet 8 HCI_WRITE_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND : 1 << (13*8+3),
( HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND : 1 << (14*8+3),
HCI_READ_PAGE_SCAN_ACTIVITY_COMMAND, HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND : 1 << (14*8+5),
HCI_WRITE_PAGE_SCAN_ACTIVITY_COMMAND, HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND : 1 << (14*8+6),
HCI_READ_INQUIRY_SCAN_ACTIVITY_COMMAND, HCI_READ_BUFFER_SIZE_COMMAND : 1 << (14*8+7),
HCI_WRITE_INQUIRY_SCAN_ACTIVITY_COMMAND, HCI_READ_BD_ADDR_COMMAND : 1 << (15*8+1),
HCI_READ_AUTHENTICATION_ENABLE_COMMAND, HCI_READ_FAILED_CONTACT_COUNTER_COMMAND : 1 << (15*8+2),
HCI_WRITE_AUTHENTICATION_ENABLE_COMMAND, HCI_RESET_FAILED_CONTACT_COUNTER_COMMAND : 1 << (15*8+3),
None, HCI_READ_LINK_QUALITY_COMMAND : 1 << (15*8+4),
None HCI_READ_RSSI_COMMAND : 1 << (15*8+5),
), HCI_READ_AFH_CHANNEL_MAP_COMMAND : 1 << (15*8+6),
# Octet 9 HCI_READ_CLOCK_COMMAND : 1 << (15*8+7),
( HCI_READ_LOOPBACK_MODE_COMMAND : 1 << (16*8+0),
HCI_READ_CLASS_OF_DEVICE_COMMAND, HCI_WRITE_LOOPBACK_MODE_COMMAND : 1 << (16*8+1),
HCI_WRITE_CLASS_OF_DEVICE_COMMAND, HCI_ENABLE_DEVICE_UNDER_TEST_MODE_COMMAND : 1 << (16*8+2),
HCI_READ_VOICE_SETTING_COMMAND, HCI_SETUP_SYNCHRONOUS_CONNECTION_COMMAND : 1 << (16*8+3),
HCI_WRITE_VOICE_SETTING_COMMAND, HCI_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND : 1 << (16*8+4),
HCI_READ_AUTOMATIC_FLUSH_TIMEOUT_COMMAND, HCI_REJECT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND : 1 << (16*8+5),
HCI_WRITE_AUTOMATIC_FLUSH_TIMEOUT_COMMAND, HCI_READ_EXTENDED_INQUIRY_RESPONSE_COMMAND : 1 << (17*8+0),
HCI_READ_NUM_BROADCAST_RETRANSMISSIONS_COMMAND, HCI_WRITE_EXTENDED_INQUIRY_RESPONSE_COMMAND : 1 << (17*8+1),
HCI_WRITE_NUM_BROADCAST_RETRANSMISSIONS_COMMAND HCI_REFRESH_ENCRYPTION_KEY_COMMAND : 1 << (17*8+2),
), HCI_SNIFF_SUBRATING_COMMAND : 1 << (17*8+4),
# Octet 10 HCI_READ_SIMPLE_PAIRING_MODE_COMMAND : 1 << (17*8+5),
( HCI_WRITE_SIMPLE_PAIRING_MODE_COMMAND : 1 << (17*8+6),
HCI_READ_HOLD_MODE_ACTIVITY_COMMAND, HCI_READ_LOCAL_OOB_DATA_COMMAND : 1 << (17*8+7),
HCI_WRITE_HOLD_MODE_ACTIVITY_COMMAND, HCI_READ_INQUIRY_RESPONSE_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (18*8+0),
HCI_READ_TRANSMIT_POWER_LEVEL_COMMAND, HCI_WRITE_INQUIRY_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (18*8+1),
HCI_READ_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND, HCI_READ_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND : 1 << (18*8+2),
HCI_WRITE_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND, HCI_WRITE_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND : 1 << (18*8+3),
HCI_SET_CONTROLLER_TO_HOST_FLOW_CONTROL_COMMAND, HCI_IO_CAPABILITY_REQUEST_REPLY_COMMAND : 1 << (18*8+7),
HCI_HOST_BUFFER_SIZE_COMMAND, HCI_USER_CONFIRMATION_REQUEST_REPLY_COMMAND : 1 << (19*8+0),
HCI_HOST_NUMBER_OF_COMPLETED_PACKETS_COMMAND HCI_USER_CONFIRMATION_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (19*8+1),
), HCI_USER_PASSKEY_REQUEST_REPLY_COMMAND : 1 << (19*8+2),
# Octet 11 HCI_USER_PASSKEY_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (19*8+3),
( HCI_REMOTE_OOB_DATA_REQUEST_REPLY_COMMAND : 1 << (19*8+4),
HCI_READ_LINK_SUPERVISION_TIMEOUT_COMMAND, HCI_WRITE_SIMPLE_PAIRING_DEBUG_MODE_COMMAND : 1 << (19*8+5),
HCI_WRITE_LINK_SUPERVISION_TIMEOUT_COMMAND, HCI_ENHANCED_FLUSH_COMMAND : 1 << (19*8+6),
HCI_READ_NUMBER_OF_SUPPORTED_IAC_COMMAND, HCI_REMOTE_OOB_DATA_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (19*8+7),
HCI_READ_CURRENT_IAC_LAP_COMMAND, HCI_SEND_KEYPRESS_NOTIFICATION_COMMAND : 1 << (20*8+2),
HCI_WRITE_CURRENT_IAC_LAP_COMMAND, HCI_IO_CAPABILITY_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (20*8+3),
None, HCI_READ_ENCRYPTION_KEY_SIZE_COMMAND : 1 << (20*8+4),
None, HCI_SET_EVENT_MASK_PAGE_2_COMMAND : 1 << (22*8+2),
None HCI_READ_FLOW_CONTROL_MODE_COMMAND : 1 << (23*8+0),
), HCI_WRITE_FLOW_CONTROL_MODE_COMMAND : 1 << (23*8+1),
# Octet 12 HCI_READ_DATA_BLOCK_SIZE_COMMAND : 1 << (23*8+2),
( HCI_READ_ENHANCED_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (24*8+0),
None, HCI_READ_LE_HOST_SUPPORT_COMMAND : 1 << (24*8+5),
HCI_SET_AFH_HOST_CHANNEL_CLASSIFICATION_COMMAND, HCI_WRITE_LE_HOST_SUPPORT_COMMAND : 1 << (24*8+6),
None, HCI_LE_SET_EVENT_MASK_COMMAND : 1 << (25*8+0),
None, HCI_LE_READ_BUFFER_SIZE_COMMAND : 1 << (25*8+1),
HCI_READ_INQUIRY_SCAN_TYPE_COMMAND, HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND : 1 << (25*8+2),
HCI_WRITE_INQUIRY_SCAN_TYPE_COMMAND, HCI_LE_SET_RANDOM_ADDRESS_COMMAND : 1 << (25*8+4),
HCI_READ_INQUIRY_MODE_COMMAND, HCI_LE_SET_ADVERTISING_PARAMETERS_COMMAND : 1 << (25*8+5),
HCI_WRITE_INQUIRY_MODE_COMMAND HCI_LE_READ_ADVERTISING_PHYSICAL_CHANNEL_TX_POWER_COMMAND : 1 << (25*8+6),
), HCI_LE_SET_ADVERTISING_DATA_COMMAND : 1 << (25*8+7),
# Octet 13 HCI_LE_SET_SCAN_RESPONSE_DATA_COMMAND : 1 << (26*8+0),
( HCI_LE_SET_ADVERTISING_ENABLE_COMMAND : 1 << (26*8+1),
HCI_READ_PAGE_SCAN_TYPE_COMMAND, HCI_LE_SET_SCAN_PARAMETERS_COMMAND : 1 << (26*8+2),
HCI_WRITE_PAGE_SCAN_TYPE_COMMAND, HCI_LE_SET_SCAN_ENABLE_COMMAND : 1 << (26*8+3),
HCI_READ_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND, HCI_LE_CREATE_CONNECTION_COMMAND : 1 << (26*8+4),
HCI_WRITE_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND, HCI_LE_CREATE_CONNECTION_CANCEL_COMMAND : 1 << (26*8+5),
None, HCI_LE_READ_FILTER_ACCEPT_LIST_SIZE_COMMAND : 1 << (26*8+6),
None, HCI_LE_CLEAR_FILTER_ACCEPT_LIST_COMMAND : 1 << (26*8+7),
None, HCI_LE_ADD_DEVICE_TO_FILTER_ACCEPT_LIST_COMMAND : 1 << (27*8+0),
None, HCI_LE_REMOVE_DEVICE_FROM_FILTER_ACCEPT_LIST_COMMAND : 1 << (27*8+1),
), HCI_LE_CONNECTION_UPDATE_COMMAND : 1 << (27*8+2),
# Octet 14 HCI_LE_SET_HOST_CHANNEL_CLASSIFICATION_COMMAND : 1 << (27*8+3),
( HCI_LE_READ_CHANNEL_MAP_COMMAND : 1 << (27*8+4),
None, HCI_LE_READ_REMOTE_FEATURES_COMMAND : 1 << (27*8+5),
None, HCI_LE_ENCRYPT_COMMAND : 1 << (27*8+6),
None, HCI_LE_RAND_COMMAND : 1 << (27*8+7),
HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND, HCI_LE_ENABLE_ENCRYPTION_COMMAND : 1 << (28*8+0),
None, HCI_LE_LONG_TERM_KEY_REQUEST_REPLY_COMMAND : 1 << (28*8+1),
HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND, HCI_LE_LONG_TERM_KEY_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (28*8+2),
HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND, HCI_LE_READ_SUPPORTED_STATES_COMMAND : 1 << (28*8+3),
HCI_READ_BUFFER_SIZE_COMMAND HCI_LE_RECEIVER_TEST_COMMAND : 1 << (28*8+4),
), HCI_LE_TRANSMITTER_TEST_COMMAND : 1 << (28*8+5),
# Octet 15 HCI_LE_TEST_END_COMMAND : 1 << (28*8+6),
( HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND : 1 << (29*8+3),
None, HCI_ENHANCED_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND : 1 << (29*8+4),
HCI_READ_BD_ADDR_COMMAND, HCI_READ_LOCAL_SUPPORTED_CODECS_COMMAND : 1 << (29*8+5),
HCI_READ_FAILED_CONTACT_COUNTER_COMMAND, HCI_SET_MWS_CHANNEL_PARAMETERS_COMMAND : 1 << (29*8+6),
HCI_RESET_FAILED_CONTACT_COUNTER_COMMAND, HCI_SET_EXTERNAL_FRAME_CONFIGURATION_COMMAND : 1 << (29*8+7),
HCI_READ_LINK_QUALITY_COMMAND, HCI_SET_MWS_SIGNALING_COMMAND : 1 << (30*8+0),
HCI_READ_RSSI_COMMAND, HCI_SET_MWS_TRANSPORT_LAYER_COMMAND : 1 << (30*8+1),
HCI_READ_AFH_CHANNEL_MAP_COMMAND, HCI_SET_MWS_SCAN_FREQUENCY_TABLE_COMMAND : 1 << (30*8+2),
HCI_READ_CLOCK_COMMAND HCI_GET_MWS_TRANSPORT_LAYER_CONFIGURATION_COMMAND : 1 << (30*8+3),
), HCI_SET_MWS_PATTERN_CONFIGURATION_COMMAND : 1 << (30*8+4),
# Octet 16 HCI_SET_TRIGGERED_CLOCK_CAPTURE_COMMAND : 1 << (30*8+5),
( HCI_TRUNCATED_PAGE_COMMAND : 1 << (30*8+6),
HCI_READ_LOOPBACK_MODE_COMMAND, HCI_TRUNCATED_PAGE_CANCEL_COMMAND : 1 << (30*8+7),
HCI_WRITE_LOOPBACK_MODE_COMMAND, HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_COMMAND : 1 << (31*8+0),
HCI_ENABLE_DEVICE_UNDER_TEST_MODE_COMMAND, HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVE_COMMAND : 1 << (31*8+1),
HCI_SETUP_SYNCHRONOUS_CONNECTION_COMMAND, HCI_START_SYNCHRONIZATION_TRAIN_COMMAND : 1 << (31*8+2),
HCI_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND, HCI_RECEIVE_SYNCHRONIZATION_TRAIN_COMMAND : 1 << (31*8+3),
HCI_REJECT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND, HCI_SET_RESERVED_LT_ADDR_COMMAND : 1 << (31*8+4),
None, HCI_DELETE_RESERVED_LT_ADDR_COMMAND : 1 << (31*8+5),
None, HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_DATA_COMMAND : 1 << (31*8+6),
), HCI_READ_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND : 1 << (31*8+7),
# Octet 17 HCI_WRITE_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND : 1 << (32*8+0),
( HCI_REMOTE_OOB_EXTENDED_DATA_REQUEST_REPLY_COMMAND : 1 << (32*8+1),
HCI_READ_EXTENDED_INQUIRY_RESPONSE_COMMAND, HCI_READ_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND : 1 << (32*8+2),
HCI_WRITE_EXTENDED_INQUIRY_RESPONSE_COMMAND, HCI_WRITE_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND : 1 << (32*8+3),
HCI_REFRESH_ENCRYPTION_KEY_COMMAND, HCI_READ_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND : 1 << (32*8+4),
None, HCI_WRITE_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND : 1 << (32*8+5),
HCI_SNIFF_SUBRATING_COMMAND, HCI_READ_LOCAL_OOB_EXTENDED_DATA_COMMAND : 1 << (32*8+6),
HCI_READ_SIMPLE_PAIRING_MODE_COMMAND, HCI_WRITE_SECURE_CONNECTIONS_TEST_MODE_COMMAND : 1 << (32*8+7),
HCI_WRITE_SIMPLE_PAIRING_MODE_COMMAND, HCI_READ_EXTENDED_PAGE_TIMEOUT_COMMAND : 1 << (33*8+0),
HCI_READ_LOCAL_OOB_DATA_COMMAND HCI_WRITE_EXTENDED_PAGE_TIMEOUT_COMMAND : 1 << (33*8+1),
), HCI_READ_EXTENDED_INQUIRY_LENGTH_COMMAND : 1 << (33*8+2),
# Octet 18 HCI_WRITE_EXTENDED_INQUIRY_LENGTH_COMMAND : 1 << (33*8+3),
( HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_REPLY_COMMAND : 1 << (33*8+4),
HCI_READ_INQUIRY_RESPONSE_TRANSMIT_POWER_LEVEL_COMMAND, HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (33*8+5),
HCI_WRITE_INQUIRY_TRANSMIT_POWER_LEVEL_COMMAND, HCI_LE_SET_DATA_LENGTH_COMMAND : 1 << (33*8+6),
HCI_READ_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND, HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND : 1 << (33*8+7),
HCI_WRITE_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND, HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND : 1 << (34*8+0),
None, HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMMAND : 1 << (34*8+1),
None, HCI_LE_GENERATE_DHKEY_COMMAND : 1 << (34*8+2),
None, HCI_LE_ADD_DEVICE_TO_RESOLVING_LIST_COMMAND : 1 << (34*8+3),
HCI_IO_CAPABILITY_REQUEST_REPLY_COMMAND HCI_LE_REMOVE_DEVICE_FROM_RESOLVING_LIST_COMMAND : 1 << (34*8+4),
), HCI_LE_CLEAR_RESOLVING_LIST_COMMAND : 1 << (34*8+5),
# Octet 19 HCI_LE_READ_RESOLVING_LIST_SIZE_COMMAND : 1 << (34*8+6),
( HCI_LE_READ_PEER_RESOLVABLE_ADDRESS_COMMAND : 1 << (34*8+7),
HCI_USER_CONFIRMATION_REQUEST_REPLY_COMMAND, HCI_LE_READ_LOCAL_RESOLVABLE_ADDRESS_COMMAND : 1 << (35*8+0),
HCI_USER_CONFIRMATION_REQUEST_NEGATIVE_REPLY_COMMAND, HCI_LE_SET_ADDRESS_RESOLUTION_ENABLE_COMMAND : 1 << (35*8+1),
HCI_USER_PASSKEY_REQUEST_REPLY_COMMAND, HCI_LE_SET_RESOLVABLE_PRIVATE_ADDRESS_TIMEOUT_COMMAND : 1 << (35*8+2),
HCI_USER_PASSKEY_REQUEST_NEGATIVE_REPLY_COMMAND, HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND : 1 << (35*8+3),
HCI_REMOTE_OOB_DATA_REQUEST_REPLY_COMMAND, HCI_LE_READ_PHY_COMMAND : 1 << (35*8+4),
HCI_WRITE_SIMPLE_PAIRING_DEBUG_MODE_COMMAND, HCI_LE_SET_DEFAULT_PHY_COMMAND : 1 << (35*8+5),
HCI_ENHANCED_FLUSH_COMMAND, HCI_LE_SET_PHY_COMMAND : 1 << (35*8+6),
HCI_REMOTE_OOB_DATA_REQUEST_NEGATIVE_REPLY_COMMAND HCI_LE_RECEIVER_TEST_V2_COMMAND : 1 << (35*8+7),
), HCI_LE_TRANSMITTER_TEST_V2_COMMAND : 1 << (36*8+0),
# Octet 20 HCI_LE_SET_ADVERTISING_SET_RANDOM_ADDRESS_COMMAND : 1 << (36*8+1),
( HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_COMMAND : 1 << (36*8+2),
None, HCI_LE_SET_EXTENDED_ADVERTISING_DATA_COMMAND : 1 << (36*8+3),
None, HCI_LE_SET_EXTENDED_SCAN_RESPONSE_DATA_COMMAND : 1 << (36*8+4),
HCI_SEND_KEYPRESS_NOTIFICATION_COMMAND, HCI_LE_SET_EXTENDED_ADVERTISING_ENABLE_COMMAND : 1 << (36*8+5),
HCI_IO_CAPABILITY_REQUEST_NEGATIVE_REPLY_COMMAND, HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND : 1 << (36*8+6),
HCI_READ_ENCRYPTION_KEY_SIZE_COMMAND, HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND : 1 << (36*8+7),
None, HCI_LE_REMOVE_ADVERTISING_SET_COMMAND : 1 << (37*8+0),
None, HCI_LE_CLEAR_ADVERTISING_SETS_COMMAND : 1 << (37*8+1),
None, HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_COMMAND : 1 << (37*8+2),
), HCI_LE_SET_PERIODIC_ADVERTISING_DATA_COMMAND : 1 << (37*8+3),
# Octet 21 HCI_LE_SET_PERIODIC_ADVERTISING_ENABLE_COMMAND : 1 << (37*8+4),
( HCI_LE_SET_EXTENDED_SCAN_PARAMETERS_COMMAND : 1 << (37*8+5),
None, HCI_LE_SET_EXTENDED_SCAN_ENABLE_COMMAND : 1 << (37*8+6),
None, HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND : 1 << (37*8+7),
None, HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_COMMAND : 1 << (38*8+0),
None, HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_CANCEL_COMMAND : 1 << (38*8+1),
None, HCI_LE_PERIODIC_ADVERTISING_TERMINATE_SYNC_COMMAND : 1 << (38*8+2),
None, HCI_LE_ADD_DEVICE_TO_PERIODIC_ADVERTISER_LIST_COMMAND : 1 << (38*8+3),
None, HCI_LE_REMOVE_DEVICE_FROM_PERIODIC_ADVERTISER_LIST_COMMAND : 1 << (38*8+4),
None, HCI_LE_CLEAR_PERIODIC_ADVERTISER_LIST_COMMAND : 1 << (38*8+5),
), HCI_LE_READ_PERIODIC_ADVERTISER_LIST_SIZE_COMMAND : 1 << (38*8+6),
# Octet 22 HCI_LE_READ_TRANSMIT_POWER_COMMAND : 1 << (38*8+7),
( HCI_LE_READ_RF_PATH_COMPENSATION_COMMAND : 1 << (39*8+0),
None, HCI_LE_WRITE_RF_PATH_COMPENSATION_COMMAND : 1 << (39*8+1),
None, HCI_LE_SET_PRIVACY_MODE_COMMAND : 1 << (39*8+2),
HCI_SET_EVENT_MASK_PAGE_2_COMMAND, HCI_LE_RECEIVER_TEST_V3_COMMAND : 1 << (39*8+3),
None, HCI_LE_TRANSMITTER_TEST_V3_COMMAND : 1 << (39*8+4),
None, HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_PARAMETERS_COMMAND : 1 << (39*8+5),
None, HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_ENABLE_COMMAND : 1 << (39*8+6),
None, HCI_LE_SET_CONNECTIONLESS_IQ_SAMPLING_ENABLE_COMMAND : 1 << (39*8+7),
None, HCI_LE_SET_CONNECTION_CTE_RECEIVE_PARAMETERS_COMMAND : 1 << (40*8+0),
), HCI_LE_SET_CONNECTION_CTE_TRANSMIT_PARAMETERS_COMMAND : 1 << (40*8+1),
# Octet 23 HCI_LE_CONNECTION_CTE_REQUEST_ENABLE_COMMAND : 1 << (40*8+2),
( HCI_LE_CONNECTION_CTE_RESPONSE_ENABLE_COMMAND : 1 << (40*8+3),
HCI_READ_FLOW_CONTROL_MODE_COMMAND, HCI_LE_READ_ANTENNA_INFORMATION_COMMAND : 1 << (40*8+4),
HCI_WRITE_FLOW_CONTROL_MODE_COMMAND, HCI_LE_SET_PERIODIC_ADVERTISING_RECEIVE_ENABLE_COMMAND : 1 << (40*8+5),
HCI_READ_DATA_BLOCK_SIZE_COMMAND, HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_COMMAND : 1 << (40*8+6),
None, HCI_LE_PERIODIC_ADVERTISING_SET_INFO_TRANSFER_COMMAND : 1 << (40*8+7),
None, HCI_LE_SET_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND : 1 << (41*8+0),
None, HCI_LE_SET_DEFAULT_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND : 1 << (41*8+1),
None, HCI_LE_GENERATE_DHKEY_V2_COMMAND : 1 << (41*8+2),
None, HCI_READ_LOCAL_SIMPLE_PAIRING_OPTIONS_COMMAND : 1 << (41*8+3),
), HCI_LE_MODIFY_SLEEP_CLOCK_ACCURACY_COMMAND : 1 << (41*8+4),
# Octet 24 HCI_LE_READ_BUFFER_SIZE_V2_COMMAND : 1 << (41*8+5),
( HCI_LE_READ_ISO_TX_SYNC_COMMAND : 1 << (41*8+6),
HCI_READ_ENHANCED_TRANSMIT_POWER_LEVEL_COMMAND, HCI_LE_SET_CIG_PARAMETERS_COMMAND : 1 << (41*8+7),
None, HCI_LE_SET_CIG_PARAMETERS_TEST_COMMAND : 1 << (42*8+0),
None, HCI_LE_CREATE_CIS_COMMAND : 1 << (42*8+1),
None, HCI_LE_REMOVE_CIG_COMMAND : 1 << (42*8+2),
None, HCI_LE_ACCEPT_CIS_REQUEST_COMMAND : 1 << (42*8+3),
HCI_READ_LE_HOST_SUPPORT_COMMAND, HCI_LE_REJECT_CIS_REQUEST_COMMAND : 1 << (42*8+4),
HCI_WRITE_LE_HOST_SUPPORT_COMMAND, HCI_LE_CREATE_BIG_COMMAND : 1 << (42*8+5),
None, HCI_LE_CREATE_BIG_TEST_COMMAND : 1 << (42*8+6),
), HCI_LE_TERMINATE_BIG_COMMAND : 1 << (42*8+7),
# Octet 25 HCI_LE_BIG_CREATE_SYNC_COMMAND : 1 << (43*8+0),
( HCI_LE_BIG_TERMINATE_SYNC_COMMAND : 1 << (43*8+1),
HCI_LE_SET_EVENT_MASK_COMMAND, HCI_LE_REQUEST_PEER_SCA_COMMAND : 1 << (43*8+2),
HCI_LE_READ_BUFFER_SIZE_COMMAND, HCI_LE_SETUP_ISO_DATA_PATH_COMMAND : 1 << (43*8+3),
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND, HCI_LE_REMOVE_ISO_DATA_PATH_COMMAND : 1 << (43*8+4),
None, HCI_LE_ISO_TRANSMIT_TEST_COMMAND : 1 << (43*8+5),
HCI_LE_SET_RANDOM_ADDRESS_COMMAND, HCI_LE_ISO_RECEIVE_TEST_COMMAND : 1 << (43*8+6),
HCI_LE_SET_ADVERTISING_PARAMETERS_COMMAND, HCI_LE_ISO_READ_TEST_COUNTERS_COMMAND : 1 << (43*8+7),
HCI_LE_READ_ADVERTISING_PHYSICAL_CHANNEL_TX_POWER_COMMAND, HCI_LE_ISO_TEST_END_COMMAND : 1 << (44*8+0),
HCI_LE_SET_ADVERTISING_DATA_COMMAND, HCI_LE_SET_HOST_FEATURE_COMMAND : 1 << (44*8+1),
), HCI_LE_READ_ISO_LINK_QUALITY_COMMAND : 1 << (44*8+2),
# Octet 26 HCI_LE_ENHANCED_READ_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (44*8+3),
( HCI_LE_READ_REMOTE_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (44*8+4),
HCI_LE_SET_SCAN_RESPONSE_DATA_COMMAND, HCI_LE_SET_PATH_LOSS_REPORTING_PARAMETERS_COMMAND : 1 << (44*8+5),
HCI_LE_SET_ADVERTISING_ENABLE_COMMAND, HCI_LE_SET_PATH_LOSS_REPORTING_ENABLE_COMMAND : 1 << (44*8+6),
HCI_LE_SET_SCAN_PARAMETERS_COMMAND, HCI_LE_SET_TRANSMIT_POWER_REPORTING_ENABLE_COMMAND : 1 << (44*8+7),
HCI_LE_SET_SCAN_ENABLE_COMMAND, HCI_LE_TRANSMITTER_TEST_V4_COMMAND : 1 << (45*8+0),
HCI_LE_CREATE_CONNECTION_COMMAND, HCI_SET_ECOSYSTEM_BASE_INTERVAL_COMMAND : 1 << (45*8+1),
HCI_LE_CREATE_CONNECTION_CANCEL_COMMAND, HCI_READ_LOCAL_SUPPORTED_CODECS_V2_COMMAND : 1 << (45*8+2),
HCI_LE_READ_FILTER_ACCEPT_LIST_SIZE_COMMAND, HCI_READ_LOCAL_SUPPORTED_CODEC_CAPABILITIES_COMMAND : 1 << (45*8+3),
HCI_LE_CLEAR_FILTER_ACCEPT_LIST_COMMAND HCI_READ_LOCAL_SUPPORTED_CONTROLLER_DELAY_COMMAND : 1 << (45*8+4),
), HCI_CONFIGURE_DATA_PATH_COMMAND : 1 << (45*8+5),
# Octet 27 HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND : 1 << (45*8+6),
( HCI_SET_MIN_ENCRYPTION_KEY_SIZE_COMMAND : 1 << (45*8+7),
HCI_LE_ADD_DEVICE_TO_FILTER_ACCEPT_LIST_COMMAND, HCI_LE_SET_DEFAULT_SUBRATE_COMMAND : 1 << (46*8+0),
HCI_LE_REMOVE_DEVICE_FROM_FILTER_ACCEPT_LIST_COMMAND, HCI_LE_SUBRATE_REQUEST_COMMAND : 1 << (46*8+1),
HCI_LE_CONNECTION_UPDATE_COMMAND, HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_V2_COMMAND : 1 << (46*8+2),
HCI_LE_SET_HOST_CHANNEL_CLASSIFICATION_COMMAND, HCI_LE_SET_PERIODIC_ADVERTISING_SUBEVENT_DATA_COMMAND : 1 << (46*8+5),
HCI_LE_READ_CHANNEL_MAP_COMMAND, HCI_LE_SET_PERIODIC_ADVERTISING_RESPONSE_DATA_COMMAND : 1 << (46*8+6),
HCI_LE_READ_REMOTE_FEATURES_COMMAND, HCI_LE_SET_PERIODIC_SYNC_SUBEVENT_COMMAND : 1 << (46*8+7),
HCI_LE_ENCRYPT_COMMAND, HCI_LE_EXTENDED_CREATE_CONNECTION_V2_COMMAND : 1 << (47*8+0),
HCI_LE_RAND_COMMAND HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_V2_COMMAND : 1 << (47*8+1),
), }
# Octet 28
(
HCI_LE_ENABLE_ENCRYPTION_COMMAND,
HCI_LE_LONG_TERM_KEY_REQUEST_REPLY_COMMAND,
HCI_LE_LONG_TERM_KEY_REQUEST_NEGATIVE_REPLY_COMMAND,
HCI_LE_READ_SUPPORTED_STATES_COMMAND,
HCI_LE_RECEIVER_TEST_COMMAND,
HCI_LE_TRANSMITTER_TEST_COMMAND,
HCI_LE_TEST_END_COMMAND,
None,
),
# Octet 29
(
None,
None,
None,
HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND,
HCI_ENHANCED_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND,
HCI_READ_LOCAL_SUPPORTED_CODECS_COMMAND,
HCI_SET_MWS_CHANNEL_PARAMETERS_COMMAND,
HCI_SET_EXTERNAL_FRAME_CONFIGURATION_COMMAND
),
# Octet 30
(
HCI_SET_MWS_SIGNALING_COMMAND,
HCI_SET_MWS_TRANSPORT_LAYER_COMMAND,
HCI_SET_MWS_SCAN_FREQUENCY_TABLE_COMMAND,
HCI_GET_MWS_TRANSPORT_LAYER_CONFIGURATION_COMMAND,
HCI_SET_MWS_PATTERN_CONFIGURATION_COMMAND,
HCI_SET_TRIGGERED_CLOCK_CAPTURE_COMMAND,
HCI_TRUNCATED_PAGE_COMMAND,
HCI_TRUNCATED_PAGE_CANCEL_COMMAND
),
# Octet 31
(
HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_COMMAND,
HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVE_COMMAND,
HCI_START_SYNCHRONIZATION_TRAIN_COMMAND,
HCI_RECEIVE_SYNCHRONIZATION_TRAIN_COMMAND,
HCI_SET_RESERVED_LT_ADDR_COMMAND,
HCI_DELETE_RESERVED_LT_ADDR_COMMAND,
HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_DATA_COMMAND,
HCI_READ_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND
),
# Octet 32
(
HCI_WRITE_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND,
HCI_REMOTE_OOB_EXTENDED_DATA_REQUEST_REPLY_COMMAND,
HCI_READ_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND,
HCI_WRITE_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND,
HCI_READ_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND,
HCI_WRITE_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND,
HCI_READ_LOCAL_OOB_EXTENDED_DATA_COMMAND,
HCI_WRITE_SECURE_CONNECTIONS_TEST_MODE_COMMAND
),
# Octet 33
(
HCI_READ_EXTENDED_PAGE_TIMEOUT_COMMAND,
HCI_WRITE_EXTENDED_PAGE_TIMEOUT_COMMAND,
HCI_READ_EXTENDED_INQUIRY_LENGTH_COMMAND,
HCI_WRITE_EXTENDED_INQUIRY_LENGTH_COMMAND,
HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_REPLY_COMMAND,
HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_NEGATIVE_REPLY_COMMAND,
HCI_LE_SET_DATA_LENGTH_COMMAND,
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
),
# Octet 34
(
HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMMAND,
HCI_LE_GENERATE_DHKEY_COMMAND,
HCI_LE_ADD_DEVICE_TO_RESOLVING_LIST_COMMAND,
HCI_LE_REMOVE_DEVICE_FROM_RESOLVING_LIST_COMMAND,
HCI_LE_CLEAR_RESOLVING_LIST_COMMAND,
HCI_LE_READ_RESOLVING_LIST_SIZE_COMMAND,
HCI_LE_READ_PEER_RESOLVABLE_ADDRESS_COMMAND
),
# Octet 35
(
HCI_LE_READ_LOCAL_RESOLVABLE_ADDRESS_COMMAND,
HCI_LE_SET_ADDRESS_RESOLUTION_ENABLE_COMMAND,
HCI_LE_SET_RESOLVABLE_PRIVATE_ADDRESS_TIMEOUT_COMMAND,
HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND,
HCI_LE_READ_PHY_COMMAND,
HCI_LE_SET_DEFAULT_PHY_COMMAND,
HCI_LE_SET_PHY_COMMAND,
HCI_LE_RECEIVER_TEST_V2_COMMAND
),
# Octet 36
(
HCI_LE_TRANSMITTER_TEST_V2_COMMAND,
HCI_LE_SET_ADVERTISING_SET_RANDOM_ADDRESS_COMMAND,
HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_COMMAND,
HCI_LE_SET_EXTENDED_ADVERTISING_DATA_COMMAND,
HCI_LE_SET_EXTENDED_SCAN_RESPONSE_DATA_COMMAND,
HCI_LE_SET_EXTENDED_ADVERTISING_ENABLE_COMMAND,
HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND,
HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND,
),
# Octet 37
(
HCI_LE_REMOVE_ADVERTISING_SET_COMMAND,
HCI_LE_CLEAR_ADVERTISING_SETS_COMMAND,
HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_COMMAND,
HCI_LE_SET_PERIODIC_ADVERTISING_DATA_COMMAND,
HCI_LE_SET_PERIODIC_ADVERTISING_ENABLE_COMMAND,
HCI_LE_SET_EXTENDED_SCAN_PARAMETERS_COMMAND,
HCI_LE_SET_EXTENDED_SCAN_ENABLE_COMMAND,
HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND
),
# Octet 38
(
HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_COMMAND,
HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_CANCEL_COMMAND,
HCI_LE_PERIODIC_ADVERTISING_TERMINATE_SYNC_COMMAND,
HCI_LE_ADD_DEVICE_TO_PERIODIC_ADVERTISER_LIST_COMMAND,
HCI_LE_REMOVE_DEVICE_FROM_PERIODIC_ADVERTISER_LIST_COMMAND,
HCI_LE_CLEAR_PERIODIC_ADVERTISER_LIST_COMMAND,
HCI_LE_READ_PERIODIC_ADVERTISER_LIST_SIZE_COMMAND,
HCI_LE_READ_TRANSMIT_POWER_COMMAND
),
# Octet 39
(
HCI_LE_READ_RF_PATH_COMPENSATION_COMMAND,
HCI_LE_WRITE_RF_PATH_COMPENSATION_COMMAND,
HCI_LE_SET_PRIVACY_MODE_COMMAND,
HCI_LE_RECEIVER_TEST_V3_COMMAND,
HCI_LE_TRANSMITTER_TEST_V3_COMMAND,
HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_PARAMETERS_COMMAND,
HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_ENABLE_COMMAND,
HCI_LE_SET_CONNECTIONLESS_IQ_SAMPLING_ENABLE_COMMAND,
),
# Octet 40
(
HCI_LE_SET_CONNECTION_CTE_RECEIVE_PARAMETERS_COMMAND,
HCI_LE_SET_CONNECTION_CTE_TRANSMIT_PARAMETERS_COMMAND,
HCI_LE_CONNECTION_CTE_REQUEST_ENABLE_COMMAND,
HCI_LE_CONNECTION_CTE_RESPONSE_ENABLE_COMMAND,
HCI_LE_READ_ANTENNA_INFORMATION_COMMAND,
HCI_LE_SET_PERIODIC_ADVERTISING_RECEIVE_ENABLE_COMMAND,
HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_COMMAND,
HCI_LE_PERIODIC_ADVERTISING_SET_INFO_TRANSFER_COMMAND
),
# Octet 41
(
HCI_LE_SET_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND,
HCI_LE_SET_DEFAULT_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND,
HCI_LE_GENERATE_DHKEY_V2_COMMAND,
HCI_READ_LOCAL_SIMPLE_PAIRING_OPTIONS_COMMAND,
HCI_LE_MODIFY_SLEEP_CLOCK_ACCURACY_COMMAND,
HCI_LE_READ_BUFFER_SIZE_V2_COMMAND,
HCI_LE_READ_ISO_TX_SYNC_COMMAND,
HCI_LE_SET_CIG_PARAMETERS_COMMAND
),
# Octet 42
(
HCI_LE_SET_CIG_PARAMETERS_TEST_COMMAND,
HCI_LE_CREATE_CIS_COMMAND,
HCI_LE_REMOVE_CIG_COMMAND,
HCI_LE_ACCEPT_CIS_REQUEST_COMMAND,
HCI_LE_REJECT_CIS_REQUEST_COMMAND,
HCI_LE_CREATE_BIG_COMMAND,
HCI_LE_CREATE_BIG_TEST_COMMAND,
HCI_LE_TERMINATE_BIG_COMMAND,
),
# Octet 43
(
HCI_LE_BIG_CREATE_SYNC_COMMAND,
HCI_LE_BIG_TERMINATE_SYNC_COMMAND,
HCI_LE_REQUEST_PEER_SCA_COMMAND,
HCI_LE_SETUP_ISO_DATA_PATH_COMMAND,
HCI_LE_REMOVE_ISO_DATA_PATH_COMMAND,
HCI_LE_ISO_TRANSMIT_TEST_COMMAND,
HCI_LE_ISO_RECEIVE_TEST_COMMAND,
HCI_LE_ISO_READ_TEST_COUNTERS_COMMAND
),
# Octet 44
(
HCI_LE_ISO_TEST_END_COMMAND,
HCI_LE_SET_HOST_FEATURE_COMMAND,
HCI_LE_READ_ISO_LINK_QUALITY_COMMAND,
HCI_LE_ENHANCED_READ_TRANSMIT_POWER_LEVEL_COMMAND,
HCI_LE_READ_REMOTE_TRANSMIT_POWER_LEVEL_COMMAND,
HCI_LE_SET_PATH_LOSS_REPORTING_PARAMETERS_COMMAND,
HCI_LE_SET_PATH_LOSS_REPORTING_ENABLE_COMMAND,
HCI_LE_SET_TRANSMIT_POWER_REPORTING_ENABLE_COMMAND
),
# Octet 45
(
HCI_LE_TRANSMITTER_TEST_V4_COMMAND,
HCI_SET_ECOSYSTEM_BASE_INTERVAL_COMMAND,
HCI_READ_LOCAL_SUPPORTED_CODECS_V2_COMMAND,
HCI_READ_LOCAL_SUPPORTED_CODEC_CAPABILITIES_COMMAND,
HCI_READ_LOCAL_SUPPORTED_CONTROLLER_DELAY_COMMAND,
HCI_CONFIGURE_DATA_PATH_COMMAND,
HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND,
HCI_SET_MIN_ENCRYPTION_KEY_SIZE_COMMAND
),
# Octet 46
(
HCI_LE_SET_DEFAULT_SUBRATE_COMMAND,
HCI_LE_SUBRATE_REQUEST_COMMAND,
HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_V2_COMMAND,
None,
None,
HCI_LE_SET_PERIODIC_ADVERTISING_SUBEVENT_DATA_COMMAND,
HCI_LE_SET_PERIODIC_ADVERTISING_RESPONSE_DATA_COMMAND,
HCI_LE_SET_PERIODIC_SYNC_SUBEVENT_COMMAND
),
# Octet 47
(
HCI_LE_EXTENDED_CREATE_CONNECTION_V2_COMMAND,
HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_V2_COMMAND,
None,
None,
None,
None,
None,
None,
)
)
# LE Supported Features # LE Supported Features
# See Bluetooth spec @ Vol 6, Part B, 4.6 FEATURE SUPPORT # See Bluetooth spec @ Vol 6, Part B, 4.6 FEATURE SUPPORT
@@ -2224,7 +2003,7 @@ class HCI_Packet:
Abstract Base class for HCI packets Abstract Base class for HCI packets
''' '''
hci_packet_type: int hci_packet_type: ClassVar[int]
@staticmethod @staticmethod
def from_bytes(packet: bytes) -> HCI_Packet: def from_bytes(packet: bytes) -> HCI_Packet:
@@ -4470,9 +4249,11 @@ class HCI_LE_Set_Extended_Scan_Parameters_Command(HCI_Command):
fields.append( fields.append(
( (
f'{scanning_phy_str}.scan_type: ', f'{scanning_phy_str}.scan_type: ',
'PASSIVE' (
if self.scan_types[i] == self.PASSIVE_SCANNING 'PASSIVE'
else 'ACTIVE', if self.scan_types[i] == self.PASSIVE_SCANNING
else 'ACTIVE'
),
) )
) )
fields.append( fields.append(
@@ -5231,9 +5012,9 @@ class HCI_LE_Advertising_Report_Event(HCI_LE_Meta_Event):
return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}' return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
HCI_LE_Meta_Event.subevent_classes[ HCI_LE_Meta_Event.subevent_classes[HCI_LE_ADVERTISING_REPORT_EVENT] = (
HCI_LE_ADVERTISING_REPORT_EVENT HCI_LE_Advertising_Report_Event
] = HCI_LE_Advertising_Report_Event )
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -5485,9 +5266,9 @@ class HCI_LE_Extended_Advertising_Report_Event(HCI_LE_Meta_Event):
return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}' return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
HCI_LE_Meta_Event.subevent_classes[ HCI_LE_Meta_Event.subevent_classes[HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT] = (
HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT HCI_LE_Extended_Advertising_Report_Event
] = HCI_LE_Extended_Advertising_Report_Event )
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -6411,12 +6192,23 @@ class HCI_SynchronousDataPacket(HCI_Packet):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@dataclasses.dataclass
class HCI_IsoDataPacket(HCI_Packet): class HCI_IsoDataPacket(HCI_Packet):
''' '''
See Bluetooth spec @ 5.4.5 HCI ISO Data Packets See Bluetooth spec @ 5.4.5 HCI ISO Data Packets
''' '''
hci_packet_type = HCI_ISO_DATA_PACKET hci_packet_type: ClassVar[int] = HCI_ISO_DATA_PACKET
connection_handle: int
data_total_length: int
iso_sdu_fragment: bytes
pb_flag: int
ts_flag: int = 0
time_stamp: Optional[int] = None
packet_sequence_number: Optional[int] = None
iso_sdu_length: Optional[int] = None
packet_status_flag: Optional[int] = None
@staticmethod @staticmethod
def from_bytes(packet: bytes) -> HCI_IsoDataPacket: def from_bytes(packet: bytes) -> HCI_IsoDataPacket:
@@ -6460,28 +6252,6 @@ class HCI_IsoDataPacket(HCI_Packet):
iso_sdu_fragment=iso_sdu_fragment, iso_sdu_fragment=iso_sdu_fragment,
) )
def __init__(
self,
connection_handle: int,
pb_flag: int,
ts_flag: int,
data_total_length: int,
time_stamp: Optional[int],
packet_sequence_number: Optional[int],
iso_sdu_length: Optional[int],
packet_status_flag: Optional[int],
iso_sdu_fragment: bytes,
) -> None:
self.connection_handle = connection_handle
self.pb_flag = pb_flag
self.ts_flag = ts_flag
self.data_total_length = data_total_length
self.time_stamp = time_stamp
self.packet_sequence_number = packet_sequence_number
self.iso_sdu_length = iso_sdu_length
self.packet_status_flag = packet_status_flag
self.iso_sdu_fragment = iso_sdu_fragment
def __bytes__(self) -> bytes: def __bytes__(self) -> bytes:
return self.to_bytes() return self.to_bytes()
+21 -6
View File
@@ -18,6 +18,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, MutableMapping from collections.abc import Callable, MutableMapping
import datetime
from typing import cast, Any, Optional from typing import cast, Any, Optional
import logging import logging
@@ -66,12 +67,13 @@ PSM_NAMES = {
rfcomm.RFCOMM_PSM: 'RFCOMM', rfcomm.RFCOMM_PSM: 'RFCOMM',
sdp.SDP_PSM: 'SDP', sdp.SDP_PSM: 'SDP',
avdtp.AVDTP_PSM: 'AVDTP', avdtp.AVDTP_PSM: 'AVDTP',
avctp.AVCTP_PSM: 'AVCTP' avctp.AVCTP_PSM: 'AVCTP',
# TODO: add more PSM values # TODO: add more PSM values
} }
AVCTP_PID_NAMES = {avrcp.AVRCP_PID: 'AVRCP'} AVCTP_PID_NAMES = {avrcp.AVRCP_PID: 'AVRCP'}
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class PacketTracer: class PacketTracer:
class AclStream: class AclStream:
@@ -207,6 +209,7 @@ class PacketTracer:
self.label = label self.label = label
self.emit_message = emit_message self.emit_message = emit_message
self.acl_streams = {} # ACL streams, by connection handle self.acl_streams = {} # ACL streams, by connection handle
self.packet_timestamp: Optional[datetime.datetime] = None
def start_acl_stream(self, connection_handle: int) -> PacketTracer.AclStream: def start_acl_stream(self, connection_handle: int) -> PacketTracer.AclStream:
logger.info( logger.info(
@@ -234,7 +237,10 @@ class PacketTracer:
# Let the other forwarder know so it can cleanup its stream as well # Let the other forwarder know so it can cleanup its stream as well
self.peer.end_acl_stream(connection_handle) self.peer.end_acl_stream(connection_handle)
def on_packet(self, packet: HCI_Packet) -> None: def on_packet(
self, timestamp: Optional[datetime.datetime], packet: HCI_Packet
) -> None:
self.packet_timestamp = timestamp
self.emit(packet) self.emit(packet)
if packet.hci_packet_type == HCI_ACL_DATA_PACKET: if packet.hci_packet_type == HCI_ACL_DATA_PACKET:
@@ -254,13 +260,22 @@ class PacketTracer:
) )
def emit(self, message: Any) -> None: def emit(self, message: Any) -> None:
self.emit_message(f'[{self.label}] {message}') if self.packet_timestamp:
prefix = f"[{self.packet_timestamp.strftime('%Y-%m-%d %H:%M:%S.%f')}]"
else:
prefix = ""
self.emit_message(f'{prefix}[{self.label}] {message}')
def trace(self, packet: HCI_Packet, direction: int = 0) -> None: def trace(
self,
packet: HCI_Packet,
direction: int = 0,
timestamp: Optional[datetime.datetime] = None,
) -> None:
if direction == 0: if direction == 0:
self.host_to_controller_analyzer.on_packet(packet) self.host_to_controller_analyzer.on_packet(timestamp, packet)
else: else:
self.controller_to_host_analyzer.on_packet(packet) self.controller_to_host_analyzer.on_packet(timestamp, packet)
def __init__( def __init__(
self, self,
+1022 -80
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -48,6 +48,7 @@ HID_INTERRUPT_PSM = 0x0013
class Message: class Message:
message_type: MessageType message_type: MessageType
# Report types # Report types
class ReportType(enum.IntEnum): class ReportType(enum.IntEnum):
OTHER_REPORT = 0x00 OTHER_REPORT = 0x00
+37 -33
View File
@@ -22,7 +22,17 @@ import dataclasses
import logging import logging
import struct import struct
from typing import Any, Awaitable, Callable, Deque, Dict, Optional, cast, TYPE_CHECKING from typing import (
Any,
Awaitable,
Callable,
Deque,
Dict,
Optional,
Set,
cast,
TYPE_CHECKING,
)
from bumble.colors import color from bumble.colors import color
from bumble.l2cap import L2CAP_PDU from bumble.l2cap import L2CAP_PDU
@@ -165,7 +175,7 @@ class Host(AbortableEventEmitter):
self.number_of_supported_advertising_sets = 0 self.number_of_supported_advertising_sets = 0
self.maximum_advertising_data_length = 31 self.maximum_advertising_data_length = 31
self.local_version = None self.local_version = None
self.local_supported_commands = bytes(64) self.local_supported_commands = 0
self.local_le_features = 0 self.local_le_features = 0
self.local_lmp_features = hci.LmpFeatureMask(0) # Classic LMP features self.local_lmp_features = hci.LmpFeatureMask(0) # Classic LMP features
self.suggested_max_tx_octets = 251 # Max allowed self.suggested_max_tx_octets = 251 # Max allowed
@@ -174,7 +184,7 @@ class Host(AbortableEventEmitter):
self.long_term_key_provider = None self.long_term_key_provider = None
self.link_key_provider = None self.link_key_provider = None
self.pairing_io_capability_provider = None # Classic only self.pairing_io_capability_provider = None # Classic only
self.snooper = None self.snooper: Optional[Snooper] = None
# Connect to the source and sink if specified # Connect to the source and sink if specified
if controller_source: if controller_source:
@@ -232,7 +242,9 @@ class Host(AbortableEventEmitter):
response = await self.send_command( response = await self.send_command(
hci.HCI_Read_Local_Supported_Commands_Command(), check_result=True hci.HCI_Read_Local_Supported_Commands_Command(), check_result=True
) )
self.local_supported_commands = response.return_parameters.supported_commands self.local_supported_commands = int.from_bytes(
response.return_parameters.supported_commands, 'little'
)
if self.supports_command(hci.HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND): if self.supports_command(hci.HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
response = await self.send_command( response = await self.send_command(
@@ -486,7 +498,7 @@ class Host(AbortableEventEmitter):
def controller(self, controller) -> None: def controller(self, controller) -> None:
self.set_packet_sink(controller) self.set_packet_sink(controller)
if controller: if controller:
controller.set_packet_sink(self) self.set_packet_source(controller)
def set_packet_sink(self, sink: Optional[TransportSink]) -> None: def set_packet_sink(self, sink: Optional[TransportSink]) -> None:
self.hci_sink = sink self.hci_sink = sink
@@ -518,7 +530,9 @@ class Host(AbortableEventEmitter):
# Check the return parameters if required # Check the return parameters if required
if check_result: if check_result:
if isinstance(response.return_parameters, int): if isinstance(response, hci.HCI_Command_Status_Event):
status = response.status
elif isinstance(response.return_parameters, int):
status = response.return_parameters status = response.return_parameters
elif isinstance(response.return_parameters, bytes): elif isinstance(response.return_parameters, bytes):
# return parameters first field is a one byte status code # return parameters first field is a one byte status code
@@ -583,31 +597,19 @@ class Host(AbortableEventEmitter):
offset += data_total_length offset += data_total_length
bytes_remaining -= data_total_length bytes_remaining -= data_total_length
def supports_command(self, command): def supports_command(self, op_code: int) -> bool:
# Find the support flag position for this command return (
for octet, flags in enumerate(hci.HCI_SUPPORTED_COMMANDS_FLAGS): self.local_supported_commands
for flag_position, value in enumerate(flags): & hci.HCI_SUPPORTED_COMMANDS_MASKS.get(op_code, 0)
if value == command: ) != 0
# Check if the flag is set
if octet < len(self.local_supported_commands) and flag_position < 8:
return (
self.local_supported_commands[octet] & (1 << flag_position)
) != 0
return False
@property @property
def supported_commands(self): def supported_commands(self) -> Set[int]:
commands = [] return set(
for octet, flags in enumerate(self.local_supported_commands): op_code
if octet < len(hci.HCI_SUPPORTED_COMMANDS_FLAGS): for op_code, mask in hci.HCI_SUPPORTED_COMMANDS_MASKS.items()
for flag in range(8): if self.local_supported_commands & mask
if flags & (1 << flag) != 0: )
command = hci.HCI_SUPPORTED_COMMANDS_FLAGS[octet][flag]
if command is not None:
commands.append(command)
return commands
def supports_le_features(self, feature: hci.LeFeatureMask) -> bool: def supports_le_features(self, feature: hci.LeFeatureMask) -> bool:
return (self.local_le_features & feature) == feature return (self.local_le_features & feature) == feature
@@ -719,14 +721,16 @@ class Host(AbortableEventEmitter):
for connection_handle, num_completed_packets in zip( for connection_handle, num_completed_packets in zip(
event.connection_handles, event.num_completed_packets event.connection_handles, event.num_completed_packets
): ):
if not (connection := self.connections.get(connection_handle)): if connection := self.connections.get(connection_handle):
connection.acl_packet_queue.on_packets_completed(num_completed_packets)
elif not (
self.cis_links.get(connection_handle)
or self.sco_links.get(connection_handle)
):
logger.warning( logger.warning(
'received packet completion event for unknown handle ' 'received packet completion event for unknown handle '
f'0x{connection_handle:04X}' f'0x{connection_handle:04X}'
) )
continue
connection.acl_packet_queue.on_packets_completed(num_completed_packets)
# Classic only # Classic only
def on_hci_connection_request_event(self, event): def on_hci_connection_request_event(self, event):
+11 -8
View File
@@ -25,7 +25,8 @@ import asyncio
import logging import logging
import os import os
import json import json
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type
from typing_extensions import Self
from .colors import color from .colors import color
from .hci import Address from .hci import Address
@@ -128,10 +129,10 @@ class PairingKeys:
def print(self, prefix=''): def print(self, prefix=''):
keys_dict = self.to_dict() keys_dict = self.to_dict()
for (container_property, value) in keys_dict.items(): for container_property, value in keys_dict.items():
if isinstance(value, dict): if isinstance(value, dict):
print(f'{prefix}{color(container_property, "cyan")}:') print(f'{prefix}{color(container_property, "cyan")}:')
for (key_property, key_value) in value.items(): for key_property, key_value in value.items():
print(f'{prefix} {color(key_property, "green")}: {key_value}') print(f'{prefix} {color(key_property, "green")}: {key_value}')
else: else:
print(f'{prefix}{color(container_property, "cyan")}: {value}') print(f'{prefix}{color(container_property, "cyan")}: {value}')
@@ -158,7 +159,7 @@ class KeyStore:
async def get_resolving_keys(self): async def get_resolving_keys(self):
all_keys = await self.get_all() all_keys = await self.get_all()
resolving_keys = [] resolving_keys = []
for (name, keys) in all_keys: for name, keys in all_keys:
if keys.irk is not None: if keys.irk is not None:
if keys.address_type is None: if keys.address_type is None:
address_type = Address.RANDOM_DEVICE_ADDRESS address_type = Address.RANDOM_DEVICE_ADDRESS
@@ -171,7 +172,7 @@ class KeyStore:
async def print(self, prefix=''): async def print(self, prefix=''):
entries = await self.get_all() entries = await self.get_all()
separator = '' separator = ''
for (name, keys) in entries: for name, keys in entries:
print(separator + prefix + color(name, 'yellow')) print(separator + prefix + color(name, 'yellow'))
keys.print(prefix=prefix + ' ') keys.print(prefix=prefix + ' ')
separator = '\n' separator = '\n'
@@ -253,8 +254,10 @@ class JsonKeyStore(KeyStore):
logger.debug(f'JSON keystore: {self.filename}') logger.debug(f'JSON keystore: {self.filename}')
@staticmethod @classmethod
def from_device(device: Device, filename=None) -> Optional[JsonKeyStore]: def from_device(
cls: Type[Self], device: Device, filename: Optional[str] = None
) -> Self:
if not filename: if not filename:
# Extract the filename from the config if there is one # Extract the filename from the config if there is one
if device.config.keystore is not None: if device.config.keystore is not None:
@@ -270,7 +273,7 @@ class JsonKeyStore(KeyStore):
else: else:
namespace = JsonKeyStore.DEFAULT_NAMESPACE namespace = JsonKeyStore.DEFAULT_NAMESPACE
return JsonKeyStore(namespace, filename) return cls(namespace, filename)
async def load(self): async def load(self):
# Try to open the file, without failing. If the file does not exist, it # Try to open the file, without failing. If the file does not exist, it
+5 -2
View File
@@ -70,6 +70,7 @@ L2CAP_LE_SIGNALING_CID = 0x05
L2CAP_MIN_LE_MTU = 23 L2CAP_MIN_LE_MTU = 23
L2CAP_MIN_BR_EDR_MTU = 48 L2CAP_MIN_BR_EDR_MTU = 48
L2CAP_MAX_BR_EDR_MTU = 65535
L2CAP_DEFAULT_MTU = 2048 # Default value for the MTU we are willing to accept L2CAP_DEFAULT_MTU = 2048 # Default value for the MTU we are willing to accept
@@ -832,7 +833,9 @@ class ClassicChannel(EventEmitter):
# Wait for the connection to succeed or fail # Wait for the connection to succeed or fail
try: try:
return await self.connection_result return await self.connection.abort_on(
'disconnection', self.connection_result
)
finally: finally:
self.connection_result = None self.connection_result = None
@@ -2225,7 +2228,7 @@ class ChannelManager:
# Connect # Connect
try: try:
await channel.connect() await channel.connect()
except Exception as e: except BaseException as e:
del connection_channels[source_cid] del connection_channels[source_cid]
raise e raise e
+172 -27
View File
@@ -34,8 +34,11 @@ from bumble.device import (
DEVICE_DEFAULT_SCAN_INTERVAL, DEVICE_DEFAULT_SCAN_INTERVAL,
DEVICE_DEFAULT_SCAN_WINDOW, DEVICE_DEFAULT_SCAN_WINDOW,
Advertisement, Advertisement,
AdvertisingParameters,
AdvertisingEventProperties,
AdvertisingType, AdvertisingType,
Device, Device,
Phy,
) )
from bumble.gatt import Service from bumble.gatt import Service
from bumble.hci import ( from bumble.hci import (
@@ -47,9 +50,12 @@ from bumble.hci import (
from google.protobuf import any_pb2 # pytype: disable=pyi-error from google.protobuf import any_pb2 # pytype: disable=pyi-error
from google.protobuf import empty_pb2 # pytype: disable=pyi-error from google.protobuf import empty_pb2 # pytype: disable=pyi-error
from pandora.host_grpc_aio import HostServicer from pandora.host_grpc_aio import HostServicer
from pandora import host_pb2
from pandora.host_pb2 import ( from pandora.host_pb2 import (
NOT_CONNECTABLE, NOT_CONNECTABLE,
NOT_DISCOVERABLE, NOT_DISCOVERABLE,
DISCOVERABLE_LIMITED,
DISCOVERABLE_GENERAL,
PRIMARY_1M, PRIMARY_1M,
PRIMARY_CODED, PRIMARY_CODED,
SECONDARY_1M, SECONDARY_1M,
@@ -65,6 +71,7 @@ from pandora.host_pb2 import (
ConnectResponse, ConnectResponse,
DataTypes, DataTypes,
DisconnectRequest, DisconnectRequest,
DiscoverabilityMode,
InquiryResponse, InquiryResponse,
PrimaryPhy, PrimaryPhy,
ReadLocalAddressResponse, ReadLocalAddressResponse,
@@ -94,6 +101,25 @@ SECONDARY_PHY_MAP: Dict[int, SecondaryPhy] = {
3: SECONDARY_CODED, 3: SECONDARY_CODED,
} }
PRIMARY_PHY_TO_BUMBLE_PHY_MAP: Dict[PrimaryPhy, Phy] = {
PRIMARY_1M: Phy.LE_1M,
PRIMARY_CODED: Phy.LE_CODED,
}
SECONDARY_PHY_TO_BUMBLE_PHY_MAP: Dict[SecondaryPhy, Phy] = {
SECONDARY_NONE: Phy.LE_1M,
SECONDARY_1M: Phy.LE_1M,
SECONDARY_2M: Phy.LE_2M,
SECONDARY_CODED: Phy.LE_CODED,
}
OWN_ADDRESS_MAP: Dict[host_pb2.OwnAddressType, bumble.hci.OwnAddressType] = {
host_pb2.PUBLIC: bumble.hci.OwnAddressType.PUBLIC,
host_pb2.RANDOM: bumble.hci.OwnAddressType.RANDOM,
host_pb2.RESOLVABLE_OR_PUBLIC: bumble.hci.OwnAddressType.RESOLVABLE_OR_PUBLIC,
host_pb2.RESOLVABLE_OR_RANDOM: bumble.hci.OwnAddressType.RESOLVABLE_OR_RANDOM,
}
class HostService(HostServicer): class HostService(HostServicer):
waited_connections: Set[int] waited_connections: Set[int]
@@ -261,9 +287,9 @@ class HostService(HostServicer):
self.log.debug(f"WaitDisconnection: {connection_handle}") self.log.debug(f"WaitDisconnection: {connection_handle}")
if connection := self.device.lookup_connection(connection_handle): if connection := self.device.lookup_connection(connection_handle):
disconnection_future: asyncio.Future[ disconnection_future: asyncio.Future[None] = (
None asyncio.get_running_loop().create_future()
] = asyncio.get_running_loop().create_future() )
def on_disconnection(_: None) -> None: def on_disconnection(_: None) -> None:
disconnection_future.set_result(None) disconnection_future.set_result(None)
@@ -281,10 +307,113 @@ class HostService(HostServicer):
async def Advertise( async def Advertise(
self, request: AdvertiseRequest, context: grpc.ServicerContext self, request: AdvertiseRequest, context: grpc.ServicerContext
) -> AsyncGenerator[AdvertiseResponse, None]: ) -> AsyncGenerator[AdvertiseResponse, None]:
if not request.legacy: try:
raise NotImplementedError( if request.legacy:
"TODO: add support for extended advertising in Bumble" async for rsp in self.legacy_advertise(request, context):
yield rsp
else:
async for rsp in self.extended_advertise(request, context):
yield rsp
finally:
pass
async def extended_advertise(
self, request: AdvertiseRequest, context: grpc.ServicerContext
) -> AsyncGenerator[AdvertiseResponse, None]:
advertising_data = bytes(self.unpack_data_types(request.data))
scan_response_data = bytes(self.unpack_data_types(request.scan_response_data))
scannable = len(scan_response_data) != 0
advertising_event_properties = AdvertisingEventProperties(
is_connectable=request.connectable,
is_scannable=scannable,
is_directed=request.target is not None,
is_high_duty_cycle_directed_connectable=False,
is_legacy=False,
is_anonymous=False,
include_tx_power=False,
)
peer_address = Address.ANY
if request.target:
# Need to reverse bytes order since Bumble Address is using MSB.
target_bytes = bytes(reversed(request.target))
if request.target_variant() == "public":
peer_address = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS)
else:
peer_address = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
advertising_parameters = AdvertisingParameters(
advertising_event_properties=advertising_event_properties,
own_address_type=OWN_ADDRESS_MAP[request.own_address_type],
peer_address=peer_address,
primary_advertising_phy=PRIMARY_PHY_TO_BUMBLE_PHY_MAP[request.primary_phy],
secondary_advertising_phy=SECONDARY_PHY_TO_BUMBLE_PHY_MAP[
request.secondary_phy
],
)
if advertising_interval := request.interval:
advertising_parameters.primary_advertising_interval_min = int(
advertising_interval
) )
advertising_parameters.primary_advertising_interval_max = int(
advertising_interval
)
if interval_range := request.interval_range:
advertising_parameters.primary_advertising_interval_max += int(
interval_range
)
advertising_set = await self.device.create_advertising_set(
advertising_parameters=advertising_parameters,
advertising_data=advertising_data,
scan_response_data=scan_response_data,
)
pending_connection: asyncio.Future[bumble.device.Connection] = (
asyncio.get_running_loop().create_future()
)
if request.connectable:
def on_connection(connection: bumble.device.Connection) -> None:
if (
connection.transport == BT_LE_TRANSPORT
and connection.role == BT_PERIPHERAL_ROLE
):
pending_connection.set_result(connection)
self.device.on('connection', on_connection)
try:
# Advertise until RPC is canceled
while True:
if not advertising_set.enabled:
self.log.debug('Advertise (extended)')
await advertising_set.start()
if not request.connectable:
await asyncio.sleep(1)
continue
connection = await pending_connection
pending_connection = asyncio.get_running_loop().create_future()
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
yield AdvertiseResponse(connection=Connection(cookie=cookie))
await asyncio.sleep(1)
finally:
try:
self.log.debug('Stop Advertise (extended)')
await advertising_set.stop()
await advertising_set.remove()
except Exception:
pass
async def legacy_advertise(
self, request: AdvertiseRequest, context: grpc.ServicerContext
) -> AsyncGenerator[AdvertiseResponse, None]:
if advertising_interval := request.interval: if advertising_interval := request.interval:
self.device.config.advertising_interval_min = int(advertising_interval) self.device.config.advertising_interval_min = int(advertising_interval)
self.device.config.advertising_interval_max = int(advertising_interval) self.device.config.advertising_interval_max = int(advertising_interval)
@@ -357,14 +486,10 @@ class HostService(HostServicer):
target_bytes = bytes(reversed(request.target)) target_bytes = bytes(reversed(request.target))
if request.target_variant() == "public": if request.target_variant() == "public":
target = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS) target = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS)
advertising_type = ( advertising_type = AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
) # FIXME: HIGH_DUTY ?
else: else:
target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS) target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
advertising_type = ( advertising_type = AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
) # FIXME: HIGH_DUTY ?
if request.connectable: if request.connectable:
@@ -391,9 +516,9 @@ class HostService(HostServicer):
await asyncio.sleep(1) await asyncio.sleep(1)
continue continue
pending_connection: asyncio.Future[ pending_connection: asyncio.Future[bumble.device.Connection] = (
bumble.device.Connection asyncio.get_running_loop().create_future()
] = asyncio.get_running_loop().create_future() )
self.log.debug('Wait for LE connection...') self.log.debug('Wait for LE connection...')
connection = await pending_connection connection = await pending_connection
@@ -422,23 +547,31 @@ class HostService(HostServicer):
self, request: ScanRequest, context: grpc.ServicerContext self, request: ScanRequest, context: grpc.ServicerContext
) -> AsyncGenerator[ScanningResponse, None]: ) -> AsyncGenerator[ScanningResponse, None]:
# TODO: modify `start_scanning` to accept floats instead of int for ms values # TODO: modify `start_scanning` to accept floats instead of int for ms values
if request.phys:
raise NotImplementedError("TODO: add support for `request.phys`")
self.log.debug('Scan') self.log.debug('Scan')
scanning_phys = []
if PRIMARY_1M in request.phys:
scanning_phys.append(int(Phy.LE_1M))
if PRIMARY_CODED in request.phys:
scanning_phys.append(int(Phy.LE_CODED))
if not scanning_phys:
scanning_phys = [int(Phy.LE_1M), int(Phy.LE_CODED)]
scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue() scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue()
handler = self.device.on('advertisement', scan_queue.put_nowait) handler = self.device.on('advertisement', scan_queue.put_nowait)
await self.device.start_scanning( await self.device.start_scanning(
legacy=request.legacy, legacy=request.legacy,
active=not request.passive, active=not request.passive,
own_address_type=request.own_address_type, own_address_type=request.own_address_type,
scan_interval=int(request.interval) scan_interval=(
if request.interval int(request.interval)
else DEVICE_DEFAULT_SCAN_INTERVAL, if request.interval
scan_window=int(request.window) else DEVICE_DEFAULT_SCAN_INTERVAL
if request.window ),
else DEVICE_DEFAULT_SCAN_WINDOW, scan_window=(
int(request.window) if request.window else DEVICE_DEFAULT_SCAN_WINDOW
),
scanning_phys=scanning_phys,
) )
try: try:
@@ -651,9 +784,11 @@ class HostService(HostServicer):
*struct.pack('<H', dt.peripheral_connection_interval_min), *struct.pack('<H', dt.peripheral_connection_interval_min),
*struct.pack( *struct.pack(
'<H', '<H',
dt.peripheral_connection_interval_max (
if dt.peripheral_connection_interval_max dt.peripheral_connection_interval_max
else dt.peripheral_connection_interval_min, if dt.peripheral_connection_interval_max
else dt.peripheral_connection_interval_min
),
), ),
] ]
), ),
@@ -735,6 +870,16 @@ class HostService(HostServicer):
) )
) )
flag_map = {
NOT_DISCOVERABLE: 0x00,
DISCOVERABLE_LIMITED: AdvertisingData.LE_LIMITED_DISCOVERABLE_MODE_FLAG,
DISCOVERABLE_GENERAL: AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG,
}
if dt.le_discoverability_mode:
flags = flag_map[dt.le_discoverability_mode]
ad_structures.append((AdvertisingData.FLAGS, flags.to_bytes(1, 'big')))
return AdvertisingData(ad_structures) return AdvertisingData(ad_structures)
def pack_data_types(self, ad: AdvertisingData) -> DataTypes: def pack_data_types(self, ad: AdvertisingData) -> DataTypes:
+3 -3
View File
@@ -383,9 +383,9 @@ class SecurityService(SecurityServicer):
connection.transport connection.transport
] == request.level_variant() ] == request.level_variant()
wait_for_security: asyncio.Future[ wait_for_security: asyncio.Future[str] = (
str asyncio.get_running_loop().create_future()
] = asyncio.get_running_loop().create_future() )
authenticate_task: Optional[asyncio.Future[None]] = None authenticate_task: Optional[asyncio.Future[None]] = None
pair_task: Optional[asyncio.Future[None]] = None pair_task: Optional[asyncio.Future[None]] = None
+90 -12
View File
@@ -24,8 +24,9 @@ import enum
import struct import struct
import functools import functools
import logging import logging
from typing import Optional, List, Union, Type, Dict, Any, Tuple, cast from typing import Optional, List, Union, Type, Dict, Any, Tuple
from bumble import core
from bumble import colors from bumble import colors
from bumble import device from bumble import device
from bumble import hci from bumble import hci
@@ -77,6 +78,10 @@ class AudioLocation(enum.IntFlag):
LEFT_SURROUND = 0x04000000 LEFT_SURROUND = 0x04000000
RIGHT_SURROUND = 0x08000000 RIGHT_SURROUND = 0x08000000
@property
def channel_count(self) -> int:
return bin(self.value).count('1')
class AudioInputType(enum.IntEnum): class AudioInputType(enum.IntEnum):
'''Bluetooth Assigned Numbers, Section 6.12.2 - Audio Input Type''' '''Bluetooth Assigned Numbers, Section 6.12.2 - Audio Input Type'''
@@ -217,6 +222,13 @@ class FrameDuration(enum.IntEnum):
DURATION_7500_US = 0x00 DURATION_7500_US = 0x00
DURATION_10000_US = 0x01 DURATION_10000_US = 0x01
@property
def us(self) -> int:
return {
FrameDuration.DURATION_7500_US: 7500,
FrameDuration.DURATION_10000_US: 10000,
}[self]
class SupportedFrameDuration(enum.IntFlag): class SupportedFrameDuration(enum.IntFlag):
'''Bluetooth Assigned Numbers, Section 6.12.4.2 - Frame Duration''' '''Bluetooth Assigned Numbers, Section 6.12.4.2 - Frame Duration'''
@@ -228,6 +240,14 @@ class SupportedFrameDuration(enum.IntFlag):
DURATION_10000_US_PREFERRED = 0b0010 DURATION_10000_US_PREFERRED = 0b0010
class AnnouncementType(enum.IntEnum):
'''Basic Audio Profile, 3.5.3. Additional Audio Stream Control Service requirements'''
# fmt: off
GENERAL = 0x00
TARGETED = 0x01
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# ASE Operations # ASE Operations
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -453,6 +473,34 @@ class AudioRole(enum.IntEnum):
SOURCE = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.HOST_TO_CONTROLLER SOURCE = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.HOST_TO_CONTROLLER
@dataclasses.dataclass
class UnicastServerAdvertisingData:
"""Advertising Data for ASCS."""
announcement_type: AnnouncementType = AnnouncementType.TARGETED
available_audio_contexts: ContextType = ContextType.MEDIA
metadata: bytes = b''
def __bytes__(self) -> bytes:
return bytes(
core.AdvertisingData(
[
(
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID,
struct.pack(
'<2sBIB',
gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE.to_bytes(),
self.announcement_type,
self.available_audio_contexts,
len(self.metadata),
)
+ self.metadata,
)
]
)
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Utils # Utils
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -497,7 +545,7 @@ class CodecSpecificCapabilities:
supported_sampling_frequencies: SupportedSamplingFrequency supported_sampling_frequencies: SupportedSamplingFrequency
supported_frame_durations: SupportedFrameDuration supported_frame_durations: SupportedFrameDuration
supported_audio_channel_counts: Sequence[int] supported_audio_channel_count: Sequence[int]
min_octets_per_codec_frame: int min_octets_per_codec_frame: int
max_octets_per_codec_frame: int max_octets_per_codec_frame: int
supported_max_codec_frames_per_sdu: int supported_max_codec_frames_per_sdu: int
@@ -506,7 +554,7 @@ class CodecSpecificCapabilities:
def from_bytes(cls, data: bytes) -> CodecSpecificCapabilities: def from_bytes(cls, data: bytes) -> CodecSpecificCapabilities:
offset = 0 offset = 0
# Allowed default values. # Allowed default values.
supported_audio_channel_counts = [1] supported_audio_channel_count = [1]
supported_max_codec_frames_per_sdu = 1 supported_max_codec_frames_per_sdu = 1
while offset < len(data): while offset < len(data):
length, type = struct.unpack_from('BB', data, offset) length, type = struct.unpack_from('BB', data, offset)
@@ -519,7 +567,7 @@ class CodecSpecificCapabilities:
elif type == CodecSpecificCapabilities.Type.FRAME_DURATION: elif type == CodecSpecificCapabilities.Type.FRAME_DURATION:
supported_frame_durations = SupportedFrameDuration(value) supported_frame_durations = SupportedFrameDuration(value)
elif type == CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT: elif type == CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT:
supported_audio_channel_counts = bits_to_channel_counts(value) supported_audio_channel_count = bits_to_channel_counts(value)
elif type == CodecSpecificCapabilities.Type.OCTETS_PER_FRAME: elif type == CodecSpecificCapabilities.Type.OCTETS_PER_FRAME:
min_octets_per_sample = value & 0xFFFF min_octets_per_sample = value & 0xFFFF
max_octets_per_sample = value >> 16 max_octets_per_sample = value >> 16
@@ -530,7 +578,7 @@ class CodecSpecificCapabilities:
return CodecSpecificCapabilities( return CodecSpecificCapabilities(
supported_sampling_frequencies=supported_sampling_frequencies, supported_sampling_frequencies=supported_sampling_frequencies,
supported_frame_durations=supported_frame_durations, supported_frame_durations=supported_frame_durations,
supported_audio_channel_counts=supported_audio_channel_counts, supported_audio_channel_count=supported_audio_channel_count,
min_octets_per_codec_frame=min_octets_per_sample, min_octets_per_codec_frame=min_octets_per_sample,
max_octets_per_codec_frame=max_octets_per_sample, max_octets_per_codec_frame=max_octets_per_sample,
supported_max_codec_frames_per_sdu=supported_max_codec_frames_per_sdu, supported_max_codec_frames_per_sdu=supported_max_codec_frames_per_sdu,
@@ -547,7 +595,7 @@ class CodecSpecificCapabilities:
self.supported_frame_durations, self.supported_frame_durations,
2, 2,
CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT, CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT,
channel_counts_to_bits(self.supported_audio_channel_counts), channel_counts_to_bits(self.supported_audio_channel_count),
5, 5,
CodecSpecificCapabilities.Type.OCTETS_PER_FRAME, CodecSpecificCapabilities.Type.OCTETS_PER_FRAME,
self.min_octets_per_codec_frame, self.min_octets_per_codec_frame,
@@ -833,15 +881,22 @@ class AseStateMachine(gatt.Characteristic):
cig_id: int, cig_id: int,
cis_id: int, cis_id: int,
) -> None: ) -> None:
if cis_id == self.cis_id and self.state == self.State.ENABLING: if (
cig_id == self.cig_id
and cis_id == self.cis_id
and self.state == self.State.ENABLING
):
acl_connection.abort_on( acl_connection.abort_on(
'flush', self.service.device.accept_cis_request(cis_handle) 'flush', self.service.device.accept_cis_request(cis_handle)
) )
def on_cis_establishment(self, cis_link: device.CisLink) -> None: def on_cis_establishment(self, cis_link: device.CisLink) -> None:
if cis_link.cis_id == self.cis_id and self.state == self.State.ENABLING: if (
self.state = self.State.STREAMING cis_link.cig_id == self.cig_id
self.cis_link = cis_link and cis_link.cis_id == self.cis_id
and self.state == self.State.ENABLING
):
cis_link.on('disconnection', self.on_cis_disconnection)
async def post_cis_established(): async def post_cis_established():
await self.service.device.send_command( await self.service.device.send_command(
@@ -854,9 +909,15 @@ class AseStateMachine(gatt.Characteristic):
codec_configuration=b'', codec_configuration=b'',
) )
) )
if self.role == AudioRole.SINK:
self.state = self.State.STREAMING
await self.service.device.notify_subscribers(self, self.value) await self.service.device.notify_subscribers(self, self.value)
cis_link.acl_connection.abort_on('flush', post_cis_established()) cis_link.acl_connection.abort_on('flush', post_cis_established())
self.cis_link = cis_link
def on_cis_disconnection(self, _reason) -> None:
self.cis_link = None
def on_config_codec( def on_config_codec(
self, self,
@@ -954,11 +1015,17 @@ class AseStateMachine(gatt.Characteristic):
AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION, AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
AseReasonCode.NONE, AseReasonCode.NONE,
) )
self.state = self.State.DISABLING if self.role == AudioRole.SINK:
self.state = self.State.QOS_CONFIGURED
else:
self.state = self.State.DISABLING
return (AseResponseCode.SUCCESS, AseReasonCode.NONE) return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
def on_receiver_stop_ready(self) -> Tuple[AseResponseCode, AseReasonCode]: def on_receiver_stop_ready(self) -> Tuple[AseResponseCode, AseReasonCode]:
if self.state != AseStateMachine.State.DISABLING: if (
self.role != AudioRole.SOURCE
or self.state != AseStateMachine.State.DISABLING
):
return ( return (
AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION, AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
AseReasonCode.NONE, AseReasonCode.NONE,
@@ -1009,6 +1076,7 @@ class AseStateMachine(gatt.Characteristic):
def state(self, new_state: State) -> None: def state(self, new_state: State) -> None:
logger.debug(f'{self} state change -> {colors.color(new_state.name, "cyan")}') logger.debug(f'{self} state change -> {colors.color(new_state.name, "cyan")}')
self._state = new_state self._state = new_state
self.emit('state_change')
@property @property
def value(self): def value(self):
@@ -1081,6 +1149,7 @@ class AudioStreamControlService(gatt.TemplateService):
ase_state_machines: Dict[int, AseStateMachine] ase_state_machines: Dict[int, AseStateMachine]
ase_control_point: gatt.Characteristic ase_control_point: gatt.Characteristic
_active_client: Optional[device.Connection] = None
def __init__( def __init__(
self, self,
@@ -1118,7 +1187,16 @@ class AudioStreamControlService(gatt.TemplateService):
else: else:
return (ase_id, AseResponseCode.INVALID_ASE_ID, AseReasonCode.NONE) return (ase_id, AseResponseCode.INVALID_ASE_ID, AseReasonCode.NONE)
def _on_client_disconnected(self, _reason: int) -> None:
for ase in self.ase_state_machines.values():
ase.state = AseStateMachine.State.IDLE
self._active_client = None
def on_write_ase_control_point(self, connection, data): def on_write_ase_control_point(self, connection, data):
if not self._active_client and connection:
self._active_client = connection
connection.once('disconnection', self._on_client_disconnected)
operation = ASE_Operation.from_bytes(data) operation = ASE_Operation.from_bytes(data)
responses = [] responses = []
logger.debug(f'*** ASCS Write {operation} ***') logger.debug(f'*** ASCS Write {operation} ***')
+14 -5
View File
@@ -19,8 +19,8 @@
import struct import struct
from typing import Optional, Tuple from typing import Optional, Tuple
from ..gatt_client import ProfileServiceProxy from bumble.gatt_client import ServiceProxy, ProfileServiceProxy, CharacteristicProxy
from ..gatt import ( from bumble.gatt import (
GATT_DEVICE_INFORMATION_SERVICE, GATT_DEVICE_INFORMATION_SERVICE,
GATT_FIRMWARE_REVISION_STRING_CHARACTERISTIC, GATT_FIRMWARE_REVISION_STRING_CHARACTERISTIC,
GATT_HARDWARE_REVISION_STRING_CHARACTERISTIC, GATT_HARDWARE_REVISION_STRING_CHARACTERISTIC,
@@ -59,7 +59,7 @@ class DeviceInformationService(TemplateService):
firmware_revision: Optional[str] = None, firmware_revision: Optional[str] = None,
software_revision: Optional[str] = None, software_revision: Optional[str] = None,
system_id: Optional[Tuple[int, int]] = None, # (OUI, Manufacturer ID) system_id: Optional[Tuple[int, int]] = None, # (OUI, Manufacturer ID)
ieee_regulatory_certification_data_list: Optional[bytes] = None ieee_regulatory_certification_data_list: Optional[bytes] = None,
# TODO: pnp_id # TODO: pnp_id
): ):
characteristics = [ characteristics = [
@@ -104,10 +104,19 @@ class DeviceInformationService(TemplateService):
class DeviceInformationServiceProxy(ProfileServiceProxy): class DeviceInformationServiceProxy(ProfileServiceProxy):
SERVICE_CLASS = DeviceInformationService SERVICE_CLASS = DeviceInformationService
def __init__(self, service_proxy): manufacturer_name: Optional[UTF8CharacteristicAdapter]
model_number: Optional[UTF8CharacteristicAdapter]
serial_number: Optional[UTF8CharacteristicAdapter]
hardware_revision: Optional[UTF8CharacteristicAdapter]
firmware_revision: Optional[UTF8CharacteristicAdapter]
software_revision: Optional[UTF8CharacteristicAdapter]
system_id: Optional[DelegatedCharacteristicAdapter]
ieee_regulatory_certification_data_list: Optional[CharacteristicProxy]
def __init__(self, service_proxy: ServiceProxy):
self.service_proxy = service_proxy self.service_proxy = service_proxy
for (field, uuid) in ( for field, uuid in (
('manufacturer_name', GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC), ('manufacturer_name', GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC),
('model_number', GATT_MODEL_NUMBER_STRING_CHARACTERISTIC), ('model_number', GATT_MODEL_NUMBER_STRING_CHARACTERISTIC),
('serial_number', GATT_SERIAL_NUMBER_STRING_CHARACTERISTIC), ('serial_number', GATT_SERIAL_NUMBER_STRING_CHARACTERISTIC),
+173 -60
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import logging import logging
import asyncio import asyncio
import collections
import dataclasses import dataclasses
import enum import enum
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
@@ -54,6 +55,7 @@ logger = logging.getLogger(__name__)
# fmt: off # fmt: off
RFCOMM_PSM = 0x0003 RFCOMM_PSM = 0x0003
DEFAULT_RX_QUEUE_SIZE = 32
class FrameType(enum.IntEnum): class FrameType(enum.IntEnum):
SABM = 0x2F # Control field [1,1,1,1,_,1,0,0] LSB-first SABM = 0x2F # Control field [1,1,1,1,_,1,0,0] LSB-first
@@ -104,9 +106,11 @@ CRC_TABLE = bytes([
0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF 0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF
]) ])
RFCOMM_DEFAULT_L2CAP_MTU = 2048 RFCOMM_DEFAULT_L2CAP_MTU = 2048
RFCOMM_DEFAULT_WINDOW_SIZE = 7 RFCOMM_DEFAULT_INITIAL_CREDITS = 7
RFCOMM_DEFAULT_MAX_FRAME_SIZE = 2000 RFCOMM_DEFAULT_MAX_CREDITS = 32
RFCOMM_DEFAULT_CREDIT_THRESHOLD = RFCOMM_DEFAULT_MAX_CREDITS // 2
RFCOMM_DEFAULT_MAX_FRAME_SIZE = 2000
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1 RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30 RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
@@ -363,12 +367,12 @@ class RFCOMM_MCC_PN:
ack_timer: int ack_timer: int
max_frame_size: int max_frame_size: int
max_retransmissions: int max_retransmissions: int
window_size: int initial_credits: int
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.window_size < 1 or self.window_size > 7: if self.initial_credits < 1 or self.initial_credits > 7:
logger.warning( logger.warning(
f'Error Recovery Window size {self.window_size} is out of range [1, 7].' f'Initial credits {self.initial_credits} is out of range [1, 7].'
) )
@staticmethod @staticmethod
@@ -380,7 +384,7 @@ class RFCOMM_MCC_PN:
ack_timer=data[3], ack_timer=data[3],
max_frame_size=data[4] | data[5] << 8, max_frame_size=data[4] | data[5] << 8,
max_retransmissions=data[6], max_retransmissions=data[6],
window_size=data[7] & 0x07, initial_credits=data[7] & 0x07,
) )
def __bytes__(self) -> bytes: def __bytes__(self) -> bytes:
@@ -394,7 +398,7 @@ class RFCOMM_MCC_PN:
(self.max_frame_size >> 8) & 0xFF, (self.max_frame_size >> 8) & 0xFF,
self.max_retransmissions & 0xFF, self.max_retransmissions & 0xFF,
# Only 3 bits are meaningful. # Only 3 bits are meaningful.
self.window_size & 0x07, self.initial_credits & 0x07,
] ]
) )
@@ -444,39 +448,58 @@ class DLC(EventEmitter):
DISCONNECTED = 0x04 DISCONNECTED = 0x04
RESET = 0x05 RESET = 0x05
connection_result: Optional[asyncio.Future]
sink: Optional[Callable[[bytes], None]]
def __init__( def __init__(
self, self,
multiplexer: Multiplexer, multiplexer: Multiplexer,
dlci: int, dlci: int,
max_frame_size: int, tx_max_frame_size: int,
window_size: int, tx_initial_credits: int,
rx_max_frame_size: int,
rx_initial_credits: int,
) -> None: ) -> None:
super().__init__() super().__init__()
self.multiplexer = multiplexer self.multiplexer = multiplexer
self.dlci = dlci self.dlci = dlci
self.max_frame_size = max_frame_size self.rx_max_frame_size = rx_max_frame_size
self.window_size = window_size self.rx_initial_credits = rx_initial_credits
self.rx_credits = window_size self.rx_max_credits = RFCOMM_DEFAULT_MAX_CREDITS
self.rx_threshold = window_size // 2 self.rx_credits = rx_initial_credits
self.tx_credits = window_size self.rx_credits_threshold = RFCOMM_DEFAULT_CREDIT_THRESHOLD
self.tx_max_frame_size = tx_max_frame_size
self.tx_credits = tx_initial_credits
self.tx_buffer = b'' self.tx_buffer = b''
self.state = DLC.State.INIT self.state = DLC.State.INIT
self.role = multiplexer.role self.role = multiplexer.role
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0 self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
self.sink = None self.connection_result: Optional[asyncio.Future] = None
self.connection_result = None self.disconnection_result: Optional[asyncio.Future] = None
self.drained = asyncio.Event() self.drained = asyncio.Event()
self.drained.set() self.drained.set()
# Queued packets when sink is not set.
self._enqueued_rx_packets: collections.deque[bytes] = collections.deque(
maxlen=DEFAULT_RX_QUEUE_SIZE
)
self._sink: Optional[Callable[[bytes], None]] = None
# Compute the MTU # Compute the MTU
max_overhead = 4 + 1 # header with 2-byte length + fcs max_overhead = 4 + 1 # header with 2-byte length + fcs
self.mtu = min( self.mtu = min(
max_frame_size, self.multiplexer.l2cap_channel.peer_mtu - max_overhead tx_max_frame_size, self.multiplexer.l2cap_channel.peer_mtu - max_overhead
) )
@property
def sink(self) -> Optional[Callable[[bytes], None]]:
return self._sink
@sink.setter
def sink(self, sink: Optional[Callable[[bytes], None]]) -> None:
self._sink = sink
# Dump queued packets to sink
if sink:
for packet in self._enqueued_rx_packets:
sink(packet) # pylint: disable=not-callable
self._enqueued_rx_packets.clear()
def change_state(self, new_state: State) -> None: def change_state(self, new_state: State) -> None:
logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}') logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}')
self.state = new_state self.state = new_state
@@ -507,20 +530,35 @@ class DLC(EventEmitter):
self.emit('open') self.emit('open')
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None: def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
if self.state != DLC.State.CONNECTING: if self.state == DLC.State.CONNECTING:
# Exchange the modem status with the peer
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
logger.debug(f'>>> MCC MSC Command: {msc}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
self.change_state(DLC.State.CONNECTED)
if self.connection_result:
self.connection_result.set_result(None)
self.connection_result = None
self.multiplexer.on_dlc_open_complete(self)
elif self.state == DLC.State.DISCONNECTING:
self.change_state(DLC.State.DISCONNECTED)
if self.disconnection_result:
self.disconnection_result.set_result(None)
self.disconnection_result = None
self.multiplexer.on_dlc_disconnection(self)
self.emit('close')
else:
logger.warning( logger.warning(
color('!!! received SABM when not in CONNECTING state', 'red') color(
(
'!!! received UA frame when not in '
'CONNECTING or DISCONNECTING state'
),
'red',
)
) )
return
# Exchange the modem status with the peer
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
logger.debug(f'>>> MCC MSC Command: {msc}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
self.change_state(DLC.State.CONNECTED)
self.multiplexer.on_dlc_open_complete(self)
def on_dm_frame(self, frame: RFCOMM_Frame) -> None: def on_dm_frame(self, frame: RFCOMM_Frame) -> None:
# TODO: handle all states # TODO: handle all states
@@ -549,8 +587,15 @@ class DLC(EventEmitter):
f'rx_credits={self.rx_credits}: {data.hex()}' f'rx_credits={self.rx_credits}: {data.hex()}'
) )
if data: if data:
if self.sink: if self._sink:
self.sink(data) # pylint: disable=not-callable self._sink(data) # pylint: disable=not-callable
else:
self._enqueued_rx_packets.append(data)
if (
self._enqueued_rx_packets.maxlen
and len(self._enqueued_rx_packets) >= self._enqueued_rx_packets.maxlen
):
logger.warning(f'DLC [{self.dlci}] received packet queue is full')
# Update the credits # Update the credits
if self.rx_credits > 0: if self.rx_credits > 0:
@@ -584,6 +629,19 @@ class DLC(EventEmitter):
self.connection_result = asyncio.get_running_loop().create_future() self.connection_result = asyncio.get_running_loop().create_future()
self.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci)) self.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci))
async def disconnect(self) -> None:
if self.state != DLC.State.CONNECTED:
raise InvalidStateError('invalid state')
self.disconnection_result = asyncio.get_running_loop().create_future()
self.change_state(DLC.State.DISCONNECTING)
self.send_frame(
RFCOMM_Frame.disc(
c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0, dlci=self.dlci
)
)
await self.disconnection_result
def accept(self) -> None: def accept(self) -> None:
if self.state != DLC.State.INIT: if self.state != DLC.State.INIT:
raise InvalidStateError('invalid state') raise InvalidStateError('invalid state')
@@ -593,9 +651,9 @@ class DLC(EventEmitter):
cl=0xE0, cl=0xE0,
priority=7, priority=7,
ack_timer=0, ack_timer=0,
max_frame_size=self.max_frame_size, max_frame_size=self.rx_max_frame_size,
max_retransmissions=0, max_retransmissions=0,
window_size=self.window_size, initial_credits=self.rx_initial_credits,
) )
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=0, data=bytes(pn)) mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=0, data=bytes(pn))
logger.debug(f'>>> PN Response: {pn}') logger.debug(f'>>> PN Response: {pn}')
@@ -603,8 +661,8 @@ class DLC(EventEmitter):
self.change_state(DLC.State.CONNECTING) self.change_state(DLC.State.CONNECTING)
def rx_credits_needed(self) -> int: def rx_credits_needed(self) -> int:
if self.rx_credits <= self.rx_threshold: if self.rx_credits <= self.rx_credits_threshold:
return self.window_size - self.rx_credits return self.rx_max_credits - self.rx_credits
return 0 return 0
@@ -664,6 +722,17 @@ class DLC(EventEmitter):
async def drain(self) -> None: async def drain(self) -> None:
await self.drained.wait() await self.drained.wait()
def abort(self) -> None:
logger.debug(f'aborting DLC: {self}')
if self.connection_result:
self.connection_result.cancel()
self.connection_result = None
if self.disconnection_result:
self.disconnection_result.cancel()
self.disconnection_result = None
self.change_state(DLC.State.RESET)
self.emit('close')
def __str__(self) -> str: def __str__(self) -> str:
return f'DLC(dlci={self.dlci},state={self.state.name})' return f'DLC(dlci={self.dlci},state={self.state.name})'
@@ -686,7 +755,7 @@ class Multiplexer(EventEmitter):
connection_result: Optional[asyncio.Future] connection_result: Optional[asyncio.Future]
disconnection_result: Optional[asyncio.Future] disconnection_result: Optional[asyncio.Future]
open_result: Optional[asyncio.Future] open_result: Optional[asyncio.Future]
acceptor: Optional[Callable[[int], bool]] acceptor: Optional[Callable[[int], Optional[Tuple[int, int]]]]
dlcs: Dict[int, DLC] dlcs: Dict[int, DLC]
def __init__(self, l2cap_channel: l2cap.ClassicChannel, role: Role) -> None: def __init__(self, l2cap_channel: l2cap.ClassicChannel, role: Role) -> None:
@@ -698,11 +767,15 @@ class Multiplexer(EventEmitter):
self.connection_result = None self.connection_result = None
self.disconnection_result = None self.disconnection_result = None
self.open_result = None self.open_result = None
self.open_pn: Optional[RFCOMM_MCC_PN] = None
self.open_rx_max_credits = 0
self.acceptor = None self.acceptor = None
# Become a sink for the L2CAP channel # Become a sink for the L2CAP channel
l2cap_channel.sink = self.on_pdu l2cap_channel.sink = self.on_pdu
l2cap_channel.on('close', self.on_l2cap_channel_close)
def change_state(self, new_state: State) -> None: def change_state(self, new_state: State) -> None:
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}') logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
self.state = new_state self.state = new_state
@@ -766,6 +839,7 @@ class Multiplexer(EventEmitter):
'rfcomm', 'rfcomm',
) )
) )
self.open_result = None
else: else:
logger.warning(f'unexpected state for DM: {self}') logger.warning(f'unexpected state for DM: {self}')
@@ -803,9 +877,16 @@ class Multiplexer(EventEmitter):
else: else:
if self.acceptor: if self.acceptor:
channel_number = pn.dlci >> 1 channel_number = pn.dlci >> 1
if self.acceptor(channel_number): if dlc_params := self.acceptor(channel_number):
# Create a new DLC # Create a new DLC
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size) dlc = DLC(
self,
dlci=pn.dlci,
tx_max_frame_size=pn.max_frame_size,
tx_initial_credits=pn.initial_credits,
rx_max_frame_size=dlc_params[0],
rx_initial_credits=dlc_params[1],
)
self.dlcs[pn.dlci] = dlc self.dlcs[pn.dlci] = dlc
# Re-emit the handshake completion event # Re-emit the handshake completion event
@@ -823,8 +904,17 @@ class Multiplexer(EventEmitter):
# Response # Response
logger.debug(f'>>> PN Response: {pn}') logger.debug(f'>>> PN Response: {pn}')
if self.state == Multiplexer.State.OPENING: if self.state == Multiplexer.State.OPENING:
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size) assert self.open_pn
dlc = DLC(
self,
dlci=pn.dlci,
tx_max_frame_size=pn.max_frame_size,
tx_initial_credits=pn.initial_credits,
rx_max_frame_size=self.open_pn.max_frame_size,
rx_initial_credits=self.open_pn.initial_credits,
)
self.dlcs[pn.dlci] = dlc self.dlcs[pn.dlci] = dlc
self.open_pn = None
dlc.connect() dlc.connect()
else: else:
logger.warning('ignoring PN response') logger.warning('ignoring PN response')
@@ -862,7 +952,7 @@ class Multiplexer(EventEmitter):
self, self,
channel: int, channel: int,
max_frame_size: int = RFCOMM_DEFAULT_MAX_FRAME_SIZE, max_frame_size: int = RFCOMM_DEFAULT_MAX_FRAME_SIZE,
window_size: int = RFCOMM_DEFAULT_WINDOW_SIZE, initial_credits: int = RFCOMM_DEFAULT_INITIAL_CREDITS,
) -> DLC: ) -> DLC:
if self.state != Multiplexer.State.CONNECTED: if self.state != Multiplexer.State.CONNECTED:
if self.state == Multiplexer.State.OPENING: if self.state == Multiplexer.State.OPENING:
@@ -870,17 +960,19 @@ class Multiplexer(EventEmitter):
raise InvalidStateError('not connected') raise InvalidStateError('not connected')
pn = RFCOMM_MCC_PN( self.open_pn = RFCOMM_MCC_PN(
dlci=channel << 1, dlci=channel << 1,
cl=0xF0, cl=0xF0,
priority=7, priority=7,
ack_timer=0, ack_timer=0,
max_frame_size=max_frame_size, max_frame_size=max_frame_size,
max_retransmissions=0, max_retransmissions=0,
window_size=window_size, initial_credits=initial_credits,
) )
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=1, data=bytes(pn)) mcc = RFCOMM_Frame.make_mcc(
logger.debug(f'>>> Sending MCC: {pn}') mcc_type=MccType.PN, c_r=1, data=bytes(self.open_pn)
)
logger.debug(f'>>> Sending MCC: {self.open_pn}')
self.open_result = asyncio.get_running_loop().create_future() self.open_result = asyncio.get_running_loop().create_future()
self.change_state(Multiplexer.State.OPENING) self.change_state(Multiplexer.State.OPENING)
self.send_frame( self.send_frame(
@@ -890,15 +982,31 @@ class Multiplexer(EventEmitter):
information=mcc, information=mcc,
) )
) )
result = await self.open_result return await self.open_result
self.open_result = None
return result
def on_dlc_open_complete(self, dlc: DLC) -> None: def on_dlc_open_complete(self, dlc: DLC) -> None:
logger.debug(f'DLC [{dlc.dlci}] open complete') logger.debug(f'DLC [{dlc.dlci}] open complete')
self.change_state(Multiplexer.State.CONNECTED) self.change_state(Multiplexer.State.CONNECTED)
if self.open_result: if self.open_result:
self.open_result.set_result(dlc) self.open_result.set_result(dlc)
self.open_result = None
def on_dlc_disconnection(self, dlc: DLC) -> None:
logger.debug(f'DLC [{dlc.dlci}] disconnection')
self.dlcs.pop(dlc.dlci, None)
def on_l2cap_channel_close(self) -> None:
logger.debug('L2CAP channel closed, cleaning up')
if self.open_result:
self.open_result.cancel()
self.open_result = None
if self.disconnection_result:
self.disconnection_result.cancel()
self.disconnection_result = None
for dlc in self.dlcs.values():
dlc.abort()
def __str__(self) -> str: def __str__(self) -> str:
return f'Multiplexer(state={self.state.name})' return f'Multiplexer(state={self.state.name})'
@@ -957,15 +1065,13 @@ class Client:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Server(EventEmitter): class Server(EventEmitter):
acceptors: Dict[int, Callable[[DLC], None]]
def __init__( def __init__(
self, device: Device, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU self, device: Device, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
) -> None: ) -> None:
super().__init__() super().__init__()
self.device = device self.device = device
self.multiplexer = None self.acceptors: Dict[int, Callable[[DLC], None]] = {}
self.acceptors = {} self.dlc_configs: Dict[int, Tuple[int, int]] = {}
# Register ourselves with the L2CAP channel manager # Register ourselves with the L2CAP channel manager
self.l2cap_server = device.create_l2cap_server( self.l2cap_server = device.create_l2cap_server(
@@ -973,7 +1079,13 @@ class Server(EventEmitter):
handler=self.on_connection, handler=self.on_connection,
) )
def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int: def listen(
self,
acceptor: Callable[[DLC], None],
channel: int = 0,
max_frame_size: int = RFCOMM_DEFAULT_MAX_FRAME_SIZE,
initial_credits: int = RFCOMM_DEFAULT_INITIAL_CREDITS,
) -> int:
if channel: if channel:
if channel in self.acceptors: if channel in self.acceptors:
# Busy # Busy
@@ -993,6 +1105,8 @@ class Server(EventEmitter):
return 0 return 0
self.acceptors[channel] = acceptor self.acceptors[channel] = acceptor
self.dlc_configs[channel] = (max_frame_size, initial_credits)
return channel return channel
def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None: def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
@@ -1010,15 +1124,14 @@ class Server(EventEmitter):
# Notify # Notify
self.emit('start', multiplexer) self.emit('start', multiplexer)
def accept_dlc(self, channel_number: int) -> bool: def accept_dlc(self, channel_number: int) -> Optional[Tuple[int, int]]:
return channel_number in self.acceptors return self.dlc_configs.get(channel_number)
def on_dlc(self, dlc: DLC) -> None: def on_dlc(self, dlc: DLC) -> None:
logger.debug(f'@@@ new DLC connected: {dlc}') logger.debug(f'@@@ new DLC connected: {dlc}')
# Let the acceptor know # Let the acceptor know
acceptor = self.acceptors.get(dlc.dlci >> 1) if acceptor := self.acceptors.get(dlc.dlci >> 1):
if acceptor:
acceptor(dlc) acceptor(dlc)
def __enter__(self) -> Self: def __enter__(self) -> Self:
+13 -9
View File
@@ -825,11 +825,13 @@ class Client:
) )
attribute_id_list = DataElement.sequence( attribute_id_list = DataElement.sequence(
[ [
DataElement.unsigned_integer( (
attribute_id[0], value_size=attribute_id[1] DataElement.unsigned_integer(
attribute_id[0], value_size=attribute_id[1]
)
if isinstance(attribute_id, tuple)
else DataElement.unsigned_integer_16(attribute_id)
) )
if isinstance(attribute_id, tuple)
else DataElement.unsigned_integer_16(attribute_id)
for attribute_id in attribute_ids for attribute_id in attribute_ids
] ]
) )
@@ -881,11 +883,13 @@ class Client:
attribute_id_list = DataElement.sequence( attribute_id_list = DataElement.sequence(
[ [
DataElement.unsigned_integer( (
attribute_id[0], value_size=attribute_id[1] DataElement.unsigned_integer(
attribute_id[0], value_size=attribute_id[1]
)
if isinstance(attribute_id, tuple)
else DataElement.unsigned_integer_16(attribute_id)
) )
if isinstance(attribute_id, tuple)
else DataElement.unsigned_integer_16(attribute_id)
for attribute_id in attribute_ids for attribute_id in attribute_ids
] ]
) )
@@ -993,7 +997,7 @@ class Server:
try: try:
handler(sdp_pdu) handler(sdp_pdu)
except Exception as error: except Exception as error:
logger.warning(f'{color("!!! Exception in handler:", "red")} {error}') logger.exception(f'{color("!!! Exception in handler:", "red")} {error}')
self.send_response( self.send_response(
SDP_ErrorResponse( SDP_ErrorResponse(
transaction_id=sdp_pdu.transaction_id, transaction_id=sdp_pdu.transaction_id,
+3 -3
View File
@@ -737,9 +737,9 @@ class Session:
# Create a future that can be used to wait for the session to complete # Create a future that can be used to wait for the session to complete
if self.is_initiator: if self.is_initiator:
self.pairing_result: Optional[ self.pairing_result: Optional[asyncio.Future[None]] = (
asyncio.Future[None] asyncio.get_running_loop().create_future()
] = asyncio.get_running_loop().create_future() )
else: else:
self.pairing_result = None self.pairing_result = None
+8 -4
View File
@@ -59,15 +59,13 @@ class TransportLostError(Exception):
# Typing Protocols # Typing Protocols
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class TransportSink(Protocol): class TransportSink(Protocol):
def on_packet(self, packet: bytes) -> None: def on_packet(self, packet: bytes) -> None: ...
...
class TransportSource(Protocol): class TransportSource(Protocol):
terminated: asyncio.Future[None] terminated: asyncio.Future[None]
def set_packet_sink(self, sink: TransportSink) -> None: def set_packet_sink(self, sink: TransportSink) -> None: ...
...
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -168,11 +166,13 @@ class PacketReader:
def __init__(self, source: io.BufferedReader) -> None: def __init__(self, source: io.BufferedReader) -> None:
self.source = source self.source = source
self.at_end = False
def next_packet(self) -> Optional[bytes]: def next_packet(self) -> Optional[bytes]:
# Get the packet type # Get the packet type
packet_type = self.source.read(1) packet_type = self.source.read(1)
if len(packet_type) != 1: if len(packet_type) != 1:
self.at_end = True
return None return None
# Get the packet info based on its type # Get the packet info based on its type
@@ -425,6 +425,10 @@ class SnoopingTransport(Transport):
class Source: class Source:
sink: TransportSink sink: TransportSink
@property
def metadata(self) -> dict[str, Any]:
return getattr(self.source, 'metadata', {})
def __init__(self, source: TransportSource, snooper: Snooper): def __init__(self, source: TransportSource, snooper: Snooper):
self.source = source self.source = source
self.snooper = snooper self.snooper = snooper
+110 -2
View File
@@ -23,11 +23,24 @@ import time
import usb.core import usb.core
import usb.util import usb.util
from typing import Optional
from usb.core import Device as UsbDevice
from usb.core import USBError
from usb.util import CTRL_TYPE_CLASS, CTRL_RECIPIENT_OTHER
from usb.legacy import REQ_SET_FEATURE, REQ_CLEAR_FEATURE, CLASS_HUB
from .common import Transport, ParserSource from .common import Transport, ParserSource
from .. import hci from .. import hci
from ..colors import color from ..colors import color
# -----------------------------------------------------------------------------
# Constant
# -----------------------------------------------------------------------------
USB_PORT_FEATURE_POWER = 8
POWER_CYCLE_DELAY = 1
RESET_DELAY = 3
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -113,9 +126,10 @@ async def open_pyusb_transport(spec: str) -> Transport:
self.loop.call_soon_threadsafe(self.stop_event.set) self.loop.call_soon_threadsafe(self.stop_event.set)
class UsbPacketSource(asyncio.Protocol, ParserSource): class UsbPacketSource(asyncio.Protocol, ParserSource):
def __init__(self, device, sco_enabled): def __init__(self, device, metadata, sco_enabled):
super().__init__() super().__init__()
self.device = device self.device = device
self.metadata = metadata
self.loop = asyncio.get_running_loop() self.loop = asyncio.get_running_loop()
self.queue = asyncio.Queue() self.queue = asyncio.Queue()
self.dequeue_task = None self.dequeue_task = None
@@ -213,9 +227,22 @@ async def open_pyusb_transport(spec: str) -> Transport:
usb_find = libusb_package.find usb_find = libusb_package.find
# Find the device according to the spec moniker # Find the device according to the spec moniker
power_cycle = False
if spec.startswith('!'):
power_cycle = True
spec = spec[1:]
if ':' in spec: if ':' in spec:
vendor_id, product_id = spec.split(':') vendor_id, product_id = spec.split(':')
device = usb_find(idVendor=int(vendor_id, 16), idProduct=int(product_id, 16)) device = usb_find(idVendor=int(vendor_id, 16), idProduct=int(product_id, 16))
elif '-' in spec:
def device_path(device):
if device.port_numbers:
return f'{device.bus}-{".".join(map(str, device.port_numbers))}'
else:
return str(device.bus)
device = usb_find(custom_match=lambda device: device_path(device) == spec)
else: else:
device_index = int(spec) device_index = int(spec)
devices = list( devices = list(
@@ -235,6 +262,17 @@ async def open_pyusb_transport(spec: str) -> Transport:
raise ValueError('device not found') raise ValueError('device not found')
logger.debug(f'USB Device: {device}') logger.debug(f'USB Device: {device}')
# Power Cycle the device
if power_cycle:
try:
device = await _power_cycle(device) # type: ignore
except Exception as e:
logging.debug(e)
logging.info(f"Unable to power cycle {hex(device.idVendor)} {hex(device.idProduct)}") # type: ignore
# Collect the metadata
device_metadata = {'vendor_id': device.idVendor, 'product_id': device.idProduct}
# Detach the kernel driver if needed # Detach the kernel driver if needed
if device.is_kernel_driver_active(0): if device.is_kernel_driver_active(0):
logger.debug("detaching kernel driver") logger.debug("detaching kernel driver")
@@ -289,9 +327,79 @@ async def open_pyusb_transport(spec: str) -> Transport:
# except usb.USBError: # except usb.USBError:
# logger.warning('failed to set alternate setting') # logger.warning('failed to set alternate setting')
packet_source = UsbPacketSource(device, sco_enabled) packet_source = UsbPacketSource(device, device_metadata, sco_enabled)
packet_sink = UsbPacketSink(device) packet_sink = UsbPacketSink(device)
packet_source.start() packet_source.start()
packet_sink.start() packet_sink.start()
return UsbTransport(device, packet_source, packet_sink) return UsbTransport(device, packet_source, packet_sink)
async def _power_cycle(device: UsbDevice) -> UsbDevice:
"""
For devices connected to compatible USB hubs: Performs a power cycle on a given USB device.
This involves temporarily disabling its port on the hub and then re-enabling it.
"""
device_path = f'{device.bus}-{".".join(map(str, device.port_numbers))}' # type: ignore
hub = _find_hub_by_device_path(device_path)
if hub:
try:
device_port = device.port_numbers[-1] # type: ignore
_set_port_status(hub, device_port, False)
await asyncio.sleep(POWER_CYCLE_DELAY)
_set_port_status(hub, device_port, True)
await asyncio.sleep(RESET_DELAY)
# Device needs to be find again otherwise it will appear as disconnected
return usb.core.find(idVendor=device.idVendor, idProduct=device.idProduct) # type: ignore
except USBError as e:
logger.error(f"Adjustment needed: Please revise the udev rule for device {hex(device.idVendor)}:{hex(device.idProduct)} for proper recognition.") # type: ignore
logger.error(e)
return device
def _set_port_status(device: UsbDevice, port: int, on: bool):
"""Sets the power status of a specific port on a USB hub."""
device.ctrl_transfer(
bmRequestType=CTRL_TYPE_CLASS | CTRL_RECIPIENT_OTHER,
bRequest=REQ_SET_FEATURE if on else REQ_CLEAR_FEATURE,
wIndex=port,
wValue=USB_PORT_FEATURE_POWER,
)
def _find_device_by_path(sys_path: str) -> Optional[UsbDevice]:
"""Finds a USB device based on its system path."""
bus_num, *port_parts = sys_path.split('-')
ports = [int(port) for port in port_parts[0].split('.')]
devices = usb.core.find(find_all=True, bus=int(bus_num))
if devices:
for device in devices:
if device.bus == int(bus_num) and list(device.port_numbers) == ports: # type: ignore
return device
return None
def _find_hub_by_device_path(sys_path: str) -> Optional[UsbDevice]:
"""Finds the USB hub associated with a specific device path."""
hub_sys_path = sys_path.rsplit('.', 1)[0]
hub_device = _find_device_by_path(hub_sys_path)
if hub_device is None:
return None
else:
return hub_device if _is_hub(hub_device) else None
def _is_hub(device: UsbDevice) -> bool:
"""Checks if a USB device is a hub"""
if device.bDeviceClass == CLASS_HUB: # type: ignore
return True
for config in device:
for interface in config:
if interface.bInterfaceClass == CLASS_HUB: # type: ignore
return True
return False
+25 -5
View File
@@ -18,6 +18,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
import socket
from .common import Transport, StreamPacketSource from .common import Transport, StreamPacketSource
@@ -28,6 +29,13 @@ logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# A pass-through function to ease mock testing.
async def _create_server(*args, **kw_args):
await asyncio.get_running_loop().create_server(*args, **kw_args)
async def open_tcp_server_transport(spec: str) -> Transport: async def open_tcp_server_transport(spec: str) -> Transport:
''' '''
Open a TCP server transport. Open a TCP server transport.
@@ -38,7 +46,22 @@ async def open_tcp_server_transport(spec: str) -> Transport:
Example: _:9001 Example: _:9001
''' '''
local_host, local_port = spec.split(':')
return await _open_tcp_server_transport_impl(
host=local_host if local_host != '_' else None, port=int(local_port)
)
async def open_tcp_server_transport_with_socket(sock: socket.socket) -> Transport:
'''
Open a TCP server transport with an existing socket.
One reason to use this variant is to let python pick an unused port.
'''
return await _open_tcp_server_transport_impl(sock=sock)
async def _open_tcp_server_transport_impl(**kwargs) -> Transport:
class TcpServerTransport(Transport): class TcpServerTransport(Transport):
async def close(self): async def close(self):
await super().close() await super().close()
@@ -77,13 +100,10 @@ async def open_tcp_server_transport(spec: str) -> Transport:
else: else:
logger.debug('no client, dropping packet') logger.debug('no client, dropping packet')
local_host, local_port = spec.split(':')
packet_source = StreamPacketSource() packet_source = StreamPacketSource()
packet_sink = TcpServerPacketSink() packet_sink = TcpServerPacketSink()
await asyncio.get_running_loop().create_server( await _create_server(
lambda: TcpServerProtocol(packet_source, packet_sink), lambda: TcpServerProtocol(packet_source, packet_sink), **kwargs
host=local_host if local_host != '_' else None,
port=int(local_port),
) )
return TcpServerTransport(packet_source, packet_sink) return TcpServerTransport(packet_source, packet_sink)
+11 -1
View File
@@ -396,6 +396,16 @@ async def open_usb_transport(spec: str) -> Transport:
break break
device_index -= 1 device_index -= 1
device.close() device.close()
elif '-' in spec:
def device_path(device):
return f'{device.getBusNumber()}-{".".join(map(str, device.getPortNumberList()))}'
for device in context.getDeviceIterator(skip_on_error=True):
if device_path(device) == spec:
found = device
break
device.close()
else: else:
# Look for a compatible device by index # Look for a compatible device by index
def device_is_bluetooth_hci(device): def device_is_bluetooth_hci(device):
@@ -439,7 +449,7 @@ async def open_usb_transport(spec: str) -> Transport:
# Look for the first interface with the right class and endpoints # Look for the first interface with the right class and endpoints
def find_endpoints(device): def find_endpoints(device):
# pylint: disable-next=too-many-nested-blocks # pylint: disable-next=too-many-nested-blocks
for (configuration_index, configuration) in enumerate(device): for configuration_index, configuration in enumerate(device):
interface = None interface = None
for interface in configuration: for interface in configuration:
setting = None setting = None
+10 -8
View File
@@ -117,12 +117,12 @@ class EventWatcher:
self.handlers = [] self.handlers = []
@overload @overload
def on(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]: def on(
... self, emitter: EventEmitter, event: str
) -> Callable[[_Handler], _Handler]: ...
@overload @overload
def on(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler: def on(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler: ...
...
def on( def on(
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
@@ -144,12 +144,14 @@ class EventWatcher:
return wrapper if handler is None else wrapper(handler) return wrapper if handler is None else wrapper(handler)
@overload @overload
def once(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]: def once(
... self, emitter: EventEmitter, event: str
) -> Callable[[_Handler], _Handler]: ...
@overload @overload
def once(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler: def once(
... self, emitter: EventEmitter, event: str, handler: _Handler
) -> _Handler: ...
def once( def once(
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
@@ -12,12 +12,25 @@ a host that send custom HCI commands that the controller may not understand.
``` ```
python hci_bridge.py <host-transport-spec> <controller-transport-spec> [command-short-circuit-list] python hci_bridge.py <host-transport-spec> <controller-transport-spec> [command-short-circuit-list]
``` ```
The command-short-circuit-list field is specified by a series of comma separated Opcode Group
Field (OGF) : OpCode Command Field (OCF) pairs. The OGF/OCF values are specified in the Blutooth
core specification.
For the commands that are listed in the short-circuit-list, the HCI bridge will always generate
a Command Complete Event for the specified op code. The return parameter will be HCI_SUCCESS.
This feature can only be used for commands that return Command Complete. Other events will not be
generated by the HCI bridge tool.
!!! example "UDP to Serial" !!! example "UDP to Serial"
``` ```
python hci_bridge.py udp:0.0.0.0:9000,127.0.0.1:9001 serial:/dev/tty.usbmodem0006839912171,1000000 0x3f:0x0070,0x3f:0x0074,0x3f:0x0077,0x3f:0x0078 python hci_bridge.py udp:0.0.0.0:9000,127.0.0.1:9001 serial:/dev/tty.usbmodem0006839912171,1000000 0x3f:0x0070,0x3f:0x0074,0x3f:0x0077,0x3f:0x0078
``` ```
In this example, the short circuit list is specified to respond to the Vendor-specific Opcode Group
Field (0x3f) commands 0x70, 0x74, 0x77, 0x78 with Command Complete. The short circuit list can be
used where the Host uses some HCI commands that are not supported/implemented by the Controller.
!!! example "PTY to Link Relay" !!! example "PTY to Link Relay"
``` ```
python hci_bridge.py serial:emulated_uart_pty,1000000 link-relay:ws://127.0.0.1:10723/test python hci_bridge.py serial:emulated_uart_pty,1000000 link-relay:ws://127.0.0.1:10723/test
@@ -28,3 +41,4 @@ a host that send custom HCI commands that the controller may not understand.
(through which the communication with other virtual controllers will be mediated). (through which the communication with other virtual controllers will be mediated).
NOTE: this assumes you're running a Link Relay on port `10723`. NOTE: this assumes you're running a Link Relay on port `10723`.
+1 -1
View File
@@ -10,7 +10,7 @@ used with particular HCI controller.
When the transport for an HCI controller is instantiated from a transport name, When the transport for an HCI controller is instantiated from a transport name,
a driver may also be forced by specifying ``driver=<driver-name>`` in the optional a driver may also be forced by specifying ``driver=<driver-name>`` in the optional
metadata portion of the transport name. For example, metadata portion of the transport name. For example,
``usb:[driver=-rtk]0`` indicates that the ``rtk`` driver should be used with the ``usb:[driver=rtk]0`` indicates that the ``rtk`` driver should be used with the
first USB device, even if a normal probe would not have selected it based on the first USB device, even if a normal probe would not have selected it based on the
USB vendor ID and product ID. USB vendor ID and product ID.
+6
View File
@@ -10,6 +10,7 @@ The moniker for a USB transport is either:
* `usb:<vendor>:<product>` * `usb:<vendor>:<product>`
* `usb:<vendor>:<product>/<serial-number>` * `usb:<vendor>:<product>/<serial-number>`
* `usb:<vendor>:<product>#<index>` * `usb:<vendor>:<product>#<index>`
* `usb:<bus>-<port_numbers>`
with `<index>` as a 0-based index (0 being the first one) to select amongst all the matching devices when there are more than one. with `<index>` as a 0-based index (0 being the first one) to select amongst all the matching devices when there are more than one.
In the `usb:<index>` form, matching devices are the ones supporting Bluetooth HCI, as declared by their Class, Subclass and Protocol. In the `usb:<index>` form, matching devices are the ones supporting Bluetooth HCI, as declared by their Class, Subclass and Protocol.
@@ -17,6 +18,8 @@ In the `usb:<vendor>:<product>#<index>` form, matching devices are the ones with
`<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal. `<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal.
with `<port_numbers>` as a list of all port numbers from root separated with dots `.`
In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode: In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
the first USB interface of the device will be used, regardless of the interface class/subclass. the first USB interface of the device will be used, regardless of the interface class/subclass.
This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is. This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
@@ -37,6 +40,9 @@ This may be useful for some devices that use a custom class/subclass but may non
`usb:0B05:17CB!` `usb:0B05:17CB!`
The BT USB dongle vendor=0B05 and product=17CB, in "forced" mode. The BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
`usb:3-3.4.1`
The BT USB dongle on bus 3 on port path 3, 4, 1.
## Alternative ## Alternative
The library includes two different implementations of the USB transport, implemented using different python bindings for `libusb`. The library includes two different implementations of the USB transport, implemented using different python bindings for `libusb`.
+2 -1
View File
@@ -25,6 +25,7 @@ from bumble.utils import AsyncRunner
my_work_queue1 = AsyncRunner.WorkQueue() my_work_queue1 = AsyncRunner.WorkQueue()
my_work_queue2 = AsyncRunner.WorkQueue(create_task=False) my_work_queue2 = AsyncRunner.WorkQueue(create_task=False)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@AsyncRunner.run_in_task() @AsyncRunner.run_in_task()
async def func1(x, y): async def func1(x, y):
@@ -60,7 +61,7 @@ async def func4(x, y):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
print("MAIN: start, loop=", asyncio.get_running_loop()) print("MAIN: start, loop=", asyncio.get_running_loop())
print("MAIN: invoke func1") print("MAIN: invoke func1")
func1(1, 2) func1(1, 2)
+9 -3
View File
@@ -21,23 +21,29 @@ import os
import logging import logging
from bumble.colors import color from bumble.colors import color
from bumble.device import Device from bumble.device import Device
from bumble.hci import Address
from bumble.transport import open_transport from bumble.transport import open_transport
from bumble.profiles.battery_service import BatteryServiceProxy from bumble.profiles.battery_service import BatteryServiceProxy
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) != 3: if len(sys.argv) != 3:
print('Usage: battery_client.py <transport-spec> <bluetooth-address>') print('Usage: battery_client.py <transport-spec> <bluetooth-address>')
print('example: battery_client.py usb:0 E1:CA:72:48:C4:E8') print('example: battery_client.py usb:0 E1:CA:72:48:C4:E8')
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport(sys.argv[1]) as (hci_source, hci_sink): async with await open_transport(sys.argv[1]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create and start a device # Create and start a device
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink) device = Device.with_hci(
'Bumble',
Address('F0:F1:F2:F3:F4:F5'),
hci_transport.source,
hci_transport.sink,
)
await device.power_on() await device.power_on()
# Connect to the peer # Connect to the peer
+5 -3
View File
@@ -29,14 +29,16 @@ from bumble.profiles.battery_service import BatteryService
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) != 3: if len(sys.argv) != 3:
print('Usage: python battery_server.py <device-config> <transport-spec>') print('Usage: python battery_server.py <device-config> <transport-spec>')
print('example: python battery_server.py device1.json usb:0') print('example: python battery_server.py device1.json usb:0')
return return
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
# Add a Battery Service to the GATT sever # Add a Battery Service to the GATT sever
battery_service = BatteryService(lambda _: random.randint(0, 100)) battery_service = BatteryService(lambda _: random.randint(0, 100))
+9 -3
View File
@@ -21,12 +21,13 @@ import os
import logging import logging
from bumble.colors import color from bumble.colors import color
from bumble.device import Device, Peer from bumble.device import Device, Peer
from bumble.hci import Address
from bumble.profiles.device_information_service import DeviceInformationServiceProxy from bumble.profiles.device_information_service import DeviceInformationServiceProxy
from bumble.transport import open_transport from bumble.transport import open_transport
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) != 3: if len(sys.argv) != 3:
print( print(
'Usage: device_information_client.py <transport-spec> <bluetooth-address>' 'Usage: device_information_client.py <transport-spec> <bluetooth-address>'
@@ -35,11 +36,16 @@ async def main():
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport(sys.argv[1]) as (hci_source, hci_sink): async with await open_transport(sys.argv[1]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create and start a device # Create and start a device
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink) device = Device.with_hci(
'Bumble',
Address('F0:F1:F2:F3:F4:F5'),
hci_transport.source,
hci_transport.sink,
)
await device.power_on() await device.power_on()
# Connect to the peer # Connect to the peer
+6 -4
View File
@@ -28,14 +28,16 @@ from bumble.profiles.device_information_service import DeviceInformationService
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) != 3: if len(sys.argv) != 3:
print('Usage: python device_info_server.py <device-config> <transport-spec>') print('Usage: python device_info_server.py <device-config> <transport-spec>')
print('example: python device_info_server.py device1.json usb:0') print('example: python device_info_server.py device1.json usb:0')
return return
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
# Add a Device Information Service to the GATT sever # Add a Device Information Service to the GATT sever
device_information_service = DeviceInformationService( device_information_service = DeviceInformationService(
@@ -64,7 +66,7 @@ async def main():
# Go! # Go!
await device.power_on() await device.power_on()
await device.start_advertising(auto_restart=True) await device.start_advertising(auto_restart=True)
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+9 -3
View File
@@ -21,23 +21,29 @@ import os
import logging import logging
from bumble.colors import color from bumble.colors import color
from bumble.device import Device from bumble.device import Device
from bumble.hci import Address
from bumble.transport import open_transport from bumble.transport import open_transport
from bumble.profiles.heart_rate_service import HeartRateServiceProxy from bumble.profiles.heart_rate_service import HeartRateServiceProxy
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) != 3: if len(sys.argv) != 3:
print('Usage: heart_rate_client.py <transport-spec> <bluetooth-address>') print('Usage: heart_rate_client.py <transport-spec> <bluetooth-address>')
print('example: heart_rate_client.py usb:0 E1:CA:72:48:C4:E8') print('example: heart_rate_client.py usb:0 E1:CA:72:48:C4:E8')
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport(sys.argv[1]) as (hci_source, hci_sink): async with await open_transport(sys.argv[1]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create and start a device # Create and start a device
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink) device = Device.with_hci(
'Bumble',
Address('F0:F1:F2:F3:F4:F5'),
hci_transport.source,
hci_transport.sink,
)
await device.power_on() await device.power_on()
# Connect to the peer # Connect to the peer
+5 -3
View File
@@ -33,14 +33,16 @@ from bumble.utils import AsyncRunner
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) != 3: if len(sys.argv) != 3:
print('Usage: python heart_rate_server.py <device-config> <transport-spec>') print('Usage: python heart_rate_server.py <device-config> <transport-spec>')
print('example: python heart_rate_server.py device1.json usb:0') print('example: python heart_rate_server.py device1.json usb:0')
return return
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
# Keep track of accumulated expended energy # Keep track of accumulated expended energy
energy_start_time = time.time() energy_start_time = time.time()
+350
View File
@@ -0,0 +1,350 @@
<html data-bs-theme="dark">
<head>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<script src="https://unpkg.com/pcm-player"></script>
</head>
<body>
<nav class="navbar navbar-dark bg-primary">
<div class="container">
<span class="navbar-brand mb-0 h1">Bumble HFP Audio Gateway</span>
</div>
</nav>
<br>
<div class="container">
<label class="form-label">Send AT Response</label>
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="AT Response" aria-label="AT response" id="at_response">
<button class="btn btn-primary" type="button"
onclick="send_at_response(document.getElementById('at_response').value)">Send</button>
</div>
<div class="row">
<div class="col-3">
<label class="form-label">Speaker Volume</label>
<div class="input-group mb-3 col-auto">
<input type="text" class="form-control" placeholder="0 - 15" aria-label="Speaker Volume"
id="speaker_volume">
<button class="btn btn-primary" type="button"
onclick="send_at_response(`+VGS: ${document.getElementById('speaker_volume').value}`)">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">Mic Volume</label>
<div class="input-group mb-3 col-auto">
<input type="text" class="form-control" placeholder="0 - 15" aria-label="Mic Volume"
id="mic_volume">
<button class="btn btn-primary" type="button"
onclick="send_at_response(`+VGM: ${document.getElementById('mic_volume').value}`)">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">Browser Gain</label>
<input type="range" class="form-range" id="browser-gain" min="0" max="2" value="1" step="0.1" onchange="setGain()">
</div>
</div>
<div class="row">
<div class="col-auto">
<div class="input-group mb-3">
<span class="input-group-text">Codec</span>
<select class="form-select" id="codec">
<option selected value="1">CVSD</option>
<option value="2">MSBC</option>
</select>
</div>
</div>
<div class="col-auto">
<button class="btn btn-primary" onclick="negotiate_codec()">Negotiate Codec</button>
</div>
<div class="col-auto">
<button class="btn btn-primary" onclick="connect_sco()">Connect SCO</button>
</div>
<div class="col-auto">
<button class="btn btn-primary" onclick="disconnect_sco()">Disconnect SCO</button>
</div>
<div class="col-auto">
<button class="btn btn-danger" onclick="connectAudio()">Connect Audio</button>
</div>
</div>
<hr>
<div class="row">
<h4>AG Indicators</h2>
<div class="col-3">
<label class="form-label">call</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="call">
<option selected value="0">Inactive</option>
<option value="1">Active</option>
</select>
<button class="btn btn-primary" type="button" onclick="update_ag_indicator('call')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">callsetup</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="callsetup">
<option selected value="0">Idle</option>
<option value="1">Incoming</option>
<option value="2">Outgoing</option>
<option value="3">Remote Alerted</option>
</select>
<button class="btn btn-primary" type="button"
onclick="update_ag_indicator('callsetup')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">callheld</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="callsetup">
<option selected value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
</select>
<button class="btn btn-primary" type="button"
onclick="update_ag_indicator('callheld')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">signal</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="signal">
<option selected value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
<button class="btn btn-primary" type="button"
onclick="update_ag_indicator('signal')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">roam</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="roam">
<option selected value="0">0</option>
<option value="1">1</option>
</select>
<button class="btn btn-primary" type="button" onclick="update_ag_indicator('roam')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">battchg</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="battchg">
<option selected value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
<button class="btn btn-primary" type="button"
onclick="update_ag_indicator('battchg')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">service</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="service">
<option selected value="0">0</option>
<option value="1">1</option>
</select>
<button class="btn btn-primary" type="button"
onclick="update_ag_indicator('service')">Set</button>
</div>
</div>
</div>
<hr>
<button class="btn btn-primary" onclick="send_at_response('+BVRA: 1')">Start Voice Assistant</button>
<button class="btn btn-primary" onclick="send_at_response('+BVRA: 0')">Stop Voice Assistant</button>
<hr>
<h4>Calls</h4>
<div id="call-lists">
<template id="call-template">
<div class="row call-row">
<div class="input-group mb-3">
<label class="input-group-text">Index</label>
<input class="form-control call-index" value="1">
<label class="input-group-text">Number</label>
<input class="form-control call-number">
<label class="input-group-text">Direction</label>
<select class="form-select call-direction">
<option selected value="0">Originated</option>
<option value="1">Terminated</option>
</select>
<label class="input-group-text">Status</label>
<select class="form-select call-status">
<option value="0">ACTIVE</option>
<option value="1">HELD</option>
<option value="2">DIALING</option>
<option value="3">ALERTING</option>
<option value="4">INCOMING</option>
<option value="5">WAITING</option>
</select>
<button class="btn btn-primary call-remover"></button>
</div>
</div>
</template>
</div>
<button class="btn btn-primary" onclick="add_call()"> Add Call</button>
<button class="btn btn-primary" onclick="update_calls()">🗘 Update Calls</button>
<hr>
<div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2">
<h3>Log</h3>
<code id="log" style="white-space: pre-line;"></code>
</div>
</div>
<script>
let atResponseInput = document.getElementById("at_response")
let gainInput = document.getElementById('browser-gain')
let log = document.getElementById("log")
let socket = new WebSocket('ws://localhost:8888');
let sampleRate = 0;
let player;
socket.binaryType = "arraybuffer";
socket.onopen = _ => {
log.textContent += 'SOCKET OPEN\n'
}
socket.onclose = _ => {
log.textContent += 'SOCKET CLOSED\n'
}
socket.onerror = (error) => {
log.textContent += 'SOCKET ERROR\n'
console.log(`ERROR: ${error}`)
}
socket.onmessage = function (message) {
if (typeof message.data === 'string' || message.data instanceof String) {
log.textContent += `<-- ${event.data}\n`
const jsonMessage = JSON.parse(event.data)
if (jsonMessage.type == 'speaker_volume') {
document.getElementById('speaker_volume').value = jsonMessage.level;
} else if (jsonMessage.type == 'microphone_volume') {
document.getElementById('microphone_volume').value = jsonMessage.level;
} else if (jsonMessage.type == 'sco_state_change') {
sampleRate = jsonMessage.sample_rate;
console.log(sampleRate);
if (player != null) {
player = new PCMPlayer({
inputCodec: 'Int16',
channels: 1,
sampleRate: sampleRate,
flushTime: 7.5,
});
player.volume(gainInput.value);
}
}
} else {
// BINARY audio data.
if (player == null) return;
player.feed(message.data);
}
};
function send(message) {
if (socket && socket.readyState == WebSocket.OPEN) {
let jsonMessage = JSON.stringify(message)
log.textContent += `--> ${jsonMessage}\n`
socket.send(jsonMessage)
} else {
log.textContent += 'NOT CONNECTED\n'
}
}
function send_at_response(response) {
send({ type: 'at_response', response: response })
}
function update_ag_indicator(indicator) {
const value = document.getElementById(indicator).value
send({ type: 'ag_indicator', indicator: indicator, value: value })
}
function connect_sco() {
send({ type: 'connect_sco' })
}
function negotiate_codec() {
const codec = document.getElementById('codec').value
send({ type: 'negotiate_codec', codec: codec })
}
function disconnect_sco() {
send({ type: 'disconnect_sco' })
}
function add_call() {
let callLists = document.getElementById('call-lists');
let template = document.getElementById('call-template');
let newNode = document.importNode(template.content, true);
newNode.querySelector('.call-remover').onclick = function (event) {
event.target.closest('.call-row').remove();
}
callLists.appendChild(newNode);
}
function update_calls() {
let callLists = document.getElementById('call-lists');
send({
type: 'update_calls',
calls: Array.from(
callLists.querySelectorAll('.call-row')).map(
function (element) {
return {
index: element.querySelector('.call-index').value,
number: element.querySelector('.call-number').value,
direction: element.querySelector('.call-direction').value,
status: element.querySelector('.call-status').value,
}
}
),
}
)
}
function connectAudio() {
player = new PCMPlayer({
inputCodec: 'Int16',
channels: 1,
sampleRate: sampleRate,
flushTime: 7.5,
});
player.volume(gainInput.value);
}
function setGain() {
if (player != null) {
player.volume(gainInput.value);
}
}
</script>
</div>
</body>
</html>
+2 -1
View File
@@ -1,4 +1,5 @@
{ {
"name": "Bumble Phone", "name": "Bumble Phone",
"class_of_device": 6291980 "class_of_device": 6291980,
"keystore": "JsonKeyStore"
} }
+107 -54
View File
@@ -1,79 +1,132 @@
<html> <html data-bs-theme="dark">
<head>
<style>
* {
font-family: sans-serif;
}
label { <head>
display: block; <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
} integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
</head>
<body>
<nav class="navbar navbar-dark bg-primary">
<div class="container">
<span class="navbar-brand mb-0 h1">Bumble Handsfree</span>
</div>
</nav>
<br>
<div class="container">
<label class="form-label">Server Port</label>
<div class="input-group mb-3">
<input type="text" class="form-control" aria-label="Port Number" value="8989" id="port">
<button class="btn btn-primary" type="button" onclick="connect()">Connect</button>
</div>
<label class="form-label">Dial Phone Number</label>
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="Phone Number" aria-label="Phone Number"
id="dial_number">
<button class="btn btn-primary" type="button"
onclick="send_at_command(`ATD${dialNumberInput.value}`)">Dial</button>
</div>
<label class="form-label">Send AT Command</label>
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="AT Command" aria-label="AT command" id="at_command">
<button class="btn btn-primary" type="button"
onclick="send_at_command(document.getElementById('at_command').value)">Send</button>
</div>
<div class="row">
<div class="col-auto">
<label class="form-label">Battery Level</label>
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="0 - 100" aria-label="Battery Level"
id="battery_level">
<button class="btn btn-primary" type="button"
onclick="send_at_command(`AT+BIEV=2,${document.getElementById('battery_level').value}`)">Set</button>
</div>
</div>
<div class="col-auto">
<label class="form-label">Speaker Volume</label>
<div class="input-group mb-3 col-auto">
<input type="text" class="form-control" placeholder="0 - 15" aria-label="Speaker Volume"
id="speaker_volume">
<button class="btn btn-primary" type="button"
onclick="send_at_command(`AT+VGS=${document.getElementById('speaker_volume').value}`)">Set</button>
</div>
</div>
<div class="col-auto">
<label class="form-label">Mic Volume</label>
<div class="input-group mb-3 col-auto">
<input type="text" class="form-control" placeholder="0 - 15" aria-label="Mic Volume"
id="mic_volume">
<button class="btn btn-primary" type="button"
onclick="send_at_command(`AT+VGM=${document.getElementById('mic_volume').value}`)">Set</button>
</div>
</div>
</div>
<button class="btn btn-primary" onclick="send_at_command('ATA')">Answer</button>
<button class="btn btn-primary" onclick="send_at_command('AT+CHUP')">Hang Up</button>
<button class="btn btn-primary" onclick="send_at_command('AT+BLDN')">Redial</button>
<button class="btn btn-primary" onclick="send({ type: 'query_call'})">Get Call Status</button>
<br><br>
<button class="btn btn-primary" onclick="send_at_command('AT+BVRA=1')">Start Voice Assistant</button>
<button class="btn btn-primary" onclick="send_at_command('AT+BVRA=0')">Stop Voice Assistant</button>
input, label {
margin: .4rem 0;
}
</style>
</head>
<body>
Server Port <input id="port" type="text" value="8989"></input> <button onclick="connect()">Connect</button><br>
AT Command <input type="text" id="at_command" required size="10"> <button onclick="send_at_command()">Send</button><br>
Dial Phone Number <input type="text" id="dial_number" required size="10"> <button onclick="dial()">Dial</button><br>
<button onclick="answer()">Answer</button>
<button onclick="hangup()">Hang Up</button>
<button onclick="start_voice_assistant()">Start Voice Assistant</button>
<button onclick="stop_voice_assistant()">Stop Voice Assistant</button>
<hr> <hr>
<div id="socketState"></div>
<script> <div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2">
<h3>Log</h3>
<code id="log" style="white-space: pre-line;"></code>
</div>
</div>
<script>
let portInput = document.getElementById("port") let portInput = document.getElementById("port")
let atCommandInput = document.getElementById("at_command") let atCommandInput = document.getElementById("at_command")
let dialNumberInput = document.getElementById("dial_number") let log = document.getElementById("log")
let socketState = document.getElementById("socketState")
let socket let socket
function connect() { function connect() {
socket = new WebSocket(`ws://localhost:${portInput.value}`); socket = new WebSocket(`ws://localhost:${portInput.value}`);
socket.onopen = _ => { socket.onopen = _ => {
socketState.innerText = 'OPEN' log.textContent += 'OPEN\n'
} }
socket.onclose = _ => { socket.onclose = _ => {
socketState.innerText = 'CLOSED' log.textContent += 'CLOSED\n'
} }
socket.onerror = (error) => { socket.onerror = (error) => {
socketState.innerText = 'ERROR' log.textContent += 'ERROR\n'
console.log(`ERROR: ${error}`) console.log(`ERROR: ${error}`)
} }
socket.onmessage = (event) => {
log.textContent += `<-- ${event.data}\n`
let volume_state = JSON.parse(event.data)
volumeSetting.value = volume_state.volume_setting
changeCounter.value = volume_state.change_counter
muted.checked = volume_state.muted ? true : false
}
} }
function send(message) { function send(message) {
if (socket && socket.readyState == WebSocket.OPEN) { if (socket && socket.readyState == WebSocket.OPEN) {
socket.send(JSON.stringify(message)) let jsonMessage = JSON.stringify(message)
log.textContent += `--> ${jsonMessage}\n`
socket.send(jsonMessage)
} else {
log.textContent += 'NOT CONNECTED\n'
} }
} }
function send_at_command() { function send_at_command(command) {
send({ type:'at_command', command: atCommandInput.value }) send({ type: 'at_command', 'command': command })
} }
</script>
</div>
</body>
function answer() { </html>
send({ type:'at_command', command: 'ATA' })
}
function hangup() {
send({ type:'at_command', command: 'AT+CHUP' })
}
function dial() {
send({ type:'at_command', command: `ATD${dialNumberInput.value}` })
}
function start_voice_assistant() {
send(({ type:'at_command', command: 'AT+BVRA=1' }))
}
function stop_voice_assistant() {
send(({ type:'at_command', command: 'AT+BVRA=0' }))
}
</script>
</body>
</html>
+5 -3
View File
@@ -416,7 +416,7 @@ async def keyboard_device(device, command):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 4: if len(sys.argv) < 4:
print( print(
'Usage: python keyboard.py <device-config> <transport-spec> <command>' 'Usage: python keyboard.py <device-config> <transport-spec> <command>'
@@ -434,9 +434,11 @@ async def main():
) )
return return
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
# Create a device to manage the host # Create a device to manage the host
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
command = sys.argv[3] command = sys.argv[3]
if command == 'connect': if command == 'connect':
+6 -4
View File
@@ -139,18 +139,20 @@ async def find_a2dp_service(connection):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 4: if len(sys.argv) < 4:
print('Usage: run_a2dp_info.py <device-config> <transport-spec> <bt-addr>') print('Usage: run_a2dp_info.py <device-config> <transport-spec> <bt-addr>')
print('example: run_a2dp_info.py classic1.json usb:0 14:7D:DA:4E:53:A8') print('example: run_a2dp_info.py classic1.json usb:0 14:7D:DA:4E:53:A8')
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create a device # Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.classic_enabled = True device.classic_enabled = True
# Start the controller # Start the controller
@@ -187,7 +189,7 @@ async def main():
client = await AVDTP_Protocol.connect(connection, avdtp_version) client = await AVDTP_Protocol.connect(connection, avdtp_version)
# Discover all endpoints on the remote device # Discover all endpoints on the remote device
endpoints = await client.discover_remote_endpoints() endpoints = list(await client.discover_remote_endpoints())
print(f'@@@ Found {len(endpoints)} endpoints') print(f'@@@ Found {len(endpoints)} endpoints')
for endpoint in endpoints: for endpoint in endpoints:
print('@@@', endpoint) print('@@@', endpoint)
+8 -5
View File
@@ -19,6 +19,7 @@ import asyncio
import sys import sys
import os import os
import logging import logging
from typing import Any, Dict
from bumble.device import Device from bumble.device import Device
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
@@ -41,7 +42,7 @@ from bumble.a2dp import (
SbcMediaCodecInformation, SbcMediaCodecInformation,
) )
Context = {'output': None} Context: Dict[Any, Any] = {'output': None}
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -104,7 +105,7 @@ def on_rtp_packet(packet):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 4: if len(sys.argv) < 4:
print( print(
'Usage: run_a2dp_sink.py <device-config> <transport-spec> <sbc-file> ' 'Usage: run_a2dp_sink.py <device-config> <transport-spec> <sbc-file> '
@@ -114,14 +115,16 @@ async def main():
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
with open(sys.argv[3], 'wb') as sbc_file: with open(sys.argv[3], 'wb') as sbc_file:
Context['output'] = sbc_file Context['output'] = sbc_file
# Create a device # Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.classic_enabled = True device.classic_enabled = True
# Setup the SDP to expose the sink service # Setup the SDP to expose the sink service
@@ -162,7 +165,7 @@ async def main():
await device.set_discoverable(True) await device.set_discoverable(True)
await device.set_connectable(True) await device.set_connectable(True)
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+6 -4
View File
@@ -114,7 +114,7 @@ async def stream_packets(read_function, protocol):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 4: if len(sys.argv) < 4:
print( print(
'Usage: run_a2dp_source.py <device-config> <transport-spec> <sbc-file> ' 'Usage: run_a2dp_source.py <device-config> <transport-spec> <sbc-file> '
@@ -126,11 +126,13 @@ async def main():
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create a device # Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.classic_enabled = True device.classic_enabled = True
# Setup the SDP to expose the SRC service # Setup the SDP to expose the SRC service
@@ -186,7 +188,7 @@ async def main():
await device.set_discoverable(True) await device.set_discoverable(True)
await device.set_connectable(True) await device.set_connectable(True)
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+6 -4
View File
@@ -28,7 +28,7 @@ from bumble.transport import open_transport_or_link
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 3: if len(sys.argv) < 3:
print( print(
'Usage: run_advertiser.py <config-file> <transport-spec> [type] [address]' 'Usage: run_advertiser.py <config-file> <transport-spec> [type] [address]'
@@ -50,10 +50,12 @@ async def main():
target = None target = None
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
if advertising_type.is_scannable: if advertising_type.is_scannable:
device.scan_response_data = bytes( device.scan_response_data = bytes(
@@ -66,7 +68,7 @@ async def main():
await device.power_on() await device.power_on()
await device.start_advertising(advertising_type=advertising_type, target=target) await device.start_advertising(advertising_type=advertising_type, target=target)
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+6 -4
View File
@@ -49,7 +49,7 @@ ASHA_LE_PSM_OUT_CHARACTERISTIC = UUID(
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) != 4: if len(sys.argv) != 4:
print( print(
'Usage: python run_asha_sink.py <device-config> <transport-spec> ' 'Usage: python run_asha_sink.py <device-config> <transport-spec> '
@@ -60,8 +60,10 @@ async def main():
audio_out = open(sys.argv[3], 'wb') audio_out = open(sys.argv[3], 'wb')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
# Handler for audio control commands # Handler for audio control commands
def on_audio_control_point_write(_connection, value): def on_audio_control_point_write(_connection, value):
@@ -197,7 +199,7 @@ async def main():
await device.power_on() await device.power_on()
await device.start_advertising(auto_restart=True) await device.start_advertising(auto_restart=True)
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+5 -3
View File
@@ -331,7 +331,7 @@ class Delegate(avrcp.Delegate):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 3: if len(sys.argv) < 3:
print( print(
'Usage: run_avrcp_controller.py <device-config> <transport-spec> ' 'Usage: run_avrcp_controller.py <device-config> <transport-spec> '
@@ -341,11 +341,13 @@ async def main():
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create a device # Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.classic_enabled = True device.classic_enabled = True
# Setup the SDP to expose the sink service # Setup the SDP to expose the sink service
+5 -3
View File
@@ -32,7 +32,7 @@ from bumble.sdp import (
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 3: if len(sys.argv) < 3:
print( print(
'Usage: run_classic_connect.py <device-config> <transport-spec> ' 'Usage: run_classic_connect.py <device-config> <transport-spec> '
@@ -42,11 +42,13 @@ async def main():
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create a device # Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.classic_enabled = True device.classic_enabled = True
device.le_enabled = False device.le_enabled = False
await device.power_on() await device.power_on()
+6 -4
View File
@@ -91,18 +91,20 @@ SDP_SERVICE_RECORDS = {
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 3: if len(sys.argv) < 3:
print('Usage: run_classic_discoverable.py <device-config> <transport-spec>') print('Usage: run_classic_discoverable.py <device-config> <transport-spec>')
print('example: run_classic_discoverable.py classic1.json usb:04b4:f901') print('example: run_classic_discoverable.py classic1.json usb:04b4:f901')
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create a device # Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.classic_enabled = True device.classic_enabled = True
device.sdp_service_records = SDP_SERVICE_RECORDS device.sdp_service_records = SDP_SERVICE_RECORDS
await device.power_on() await device.power_on()
@@ -111,7 +113,7 @@ async def main():
await device.set_discoverable(True) await device.set_discoverable(True)
await device.set_connectable(True) await device.set_connectable(True)
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+10 -5
View File
@@ -20,8 +20,8 @@ import sys
import os import os
import logging import logging
from bumble.colors import color from bumble.colors import color
from bumble.device import Device from bumble.device import Device
from bumble.hci import Address
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
from bumble.core import DeviceClass from bumble.core import DeviceClass
@@ -53,22 +53,27 @@ class DiscoveryListener(Device.Listener):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) != 2: if len(sys.argv) != 2:
print('Usage: run_classic_discovery.py <transport-spec>') print('Usage: run_classic_discovery.py <transport-spec>')
print('example: run_classic_discovery.py usb:04b4:f901') print('example: run_classic_discovery.py usb:04b4:f901')
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[1]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[1]) as hci_transport:
print('<<< connected') print('<<< connected')
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink) device = Device.with_hci(
'Bumble',
Address('F0:F1:F2:F3:F4:F5'),
hci_transport.source,
hci_transport.sink,
)
device.listener = DiscoveryListener() device.listener = DiscoveryListener()
await device.power_on() await device.power_on()
await device.start_discovery() await device.start_discovery()
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+6 -4
View File
@@ -25,7 +25,7 @@ from bumble.transport import open_transport_or_link
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 3: if len(sys.argv) < 3:
print( print(
'Usage: run_connect_and_encrypt.py <device-config> <transport-spec> ' 'Usage: run_connect_and_encrypt.py <device-config> <transport-spec> '
@@ -37,11 +37,13 @@ async def main():
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create a device # Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
await device.power_on() await device.power_on()
# Connect to the peer # Connect to the peer
@@ -56,7 +58,7 @@ async def main():
print(f'!!! Encryption failed: {error}') print(f'!!! Encryption failed: {error}')
return return
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+7 -4
View File
@@ -36,7 +36,7 @@ from bumble.transport import open_transport_or_link
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) != 4: if len(sys.argv) != 4:
print( print(
'Usage: run_controller.py <controller-address> <device-config> ' 'Usage: run_controller.py <controller-address> <device-config> '
@@ -49,7 +49,7 @@ async def main():
return return
print('>>> connecting to HCI...') print('>>> connecting to HCI...')
async with await open_transport_or_link(sys.argv[3]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[3]) as hci_transport:
print('>>> connected') print('>>> connected')
# Create a local link # Create a local link
@@ -57,7 +57,10 @@ async def main():
# Create a first controller using the packet source/sink as its host interface # Create a first controller using the packet source/sink as its host interface
controller1 = Controller( controller1 = Controller(
'C1', host_source=hci_source, host_sink=hci_sink, link=link 'C1',
host_source=hci_transport.source,
host_sink=hci_transport.sink,
link=link,
) )
controller1.random_address = sys.argv[1] controller1.random_address = sys.argv[1]
@@ -98,7 +101,7 @@ async def main():
await device.start_advertising() await device.start_advertising()
await device.start_scanning() await device.start_scanning()
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+10 -7
View File
@@ -20,9 +20,9 @@ import asyncio
import sys import sys
import os import os
from bumble.colors import color from bumble.colors import color
from bumble.device import Device from bumble.device import Device
from bumble.controller import Controller from bumble.controller import Controller
from bumble.hci import Address
from bumble.link import LocalLink from bumble.link import LocalLink
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
@@ -45,14 +45,14 @@ class ScannerListener(Device.Listener):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) != 2: if len(sys.argv) != 2:
print('Usage: run_controller.py <transport-spec>') print('Usage: run_controller.py <transport-spec>')
print('example: run_controller_with_scanner.py serial:/dev/pts/14,1000000') print('example: run_controller_with_scanner.py serial:/dev/pts/14,1000000')
return return
print('>>> connecting to HCI...') print('>>> connecting to HCI...')
async with await open_transport_or_link(sys.argv[1]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[1]) as hci_transport:
print('>>> connected') print('>>> connected')
# Create a local link # Create a local link
@@ -60,22 +60,25 @@ async def main():
# Create a first controller using the packet source/sink as its host interface # Create a first controller using the packet source/sink as its host interface
controller1 = Controller( controller1 = Controller(
'C1', host_source=hci_source, host_sink=hci_sink, link=link 'C1',
host_source=hci_transport.source,
host_sink=hci_transport.sink,
link=link,
public_address='E0:E1:E2:E3:E4:E5',
) )
controller1.address = 'E0:E1:E2:E3:E4:E5'
# Create a second controller using the same link # Create a second controller using the same link
controller2 = Controller('C2', link=link) controller2 = Controller('C2', link=link)
# Create a device with a scanner listener # Create a device with a scanner listener
device = Device.with_hci( device = Device.with_hci(
'Bumble', 'F0:F1:F2:F3:F4:F5', controller2, controller2 'Bumble', Address('F0:F1:F2:F3:F4:F5'), controller2, controller2
) )
device.listener = ScannerListener() device.listener = ScannerListener()
await device.power_on() await device.power_on()
await device.start_scanning() await device.start_scanning()
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+11 -5
View File
@@ -20,30 +20,36 @@ import sys
import os import os
import logging import logging
from bumble.colors import color from bumble.colors import color
from bumble.hci import Address
from bumble.device import Device from bumble.device import Device
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
from bumble.snoop import BtSnooper from bumble.snoop import BtSnooper
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) != 3: if len(sys.argv) != 3:
print('Usage: run_device_with_snooper.py <transport-spec> <snoop-file>') print('Usage: run_device_with_snooper.py <transport-spec> <snoop-file>')
print('example: run_device_with_snooper.py usb:0 btsnoop.log') print('example: run_device_with_snooper.py usb:0 btsnoop.log')
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[1]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[1]) as hci_transport:
print('<<< connected') print('<<< connected')
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink) device = Device.with_hci(
'Bumble',
Address('F0:F1:F2:F3:F4:F5'),
hci_transport.source,
hci_transport.sink,
)
with open(sys.argv[2], "wb") as snoop_file: with open(sys.argv[2], "wb") as snoop_file:
device.host.snooper = BtSnooper(snoop_file) device.host.snooper = BtSnooper(snoop_file)
await device.power_on() await device.power_on()
await device.start_scanning() await device.start_scanning()
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+5 -3
View File
@@ -69,7 +69,7 @@ class Listener(Device.Listener):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 3: if len(sys.argv) < 3:
print( print(
'Usage: run_gatt_client.py <device-config> <transport-spec> ' 'Usage: run_gatt_client.py <device-config> <transport-spec> '
@@ -79,11 +79,13 @@ async def main():
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create a device to manage the host, with a custom listener # Create a device to manage the host, with a custom listener
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.listener = Listener(device) device.listener = Listener(device)
await device.power_on() await device.power_on()
+9 -5
View File
@@ -19,21 +19,21 @@ import asyncio
import os import os
import logging import logging
from bumble.colors import color from bumble.colors import color
from bumble.core import ProtocolError from bumble.core import ProtocolError
from bumble.controller import Controller from bumble.controller import Controller
from bumble.device import Device, Peer from bumble.device import Device, Peer
from bumble.hci import Address
from bumble.host import Host from bumble.host import Host
from bumble.link import LocalLink from bumble.link import LocalLink
from bumble.gatt import ( from bumble.gatt import (
Service, Service,
Characteristic, Characteristic,
Descriptor, Descriptor,
show_services,
GATT_CHARACTERISTIC_USER_DESCRIPTION_DESCRIPTOR, GATT_CHARACTERISTIC_USER_DESCRIPTION_DESCRIPTOR,
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC, GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
GATT_DEVICE_INFORMATION_SERVICE, GATT_DEVICE_INFORMATION_SERVICE,
) )
from bumble.gatt_client import show_services
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -43,7 +43,7 @@ class ServerListener(Device.Listener):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
# Create a local link # Create a local link
link = LocalLink() link = LocalLink()
@@ -51,14 +51,18 @@ async def main():
client_controller = Controller("client controller", link=link) client_controller = Controller("client controller", link=link)
client_host = Host() client_host = Host()
client_host.controller = client_controller client_host.controller = client_controller
client_device = Device("client", address='F0:F1:F2:F3:F4:F5', host=client_host) client_device = Device(
"client", address=Address('F0:F1:F2:F3:F4:F5'), host=client_host
)
await client_device.power_on() await client_device.power_on()
# Setup a stack for the server # Setup a stack for the server
server_controller = Controller("server controller", link=link) server_controller = Controller("server controller", link=link)
server_host = Host() server_host = Host()
server_host.controller = server_controller server_host.controller = server_controller
server_device = Device("server", address='F6:F7:F8:F9:FA:FB', host=server_host) server_device = Device(
"server", address=Address('F6:F7:F8:F9:FA:FB'), host=server_host
)
server_device.listener = ServerListener() server_device.listener = ServerListener()
await server_device.power_on() await server_device.power_on()
+6 -4
View File
@@ -71,7 +71,7 @@ def my_custom_write_with_error(connection, value):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 3: if len(sys.argv) < 3:
print( print(
'Usage: run_gatt_server.py <device-config> <transport-spec> ' 'Usage: run_gatt_server.py <device-config> <transport-spec> '
@@ -81,11 +81,13 @@ async def main():
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create a device to manage the host # Create a device to manage the host
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.listener = Listener(device) device.listener = Listener(device)
# Add a few entries to the device's GATT server # Add a few entries to the device's GATT server
@@ -146,7 +148,7 @@ async def main():
else: else:
await device.start_advertising(auto_restart=True) await device.start_advertising(auto_restart=True)
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+213 -183
View File
@@ -16,240 +16,270 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import asyncio import asyncio
import json
import sys import sys
import os import os
import io
import logging import logging
import websockets
from bumble.colors import color from typing import Optional
import bumble.core import bumble.core
from bumble.device import Device from bumble.device import Device, ScoLink
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
from bumble.core import ( from bumble.core import (
BT_HANDSFREE_SERVICE,
BT_RFCOMM_PROTOCOL_ID,
BT_BR_EDR_TRANSPORT, BT_BR_EDR_TRANSPORT,
) )
from bumble import rfcomm, hfp from bumble import hci, rfcomm, hfp
from bumble.hci import HCI_SynchronousDataPacket
from bumble.sdp import (
Client as SDP_Client,
DataElement,
ServiceAttribute,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ws: Optional[websockets.WebSocketServerProtocol] = None
ag_protocol: Optional[hfp.AgProtocol] = None
source_file: Optional[io.BufferedReader] = None
# -----------------------------------------------------------------------------
# pylint: disable-next=too-many-nested-blocks
async def list_rfcomm_channels(device, connection):
# Connect to the SDP Server
sdp_client = SDP_Client(connection)
await sdp_client.connect()
# Search for services that support the Handsfree Profile def _default_configuration() -> hfp.AgConfiguration:
search_result = await sdp_client.search_attributes( return hfp.AgConfiguration(
[BT_HANDSFREE_SERVICE], supported_ag_features=[
[ hfp.AgFeature.HF_INDICATORS,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID, hfp.AgFeature.IN_BAND_RING_TONE_CAPABILITY,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID, hfp.AgFeature.REJECT_CALL,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID, hfp.AgFeature.CODEC_NEGOTIATION,
hfp.AgFeature.ESCO_S4_SETTINGS_SUPPORTED,
hfp.AgFeature.ENHANCED_CALL_STATUS,
], ],
supported_ag_indicators=[
hfp.AgIndicatorState.call(),
hfp.AgIndicatorState.callsetup(),
hfp.AgIndicatorState.callheld(),
hfp.AgIndicatorState.service(),
hfp.AgIndicatorState.signal(),
hfp.AgIndicatorState.roam(),
hfp.AgIndicatorState.battchg(),
],
supported_hf_indicators=[
hfp.HfIndicator.ENHANCED_SAFETY,
hfp.HfIndicator.BATTERY_LEVEL,
],
supported_ag_call_hold_operations=[],
supported_audio_codecs=[hfp.AudioCodec.CVSD, hfp.AudioCodec.MSBC],
) )
print(color('==================================', 'blue'))
print(color('Handsfree Services:', 'yellow'))
rfcomm_channels = [] def send_message(type: str, **kwargs) -> None:
# pylint: disable-next=too-many-nested-blocks if ws:
for attribute_list in search_result: asyncio.create_task(ws.send(json.dumps({'type': type, **kwargs})))
# Look for the RFCOMM Channel number
protocol_descriptor_list = ServiceAttribute.find_attribute_in_list(
attribute_list, SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID def on_speaker_volume(level: int):
send_message(type='speaker_volume', level=level)
def on_microphone_volume(level: int):
send_message(type='microphone_volume', level=level)
def on_sco_state_change(codec: int):
if codec == hfp.AudioCodec.CVSD:
sample_rate = 8000
elif codec == hfp.AudioCodec.MSBC:
sample_rate = 16000
else:
sample_rate = 0
send_message(type='sco_state_change', sample_rate=sample_rate)
def on_sco_packet(packet: hci.HCI_SynchronousDataPacket):
if ws:
asyncio.create_task(ws.send(packet.data))
if source_file and (pcm_data := source_file.read(packet.data_total_length)):
assert ag_protocol
host = ag_protocol.dlc.multiplexer.l2cap_channel.connection.device.host
host.send_hci_packet(
hci.HCI_SynchronousDataPacket(
connection_handle=packet.connection_handle,
packet_status=0,
data_total_length=len(pcm_data),
data=pcm_data,
)
) )
if protocol_descriptor_list:
for protocol_descriptor in protocol_descriptor_list.value:
if len(protocol_descriptor.value) >= 2:
if protocol_descriptor.value[0].value == BT_RFCOMM_PROTOCOL_ID:
print(color('SERVICE:', 'green'))
print(
color(' RFCOMM Channel:', 'cyan'),
protocol_descriptor.value[1].value,
)
rfcomm_channels.append(protocol_descriptor.value[1].value)
# List profiles
bluetooth_profile_descriptor_list = (
ServiceAttribute.find_attribute_in_list(
attribute_list,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
)
)
if bluetooth_profile_descriptor_list:
if bluetooth_profile_descriptor_list.value:
if (
bluetooth_profile_descriptor_list.value[0].type
== DataElement.SEQUENCE
):
bluetooth_profile_descriptors = (
bluetooth_profile_descriptor_list.value
)
else:
# Sometimes, instead of a list of lists, we just
# find a list. Fix that
bluetooth_profile_descriptors = [
bluetooth_profile_descriptor_list
]
print(color(' Profiles:', 'green')) def on_hfp_state_change(connected: bool):
for ( send_message(type='hfp_state_change', connected=connected)
bluetooth_profile_descriptor
) in bluetooth_profile_descriptors:
version_major = (
bluetooth_profile_descriptor.value[1].value >> 8
)
version_minor = (
bluetooth_profile_descriptor.value[1].value
& 0xFF
)
print(
' '
f'{bluetooth_profile_descriptor.value[0].value}'
f' - version {version_major}.{version_minor}'
)
# List service classes
service_class_id_list = ServiceAttribute.find_attribute_in_list(
attribute_list, SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID
)
if service_class_id_list:
if service_class_id_list.value:
print(color(' Service Classes:', 'green'))
for service_class_id in service_class_id_list.value:
print(' ', service_class_id.value)
await sdp_client.disconnect() async def ws_server(ws_client: websockets.WebSocketServerProtocol, path: str):
return rfcomm_channels del path
global ws
ws = ws_client
async for message in ws_client:
if not ag_protocol:
continue
json_message = json.loads(message)
message_type = json_message['type']
connection = ag_protocol.dlc.multiplexer.l2cap_channel.connection
device = connection.device
try:
if message_type == 'at_response':
ag_protocol.send_response(json_message['response'])
elif message_type == 'ag_indicator':
ag_protocol.update_ag_indicator(
hfp.AgIndicator(json_message['indicator']),
int(json_message['value']),
)
elif message_type == 'negotiate_codec':
codec = hfp.AudioCodec(int(json_message['codec']))
await ag_protocol.negotiate_codec(codec)
elif message_type == 'connect_sco':
if ag_protocol.active_codec == hfp.AudioCodec.CVSD:
esco_param = hfp.ESCO_PARAMETERS[
hfp.DefaultCodecParameters.ESCO_CVSD_S4
]
elif ag_protocol.active_codec == hfp.AudioCodec.MSBC:
esco_param = hfp.ESCO_PARAMETERS[
hfp.DefaultCodecParameters.ESCO_MSBC_T2
]
else:
raise ValueError(f'Unsupported codec {codec}')
await device.send_command(
hci.HCI_Enhanced_Setup_Synchronous_Connection_Command(
connection_handle=connection.handle, **esco_param.asdict()
)
)
elif message_type == 'disconnect_sco':
# Copy the values to avoid iteration error.
for sco_link in list(device.sco_links.values()):
await sco_link.disconnect()
elif message_type == 'update_calls':
ag_protocol.calls = [
hfp.CallInfo(
index=int(call['index']),
direction=hfp.CallInfoDirection(int(call['direction'])),
status=hfp.CallInfoStatus(int(call['status'])),
number=call['number'],
multi_party=hfp.CallInfoMultiParty.NOT_IN_CONFERENCE,
mode=hfp.CallInfoMode.VOICE,
)
for call in json_message['calls']
]
except Exception as e:
send_message(type='error', message=e)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 4: if len(sys.argv) < 3:
print( print(
'Usage: run_hfp_gateway.py <device-config> <transport-spec> ' 'Usage: run_hfp_gateway.py <device-config> <transport-spec> '
'<bluetooth-address>' '[bluetooth-address] [wav-file-for-source]'
) )
print( print(
' specifying a channel number, or "discover" to list all RFCOMM channels' 'example: run_hfp_gateway.py hfp_gateway.json usb:0 E1:CA:72:48:C4:E8 sample.wav'
) )
print('example: run_hfp_gateway.py hfp_gateway.json usb:0 E1:CA:72:48:C4:E8')
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create a device # Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.classic_enabled = True device.classic_enabled = True
await device.power_on() await device.power_on()
# Connect to a peer rfcomm_server = rfcomm.Server(device)
target_address = sys.argv[3] configuration = _default_configuration()
print(f'=== Connecting to {target_address}...')
connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
print(f'=== Connected to {connection.peer_address}!')
# Get a list of all the Handsfree services (should only be 1) def on_dlc(dlc: rfcomm.DLC):
channels = await list_rfcomm_channels(device, connection) global ag_protocol
if len(channels) == 0: ag_protocol = hfp.AgProtocol(dlc, configuration)
print('!!! no service found') ag_protocol.on('speaker_volume', on_speaker_volume)
return ag_protocol.on('microphone_volume', on_microphone_volume)
on_hfp_state_change(True)
dlc.multiplexer.l2cap_channel.on(
'close', lambda: on_hfp_state_change(False)
)
# Pick the first one channel = rfcomm_server.listen(on_dlc)
channel = channels[0] device.sdp_service_records = {
1: hfp.make_ag_sdp_records(1, channel, configuration)
}
# Request authentication def on_sco_connection(sco_link: ScoLink):
print('*** Authenticating...') assert ag_protocol
await connection.authenticate() on_sco_state_change(ag_protocol.active_codec)
print('*** Authenticated') sco_link.on('disconnection', lambda _: on_sco_state_change(0))
sco_link.sink = on_sco_packet
# Enable encryption device.on('sco_connection', on_sco_connection)
print('*** Enabling encryption...') if len(sys.argv) >= 4:
await connection.encrypt() # Connect to a peer
print('*** Encryption on') target_address = sys.argv[3]
print(f'=== Connecting to {target_address}...')
connection = await device.connect(
target_address, transport=BT_BR_EDR_TRANSPORT
)
print(f'=== Connected to {connection.peer_address}!')
# Create a client and start it # Get a list of all the Handsfree services (should only be 1)
print('@@@ Starting to RFCOMM client...') if not (hfp_record := await hfp.find_hf_sdp_record(connection)):
rfcomm_client = rfcomm.Client(connection) print('!!! no service found')
rfcomm_mux = await rfcomm_client.start() return
print('@@@ Started')
print(f'### Opening session for channel {channel}...') # Pick the first one
try: channel, version, hf_sdp_features = hfp_record
session = await rfcomm_mux.open_dlc(channel) print(f'HF version: {version}')
print('### Session open', session) print(f'HF features: {hf_sdp_features}')
except bumble.core.ConnectionError as error:
print(f'### Session open failed: {error}')
await rfcomm_mux.disconnect()
print('@@@ Disconnected from RFCOMM server')
return
def on_sco(connection_handle: int, packet: HCI_SynchronousDataPacket): # Request authentication
# Reset packet and loopback print('*** Authenticating...')
packet.packet_status = 0 await connection.authenticate()
device.host.send_hci_packet(packet) print('*** Authenticated')
device.host.on('sco_packet', on_sco) # Enable encryption
print('*** Enabling encryption...')
await connection.encrypt()
print('*** Encryption on')
# Protocol loop (just for testing at this point) # Create a client and start it
protocol = hfp.HfpProtocol(session) print('@@@ Starting to RFCOMM client...')
while True: rfcomm_client = rfcomm.Client(connection)
line = await protocol.next_line() rfcomm_mux = await rfcomm_client.start()
print('@@@ Started')
if line.startswith('AT+BRSF='): print(f'### Opening session for channel {channel}...')
protocol.send_response_line('+BRSF: 30') try:
protocol.send_response_line('OK') session = await rfcomm_mux.open_dlc(channel)
elif line.startswith('AT+CIND=?'): print('### Session open', session)
protocol.send_response_line( except bumble.core.ConnectionError as error:
'+CIND: ("call",(0,1)),("callsetup",(0-3)),("service",(0-1)),' print(f'### Session open failed: {error}')
'("signal",(0-5)),("roam",(0,1)),("battchg",(0-5)),' await rfcomm_mux.disconnect()
'("callheld",(0-2))' print('@@@ Disconnected from RFCOMM server')
) return
protocol.send_response_line('OK')
elif line.startswith('AT+CIND?'):
protocol.send_response_line('+CIND: 0,0,1,4,1,5,0')
protocol.send_response_line('OK')
elif line.startswith('AT+CMER='):
protocol.send_response_line('OK')
elif line.startswith('AT+CHLD=?'):
protocol.send_response_line('+CHLD: 0')
protocol.send_response_line('OK')
elif line.startswith('AT+BTRH?'):
protocol.send_response_line('+BTRH: 0')
protocol.send_response_line('OK')
elif line.startswith('AT+CLIP='):
protocol.send_response_line('OK')
elif line.startswith('AT+VGS='):
protocol.send_response_line('OK')
elif line.startswith('AT+BIA='):
protocol.send_response_line('OK')
elif line.startswith('AT+BVRA='):
protocol.send_response_line(
'+BVRA: 1,1,12AA,1,1,"Message 1 from Janina"'
)
elif line.startswith('AT+XEVENT='):
protocol.send_response_line('OK')
elif line.startswith('AT+XAPL='):
protocol.send_response_line('OK')
else:
print(color('UNSUPPORTED AT COMMAND', 'red'))
protocol.send_response_line('ERROR')
await hci_source.wait_for_termination() on_dlc(session)
await websockets.serve(ws_server, port=8888)
if len(sys.argv) >= 5:
global source_file
source_file = open(sys.argv[4], 'rb')
# Skip header
source_file.seek(44)
await hci_transport.source.terminated
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+49 -38
View File
@@ -16,6 +16,7 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import asyncio import asyncio
import contextlib
import sys import sys
import os import os
import logging import logging
@@ -31,39 +32,16 @@ from bumble.transport import open_transport_or_link
from bumble import hfp from bumble import hfp
from bumble.hfp import HfProtocol from bumble.hfp import HfProtocol
ws: Optional[websockets.WebSocketServerProtocol] = None
# ----------------------------------------------------------------------------- hf_protocol: Optional[HfProtocol] = None
class UiServer:
protocol: Optional[HfProtocol] = None
async def start(self):
"""Start a Websocket server to receive events from a web page."""
async def serve(websocket, _path):
while True:
try:
message = await websocket.recv()
print('Received: ', str(message))
parsed = json.loads(message)
message_type = parsed['type']
if message_type == 'at_command':
if self.protocol is not None:
await self.protocol.execute_command(parsed['command'])
except websockets.exceptions.ConnectionClosedOK:
pass
# pylint: disable=no-member
await websockets.serve(serve, 'localhost', 8989)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def on_dlc(dlc: rfcomm.DLC, configuration: hfp.Configuration): def on_dlc(dlc: rfcomm.DLC, configuration: hfp.HfConfiguration):
print('*** DLC connected', dlc) print('*** DLC connected', dlc)
protocol = HfProtocol(dlc, configuration) global hf_protocol
UiServer.protocol = protocol hf_protocol = HfProtocol(dlc, configuration)
asyncio.create_task(protocol.run()) asyncio.create_task(hf_protocol.run())
def on_sco_request(connection: Connection, link_type: int, protocol: HfProtocol): def on_sco_request(connection: Connection, link_type: int, protocol: HfProtocol):
if connection == protocol.dlc.multiplexer.l2cap_channel.connection: if connection == protocol.dlc.multiplexer.l2cap_channel.connection:
@@ -88,7 +66,7 @@ def on_dlc(dlc: rfcomm.DLC, configuration: hfp.Configuration):
), ),
) )
handler = functools.partial(on_sco_request, protocol=protocol) handler = functools.partial(on_sco_request, protocol=hf_protocol)
dlc.multiplexer.l2cap_channel.connection.device.on('sco_request', handler) dlc.multiplexer.l2cap_channel.connection.device.on('sco_request', handler)
dlc.multiplexer.l2cap_channel.once( dlc.multiplexer.l2cap_channel.once(
'close', 'close',
@@ -97,21 +75,28 @@ def on_dlc(dlc: rfcomm.DLC, configuration: hfp.Configuration):
), ),
) )
def on_ag_indicator(indicator):
global ws
if ws:
asyncio.create_task(ws.send(str(indicator)))
hf_protocol.on('ag_indicator', on_ag_indicator)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 3: if len(sys.argv) < 3:
print('Usage: run_classic_hfp.py <device-config> <transport-spec>') print('Usage: run_classic_hfp.py <device-config> <transport-spec>')
print('example: run_classic_hfp.py classic2.json usb:04b4:f901') print('example: run_classic_hfp.py classic2.json usb:04b4:f901')
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
# Hands-Free profile configuration. # Hands-Free profile configuration.
# TODO: load configuration from file. # TODO: load configuration from file.
configuration = hfp.Configuration( configuration = hfp.HfConfiguration(
supported_hf_features=[ supported_hf_features=[
hfp.HfFeature.THREE_WAY_CALLING, hfp.HfFeature.THREE_WAY_CALLING,
hfp.HfFeature.REMOTE_VOLUME_CONTROL, hfp.HfFeature.REMOTE_VOLUME_CONTROL,
@@ -131,7 +116,9 @@ async def main():
) )
# Create a device # Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.classic_enabled = True device.classic_enabled = True
# Create and register a server # Create and register a server
@@ -143,7 +130,9 @@ async def main():
# Advertise the HFP RFComm channel in the SDP # Advertise the HFP RFComm channel in the SDP
device.sdp_service_records = { device.sdp_service_records = {
0x00010001: hfp.sdp_records(0x00010001, channel_number, configuration) 0x00010001: hfp.make_hf_sdp_records(
0x00010001, channel_number, configuration
)
} }
# Let's go! # Let's go!
@@ -154,10 +143,32 @@ async def main():
await device.set_connectable(True) await device.set_connectable(True)
# Start the UI websocket server to offer a few buttons and input boxes # Start the UI websocket server to offer a few buttons and input boxes
ui_server = UiServer() async def serve(websocket: websockets.WebSocketServerProtocol, _path):
await ui_server.start() global ws
ws = websocket
async for message in websocket:
with contextlib.suppress(websockets.exceptions.ConnectionClosedOK):
print('Received: ', str(message))
await hci_source.wait_for_termination() parsed = json.loads(message)
message_type = parsed['type']
if message_type == 'at_command':
if hf_protocol is not None:
response = str(
await hf_protocol.execute_command(
parsed['command'],
response_type=hfp.AtResponseType.MULTIPLE,
)
)
await websocket.send(response)
elif message_type == 'query_call':
if hf_protocol:
response = str(await hf_protocol.query_current_calls())
await websocket.send(response)
await websockets.serve(serve, 'localhost', 8989)
await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+8 -4
View File
@@ -229,6 +229,7 @@ HID_REPORT_MAP = bytes( # Text String, 50 Octet Report Descriptor
# Default protocol mode set to report protocol # Default protocol mode set to report protocol
protocol_mode = Message.ProtocolMode.REPORT_PROTOCOL protocol_mode = Message.ProtocolMode.REPORT_PROTOCOL
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def sdp_records(): def sdp_records():
service_record_handle = 0x00010002 service_record_handle = 0x00010002
@@ -427,6 +428,7 @@ class DeviceData:
# Device's live data - Mouse and Keyboard will be stored in this # Device's live data - Mouse and Keyboard will be stored in this
deviceData = DeviceData() deviceData = DeviceData()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def keyboard_device(hid_device): async def keyboard_device(hid_device):
@@ -487,7 +489,7 @@ async def keyboard_device(hid_device):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 3: if len(sys.argv) < 3:
print( print(
'Usage: python run_hid_device.py <device-config> <transport-spec> <command>' 'Usage: python run_hid_device.py <device-config> <transport-spec> <command>'
@@ -599,11 +601,13 @@ async def main():
asyncio.create_task(handle_virtual_cable_unplug()) asyncio.create_task(handle_virtual_cable_unplug())
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create a device # Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.classic_enabled = True device.classic_enabled = True
# Create and register HID device # Create and register HID device
@@ -740,7 +744,7 @@ async def main():
print("Executing in Web mode") print("Executing in Web mode")
await keyboard_device(hid_device) await keyboard_device(hid_device)
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+6 -4
View File
@@ -275,7 +275,7 @@ async def get_stream_reader(pipe) -> asyncio.StreamReader:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 4: if len(sys.argv) < 4:
print( print(
'Usage: run_hid_host.py <device-config> <transport-spec> ' 'Usage: run_hid_host.py <device-config> <transport-spec> '
@@ -324,11 +324,13 @@ async def main():
asyncio.create_task(handle_virtual_cable_unplug()) asyncio.create_task(handle_virtual_cable_unplug())
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< CONNECTED') print('<<< CONNECTED')
# Create a device # Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.classic_enabled = True device.classic_enabled = True
# Create HID host and start it # Create HID host and start it
@@ -557,7 +559,7 @@ async def main():
# Interrupt Channel # Interrupt Channel
await hid_host.connect_interrupt_channel() await hid_host.connect_interrupt_channel()
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+5 -3
View File
@@ -57,18 +57,20 @@ def on_my_characteristic_subscription(peer, enabled):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 3: if len(sys.argv) < 3:
print('Usage: run_notifier.py <device-config> <transport-spec>') print('Usage: run_notifier.py <device-config> <transport-spec>')
print('example: run_notifier.py device1.json usb:0') print('example: run_notifier.py device1.json usb:0')
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create a device to manage the host # Create a device to manage the host
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.listener = Listener(device) device.listener = Listener(device)
# Add a few entries to the device's GATT server # Add a few entries to the device's GATT server
+9 -7
View File
@@ -165,7 +165,7 @@ async def tcp_server(tcp_port, rfcomm_session):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 5: if len(sys.argv) < 5:
print( print(
'Usage: run_rfcomm_client.py <device-config> <transport-spec> ' 'Usage: run_rfcomm_client.py <device-config> <transport-spec> '
@@ -178,11 +178,13 @@ async def main():
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create a device # Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.classic_enabled = True device.classic_enabled = True
await device.power_on() await device.power_on()
@@ -192,8 +194,8 @@ async def main():
connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT) connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
print(f'=== Connected to {connection.peer_address}!') print(f'=== Connected to {connection.peer_address}!')
channel = sys.argv[4] channel_str = sys.argv[4]
if channel == 'discover': if channel_str == 'discover':
await list_rfcomm_channels(connection) await list_rfcomm_channels(connection)
return return
@@ -213,7 +215,7 @@ async def main():
rfcomm_mux = await rfcomm_client.start() rfcomm_mux = await rfcomm_client.start()
print('@@@ Started') print('@@@ Started')
channel = int(channel) channel = int(channel_str)
print(f'### Opening session for channel {channel}...') print(f'### Opening session for channel {channel}...')
try: try:
session = await rfcomm_mux.open_dlc(channel) session = await rfcomm_mux.open_dlc(channel)
@@ -229,7 +231,7 @@ async def main():
tcp_port = int(sys.argv[5]) tcp_port = int(sys.argv[5])
asyncio.create_task(tcp_server(tcp_port, session)) asyncio.create_task(tcp_server(tcp_port, session))
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+6 -4
View File
@@ -107,7 +107,7 @@ class TcpServer:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 4: if len(sys.argv) < 4:
print( print(
'Usage: run_rfcomm_server.py <device-config> <transport-spec> ' 'Usage: run_rfcomm_server.py <device-config> <transport-spec> '
@@ -124,11 +124,13 @@ async def main():
uuid = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE' uuid = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected') print('<<< connected')
# Create a device # Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.classic_enabled = True device.classic_enabled = True
# Create a TCP server # Create a TCP server
@@ -153,7 +155,7 @@ async def main():
await device.set_discoverable(True) await device.set_discoverable(True)
await device.set_connectable(True) await device.set_connectable(True)
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+12 -7
View File
@@ -20,27 +20,31 @@ import sys
import os import os
import logging import logging
from bumble.colors import color from bumble.colors import color
from bumble.hci import Address
from bumble.device import Device from bumble.device import Device
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main(): async def main() -> None:
if len(sys.argv) < 2: if len(sys.argv) < 2:
print('Usage: run_scanner.py <transport-spec> [filter]') print('Usage: run_scanner.py <transport-spec> [filter]')
print('example: run_scanner.py usb:0') print('example: run_scanner.py usb:0')
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[1]) as (hci_source, hci_sink): async with await open_transport_or_link(sys.argv[1]) as hci_transport:
print('<<< connected') print('<<< connected')
filter_duplicates = len(sys.argv) == 3 and sys.argv[2] == 'filter' filter_duplicates = len(sys.argv) == 3 and sys.argv[2] == 'filter'
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink) device = Device.with_hci(
'Bumble',
Address('F0:F1:F2:F3:F4:F5'),
hci_transport.source,
hci_transport.sink,
)
@device.on('advertisement') def on_adv(advertisement):
def _(advertisement):
address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[ address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[
advertisement.address.address_type advertisement.address.address_type
] ]
@@ -67,10 +71,11 @@ async def main():
f'{advertisement.data.to_string(separator)}' f'{advertisement.data.to_string(separator)}'
) )
device.on('advertisement', on_adv)
await device.power_on() await device.power_on()
await device.start_scanning(filter_duplicates=filter_duplicates) await device.start_scanning(filter_duplicates=filter_duplicates)
await hci_source.wait_for_termination() await hci_transport.source.wait_for_termination()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+86 -70
View File
@@ -16,20 +16,28 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import asyncio import asyncio
import datetime
import functools
import logging import logging
import sys import sys
import os import os
import io
import struct import struct
import secrets import secrets
from typing import Dict
from bumble.core import AdvertisingData from bumble.core import AdvertisingData
from bumble.device import Device, CisLink, AdvertisingParameters from bumble.device import Device
from bumble.hci import ( from bumble.hci import (
CodecID, CodecID,
CodingFormat, CodingFormat,
OwnAddressType,
HCI_IsoDataPacket, HCI_IsoDataPacket,
) )
from bumble.profiles.bap import ( from bumble.profiles.bap import (
AseStateMachine,
UnicastServerAdvertisingData,
CodecSpecificConfiguration,
CodecSpecificCapabilities, CodecSpecificCapabilities,
ContextType, ContextType,
AudioLocation, AudioLocation,
@@ -45,6 +53,32 @@ from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
def _sink_pac_record() -> PacRecord:
return PacRecord(
coding_format=CodingFormat(CodecID.LC3),
codec_specific_capabilities=CodecSpecificCapabilities(
supported_sampling_frequencies=(
SupportedSamplingFrequency.FREQ_8000
| SupportedSamplingFrequency.FREQ_16000
| SupportedSamplingFrequency.FREQ_24000
| SupportedSamplingFrequency.FREQ_32000
| SupportedSamplingFrequency.FREQ_48000
),
supported_frame_durations=(
SupportedFrameDuration.DURATION_7500_US_SUPPORTED
| SupportedFrameDuration.DURATION_10000_US_SUPPORTED
),
supported_audio_channel_count=[1, 2],
min_octets_per_codec_frame=26,
max_octets_per_codec_frame=240,
supported_max_codec_frames_per_sdu=2,
),
)
file_outputs: Dict[AseStateMachine, io.BufferedWriter] = {}
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main() -> None: async def main() -> None:
if len(sys.argv) < 3: if len(sys.argv) < 3:
@@ -71,49 +105,17 @@ async def main() -> None:
PublishedAudioCapabilitiesService( PublishedAudioCapabilitiesService(
supported_source_context=ContextType.PROHIBITED, supported_source_context=ContextType.PROHIBITED,
available_source_context=ContextType.PROHIBITED, available_source_context=ContextType.PROHIBITED,
supported_sink_context=ContextType.MEDIA, supported_sink_context=ContextType(0xFF), # All context types
available_sink_context=ContextType.MEDIA, available_sink_context=ContextType(0xFF), # All context types
sink_audio_locations=( sink_audio_locations=(
AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT
), ),
sink_pac=[ sink_pac=[_sink_pac_record()],
# Codec Capability Setting 16_2
PacRecord(
coding_format=CodingFormat(CodecID.LC3),
codec_specific_capabilities=CodecSpecificCapabilities(
supported_sampling_frequencies=(
SupportedSamplingFrequency.FREQ_16000
),
supported_frame_durations=(
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
),
supported_audio_channel_counts=[1],
min_octets_per_codec_frame=40,
max_octets_per_codec_frame=40,
supported_max_codec_frames_per_sdu=1,
),
),
# Codec Capability Setting 24_2
PacRecord(
coding_format=CodingFormat(CodecID.LC3),
codec_specific_capabilities=CodecSpecificCapabilities(
supported_sampling_frequencies=(
SupportedSamplingFrequency.FREQ_48000
),
supported_frame_durations=(
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
),
supported_audio_channel_counts=[1],
min_octets_per_codec_frame=120,
max_octets_per_codec_frame=120,
supported_max_codec_frames_per_sdu=1,
),
),
],
) )
) )
device.add_service(AudioStreamControlService(device, sink_ase_id=[1, 2])) ascs = AudioStreamControlService(device, sink_ase_id=[1], source_ase_id=[2])
device.add_service(ascs)
advertising_data = ( advertising_data = (
bytes( bytes(
@@ -141,45 +143,59 @@ async def main() -> None:
) )
) )
+ csis.get_advertising_data() + csis.get_advertising_data()
) + bytes(UnicastServerAdvertisingData())
subprocess = await asyncio.create_subprocess_shell(
f'dlc3 | ffplay pipe:0',
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
stdin = subprocess.stdin def on_pdu(ase: AseStateMachine, pdu: HCI_IsoDataPacket):
assert stdin
# Write a fake LC3 header to dlc3.
stdin.write(
bytes([0x1C, 0xCC]) # Header.
+ struct.pack(
'<HHHHHHI',
18, # Header length.
48000 // 100, # Sampling Rate(/100Hz).
0, # Bitrate(unused).
1, # Channels.
10000 // 10, # Frame duration(/10us).
0, # RFU.
0x0FFFFFFF, # Frame counts.
)
)
def on_pdu(pdu: HCI_IsoDataPacket):
# LC3 format: |frame_length(2)| + |frame(length)|. # LC3 format: |frame_length(2)| + |frame(length)|.
sdu = b''
if pdu.iso_sdu_length: if pdu.iso_sdu_length:
stdin.write(struct.pack('<H', pdu.iso_sdu_length)) sdu = struct.pack('<H', pdu.iso_sdu_length)
stdin.write(pdu.iso_sdu_fragment) sdu += pdu.iso_sdu_fragment
file_outputs[ase].write(sdu)
def on_cis(cis_link: CisLink): def on_ase_state_change(
cis_link.on('pdu', on_pdu) state: AseStateMachine.State,
ase: AseStateMachine,
) -> None:
if state != AseStateMachine.State.STREAMING:
if file_output := file_outputs.pop(ase):
file_output.close()
else:
file_output = open(f'{datetime.datetime.now().isoformat()}.lc3', 'wb')
codec_configuration = ase.codec_specific_configuration
assert isinstance(codec_configuration, CodecSpecificConfiguration)
# Write a LC3 header.
file_output.write(
bytes([0x1C, 0xCC]) # Header.
+ struct.pack(
'<HHHHHHI',
18, # Header length.
codec_configuration.sampling_frequency.hz
// 100, # Sampling Rate(/100Hz).
0, # Bitrate(unused).
bin(codec_configuration.audio_channel_allocation).count(
'1'
), # Channels.
codec_configuration.frame_duration.us
// 10, # Frame duration(/10us).
0, # RFU.
0x0FFFFFFF, # Frame counts.
)
)
file_outputs[ase] = file_output
assert ase.cis_link
ase.cis_link.sink = functools.partial(on_pdu, ase)
device.once('cis_establishment', on_cis) for ase in ascs.ase_state_machines.values():
ase.on(
'state_change',
functools.partial(on_ase_state_change, ase=ase),
)
advertising_set = await device.create_advertising_set( await device.create_advertising_set(
advertising_data=advertising_data, advertising_data=advertising_data,
auto_restart=True,
) )
await hci_transport.source.terminated await hci_transport.source.terminated
+3 -1
View File
@@ -31,6 +31,7 @@ from bumble.hci import (
OwnAddressType, OwnAddressType,
) )
from bumble.profiles.bap import ( from bumble.profiles.bap import (
UnicastServerAdvertisingData,
CodecSpecificCapabilities, CodecSpecificCapabilities,
ContextType, ContextType,
AudioLocation, AudioLocation,
@@ -101,7 +102,7 @@ async def main() -> None:
supported_frame_durations=( supported_frame_durations=(
SupportedFrameDuration.DURATION_10000_US_SUPPORTED SupportedFrameDuration.DURATION_10000_US_SUPPORTED
), ),
supported_audio_channel_counts=[1], supported_audio_channel_count=[1],
min_octets_per_codec_frame=120, min_octets_per_codec_frame=120,
max_octets_per_codec_frame=120, max_octets_per_codec_frame=120,
supported_max_codec_frames_per_sdu=1, supported_max_codec_frames_per_sdu=1,
@@ -151,6 +152,7 @@ async def main() -> None:
) )
) )
+ csis.get_advertising_data() + csis.get_advertising_data()
+ bytes(UnicastServerAdvertisingData())
) )
await device.create_advertising_set( await device.create_advertising_set(
@@ -56,13 +56,19 @@ class SocketClient(private val viewModel: AppViewModel, private val socket: Blue
thread { thread {
socketDataSource.receive() socketDataSource.receive()
socket.close()
sender.abort()
} }
Log.info("Startup delay: $DEFAULT_STARTUP_DELAY") Log.info("Startup delay: $DEFAULT_STARTUP_DELAY")
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong()); Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
Log.info("Starting to send") Log.info("Starting to send")
sender.run() try {
sender.run()
} catch (error: IOException) {
Log.info("run ended abruptly")
}
cleanup() cleanup()
} }
} }
+5 -2
View File
@@ -1,7 +1,10 @@
# Next # 0.2.0
- Code-gen company ID table - Code-gen company ID table
- Unstable support for extended advertisements
- CLI tools for downloading Realtek firmware
- PDL-generated types for HCI commands
# 0.1.0 # 0.1.0
- Initial release - Initial release
+1 -1
View File
@@ -182,7 +182,7 @@ dependencies = [
[[package]] [[package]]
name = "bumble" name = "bumble"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
+2 -2
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "bumble" name = "bumble"
description = "Rust API for the Bumble Bluetooth stack" description = "Rust API for the Bumble Bluetooth stack"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
homepage = "https://google.github.io/bumble/index.html" homepage = "https://google.github.io/bumble/index.html"
@@ -10,7 +10,7 @@ documentation = "https://docs.rs/crate/bumble"
authors = ["Marshall Pierce <marshallpierce@google.com>"] authors = ["Marshall Pierce <marshallpierce@google.com>"]
keywords = ["bluetooth", "ble"] keywords = ["bluetooth", "ble"]
categories = ["api-bindings", "network-programming"] categories = ["api-bindings", "network-programming"]
rust-version = "1.70.0" rust-version = "1.76.0"
# https://github.com/frewsxcv/cargo-all-features#options # https://github.com/frewsxcv/cargo-all-features#options
[package.metadata.cargo-all-features] [package.metadata.cargo-all-features]
+6 -1
View File
@@ -37,6 +37,11 @@ PYTHONPATH=..:[virtualenv site-packages] \
cargo run --features bumble-tools --bin bumble -- --help cargo run --features bumble-tools --bin bumble -- --help
``` ```
Notable subcommands:
- `firmware realtek download`: download Realtek firmware for various chipsets so that it can be automatically loaded when needed
- `usb probe`: show USB devices, highlighting the ones usable for Bluetooth
# Development # Development
Run the tests: Run the tests:
@@ -63,4 +68,4 @@ To regenerate the assigned number tables based on the Python codebase:
``` ```
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features dev-tools PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features dev-tools
``` ```
+1 -1
View File
@@ -35,7 +35,7 @@ impl Controller {
/// module specifies the defaults. Must be called from a thread with a Python event loop, which /// module specifies the defaults. Must be called from a thread with a Python event loop, which
/// should be true on `tokio::main` and `async_std::main`. /// should be true on `tokio::main` and `async_std::main`.
/// ///
/// For more info, see https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars. /// For more info, see <https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars>.
pub async fn new( pub async fn new(
name: &str, name: &str,
host_source: Option<TransportSource>, host_source: Option<TransportSource>,
+1 -1
View File
@@ -149,7 +149,7 @@ impl ToPyObject for Address {
/// An error meaning that the u64 value did not represent a valid BT address. /// An error meaning that the u64 value did not represent a valid BT address.
#[derive(Debug)] #[derive(Debug)]
pub struct InvalidAddress(u64); pub struct InvalidAddress(#[allow(unused)] u64);
impl TryInto<packets::Address> for Address { impl TryInto<packets::Address> for Address {
type Error = ConversionError<InvalidAddress>; type Error = ConversionError<InvalidAddress>;
+1 -1
View File
@@ -71,7 +71,7 @@ impl LeConnectionOrientedChannel {
/// Must be called from a thread with a Python event loop, which should be true on /// Must be called from a thread with a Python event loop, which should be true on
/// `tokio::main` and `async_std::main`. /// `tokio::main` and `async_std::main`.
/// ///
/// For more info, see https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars. /// For more info, see <https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars>.
pub async fn disconnect(&mut self) -> PyResult<()> { pub async fn disconnect(&mut self) -> PyResult<()> {
Python::with_gil(|py| { Python::with_gil(|py| {
self.0 self.0
+15 -12
View File
@@ -33,18 +33,17 @@ include_package_data = True
install_requires = install_requires =
aiohttp ~= 3.8; platform_system!='Emscripten' aiohttp ~= 3.8; platform_system!='Emscripten'
appdirs >= 1.4; platform_system!='Emscripten' appdirs >= 1.4; platform_system!='Emscripten'
bt-test-interfaces >= 0.0.2; platform_system!='Emscripten' click >= 8.1.3; platform_system!='Emscripten'
click == 8.1.3; platform_system!='Emscripten'
cryptography == 39; platform_system!='Emscripten' cryptography == 39; platform_system!='Emscripten'
# Pyodide bundles a version of cryptography that is built for wasm, which may not match the # Pyodide bundles a version of cryptography that is built for wasm, which may not match the
# versions available on PyPI. Relax the version requirement since it's better than being # versions available on PyPI. Relax the version requirement since it's better than being
# completely unable to import the package in case of version mismatch. # completely unable to import the package in case of version mismatch.
cryptography >= 39.0; platform_system=='Emscripten' cryptography >= 39.0; platform_system=='Emscripten'
grpcio == 1.57.0; platform_system!='Emscripten' grpcio >= 1.62.1; platform_system!='Emscripten'
humanize >= 4.6.0; platform_system!='Emscripten' humanize >= 4.6.0; platform_system!='Emscripten'
libusb1 >= 2.0.1; platform_system!='Emscripten' libusb1 >= 2.0.1; platform_system!='Emscripten'
libusb-package == 1.0.26.1; platform_system!='Emscripten' libusb-package == 1.0.26.1; platform_system!='Emscripten'
platformdirs == 3.10.0; platform_system!='Emscripten' platformdirs >= 3.10.0; platform_system!='Emscripten'
prompt_toolkit >= 3.0.16; platform_system!='Emscripten' prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
prettytable >= 3.6.0; platform_system!='Emscripten' prettytable >= 3.6.0; platform_system!='Emscripten'
protobuf >= 3.12.4; platform_system!='Emscripten' protobuf >= 3.12.4; platform_system!='Emscripten'
@@ -63,6 +62,7 @@ console_scripts =
bumble-gatt-dump = bumble.apps.gatt_dump:main bumble-gatt-dump = bumble.apps.gatt_dump:main
bumble-hci-bridge = bumble.apps.hci_bridge:main bumble-hci-bridge = bumble.apps.hci_bridge:main
bumble-l2cap-bridge = bumble.apps.l2cap_bridge:main bumble-l2cap-bridge = bumble.apps.l2cap_bridge:main
bumble-rfcomm-bridge = bumble.apps.rfcomm_bridge:main
bumble-pair = bumble.apps.pair:main bumble-pair = bumble.apps.pair:main
bumble-scan = bumble.apps.scan:main bumble-scan = bumble.apps.scan:main
bumble-show = bumble.apps.show:main bumble-show = bumble.apps.show:main
@@ -82,24 +82,27 @@ console_scripts =
build = build =
build >= 0.7 build >= 0.7
test = test =
pytest >= 8.0 pytest >= 8.2
pytest-asyncio == 0.21.1 pytest-asyncio >= 0.23.5
pytest-html >= 3.2.0 pytest-html >= 3.2.0
coverage >= 6.4 coverage >= 6.4
development = development =
black == 22.10 black == 24.3
grpcio-tools >= 1.57.0 grpcio-tools >= 1.62.1
invoke >= 1.7.3 invoke >= 1.7.3
mypy == 1.8.0 mypy == 1.10.0
nox >= 2022 nox >= 2022
pylint == 2.15.8 pylint == 3.1.0
pyyaml >= 6.0 pyyaml >= 6.0
types-appdirs >= 1.4.3 types-appdirs >= 1.4.3
types-invoke >= 1.7.3 types-invoke >= 1.7.3
types-protobuf >= 4.21.0 types-protobuf >= 4.21.0
wasmtime == 20.0.0
avatar = avatar =
pandora-avatar == 0.0.5 pandora-avatar == 0.0.9
rootcanal == 1.7.0 ; python_version>='3.10' rootcanal == 1.10.0 ; python_version>='3.10'
pandora =
bt-test-interfaces >= 0.0.6
documentation = documentation =
mkdocs >= 1.4.0 mkdocs >= 1.4.0
mkdocs-material >= 8.5.6 mkdocs-material >= 8.5.6
+4 -4
View File
@@ -72,7 +72,7 @@ def test_codec_specific_capabilities() -> None:
cap = CodecSpecificCapabilities( cap = CodecSpecificCapabilities(
supported_sampling_frequencies=SAMPLE_FREQUENCY, supported_sampling_frequencies=SAMPLE_FREQUENCY,
supported_frame_durations=FRAME_SURATION, supported_frame_durations=FRAME_SURATION,
supported_audio_channel_counts=AUDIO_CHANNEL_COUNTS, supported_audio_channel_count=AUDIO_CHANNEL_COUNTS,
min_octets_per_codec_frame=40, min_octets_per_codec_frame=40,
max_octets_per_codec_frame=40, max_octets_per_codec_frame=40,
supported_max_codec_frames_per_sdu=1, supported_max_codec_frames_per_sdu=1,
@@ -88,7 +88,7 @@ def test_pac_record() -> None:
cap = CodecSpecificCapabilities( cap = CodecSpecificCapabilities(
supported_sampling_frequencies=SAMPLE_FREQUENCY, supported_sampling_frequencies=SAMPLE_FREQUENCY,
supported_frame_durations=FRAME_SURATION, supported_frame_durations=FRAME_SURATION,
supported_audio_channel_counts=AUDIO_CHANNEL_COUNTS, supported_audio_channel_count=AUDIO_CHANNEL_COUNTS,
min_octets_per_codec_frame=40, min_octets_per_codec_frame=40,
max_octets_per_codec_frame=40, max_octets_per_codec_frame=40,
supported_max_codec_frames_per_sdu=1, supported_max_codec_frames_per_sdu=1,
@@ -216,7 +216,7 @@ async def test_pacs():
supported_frame_durations=( supported_frame_durations=(
SupportedFrameDuration.DURATION_10000_US_SUPPORTED SupportedFrameDuration.DURATION_10000_US_SUPPORTED
), ),
supported_audio_channel_counts=[1], supported_audio_channel_count=[1],
min_octets_per_codec_frame=40, min_octets_per_codec_frame=40,
max_octets_per_codec_frame=40, max_octets_per_codec_frame=40,
supported_max_codec_frames_per_sdu=1, supported_max_codec_frames_per_sdu=1,
@@ -232,7 +232,7 @@ async def test_pacs():
supported_frame_durations=( supported_frame_durations=(
SupportedFrameDuration.DURATION_10000_US_SUPPORTED SupportedFrameDuration.DURATION_10000_US_SUPPORTED
), ),
supported_audio_channel_counts=[1], supported_audio_channel_count=[1],
min_octets_per_codec_frame=60, min_octets_per_codec_frame=60,
max_octets_per_codec_frame=60, max_octets_per_codec_frame=60,
supported_max_codec_frames_per_sdu=1, supported_max_codec_frames_per_sdu=1,
+1
View File
@@ -17,6 +17,7 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from bumble.core import AdvertisingData, UUID, get_dict_key_by_value from bumble.core import AdvertisingData, UUID, get_dict_key_by_value
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def test_ad_data(): def test_ad_data():
data = bytes([2, AdvertisingData.TX_POWER_LEVEL, 123]) data = bytes([2, AdvertisingData.TX_POWER_LEVEL, 123])
+97 -2
View File
@@ -16,6 +16,7 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import asyncio import asyncio
import functools
import logging import logging
import os import os
from types import LambdaType from types import LambdaType
@@ -35,12 +36,14 @@ from bumble.hci import (
HCI_COMMAND_STATUS_PENDING, HCI_COMMAND_STATUS_PENDING,
HCI_CREATE_CONNECTION_COMMAND, HCI_CREATE_CONNECTION_COMMAND,
HCI_SUCCESS, HCI_SUCCESS,
HCI_CONNECTION_FAILED_TO_BE_ESTABLISHED_ERROR,
Address, Address,
OwnAddressType, OwnAddressType,
HCI_Command_Complete_Event, HCI_Command_Complete_Event,
HCI_Command_Status_Event, HCI_Command_Status_Event,
HCI_Connection_Complete_Event, HCI_Connection_Complete_Event,
HCI_Connection_Request_Event, HCI_Connection_Request_Event,
HCI_Error,
HCI_Packet, HCI_Packet,
) )
from bumble.gatt import ( from bumble.gatt import (
@@ -52,6 +55,10 @@ from bumble.gatt import (
from .test_utils import TwoDevices, async_barrier from .test_utils import TwoDevices, async_barrier
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
_TIMEOUT = 0.1
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
@@ -214,6 +221,12 @@ async def test_device_connect_parallel():
d1.host.set_packet_sink(Sink(d1_flow())) d1.host.set_packet_sink(Sink(d1_flow()))
d2.host.set_packet_sink(Sink(d2_flow())) d2.host.set_packet_sink(Sink(d2_flow()))
d1_accept_task = asyncio.create_task(d1.accept(peer_address=d0.public_address))
d2_accept_task = asyncio.create_task(d2.accept())
# Ensure that the accept tasks have started.
await async_barrier()
[c01, c02, a10, a20] = await asyncio.gather( [c01, c02, a10, a20] = await asyncio.gather(
*[ *[
asyncio.create_task( asyncio.create_task(
@@ -222,8 +235,8 @@ async def test_device_connect_parallel():
asyncio.create_task( asyncio.create_task(
d0.connect(d2.public_address, transport=BT_BR_EDR_TRANSPORT) d0.connect(d2.public_address, transport=BT_BR_EDR_TRANSPORT)
), ),
asyncio.create_task(d1.accept(peer_address=d0.public_address)), d1_accept_task,
asyncio.create_task(d2.accept()), d2_accept_task,
] ]
) )
@@ -385,6 +398,29 @@ async def test_get_remote_le_features():
assert (await devices.connections[0].get_remote_le_features()) is not None assert (await devices.connections[0].get_remote_le_features()) is not None
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_remote_le_features_failed():
devices = TwoDevices()
await devices.setup_connection()
def on_hci_le_read_remote_features_complete_event(event):
devices[0].host.emit(
'le_remote_features_failure',
event.connection_handle,
HCI_CONNECTION_FAILED_TO_BE_ESTABLISHED_ERROR,
)
devices[0].host.on_hci_le_read_remote_features_complete_event = (
on_hci_le_read_remote_features_complete_event
)
with pytest.raises(HCI_Error):
await asyncio.wait_for(
devices.connections[0].get_remote_le_features(), _TIMEOUT
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cis(): async def test_cis():
@@ -433,6 +469,65 @@ async def test_cis():
await cis_links[1].disconnect() await cis_links[1].disconnect()
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cis_setup_failure():
devices = TwoDevices()
await devices.setup_connection()
cis_requests = asyncio.Queue()
def on_cis_request(
acl_connection: Connection,
cis_handle: int,
cig_id: int,
cis_id: int,
):
del acl_connection, cig_id, cis_id
cis_requests.put_nowait(cis_handle)
devices[1].on('cis_request', on_cis_request)
cis_handles = await devices[0].setup_cig(
cig_id=1,
cis_id=[2],
sdu_interval=(0, 0),
framing=0,
max_sdu=(0, 0),
retransmission_number=0,
max_transport_latency=(0, 0),
)
assert len(cis_handles) == 1
cis_create_task = asyncio.create_task(
devices[0].create_cis(
[
(cis_handles[0], devices.connections[0].handle),
]
)
)
def on_hci_le_cis_established_event(host, event):
host.emit(
'cis_establishment_failure',
event.connection_handle,
HCI_CONNECTION_FAILED_TO_BE_ESTABLISHED_ERROR,
)
for device in devices:
device.host.on_hci_le_cis_established_event = functools.partial(
on_hci_le_cis_established_event, device.host
)
cis_request = await asyncio.wait_for(cis_requests.get(), _TIMEOUT)
with pytest.raises(HCI_Error):
await asyncio.wait_for(devices[1].accept_cis_request(cis_request), _TIMEOUT)
with pytest.raises(HCI_Error):
await asyncio.wait_for(cis_create_task, _TIMEOUT)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def test_gatt_services_with_gas(): def test_gatt_services_with_gas():
device = Device(host=Host(None, None)) device = Device(host=Host(None, None))
+444 -26
View File
@@ -19,8 +19,9 @@ import asyncio
import logging import logging
import os import os
import pytest import pytest
import pytest_asyncio
from typing import Tuple from typing import Tuple, Optional
from .test_utils import TwoDevices from .test_utils import TwoDevices
from bumble import core from bumble import core
@@ -35,10 +36,94 @@ from bumble import hci
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
def _default_hf_configuration() -> hfp.HfConfiguration:
return hfp.HfConfiguration(
supported_hf_features=[
hfp.HfFeature.CODEC_NEGOTIATION,
hfp.HfFeature.ESCO_S4_SETTINGS_SUPPORTED,
hfp.HfFeature.HF_INDICATORS,
hfp.HfFeature.ENHANCED_CALL_STATUS,
hfp.HfFeature.THREE_WAY_CALLING,
hfp.HfFeature.CLI_PRESENTATION_CAPABILITY,
],
supported_hf_indicators=[
hfp.HfIndicator.ENHANCED_SAFETY,
hfp.HfIndicator.BATTERY_LEVEL,
],
supported_audio_codecs=[
hfp.AudioCodec.CVSD,
hfp.AudioCodec.MSBC,
],
)
# -----------------------------------------------------------------------------
def _default_hf_sdp_features() -> hfp.HfSdpFeature:
return (
hfp.HfSdpFeature.WIDE_BAND
| hfp.HfSdpFeature.THREE_WAY_CALLING
| hfp.HfSdpFeature.CLI_PRESENTATION_CAPABILITY
)
# -----------------------------------------------------------------------------
def _default_ag_configuration() -> hfp.AgConfiguration:
return hfp.AgConfiguration(
supported_ag_features=[
hfp.AgFeature.HF_INDICATORS,
hfp.AgFeature.IN_BAND_RING_TONE_CAPABILITY,
hfp.AgFeature.REJECT_CALL,
hfp.AgFeature.CODEC_NEGOTIATION,
hfp.AgFeature.ESCO_S4_SETTINGS_SUPPORTED,
hfp.AgFeature.ENHANCED_CALL_STATUS,
hfp.AgFeature.THREE_WAY_CALLING,
],
supported_ag_indicators=[
hfp.AgIndicatorState.call(),
hfp.AgIndicatorState.service(),
hfp.AgIndicatorState.callsetup(),
hfp.AgIndicatorState.callsetup(),
hfp.AgIndicatorState.signal(),
hfp.AgIndicatorState.roam(),
hfp.AgIndicatorState.battchg(),
],
supported_hf_indicators=[
hfp.HfIndicator.ENHANCED_SAFETY,
hfp.HfIndicator.BATTERY_LEVEL,
],
supported_ag_call_hold_operations=[
hfp.CallHoldOperation.ADD_HELD_CALL,
hfp.CallHoldOperation.HOLD_ALL_ACTIVE_CALLS,
hfp.CallHoldOperation.HOLD_ALL_CALLS_EXCEPT,
hfp.CallHoldOperation.RELEASE_ALL_ACTIVE_CALLS,
hfp.CallHoldOperation.RELEASE_ALL_HELD_CALLS,
hfp.CallHoldOperation.RELEASE_SPECIFIC_CALL,
hfp.CallHoldOperation.CONNECT_TWO_CALLS,
],
supported_audio_codecs=[hfp.AudioCodec.CVSD, hfp.AudioCodec.MSBC],
)
# -----------------------------------------------------------------------------
def _default_ag_sdp_features() -> hfp.AgSdpFeature:
return (
hfp.AgSdpFeature.WIDE_BAND
| hfp.AgSdpFeature.IN_BAND_RING_TONE_CAPABILITY
| hfp.AgSdpFeature.THREE_WAY_CALLING
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def make_hfp_connections( async def make_hfp_connections(
hf_config: hfp.Configuration, hf_config: Optional[hfp.HfConfiguration] = None,
) -> Tuple[hfp.HfProtocol, hfp.HfpProtocol]: ag_config: Optional[hfp.AgConfiguration] = None,
):
if not hf_config:
hf_config = _default_hf_configuration()
if not ag_config:
ag_config = _default_ag_configuration()
# Setup devices # Setup devices
devices = TwoDevices() devices = TwoDevices()
await devices.setup_connection() await devices.setup_connection()
@@ -55,38 +140,371 @@ async def make_hfp_connections(
# Setup HFP connection # Setup HFP connection
hf = hfp.HfProtocol(client_dlc, hf_config) hf = hfp.HfProtocol(client_dlc, hf_config)
ag = hfp.HfpProtocol(server_dlc) ag = hfp.AgProtocol(server_dlc, ag_config)
return hf, ag
await hf.initiate_slc()
return (hf, ag)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@pytest_asyncio.fixture
async def hfp_connections():
hf, ag = await make_hfp_connections()
hf_loop_task = asyncio.create_task(hf.run())
try:
yield (hf, ag)
finally:
# Close the coroutine.
hf.unsolicited_queue.put_nowait(None)
await hf_loop_task
# -----------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_slc(): async def test_slc_with_minimal_features():
hf_config = hfp.Configuration( hf, ag = await make_hfp_connections(
supported_hf_features=[], supported_hf_indicators=[], supported_audio_codecs=[] hfp.HfConfiguration(
) supported_audio_codecs=[],
hf, ag = await make_hfp_connections(hf_config) supported_hf_features=[],
supported_hf_indicators=[],
async def ag_loop(): ),
while line := await ag.next_line(): hfp.AgConfiguration(
if line.startswith('AT+BRSF'): supported_ag_call_hold_operations=[],
ag.send_response_line('+BRSF: 0') supported_ag_features=[],
elif line.startswith('AT+CIND=?'): supported_ag_indicators=[
ag.send_response_line( hfp.AgIndicatorState(
'+CIND: ("call",(0,1)),("callsetup",(0-3)),("service",(0-1)),' indicator=hfp.AgIndicator.CALL,
'("signal",(0-5)),("roam",(0,1)),("battchg",(0-5)),' supported_values={0, 1},
'("callheld",(0-2))' current_status=0,
) )
elif line.startswith('AT+CIND?'): ],
ag.send_response_line('+CIND: 0,0,1,4,1,5,0') supported_hf_indicators=[],
ag.send_response_line('OK') supported_audio_codecs=[],
),
)
ag_task = asyncio.create_task(ag_loop()) assert hf.supported_ag_features == ag.supported_ag_features
assert hf.supported_hf_features == ag.supported_hf_features
assert hf.supported_ag_call_hold_operations == ag.supported_ag_call_hold_operations
for a, b in zip(hf.ag_indicators, ag.ag_indicators):
assert a.indicator == b.indicator
assert a.current_status == b.current_status
await hf.initiate_slc()
ag_task.cancel() # -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_slc(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
hf, ag = hfp_connections
assert hf.supported_ag_features == ag.supported_ag_features
assert hf.supported_hf_features == ag.supported_hf_features
assert hf.supported_ag_call_hold_operations == ag.supported_ag_call_hold_operations
for a, b in zip(hf.ag_indicators, ag.ag_indicators):
assert a.indicator == b.indicator
assert a.current_status == b.current_status
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ag_indicator(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
hf, ag = hfp_connections
future = asyncio.get_running_loop().create_future()
hf.on('ag_indicator', future.set_result)
ag.update_ag_indicator(hfp.AgIndicator.CALL, 1)
indicator: hfp.AgIndicatorState = await future
assert indicator.current_status == 1
assert indicator.indicator == hfp.AgIndicator.CALL
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_hf_indicator(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
hf, ag = hfp_connections
future = asyncio.get_running_loop().create_future()
ag.on('hf_indicator', future.set_result)
await hf.execute_command('AT+BIEV=2,100')
indicator: hfp.HfIndicatorState = await future
assert indicator.current_status == 100
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_codec_negotiation(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
):
hf, ag = hfp_connections
futures = [
asyncio.get_running_loop().create_future(),
asyncio.get_running_loop().create_future(),
]
hf.on('codec_negotiation', futures[0].set_result)
ag.on('codec_negotiation', futures[1].set_result)
await ag.negotiate_codec(hfp.AudioCodec.MSBC)
assert await futures[0] == await futures[1]
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_dial(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
hf, ag = hfp_connections
NUMBER = 'ATD123456789'
future = asyncio.get_running_loop().create_future()
ag.on('dial', future.set_result)
await hf.execute_command(f'ATD{NUMBER}')
number: str = await future
assert number == NUMBER
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_answer(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
hf, ag = hfp_connections
future = asyncio.get_running_loop().create_future()
ag.on('answer', lambda: future.set_result(None))
await hf.answer_incoming_call()
await future
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_reject_incoming_call(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
):
hf, ag = hfp_connections
future = asyncio.get_running_loop().create_future()
ag.on('hang_up', lambda: future.set_result(None))
await hf.reject_incoming_call()
await future
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_terminate_call(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
hf, ag = hfp_connections
future = asyncio.get_running_loop().create_future()
ag.on('hang_up', lambda: future.set_result(None))
await hf.terminate_call()
await future
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_query_calls_without_calls(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
):
hf, ag = hfp_connections
assert await hf.query_current_calls() == []
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_query_calls_with_calls(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
):
hf, ag = hfp_connections
ag.calls.append(
hfp.CallInfo(
index=1,
direction=hfp.CallInfoDirection.MOBILE_ORIGINATED_CALL,
status=hfp.CallInfoStatus.ACTIVE,
mode=hfp.CallInfoMode.VOICE,
multi_party=hfp.CallInfoMultiParty.NOT_IN_CONFERENCE,
number='123456789',
)
)
assert await hf.query_current_calls() == ag.calls
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize(
"operation,",
(
hfp.CallHoldOperation.RELEASE_ALL_HELD_CALLS,
hfp.CallHoldOperation.RELEASE_ALL_ACTIVE_CALLS,
hfp.CallHoldOperation.HOLD_ALL_ACTIVE_CALLS,
hfp.CallHoldOperation.ADD_HELD_CALL,
hfp.CallHoldOperation.CONNECT_TWO_CALLS,
),
)
async def test_hold_call_without_call_index(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol],
operation: hfp.CallHoldOperation,
):
hf, ag = hfp_connections
call_hold_future = asyncio.get_running_loop().create_future()
ag.on("call_hold", lambda op, index: call_hold_future.set_result((op, index)))
await hf.execute_command(f"AT+CHLD={operation.value}")
assert (await call_hold_future) == (operation, None)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize(
"operation,",
(
hfp.CallHoldOperation.RELEASE_SPECIFIC_CALL,
hfp.CallHoldOperation.HOLD_ALL_CALLS_EXCEPT,
),
)
async def test_hold_call_with_call_index(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol],
operation: hfp.CallHoldOperation,
):
hf, ag = hfp_connections
call_hold_future = asyncio.get_running_loop().create_future()
ag.on("call_hold", lambda op, index: call_hold_future.set_result((op, index)))
ag.calls.append(
hfp.CallInfo(
index=1,
direction=hfp.CallInfoDirection.MOBILE_ORIGINATED_CALL,
status=hfp.CallInfoStatus.ACTIVE,
mode=hfp.CallInfoMode.VOICE,
multi_party=hfp.CallInfoMultiParty.NOT_IN_CONFERENCE,
number='123456789',
)
)
await hf.execute_command(f"AT+CHLD={operation.value.replace('x', '1')}")
assert (await call_hold_future) == (operation, 1)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ring(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
hf, ag = hfp_connections
ring_future = asyncio.get_running_loop().create_future()
hf.on("ring", lambda: ring_future.set_result(None))
ag.send_ring()
await ring_future
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_speaker_volume(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
hf, ag = hfp_connections
speaker_volume_future = asyncio.get_running_loop().create_future()
hf.on("speaker_volume", speaker_volume_future.set_result)
ag.set_speaker_volume(10)
assert await speaker_volume_future == 10
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_microphone_volume(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
):
hf, ag = hfp_connections
microphone_volume_future = asyncio.get_running_loop().create_future()
hf.on("microphone_volume", microphone_volume_future.set_result)
ag.set_microphone_volume(10)
assert await microphone_volume_future == 10
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cli_notification(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
hf, ag = hfp_connections
cli_notification_future = asyncio.get_running_loop().create_future()
hf.on("cli_notification", cli_notification_future.set_result)
ag.send_cli_notification(
hfp.CallLineIdentification(number="\"123456789\"", type=129, alpha="\"Bumble\"")
)
assert await cli_notification_future == hfp.CallLineIdentification(
number="123456789", type=129, alpha="Bumble", subaddr="", satype=None
)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_voice_recognition_from_hf(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
):
hf, ag = hfp_connections
voice_recognition_future = asyncio.get_running_loop().create_future()
ag.on("voice_recognition", voice_recognition_future.set_result)
await hf.execute_command("AT+BVRA=1")
assert await voice_recognition_future == hfp.VoiceRecognitionState.ENABLE
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_voice_recognition_from_ag(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
):
hf, ag = hfp_connections
voice_recognition_future = asyncio.get_running_loop().create_future()
hf.on("voice_recognition", voice_recognition_future.set_result)
ag.send_response("+BVRA: 1")
assert await voice_recognition_future == hfp.VoiceRecognitionState.ENABLE
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_hf_sdp_record():
devices = TwoDevices()
await devices.setup_connection()
devices[0].sdp_service_records[1] = hfp.make_hf_sdp_records(
1, 2, _default_hf_configuration(), hfp.ProfileVersion.V1_8
)
assert await hfp.find_hf_sdp_record(devices.connections[1]) == (
2,
hfp.ProfileVersion.V1_8,
_default_hf_sdp_features(),
)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ag_sdp_record():
devices = TwoDevices()
await devices.setup_connection()
devices[0].sdp_service_records[1] = hfp.make_ag_sdp_records(
1, 2, _default_ag_configuration(), hfp.ProfileVersion.V1_8
)
assert await hfp.find_ag_sdp_record(devices.connections[1]) == (
2,
hfp.ProfileVersion.V1_8,
_default_ag_sdp_features(),
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+31 -1
View File
@@ -32,6 +32,8 @@ from bumble.rfcomm import (
RFCOMM_PSM, RFCOMM_PSM,
) )
_TIMEOUT = 0.1
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def basic_frame_check(x): def basic_frame_check(x):
@@ -60,7 +62,7 @@ def test_frames():
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_basic_connection() -> None: async def test_connection_and_disconnection() -> None:
devices = test_utils.TwoDevices() devices = test_utils.TwoDevices()
await devices.setup_connection() await devices.setup_connection()
@@ -81,6 +83,34 @@ async def test_basic_connection() -> None:
dlcs[1].write(b'Lorem ipsum dolor sit amet') dlcs[1].write(b'Lorem ipsum dolor sit amet')
assert await queues[0].get() == b'Lorem ipsum dolor sit amet' assert await queues[0].get() == b'Lorem ipsum dolor sit amet'
closed = asyncio.Event()
dlcs[1].on('close', closed.set)
await dlcs[1].disconnect()
await closed.wait()
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_receive_pdu_before_open_dlc_returns() -> None:
devices = await test_utils.TwoDevices.create_with_connection()
DATA = b'123'
accept_future: asyncio.Future[DLC] = asyncio.get_running_loop().create_future()
channel = Server(devices[0]).listen(acceptor=accept_future.set_result)
assert devices.connections[1]
multiplexer = await Client(devices.connections[1]).start()
open_dlc_task = asyncio.create_task(multiplexer.open_dlc(channel))
dlc_responder = await accept_future
dlc_responder.write(DATA)
dlc_initiator = await open_dlc_task
dlc_initiator_queue = asyncio.Queue() # type: ignore[var-annotated]
dlc_initiator.sink = dlc_initiator_queue.put_nowait
assert await asyncio.wait_for(dlc_initiator_queue.get(), timeout=_TIMEOUT) == DATA
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
+6 -6
View File
@@ -316,13 +316,13 @@ async def _test_self_smp_with_configs(pairing_config1, pairing_config2):
# Set up the pairing configs # Set up the pairing configs
if pairing_config1: if pairing_config1:
two_devices.devices[ two_devices.devices[0].pairing_config_factory = (
0 lambda connection: pairing_config1
].pairing_config_factory = lambda connection: pairing_config1 )
if pairing_config2: if pairing_config2:
two_devices.devices[ two_devices.devices[1].pairing_config_factory = (
1 lambda connection: pairing_config2
].pairing_config_factory = lambda connection: pairing_config2 )
# Pair # Pair
await two_devices.devices[0].pair(connection) await two_devices.devices[0].pair(connection)
+8 -1
View File
@@ -16,7 +16,8 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import asyncio import asyncio
from typing import List, Optional from typing import List, Optional, Type
from typing_extensions import Self
from bumble.controller import Controller from bumble.controller import Controller
from bumble.link import LocalLink from bumble.link import LocalLink
@@ -81,6 +82,12 @@ class TwoDevices:
def __getitem__(self, index: int) -> Device: def __getitem__(self, index: int) -> Device:
return self.devices[index] return self.devices[index]
@classmethod
async def create_with_connection(cls: Type[Self]) -> Self:
devices = cls()
await devices.setup_connection()
return devices
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def async_barrier(): async def async_barrier():

Some files were not shown because too many files have changed in this diff Show More