forked from auracaster/bumble_mirror
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 986343a807 | |||
| 5211d7ba96 | |||
| a167342778 | |||
| 1efb8cdbee | |||
| 80d83e6a70 | |||
| 31ec1c41ce | |||
| aba1ac0cea | |||
| 2920f05dae | |||
| bc911d6da0 | |||
| 4f87f587e4 | |||
| 3e38ab3638 | |||
| 21bb911fea | |||
| 744dfa33a2 | |||
| ec5f8535a8 | |||
| 5a83734a00 | |||
| b4ae8af3a7 | |||
| 45c4c4f4c5 | |||
| 9187c75d68 | |||
| abeec22546 | |||
| a6bab755cf | |||
| acd9d994c3 | |||
| 37afda3ed3 | |||
| 54f2981267 | |||
| bb025514e7 | |||
| e228597269 | |||
| 95b0d6c6f2 | |||
| fa4df6e3a2 | |||
| 46ceea7ecd | |||
| 30f89d5739 | |||
| 481cf40831 | |||
| eff05afb7a | |||
| d8e6700611 | |||
| 56eb5a933b | |||
| caacc0c133 | |||
| 5f377c024b | |||
| 00cd8fbdd0 | |||
| aeeff18428 | |||
| c48e3f5e9c | |||
| d6bbc1145a | |||
| e2fec67bd9 | |||
| 88cb3b2a4d | |||
| 9ebb03be46 | |||
| 80d84af76c | |||
| 8f4721758f | |||
| 8864af4acd | |||
| 8980fb8cc7 | |||
| 2c5f3472a9 | |||
| f18277ac78 | |||
| 8d46bc04d2 | |||
| 09e5ea5dec | |||
| d43281c57e | |||
| 6810865670 | |||
| 3e9e06a02c | |||
| ccd12f6591 | |||
| f9a7843f7e | |||
| 210c334db7 | |||
| f297cdfcce | |||
| 5b536d00ab | |||
| b4af46ebd5 | |||
| c08da3193e | |||
| fd4d68e5c0 | |||
| b90d0f8710 | |||
| afc6d19e04 | |||
| c05f073b33 | |||
| 2b4c2a22f4 | |||
| 47fe93a148 |
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v3
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ __pycache__
|
|||||||
bumble/_version.py
|
bumble/_version.py
|
||||||
.vscode/launch.json
|
.vscode/launch.json
|
||||||
/.idea
|
/.idea
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
|||||||
Vendored
+4
@@ -12,7 +12,9 @@
|
|||||||
"ASHA",
|
"ASHA",
|
||||||
"asyncio",
|
"asyncio",
|
||||||
"ATRAC",
|
"ATRAC",
|
||||||
|
"avctp",
|
||||||
"avdtp",
|
"avdtp",
|
||||||
|
"avrcp",
|
||||||
"bitpool",
|
"bitpool",
|
||||||
"bitstruct",
|
"bitstruct",
|
||||||
"BSCP",
|
"BSCP",
|
||||||
@@ -22,6 +24,7 @@
|
|||||||
"cmac",
|
"cmac",
|
||||||
"CONNECTIONLESS",
|
"CONNECTIONLESS",
|
||||||
"csip",
|
"csip",
|
||||||
|
"csis",
|
||||||
"csrcs",
|
"csrcs",
|
||||||
"CVSD",
|
"CVSD",
|
||||||
"datagram",
|
"datagram",
|
||||||
@@ -32,6 +35,7 @@
|
|||||||
"dhkey",
|
"dhkey",
|
||||||
"diversifier",
|
"diversifier",
|
||||||
"endianness",
|
"endianness",
|
||||||
|
"ESCO",
|
||||||
"Fitbit",
|
"Fitbit",
|
||||||
"GATTLINK",
|
"GATTLINK",
|
||||||
"HANDSFREE",
|
"HANDSFREE",
|
||||||
|
|||||||
+395
-131
File diff suppressed because it is too large
Load Diff
+67
-9
@@ -18,24 +18,30 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import click
|
import time
|
||||||
from bumble.company_ids import COMPANY_IDENTIFIERS
|
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from bumble.company_ids import COMPANY_IDENTIFIERS
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import name_or_number
|
from bumble.core import name_or_number
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
map_null_terminated_utf8_string,
|
map_null_terminated_utf8_string,
|
||||||
|
LeFeatureMask,
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
HCI_LE_SUPPORTED_FEATURES_NAMES,
|
|
||||||
HCI_VERSION_NAMES,
|
HCI_VERSION_NAMES,
|
||||||
LMP_VERSION_NAMES,
|
LMP_VERSION_NAMES,
|
||||||
HCI_Command,
|
HCI_Command,
|
||||||
HCI_Command_Complete_Event,
|
HCI_Command_Complete_Event,
|
||||||
HCI_Command_Status_Event,
|
HCI_Command_Status_Event,
|
||||||
|
HCI_READ_BUFFER_SIZE_COMMAND,
|
||||||
|
HCI_Read_Buffer_Size_Command,
|
||||||
HCI_READ_BD_ADDR_COMMAND,
|
HCI_READ_BD_ADDR_COMMAND,
|
||||||
HCI_Read_BD_ADDR_Command,
|
HCI_Read_BD_ADDR_Command,
|
||||||
HCI_READ_LOCAL_NAME_COMMAND,
|
HCI_READ_LOCAL_NAME_COMMAND,
|
||||||
HCI_Read_Local_Name_Command,
|
HCI_Read_Local_Name_Command,
|
||||||
|
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
||||||
|
HCI_LE_Read_Buffer_Size_Command,
|
||||||
HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND,
|
HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND,
|
||||||
HCI_LE_Read_Maximum_Data_Length_Command,
|
HCI_LE_Read_Maximum_Data_Length_Command,
|
||||||
HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND,
|
HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND,
|
||||||
@@ -44,6 +50,7 @@ from bumble.hci import (
|
|||||||
HCI_LE_Read_Maximum_Advertising_Data_Length_Command,
|
HCI_LE_Read_Maximum_Advertising_Data_Length_Command,
|
||||||
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
||||||
HCI_LE_Read_Suggested_Default_Data_Length_Command,
|
HCI_LE_Read_Suggested_Default_Data_Length_Command,
|
||||||
|
HCI_Read_Local_Version_Information_Command,
|
||||||
)
|
)
|
||||||
from bumble.host import Host
|
from bumble.host import Host
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
@@ -59,7 +66,7 @@ def command_succeeded(response):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def get_classic_info(host):
|
async def get_classic_info(host: Host) -> None:
|
||||||
if host.supports_command(HCI_READ_BD_ADDR_COMMAND):
|
if host.supports_command(HCI_READ_BD_ADDR_COMMAND):
|
||||||
response = await host.send_command(HCI_Read_BD_ADDR_Command())
|
response = await host.send_command(HCI_Read_BD_ADDR_Command())
|
||||||
if command_succeeded(response):
|
if command_succeeded(response):
|
||||||
@@ -80,7 +87,7 @@ async def get_classic_info(host):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def get_le_info(host):
|
async def get_le_info(host: Host) -> None:
|
||||||
print()
|
print()
|
||||||
|
|
||||||
if host.supports_command(HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND):
|
if host.supports_command(HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND):
|
||||||
@@ -133,11 +140,36 @@ async def get_le_info(host):
|
|||||||
|
|
||||||
print(color('LE Features:', 'yellow'))
|
print(color('LE Features:', 'yellow'))
|
||||||
for feature in host.supported_le_features:
|
for feature in host.supported_le_features:
|
||||||
print(' ', name_or_number(HCI_LE_SUPPORTED_FEATURES_NAMES, feature))
|
print(LeFeatureMask(feature).name)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def async_main(transport):
|
async def get_acl_flow_control_info(host: Host) -> None:
|
||||||
|
print()
|
||||||
|
|
||||||
|
if host.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
|
||||||
|
response = await host.send_command(
|
||||||
|
HCI_Read_Buffer_Size_Command(), check_result=True
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color('ACL Flow Control:', 'yellow'),
|
||||||
|
f'{response.return_parameters.hc_total_num_acl_data_packets} '
|
||||||
|
f'packets of size {response.return_parameters.hc_acl_data_packet_length}',
|
||||||
|
)
|
||||||
|
|
||||||
|
if host.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||||
|
response = await host.send_command(
|
||||||
|
HCI_LE_Read_Buffer_Size_Command(), check_result=True
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color('LE ACL Flow Control:', 'yellow'),
|
||||||
|
f'{response.return_parameters.hc_total_num_le_acl_data_packets} '
|
||||||
|
f'packets of size {response.return_parameters.hc_le_acl_data_packet_length}',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def async_main(latency_probes, transport):
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
|
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
@@ -145,6 +177,23 @@ async def async_main(transport):
|
|||||||
host = Host(hci_source, hci_sink)
|
host = Host(hci_source, hci_sink)
|
||||||
await host.reset()
|
await host.reset()
|
||||||
|
|
||||||
|
# Measure the latency if requested
|
||||||
|
latencies = []
|
||||||
|
if latency_probes:
|
||||||
|
for _ in range(latency_probes):
|
||||||
|
start = time.time()
|
||||||
|
await host.send_command(HCI_Read_Local_Version_Information_Command())
|
||||||
|
latencies.append(1000 * (time.time() - start))
|
||||||
|
print(
|
||||||
|
color('HCI Command Latency:', 'yellow'),
|
||||||
|
(
|
||||||
|
f'min={min(latencies):.2f}, '
|
||||||
|
f'max={max(latencies):.2f}, '
|
||||||
|
f'average={sum(latencies)/len(latencies):.2f}'
|
||||||
|
),
|
||||||
|
'\n',
|
||||||
|
)
|
||||||
|
|
||||||
# Print version
|
# Print version
|
||||||
print(color('Version:', 'yellow'))
|
print(color('Version:', 'yellow'))
|
||||||
print(
|
print(
|
||||||
@@ -168,6 +217,9 @@ async def async_main(transport):
|
|||||||
# Get the LE info
|
# Get the LE info
|
||||||
await get_le_info(host)
|
await get_le_info(host)
|
||||||
|
|
||||||
|
# Print the ACL flow control info
|
||||||
|
await get_acl_flow_control_info(host)
|
||||||
|
|
||||||
# Print the list of commands supported by the controller
|
# Print the list of commands supported by the controller
|
||||||
print()
|
print()
|
||||||
print(color('Supported Commands:', 'yellow'))
|
print(color('Supported Commands:', 'yellow'))
|
||||||
@@ -177,10 +229,16 @@ async def async_main(transport):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@click.command()
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
'--latency-probes',
|
||||||
|
metavar='N',
|
||||||
|
type=int,
|
||||||
|
help='Send N commands to measure HCI transport latency statistics',
|
||||||
|
)
|
||||||
@click.argument('transport')
|
@click.argument('transport')
|
||||||
def main(transport):
|
def main(latency_probes, transport):
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||||
asyncio.run(async_main(transport))
|
asyncio.run(async_main(latency_probes, transport))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# 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
|
||||||
|
from bumble.colors import color
|
||||||
|
from bumble.hci import (
|
||||||
|
HCI_READ_LOOPBACK_MODE_COMMAND,
|
||||||
|
HCI_Read_Loopback_Mode_Command,
|
||||||
|
HCI_WRITE_LOOPBACK_MODE_COMMAND,
|
||||||
|
HCI_Write_Loopback_Mode_Command,
|
||||||
|
LoopbackMode,
|
||||||
|
)
|
||||||
|
from bumble.host import Host
|
||||||
|
from bumble.transport import open_transport_or_link
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
class Loopback:
|
||||||
|
"""Send and receive ACL data packets in local loopback mode"""
|
||||||
|
|
||||||
|
def __init__(self, packet_size: int, packet_count: int, transport: str):
|
||||||
|
self.transport = transport
|
||||||
|
self.packet_size = packet_size
|
||||||
|
self.packet_count = packet_count
|
||||||
|
self.connection_handle: Optional[int] = None
|
||||||
|
self.connection_event = asyncio.Event()
|
||||||
|
self.done = asyncio.Event()
|
||||||
|
self.expected_cid = 0
|
||||||
|
self.bytes_received = 0
|
||||||
|
self.start_timestamp = 0.0
|
||||||
|
self.last_timestamp = 0.0
|
||||||
|
|
||||||
|
def on_connection(self, connection_handle: int, *args):
|
||||||
|
"""Retrieve connection handle from new connection event"""
|
||||||
|
if not self.connection_event.is_set():
|
||||||
|
# save first connection handle for ACL
|
||||||
|
# subsequent connections are SCO
|
||||||
|
self.connection_handle = connection_handle
|
||||||
|
self.connection_event.set()
|
||||||
|
|
||||||
|
def on_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes):
|
||||||
|
"""Calculate packet receive speed"""
|
||||||
|
now = time.time()
|
||||||
|
print(f'<<< Received packet {cid}: {len(pdu)} bytes')
|
||||||
|
assert connection_handle == self.connection_handle
|
||||||
|
assert cid == self.expected_cid
|
||||||
|
self.expected_cid += 1
|
||||||
|
if cid == 0:
|
||||||
|
self.start_timestamp = now
|
||||||
|
else:
|
||||||
|
elapsed_since_start = now - self.start_timestamp
|
||||||
|
elapsed_since_last = now - self.last_timestamp
|
||||||
|
self.bytes_received += len(pdu)
|
||||||
|
instant_rx_speed = len(pdu) / elapsed_since_last
|
||||||
|
average_rx_speed = self.bytes_received / elapsed_since_start
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f'@@@ RX speed: instant={instant_rx_speed:.4f},'
|
||||||
|
f' average={average_rx_speed:.4f}',
|
||||||
|
'cyan',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.last_timestamp = now
|
||||||
|
|
||||||
|
if self.expected_cid == self.packet_count:
|
||||||
|
print(color('@@@ Received last packet', 'green'))
|
||||||
|
self.done.set()
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""Run a loopback throughput test"""
|
||||||
|
print(color('>>> Connecting to HCI...', 'green'))
|
||||||
|
async with await open_transport_or_link(self.transport) as (
|
||||||
|
hci_source,
|
||||||
|
hci_sink,
|
||||||
|
):
|
||||||
|
print(color('>>> Connected', 'green'))
|
||||||
|
|
||||||
|
host = Host(hci_source, hci_sink)
|
||||||
|
await host.reset()
|
||||||
|
|
||||||
|
# make sure data can fit in one l2cap pdu
|
||||||
|
l2cap_header_size = 4
|
||||||
|
max_packet_size = host.acl_packet_queue.max_packet_size - l2cap_header_size
|
||||||
|
if self.packet_size > max_packet_size:
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f'!!! Packet size ({self.packet_size}) larger than max supported'
|
||||||
|
f' size ({max_packet_size})',
|
||||||
|
'red',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not host.supports_command(
|
||||||
|
HCI_WRITE_LOOPBACK_MODE_COMMAND
|
||||||
|
) or not host.supports_command(HCI_READ_LOOPBACK_MODE_COMMAND):
|
||||||
|
print(color('!!! Loopback mode not supported', 'red'))
|
||||||
|
return
|
||||||
|
|
||||||
|
# set event callbacks
|
||||||
|
host.on('connection', self.on_connection)
|
||||||
|
host.on('l2cap_pdu', self.on_l2cap_pdu)
|
||||||
|
|
||||||
|
loopback_mode = LoopbackMode.LOCAL
|
||||||
|
|
||||||
|
print(color('### Setting loopback mode', 'blue'))
|
||||||
|
await host.send_command(
|
||||||
|
HCI_Write_Loopback_Mode_Command(loopback_mode=LoopbackMode.LOCAL),
|
||||||
|
check_result=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(color('### Checking loopback mode', 'blue'))
|
||||||
|
response = await host.send_command(
|
||||||
|
HCI_Read_Loopback_Mode_Command(), check_result=True
|
||||||
|
)
|
||||||
|
if response.return_parameters.loopback_mode != loopback_mode:
|
||||||
|
print(color('!!! Loopback mode mismatch', 'red'))
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.connection_event.wait()
|
||||||
|
print(color('### Connected', 'cyan'))
|
||||||
|
|
||||||
|
print(color('=== Start sending', 'magenta'))
|
||||||
|
start_time = time.time()
|
||||||
|
bytes_sent = 0
|
||||||
|
for cid in range(0, self.packet_count):
|
||||||
|
# using the cid as an incremental index
|
||||||
|
host.send_l2cap_pdu(
|
||||||
|
self.connection_handle, cid, bytes(self.packet_size)
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f'>>> Sending packet {cid}: {self.packet_size} bytes', 'yellow'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bytes_sent += self.packet_size # don't count L2CAP or HCI header sizes
|
||||||
|
await asyncio.sleep(0) # yield to allow packet receive
|
||||||
|
|
||||||
|
await self.done.wait()
|
||||||
|
print(color('=== Done!', 'magenta'))
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
average_tx_speed = bytes_sent / elapsed
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f'@@@ TX speed: average={average_tx_speed:.4f} ({bytes_sent} bytes'
|
||||||
|
f' in {elapsed:.2f} seconds)',
|
||||||
|
'green',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
'--packet-size',
|
||||||
|
'-s',
|
||||||
|
metavar='SIZE',
|
||||||
|
type=click.IntRange(8, 4096),
|
||||||
|
default=500,
|
||||||
|
help='Packet size',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--packet-count',
|
||||||
|
'-c',
|
||||||
|
metavar='COUNT',
|
||||||
|
type=int,
|
||||||
|
default=10,
|
||||||
|
help='Packet count',
|
||||||
|
)
|
||||||
|
@click.argument('transport')
|
||||||
|
def main(packet_size, packet_count, transport):
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||||
|
|
||||||
|
loopback = Loopback(packet_size, packet_count, transport)
|
||||||
|
asyncio.run(loopback.run())
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
+32
-24
@@ -49,14 +49,16 @@ class ServerBridge:
|
|||||||
self.tcp_port = tcp_port
|
self.tcp_port = tcp_port
|
||||||
|
|
||||||
async def start(self, device: Device) -> None:
|
async def start(self, device: Device) -> None:
|
||||||
# Listen for incoming L2CAP CoC connections
|
# Listen for incoming L2CAP channel connections
|
||||||
device.create_l2cap_server(
|
device.create_l2cap_server(
|
||||||
spec=l2cap.LeCreditBasedChannelSpec(
|
spec=l2cap.LeCreditBasedChannelSpec(
|
||||||
psm=self.psm, mtu=self.mtu, mps=self.mps, max_credits=self.max_credits
|
psm=self.psm, mtu=self.mtu, mps=self.mps, max_credits=self.max_credits
|
||||||
),
|
),
|
||||||
handler=self.on_coc,
|
handler=self.on_channel,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(f'### Listening for channel connection on PSM {self.psm}', 'yellow')
|
||||||
)
|
)
|
||||||
print(color(f'### Listening for CoC connection on PSM {self.psm}', 'yellow'))
|
|
||||||
|
|
||||||
def on_ble_connection(connection):
|
def on_ble_connection(connection):
|
||||||
def on_ble_disconnection(reason):
|
def on_ble_disconnection(reason):
|
||||||
@@ -73,7 +75,7 @@ class ServerBridge:
|
|||||||
await device.start_advertising(auto_restart=True)
|
await device.start_advertising(auto_restart=True)
|
||||||
|
|
||||||
# Called when a new L2CAP connection is established
|
# Called when a new L2CAP connection is established
|
||||||
def on_coc(self, l2cap_channel):
|
def on_channel(self, l2cap_channel):
|
||||||
print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
|
print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
|
||||||
|
|
||||||
class Pipe:
|
class Pipe:
|
||||||
@@ -83,7 +85,7 @@ class ServerBridge:
|
|||||||
self.l2cap_channel = l2cap_channel
|
self.l2cap_channel = l2cap_channel
|
||||||
|
|
||||||
l2cap_channel.on('close', self.on_l2cap_close)
|
l2cap_channel.on('close', self.on_l2cap_close)
|
||||||
l2cap_channel.sink = self.on_coc_sdu
|
l2cap_channel.sink = self.on_channel_sdu
|
||||||
|
|
||||||
async def connect_to_tcp(self):
|
async def connect_to_tcp(self):
|
||||||
# Connect to the TCP server
|
# Connect to the TCP server
|
||||||
@@ -128,7 +130,7 @@ class ServerBridge:
|
|||||||
if self.tcp_transport is not None:
|
if self.tcp_transport is not None:
|
||||||
self.tcp_transport.close()
|
self.tcp_transport.close()
|
||||||
|
|
||||||
def on_coc_sdu(self, sdu):
|
def on_channel_sdu(self, sdu):
|
||||||
print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
|
print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
|
||||||
if self.tcp_transport is None:
|
if self.tcp_transport is None:
|
||||||
print(color('!!! TCP socket not open, dropping', 'red'))
|
print(color('!!! TCP socket not open, dropping', 'red'))
|
||||||
@@ -183,7 +185,7 @@ class ClientBridge:
|
|||||||
peer_name = writer.get_extra_info('peer_name')
|
peer_name = writer.get_extra_info('peer_name')
|
||||||
print(color(f'<<< TCP connection from {peer_name}', 'magenta'))
|
print(color(f'<<< TCP connection from {peer_name}', 'magenta'))
|
||||||
|
|
||||||
def on_coc_sdu(sdu):
|
def on_channel_sdu(sdu):
|
||||||
print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
|
print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
|
||||||
l2cap_to_tcp_pipe.write(sdu)
|
l2cap_to_tcp_pipe.write(sdu)
|
||||||
|
|
||||||
@@ -209,7 +211,7 @@ class ClientBridge:
|
|||||||
writer.close()
|
writer.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
l2cap_channel.sink = on_coc_sdu
|
l2cap_channel.sink = on_channel_sdu
|
||||||
l2cap_channel.on('close', on_l2cap_close)
|
l2cap_channel.on('close', on_l2cap_close)
|
||||||
|
|
||||||
# Start a flow control pipe from L2CAP to TCP
|
# Start a flow control pipe from L2CAP to TCP
|
||||||
@@ -274,23 +276,29 @@ async def run(device_config, hci_transport, bridge):
|
|||||||
@click.pass_context
|
@click.pass_context
|
||||||
@click.option('--device-config', help='Device configuration file', required=True)
|
@click.option('--device-config', help='Device configuration file', required=True)
|
||||||
@click.option('--hci-transport', help='HCI transport', required=True)
|
@click.option('--hci-transport', help='HCI transport', required=True)
|
||||||
@click.option('--psm', help='PSM for L2CAP CoC', type=int, default=1234)
|
@click.option('--psm', help='PSM for L2CAP', type=int, default=1234)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--l2cap-coc-max-credits',
|
'--l2cap-max-credits',
|
||||||
help='Maximum L2CAP CoC Credits',
|
help='Maximum L2CAP Credits',
|
||||||
type=click.IntRange(1, 65535),
|
type=click.IntRange(1, 65535),
|
||||||
default=128,
|
default=128,
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--l2cap-coc-mtu',
|
'--l2cap-mtu',
|
||||||
help='L2CAP CoC MTU',
|
help='L2CAP MTU',
|
||||||
type=click.IntRange(23, 65535),
|
type=click.IntRange(
|
||||||
default=1022,
|
l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU,
|
||||||
|
l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MTU,
|
||||||
|
),
|
||||||
|
default=1024,
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--l2cap-coc-mps',
|
'--l2cap-mps',
|
||||||
help='L2CAP CoC MPS',
|
help='L2CAP MPS',
|
||||||
type=click.IntRange(23, 65533),
|
type=click.IntRange(
|
||||||
|
l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS,
|
||||||
|
l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS,
|
||||||
|
),
|
||||||
default=1024,
|
default=1024,
|
||||||
)
|
)
|
||||||
def cli(
|
def cli(
|
||||||
@@ -298,17 +306,17 @@ def cli(
|
|||||||
device_config,
|
device_config,
|
||||||
hci_transport,
|
hci_transport,
|
||||||
psm,
|
psm,
|
||||||
l2cap_coc_max_credits,
|
l2cap_max_credits,
|
||||||
l2cap_coc_mtu,
|
l2cap_mtu,
|
||||||
l2cap_coc_mps,
|
l2cap_mps,
|
||||||
):
|
):
|
||||||
context.ensure_object(dict)
|
context.ensure_object(dict)
|
||||||
context.obj['device_config'] = device_config
|
context.obj['device_config'] = device_config
|
||||||
context.obj['hci_transport'] = hci_transport
|
context.obj['hci_transport'] = hci_transport
|
||||||
context.obj['psm'] = psm
|
context.obj['psm'] = psm
|
||||||
context.obj['max_credits'] = l2cap_coc_max_credits
|
context.obj['max_credits'] = l2cap_max_credits
|
||||||
context.obj['mtu'] = l2cap_coc_mtu
|
context.obj['mtu'] = l2cap_mtu
|
||||||
context.obj['mps'] = l2cap_coc_mps
|
context.obj['mps'] = l2cap_mps
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
+44
-8
@@ -26,7 +26,7 @@ from bumble.transport import open_transport_or_link
|
|||||||
from bumble.keys import JsonKeyStore
|
from bumble.keys import JsonKeyStore
|
||||||
from bumble.smp import AddressResolver
|
from bumble.smp import AddressResolver
|
||||||
from bumble.device import Advertisement
|
from bumble.device import Advertisement
|
||||||
from bumble.hci import HCI_Constant, HCI_LE_1M_PHY, HCI_LE_CODED_PHY
|
from bumble.hci import Address, HCI_Constant, HCI_LE_1M_PHY, HCI_LE_CODED_PHY
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -66,10 +66,15 @@ class AdvertisementPrinter:
|
|||||||
address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[
|
address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[
|
||||||
address.address_type
|
address.address_type
|
||||||
]
|
]
|
||||||
if address.is_public:
|
if address.address_type in (
|
||||||
type_color = 'cyan'
|
Address.RANDOM_IDENTITY_ADDRESS,
|
||||||
|
Address.PUBLIC_IDENTITY_ADDRESS,
|
||||||
|
):
|
||||||
|
type_color = 'yellow'
|
||||||
else:
|
else:
|
||||||
if address.is_static:
|
if address.is_public:
|
||||||
|
type_color = 'cyan'
|
||||||
|
elif address.is_static:
|
||||||
type_color = 'green'
|
type_color = 'green'
|
||||||
address_qualifier = '(static)'
|
address_qualifier = '(static)'
|
||||||
elif address.is_resolvable:
|
elif address.is_resolvable:
|
||||||
@@ -116,6 +121,7 @@ async def scan(
|
|||||||
phy,
|
phy,
|
||||||
filter_duplicates,
|
filter_duplicates,
|
||||||
raw,
|
raw,
|
||||||
|
irks,
|
||||||
keystore_file,
|
keystore_file,
|
||||||
device_config,
|
device_config,
|
||||||
transport,
|
transport,
|
||||||
@@ -140,9 +146,21 @@ async def scan(
|
|||||||
|
|
||||||
if device.keystore:
|
if device.keystore:
|
||||||
resolving_keys = await device.keystore.get_resolving_keys()
|
resolving_keys = await device.keystore.get_resolving_keys()
|
||||||
resolver = AddressResolver(resolving_keys)
|
|
||||||
else:
|
else:
|
||||||
resolver = None
|
resolving_keys = []
|
||||||
|
|
||||||
|
for irk_and_address in irks:
|
||||||
|
if ':' not in irk_and_address:
|
||||||
|
raise ValueError('invalid IRK:ADDRESS value')
|
||||||
|
irk_hex, address_str = irk_and_address.split(':', 1)
|
||||||
|
resolving_keys.append(
|
||||||
|
(
|
||||||
|
bytes.fromhex(irk_hex),
|
||||||
|
Address(address_str, Address.RANDOM_DEVICE_ADDRESS),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resolver = AddressResolver(resolving_keys) if resolving_keys else None
|
||||||
|
|
||||||
printer = AdvertisementPrinter(min_rssi, resolver)
|
printer = AdvertisementPrinter(min_rssi, resolver)
|
||||||
if raw:
|
if raw:
|
||||||
@@ -187,8 +205,24 @@ async def scan(
|
|||||||
default=False,
|
default=False,
|
||||||
help='Listen for raw advertising reports instead of processed ones',
|
help='Listen for raw advertising reports instead of processed ones',
|
||||||
)
|
)
|
||||||
@click.option('--keystore-file', help='Keystore file to use when resolving addresses')
|
@click.option(
|
||||||
@click.option('--device-config', help='Device config file for the scanning device')
|
'--irk',
|
||||||
|
metavar='<IRK_HEX>:<ADDRESS>',
|
||||||
|
help=(
|
||||||
|
'Use this IRK for resolving private addresses ' '(may be used more than once)'
|
||||||
|
),
|
||||||
|
multiple=True,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--keystore-file',
|
||||||
|
metavar='FILE_PATH',
|
||||||
|
help='Keystore file to use when resolving addresses',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--device-config',
|
||||||
|
metavar='FILE_PATH',
|
||||||
|
help='Device config file for the scanning device',
|
||||||
|
)
|
||||||
@click.argument('transport')
|
@click.argument('transport')
|
||||||
def main(
|
def main(
|
||||||
min_rssi,
|
min_rssi,
|
||||||
@@ -198,6 +232,7 @@ def main(
|
|||||||
phy,
|
phy,
|
||||||
filter_duplicates,
|
filter_duplicates,
|
||||||
raw,
|
raw,
|
||||||
|
irk,
|
||||||
keystore_file,
|
keystore_file,
|
||||||
device_config,
|
device_config,
|
||||||
transport,
|
transport,
|
||||||
@@ -212,6 +247,7 @@ def main(
|
|||||||
phy,
|
phy,
|
||||||
filter_duplicates,
|
filter_duplicates,
|
||||||
raw,
|
raw,
|
||||||
|
irk,
|
||||||
keystore_file,
|
keystore_file,
|
||||||
device_config,
|
device_config,
|
||||||
transport,
|
transport,
|
||||||
|
|||||||
+12
-4
@@ -184,8 +184,12 @@ def make_audio_source_service_sdp_records(service_record_handle, version=(1, 3))
|
|||||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
DataElement.sequence(
|
DataElement.sequence(
|
||||||
[
|
[
|
||||||
DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
|
DataElement.sequence(
|
||||||
DataElement.unsigned_integer_16(version_int),
|
[
|
||||||
|
DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
|
||||||
|
DataElement.unsigned_integer_16(version_int),
|
||||||
|
]
|
||||||
|
)
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -234,8 +238,12 @@ def make_audio_sink_service_sdp_records(service_record_handle, version=(1, 3)):
|
|||||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
DataElement.sequence(
|
DataElement.sequence(
|
||||||
[
|
[
|
||||||
DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
|
DataElement.sequence(
|
||||||
DataElement.unsigned_integer_16(version_int),
|
[
|
||||||
|
DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
|
||||||
|
DataElement.unsigned_integer_16(version_int),
|
||||||
|
]
|
||||||
|
)
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+520
@@ -0,0 +1,520 @@
|
|||||||
|
# Copyright 2021-2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# 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 enum
|
||||||
|
import struct
|
||||||
|
from typing import Dict, Type, Union, Tuple
|
||||||
|
|
||||||
|
from bumble.utils import OpenIntEnum
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Frame:
|
||||||
|
class SubunitType(enum.IntEnum):
|
||||||
|
# AV/C Digital Interface Command Set General Specification Version 4.1
|
||||||
|
# Table 7.4
|
||||||
|
MONITOR = 0x00
|
||||||
|
AUDIO = 0x01
|
||||||
|
PRINTER = 0x02
|
||||||
|
DISC = 0x03
|
||||||
|
TAPE_RECORDER_OR_PLAYER = 0x04
|
||||||
|
TUNER = 0x05
|
||||||
|
CA = 0x06
|
||||||
|
CAMERA = 0x07
|
||||||
|
PANEL = 0x09
|
||||||
|
BULLETIN_BOARD = 0x0A
|
||||||
|
VENDOR_UNIQUE = 0x1C
|
||||||
|
EXTENDED = 0x1E
|
||||||
|
UNIT = 0x1F
|
||||||
|
|
||||||
|
class OperationCode(OpenIntEnum):
|
||||||
|
# 0x00 - 0x0F: Unit and subunit commands
|
||||||
|
VENDOR_DEPENDENT = 0x00
|
||||||
|
RESERVE = 0x01
|
||||||
|
PLUG_INFO = 0x02
|
||||||
|
|
||||||
|
# 0x10 - 0x3F: Unit commands
|
||||||
|
DIGITAL_OUTPUT = 0x10
|
||||||
|
DIGITAL_INPUT = 0x11
|
||||||
|
CHANNEL_USAGE = 0x12
|
||||||
|
OUTPUT_PLUG_SIGNAL_FORMAT = 0x18
|
||||||
|
INPUT_PLUG_SIGNAL_FORMAT = 0x19
|
||||||
|
GENERAL_BUS_SETUP = 0x1F
|
||||||
|
CONNECT_AV = 0x20
|
||||||
|
DISCONNECT_AV = 0x21
|
||||||
|
CONNECTIONS = 0x22
|
||||||
|
CONNECT = 0x24
|
||||||
|
DISCONNECT = 0x25
|
||||||
|
UNIT_INFO = 0x30
|
||||||
|
SUBUNIT_INFO = 0x31
|
||||||
|
|
||||||
|
# 0x40 - 0x7F: Subunit commands
|
||||||
|
PASS_THROUGH = 0x7C
|
||||||
|
GUI_UPDATE = 0x7D
|
||||||
|
PUSH_GUI_DATA = 0x7E
|
||||||
|
USER_ACTION = 0x7F
|
||||||
|
|
||||||
|
# 0xA0 - 0xBF: Unit and subunit commands
|
||||||
|
VERSION = 0xB0
|
||||||
|
POWER = 0xB2
|
||||||
|
|
||||||
|
subunit_type: SubunitType
|
||||||
|
subunit_id: int
|
||||||
|
opcode: OperationCode
|
||||||
|
operands: bytes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def subclass(subclass):
|
||||||
|
# Infer the opcode from the class name
|
||||||
|
if subclass.__name__.endswith("CommandFrame"):
|
||||||
|
short_name = subclass.__name__.replace("CommandFrame", "")
|
||||||
|
category_class = CommandFrame
|
||||||
|
elif subclass.__name__.endswith("ResponseFrame"):
|
||||||
|
short_name = subclass.__name__.replace("ResponseFrame", "")
|
||||||
|
category_class = ResponseFrame
|
||||||
|
else:
|
||||||
|
raise ValueError(f"invalid subclass name {subclass.__name__}")
|
||||||
|
|
||||||
|
uppercase_indexes = [
|
||||||
|
i for i in range(len(short_name)) if short_name[i].isupper()
|
||||||
|
]
|
||||||
|
uppercase_indexes.append(len(short_name))
|
||||||
|
words = [
|
||||||
|
short_name[uppercase_indexes[i] : uppercase_indexes[i + 1]].upper()
|
||||||
|
for i in range(len(uppercase_indexes) - 1)
|
||||||
|
]
|
||||||
|
opcode_name = "_".join(words)
|
||||||
|
opcode = Frame.OperationCode[opcode_name]
|
||||||
|
category_class.subclasses[opcode] = subclass
|
||||||
|
return subclass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_bytes(data: bytes) -> Frame:
|
||||||
|
if data[0] >> 4 != 0:
|
||||||
|
raise ValueError("first 4 bits must be 0s")
|
||||||
|
|
||||||
|
ctype_or_response = data[0] & 0xF
|
||||||
|
subunit_type = Frame.SubunitType(data[1] >> 3)
|
||||||
|
subunit_id = data[1] & 7
|
||||||
|
|
||||||
|
if subunit_type == Frame.SubunitType.EXTENDED:
|
||||||
|
# Not supported
|
||||||
|
raise NotImplementedError("extended subunit types not supported")
|
||||||
|
|
||||||
|
if subunit_id < 5:
|
||||||
|
opcode_offset = 2
|
||||||
|
elif subunit_id == 5:
|
||||||
|
# Extended to the next byte
|
||||||
|
extension = data[2]
|
||||||
|
if extension == 0:
|
||||||
|
raise ValueError("extended subunit ID value reserved")
|
||||||
|
if extension == 0xFF:
|
||||||
|
subunit_id = 5 + 254 + data[3]
|
||||||
|
opcode_offset = 4
|
||||||
|
else:
|
||||||
|
subunit_id = 5 + extension
|
||||||
|
opcode_offset = 3
|
||||||
|
|
||||||
|
elif subunit_id == 6:
|
||||||
|
raise ValueError("reserved subunit ID")
|
||||||
|
|
||||||
|
opcode = Frame.OperationCode(data[opcode_offset])
|
||||||
|
operands = data[opcode_offset + 1 :]
|
||||||
|
|
||||||
|
# Look for a registered subclass
|
||||||
|
if ctype_or_response < 8:
|
||||||
|
# Command
|
||||||
|
ctype = CommandFrame.CommandType(ctype_or_response)
|
||||||
|
if c_subclass := CommandFrame.subclasses.get(opcode):
|
||||||
|
return c_subclass(
|
||||||
|
ctype,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
*c_subclass.parse_operands(operands),
|
||||||
|
)
|
||||||
|
return CommandFrame(ctype, subunit_type, subunit_id, opcode, operands)
|
||||||
|
else:
|
||||||
|
# Response
|
||||||
|
response = ResponseFrame.ResponseCode(ctype_or_response)
|
||||||
|
if r_subclass := ResponseFrame.subclasses.get(opcode):
|
||||||
|
return r_subclass(
|
||||||
|
response,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
*r_subclass.parse_operands(operands),
|
||||||
|
)
|
||||||
|
return ResponseFrame(response, subunit_type, subunit_id, opcode, operands)
|
||||||
|
|
||||||
|
def to_bytes(
|
||||||
|
self,
|
||||||
|
ctype_or_response: Union[CommandFrame.CommandType, ResponseFrame.ResponseCode],
|
||||||
|
) -> bytes:
|
||||||
|
# TODO: support extended subunit types and ids.
|
||||||
|
return (
|
||||||
|
bytes(
|
||||||
|
[
|
||||||
|
ctype_or_response,
|
||||||
|
self.subunit_type << 3 | self.subunit_id,
|
||||||
|
self.opcode,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ self.operands
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_string(self, extra: str) -> str:
|
||||||
|
return (
|
||||||
|
f"{self.__class__.__name__}({extra}"
|
||||||
|
f"subunit_type={self.subunit_type.name}, "
|
||||||
|
f"subunit_id=0x{self.subunit_id:02X}, "
|
||||||
|
f"opcode={self.opcode.name}, "
|
||||||
|
f"operands={self.operands.hex()})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
subunit_type: SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
opcode: OperationCode,
|
||||||
|
operands: bytes,
|
||||||
|
) -> None:
|
||||||
|
self.subunit_type = subunit_type
|
||||||
|
self.subunit_id = subunit_id
|
||||||
|
self.opcode = opcode
|
||||||
|
self.operands = operands
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class CommandFrame(Frame):
|
||||||
|
class CommandType(OpenIntEnum):
|
||||||
|
# AV/C Digital Interface Command Set General Specification Version 4.1
|
||||||
|
# Table 7.1
|
||||||
|
CONTROL = 0x00
|
||||||
|
STATUS = 0x01
|
||||||
|
SPECIFIC_INQUIRY = 0x02
|
||||||
|
NOTIFY = 0x03
|
||||||
|
GENERAL_INQUIRY = 0x04
|
||||||
|
|
||||||
|
subclasses: Dict[Frame.OperationCode, Type[CommandFrame]] = {}
|
||||||
|
ctype: CommandType
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_operands(operands: bytes) -> Tuple:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ctype: CommandType,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
opcode: Frame.OperationCode,
|
||||||
|
operands: bytes,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(subunit_type, subunit_id, opcode, operands)
|
||||||
|
self.ctype = ctype
|
||||||
|
|
||||||
|
def __bytes__(self):
|
||||||
|
return self.to_bytes(self.ctype)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.to_string(f"ctype={self.ctype.name}, ")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class ResponseFrame(Frame):
|
||||||
|
class ResponseCode(OpenIntEnum):
|
||||||
|
# AV/C Digital Interface Command Set General Specification Version 4.1
|
||||||
|
# Table 7.2
|
||||||
|
NOT_IMPLEMENTED = 0x08
|
||||||
|
ACCEPTED = 0x09
|
||||||
|
REJECTED = 0x0A
|
||||||
|
IN_TRANSITION = 0x0B
|
||||||
|
IMPLEMENTED_OR_STABLE = 0x0C
|
||||||
|
CHANGED = 0x0D
|
||||||
|
INTERIM = 0x0F
|
||||||
|
|
||||||
|
subclasses: Dict[Frame.OperationCode, Type[ResponseFrame]] = {}
|
||||||
|
response: ResponseCode
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_operands(operands: bytes) -> Tuple:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
response: ResponseCode,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
opcode: Frame.OperationCode,
|
||||||
|
operands: bytes,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(subunit_type, subunit_id, opcode, operands)
|
||||||
|
self.response = response
|
||||||
|
|
||||||
|
def __bytes__(self):
|
||||||
|
return self.to_bytes(self.response)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.to_string(f"response={self.response.name}, ")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class VendorDependentFrame:
|
||||||
|
company_id: int
|
||||||
|
vendor_dependent_data: bytes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_operands(operands: bytes) -> Tuple:
|
||||||
|
return (
|
||||||
|
struct.unpack(">I", b"\x00" + operands[:3])[0],
|
||||||
|
operands[3:],
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_operands(self) -> bytes:
|
||||||
|
return struct.pack(">I", self.company_id)[1:] + self.vendor_dependent_data
|
||||||
|
|
||||||
|
def __init__(self, company_id: int, vendor_dependent_data: bytes):
|
||||||
|
self.company_id = company_id
|
||||||
|
self.vendor_dependent_data = vendor_dependent_data
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@Frame.subclass
|
||||||
|
class VendorDependentCommandFrame(VendorDependentFrame, CommandFrame):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ctype: CommandFrame.CommandType,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
company_id: int,
|
||||||
|
vendor_dependent_data: bytes,
|
||||||
|
) -> None:
|
||||||
|
VendorDependentFrame.__init__(self, company_id, vendor_dependent_data)
|
||||||
|
CommandFrame.__init__(
|
||||||
|
self,
|
||||||
|
ctype,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
Frame.OperationCode.VENDOR_DEPENDENT,
|
||||||
|
self.make_operands(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"VendorDependentCommandFrame(ctype={self.ctype.name}, "
|
||||||
|
f"subunit_type={self.subunit_type.name}, "
|
||||||
|
f"subunit_id=0x{self.subunit_id:02X}, "
|
||||||
|
f"company_id=0x{self.company_id:06X}, "
|
||||||
|
f"vendor_dependent_data={self.vendor_dependent_data.hex()})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@Frame.subclass
|
||||||
|
class VendorDependentResponseFrame(VendorDependentFrame, ResponseFrame):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
response: ResponseFrame.ResponseCode,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
company_id: int,
|
||||||
|
vendor_dependent_data: bytes,
|
||||||
|
) -> None:
|
||||||
|
VendorDependentFrame.__init__(self, company_id, vendor_dependent_data)
|
||||||
|
ResponseFrame.__init__(
|
||||||
|
self,
|
||||||
|
response,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
Frame.OperationCode.VENDOR_DEPENDENT,
|
||||||
|
self.make_operands(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"VendorDependentResponseFrame(response={self.response.name}, "
|
||||||
|
f"subunit_type={self.subunit_type.name}, "
|
||||||
|
f"subunit_id=0x{self.subunit_id:02X}, "
|
||||||
|
f"company_id=0x{self.company_id:06X}, "
|
||||||
|
f"vendor_dependent_data={self.vendor_dependent_data.hex()})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class PassThroughFrame:
|
||||||
|
"""
|
||||||
|
See AV/C Panel Subunit Specification 1.1 - 9.4 PASS THROUGH control command
|
||||||
|
"""
|
||||||
|
|
||||||
|
class StateFlag(enum.IntEnum):
|
||||||
|
PRESSED = 0
|
||||||
|
RELEASED = 1
|
||||||
|
|
||||||
|
class OperationId(OpenIntEnum):
|
||||||
|
SELECT = 0x00
|
||||||
|
UP = 0x01
|
||||||
|
DOWN = 0x01
|
||||||
|
LEFT = 0x03
|
||||||
|
RIGHT = 0x04
|
||||||
|
RIGHT_UP = 0x05
|
||||||
|
RIGHT_DOWN = 0x06
|
||||||
|
LEFT_UP = 0x07
|
||||||
|
LEFT_DOWN = 0x08
|
||||||
|
ROOT_MENU = 0x09
|
||||||
|
SETUP_MENU = 0x0A
|
||||||
|
CONTENTS_MENU = 0x0B
|
||||||
|
FAVORITE_MENU = 0x0C
|
||||||
|
EXIT = 0x0D
|
||||||
|
NUMBER_0 = 0x20
|
||||||
|
NUMBER_1 = 0x21
|
||||||
|
NUMBER_2 = 0x22
|
||||||
|
NUMBER_3 = 0x23
|
||||||
|
NUMBER_4 = 0x24
|
||||||
|
NUMBER_5 = 0x25
|
||||||
|
NUMBER_6 = 0x26
|
||||||
|
NUMBER_7 = 0x27
|
||||||
|
NUMBER_8 = 0x28
|
||||||
|
NUMBER_9 = 0x29
|
||||||
|
DOT = 0x2A
|
||||||
|
ENTER = 0x2B
|
||||||
|
CLEAR = 0x2C
|
||||||
|
CHANNEL_UP = 0x30
|
||||||
|
CHANNEL_DOWN = 0x31
|
||||||
|
PREVIOUS_CHANNEL = 0x32
|
||||||
|
SOUND_SELECT = 0x33
|
||||||
|
INPUT_SELECT = 0x34
|
||||||
|
DISPLAY_INFORMATION = 0x35
|
||||||
|
HELP = 0x36
|
||||||
|
PAGE_UP = 0x37
|
||||||
|
PAGE_DOWN = 0x38
|
||||||
|
POWER = 0x40
|
||||||
|
VOLUME_UP = 0x41
|
||||||
|
VOLUME_DOWN = 0x42
|
||||||
|
MUTE = 0x43
|
||||||
|
PLAY = 0x44
|
||||||
|
STOP = 0x45
|
||||||
|
PAUSE = 0x46
|
||||||
|
RECORD = 0x47
|
||||||
|
REWIND = 0x48
|
||||||
|
FAST_FORWARD = 0x49
|
||||||
|
EJECT = 0x4A
|
||||||
|
FORWARD = 0x4B
|
||||||
|
BACKWARD = 0x4C
|
||||||
|
ANGLE = 0x50
|
||||||
|
SUBPICTURE = 0x51
|
||||||
|
F1 = 0x71
|
||||||
|
F2 = 0x72
|
||||||
|
F3 = 0x73
|
||||||
|
F4 = 0x74
|
||||||
|
F5 = 0x75
|
||||||
|
VENDOR_UNIQUE = 0x7E
|
||||||
|
|
||||||
|
state_flag: StateFlag
|
||||||
|
operation_id: OperationId
|
||||||
|
operation_data: bytes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_operands(operands: bytes) -> Tuple:
|
||||||
|
return (
|
||||||
|
PassThroughFrame.StateFlag(operands[0] >> 7),
|
||||||
|
PassThroughFrame.OperationId(operands[0] & 0x7F),
|
||||||
|
operands[1 : 1 + operands[1]],
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_operands(self):
|
||||||
|
return (
|
||||||
|
bytes([self.state_flag << 7 | self.operation_id, len(self.operation_data)])
|
||||||
|
+ self.operation_data
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
state_flag: StateFlag,
|
||||||
|
operation_id: OperationId,
|
||||||
|
operation_data: bytes,
|
||||||
|
) -> None:
|
||||||
|
if len(operation_data) > 255:
|
||||||
|
raise ValueError("operation data must be <= 255 bytes")
|
||||||
|
self.state_flag = state_flag
|
||||||
|
self.operation_id = operation_id
|
||||||
|
self.operation_data = operation_data
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@Frame.subclass
|
||||||
|
class PassThroughCommandFrame(PassThroughFrame, CommandFrame):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ctype: CommandFrame.CommandType,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
state_flag: PassThroughFrame.StateFlag,
|
||||||
|
operation_id: PassThroughFrame.OperationId,
|
||||||
|
operation_data: bytes,
|
||||||
|
) -> None:
|
||||||
|
PassThroughFrame.__init__(self, state_flag, operation_id, operation_data)
|
||||||
|
CommandFrame.__init__(
|
||||||
|
self,
|
||||||
|
ctype,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
Frame.OperationCode.PASS_THROUGH,
|
||||||
|
self.make_operands(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"PassThroughCommandFrame(ctype={self.ctype.name}, "
|
||||||
|
f"subunit_type={self.subunit_type.name}, "
|
||||||
|
f"subunit_id=0x{self.subunit_id:02X}, "
|
||||||
|
f"state_flag={self.state_flag.name}, "
|
||||||
|
f"operation_id={self.operation_id.name}, "
|
||||||
|
f"operation_data={self.operation_data.hex()})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@Frame.subclass
|
||||||
|
class PassThroughResponseFrame(PassThroughFrame, ResponseFrame):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
response: ResponseFrame.ResponseCode,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
state_flag: PassThroughFrame.StateFlag,
|
||||||
|
operation_id: PassThroughFrame.OperationId,
|
||||||
|
operation_data: bytes,
|
||||||
|
) -> None:
|
||||||
|
PassThroughFrame.__init__(self, state_flag, operation_id, operation_data)
|
||||||
|
ResponseFrame.__init__(
|
||||||
|
self,
|
||||||
|
response,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
Frame.OperationCode.PASS_THROUGH,
|
||||||
|
self.make_operands(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"PassThroughResponseFrame(response={self.response.name}, "
|
||||||
|
f"subunit_type={self.subunit_type.name}, "
|
||||||
|
f"subunit_id=0x{self.subunit_id:02X}, "
|
||||||
|
f"state_flag={self.state_flag.name}, "
|
||||||
|
f"operation_id={self.operation_id.name}, "
|
||||||
|
f"operation_data={self.operation_data.hex()})"
|
||||||
|
)
|
||||||
+291
@@ -0,0 +1,291 @@
|
|||||||
|
# Copyright 2021-2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
from enum import IntEnum
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
from typing import Callable, cast, Dict, Optional
|
||||||
|
|
||||||
|
from bumble.colors import color
|
||||||
|
from bumble import avc
|
||||||
|
from bumble import l2cap
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
AVCTP_PSM = 0x0017
|
||||||
|
AVCTP_BROWSING_PSM = 0x001B
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class MessageAssembler:
|
||||||
|
Callback = Callable[[int, bool, bool, int, bytes], None]
|
||||||
|
|
||||||
|
transaction_label: int
|
||||||
|
pid: int
|
||||||
|
c_r: int
|
||||||
|
ipid: int
|
||||||
|
payload: bytes
|
||||||
|
number_of_packets: int
|
||||||
|
packets_received: int
|
||||||
|
|
||||||
|
def __init__(self, callback: Callback) -> None:
|
||||||
|
self.callback = callback
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.packets_received = 0
|
||||||
|
self.transaction_label = -1
|
||||||
|
self.pid = -1
|
||||||
|
self.c_r = -1
|
||||||
|
self.ipid = -1
|
||||||
|
self.payload = b''
|
||||||
|
self.number_of_packets = 0
|
||||||
|
self.packet_count = 0
|
||||||
|
|
||||||
|
def on_pdu(self, pdu: bytes) -> None:
|
||||||
|
self.packets_received += 1
|
||||||
|
|
||||||
|
transaction_label = pdu[0] >> 4
|
||||||
|
packet_type = Protocol.PacketType((pdu[0] >> 2) & 3)
|
||||||
|
c_r = (pdu[0] >> 1) & 1
|
||||||
|
ipid = pdu[0] & 1
|
||||||
|
|
||||||
|
if c_r == 0 and ipid != 0:
|
||||||
|
logger.warning("invalid IPID in command frame")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
pid_offset = 1
|
||||||
|
if packet_type in (Protocol.PacketType.SINGLE, Protocol.PacketType.START):
|
||||||
|
if self.transaction_label >= 0:
|
||||||
|
# We are already in a transaction
|
||||||
|
logger.warning("received START or SINGLE fragment while in transaction")
|
||||||
|
self.reset()
|
||||||
|
self.packets_received = 1
|
||||||
|
|
||||||
|
if packet_type == Protocol.PacketType.START:
|
||||||
|
self.number_of_packets = pdu[1]
|
||||||
|
pid_offset = 2
|
||||||
|
|
||||||
|
pid = struct.unpack_from(">H", pdu, pid_offset)[0]
|
||||||
|
self.payload += pdu[pid_offset + 2 :]
|
||||||
|
|
||||||
|
if packet_type in (Protocol.PacketType.CONTINUE, Protocol.PacketType.END):
|
||||||
|
if transaction_label != self.transaction_label:
|
||||||
|
logger.warning("transaction label does not match")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
if pid != self.pid:
|
||||||
|
logger.warning("PID does not match")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
if c_r != self.c_r:
|
||||||
|
logger.warning("C/R does not match")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.packets_received > self.number_of_packets:
|
||||||
|
logger.warning("too many fragments in transaction")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
if packet_type == Protocol.PacketType.END:
|
||||||
|
if self.packets_received != self.number_of_packets:
|
||||||
|
logger.warning("premature END")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
self.transaction_label = transaction_label
|
||||||
|
self.c_r = c_r
|
||||||
|
self.ipid = ipid
|
||||||
|
self.pid = pid
|
||||||
|
|
||||||
|
if packet_type in (Protocol.PacketType.SINGLE, Protocol.PacketType.END):
|
||||||
|
self.on_message_complete()
|
||||||
|
|
||||||
|
def on_message_complete(self):
|
||||||
|
try:
|
||||||
|
self.callback(
|
||||||
|
self.transaction_label,
|
||||||
|
self.c_r == 0,
|
||||||
|
self.ipid != 0,
|
||||||
|
self.pid,
|
||||||
|
self.payload,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.exception(color(f"!!! exception in callback: {error}", "red"))
|
||||||
|
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Protocol:
|
||||||
|
CommandHandler = Callable[[int, avc.CommandFrame], None]
|
||||||
|
command_handlers: Dict[int, CommandHandler] # Command handlers, by PID
|
||||||
|
ResponseHandler = Callable[[int, Optional[avc.ResponseFrame]], None]
|
||||||
|
response_handlers: Dict[int, ResponseHandler] # Response handlers, by PID
|
||||||
|
next_transaction_label: int
|
||||||
|
message_assembler: MessageAssembler
|
||||||
|
|
||||||
|
class PacketType(IntEnum):
|
||||||
|
SINGLE = 0b00
|
||||||
|
START = 0b01
|
||||||
|
CONTINUE = 0b10
|
||||||
|
END = 0b11
|
||||||
|
|
||||||
|
def __init__(self, l2cap_channel: l2cap.ClassicChannel) -> None:
|
||||||
|
self.command_handlers = {}
|
||||||
|
self.response_handlers = {}
|
||||||
|
self.l2cap_channel = l2cap_channel
|
||||||
|
self.message_assembler = MessageAssembler(self.on_message)
|
||||||
|
|
||||||
|
# Register to receive PDUs from the channel
|
||||||
|
l2cap_channel.sink = self.on_pdu
|
||||||
|
l2cap_channel.on("open", self.on_l2cap_channel_open)
|
||||||
|
l2cap_channel.on("close", self.on_l2cap_channel_close)
|
||||||
|
|
||||||
|
def on_l2cap_channel_open(self):
|
||||||
|
logger.debug(color("<<< AVCTP channel open", "magenta"))
|
||||||
|
|
||||||
|
def on_l2cap_channel_close(self):
|
||||||
|
logger.debug(color("<<< AVCTP channel closed", "magenta"))
|
||||||
|
|
||||||
|
def on_pdu(self, pdu: bytes) -> None:
|
||||||
|
self.message_assembler.on_pdu(pdu)
|
||||||
|
|
||||||
|
def on_message(
|
||||||
|
self,
|
||||||
|
transaction_label: int,
|
||||||
|
is_command: bool,
|
||||||
|
ipid: bool,
|
||||||
|
pid: int,
|
||||||
|
payload: bytes,
|
||||||
|
) -> None:
|
||||||
|
logger.debug(
|
||||||
|
f"<<< AVCTP Message: pid={pid}, "
|
||||||
|
f"transaction_label={transaction_label}, "
|
||||||
|
f"is_command={is_command}, "
|
||||||
|
f"ipid={ipid}, "
|
||||||
|
f"payload={payload.hex()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for invalid PID responses.
|
||||||
|
if ipid:
|
||||||
|
logger.debug(f"received IPID for PID={pid}")
|
||||||
|
|
||||||
|
# Find the appropriate handler.
|
||||||
|
if is_command:
|
||||||
|
if pid not in self.command_handlers:
|
||||||
|
logger.warning(f"no command handler for PID {pid}")
|
||||||
|
self.send_ipid(transaction_label, pid)
|
||||||
|
return
|
||||||
|
|
||||||
|
command_frame = cast(avc.CommandFrame, avc.Frame.from_bytes(payload))
|
||||||
|
self.command_handlers[pid](transaction_label, command_frame)
|
||||||
|
else:
|
||||||
|
if pid not in self.response_handlers:
|
||||||
|
logger.warning(f"no response handler for PID {pid}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# By convention, for an ipid, send a None payload to the response handler.
|
||||||
|
if ipid:
|
||||||
|
response_frame = None
|
||||||
|
else:
|
||||||
|
response_frame = cast(avc.ResponseFrame, avc.Frame.from_bytes(payload))
|
||||||
|
|
||||||
|
self.response_handlers[pid](transaction_label, response_frame)
|
||||||
|
|
||||||
|
def send_message(
|
||||||
|
self,
|
||||||
|
transaction_label: int,
|
||||||
|
is_command: bool,
|
||||||
|
ipid: bool,
|
||||||
|
pid: int,
|
||||||
|
payload: bytes,
|
||||||
|
):
|
||||||
|
# TODO: fragment large messages
|
||||||
|
packet_type = Protocol.PacketType.SINGLE
|
||||||
|
pdu = (
|
||||||
|
struct.pack(
|
||||||
|
">BH",
|
||||||
|
transaction_label << 4
|
||||||
|
| packet_type << 2
|
||||||
|
| (0 if is_command else 1) << 1
|
||||||
|
| (1 if ipid else 0),
|
||||||
|
pid,
|
||||||
|
)
|
||||||
|
+ payload
|
||||||
|
)
|
||||||
|
self.l2cap_channel.send_pdu(pdu)
|
||||||
|
|
||||||
|
def send_command(self, transaction_label: int, pid: int, payload: bytes) -> None:
|
||||||
|
logger.debug(
|
||||||
|
">>> AVCTP command: "
|
||||||
|
f"transaction_label={transaction_label}, "
|
||||||
|
f"pid={pid}, "
|
||||||
|
f"payload={payload.hex()}"
|
||||||
|
)
|
||||||
|
self.send_message(transaction_label, True, False, pid, payload)
|
||||||
|
|
||||||
|
def send_response(self, transaction_label: int, pid: int, payload: bytes):
|
||||||
|
logger.debug(
|
||||||
|
">>> AVCTP response: "
|
||||||
|
f"transaction_label={transaction_label}, "
|
||||||
|
f"pid={pid}, "
|
||||||
|
f"payload={payload.hex()}"
|
||||||
|
)
|
||||||
|
self.send_message(transaction_label, False, False, pid, payload)
|
||||||
|
|
||||||
|
def send_ipid(self, transaction_label: int, pid: int) -> None:
|
||||||
|
logger.debug(
|
||||||
|
">>> AVCTP ipid: " f"transaction_label={transaction_label}, " f"pid={pid}"
|
||||||
|
)
|
||||||
|
self.send_message(transaction_label, False, True, pid, b'')
|
||||||
|
|
||||||
|
def register_command_handler(
|
||||||
|
self, pid: int, handler: Protocol.CommandHandler
|
||||||
|
) -> None:
|
||||||
|
self.command_handlers[pid] = handler
|
||||||
|
|
||||||
|
def unregister_command_handler(
|
||||||
|
self, pid: int, handler: Protocol.CommandHandler
|
||||||
|
) -> None:
|
||||||
|
if pid not in self.command_handlers or self.command_handlers[pid] != handler:
|
||||||
|
raise ValueError("command handler not registered")
|
||||||
|
del self.command_handlers[pid]
|
||||||
|
|
||||||
|
def register_response_handler(
|
||||||
|
self, pid: int, handler: Protocol.ResponseHandler
|
||||||
|
) -> None:
|
||||||
|
self.response_handlers[pid] = handler
|
||||||
|
|
||||||
|
def unregister_response_handler(
|
||||||
|
self, pid: int, handler: Protocol.ResponseHandler
|
||||||
|
) -> None:
|
||||||
|
if pid not in self.response_handlers or self.response_handlers[pid] != handler:
|
||||||
|
raise ValueError("response handler not registered")
|
||||||
|
del self.response_handlers[pid]
|
||||||
+6
-2
@@ -241,7 +241,10 @@ async def find_avdtp_service_with_sdp_client(
|
|||||||
)
|
)
|
||||||
if profile_descriptor_list:
|
if profile_descriptor_list:
|
||||||
for profile_descriptor in profile_descriptor_list.value:
|
for profile_descriptor in profile_descriptor_list.value:
|
||||||
if len(profile_descriptor.value) >= 2:
|
if (
|
||||||
|
profile_descriptor.type == sdp.DataElement.SEQUENCE
|
||||||
|
and len(profile_descriptor.value) >= 2
|
||||||
|
):
|
||||||
avdtp_version_major = profile_descriptor.value[1].value >> 8
|
avdtp_version_major = profile_descriptor.value[1].value >> 8
|
||||||
avdtp_version_minor = profile_descriptor.value[1].value & 0xFF
|
avdtp_version_minor = profile_descriptor.value[1].value & 0xFF
|
||||||
return (avdtp_version_major, avdtp_version_minor)
|
return (avdtp_version_major, avdtp_version_minor)
|
||||||
@@ -511,7 +514,8 @@ class MessageAssembler:
|
|||||||
try:
|
try:
|
||||||
self.callback(self.transaction_label, message)
|
self.callback(self.transaction_label, message)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(color(f'!!! exception in callback: {error}'))
|
logger.exception(color(f'!!! exception in callback: {error}', 'red'))
|
||||||
|
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1916
File diff suppressed because it is too large
Load Diff
+391
-26
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
import itertools
|
import itertools
|
||||||
import random
|
import random
|
||||||
import struct
|
import struct
|
||||||
@@ -42,6 +43,7 @@ from bumble.hci import (
|
|||||||
HCI_LE_1M_PHY,
|
HCI_LE_1M_PHY,
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
HCI_UNKNOWN_HCI_COMMAND_ERROR,
|
HCI_UNKNOWN_HCI_COMMAND_ERROR,
|
||||||
|
HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
||||||
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||||
HCI_VERSION_BLUETOOTH_CORE_5_0,
|
HCI_VERSION_BLUETOOTH_CORE_5_0,
|
||||||
Address,
|
Address,
|
||||||
@@ -53,17 +55,21 @@ from bumble.hci import (
|
|||||||
HCI_Connection_Request_Event,
|
HCI_Connection_Request_Event,
|
||||||
HCI_Disconnection_Complete_Event,
|
HCI_Disconnection_Complete_Event,
|
||||||
HCI_Encryption_Change_Event,
|
HCI_Encryption_Change_Event,
|
||||||
|
HCI_Synchronous_Connection_Complete_Event,
|
||||||
HCI_LE_Advertising_Report_Event,
|
HCI_LE_Advertising_Report_Event,
|
||||||
|
HCI_LE_CIS_Established_Event,
|
||||||
|
HCI_LE_CIS_Request_Event,
|
||||||
HCI_LE_Connection_Complete_Event,
|
HCI_LE_Connection_Complete_Event,
|
||||||
HCI_LE_Read_Remote_Features_Complete_Event,
|
HCI_LE_Read_Remote_Features_Complete_Event,
|
||||||
HCI_Number_Of_Completed_Packets_Event,
|
HCI_Number_Of_Completed_Packets_Event,
|
||||||
HCI_Packet,
|
HCI_Packet,
|
||||||
HCI_Role_Change_Event,
|
HCI_Role_Change_Event,
|
||||||
)
|
)
|
||||||
from typing import Optional, Union, Dict, TYPE_CHECKING
|
from typing import Optional, Union, Dict, Any, TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bumble.transport.common import TransportSink, TransportSource
|
from bumble.link import LocalLink
|
||||||
|
from bumble.transport.common import TransportSink
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -79,15 +85,27 @@ class DataObject:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class CisLink:
|
||||||
|
handle: int
|
||||||
|
cis_id: int
|
||||||
|
cig_id: int
|
||||||
|
acl_connection: Optional[Connection] = None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclasses.dataclass
|
||||||
class Connection:
|
class Connection:
|
||||||
def __init__(self, controller, handle, role, peer_address, link, transport):
|
controller: Controller
|
||||||
self.controller = controller
|
handle: int
|
||||||
self.handle = handle
|
role: int
|
||||||
self.role = role
|
peer_address: Address
|
||||||
self.peer_address = peer_address
|
link: Any
|
||||||
self.link = link
|
transport: int
|
||||||
|
link_type: int
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||||
self.transport = transport
|
|
||||||
|
|
||||||
def on_hci_acl_data_packet(self, packet):
|
def on_hci_acl_data_packet(self, packet):
|
||||||
self.assembler.feed_packet(packet)
|
self.assembler.feed_packet(packet)
|
||||||
@@ -106,10 +124,10 @@ class Connection:
|
|||||||
class Controller:
|
class Controller:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name,
|
name: str,
|
||||||
host_source=None,
|
host_source=None,
|
||||||
host_sink: Optional[TransportSink] = None,
|
host_sink: Optional[TransportSink] = None,
|
||||||
link=None,
|
link: Optional[LocalLink] = None,
|
||||||
public_address: Optional[Union[bytes, str, Address]] = None,
|
public_address: Optional[Union[bytes, str, Address]] = None,
|
||||||
):
|
):
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -125,6 +143,8 @@ class Controller:
|
|||||||
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.peripheral_cis_links: Dict[int, CisLink] = {} # CIS links by handle
|
||||||
|
|
||||||
self.hci_version = HCI_VERSION_BLUETOOTH_CORE_5_0
|
self.hci_version = HCI_VERSION_BLUETOOTH_CORE_5_0
|
||||||
self.hci_revision = 0
|
self.hci_revision = 0
|
||||||
@@ -134,12 +154,14 @@ class Controller:
|
|||||||
'0000000060000000'
|
'0000000060000000'
|
||||||
) # BR/EDR Not Supported, LE Supported (Controller)
|
) # BR/EDR Not Supported, LE Supported (Controller)
|
||||||
self.manufacturer_name = 0xFFFF
|
self.manufacturer_name = 0xFFFF
|
||||||
|
self.hc_data_packet_length = 27
|
||||||
|
self.hc_total_num_data_packets = 64
|
||||||
self.hc_le_data_packet_length = 27
|
self.hc_le_data_packet_length = 27
|
||||||
self.hc_total_num_le_data_packets = 64
|
self.hc_total_num_le_data_packets = 64
|
||||||
self.event_mask = 0
|
self.event_mask = 0
|
||||||
self.event_mask_page_2 = 0
|
self.event_mask_page_2 = 0
|
||||||
self.supported_commands = bytes.fromhex(
|
self.supported_commands = bytes.fromhex(
|
||||||
'2000800000c000000000e40000002822000000000000040000f7ffff7f000000'
|
'2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
|
||||||
'30f0f9ff01008004000000000000000000000000000000000000000000000000'
|
'30f0f9ff01008004000000000000000000000000000000000000000000000000'
|
||||||
)
|
)
|
||||||
self.le_event_mask = 0
|
self.le_event_mask = 0
|
||||||
@@ -301,7 +323,7 @@ class Controller:
|
|||||||
############################################################
|
############################################################
|
||||||
# Link connections
|
# Link connections
|
||||||
############################################################
|
############################################################
|
||||||
def allocate_connection_handle(self):
|
def allocate_connection_handle(self) -> int:
|
||||||
handle = 0
|
handle = 0
|
||||||
max_handle = 0
|
max_handle = 0
|
||||||
for connection in itertools.chain(
|
for connection in itertools.chain(
|
||||||
@@ -313,6 +335,13 @@ class Controller:
|
|||||||
if connection.handle == handle:
|
if connection.handle == handle:
|
||||||
# Already used, continue searching after the current max
|
# Already used, continue searching after the current max
|
||||||
handle = max_handle + 1
|
handle = max_handle + 1
|
||||||
|
for cis_handle in itertools.chain(
|
||||||
|
self.central_cis_links.keys(), self.peripheral_cis_links.keys()
|
||||||
|
):
|
||||||
|
max_handle = max(max_handle, cis_handle)
|
||||||
|
if cis_handle == handle:
|
||||||
|
# Already used, continue searching after the current max
|
||||||
|
handle = max_handle + 1
|
||||||
return handle
|
return handle
|
||||||
|
|
||||||
def find_le_connection_by_address(self, address):
|
def find_le_connection_by_address(self, address):
|
||||||
@@ -357,12 +386,13 @@ class Controller:
|
|||||||
if connection is None:
|
if connection is None:
|
||||||
connection_handle = self.allocate_connection_handle()
|
connection_handle = self.allocate_connection_handle()
|
||||||
connection = Connection(
|
connection = Connection(
|
||||||
self,
|
controller=self,
|
||||||
connection_handle,
|
handle=connection_handle,
|
||||||
BT_PERIPHERAL_ROLE,
|
role=BT_PERIPHERAL_ROLE,
|
||||||
peer_address,
|
peer_address=peer_address,
|
||||||
self.link,
|
link=self.link,
|
||||||
BT_LE_TRANSPORT,
|
transport=BT_LE_TRANSPORT,
|
||||||
|
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
|
||||||
)
|
)
|
||||||
self.peripheral_connections[peer_address] = connection
|
self.peripheral_connections[peer_address] = connection
|
||||||
logger.debug(f'New PERIPHERAL connection handle: 0x{connection_handle:04X}')
|
logger.debug(f'New PERIPHERAL connection handle: 0x{connection_handle:04X}')
|
||||||
@@ -416,12 +446,13 @@ class Controller:
|
|||||||
if connection is None:
|
if connection is None:
|
||||||
connection_handle = self.allocate_connection_handle()
|
connection_handle = self.allocate_connection_handle()
|
||||||
connection = Connection(
|
connection = Connection(
|
||||||
self,
|
controller=self,
|
||||||
connection_handle,
|
handle=connection_handle,
|
||||||
BT_CENTRAL_ROLE,
|
role=BT_CENTRAL_ROLE,
|
||||||
peer_address,
|
peer_address=peer_address,
|
||||||
self.link,
|
link=self.link,
|
||||||
BT_LE_TRANSPORT,
|
transport=BT_LE_TRANSPORT,
|
||||||
|
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
|
||||||
)
|
)
|
||||||
self.central_connections[peer_address] = connection
|
self.central_connections[peer_address] = connection
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -538,6 +569,104 @@ class Controller:
|
|||||||
)
|
)
|
||||||
self.send_hci_packet(HCI_LE_Advertising_Report_Event([report]))
|
self.send_hci_packet(HCI_LE_Advertising_Report_Event([report]))
|
||||||
|
|
||||||
|
def on_link_cis_request(
|
||||||
|
self, central_address: Address, cig_id: int, cis_id: int
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Called when an incoming CIS request occurs from a central on the link
|
||||||
|
'''
|
||||||
|
|
||||||
|
connection = self.peripheral_connections.get(central_address)
|
||||||
|
assert connection
|
||||||
|
|
||||||
|
pending_cis_link = CisLink(
|
||||||
|
handle=self.allocate_connection_handle(),
|
||||||
|
cis_id=cis_id,
|
||||||
|
cig_id=cig_id,
|
||||||
|
acl_connection=connection,
|
||||||
|
)
|
||||||
|
self.peripheral_cis_links[pending_cis_link.handle] = pending_cis_link
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_LE_CIS_Request_Event(
|
||||||
|
acl_connection_handle=connection.handle,
|
||||||
|
cis_connection_handle=pending_cis_link.handle,
|
||||||
|
cig_id=cig_id,
|
||||||
|
cis_id=cis_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_link_cis_established(self, cig_id: int, cis_id: int) -> None:
|
||||||
|
'''
|
||||||
|
Called when an incoming CIS established.
|
||||||
|
'''
|
||||||
|
|
||||||
|
cis_link = next(
|
||||||
|
cis_link
|
||||||
|
for cis_link in itertools.chain(
|
||||||
|
self.central_cis_links.values(), self.peripheral_cis_links.values()
|
||||||
|
)
|
||||||
|
if cis_link.cis_id == cis_id and cis_link.cig_id == cig_id
|
||||||
|
)
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_LE_CIS_Established_Event(
|
||||||
|
status=HCI_SUCCESS,
|
||||||
|
connection_handle=cis_link.handle,
|
||||||
|
# CIS parameters are ignored.
|
||||||
|
cig_sync_delay=0,
|
||||||
|
cis_sync_delay=0,
|
||||||
|
transport_latency_c_to_p=0,
|
||||||
|
transport_latency_p_to_c=0,
|
||||||
|
phy_c_to_p=0,
|
||||||
|
phy_p_to_c=0,
|
||||||
|
nse=0,
|
||||||
|
bn_c_to_p=0,
|
||||||
|
bn_p_to_c=0,
|
||||||
|
ft_c_to_p=0,
|
||||||
|
ft_p_to_c=0,
|
||||||
|
max_pdu_c_to_p=0,
|
||||||
|
max_pdu_p_to_c=0,
|
||||||
|
iso_interval=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_link_cis_disconnected(self, cig_id: int, cis_id: int) -> None:
|
||||||
|
'''
|
||||||
|
Called when a CIS disconnected.
|
||||||
|
'''
|
||||||
|
|
||||||
|
if cis_link := next(
|
||||||
|
(
|
||||||
|
cis_link
|
||||||
|
for cis_link in self.peripheral_cis_links.values()
|
||||||
|
if cis_link.cis_id == cis_id and cis_link.cig_id == cig_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
):
|
||||||
|
# Remove peripheral CIS on disconnection.
|
||||||
|
self.peripheral_cis_links.pop(cis_link.handle)
|
||||||
|
elif cis_link := next(
|
||||||
|
(
|
||||||
|
cis_link
|
||||||
|
for cis_link in self.central_cis_links.values()
|
||||||
|
if cis_link.cis_id == cis_id and cis_link.cig_id == cig_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
):
|
||||||
|
# Keep central CIS on disconnection. They should be removed by HCI_LE_Remove_CIG_Command.
|
||||||
|
cis_link.acl_connection = None
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Disconnection_Complete_Event(
|
||||||
|
status=HCI_SUCCESS,
|
||||||
|
connection_handle=cis_link.handle,
|
||||||
|
reason=HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# Classic link connections
|
# Classic link connections
|
||||||
############################################################
|
############################################################
|
||||||
@@ -566,6 +695,7 @@ class Controller:
|
|||||||
peer_address=peer_address,
|
peer_address=peer_address,
|
||||||
link=self.link,
|
link=self.link,
|
||||||
transport=BT_BR_EDR_TRANSPORT,
|
transport=BT_BR_EDR_TRANSPORT,
|
||||||
|
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
|
||||||
)
|
)
|
||||||
self.classic_connections[peer_address] = connection
|
self.classic_connections[peer_address] = connection
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -619,6 +749,42 @@ class Controller:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_classic_sco_connection_complete(
|
||||||
|
self, peer_address: Address, status: int, link_type: int
|
||||||
|
):
|
||||||
|
if status == HCI_SUCCESS:
|
||||||
|
# Allocate (or reuse) a connection handle
|
||||||
|
connection_handle = self.allocate_connection_handle()
|
||||||
|
connection = Connection(
|
||||||
|
controller=self,
|
||||||
|
handle=connection_handle,
|
||||||
|
# Role doesn't matter in SCO.
|
||||||
|
role=BT_CENTRAL_ROLE,
|
||||||
|
peer_address=peer_address,
|
||||||
|
link=self.link,
|
||||||
|
transport=BT_BR_EDR_TRANSPORT,
|
||||||
|
link_type=link_type,
|
||||||
|
)
|
||||||
|
self.classic_connections[peer_address] = connection
|
||||||
|
logger.debug(f'New SCO connection handle: 0x{connection_handle:04X}')
|
||||||
|
else:
|
||||||
|
connection_handle = 0
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Synchronous_Connection_Complete_Event(
|
||||||
|
status=status,
|
||||||
|
connection_handle=connection_handle,
|
||||||
|
bd_addr=peer_address,
|
||||||
|
link_type=link_type,
|
||||||
|
# TODO: Provide SCO connection parameters.
|
||||||
|
transmission_interval=0,
|
||||||
|
retransmission_window=0,
|
||||||
|
rx_packet_length=0,
|
||||||
|
tx_packet_length=0,
|
||||||
|
air_mode=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# Advertising support
|
# Advertising support
|
||||||
############################################################
|
############################################################
|
||||||
@@ -721,6 +887,17 @@ class Controller:
|
|||||||
else:
|
else:
|
||||||
# Remove the connection
|
# Remove the connection
|
||||||
del self.classic_connections[connection.peer_address]
|
del self.classic_connections[connection.peer_address]
|
||||||
|
elif cis_link := (
|
||||||
|
self.central_cis_links.get(handle) or self.peripheral_cis_links.get(handle)
|
||||||
|
):
|
||||||
|
if self.link:
|
||||||
|
self.link.disconnect_cis(
|
||||||
|
initiator_controller=self,
|
||||||
|
peer_address=cis_link.acl_connection.peer_address,
|
||||||
|
cig_id=cis_link.cig_id,
|
||||||
|
cis_id=cis_link.cis_id,
|
||||||
|
)
|
||||||
|
# Spec requires handle to be kept after disconnection.
|
||||||
|
|
||||||
def on_hci_accept_connection_request_command(self, command):
|
def on_hci_accept_connection_request_command(self, command):
|
||||||
'''
|
'''
|
||||||
@@ -738,6 +915,68 @@ class Controller:
|
|||||||
)
|
)
|
||||||
self.link.classic_accept_connection(self, command.bd_addr, command.role)
|
self.link.classic_accept_connection(self, command.bd_addr, command.role)
|
||||||
|
|
||||||
|
def on_hci_enhanced_setup_synchronous_connection_command(self, command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.1.45 Enhanced Setup Synchronous Connection command
|
||||||
|
'''
|
||||||
|
|
||||||
|
if self.link is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not (
|
||||||
|
connection := self.find_classic_connection_by_handle(
|
||||||
|
command.connection_handle
|
||||||
|
)
|
||||||
|
):
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
||||||
|
num_hci_command_packets=1,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_SUCCESS,
|
||||||
|
num_hci_command_packets=1,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.link.classic_sco_connect(
|
||||||
|
self, connection.peer_address, HCI_Connection_Complete_Event.ESCO_LINK_TYPE
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_hci_enhanced_accept_synchronous_connection_request_command(self, command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.1.46 Enhanced Accept Synchronous Connection Request command
|
||||||
|
'''
|
||||||
|
|
||||||
|
if self.link is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not (connection := self.find_classic_connection_by_address(command.bd_addr)):
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
||||||
|
num_hci_command_packets=1,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_SUCCESS,
|
||||||
|
num_hci_command_packets=1,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.link.classic_accept_sco_connection(
|
||||||
|
self, connection.peer_address, HCI_Connection_Complete_Event.ESCO_LINK_TYPE
|
||||||
|
)
|
||||||
|
|
||||||
def on_hci_switch_role_command(self, command):
|
def on_hci_switch_role_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 4, Part E - 7.2.8 Switch Role command
|
See Bluetooth spec Vol 4, Part E - 7.2.8 Switch Role command
|
||||||
@@ -914,6 +1153,19 @@ class Controller:
|
|||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS]) + self.lmp_features
|
return bytes([HCI_SUCCESS]) + self.lmp_features
|
||||||
|
|
||||||
|
def on_hci_read_buffer_size_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.4.5 Read Buffer Size Command
|
||||||
|
'''
|
||||||
|
return struct.pack(
|
||||||
|
'<BHBHH',
|
||||||
|
HCI_SUCCESS,
|
||||||
|
self.hc_data_packet_length,
|
||||||
|
0,
|
||||||
|
self.hc_total_num_data_packets,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
def on_hci_read_bd_addr_command(self, _command):
|
def on_hci_read_bd_addr_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 4, Part E - 7.4.6 Read BD_ADDR Command
|
See Bluetooth spec Vol 4, Part E - 7.4.6 Read BD_ADDR Command
|
||||||
@@ -1089,6 +1341,18 @@ class Controller:
|
|||||||
See Bluetooth spec Vol 4, Part E - 7.8.21 LE Read Remote Features Command
|
See Bluetooth spec Vol 4, Part E - 7.8.21 LE Read Remote Features Command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
handle = command.connection_handle
|
||||||
|
|
||||||
|
if not self.find_connection_by_handle(handle):
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR,
|
||||||
|
num_hci_command_packets=1,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# First, say that the command is pending
|
# First, say that the command is pending
|
||||||
self.send_hci_packet(
|
self.send_hci_packet(
|
||||||
HCI_Command_Status_Event(
|
HCI_Command_Status_Event(
|
||||||
@@ -1102,7 +1366,7 @@ class Controller:
|
|||||||
self.send_hci_packet(
|
self.send_hci_packet(
|
||||||
HCI_LE_Read_Remote_Features_Complete_Event(
|
HCI_LE_Read_Remote_Features_Complete_Event(
|
||||||
status=HCI_SUCCESS,
|
status=HCI_SUCCESS,
|
||||||
connection_handle=0,
|
connection_handle=handle,
|
||||||
le_features=bytes.fromhex('dd40000000000000'),
|
le_features=bytes.fromhex('dd40000000000000'),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1264,6 +1528,107 @@ class Controller:
|
|||||||
'''
|
'''
|
||||||
return struct.pack('<BBB', HCI_SUCCESS, 0, 0)
|
return struct.pack('<BBB', HCI_SUCCESS, 0, 0)
|
||||||
|
|
||||||
|
def on_hci_le_set_cig_parameters_command(self, command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.97 LE Set CIG Parameter Command
|
||||||
|
'''
|
||||||
|
|
||||||
|
# Remove old CIG implicitly.
|
||||||
|
for handle, cis_link in self.central_cis_links.items():
|
||||||
|
if cis_link.cig_id == command.cig_id:
|
||||||
|
self.central_cis_links.pop(handle)
|
||||||
|
|
||||||
|
handles = []
|
||||||
|
for cis_id in command.cis_id:
|
||||||
|
handle = self.allocate_connection_handle()
|
||||||
|
handles.append(handle)
|
||||||
|
self.central_cis_links[handle] = CisLink(
|
||||||
|
cis_id=cis_id,
|
||||||
|
cig_id=command.cig_id,
|
||||||
|
handle=handle,
|
||||||
|
)
|
||||||
|
return struct.pack(
|
||||||
|
'<BBB', HCI_SUCCESS, command.cig_id, len(handles)
|
||||||
|
) + b''.join([struct.pack('<H', handle) for handle in handles])
|
||||||
|
|
||||||
|
def on_hci_le_create_cis_command(self, command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.99 LE Create CIS Command
|
||||||
|
'''
|
||||||
|
if not self.link:
|
||||||
|
return
|
||||||
|
|
||||||
|
for cis_handle, acl_handle in zip(
|
||||||
|
command.cis_connection_handle, command.acl_connection_handle
|
||||||
|
):
|
||||||
|
if not (connection := self.find_connection_by_handle(acl_handle)):
|
||||||
|
logger.error(f'Cannot find connection with handle={acl_handle}')
|
||||||
|
return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
|
||||||
|
|
||||||
|
if not (cis_link := self.central_cis_links.get(cis_handle)):
|
||||||
|
logger.error(f'Cannot find CIS with handle={cis_handle}')
|
||||||
|
return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
|
||||||
|
|
||||||
|
cis_link.acl_connection = connection
|
||||||
|
|
||||||
|
self.link.create_cis(
|
||||||
|
self,
|
||||||
|
peripheral_address=connection.peer_address,
|
||||||
|
cig_id=cis_link.cig_id,
|
||||||
|
cis_id=cis_link.cis_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_COMMAND_STATUS_PENDING,
|
||||||
|
num_hci_command_packets=1,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_hci_le_remove_cig_command(self, command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.100 LE Remove CIG Command
|
||||||
|
'''
|
||||||
|
|
||||||
|
status = HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR
|
||||||
|
|
||||||
|
for cis_handle, cis_link in self.central_cis_links.items():
|
||||||
|
if cis_link.cig_id == command.cig_id:
|
||||||
|
self.central_cis_links.pop(cis_handle)
|
||||||
|
status = HCI_SUCCESS
|
||||||
|
|
||||||
|
return struct.pack('<BH', status, command.cig_id)
|
||||||
|
|
||||||
|
def on_hci_le_accept_cis_request_command(self, command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.101 LE Accept CIS Request Command
|
||||||
|
'''
|
||||||
|
if not self.link:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not (
|
||||||
|
pending_cis_link := self.peripheral_cis_links.get(command.connection_handle)
|
||||||
|
):
|
||||||
|
logger.error(f'Cannot find CIS with handle={command.connection_handle}')
|
||||||
|
return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
|
||||||
|
|
||||||
|
assert pending_cis_link.acl_connection
|
||||||
|
self.link.accept_cis(
|
||||||
|
peripheral_controller=self,
|
||||||
|
central_address=pending_cis_link.acl_connection.peer_address,
|
||||||
|
cig_id=pending_cis_link.cig_id,
|
||||||
|
cis_id=pending_cis_link.cis_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_COMMAND_STATUS_PENDING,
|
||||||
|
num_hci_command_packets=1,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def on_hci_le_setup_iso_data_path_command(self, command):
|
def on_hci_le_setup_iso_data_path_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 4, Part E - 7.8.109 LE Setup ISO Data Path Command
|
See Bluetooth spec Vol 4, Part E - 7.8.109 LE Setup ISO Data Path Command
|
||||||
|
|||||||
+14
-10
@@ -97,12 +97,16 @@ class BaseError(Exception):
|
|||||||
namespace = f'{self.error_namespace}/'
|
namespace = f'{self.error_namespace}/'
|
||||||
else:
|
else:
|
||||||
namespace = ''
|
namespace = ''
|
||||||
error_text = {
|
have_name = self.error_name != ''
|
||||||
(True, True): f'{self.error_name} [0x{self.error_code:X}]',
|
have_code = self.error_code is not None
|
||||||
(True, False): self.error_name,
|
if have_name and have_code:
|
||||||
(False, True): f'0x{self.error_code:X}',
|
error_text = f'{self.error_name} [0x{self.error_code:X}]'
|
||||||
(False, False): '',
|
elif have_name and not have_code:
|
||||||
}[(self.error_name != '', self.error_code is not None)]
|
error_text = self.error_name
|
||||||
|
elif not have_name and have_code:
|
||||||
|
error_text = f'0x{self.error_code:X}'
|
||||||
|
else:
|
||||||
|
error_text = '<unspecified>'
|
||||||
|
|
||||||
return f'{type(self).__name__}({namespace}{error_text})'
|
return f'{type(self).__name__}({namespace}{error_text})'
|
||||||
|
|
||||||
@@ -319,7 +323,7 @@ BT_HIDP_PROTOCOL_ID = UUID.from_16_bits(0x0011, 'HIDP')
|
|||||||
BT_HARDCOPY_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0012, 'HardcopyControlChannel')
|
BT_HARDCOPY_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0012, 'HardcopyControlChannel')
|
||||||
BT_HARDCOPY_DATA_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0014, 'HardcopyDataChannel')
|
BT_HARDCOPY_DATA_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0014, 'HardcopyDataChannel')
|
||||||
BT_HARDCOPY_NOTIFICATION_PROTOCOL_ID = UUID.from_16_bits(0x0016, 'HardcopyNotification')
|
BT_HARDCOPY_NOTIFICATION_PROTOCOL_ID = UUID.from_16_bits(0x0016, 'HardcopyNotification')
|
||||||
BT_AVTCP_PROTOCOL_ID = UUID.from_16_bits(0x0017, 'AVCTP')
|
BT_AVCTP_PROTOCOL_ID = UUID.from_16_bits(0x0017, 'AVCTP')
|
||||||
BT_AVDTP_PROTOCOL_ID = UUID.from_16_bits(0x0019, 'AVDTP')
|
BT_AVDTP_PROTOCOL_ID = UUID.from_16_bits(0x0019, 'AVDTP')
|
||||||
BT_CMTP_PROTOCOL_ID = UUID.from_16_bits(0x001B, 'CMTP')
|
BT_CMTP_PROTOCOL_ID = UUID.from_16_bits(0x001B, 'CMTP')
|
||||||
BT_MCAP_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x001E, 'MCAPControlChannel')
|
BT_MCAP_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x001E, 'MCAPControlChannel')
|
||||||
@@ -821,8 +825,8 @@ class AdvertisingData:
|
|||||||
ad_structures = []
|
ad_structures = []
|
||||||
self.ad_structures = ad_structures[:]
|
self.ad_structures = ad_structures[:]
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def from_bytes(data):
|
def from_bytes(cls, data: bytes) -> AdvertisingData:
|
||||||
instance = AdvertisingData()
|
instance = AdvertisingData()
|
||||||
instance.append(data)
|
instance.append(data)
|
||||||
return instance
|
return instance
|
||||||
@@ -978,7 +982,7 @@ class AdvertisingData:
|
|||||||
|
|
||||||
return ad_data
|
return ad_data
|
||||||
|
|
||||||
def append(self, data):
|
def append(self, data: bytes) -> None:
|
||||||
offset = 0
|
offset = 0
|
||||||
while offset + 1 < len(data):
|
while offset + 1 < len(data):
|
||||||
length = data[offset]
|
length = data[offset]
|
||||||
|
|||||||
+99
-65
@@ -34,7 +34,6 @@ from typing import (
|
|||||||
Tuple,
|
Tuple,
|
||||||
Type,
|
Type,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
Set,
|
|
||||||
Union,
|
Union,
|
||||||
cast,
|
cast,
|
||||||
overload,
|
overload,
|
||||||
@@ -49,7 +48,6 @@ from .hci import (
|
|||||||
HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
|
HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
|
||||||
HCI_CENTRAL_ROLE,
|
HCI_CENTRAL_ROLE,
|
||||||
HCI_COMMAND_STATUS_PENDING,
|
HCI_COMMAND_STATUS_PENDING,
|
||||||
HCI_CONNECTED_ISOCHRONOUS_STREAM_LE_SUPPORTED_FEATURE,
|
|
||||||
HCI_CONNECTION_REJECTED_DUE_TO_LIMITED_RESOURCES_ERROR,
|
HCI_CONNECTION_REJECTED_DUE_TO_LIMITED_RESOURCES_ERROR,
|
||||||
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
|
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
|
||||||
HCI_DISPLAY_ONLY_IO_CAPABILITY,
|
HCI_DISPLAY_ONLY_IO_CAPABILITY,
|
||||||
@@ -60,12 +58,8 @@ from .hci import (
|
|||||||
HCI_LE_1M_PHY,
|
HCI_LE_1M_PHY,
|
||||||
HCI_LE_1M_PHY_BIT,
|
HCI_LE_1M_PHY_BIT,
|
||||||
HCI_LE_2M_PHY,
|
HCI_LE_2M_PHY,
|
||||||
HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE,
|
|
||||||
HCI_LE_CLEAR_RESOLVING_LIST_COMMAND,
|
|
||||||
HCI_LE_CODED_PHY,
|
HCI_LE_CODED_PHY,
|
||||||
HCI_LE_CODED_PHY_BIT,
|
HCI_LE_CODED_PHY_BIT,
|
||||||
HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE,
|
|
||||||
HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE,
|
|
||||||
HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND,
|
HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND,
|
||||||
HCI_LE_RAND_COMMAND,
|
HCI_LE_RAND_COMMAND,
|
||||||
HCI_LE_READ_PHY_COMMAND,
|
HCI_LE_READ_PHY_COMMAND,
|
||||||
@@ -86,7 +80,7 @@ from .hci import (
|
|||||||
HCI_Constant,
|
HCI_Constant,
|
||||||
HCI_Create_Connection_Cancel_Command,
|
HCI_Create_Connection_Cancel_Command,
|
||||||
HCI_Create_Connection_Command,
|
HCI_Create_Connection_Command,
|
||||||
HCI_Create_Connection_Command,
|
HCI_Connection_Complete_Event,
|
||||||
HCI_Disconnect_Command,
|
HCI_Disconnect_Command,
|
||||||
HCI_Encryption_Change_Event,
|
HCI_Encryption_Change_Event,
|
||||||
HCI_Error,
|
HCI_Error,
|
||||||
@@ -107,6 +101,7 @@ from .hci import (
|
|||||||
HCI_LE_Extended_Create_Connection_Command,
|
HCI_LE_Extended_Create_Connection_Command,
|
||||||
HCI_LE_Rand_Command,
|
HCI_LE_Rand_Command,
|
||||||
HCI_LE_Read_PHY_Command,
|
HCI_LE_Read_PHY_Command,
|
||||||
|
HCI_LE_Read_Remote_Features_Command,
|
||||||
HCI_LE_Reject_CIS_Request_Command,
|
HCI_LE_Reject_CIS_Request_Command,
|
||||||
HCI_LE_Remove_Advertising_Set_Command,
|
HCI_LE_Remove_Advertising_Set_Command,
|
||||||
HCI_LE_Set_Address_Resolution_Enable_Command,
|
HCI_LE_Set_Address_Resolution_Enable_Command,
|
||||||
@@ -152,6 +147,8 @@ from .hci import (
|
|||||||
HCI_Write_Secure_Connections_Host_Support_Command,
|
HCI_Write_Secure_Connections_Host_Support_Command,
|
||||||
HCI_Write_Simple_Pairing_Mode_Command,
|
HCI_Write_Simple_Pairing_Mode_Command,
|
||||||
OwnAddressType,
|
OwnAddressType,
|
||||||
|
LeFeature,
|
||||||
|
LeFeatureMask,
|
||||||
phy_list_to_bits,
|
phy_list_to_bits,
|
||||||
)
|
)
|
||||||
from .host import Host
|
from .host import Host
|
||||||
@@ -245,16 +242,40 @@ DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS = l2cap.L2CAP_LE_CREDIT_BASED_CONN
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
class Advertisement:
|
class Advertisement:
|
||||||
|
# Attributes
|
||||||
address: Address
|
address: Address
|
||||||
|
rssi: int = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE
|
||||||
TX_POWER_NOT_AVAILABLE = (
|
is_legacy: bool = False
|
||||||
|
is_anonymous: bool = False
|
||||||
|
is_connectable: bool = False
|
||||||
|
is_directed: bool = False
|
||||||
|
is_scannable: bool = False
|
||||||
|
is_scan_response: bool = False
|
||||||
|
is_complete: bool = True
|
||||||
|
is_truncated: bool = False
|
||||||
|
primary_phy: int = 0
|
||||||
|
secondary_phy: int = 0
|
||||||
|
tx_power: 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 = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE
|
sid: int = 0
|
||||||
|
data_bytes: bytes = b''
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
TX_POWER_NOT_AVAILABLE: ClassVar[
|
||||||
|
int
|
||||||
|
] = HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
|
||||||
|
RSSI_NOT_AVAILABLE: ClassVar[
|
||||||
|
int
|
||||||
|
] = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self.data = AdvertisingData.from_bytes(self.data_bytes)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_advertising_report(cls, report):
|
def from_advertising_report(cls, report) -> Optional[Advertisement]:
|
||||||
if isinstance(report, HCI_LE_Advertising_Report_Event.Report):
|
if isinstance(report, HCI_LE_Advertising_Report_Event.Report):
|
||||||
return LegacyAdvertisement.from_advertising_report(report)
|
return LegacyAdvertisement.from_advertising_report(report)
|
||||||
|
|
||||||
@@ -263,41 +284,6 @@ class Advertisement:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# pylint: disable=line-too-long
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
address,
|
|
||||||
rssi=HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE,
|
|
||||||
is_legacy=False,
|
|
||||||
is_anonymous=False,
|
|
||||||
is_connectable=False,
|
|
||||||
is_directed=False,
|
|
||||||
is_scannable=False,
|
|
||||||
is_scan_response=False,
|
|
||||||
is_complete=True,
|
|
||||||
is_truncated=False,
|
|
||||||
primary_phy=0,
|
|
||||||
secondary_phy=0,
|
|
||||||
tx_power=HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE,
|
|
||||||
sid=0,
|
|
||||||
data=b'',
|
|
||||||
):
|
|
||||||
self.address = address
|
|
||||||
self.rssi = rssi
|
|
||||||
self.is_legacy = is_legacy
|
|
||||||
self.is_anonymous = is_anonymous
|
|
||||||
self.is_connectable = is_connectable
|
|
||||||
self.is_directed = is_directed
|
|
||||||
self.is_scannable = is_scannable
|
|
||||||
self.is_scan_response = is_scan_response
|
|
||||||
self.is_complete = is_complete
|
|
||||||
self.is_truncated = is_truncated
|
|
||||||
self.primary_phy = primary_phy
|
|
||||||
self.secondary_phy = secondary_phy
|
|
||||||
self.tx_power = tx_power
|
|
||||||
self.sid = sid
|
|
||||||
self.data = AdvertisingData.from_bytes(data)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class LegacyAdvertisement(Advertisement):
|
class LegacyAdvertisement(Advertisement):
|
||||||
@@ -321,7 +307,7 @@ class LegacyAdvertisement(Advertisement):
|
|||||||
),
|
),
|
||||||
is_scan_response=report.event_type
|
is_scan_response=report.event_type
|
||||||
== HCI_LE_Advertising_Report_Event.SCAN_RSP,
|
== HCI_LE_Advertising_Report_Event.SCAN_RSP,
|
||||||
data=report.data,
|
data_bytes=report.data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -346,7 +332,7 @@ class ExtendedAdvertisement(Advertisement):
|
|||||||
secondary_phy = report.secondary_phy,
|
secondary_phy = report.secondary_phy,
|
||||||
tx_power = report.tx_power,
|
tx_power = report.tx_power,
|
||||||
sid = report.advertising_sid,
|
sid = report.advertising_sid,
|
||||||
data = report.data
|
data_bytes = report.data
|
||||||
)
|
)
|
||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
@@ -407,7 +393,7 @@ class AdvertisingType(IntEnum):
|
|||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_data(self):
|
def has_data(self) -> bool:
|
||||||
return self in (
|
return self in (
|
||||||
AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
|
AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
|
||||||
AdvertisingType.UNDIRECTED_SCANNABLE,
|
AdvertisingType.UNDIRECTED_SCANNABLE,
|
||||||
@@ -415,7 +401,7 @@ class AdvertisingType(IntEnum):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_connectable(self):
|
def is_connectable(self) -> bool:
|
||||||
return self in (
|
return self in (
|
||||||
AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
|
AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
|
||||||
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY,
|
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY,
|
||||||
@@ -423,14 +409,14 @@ class AdvertisingType(IntEnum):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_scannable(self):
|
def is_scannable(self) -> bool:
|
||||||
return self in (
|
return self in (
|
||||||
AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
|
AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
|
||||||
AdvertisingType.UNDIRECTED_SCANNABLE,
|
AdvertisingType.UNDIRECTED_SCANNABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_directed(self):
|
def is_directed(self) -> bool:
|
||||||
return self in (
|
return self in (
|
||||||
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY,
|
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY,
|
||||||
AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY,
|
AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY,
|
||||||
@@ -682,6 +668,7 @@ class Connection(CompositeEventEmitter):
|
|||||||
self_address: Address
|
self_address: Address
|
||||||
peer_address: Address
|
peer_address: Address
|
||||||
peer_resolvable_address: Optional[Address]
|
peer_resolvable_address: Optional[Address]
|
||||||
|
peer_le_features: Optional[LeFeatureMask]
|
||||||
role: int
|
role: int
|
||||||
encryption: int
|
encryption: int
|
||||||
authenticated: bool
|
authenticated: bool
|
||||||
@@ -758,6 +745,7 @@ class Connection(CompositeEventEmitter):
|
|||||||
) # By default, use the device's shared server
|
) # By default, use the device's shared server
|
||||||
self.pairing_peer_io_capability = None
|
self.pairing_peer_io_capability = None
|
||||||
self.pairing_peer_authentication_requirements = None
|
self.pairing_peer_authentication_requirements = None
|
||||||
|
self.peer_le_features = None
|
||||||
|
|
||||||
# [Classic only]
|
# [Classic only]
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -906,6 +894,15 @@ class Connection(CompositeEventEmitter):
|
|||||||
async def request_remote_name(self):
|
async def request_remote_name(self):
|
||||||
return await self.device.request_remote_name(self)
|
return await self.device.request_remote_name(self)
|
||||||
|
|
||||||
|
async def get_remote_le_features(self) -> LeFeatureMask:
|
||||||
|
"""[LE Only] Reads remote LE supported features.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LE features supported by the remote device.
|
||||||
|
"""
|
||||||
|
self.peer_le_features = await self.device.get_remote_le_features(self)
|
||||||
|
return self.peer_le_features
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -1538,9 +1535,7 @@ class Device(CompositeEventEmitter):
|
|||||||
if self.cis_enabled:
|
if self.cis_enabled:
|
||||||
await self.send_command(
|
await self.send_command(
|
||||||
HCI_LE_Set_Host_Feature_Command(
|
HCI_LE_Set_Host_Feature_Command(
|
||||||
bit_number=(
|
bit_number=LeFeature.CONNECTED_ISOCHRONOUS_STREAM,
|
||||||
HCI_CONNECTED_ISOCHRONOUS_STREAM_LE_SUPPORTED_FEATURE
|
|
||||||
),
|
|
||||||
bit_value=1,
|
bit_value=1,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1596,21 +1591,21 @@ class Device(CompositeEventEmitter):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def supports_le_feature(self, feature):
|
def supports_le_features(self, feature: LeFeatureMask) -> bool:
|
||||||
return self.host.supports_le_feature(feature)
|
return self.host.supports_le_features(feature)
|
||||||
|
|
||||||
def supports_le_phy(self, phy):
|
def supports_le_phy(self, phy):
|
||||||
if phy == HCI_LE_1M_PHY:
|
if phy == HCI_LE_1M_PHY:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
feature_map = {
|
feature_map = {
|
||||||
HCI_LE_2M_PHY: HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE,
|
HCI_LE_2M_PHY: LeFeatureMask.LE_2M_PHY,
|
||||||
HCI_LE_CODED_PHY: HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE,
|
HCI_LE_CODED_PHY: LeFeatureMask.LE_CODED_PHY,
|
||||||
}
|
}
|
||||||
if phy not in feature_map:
|
if phy not in feature_map:
|
||||||
raise ValueError('invalid PHY')
|
raise ValueError('invalid PHY')
|
||||||
|
|
||||||
return self.host.supports_le_feature(feature_map[phy])
|
return self.host.supports_le_features(feature_map[phy])
|
||||||
|
|
||||||
@deprecated("Please use start_legacy_advertising.")
|
@deprecated("Please use start_legacy_advertising.")
|
||||||
async def start_advertising(
|
async def start_advertising(
|
||||||
@@ -1920,8 +1915,8 @@ class Device(CompositeEventEmitter):
|
|||||||
self.advertisement_accumulators = {}
|
self.advertisement_accumulators = {}
|
||||||
|
|
||||||
# Enable scanning
|
# Enable scanning
|
||||||
if not legacy and self.supports_le_feature(
|
if not legacy and self.supports_le_features(
|
||||||
HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE
|
LeFeatureMask.LE_EXTENDED_ADVERTISING
|
||||||
):
|
):
|
||||||
# Set the scanning parameters
|
# Set the scanning parameters
|
||||||
scan_type = (
|
scan_type = (
|
||||||
@@ -1939,7 +1934,7 @@ class Device(CompositeEventEmitter):
|
|||||||
scanning_phys_bits |= 1 << HCI_LE_1M_PHY_BIT
|
scanning_phys_bits |= 1 << HCI_LE_1M_PHY_BIT
|
||||||
scanning_phy_count += 1
|
scanning_phy_count += 1
|
||||||
if HCI_LE_CODED_PHY in scanning_phys:
|
if HCI_LE_CODED_PHY in scanning_phys:
|
||||||
if self.supports_le_feature(HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE):
|
if self.supports_le_features(LeFeatureMask.LE_CODED_PHY):
|
||||||
scanning_phys_bits |= 1 << HCI_LE_CODED_PHY_BIT
|
scanning_phys_bits |= 1 << HCI_LE_CODED_PHY_BIT
|
||||||
scanning_phy_count += 1
|
scanning_phy_count += 1
|
||||||
|
|
||||||
@@ -2000,7 +1995,7 @@ class Device(CompositeEventEmitter):
|
|||||||
|
|
||||||
async def stop_scanning(self) -> None:
|
async def stop_scanning(self) -> None:
|
||||||
# Disable scanning
|
# Disable scanning
|
||||||
if self.supports_le_feature(HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE):
|
if self.supports_le_features(LeFeatureMask.LE_EXTENDED_ADVERTISING):
|
||||||
await self.send_command(
|
await self.send_command(
|
||||||
HCI_LE_Set_Extended_Scan_Enable_Command(
|
HCI_LE_Set_Extended_Scan_Enable_Command(
|
||||||
enable=0, filter_duplicates=0, duration=0, period=0
|
enable=0, filter_duplicates=0, duration=0, period=0
|
||||||
@@ -3142,6 +3137,32 @@ class Device(CompositeEventEmitter):
|
|||||||
)
|
)
|
||||||
raise HCI_StatusError(result)
|
raise HCI_StatusError(result)
|
||||||
|
|
||||||
|
async def get_remote_le_features(self, connection: Connection) -> LeFeatureMask:
|
||||||
|
"""[LE Only] Reads remote LE supported features.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
handle: connection handle to read LE features.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LE features supported by the remote device.
|
||||||
|
"""
|
||||||
|
with closing(EventWatcher()) as watcher:
|
||||||
|
read_feature_future: asyncio.Future[
|
||||||
|
LeFeatureMask
|
||||||
|
] = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
|
def on_le_remote_features(handle: int, features: int):
|
||||||
|
if handle == connection.handle:
|
||||||
|
read_feature_future.set_result(LeFeatureMask(features))
|
||||||
|
|
||||||
|
watcher.on(self.host, 'le_remote_features', on_le_remote_features)
|
||||||
|
await self.send_command(
|
||||||
|
HCI_LE_Read_Remote_Features_Command(
|
||||||
|
connection_handle=connection.handle
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return await read_feature_future
|
||||||
|
|
||||||
@host_event_handler
|
@host_event_handler
|
||||||
def on_flush(self):
|
def on_flush(self):
|
||||||
self.emit('flush')
|
self.emit('flush')
|
||||||
@@ -3319,8 +3340,21 @@ class Device(CompositeEventEmitter):
|
|||||||
def on_connection_request(self, bd_addr, class_of_device, link_type):
|
def on_connection_request(self, bd_addr, class_of_device, link_type):
|
||||||
logger.debug(f'*** Connection request: {bd_addr}')
|
logger.debug(f'*** Connection request: {bd_addr}')
|
||||||
|
|
||||||
|
# Handle SCO request.
|
||||||
|
if link_type in (
|
||||||
|
HCI_Connection_Complete_Event.SCO_LINK_TYPE,
|
||||||
|
HCI_Connection_Complete_Event.ESCO_LINK_TYPE,
|
||||||
|
):
|
||||||
|
if connection := self.find_connection_by_bd_addr(
|
||||||
|
bd_addr, transport=BT_BR_EDR_TRANSPORT
|
||||||
|
):
|
||||||
|
self.emit('sco_request', connection, link_type)
|
||||||
|
else:
|
||||||
|
logger.error(f'SCO request from a non-connected device {bd_addr}')
|
||||||
|
return
|
||||||
|
|
||||||
# match a pending future using `bd_addr`
|
# match a pending future using `bd_addr`
|
||||||
if bd_addr in self.classic_pending_accepts:
|
elif bd_addr in self.classic_pending_accepts:
|
||||||
future, *_ = self.classic_pending_accepts.pop(bd_addr)
|
future, *_ = self.classic_pending_accepts.pop(bd_addr)
|
||||||
future.set_result((bd_addr, class_of_device, link_type))
|
future.set_result((bd_addr, class_of_device, link_type))
|
||||||
|
|
||||||
|
|||||||
+129
-50
@@ -1360,55 +1360,97 @@ HCI_SUPPORTED_COMMANDS_FLAGS = (
|
|||||||
|
|
||||||
# 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
|
||||||
HCI_LE_ENCRYPTION_LE_SUPPORTED_FEATURE = 0
|
class LeFeature(enum.IntEnum):
|
||||||
HCI_CONNECTION_PARAMETERS_REQUEST_PROCEDURE_LE_SUPPORTED_FEATURE = 1
|
LE_ENCRYPTION = 0
|
||||||
HCI_EXTENDED_REJECT_INDICATION_LE_SUPPORTED_FEATURE = 2
|
CONNECTION_PARAMETERS_REQUEST_PROCEDURE = 1
|
||||||
HCI_PERIPHERAL_INITIATED_FEATURE_EXCHANGE_LE_SUPPORTED_FEATURE = 3
|
EXTENDED_REJECT_INDICATION = 2
|
||||||
HCI_LE_PING_LE_SUPPORTED_FEATURE = 4
|
PERIPHERAL_INITIATED_FEATURE_EXCHANGE = 3
|
||||||
HCI_LE_DATA_PACKET_LENGTH_EXTENSION_LE_SUPPORTED_FEATURE = 5
|
LE_PING = 4
|
||||||
HCI_LL_PRIVACY_LE_SUPPORTED_FEATURE = 6
|
LE_DATA_PACKET_LENGTH_EXTENSION = 5
|
||||||
HCI_EXTENDED_SCANNER_FILTER_POLICIES_LE_SUPPORTED_FEATURE = 7
|
LL_PRIVACY = 6
|
||||||
HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE = 8
|
EXTENDED_SCANNER_FILTER_POLICIES = 7
|
||||||
HCI_STABLE_MODULATION_INDEX_TRANSMITTER_LE_SUPPORTED_FEATURE = 9
|
LE_2M_PHY = 8
|
||||||
HCI_STABLE_MODULATION_INDEX_RECEIVER_LE_SUPPORTED_FEATURE = 10
|
STABLE_MODULATION_INDEX_TRANSMITTER = 9
|
||||||
HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE = 11
|
STABLE_MODULATION_INDEX_RECEIVER = 10
|
||||||
HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE = 12
|
LE_CODED_PHY = 11
|
||||||
HCI_LE_PERIODIC_ADVERTISING_LE_SUPPORTED_FEATURE = 13
|
LE_EXTENDED_ADVERTISING = 12
|
||||||
HCI_CHANNEL_SELECTION_ALGORITHM_2_LE_SUPPORTED_FEATURE = 14
|
LE_PERIODIC_ADVERTISING = 13
|
||||||
HCI_LE_POWER_CLASS_1_LE_SUPPORTED_FEATURE = 15
|
CHANNEL_SELECTION_ALGORITHM_2 = 14
|
||||||
HCI_MINIMUM_NUMBER_OF_USED_CHANNELS_PROCEDURE_LE_SUPPORTED_FEATURE = 16
|
LE_POWER_CLASS_1 = 15
|
||||||
HCI_CONNECTION_CTE_REQUEST_LE_SUPPORTED_FEATURE = 17
|
MINIMUM_NUMBER_OF_USED_CHANNELS_PROCEDURE = 16
|
||||||
HCI_CONNECTION_CTE_RESPONSE_LE_SUPPORTED_FEATURE = 18
|
CONNECTION_CTE_REQUEST = 17
|
||||||
HCI_CONNECTIONLESS_CTE_TRANSMITTER_LE_SUPPORTED_FEATURE = 19
|
CONNECTION_CTE_RESPONSE = 18
|
||||||
HCI_CONNECTIONLESS_CTR_RECEIVER_LE_SUPPORTED_FEATURE = 20
|
CONNECTIONLESS_CTE_TRANSMITTER = 19
|
||||||
HCI_ANTENNA_SWITCHING_DURING_CTE_TRANSMISSION_LE_SUPPORTED_FEATURE = 21
|
CONNECTIONLESS_CTR_RECEIVER = 20
|
||||||
HCI_ANTENNA_SWITCHING_DURING_CTE_RECEPTION_LE_SUPPORTED_FEATURE = 22
|
ANTENNA_SWITCHING_DURING_CTE_TRANSMISSION = 21
|
||||||
HCI_RECEIVING_CONSTANT_TONE_EXTENSIONS_LE_SUPPORTED_FEATURE = 23
|
ANTENNA_SWITCHING_DURING_CTE_RECEPTION = 22
|
||||||
HCI_PERIODIC_ADVERTISING_SYNC_TRANSFER_SENDER_LE_SUPPORTED_FEATURE = 24
|
RECEIVING_CONSTANT_TONE_EXTENSIONS = 23
|
||||||
HCI_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECIPIENT_LE_SUPPORTED_FEATURE = 25
|
PERIODIC_ADVERTISING_SYNC_TRANSFER_SENDER = 24
|
||||||
HCI_SLEEP_CLOCK_ACCURACY_UPDATES_LE_SUPPORTED_FEATURE = 26
|
PERIODIC_ADVERTISING_SYNC_TRANSFER_RECIPIENT = 25
|
||||||
HCI_REMOTE_PUBLIC_KEY_VALIDATION_LE_SUPPORTED_FEATURE = 27
|
SLEEP_CLOCK_ACCURACY_UPDATES = 26
|
||||||
HCI_CONNECTED_ISOCHRONOUS_STREAM_CENTRAL_LE_SUPPORTED_FEATURE = 28
|
REMOTE_PUBLIC_KEY_VALIDATION = 27
|
||||||
HCI_CONNECTED_ISOCHRONOUS_STREAM_PERIPHERAL_LE_SUPPORTED_FEATURE = 29
|
CONNECTED_ISOCHRONOUS_STREAM_CENTRAL = 28
|
||||||
HCI_ISOCHRONOUS_BROADCASTER_LE_SUPPORTED_FEATURE = 30
|
CONNECTED_ISOCHRONOUS_STREAM_PERIPHERAL = 29
|
||||||
HCI_SYNCHRONIZED_RECEIVER_LE_SUPPORTED_FEATURE = 31
|
ISOCHRONOUS_BROADCASTER = 30
|
||||||
HCI_CONNECTED_ISOCHRONOUS_STREAM_LE_SUPPORTED_FEATURE = 32
|
SYNCHRONIZED_RECEIVER = 31
|
||||||
HCI_LE_POWER_CONTROL_REQUEST_LE_SUPPORTED_FEATURE = 33
|
CONNECTED_ISOCHRONOUS_STREAM = 32
|
||||||
HCI_LE_POWER_CONTROL_REQUEST_DUP_LE_SUPPORTED_FEATURE = 34
|
LE_POWER_CONTROL_REQUEST = 33
|
||||||
HCI_LE_PATH_LOSS_MONITORING_LE_SUPPORTED_FEATURE = 35
|
LE_POWER_CONTROL_REQUEST_DUP = 34
|
||||||
HCI_PERIODIC_ADVERTISING_ADI_SUPPORT_LE_SUPPORTED_FEATURE = 36
|
LE_PATH_LOSS_MONITORING = 35
|
||||||
HCI_CONNECTION_SUBRATING_LE_SUPPORTED_FEATURE = 37
|
PERIODIC_ADVERTISING_ADI_SUPPORT = 36
|
||||||
HCI_CONNECTION_SUBRATING_HOST_SUPPORT_LE_SUPPORTED_FEATURE = 38
|
CONNECTION_SUBRATING = 37
|
||||||
HCI_CHANNEL_CLASSIFICATION_LE_SUPPORTED_FEATURE = 39
|
CONNECTION_SUBRATING_HOST_SUPPORT = 38
|
||||||
HCI_ADVERTISING_CODING_SELECTION_LE_SUPPORTED_FEATURE = 40
|
CHANNEL_CLASSIFICATION = 39
|
||||||
HCI_ADVERTISING_CODING_SELECTION_HOST_SUPPORT_LE_SUPPORTED_FEATURE = 41
|
ADVERTISING_CODING_SELECTION = 40
|
||||||
HCI_PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER_LE_SUPPORTED_FEATURE = 43
|
ADVERTISING_CODING_SELECTION_HOST_SUPPORT = 41
|
||||||
HCI_PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER_LE_SUPPORTED_FEATURE = 44
|
PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER = 43
|
||||||
|
PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER = 44
|
||||||
|
|
||||||
HCI_LE_SUPPORTED_FEATURES_NAMES = {
|
class LeFeatureMask(enum.IntFlag):
|
||||||
flag: feature_name for (feature_name, flag) in globals().items()
|
LE_ENCRYPTION = 1 << LeFeature.LE_ENCRYPTION
|
||||||
if feature_name.startswith('HCI_') and feature_name.endswith('_LE_SUPPORTED_FEATURE')
|
CONNECTION_PARAMETERS_REQUEST_PROCEDURE = 1 << LeFeature.CONNECTION_PARAMETERS_REQUEST_PROCEDURE
|
||||||
}
|
EXTENDED_REJECT_INDICATION = 1 << LeFeature.EXTENDED_REJECT_INDICATION
|
||||||
|
PERIPHERAL_INITIATED_FEATURE_EXCHANGE = 1 << LeFeature.PERIPHERAL_INITIATED_FEATURE_EXCHANGE
|
||||||
|
LE_PING = 1 << LeFeature.LE_PING
|
||||||
|
LE_DATA_PACKET_LENGTH_EXTENSION = 1 << LeFeature.LE_DATA_PACKET_LENGTH_EXTENSION
|
||||||
|
LL_PRIVACY = 1 << LeFeature.LL_PRIVACY
|
||||||
|
EXTENDED_SCANNER_FILTER_POLICIES = 1 << LeFeature.EXTENDED_SCANNER_FILTER_POLICIES
|
||||||
|
LE_2M_PHY = 1 << LeFeature.LE_2M_PHY
|
||||||
|
STABLE_MODULATION_INDEX_TRANSMITTER = 1 << LeFeature.STABLE_MODULATION_INDEX_TRANSMITTER
|
||||||
|
STABLE_MODULATION_INDEX_RECEIVER = 1 << LeFeature.STABLE_MODULATION_INDEX_RECEIVER
|
||||||
|
LE_CODED_PHY = 1 << LeFeature.LE_CODED_PHY
|
||||||
|
LE_EXTENDED_ADVERTISING = 1 << LeFeature.LE_EXTENDED_ADVERTISING
|
||||||
|
LE_PERIODIC_ADVERTISING = 1 << LeFeature.LE_PERIODIC_ADVERTISING
|
||||||
|
CHANNEL_SELECTION_ALGORITHM_2 = 1 << LeFeature.CHANNEL_SELECTION_ALGORITHM_2
|
||||||
|
LE_POWER_CLASS_1 = 1 << LeFeature.LE_POWER_CLASS_1
|
||||||
|
MINIMUM_NUMBER_OF_USED_CHANNELS_PROCEDURE = 1 << LeFeature.MINIMUM_NUMBER_OF_USED_CHANNELS_PROCEDURE
|
||||||
|
CONNECTION_CTE_REQUEST = 1 << LeFeature.CONNECTION_CTE_REQUEST
|
||||||
|
CONNECTION_CTE_RESPONSE = 1 << LeFeature.CONNECTION_CTE_RESPONSE
|
||||||
|
CONNECTIONLESS_CTE_TRANSMITTER = 1 << LeFeature.CONNECTIONLESS_CTE_TRANSMITTER
|
||||||
|
CONNECTIONLESS_CTR_RECEIVER = 1 << LeFeature.CONNECTIONLESS_CTR_RECEIVER
|
||||||
|
ANTENNA_SWITCHING_DURING_CTE_TRANSMISSION = 1 << LeFeature.ANTENNA_SWITCHING_DURING_CTE_TRANSMISSION
|
||||||
|
ANTENNA_SWITCHING_DURING_CTE_RECEPTION = 1 << LeFeature.ANTENNA_SWITCHING_DURING_CTE_RECEPTION
|
||||||
|
RECEIVING_CONSTANT_TONE_EXTENSIONS = 1 << LeFeature.RECEIVING_CONSTANT_TONE_EXTENSIONS
|
||||||
|
PERIODIC_ADVERTISING_SYNC_TRANSFER_SENDER = 1 << LeFeature.PERIODIC_ADVERTISING_SYNC_TRANSFER_SENDER
|
||||||
|
PERIODIC_ADVERTISING_SYNC_TRANSFER_RECIPIENT = 1 << LeFeature.PERIODIC_ADVERTISING_SYNC_TRANSFER_RECIPIENT
|
||||||
|
SLEEP_CLOCK_ACCURACY_UPDATES = 1 << LeFeature.SLEEP_CLOCK_ACCURACY_UPDATES
|
||||||
|
REMOTE_PUBLIC_KEY_VALIDATION = 1 << LeFeature.REMOTE_PUBLIC_KEY_VALIDATION
|
||||||
|
CONNECTED_ISOCHRONOUS_STREAM_CENTRAL = 1 << LeFeature.CONNECTED_ISOCHRONOUS_STREAM_CENTRAL
|
||||||
|
CONNECTED_ISOCHRONOUS_STREAM_PERIPHERAL = 1 << LeFeature.CONNECTED_ISOCHRONOUS_STREAM_PERIPHERAL
|
||||||
|
ISOCHRONOUS_BROADCASTER = 1 << LeFeature.ISOCHRONOUS_BROADCASTER
|
||||||
|
SYNCHRONIZED_RECEIVER = 1 << LeFeature.SYNCHRONIZED_RECEIVER
|
||||||
|
CONNECTED_ISOCHRONOUS_STREAM = 1 << LeFeature.CONNECTED_ISOCHRONOUS_STREAM
|
||||||
|
LE_POWER_CONTROL_REQUEST = 1 << LeFeature.LE_POWER_CONTROL_REQUEST
|
||||||
|
LE_POWER_CONTROL_REQUEST_DUP = 1 << LeFeature.LE_POWER_CONTROL_REQUEST_DUP
|
||||||
|
LE_PATH_LOSS_MONITORING = 1 << LeFeature.LE_PATH_LOSS_MONITORING
|
||||||
|
PERIODIC_ADVERTISING_ADI_SUPPORT = 1 << LeFeature.PERIODIC_ADVERTISING_ADI_SUPPORT
|
||||||
|
CONNECTION_SUBRATING = 1 << LeFeature.CONNECTION_SUBRATING
|
||||||
|
CONNECTION_SUBRATING_HOST_SUPPORT = 1 << LeFeature.CONNECTION_SUBRATING_HOST_SUPPORT
|
||||||
|
CHANNEL_CLASSIFICATION = 1 << LeFeature.CHANNEL_CLASSIFICATION
|
||||||
|
ADVERTISING_CODING_SELECTION = 1 << LeFeature.ADVERTISING_CODING_SELECTION
|
||||||
|
ADVERTISING_CODING_SELECTION_HOST_SUPPORT = 1 << LeFeature.ADVERTISING_CODING_SELECTION_HOST_SUPPORT
|
||||||
|
PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER = 1 << LeFeature.PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER
|
||||||
|
PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER = 1 << LeFeature.PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER
|
||||||
|
|
||||||
|
|
||||||
# fmt: on
|
# fmt: on
|
||||||
@@ -2026,6 +2068,17 @@ class OwnAddressType(enum.IntEnum):
|
|||||||
return {'size': 1, 'mapper': lambda x: OwnAddressType(x).name}
|
return {'size': 1, 'mapper': lambda x: OwnAddressType(x).name}
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class LoopbackMode(enum.IntEnum):
|
||||||
|
DISABLED = 0
|
||||||
|
LOCAL = 1
|
||||||
|
REMOTE = 2
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def type_spec(cls):
|
||||||
|
return {'size': 1, 'mapper': lambda x: LoopbackMode(x).name}
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class HCI_Packet:
|
class HCI_Packet:
|
||||||
'''
|
'''
|
||||||
@@ -3352,6 +3405,27 @@ class HCI_Read_Encryption_Key_Size_Command(HCI_Command):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('loopback_mode', LoopbackMode.type_spec()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_Read_Loopback_Mode_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.6.1 Read Loopback Mode Command
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command([('loopback_mode', 1)])
|
||||||
|
class HCI_Write_Loopback_Mode_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.6.2 Write Loopback Mode Command
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command([('le_event_mask', 8)])
|
@HCI_Command.command([('le_event_mask', 8)])
|
||||||
class HCI_LE_Set_Event_Mask_Command(HCI_Command):
|
class HCI_LE_Set_Event_Mask_Command(HCI_Command):
|
||||||
@@ -4733,7 +4807,11 @@ class HCI_Event(HCI_Packet):
|
|||||||
HCI_Object.init_from_bytes(self, parameters, 0, fields)
|
HCI_Object.init_from_bytes(self, parameters, 0, fields)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __init__(self, event_code, parameters=None, **kwargs):
|
def __init__(self, event_code=-1, parameters=None, **kwargs):
|
||||||
|
# Since the legacy implementation relies on an __init__ injector, typing always
|
||||||
|
# complains that positional argument event_code is not passed, so here sets a
|
||||||
|
# default value to allow building derived HCI_Event without event_code.
|
||||||
|
assert event_code != -1
|
||||||
super().__init__(HCI_Event.event_name(event_code))
|
super().__init__(HCI_Event.event_name(event_code))
|
||||||
if (fields := getattr(self, 'fields', None)) and kwargs:
|
if (fields := getattr(self, 'fields', None)) and kwargs:
|
||||||
HCI_Object.init_from_fields(self, fields, kwargs)
|
HCI_Object.init_from_fields(self, fields, kwargs)
|
||||||
@@ -4827,7 +4905,8 @@ class HCI_Extended_Event(HCI_Event):
|
|||||||
HCI_Object.init_from_bytes(self, parameters, 1, fields)
|
HCI_Object.init_from_bytes(self, parameters, 1, fields)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __init__(self, subevent_code, parameters, **kwargs):
|
def __init__(self, subevent_code=None, parameters=None, **kwargs):
|
||||||
|
assert subevent_code is not None
|
||||||
self.subevent_code = subevent_code
|
self.subevent_code = subevent_code
|
||||||
if parameters is None and (fields := getattr(self, 'fields', None)) and kwargs:
|
if parameters is None and (fields := getattr(self, 'fields', None)) and kwargs:
|
||||||
parameters = bytes([subevent_code]) + HCI_Object.dict_to_bytes(
|
parameters = bytes([subevent_code]) + HCI_Object.dict_to_bytes(
|
||||||
|
|||||||
+74
-32
@@ -18,10 +18,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, MutableMapping
|
from collections.abc import Callable, MutableMapping
|
||||||
from typing import cast, Any
|
from typing import cast, Any, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from bumble import avc
|
||||||
|
from bumble import avctp
|
||||||
from bumble import avdtp
|
from bumble import avdtp
|
||||||
|
from bumble import avrcp
|
||||||
|
from bumble import crypto
|
||||||
|
from bumble import rfcomm
|
||||||
|
from bumble import sdp
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.att import ATT_CID, ATT_PDU
|
from bumble.att import ATT_CID, ATT_PDU
|
||||||
from bumble.smp import SMP_CID, SMP_Command
|
from bumble.smp import SMP_CID, SMP_Command
|
||||||
@@ -47,9 +53,7 @@ from bumble.hci import (
|
|||||||
HCI_AclDataPacket,
|
HCI_AclDataPacket,
|
||||||
HCI_Disconnection_Complete_Event,
|
HCI_Disconnection_Complete_Event,
|
||||||
)
|
)
|
||||||
from bumble.rfcomm import RFCOMM_Frame, RFCOMM_PSM
|
|
||||||
from bumble.sdp import SDP_PDU, SDP_PSM
|
|
||||||
from bumble import crypto
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -59,28 +63,35 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
PSM_NAMES = {
|
PSM_NAMES = {
|
||||||
RFCOMM_PSM: 'RFCOMM',
|
rfcomm.RFCOMM_PSM: 'RFCOMM',
|
||||||
SDP_PSM: 'SDP',
|
sdp.SDP_PSM: 'SDP',
|
||||||
avdtp.AVDTP_PSM: 'AVDTP',
|
avdtp.AVDTP_PSM: 'AVDTP',
|
||||||
|
avctp.AVCTP_PSM: 'AVCTP'
|
||||||
|
# TODO: add more PSM values
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AVCTP_PID_NAMES = {avrcp.AVRCP_PID: 'AVRCP'}
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PacketTracer:
|
class PacketTracer:
|
||||||
class AclStream:
|
class AclStream:
|
||||||
psms: MutableMapping[int, int]
|
psms: MutableMapping[int, int]
|
||||||
peer: PacketTracer.AclStream
|
peer: Optional[PacketTracer.AclStream]
|
||||||
avdtp_assemblers: MutableMapping[int, avdtp.MessageAssembler]
|
avdtp_assemblers: MutableMapping[int, avdtp.MessageAssembler]
|
||||||
|
avctp_assemblers: MutableMapping[int, avctp.MessageAssembler]
|
||||||
|
|
||||||
def __init__(self, analyzer: PacketTracer.Analyzer) -> None:
|
def __init__(self, analyzer: PacketTracer.Analyzer) -> None:
|
||||||
self.analyzer = analyzer
|
self.analyzer = analyzer
|
||||||
self.packet_assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
self.packet_assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||||
self.avdtp_assemblers = {} # AVDTP assemblers, by source_cid
|
self.avdtp_assemblers = {} # AVDTP assemblers, by source_cid
|
||||||
|
self.avctp_assemblers = {} # AVCTP assemblers, by source_cid
|
||||||
self.psms = {} # PSM, by source_cid
|
self.psms = {} # PSM, by source_cid
|
||||||
|
self.peer = None
|
||||||
|
|
||||||
# pylint: disable=too-many-nested-blocks
|
# pylint: disable=too-many-nested-blocks
|
||||||
def on_acl_pdu(self, pdu: bytes) -> None:
|
def on_acl_pdu(self, pdu: bytes) -> None:
|
||||||
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
||||||
|
self.analyzer.emit(l2cap_pdu)
|
||||||
|
|
||||||
if l2cap_pdu.cid == ATT_CID:
|
if l2cap_pdu.cid == ATT_CID:
|
||||||
att_pdu = ATT_PDU.from_bytes(l2cap_pdu.payload)
|
att_pdu = ATT_PDU.from_bytes(l2cap_pdu.payload)
|
||||||
@@ -102,42 +113,51 @@ class PacketTracer:
|
|||||||
connection_response.result
|
connection_response.result
|
||||||
== L2CAP_Connection_Response.CONNECTION_SUCCESSFUL
|
== L2CAP_Connection_Response.CONNECTION_SUCCESSFUL
|
||||||
):
|
):
|
||||||
if self.peer:
|
if self.peer and (
|
||||||
if psm := self.peer.psms.get(
|
psm := self.peer.psms.get(connection_response.source_cid)
|
||||||
connection_response.source_cid
|
):
|
||||||
):
|
# Found a pending connection
|
||||||
# Found a pending connection
|
self.psms[connection_response.destination_cid] = psm
|
||||||
self.psms[connection_response.destination_cid] = psm
|
|
||||||
|
|
||||||
# For AVDTP connections, create a packet assembler for
|
|
||||||
# each direction
|
|
||||||
if psm == avdtp.AVDTP_PSM:
|
|
||||||
self.avdtp_assemblers[
|
|
||||||
connection_response.source_cid
|
|
||||||
] = avdtp.MessageAssembler(self.on_avdtp_message)
|
|
||||||
self.peer.avdtp_assemblers[
|
|
||||||
connection_response.destination_cid
|
|
||||||
] = avdtp.MessageAssembler(
|
|
||||||
self.peer.on_avdtp_message
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# For AVDTP connections, create a packet assembler for
|
||||||
|
# each direction
|
||||||
|
if psm == avdtp.AVDTP_PSM:
|
||||||
|
self.avdtp_assemblers[
|
||||||
|
connection_response.source_cid
|
||||||
|
] = avdtp.MessageAssembler(self.on_avdtp_message)
|
||||||
|
self.peer.avdtp_assemblers[
|
||||||
|
connection_response.destination_cid
|
||||||
|
] = avdtp.MessageAssembler(self.peer.on_avdtp_message)
|
||||||
|
elif psm == avctp.AVCTP_PSM:
|
||||||
|
self.avctp_assemblers[
|
||||||
|
connection_response.source_cid
|
||||||
|
] = avctp.MessageAssembler(self.on_avctp_message)
|
||||||
|
self.peer.avctp_assemblers[
|
||||||
|
connection_response.destination_cid
|
||||||
|
] = avctp.MessageAssembler(self.peer.on_avctp_message)
|
||||||
else:
|
else:
|
||||||
# Try to find the PSM associated with this PDU
|
# Try to find the PSM associated with this PDU
|
||||||
if self.peer and (psm := self.peer.psms.get(l2cap_pdu.cid)):
|
if self.peer and (psm := self.peer.psms.get(l2cap_pdu.cid)):
|
||||||
if psm == SDP_PSM:
|
if psm == sdp.SDP_PSM:
|
||||||
sdp_pdu = SDP_PDU.from_bytes(l2cap_pdu.payload)
|
sdp_pdu = sdp.SDP_PDU.from_bytes(l2cap_pdu.payload)
|
||||||
self.analyzer.emit(sdp_pdu)
|
self.analyzer.emit(sdp_pdu)
|
||||||
elif psm == RFCOMM_PSM:
|
elif psm == rfcomm.RFCOMM_PSM:
|
||||||
rfcomm_frame = RFCOMM_Frame.from_bytes(l2cap_pdu.payload)
|
rfcomm_frame = rfcomm.RFCOMM_Frame.from_bytes(l2cap_pdu.payload)
|
||||||
self.analyzer.emit(rfcomm_frame)
|
self.analyzer.emit(rfcomm_frame)
|
||||||
elif psm == avdtp.AVDTP_PSM:
|
elif psm == avdtp.AVDTP_PSM:
|
||||||
self.analyzer.emit(
|
self.analyzer.emit(
|
||||||
f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
|
f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
|
||||||
f'PSM=AVDTP]: {l2cap_pdu.payload.hex()}'
|
f'PSM=AVDTP]: {l2cap_pdu.payload.hex()}'
|
||||||
)
|
)
|
||||||
assembler = self.avdtp_assemblers.get(l2cap_pdu.cid)
|
if avdtp_assembler := self.avdtp_assemblers.get(l2cap_pdu.cid):
|
||||||
if assembler:
|
avdtp_assembler.on_pdu(l2cap_pdu.payload)
|
||||||
assembler.on_pdu(l2cap_pdu.payload)
|
elif psm == avctp.AVCTP_PSM:
|
||||||
|
self.analyzer.emit(
|
||||||
|
f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
|
||||||
|
f'PSM=AVCTP]: {l2cap_pdu.payload.hex()}'
|
||||||
|
)
|
||||||
|
if avctp_assembler := self.avctp_assemblers.get(l2cap_pdu.cid):
|
||||||
|
avctp_assembler.on_pdu(l2cap_pdu.payload)
|
||||||
else:
|
else:
|
||||||
psm_string = name_or_number(PSM_NAMES, psm)
|
psm_string = name_or_number(PSM_NAMES, psm)
|
||||||
self.analyzer.emit(
|
self.analyzer.emit(
|
||||||
@@ -154,6 +174,28 @@ class PacketTracer:
|
|||||||
f'{color("AVDTP", "green")} [{transaction_label}] {message}'
|
f'{color("AVDTP", "green")} [{transaction_label}] {message}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_avctp_message(
|
||||||
|
self,
|
||||||
|
transaction_label: int,
|
||||||
|
is_command: bool,
|
||||||
|
ipid: bool,
|
||||||
|
pid: int,
|
||||||
|
payload: bytes,
|
||||||
|
):
|
||||||
|
if pid == avrcp.AVRCP_PID:
|
||||||
|
avc_frame = avc.Frame.from_bytes(payload)
|
||||||
|
details = str(avc_frame)
|
||||||
|
else:
|
||||||
|
details = payload.hex()
|
||||||
|
|
||||||
|
c_r = 'Command' if is_command else 'Response'
|
||||||
|
self.analyzer.emit(
|
||||||
|
f'{color("AVCTP", "green")} '
|
||||||
|
f'{c_r}[{transaction_label}][{name_or_number(AVCTP_PID_NAMES, pid)}] '
|
||||||
|
f'{"#" if ipid else ""}'
|
||||||
|
f'{details}'
|
||||||
|
)
|
||||||
|
|
||||||
def feed_packet(self, packet: HCI_AclDataPacket) -> None:
|
def feed_packet(self, packet: HCI_AclDataPacket) -> None:
|
||||||
self.packet_assembler.feed_packet(packet)
|
self.packet_assembler.feed_packet(packet)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -402,7 +402,7 @@ class Device(HID):
|
|||||||
report_type = pdu[0] & 0x03
|
report_type = pdu[0] & 0x03
|
||||||
buffer_flag = (pdu[0] & 0x08) >> 3
|
buffer_flag = (pdu[0] & 0x08) >> 3
|
||||||
report_id = pdu[1]
|
report_id = pdu[1]
|
||||||
logger.debug("buffer_flag: " + str(buffer_flag))
|
logger.debug(f"buffer_flag: {buffer_flag}")
|
||||||
if buffer_flag == 1:
|
if buffer_flag == 1:
|
||||||
buffer_size = (pdu[3] << 8) | pdu[2]
|
buffer_size = (pdu[3] << 8) | pdu[2]
|
||||||
else:
|
else:
|
||||||
|
|||||||
+118
-76
@@ -21,7 +21,7 @@ import collections
|
|||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
from typing import Any, Awaitable, Callable, Dict, Optional, Union, cast, TYPE_CHECKING
|
from typing import Any, Awaitable, Callable, Deque, Dict, Optional, 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
|
||||||
@@ -70,6 +70,7 @@ from .hci import (
|
|||||||
HCI_Reset_Command,
|
HCI_Reset_Command,
|
||||||
HCI_Set_Event_Mask_Command,
|
HCI_Set_Event_Mask_Command,
|
||||||
HCI_SynchronousDataPacket,
|
HCI_SynchronousDataPacket,
|
||||||
|
LeFeatureMask,
|
||||||
)
|
)
|
||||||
from .core import (
|
from .core import (
|
||||||
BT_BR_EDR_TRANSPORT,
|
BT_BR_EDR_TRANSPORT,
|
||||||
@@ -91,16 +92,49 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
class AclPacketQueue:
|
||||||
# -----------------------------------------------------------------------------
|
max_packet_size: int
|
||||||
# fmt: off
|
|
||||||
|
|
||||||
HOST_DEFAULT_HC_LE_ACL_DATA_PACKET_LENGTH = 27
|
def __init__(
|
||||||
HOST_HC_TOTAL_NUM_LE_ACL_DATA_PACKETS = 1
|
self,
|
||||||
HOST_DEFAULT_HC_ACL_DATA_PACKET_LENGTH = 27
|
max_packet_size: int,
|
||||||
HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
|
max_in_flight: int,
|
||||||
|
send: Callable[[HCI_Packet], None],
|
||||||
|
) -> None:
|
||||||
|
self.max_packet_size = max_packet_size
|
||||||
|
self.max_in_flight = max_in_flight
|
||||||
|
self.in_flight = 0
|
||||||
|
self.send = send
|
||||||
|
self.packets: Deque[HCI_AclDataPacket] = collections.deque()
|
||||||
|
|
||||||
# fmt: on
|
def enqueue(self, packet: HCI_AclDataPacket) -> None:
|
||||||
|
self.packets.appendleft(packet)
|
||||||
|
self.check_queue()
|
||||||
|
|
||||||
|
if self.packets:
|
||||||
|
logger.debug(
|
||||||
|
f'{self.in_flight} ACL packets in flight, '
|
||||||
|
f'{len(self.packets)} in queue'
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_queue(self) -> None:
|
||||||
|
while self.packets and self.in_flight < self.max_in_flight:
|
||||||
|
packet = self.packets.pop()
|
||||||
|
self.send(packet)
|
||||||
|
self.in_flight += 1
|
||||||
|
|
||||||
|
def on_packets_completed(self, packet_count: int) -> None:
|
||||||
|
if packet_count > self.in_flight:
|
||||||
|
logger.warning(
|
||||||
|
color(
|
||||||
|
'!!! {packet_count} completed but only '
|
||||||
|
f'{self.in_flight} in flight'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
packet_count = self.in_flight
|
||||||
|
|
||||||
|
self.in_flight -= packet_count
|
||||||
|
self.check_queue()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -111,6 +145,13 @@ class Connection:
|
|||||||
self.peer_address = peer_address
|
self.peer_address = peer_address
|
||||||
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
|
acl_packet_queue: Optional[AclPacketQueue] = (
|
||||||
|
host.le_acl_packet_queue
|
||||||
|
if transport == BT_LE_TRANSPORT
|
||||||
|
else host.acl_packet_queue
|
||||||
|
)
|
||||||
|
assert acl_packet_queue
|
||||||
|
self.acl_packet_queue = acl_packet_queue
|
||||||
|
|
||||||
def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
|
def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
|
||||||
self.assembler.feed_packet(packet)
|
self.assembler.feed_packet(packet)
|
||||||
@@ -123,7 +164,8 @@ class Connection:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Host(AbortableEventEmitter):
|
class Host(AbortableEventEmitter):
|
||||||
connections: Dict[int, Connection]
|
connections: Dict[int, Connection]
|
||||||
acl_packet_queue: collections.deque[HCI_AclDataPacket]
|
acl_packet_queue: Optional[AclPacketQueue] = None
|
||||||
|
le_acl_packet_queue: Optional[AclPacketQueue] = None
|
||||||
hci_sink: Optional[TransportSink] = None
|
hci_sink: Optional[TransportSink] = None
|
||||||
hci_metadata: Dict[str, Any]
|
hci_metadata: Dict[str, Any]
|
||||||
long_term_key_provider: Optional[
|
long_term_key_provider: Optional[
|
||||||
@@ -143,12 +185,6 @@ class Host(AbortableEventEmitter):
|
|||||||
self.connections = {} # Connections, by connection handle
|
self.connections = {} # Connections, by connection handle
|
||||||
self.pending_command = None
|
self.pending_command = None
|
||||||
self.pending_response = None
|
self.pending_response = None
|
||||||
self.hc_le_acl_data_packet_length = HOST_DEFAULT_HC_LE_ACL_DATA_PACKET_LENGTH
|
|
||||||
self.hc_total_num_le_acl_data_packets = HOST_HC_TOTAL_NUM_LE_ACL_DATA_PACKETS
|
|
||||||
self.hc_acl_data_packet_length = HOST_DEFAULT_HC_ACL_DATA_PACKET_LENGTH
|
|
||||||
self.hc_total_num_acl_data_packets = HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS
|
|
||||||
self.acl_packet_queue = collections.deque()
|
|
||||||
self.acl_packets_in_flight = 0
|
|
||||||
self.local_version = None
|
self.local_version = None
|
||||||
self.local_supported_commands = bytes(64)
|
self.local_supported_commands = bytes(64)
|
||||||
self.local_le_features = 0
|
self.local_le_features = 0
|
||||||
@@ -254,46 +290,54 @@ class Host(AbortableEventEmitter):
|
|||||||
response = await self.send_command(
|
response = await self.send_command(
|
||||||
HCI_Read_Buffer_Size_Command(), check_result=True
|
HCI_Read_Buffer_Size_Command(), check_result=True
|
||||||
)
|
)
|
||||||
self.hc_acl_data_packet_length = (
|
hc_acl_data_packet_length = (
|
||||||
response.return_parameters.hc_acl_data_packet_length
|
response.return_parameters.hc_acl_data_packet_length
|
||||||
)
|
)
|
||||||
self.hc_total_num_acl_data_packets = (
|
hc_total_num_acl_data_packets = (
|
||||||
response.return_parameters.hc_total_num_acl_data_packets
|
response.return_parameters.hc_total_num_acl_data_packets
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'HCI ACL flow control: '
|
'HCI ACL flow control: '
|
||||||
f'hc_acl_data_packet_length={self.hc_acl_data_packet_length},'
|
f'hc_acl_data_packet_length={hc_acl_data_packet_length},'
|
||||||
f'hc_total_num_acl_data_packets={self.hc_total_num_acl_data_packets}'
|
f'hc_total_num_acl_data_packets={hc_total_num_acl_data_packets}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.acl_packet_queue = AclPacketQueue(
|
||||||
|
max_packet_size=hc_acl_data_packet_length,
|
||||||
|
max_in_flight=hc_total_num_acl_data_packets,
|
||||||
|
send=self.send_hci_packet,
|
||||||
|
)
|
||||||
|
|
||||||
|
hc_le_acl_data_packet_length = 0
|
||||||
|
hc_total_num_le_acl_data_packets = 0
|
||||||
if self.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
if self.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||||
response = await self.send_command(
|
response = await self.send_command(
|
||||||
HCI_LE_Read_Buffer_Size_Command(), check_result=True
|
HCI_LE_Read_Buffer_Size_Command(), check_result=True
|
||||||
)
|
)
|
||||||
self.hc_le_acl_data_packet_length = (
|
hc_le_acl_data_packet_length = (
|
||||||
response.return_parameters.hc_le_acl_data_packet_length
|
response.return_parameters.hc_le_acl_data_packet_length
|
||||||
)
|
)
|
||||||
self.hc_total_num_le_acl_data_packets = (
|
hc_total_num_le_acl_data_packets = (
|
||||||
response.return_parameters.hc_total_num_le_acl_data_packets
|
response.return_parameters.hc_total_num_le_acl_data_packets
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'HCI LE ACL flow control: '
|
'HCI LE ACL flow control: '
|
||||||
f'hc_le_acl_data_packet_length={self.hc_le_acl_data_packet_length},'
|
f'hc_le_acl_data_packet_length={hc_le_acl_data_packet_length},'
|
||||||
'hc_total_num_le_acl_data_packets='
|
f'hc_total_num_le_acl_data_packets={hc_total_num_le_acl_data_packets}'
|
||||||
f'{self.hc_total_num_le_acl_data_packets}'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if hc_le_acl_data_packet_length == 0 or hc_total_num_le_acl_data_packets == 0:
|
||||||
response.return_parameters.hc_le_acl_data_packet_length == 0
|
# LE and Classic share the same queue
|
||||||
or response.return_parameters.hc_total_num_le_acl_data_packets == 0
|
self.le_acl_packet_queue = self.acl_packet_queue
|
||||||
):
|
else:
|
||||||
# LE and Classic share the same values
|
# Create a separate queue for LE
|
||||||
self.hc_le_acl_data_packet_length = self.hc_acl_data_packet_length
|
self.le_acl_packet_queue = AclPacketQueue(
|
||||||
self.hc_total_num_le_acl_data_packets = (
|
max_packet_size=hc_le_acl_data_packet_length,
|
||||||
self.hc_total_num_acl_data_packets
|
max_in_flight=hc_total_num_le_acl_data_packets,
|
||||||
)
|
send=self.send_hci_packet,
|
||||||
|
)
|
||||||
|
|
||||||
if self.supports_command(
|
if self.supports_command(
|
||||||
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
|
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
|
||||||
@@ -332,14 +376,13 @@ class Host(AbortableEventEmitter):
|
|||||||
self.hci_metadata = getattr(source, 'metadata', self.hci_metadata)
|
self.hci_metadata = getattr(source, 'metadata', self.hci_metadata)
|
||||||
|
|
||||||
def send_hci_packet(self, packet: HCI_Packet) -> None:
|
def send_hci_packet(self, packet: HCI_Packet) -> None:
|
||||||
|
logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: {packet}')
|
||||||
if self.snooper:
|
if self.snooper:
|
||||||
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
|
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
|
||||||
if self.hci_sink:
|
if self.hci_sink:
|
||||||
self.hci_sink.on_packet(bytes(packet))
|
self.hci_sink.on_packet(bytes(packet))
|
||||||
|
|
||||||
async def send_command(self, command, check_result=False):
|
async def send_command(self, command, check_result=False):
|
||||||
logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: {command}')
|
|
||||||
|
|
||||||
# Wait until we can send (only one pending command at a time)
|
# Wait until we can send (only one pending command at a time)
|
||||||
async with self.command_semaphore:
|
async with self.command_semaphore:
|
||||||
assert self.pending_command is None
|
assert self.pending_command is None
|
||||||
@@ -387,6 +430,17 @@ class Host(AbortableEventEmitter):
|
|||||||
asyncio.create_task(send_command(command))
|
asyncio.create_task(send_command(command))
|
||||||
|
|
||||||
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
||||||
|
if not (connection := self.connections.get(connection_handle)):
|
||||||
|
logger.warning(f'connection 0x{connection_handle:04X} not found')
|
||||||
|
return
|
||||||
|
packet_queue = connection.acl_packet_queue
|
||||||
|
if packet_queue is None:
|
||||||
|
logger.warning(
|
||||||
|
f'no ACL packet queue for connection 0x{connection_handle:04X}'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create a PDU
|
||||||
l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
|
l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
|
||||||
|
|
||||||
# Send the data to the controller via ACL packets
|
# Send the data to the controller via ACL packets
|
||||||
@@ -394,8 +448,7 @@ class Host(AbortableEventEmitter):
|
|||||||
offset = 0
|
offset = 0
|
||||||
pb_flag = 0
|
pb_flag = 0
|
||||||
while bytes_remaining:
|
while bytes_remaining:
|
||||||
# TODO: support different LE/Classic lengths
|
data_total_length = min(bytes_remaining, packet_queue.max_packet_size)
|
||||||
data_total_length = min(bytes_remaining, self.hc_le_acl_data_packet_length)
|
|
||||||
acl_packet = HCI_AclDataPacket(
|
acl_packet = HCI_AclDataPacket(
|
||||||
connection_handle=connection_handle,
|
connection_handle=connection_handle,
|
||||||
pb_flag=pb_flag,
|
pb_flag=pb_flag,
|
||||||
@@ -403,34 +456,12 @@ class Host(AbortableEventEmitter):
|
|||||||
data_total_length=data_total_length,
|
data_total_length=data_total_length,
|
||||||
data=l2cap_pdu[offset : offset + data_total_length],
|
data=l2cap_pdu[offset : offset + data_total_length],
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(f'>>> ACL packet enqueue: (CID={cid}) {acl_packet}')
|
||||||
f'{color("### HOST -> CONTROLLER", "blue")}: (CID={cid}) {acl_packet}'
|
packet_queue.enqueue(acl_packet)
|
||||||
)
|
|
||||||
self.queue_acl_packet(acl_packet)
|
|
||||||
pb_flag = 1
|
pb_flag = 1
|
||||||
offset += data_total_length
|
offset += data_total_length
|
||||||
bytes_remaining -= data_total_length
|
bytes_remaining -= data_total_length
|
||||||
|
|
||||||
def queue_acl_packet(self, acl_packet: HCI_AclDataPacket) -> None:
|
|
||||||
self.acl_packet_queue.appendleft(acl_packet)
|
|
||||||
self.check_acl_packet_queue()
|
|
||||||
|
|
||||||
if len(self.acl_packet_queue):
|
|
||||||
logger.debug(
|
|
||||||
f'{self.acl_packets_in_flight} ACL packets in flight, '
|
|
||||||
f'{len(self.acl_packet_queue)} in queue'
|
|
||||||
)
|
|
||||||
|
|
||||||
def check_acl_packet_queue(self) -> None:
|
|
||||||
# Send all we can (TODO: support different LE/Classic limits)
|
|
||||||
while (
|
|
||||||
len(self.acl_packet_queue) > 0
|
|
||||||
and self.acl_packets_in_flight < self.hc_total_num_le_acl_data_packets
|
|
||||||
):
|
|
||||||
packet = self.acl_packet_queue.pop()
|
|
||||||
self.send_hci_packet(packet)
|
|
||||||
self.acl_packets_in_flight += 1
|
|
||||||
|
|
||||||
def supports_command(self, command):
|
def supports_command(self, command):
|
||||||
# Find the support flag position for this command
|
# Find the support flag position for this command
|
||||||
for octet, flags in enumerate(HCI_SUPPORTED_COMMANDS_FLAGS):
|
for octet, flags in enumerate(HCI_SUPPORTED_COMMANDS_FLAGS):
|
||||||
@@ -457,8 +488,8 @@ class Host(AbortableEventEmitter):
|
|||||||
|
|
||||||
return commands
|
return commands
|
||||||
|
|
||||||
def supports_le_feature(self, feature):
|
def supports_le_features(self, feature: LeFeatureMask) -> bool:
|
||||||
return (self.local_le_features & (1 << feature)) != 0
|
return (self.local_le_features & feature) == feature
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supported_le_features(self):
|
def supported_le_features(self):
|
||||||
@@ -553,7 +584,7 @@ class Host(AbortableEventEmitter):
|
|||||||
# This is used just for the Num_HCI_Command_Packets field, not related to
|
# This is used just for the Num_HCI_Command_Packets field, not related to
|
||||||
# an actual command
|
# an actual command
|
||||||
logger.debug('no-command event')
|
logger.debug('no-command event')
|
||||||
return None
|
return
|
||||||
|
|
||||||
return self.on_command_processed(event)
|
return self.on_command_processed(event)
|
||||||
|
|
||||||
@@ -561,18 +592,17 @@ class Host(AbortableEventEmitter):
|
|||||||
return self.on_command_processed(event)
|
return self.on_command_processed(event)
|
||||||
|
|
||||||
def on_hci_number_of_completed_packets_event(self, event):
|
def on_hci_number_of_completed_packets_event(self, event):
|
||||||
total_packets = sum(event.num_completed_packets)
|
for connection_handle, num_completed_packets in zip(
|
||||||
if total_packets <= self.acl_packets_in_flight:
|
event.connection_handles, event.num_completed_packets
|
||||||
self.acl_packets_in_flight -= total_packets
|
):
|
||||||
self.check_acl_packet_queue()
|
if not (connection := self.connections.get(connection_handle)):
|
||||||
else:
|
logger.warning(
|
||||||
logger.warning(
|
'received packet completion event for unknown handle '
|
||||||
color(
|
f'0x{connection_handle:04X}'
|
||||||
'!!! {total_packets} completed but only '
|
|
||||||
f'{self.acl_packets_in_flight} in flight'
|
|
||||||
)
|
)
|
||||||
)
|
continue
|
||||||
self.acl_packets_in_flight = 0
|
|
||||||
|
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):
|
||||||
@@ -1004,3 +1034,15 @@ class Host(AbortableEventEmitter):
|
|||||||
event.bd_addr,
|
event.bd_addr,
|
||||||
event.host_supported_features,
|
event.host_supported_features,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_hci_le_read_remote_features_complete_event(self, event):
|
||||||
|
if event.status != HCI_SUCCESS:
|
||||||
|
self.emit(
|
||||||
|
'le_remote_features_failure', event.connection_handle, event.status
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.emit(
|
||||||
|
'le_remote_features',
|
||||||
|
event.connection_handle,
|
||||||
|
int.from_bytes(event.le_features, 'little'),
|
||||||
|
)
|
||||||
|
|||||||
+10
-5
@@ -149,9 +149,10 @@ L2CAP_INVALID_CID_IN_REQUEST_REASON = 0x0002
|
|||||||
|
|
||||||
L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS = 65535
|
L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS = 65535
|
||||||
L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU = 23
|
L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU = 23
|
||||||
|
L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MTU = 65535
|
||||||
L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS = 23
|
L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS = 23
|
||||||
L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS = 65533
|
L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS = 65533
|
||||||
L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU = 2046
|
L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU = 2048
|
||||||
L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS = 2048
|
L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS = 2048
|
||||||
L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS = 256
|
L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS = 256
|
||||||
|
|
||||||
@@ -188,8 +189,11 @@ class LeCreditBasedChannelSpec:
|
|||||||
or self.max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
|
or self.max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
|
||||||
):
|
):
|
||||||
raise ValueError('max credits out of range')
|
raise ValueError('max credits out of range')
|
||||||
if self.mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU:
|
if (
|
||||||
raise ValueError('MTU too small')
|
self.mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU
|
||||||
|
or self.mtu > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MTU
|
||||||
|
):
|
||||||
|
raise ValueError('MTU out of range')
|
||||||
if (
|
if (
|
||||||
self.mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
|
self.mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
|
||||||
or self.mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
|
or self.mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
|
||||||
@@ -1644,12 +1648,13 @@ class ChannelManager:
|
|||||||
|
|
||||||
def send_pdu(self, connection, cid: int, pdu: Union[SupportsBytes, bytes]) -> None:
|
def send_pdu(self, connection, cid: int, pdu: Union[SupportsBytes, bytes]) -> None:
|
||||||
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
|
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
|
||||||
|
pdu_bytes = bytes(pdu)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'{color(">>> Sending L2CAP PDU", "blue")} '
|
f'{color(">>> Sending L2CAP PDU", "blue")} '
|
||||||
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
|
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
|
||||||
f'{connection.peer_address}: {pdu_str}'
|
f'{connection.peer_address}: {len(pdu_bytes)} bytes, {pdu_str}'
|
||||||
)
|
)
|
||||||
self.host.send_l2cap_pdu(connection.handle, cid, bytes(pdu))
|
self.host.send_l2cap_pdu(connection.handle, cid, pdu_bytes)
|
||||||
|
|
||||||
def on_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
|
def on_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
|
||||||
if cid in (L2CAP_SIGNALING_CID, L2CAP_LE_SIGNALING_CID):
|
if cid in (L2CAP_SIGNALING_CID, L2CAP_LE_SIGNALING_CID):
|
||||||
|
|||||||
+109
-1
@@ -26,9 +26,13 @@ from bumble.hci import (
|
|||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR,
|
HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR,
|
||||||
HCI_CONNECTION_TIMEOUT_ERROR,
|
HCI_CONNECTION_TIMEOUT_ERROR,
|
||||||
|
HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
||||||
HCI_PAGE_TIMEOUT_ERROR,
|
HCI_PAGE_TIMEOUT_ERROR,
|
||||||
HCI_Connection_Complete_Event,
|
HCI_Connection_Complete_Event,
|
||||||
)
|
)
|
||||||
|
from bumble import controller
|
||||||
|
|
||||||
|
from typing import Optional, Set
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -57,6 +61,8 @@ class LocalLink:
|
|||||||
Link bus for controllers to communicate with each other
|
Link bus for controllers to communicate with each other
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
controllers: Set[controller.Controller]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.controllers = set()
|
self.controllers = set()
|
||||||
self.pending_connection = None
|
self.pending_connection = None
|
||||||
@@ -79,7 +85,9 @@ class LocalLink:
|
|||||||
return controller
|
return controller
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def find_classic_controller(self, address):
|
def find_classic_controller(
|
||||||
|
self, address: Address
|
||||||
|
) -> Optional[controller.Controller]:
|
||||||
for controller in self.controllers:
|
for controller in self.controllers:
|
||||||
if controller.public_address == address:
|
if controller.public_address == address:
|
||||||
return controller
|
return controller
|
||||||
@@ -188,6 +196,60 @@ class LocalLink:
|
|||||||
if peripheral_controller := self.find_controller(peripheral_address):
|
if peripheral_controller := self.find_controller(peripheral_address):
|
||||||
peripheral_controller.on_link_encrypted(central_address, rand, ediv, ltk)
|
peripheral_controller.on_link_encrypted(central_address, rand, ediv, ltk)
|
||||||
|
|
||||||
|
def create_cis(
|
||||||
|
self,
|
||||||
|
central_controller: controller.Controller,
|
||||||
|
peripheral_address: Address,
|
||||||
|
cig_id: int,
|
||||||
|
cis_id: int,
|
||||||
|
) -> None:
|
||||||
|
logger.debug(
|
||||||
|
f'$$$ CIS Request {central_controller.random_address} -> {peripheral_address}'
|
||||||
|
)
|
||||||
|
if peripheral_controller := self.find_controller(peripheral_address):
|
||||||
|
asyncio.get_running_loop().call_soon(
|
||||||
|
peripheral_controller.on_link_cis_request,
|
||||||
|
central_controller.random_address,
|
||||||
|
cig_id,
|
||||||
|
cis_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def accept_cis(
|
||||||
|
self,
|
||||||
|
peripheral_controller: controller.Controller,
|
||||||
|
central_address: Address,
|
||||||
|
cig_id: int,
|
||||||
|
cis_id: int,
|
||||||
|
) -> None:
|
||||||
|
logger.debug(
|
||||||
|
f'$$$ CIS Accept {peripheral_controller.random_address} -> {central_address}'
|
||||||
|
)
|
||||||
|
if central_controller := self.find_controller(central_address):
|
||||||
|
asyncio.get_running_loop().call_soon(
|
||||||
|
central_controller.on_link_cis_established, cig_id, cis_id
|
||||||
|
)
|
||||||
|
asyncio.get_running_loop().call_soon(
|
||||||
|
peripheral_controller.on_link_cis_established, cig_id, cis_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def disconnect_cis(
|
||||||
|
self,
|
||||||
|
initiator_controller: controller.Controller,
|
||||||
|
peer_address: Address,
|
||||||
|
cig_id: int,
|
||||||
|
cis_id: int,
|
||||||
|
) -> None:
|
||||||
|
logger.debug(
|
||||||
|
f'$$$ CIS Disconnect {initiator_controller.random_address} -> {peer_address}'
|
||||||
|
)
|
||||||
|
if peer_controller := self.find_controller(peer_address):
|
||||||
|
asyncio.get_running_loop().call_soon(
|
||||||
|
initiator_controller.on_link_cis_disconnected, cig_id, cis_id
|
||||||
|
)
|
||||||
|
asyncio.get_running_loop().call_soon(
|
||||||
|
peer_controller.on_link_cis_disconnected, cig_id, cis_id
|
||||||
|
)
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# Classic handlers
|
# Classic handlers
|
||||||
############################################################
|
############################################################
|
||||||
@@ -271,6 +333,52 @@ class LocalLink:
|
|||||||
initiator_controller.public_address, int(not (initiator_new_role))
|
initiator_controller.public_address, int(not (initiator_new_role))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def classic_sco_connect(
|
||||||
|
self,
|
||||||
|
initiator_controller: controller.Controller,
|
||||||
|
responder_address: Address,
|
||||||
|
link_type: int,
|
||||||
|
):
|
||||||
|
logger.debug(
|
||||||
|
f'[Classic] {initiator_controller.public_address} connects SCO to {responder_address}'
|
||||||
|
)
|
||||||
|
responder_controller = self.find_classic_controller(responder_address)
|
||||||
|
# Initiator controller should handle it.
|
||||||
|
assert responder_controller
|
||||||
|
|
||||||
|
responder_controller.on_classic_connection_request(
|
||||||
|
initiator_controller.public_address,
|
||||||
|
link_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
def classic_accept_sco_connection(
|
||||||
|
self,
|
||||||
|
responder_controller: controller.Controller,
|
||||||
|
initiator_address: Address,
|
||||||
|
link_type: int,
|
||||||
|
):
|
||||||
|
logger.debug(
|
||||||
|
f'[Classic] {responder_controller.public_address} accepts to connect SCO {initiator_address}'
|
||||||
|
)
|
||||||
|
initiator_controller = self.find_classic_controller(initiator_address)
|
||||||
|
if initiator_controller is None:
|
||||||
|
responder_controller.on_classic_sco_connection_complete(
|
||||||
|
responder_controller.public_address,
|
||||||
|
HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
||||||
|
link_type,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def task():
|
||||||
|
initiator_controller.on_classic_sco_connection_complete(
|
||||||
|
responder_controller.public_address, HCI_SUCCESS, link_type
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.create_task(task())
|
||||||
|
responder_controller.on_classic_sco_connection_complete(
|
||||||
|
initiator_controller.public_address, HCI_SUCCESS, link_type
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class RemoteLink:
|
class RemoteLink:
|
||||||
|
|||||||
@@ -285,10 +285,11 @@ class HostService(HostServicer):
|
|||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"TODO: add support for extended advertising in Bumble"
|
"TODO: add support for extended advertising in Bumble"
|
||||||
)
|
)
|
||||||
if request.interval:
|
if advertising_interval := request.interval:
|
||||||
raise NotImplementedError("TODO: add support for `request.interval`")
|
self.device.config.advertising_interval_min = int(advertising_interval)
|
||||||
if request.interval_range:
|
self.device.config.advertising_interval_max = int(advertising_interval)
|
||||||
raise NotImplementedError("TODO: add support for `request.interval_range`")
|
if interval_range := request.interval_range:
|
||||||
|
self.device.config.advertising_interval_max += int(interval_range)
|
||||||
if request.primary_phy:
|
if request.primary_phy:
|
||||||
raise NotImplementedError("TODO: add support for `request.primary_phy`")
|
raise NotImplementedError("TODO: add support for `request.primary_phy`")
|
||||||
if request.secondary_phy:
|
if request.secondary_phy:
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
|
|
||||||
event = self.add_origin(PairingEvent(just_works=empty_pb2.Empty()))
|
event = self.add_origin(PairingEvent(just_works=empty_pb2.Empty()))
|
||||||
self.service.event_queue.put_nowait(event)
|
self.service.event_queue.put_nowait(event)
|
||||||
answer = await anext(self.service.event_answer) # pytype: disable=name-error
|
answer = await anext(self.service.event_answer) # type: ignore
|
||||||
assert answer.event == event
|
assert answer.event == event
|
||||||
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
|
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
|
||||||
return answer.confirm
|
return answer.confirm
|
||||||
@@ -125,7 +125,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
|
|
||||||
event = self.add_origin(PairingEvent(numeric_comparison=number))
|
event = self.add_origin(PairingEvent(numeric_comparison=number))
|
||||||
self.service.event_queue.put_nowait(event)
|
self.service.event_queue.put_nowait(event)
|
||||||
answer = await anext(self.service.event_answer) # pytype: disable=name-error
|
answer = await anext(self.service.event_answer) # type: ignore
|
||||||
assert answer.event == event
|
assert answer.event == event
|
||||||
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
|
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
|
||||||
return answer.confirm
|
return answer.confirm
|
||||||
@@ -140,7 +140,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
|
|
||||||
event = self.add_origin(PairingEvent(passkey_entry_request=empty_pb2.Empty()))
|
event = self.add_origin(PairingEvent(passkey_entry_request=empty_pb2.Empty()))
|
||||||
self.service.event_queue.put_nowait(event)
|
self.service.event_queue.put_nowait(event)
|
||||||
answer = await anext(self.service.event_answer) # pytype: disable=name-error
|
answer = await anext(self.service.event_answer) # type: ignore
|
||||||
assert answer.event == event
|
assert answer.event == event
|
||||||
if answer.answer_variant() is None:
|
if answer.answer_variant() is None:
|
||||||
return None
|
return None
|
||||||
@@ -157,7 +157,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
|
|
||||||
event = self.add_origin(PairingEvent(pin_code_request=empty_pb2.Empty()))
|
event = self.add_origin(PairingEvent(pin_code_request=empty_pb2.Empty()))
|
||||||
self.service.event_queue.put_nowait(event)
|
self.service.event_queue.put_nowait(event)
|
||||||
answer = await anext(self.service.event_answer) # pytype: disable=name-error
|
answer = await anext(self.service.event_answer) # type: ignore
|
||||||
assert answer.event == event
|
assert answer.event == event
|
||||||
if answer.answer_variant() is None:
|
if answer.answer_variant() is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
+60
-8
@@ -19,7 +19,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import enum
|
import enum
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from bumble import core
|
from bumble import core
|
||||||
from bumble import crypto
|
from bumble import crypto
|
||||||
@@ -31,6 +31,9 @@ from bumble import gatt_client
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
SET_IDENTITY_RESOLVING_KEY_LENGTH = 16
|
||||||
|
|
||||||
|
|
||||||
class SirkType(enum.IntEnum):
|
class SirkType(enum.IntEnum):
|
||||||
'''Coordinated Set Identification Service - 5.1 Set Identity Resolving Key.'''
|
'''Coordinated Set Identification Service - 5.1 Set Identity Resolving Key.'''
|
||||||
|
|
||||||
@@ -66,6 +69,10 @@ def k1(n: bytes, salt: bytes, p: bytes) -> bytes:
|
|||||||
def sef(k: bytes, r: bytes) -> bytes:
|
def sef(k: bytes, r: bytes) -> bytes:
|
||||||
'''
|
'''
|
||||||
Coordinated Set Identification Service - 4.5 SIRK encryption function sef.
|
Coordinated Set Identification Service - 4.5 SIRK encryption function sef.
|
||||||
|
|
||||||
|
SIRK decryption function sdf shares the same algorithm. The only difference is that argument r is:
|
||||||
|
* Plaintext in encryption
|
||||||
|
* Cipher in decryption
|
||||||
'''
|
'''
|
||||||
return crypto.xor(k1(k, s1(b'SIRKenc'[::-1]), b'csis'[::-1]), r)
|
return crypto.xor(k1(k, s1(b'SIRKenc'[::-1]), b'csis'[::-1]), r)
|
||||||
|
|
||||||
@@ -105,6 +112,11 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
|||||||
set_member_lock: Optional[MemberLock] = None,
|
set_member_lock: Optional[MemberLock] = None,
|
||||||
set_member_rank: Optional[int] = None,
|
set_member_rank: Optional[int] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if len(set_identity_resolving_key) != SET_IDENTITY_RESOLVING_KEY_LENGTH:
|
||||||
|
raise ValueError(
|
||||||
|
f'Invalid SIRK length {len(set_identity_resolving_key)}, expected {SET_IDENTITY_RESOLVING_KEY_LENGTH}'
|
||||||
|
)
|
||||||
|
|
||||||
characteristics = []
|
characteristics = []
|
||||||
|
|
||||||
self.set_identity_resolving_key = set_identity_resolving_key
|
self.set_identity_resolving_key = set_identity_resolving_key
|
||||||
@@ -113,7 +125,7 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
|||||||
uuid=gatt.GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC,
|
uuid=gatt.GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC,
|
||||||
properties=gatt.Characteristic.Properties.READ
|
properties=gatt.Characteristic.Properties.READ
|
||||||
| gatt.Characteristic.Properties.NOTIFY,
|
| gatt.Characteristic.Properties.NOTIFY,
|
||||||
permissions=gatt.Characteristic.Permissions.READABLE,
|
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
value=gatt.CharacteristicValue(read=self.on_sirk_read),
|
value=gatt.CharacteristicValue(read=self.on_sirk_read),
|
||||||
)
|
)
|
||||||
characteristics.append(self.set_identity_resolving_key_characteristic)
|
characteristics.append(self.set_identity_resolving_key_characteristic)
|
||||||
@@ -123,7 +135,7 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
|||||||
uuid=gatt.GATT_COORDINATED_SET_SIZE_CHARACTERISTIC,
|
uuid=gatt.GATT_COORDINATED_SET_SIZE_CHARACTERISTIC,
|
||||||
properties=gatt.Characteristic.Properties.READ
|
properties=gatt.Characteristic.Properties.READ
|
||||||
| gatt.Characteristic.Properties.NOTIFY,
|
| gatt.Characteristic.Properties.NOTIFY,
|
||||||
permissions=gatt.Characteristic.Permissions.READABLE,
|
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
value=struct.pack('B', coordinated_set_size),
|
value=struct.pack('B', coordinated_set_size),
|
||||||
)
|
)
|
||||||
characteristics.append(self.coordinated_set_size_characteristic)
|
characteristics.append(self.coordinated_set_size_characteristic)
|
||||||
@@ -134,7 +146,7 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
|||||||
properties=gatt.Characteristic.Properties.READ
|
properties=gatt.Characteristic.Properties.READ
|
||||||
| gatt.Characteristic.Properties.NOTIFY
|
| gatt.Characteristic.Properties.NOTIFY
|
||||||
| gatt.Characteristic.Properties.WRITE,
|
| gatt.Characteristic.Properties.WRITE,
|
||||||
permissions=gatt.Characteristic.Permissions.READABLE
|
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
|
||||||
| gatt.Characteristic.Permissions.WRITEABLE,
|
| gatt.Characteristic.Permissions.WRITEABLE,
|
||||||
value=struct.pack('B', set_member_lock),
|
value=struct.pack('B', set_member_lock),
|
||||||
)
|
)
|
||||||
@@ -145,18 +157,32 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
|||||||
uuid=gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC,
|
uuid=gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC,
|
||||||
properties=gatt.Characteristic.Properties.READ
|
properties=gatt.Characteristic.Properties.READ
|
||||||
| gatt.Characteristic.Properties.NOTIFY,
|
| gatt.Characteristic.Properties.NOTIFY,
|
||||||
permissions=gatt.Characteristic.Permissions.READABLE,
|
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
value=struct.pack('B', set_member_rank),
|
value=struct.pack('B', set_member_rank),
|
||||||
)
|
)
|
||||||
characteristics.append(self.set_member_rank_characteristic)
|
characteristics.append(self.set_member_rank_characteristic)
|
||||||
|
|
||||||
super().__init__(characteristics)
|
super().__init__(characteristics)
|
||||||
|
|
||||||
def on_sirk_read(self, _connection: Optional[device.Connection]) -> bytes:
|
async def on_sirk_read(self, connection: Optional[device.Connection]) -> bytes:
|
||||||
if self.set_identity_resolving_key_type == SirkType.PLAINTEXT:
|
if self.set_identity_resolving_key_type == SirkType.PLAINTEXT:
|
||||||
return bytes([SirkType.PLAINTEXT]) + self.set_identity_resolving_key
|
sirk_bytes = self.set_identity_resolving_key
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError('TODO: Pending async Characteristic read.')
|
assert connection
|
||||||
|
|
||||||
|
if connection.transport == core.BT_LE_TRANSPORT:
|
||||||
|
key = await connection.device.get_long_term_key(
|
||||||
|
connection_handle=connection.handle, rand=b'', ediv=0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
key = await connection.device.get_link_key(connection.peer_address)
|
||||||
|
|
||||||
|
if not key:
|
||||||
|
raise RuntimeError('LTK or LinkKey is not present')
|
||||||
|
|
||||||
|
sirk_bytes = sef(key, self.set_identity_resolving_key)
|
||||||
|
|
||||||
|
return bytes([self.set_identity_resolving_key_type]) + sirk_bytes
|
||||||
|
|
||||||
def get_advertising_data(self) -> bytes:
|
def get_advertising_data(self) -> bytes:
|
||||||
return bytes(
|
return bytes(
|
||||||
@@ -203,3 +229,29 @@ class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
|
|||||||
gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC
|
gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC
|
||||||
):
|
):
|
||||||
self.set_member_rank = characteristics[0]
|
self.set_member_rank = characteristics[0]
|
||||||
|
|
||||||
|
async def read_set_identity_resolving_key(self) -> Tuple[SirkType, bytes]:
|
||||||
|
'''Reads SIRK and decrypts if encrypted.'''
|
||||||
|
response = await self.set_identity_resolving_key.read_value()
|
||||||
|
if len(response) != SET_IDENTITY_RESOLVING_KEY_LENGTH + 1:
|
||||||
|
raise RuntimeError('Invalid SIRK value')
|
||||||
|
|
||||||
|
sirk_type = SirkType(response[0])
|
||||||
|
if sirk_type == SirkType.PLAINTEXT:
|
||||||
|
sirk = response[1:]
|
||||||
|
else:
|
||||||
|
connection = self.service_proxy.client.connection
|
||||||
|
device = connection.device
|
||||||
|
if connection.transport == core.BT_LE_TRANSPORT:
|
||||||
|
key = await device.get_long_term_key(
|
||||||
|
connection_handle=connection.handle, rand=b'', ediv=0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
key = await device.get_link_key(connection.peer_address)
|
||||||
|
|
||||||
|
if not key:
|
||||||
|
raise RuntimeError('LTK or LinkKey is not present')
|
||||||
|
|
||||||
|
sirk = sef(key, response[1:])
|
||||||
|
|
||||||
|
return (sirk_type, sirk)
|
||||||
|
|||||||
+34
-22
@@ -118,8 +118,8 @@ CRC_TABLE = bytes([
|
|||||||
0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF
|
0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF
|
||||||
])
|
])
|
||||||
|
|
||||||
RFCOMM_DEFAULT_INITIAL_RX_CREDITS = 7
|
RFCOMM_DEFAULT_WINDOW_SIZE = 16
|
||||||
RFCOMM_DEFAULT_PREFERRED_MTU = 1280
|
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
|
||||||
@@ -438,20 +438,24 @@ class DLC(EventEmitter):
|
|||||||
multiplexer: Multiplexer,
|
multiplexer: Multiplexer,
|
||||||
dlci: int,
|
dlci: int,
|
||||||
max_frame_size: int,
|
max_frame_size: int,
|
||||||
initial_tx_credits: int,
|
window_size: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.multiplexer = multiplexer
|
self.multiplexer = multiplexer
|
||||||
self.dlci = dlci
|
self.dlci = dlci
|
||||||
self.rx_credits = RFCOMM_DEFAULT_INITIAL_RX_CREDITS
|
self.max_frame_size = max_frame_size
|
||||||
self.rx_threshold = self.rx_credits // 2
|
self.window_size = window_size
|
||||||
self.tx_credits = initial_tx_credits
|
self.rx_credits = window_size
|
||||||
|
self.rx_threshold = window_size // 2
|
||||||
|
self.tx_credits = window_size
|
||||||
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.sink = None
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
|
self.drained = asyncio.Event()
|
||||||
|
self.drained.set()
|
||||||
|
|
||||||
# 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
|
||||||
@@ -534,14 +538,15 @@ class DLC(EventEmitter):
|
|||||||
f'[{self.dlci}] {len(data)} bytes, '
|
f'[{self.dlci}] {len(data)} bytes, '
|
||||||
f'rx_credits={self.rx_credits}: {data.hex()}'
|
f'rx_credits={self.rx_credits}: {data.hex()}'
|
||||||
)
|
)
|
||||||
if len(data) and self.sink:
|
if data:
|
||||||
self.sink(data) # pylint: disable=not-callable
|
if self.sink:
|
||||||
|
self.sink(data) # pylint: disable=not-callable
|
||||||
|
|
||||||
# Update the credits
|
# Update the credits
|
||||||
if self.rx_credits > 0:
|
if self.rx_credits > 0:
|
||||||
self.rx_credits -= 1
|
self.rx_credits -= 1
|
||||||
else:
|
else:
|
||||||
logger.warning(color('!!! received frame with no rx credits', 'red'))
|
logger.warning(color('!!! received frame with no rx credits', 'red'))
|
||||||
|
|
||||||
# Check if there's anything to send (including credits)
|
# Check if there's anything to send (including credits)
|
||||||
self.process_tx()
|
self.process_tx()
|
||||||
@@ -580,9 +585,9 @@ class DLC(EventEmitter):
|
|||||||
cl=0xE0,
|
cl=0xE0,
|
||||||
priority=7,
|
priority=7,
|
||||||
ack_timer=0,
|
ack_timer=0,
|
||||||
max_frame_size=RFCOMM_DEFAULT_PREFERRED_MTU,
|
max_frame_size=self.max_frame_size,
|
||||||
max_retransmissions=0,
|
max_retransmissions=0,
|
||||||
window_size=RFCOMM_DEFAULT_INITIAL_RX_CREDITS,
|
window_size=self.window_size,
|
||||||
)
|
)
|
||||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=0, data=bytes(pn))
|
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=0, data=bytes(pn))
|
||||||
logger.debug(f'>>> PN Response: {pn}')
|
logger.debug(f'>>> PN Response: {pn}')
|
||||||
@@ -591,7 +596,7 @@ class DLC(EventEmitter):
|
|||||||
|
|
||||||
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_threshold:
|
||||||
return RFCOMM_DEFAULT_INITIAL_RX_CREDITS - self.rx_credits
|
return self.window_size - self.rx_credits
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -631,6 +636,8 @@ class DLC(EventEmitter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
rx_credits_needed = 0
|
rx_credits_needed = 0
|
||||||
|
if not self.tx_buffer:
|
||||||
|
self.drained.set()
|
||||||
|
|
||||||
# Stream protocol
|
# Stream protocol
|
||||||
def write(self, data: Union[bytes, str]) -> None:
|
def write(self, data: Union[bytes, str]) -> None:
|
||||||
@@ -643,11 +650,11 @@ class DLC(EventEmitter):
|
|||||||
raise ValueError('write only accept bytes or strings')
|
raise ValueError('write only accept bytes or strings')
|
||||||
|
|
||||||
self.tx_buffer += data
|
self.tx_buffer += data
|
||||||
|
self.drained.clear()
|
||||||
self.process_tx()
|
self.process_tx()
|
||||||
|
|
||||||
def drain(self) -> None:
|
async def drain(self) -> None:
|
||||||
# TODO
|
await self.drained.wait()
|
||||||
pass
|
|
||||||
|
|
||||||
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})'
|
||||||
@@ -843,7 +850,12 @@ class Multiplexer(EventEmitter):
|
|||||||
)
|
)
|
||||||
await self.disconnection_result
|
await self.disconnection_result
|
||||||
|
|
||||||
async def open_dlc(self, channel: int) -> DLC:
|
async def open_dlc(
|
||||||
|
self,
|
||||||
|
channel: int,
|
||||||
|
max_frame_size: int = RFCOMM_DEFAULT_MAX_FRAME_SIZE,
|
||||||
|
window_size: int = RFCOMM_DEFAULT_WINDOW_SIZE,
|
||||||
|
) -> 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:
|
||||||
raise InvalidStateError('open already in progress')
|
raise InvalidStateError('open already in progress')
|
||||||
@@ -855,9 +867,9 @@ class Multiplexer(EventEmitter):
|
|||||||
cl=0xF0,
|
cl=0xF0,
|
||||||
priority=7,
|
priority=7,
|
||||||
ack_timer=0,
|
ack_timer=0,
|
||||||
max_frame_size=RFCOMM_DEFAULT_PREFERRED_MTU,
|
max_frame_size=max_frame_size,
|
||||||
max_retransmissions=0,
|
max_retransmissions=0,
|
||||||
window_size=RFCOMM_DEFAULT_INITIAL_RX_CREDITS,
|
window_size=window_size,
|
||||||
)
|
)
|
||||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=1, data=bytes(pn))
|
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=1, data=bytes(pn))
|
||||||
logger.debug(f'>>> Sending MCC: {pn}')
|
logger.debug(f'>>> Sending MCC: {pn}')
|
||||||
|
|||||||
+4
-2
@@ -97,7 +97,8 @@ SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID = 0X000B
|
|||||||
SDP_ICON_URL_ATTRIBUTE_ID = 0X000C
|
SDP_ICON_URL_ATTRIBUTE_ID = 0X000C
|
||||||
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
|
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
|
||||||
|
|
||||||
# Attribute Identifier (cf. Assigned Numbers for Service Discovery)
|
|
||||||
|
# Profile-specific Attribute Identifiers (cf. Assigned Numbers for Service Discovery)
|
||||||
# used by AVRCP, HFP and A2DP
|
# used by AVRCP, HFP and A2DP
|
||||||
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID = 0x0311
|
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID = 0x0311
|
||||||
|
|
||||||
@@ -115,7 +116,8 @@ SDP_ATTRIBUTE_ID_NAMES = {
|
|||||||
SDP_DOCUMENTATION_URL_ATTRIBUTE_ID: 'SDP_DOCUMENTATION_URL_ATTRIBUTE_ID',
|
SDP_DOCUMENTATION_URL_ATTRIBUTE_ID: 'SDP_DOCUMENTATION_URL_ATTRIBUTE_ID',
|
||||||
SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID: 'SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID',
|
SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID: 'SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID',
|
||||||
SDP_ICON_URL_ATTRIBUTE_ID: 'SDP_ICON_URL_ATTRIBUTE_ID',
|
SDP_ICON_URL_ATTRIBUTE_ID: 'SDP_ICON_URL_ATTRIBUTE_ID',
|
||||||
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID: 'SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID'
|
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID: 'SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID',
|
||||||
|
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID: 'SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID',
|
||||||
}
|
}
|
||||||
|
|
||||||
SDP_PUBLIC_BROWSE_ROOT = core.UUID.from_16_bits(0x1002, 'PublicBrowseRoot')
|
SDP_PUBLIC_BROWSE_ROOT = core.UUID.from_16_bits(0x1002, 'PublicBrowseRoot')
|
||||||
|
|||||||
+5
-3
@@ -1134,8 +1134,10 @@ class Session:
|
|||||||
|
|
||||||
async def get_link_key_and_derive_ltk(self) -> None:
|
async def get_link_key_and_derive_ltk(self) -> None:
|
||||||
'''Retrieves BR/EDR Link Key from storage and derive it to LE LTK.'''
|
'''Retrieves BR/EDR Link Key from storage and derive it to LE LTK.'''
|
||||||
link_key = await self.manager.device.get_link_key(self.connection.peer_address)
|
self.link_key = await self.manager.device.get_link_key(
|
||||||
if link_key is None:
|
self.connection.peer_address
|
||||||
|
)
|
||||||
|
if self.link_key is None:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
'Try to derive LTK but host does not have the LK. Send a SMP_PAIRING_FAILED but the procedure will not be paused!'
|
'Try to derive LTK but host does not have the LK. Send a SMP_PAIRING_FAILED but the procedure will not be paused!'
|
||||||
)
|
)
|
||||||
@@ -1143,7 +1145,7 @@ class Session:
|
|||||||
SMP_CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED_ERROR
|
SMP_CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED_ERROR
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.ltk = self.derive_ltk(link_key, self.ct2)
|
self.ltk = self.derive_ltk(self.link_key, self.ct2)
|
||||||
|
|
||||||
def distribute_keys(self) -> None:
|
def distribute_keys(self) -> None:
|
||||||
# Distribute the keys as required
|
# Distribute the keys as required
|
||||||
|
|||||||
@@ -82,14 +82,13 @@ async def open_transport(name: str) -> Transport:
|
|||||||
|
|
||||||
scheme, *tail = name.split(':', 1)
|
scheme, *tail = name.split(':', 1)
|
||||||
spec = tail[0] if tail else None
|
spec = tail[0] if tail else None
|
||||||
|
metadata = None
|
||||||
if spec:
|
if spec:
|
||||||
# Metadata may precede the spec
|
# Metadata may precede the spec
|
||||||
if spec.startswith('['):
|
if spec.startswith('['):
|
||||||
metadata_str, *tail = spec[1:].split(']')
|
metadata_str, *tail = spec[1:].split(']')
|
||||||
spec = tail[0] if tail else None
|
spec = tail[0] if tail else None
|
||||||
metadata = dict([entry.split('=') for entry in metadata_str.split(',')])
|
metadata = dict([entry.split('=') for entry in metadata_str.split(',')])
|
||||||
else:
|
|
||||||
metadata = None
|
|
||||||
|
|
||||||
transport = await _open_transport(scheme, spec)
|
transport = await _open_transport(scheme, spec)
|
||||||
if metadata:
|
if metadata:
|
||||||
@@ -198,12 +197,13 @@ async def open_transport_or_link(name: str) -> Transport:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
if name.startswith('link-relay:'):
|
if name.startswith('link-relay:'):
|
||||||
|
logger.warning('Link Relay has been deprecated.')
|
||||||
from ..controller import Controller
|
from ..controller import Controller
|
||||||
from ..link import RemoteLink # lazy import
|
from ..link import RemoteLink # lazy import
|
||||||
|
|
||||||
link = RemoteLink(name[11:])
|
link = RemoteLink(name[11:])
|
||||||
await link.wait_until_connected()
|
await link.wait_until_connected()
|
||||||
controller = Controller('remote', link=link)
|
controller = Controller('remote', link=link) # type:ignore[arg-type]
|
||||||
|
|
||||||
class LinkTransport(Transport):
|
class LinkTransport(Transport):
|
||||||
async def close(self):
|
async def close(self):
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ async def open_usb_transport(spec: str) -> Transport:
|
|||||||
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER,
|
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER,
|
||||||
)
|
)
|
||||||
|
|
||||||
READ_SIZE = 1024
|
READ_SIZE = 4096
|
||||||
|
|
||||||
class UsbPacketSink:
|
class UsbPacketSink:
|
||||||
def __init__(self, device, acl_out):
|
def __init__(self, device, acl_out):
|
||||||
|
|||||||
+47
-19
@@ -17,9 +17,10 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import traceback
|
|
||||||
import collections
|
import collections
|
||||||
|
import enum
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
from typing import (
|
from typing import (
|
||||||
@@ -34,7 +35,7 @@ from typing import (
|
|||||||
Union,
|
Union,
|
||||||
overload,
|
overload,
|
||||||
)
|
)
|
||||||
from functools import wraps, partial
|
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
|
|
||||||
from .colors import color
|
from .colors import color
|
||||||
@@ -131,13 +132,14 @@ class EventWatcher:
|
|||||||
Args:
|
Args:
|
||||||
emitter: EventEmitter to watch
|
emitter: EventEmitter to watch
|
||||||
event: Event name
|
event: Event name
|
||||||
handler: (Optional) Event handler. When nothing is passed, this method works as a decorator.
|
handler: (Optional) Event handler. When nothing is passed, this method
|
||||||
|
works as a decorator.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def wrapper(f: _Handler) -> _Handler:
|
def wrapper(wrapped: _Handler) -> _Handler:
|
||||||
self.handlers.append((emitter, event, f))
|
self.handlers.append((emitter, event, wrapped))
|
||||||
emitter.on(event, f)
|
emitter.on(event, wrapped)
|
||||||
return f
|
return wrapped
|
||||||
|
|
||||||
return wrapper if handler is None else wrapper(handler)
|
return wrapper if handler is None else wrapper(handler)
|
||||||
|
|
||||||
@@ -157,13 +159,14 @@ class EventWatcher:
|
|||||||
Args:
|
Args:
|
||||||
emitter: EventEmitter to watch
|
emitter: EventEmitter to watch
|
||||||
event: Event name
|
event: Event name
|
||||||
handler: (Optional) Event handler. When nothing passed, this method works as a decorator.
|
handler: (Optional) Event handler. When nothing passed, this method works
|
||||||
|
as a decorator.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def wrapper(f: _Handler) -> _Handler:
|
def wrapper(wrapped: _Handler) -> _Handler:
|
||||||
self.handlers.append((emitter, event, f))
|
self.handlers.append((emitter, event, wrapped))
|
||||||
emitter.once(event, f)
|
emitter.once(event, wrapped)
|
||||||
return f
|
return wrapped
|
||||||
|
|
||||||
return wrapper if handler is None else wrapper(handler)
|
return wrapper if handler is None else wrapper(handler)
|
||||||
|
|
||||||
@@ -276,7 +279,7 @@ class AsyncRunner:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
@wraps(func)
|
@functools.wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
coroutine = func(*args, **kwargs)
|
coroutine = func(*args, **kwargs)
|
||||||
if queue is None:
|
if queue is None:
|
||||||
@@ -410,30 +413,35 @@ class FlowControlAsyncPipe:
|
|||||||
self.check_pump()
|
self.check_pump()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
async def async_call(function, *args, **kwargs):
|
async def async_call(function, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Immediately calls the function with provided args and kwargs, wrapping it in an async function.
|
Immediately calls the function with provided args and kwargs, wrapping it in an
|
||||||
Rust's `pyo3_asyncio` library needs functions to be marked async to properly inject a running loop.
|
async function.
|
||||||
|
Rust's `pyo3_asyncio` library needs functions to be marked async to properly inject
|
||||||
|
a running loop.
|
||||||
|
|
||||||
result = await async_call(some_function, ...)
|
result = await async_call(some_function, ...)
|
||||||
"""
|
"""
|
||||||
return function(*args, **kwargs)
|
return function(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def wrap_async(function):
|
def wrap_async(function):
|
||||||
"""
|
"""
|
||||||
Wraps the provided function in an async function.
|
Wraps the provided function in an async function.
|
||||||
"""
|
"""
|
||||||
return partial(async_call, function)
|
return functools.partial(async_call, function)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def deprecated(msg: str):
|
def deprecated(msg: str):
|
||||||
"""
|
"""
|
||||||
Throw deprecation warning before execution.
|
Throw deprecation warning before execution.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def wrapper(function):
|
def wrapper(function):
|
||||||
@wraps(function)
|
@functools.wraps(function)
|
||||||
def inner(*args, **kwargs):
|
def inner(*args, **kwargs):
|
||||||
warnings.warn(msg, DeprecationWarning)
|
warnings.warn(msg, DeprecationWarning)
|
||||||
return function(*args, **kwargs)
|
return function(*args, **kwargs)
|
||||||
@@ -443,13 +451,14 @@ def deprecated(msg: str):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def experimental(msg: str):
|
def experimental(msg: str):
|
||||||
"""
|
"""
|
||||||
Throws a future warning before execution.
|
Throws a future warning before execution.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def wrapper(function):
|
def wrapper(function):
|
||||||
@wraps(function)
|
@functools.wraps(function)
|
||||||
def inner(*args, **kwargs):
|
def inner(*args, **kwargs):
|
||||||
warnings.warn(msg, FutureWarning)
|
warnings.warn(msg, FutureWarning)
|
||||||
return function(*args, **kwargs)
|
return function(*args, **kwargs)
|
||||||
@@ -457,3 +466,22 @@ def experimental(msg: str):
|
|||||||
return inner
|
return inner
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class OpenIntEnum(enum.IntEnum):
|
||||||
|
"""
|
||||||
|
Subclass of enum.IntEnum that can hold integer values outside the set of
|
||||||
|
predefined values. This is convenient for implementing protocols where some
|
||||||
|
integer constants may be added over time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _missing_(cls, value):
|
||||||
|
if not isinstance(value, int):
|
||||||
|
return None
|
||||||
|
|
||||||
|
obj = int.__new__(cls, value)
|
||||||
|
obj._value_ = value
|
||||||
|
obj._name_ = f"{cls.__name__}[{value}]"
|
||||||
|
return obj
|
||||||
|
|||||||
@@ -7,16 +7,36 @@ throughput and/or latency between two devices.
|
|||||||
# General Usage
|
# General Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
Usage: bench.py [OPTIONS] COMMAND [ARGS]...
|
Usage: bumble-bench [OPTIONS] COMMAND [ARGS]...
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--device-config FILENAME Device configuration file
|
--device-config FILENAME Device configuration file
|
||||||
--role [sender|receiver|ping|pong]
|
--role [sender|receiver|ping|pong]
|
||||||
--mode [gatt-client|gatt-server|l2cap-client|l2cap-server|rfcomm-client|rfcomm-server]
|
--mode [gatt-client|gatt-server|l2cap-client|l2cap-server|rfcomm-client|rfcomm-server]
|
||||||
--att-mtu MTU GATT MTU (gatt-client mode) [23<=x<=517]
|
--att-mtu MTU GATT MTU (gatt-client mode) [23<=x<=517]
|
||||||
-s, --packet-size SIZE Packet size (server role) [8<=x<=4096]
|
--extended-data-length TEXT Request a data length upon connection,
|
||||||
-c, --packet-count COUNT Packet count (server role)
|
specified as tx_octets/tx_time
|
||||||
-sd, --start-delay SECONDS Start delay (server role)
|
--rfcomm-channel INTEGER RFComm channel to use
|
||||||
|
--rfcomm-uuid TEXT RFComm service UUID to use (ignored if
|
||||||
|
--rfcomm-channel is not 0)
|
||||||
|
--l2cap-psm INTEGER L2CAP PSM to use
|
||||||
|
--l2cap-mtu INTEGER L2CAP MTU to use
|
||||||
|
--l2cap-mps INTEGER L2CAP MPS to use
|
||||||
|
--l2cap-max-credits INTEGER L2CAP maximum number of credits allowed for
|
||||||
|
the peer
|
||||||
|
-s, --packet-size SIZE Packet size (client or ping role)
|
||||||
|
[8<=x<=4096]
|
||||||
|
-c, --packet-count COUNT Packet count (client or ping role)
|
||||||
|
-sd, --start-delay SECONDS Start delay (client or ping role)
|
||||||
|
--repeat N Repeat the run N times (client and ping
|
||||||
|
roles)(0, which is the fault, to run just
|
||||||
|
once)
|
||||||
|
--repeat-delay SECONDS Delay, in seconds, between repeats
|
||||||
|
--pace MILLISECONDS Wait N milliseconds between packets (0,
|
||||||
|
which is the fault, to send as fast as
|
||||||
|
possible)
|
||||||
|
--linger Don't exit at the end of a run (server and
|
||||||
|
pong roles)
|
||||||
--help Show this message and exit.
|
--help Show this message and exit.
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
@@ -35,17 +55,18 @@ Options:
|
|||||||
--connection-interval, --ci CONNECTION_INTERVAL
|
--connection-interval, --ci CONNECTION_INTERVAL
|
||||||
Connection interval (in ms)
|
Connection interval (in ms)
|
||||||
--phy [1m|2m|coded] PHY to use
|
--phy [1m|2m|coded] PHY to use
|
||||||
|
--authenticate Authenticate (RFComm only)
|
||||||
|
--encrypt Encrypt the connection (RFComm only)
|
||||||
--help Show this message and exit.
|
--help Show this message and exit.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To test once device against another, one of the two devices must be running
|
||||||
To test once device against another, one of the two devices must be running
|
|
||||||
the ``peripheral`` command and the other the ``central`` command. The device
|
the ``peripheral`` command and the other the ``central`` command. The device
|
||||||
running the ``peripheral`` command will accept connections from the device
|
running the ``peripheral`` command will accept connections from the device
|
||||||
running the ``central`` command.
|
running the ``central`` command.
|
||||||
When using Bluetooth LE (all modes except for ``rfcomm-server`` and ``rfcomm-client``utils),
|
When using Bluetooth LE (all modes except for ``rfcomm-server`` and ``rfcomm-client``utils),
|
||||||
the default addresses configured in the tool should be sufficient. But when using
|
the default addresses configured in the tool should be sufficient. But when using
|
||||||
Bluetooth Classic, the address of the Peripheral must be specified on the Central
|
Bluetooth Classic, the address of the Peripheral must be specified on the Central
|
||||||
using the ``--peripheral`` option. The address will be printed by the Peripheral when
|
using the ``--peripheral`` option. The address will be printed by the Peripheral when
|
||||||
it starts.
|
it starts.
|
||||||
|
|
||||||
@@ -83,7 +104,7 @@ the other on `usb:1`, and two consoles/terminals. We will run a command in each.
|
|||||||
$ bumble-bench central usb:1
|
$ bumble-bench central usb:1
|
||||||
```
|
```
|
||||||
|
|
||||||
In this default configuration, the Central runs a Sender, as a GATT client,
|
In this default configuration, the Central runs a Sender, as a GATT client,
|
||||||
connecting to the Peripheral running a Receiver, as a GATT server.
|
connecting to the Peripheral running a Receiver, as a GATT server.
|
||||||
|
|
||||||
!!! example "L2CAP Throughput"
|
!!! example "L2CAP Throughput"
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
Server Port <input id="port" type="text" value="8989"></input> <button id="connectButton" onclick="connect()">Connect</button><br>
|
||||||
|
<div id="socketState"></div>
|
||||||
|
<br>
|
||||||
|
<div id="buttons"></div><br>
|
||||||
|
<hr>
|
||||||
|
<button onclick="onGetPlayStatusButtonClicked()">Get Play Status</button><br>
|
||||||
|
<div id="getPlayStatusResponseTable"></div>
|
||||||
|
<hr>
|
||||||
|
<button onclick="onGetElementAttributesButtonClicked()">Get Element Attributes</button><br>
|
||||||
|
<div id="getElementAttributesResponseTable"></div>
|
||||||
|
<hr>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<b>VOLUME</b>:
|
||||||
|
<button onclick="onVolumeDownButtonClicked()">-</button>
|
||||||
|
<button onclick="onVolumeUpButtonClicked()">+</button>
|
||||||
|
<span id="volumeText"></span><br>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>PLAYBACK STATUS</b></td><td><span id="playbackStatusText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>POSITION</b></td><td><span id="positionText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>TRACK</b></td><td><span id="trackText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>ADDRESSED PLAYER</b></td><td><span id="addressedPlayerText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>UID COUNTER</b></td><td><span id="uidCounterText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>SUPPORTED EVENTS</b></td><td><span id="supportedEventsText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>PLAYER SETTINGS</b></td><td><div id="playerSettingsTable"></div></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<script>
|
||||||
|
const portInput = document.getElementById("port")
|
||||||
|
const connectButton = document.getElementById("connectButton")
|
||||||
|
const socketState = document.getElementById("socketState")
|
||||||
|
const volumeText = document.getElementById("volumeText")
|
||||||
|
const positionText = document.getElementById("positionText")
|
||||||
|
const trackText = document.getElementById("trackText")
|
||||||
|
const playbackStatusText = document.getElementById("playbackStatusText")
|
||||||
|
const addressedPlayerText = document.getElementById("addressedPlayerText")
|
||||||
|
const uidCounterText = document.getElementById("uidCounterText")
|
||||||
|
const supportedEventsText = document.getElementById("supportedEventsText")
|
||||||
|
const playerSettingsTable = document.getElementById("playerSettingsTable")
|
||||||
|
const getPlayStatusResponseTable = document.getElementById("getPlayStatusResponseTable")
|
||||||
|
const getElementAttributesResponseTable = document.getElementById("getElementAttributesResponseTable")
|
||||||
|
let socket
|
||||||
|
let volume = 0
|
||||||
|
|
||||||
|
const keyNames = [
|
||||||
|
"SELECT",
|
||||||
|
"UP",
|
||||||
|
"DOWN",
|
||||||
|
"LEFT",
|
||||||
|
"RIGHT",
|
||||||
|
"RIGHT_UP",
|
||||||
|
"RIGHT_DOWN",
|
||||||
|
"LEFT_UP",
|
||||||
|
"LEFT_DOWN",
|
||||||
|
"ROOT_MENU",
|
||||||
|
"SETUP_MENU",
|
||||||
|
"CONTENTS_MENU",
|
||||||
|
"FAVORITE_MENU",
|
||||||
|
"EXIT",
|
||||||
|
"NUMBER_0",
|
||||||
|
"NUMBER_1",
|
||||||
|
"NUMBER_2",
|
||||||
|
"NUMBER_3",
|
||||||
|
"NUMBER_4",
|
||||||
|
"NUMBER_5",
|
||||||
|
"NUMBER_6",
|
||||||
|
"NUMBER_7",
|
||||||
|
"NUMBER_8",
|
||||||
|
"NUMBER_9",
|
||||||
|
"DOT",
|
||||||
|
"ENTER",
|
||||||
|
"CLEAR",
|
||||||
|
"CHANNEL_UP",
|
||||||
|
"CHANNEL_DOWN",
|
||||||
|
"PREVIOUS_CHANNEL",
|
||||||
|
"SOUND_SELECT",
|
||||||
|
"INPUT_SELECT",
|
||||||
|
"DISPLAY_INFORMATION",
|
||||||
|
"HELP",
|
||||||
|
"PAGE_UP",
|
||||||
|
"PAGE_DOWN",
|
||||||
|
"POWER",
|
||||||
|
"VOLUME_UP",
|
||||||
|
"VOLUME_DOWN",
|
||||||
|
"MUTE",
|
||||||
|
"PLAY",
|
||||||
|
"STOP",
|
||||||
|
"PAUSE",
|
||||||
|
"RECORD",
|
||||||
|
"REWIND",
|
||||||
|
"FAST_FORWARD",
|
||||||
|
"EJECT",
|
||||||
|
"FORWARD",
|
||||||
|
"BACKWARD",
|
||||||
|
"ANGLE",
|
||||||
|
"SUBPICTURE",
|
||||||
|
"F1",
|
||||||
|
"F2",
|
||||||
|
"F3",
|
||||||
|
"F4",
|
||||||
|
"F5",
|
||||||
|
]
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
document.addEventListener('keyup', onKeyUp)
|
||||||
|
|
||||||
|
const buttons = document.getElementById("buttons")
|
||||||
|
keyNames.forEach(name => {
|
||||||
|
const button = document.createElement("BUTTON")
|
||||||
|
button.appendChild(document.createTextNode(name))
|
||||||
|
button.addEventListener("mousedown", event => {
|
||||||
|
send({type: 'send-key-down', key: name})
|
||||||
|
})
|
||||||
|
button.addEventListener("mouseup", event => {
|
||||||
|
send({type: 'send-key-up', key: name})
|
||||||
|
})
|
||||||
|
buttons.appendChild(button)
|
||||||
|
})
|
||||||
|
|
||||||
|
updateVolume(0)
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
socket = new WebSocket(`ws://localhost:${portInput.value}`);
|
||||||
|
socket.onopen = _ => {
|
||||||
|
socketState.innerText = 'OPEN'
|
||||||
|
connectButton.disabled = true
|
||||||
|
}
|
||||||
|
socket.onclose = _ => {
|
||||||
|
socketState.innerText = 'CLOSED'
|
||||||
|
connectButton.disabled = false
|
||||||
|
}
|
||||||
|
socket.onerror = (error) => {
|
||||||
|
socketState.innerText = 'ERROR'
|
||||||
|
console.log(`ERROR: ${error}`)
|
||||||
|
connectButton.disabled = false
|
||||||
|
}
|
||||||
|
socket.onmessage = (message) => {
|
||||||
|
onMessage(JSON.parse(message.data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(message) {
|
||||||
|
if (socket && socket.readyState == WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmsText(position) {
|
||||||
|
const h_1 = 1000 * 60 * 60
|
||||||
|
const h = Math.floor(position / h_1)
|
||||||
|
position -= h * h_1
|
||||||
|
const m_1 = 1000 * 60
|
||||||
|
const m = Math.floor(position / m_1)
|
||||||
|
position -= m * m_1
|
||||||
|
const s_1 = 1000
|
||||||
|
const s = Math.floor(position / s_1)
|
||||||
|
position -= s * s_1
|
||||||
|
|
||||||
|
return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}:${position}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTableHead(table, columns) {
|
||||||
|
let thead = table.createTHead()
|
||||||
|
let row = thead.insertRow()
|
||||||
|
for (let column of columns) {
|
||||||
|
let th = document.createElement("th")
|
||||||
|
let text = document.createTextNode(column)
|
||||||
|
th.appendChild(text)
|
||||||
|
row.appendChild(th)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTable(rows) {
|
||||||
|
const table = document.createElement("table")
|
||||||
|
|
||||||
|
if (rows.length != 0) {
|
||||||
|
columns = Object.keys(rows[0])
|
||||||
|
setTableHead(table, columns)
|
||||||
|
}
|
||||||
|
for (let element of rows) {
|
||||||
|
let row = table.insertRow()
|
||||||
|
for (key in element) {
|
||||||
|
let cell = row.insertCell()
|
||||||
|
let text = document.createTextNode(element[key])
|
||||||
|
cell.appendChild(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMessage(message) {
|
||||||
|
console.log(message)
|
||||||
|
if (message.type == "set-volume") {
|
||||||
|
updateVolume(message.params.volume)
|
||||||
|
} else if (message.type == "supported-events") {
|
||||||
|
supportedEventsText.innerText = JSON.stringify(message.params.events)
|
||||||
|
} else if (message.type == "playback-position-changed") {
|
||||||
|
positionText.innerText = hmsText(message.params.position)
|
||||||
|
} else if (message.type == "playback-status-changed") {
|
||||||
|
playbackStatusText.innerText = message.params.status
|
||||||
|
} else if (message.type == "player-settings-changed") {
|
||||||
|
playerSettingsTable.replaceChildren(message.params.settings)
|
||||||
|
} else if (message.type == "track-changed") {
|
||||||
|
trackText.innerText = message.params.identifier
|
||||||
|
} else if (message.type == "addressed-player-changed") {
|
||||||
|
addressedPlayerText.innerText = JSON.stringify(message.params.player)
|
||||||
|
} else if (message.type == "uids-changed") {
|
||||||
|
uidCounterText.innerText = message.params.uid_counter
|
||||||
|
} else if (message.type == "get-play-status-response") {
|
||||||
|
getPlayStatusResponseTable.replaceChildren(message.params)
|
||||||
|
} else if (message.type == "get-element-attributes-response") {
|
||||||
|
getElementAttributesResponseTable.replaceChildren(createTable(message.params))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVolume(newVolume) {
|
||||||
|
volume = newVolume
|
||||||
|
volumeText.innerText = `${volume} (${Math.round(100*volume/0x7F)}%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event) {
|
||||||
|
console.log(event)
|
||||||
|
send({ type: 'send-key-down', key: event.key })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyUp(event) {
|
||||||
|
console.log(event)
|
||||||
|
send({ type: 'send-key-up', key: event.key })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVolumeUpButtonClicked() {
|
||||||
|
updateVolume(Math.min(volume + 5, 0x7F))
|
||||||
|
send({ type: 'set-volume', volume })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVolumeDownButtonClicked() {
|
||||||
|
updateVolume(Math.max(volume - 5, 0))
|
||||||
|
send({ type: 'set-volume', volume })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGetPlayStatusButtonClicked() {
|
||||||
|
send({ type: 'get-play-status', volume })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGetElementAttributesButtonClicked() {
|
||||||
|
send({ type: 'get-element-attributes' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
# Copyright 2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# 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 json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
from bumble.device import Device
|
||||||
|
from bumble.transport import open_transport_or_link
|
||||||
|
from bumble.core import BT_BR_EDR_TRANSPORT
|
||||||
|
from bumble import avc
|
||||||
|
from bumble import avrcp
|
||||||
|
from bumble import avdtp
|
||||||
|
from bumble import a2dp
|
||||||
|
from bumble import utils
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def sdp_records():
|
||||||
|
a2dp_sink_service_record_handle = 0x00010001
|
||||||
|
avrcp_controller_service_record_handle = 0x00010002
|
||||||
|
avrcp_target_service_record_handle = 0x00010003
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
return {
|
||||||
|
a2dp_sink_service_record_handle: a2dp.make_audio_sink_service_sdp_records(
|
||||||
|
a2dp_sink_service_record_handle
|
||||||
|
),
|
||||||
|
avrcp_controller_service_record_handle: avrcp.make_controller_service_sdp_records(
|
||||||
|
avrcp_controller_service_record_handle
|
||||||
|
),
|
||||||
|
avrcp_target_service_record_handle: avrcp.make_target_service_sdp_records(
|
||||||
|
avrcp_controller_service_record_handle
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def codec_capabilities():
|
||||||
|
return avdtp.MediaCodecCapabilities(
|
||||||
|
media_type=avdtp.AVDTP_AUDIO_MEDIA_TYPE,
|
||||||
|
media_codec_type=a2dp.A2DP_SBC_CODEC_TYPE,
|
||||||
|
media_codec_information=a2dp.SbcMediaCodecInformation.from_lists(
|
||||||
|
sampling_frequencies=[48000, 44100, 32000, 16000],
|
||||||
|
channel_modes=[
|
||||||
|
a2dp.SBC_MONO_CHANNEL_MODE,
|
||||||
|
a2dp.SBC_DUAL_CHANNEL_MODE,
|
||||||
|
a2dp.SBC_STEREO_CHANNEL_MODE,
|
||||||
|
a2dp.SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||||
|
],
|
||||||
|
block_lengths=[4, 8, 12, 16],
|
||||||
|
subbands=[4, 8],
|
||||||
|
allocation_methods=[
|
||||||
|
a2dp.SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||||
|
a2dp.SBC_SNR_ALLOCATION_METHOD,
|
||||||
|
],
|
||||||
|
minimum_bitpool_value=2,
|
||||||
|
maximum_bitpool_value=53,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def on_avdtp_connection(server):
|
||||||
|
# Add a sink endpoint to the server
|
||||||
|
sink = server.add_sink(codec_capabilities())
|
||||||
|
sink.on('rtp_packet', on_rtp_packet)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def on_rtp_packet(packet):
|
||||||
|
print(f'RTP: {packet}')
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def on_avrcp_start(avrcp_protocol: avrcp.Protocol, websocket_server: WebSocketServer):
|
||||||
|
async def get_supported_events():
|
||||||
|
events = await avrcp_protocol.get_supported_events()
|
||||||
|
print("SUPPORTED EVENTS:", events)
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "supported-events",
|
||||||
|
"params": {"events": [event.name for event in events]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if avrcp.EventId.TRACK_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_track_changed())
|
||||||
|
|
||||||
|
if avrcp.EventId.PLAYBACK_STATUS_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_playback_status())
|
||||||
|
|
||||||
|
if avrcp.EventId.PLAYBACK_POS_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_playback_position())
|
||||||
|
|
||||||
|
if avrcp.EventId.PLAYER_APPLICATION_SETTING_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_player_application_settings())
|
||||||
|
|
||||||
|
if avrcp.EventId.AVAILABLE_PLAYERS_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_available_players())
|
||||||
|
|
||||||
|
if avrcp.EventId.ADDRESSED_PLAYER_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_addressed_player())
|
||||||
|
|
||||||
|
if avrcp.EventId.UIDS_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_uids())
|
||||||
|
|
||||||
|
if avrcp.EventId.VOLUME_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_volume())
|
||||||
|
|
||||||
|
utils.AsyncRunner.spawn(get_supported_events())
|
||||||
|
|
||||||
|
async def monitor_track_changed():
|
||||||
|
async for identifier in avrcp_protocol.monitor_track_changed():
|
||||||
|
print("TRACK CHANGED:", identifier.hex())
|
||||||
|
websocket_server.send_message(
|
||||||
|
{"type": "track-changed", "params": {"identifier": identifier.hex()}}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_playback_status():
|
||||||
|
async for playback_status in avrcp_protocol.monitor_playback_status():
|
||||||
|
print("PLAYBACK STATUS CHANGED:", playback_status.name)
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "playback-status-changed",
|
||||||
|
"params": {"status": playback_status.name},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_playback_position():
|
||||||
|
async for playback_position in avrcp_protocol.monitor_playback_position(
|
||||||
|
playback_interval=1
|
||||||
|
):
|
||||||
|
print("PLAYBACK POSITION CHANGED:", playback_position)
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "playback-position-changed",
|
||||||
|
"params": {"position": playback_position},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_player_application_settings():
|
||||||
|
async for settings in avrcp_protocol.monitor_player_application_settings():
|
||||||
|
print("PLAYER APPLICATION SETTINGS:", settings)
|
||||||
|
settings_as_dict = [
|
||||||
|
{"attribute": setting.attribute_id.name, "value": setting.value_id.name}
|
||||||
|
for setting in settings
|
||||||
|
]
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "player-settings-changed",
|
||||||
|
"params": {"settings": settings_as_dict},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_available_players():
|
||||||
|
async for _ in avrcp_protocol.monitor_available_players():
|
||||||
|
print("AVAILABLE PLAYERS CHANGED")
|
||||||
|
websocket_server.send_message(
|
||||||
|
{"type": "available-players-changed", "params": {}}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_addressed_player():
|
||||||
|
async for player in avrcp_protocol.monitor_addressed_player():
|
||||||
|
print("ADDRESSED PLAYER CHANGED")
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "addressed-player-changed",
|
||||||
|
"params": {
|
||||||
|
"player": {
|
||||||
|
"player_id": player.player_id,
|
||||||
|
"uid_counter": player.uid_counter,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_uids():
|
||||||
|
async for uid_counter in avrcp_protocol.monitor_uids():
|
||||||
|
print("UIDS CHANGED")
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "uids-changed",
|
||||||
|
"params": {
|
||||||
|
"uid_counter": uid_counter,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_volume():
|
||||||
|
async for volume in avrcp_protocol.monitor_volume():
|
||||||
|
print("VOLUME CHANGED:", volume)
|
||||||
|
websocket_server.send_message(
|
||||||
|
{"type": "volume-changed", "params": {"volume": volume}}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class WebSocketServer:
|
||||||
|
def __init__(
|
||||||
|
self, avrcp_protocol: avrcp.Protocol, avrcp_delegate: Delegate
|
||||||
|
) -> None:
|
||||||
|
self.socket = None
|
||||||
|
self.delegate = None
|
||||||
|
self.avrcp_protocol = avrcp_protocol
|
||||||
|
self.avrcp_delegate = avrcp_delegate
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
# pylint: disable-next=no-member
|
||||||
|
await websockets.serve(self.serve, 'localhost', 8989) # type: ignore
|
||||||
|
|
||||||
|
async def serve(self, socket, _path) -> None:
|
||||||
|
print('### WebSocket connected')
|
||||||
|
self.socket = socket
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
message = await socket.recv()
|
||||||
|
print('Received: ', str(message))
|
||||||
|
|
||||||
|
parsed = json.loads(message)
|
||||||
|
message_type = parsed['type']
|
||||||
|
if message_type == 'send-key-down':
|
||||||
|
await self.on_send_key_down(parsed)
|
||||||
|
elif message_type == 'send-key-up':
|
||||||
|
await self.on_send_key_up(parsed)
|
||||||
|
elif message_type == 'set-volume':
|
||||||
|
await self.on_set_volume(parsed)
|
||||||
|
elif message_type == 'get-play-status':
|
||||||
|
await self.on_get_play_status()
|
||||||
|
elif message_type == 'get-element-attributes':
|
||||||
|
await self.on_get_element_attributes()
|
||||||
|
except websockets.exceptions.ConnectionClosedOK:
|
||||||
|
self.socket = None
|
||||||
|
break
|
||||||
|
|
||||||
|
async def on_send_key_down(self, message: dict) -> None:
|
||||||
|
key = avc.PassThroughFrame.OperationId[message["key"]]
|
||||||
|
await self.avrcp_protocol.send_key_event(key, True)
|
||||||
|
|
||||||
|
async def on_send_key_up(self, message: dict) -> None:
|
||||||
|
key = avc.PassThroughFrame.OperationId[message["key"]]
|
||||||
|
await self.avrcp_protocol.send_key_event(key, False)
|
||||||
|
|
||||||
|
async def on_set_volume(self, message: dict) -> None:
|
||||||
|
volume = message["volume"]
|
||||||
|
self.avrcp_delegate.volume = volume
|
||||||
|
self.avrcp_protocol.notify_volume_changed(volume)
|
||||||
|
|
||||||
|
async def on_get_play_status(self) -> None:
|
||||||
|
play_status = await self.avrcp_protocol.get_play_status()
|
||||||
|
self.send_message(
|
||||||
|
{
|
||||||
|
"type": "get-play-status-response",
|
||||||
|
"params": {
|
||||||
|
"song_length": play_status.song_length,
|
||||||
|
"song_position": play_status.song_position,
|
||||||
|
"play_status": play_status.play_status.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_get_element_attributes(self) -> None:
|
||||||
|
attributes = await self.avrcp_protocol.get_element_attributes(
|
||||||
|
0,
|
||||||
|
[
|
||||||
|
avrcp.MediaAttributeId.TITLE,
|
||||||
|
avrcp.MediaAttributeId.ARTIST_NAME,
|
||||||
|
avrcp.MediaAttributeId.ALBUM_NAME,
|
||||||
|
avrcp.MediaAttributeId.TRACK_NUMBER,
|
||||||
|
avrcp.MediaAttributeId.TOTAL_NUMBER_OF_TRACKS,
|
||||||
|
avrcp.MediaAttributeId.GENRE,
|
||||||
|
avrcp.MediaAttributeId.PLAYING_TIME,
|
||||||
|
avrcp.MediaAttributeId.DEFAULT_COVER_ART,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.send_message(
|
||||||
|
{
|
||||||
|
"type": "get-element-attributes-response",
|
||||||
|
"params": [
|
||||||
|
{
|
||||||
|
"attribute_id": attribute.attribute_id.name,
|
||||||
|
"attribute_value": attribute.attribute_value,
|
||||||
|
}
|
||||||
|
for attribute in attributes
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_message(self, message: dict) -> None:
|
||||||
|
if self.socket is None:
|
||||||
|
print("no socket, dropping message")
|
||||||
|
return
|
||||||
|
serialized = json.dumps(message)
|
||||||
|
utils.AsyncRunner.spawn(self.socket.send(serialized))
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Delegate(avrcp.Delegate):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
[avrcp.EventId.VOLUME_CHANGED, avrcp.EventId.PLAYBACK_STATUS_CHANGED]
|
||||||
|
)
|
||||||
|
self.websocket_server = None
|
||||||
|
|
||||||
|
async def set_absolute_volume(self, volume: int) -> None:
|
||||||
|
await super().set_absolute_volume(volume)
|
||||||
|
if self.websocket_server is not None:
|
||||||
|
self.websocket_server.send_message(
|
||||||
|
{"type": "set-volume", "params": {"volume": volume}}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(
|
||||||
|
'Usage: run_avrcp_controller.py <device-config> <transport-spec> '
|
||||||
|
'<sbc-file> [<bt-addr>]'
|
||||||
|
)
|
||||||
|
print('example: run_avrcp_controller.py classic1.json usb:0')
|
||||||
|
return
|
||||||
|
|
||||||
|
print('<<< connecting to HCI...')
|
||||||
|
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
||||||
|
print('<<< connected')
|
||||||
|
|
||||||
|
# Create a device
|
||||||
|
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
||||||
|
device.classic_enabled = True
|
||||||
|
|
||||||
|
# Setup the SDP to expose the sink service
|
||||||
|
device.sdp_service_records = sdp_records()
|
||||||
|
|
||||||
|
# Start the controller
|
||||||
|
await device.power_on()
|
||||||
|
|
||||||
|
# Create a listener to wait for AVDTP connections
|
||||||
|
listener = avdtp.Listener(avdtp.Listener.create_registrar(device))
|
||||||
|
listener.on('connection', on_avdtp_connection)
|
||||||
|
|
||||||
|
avrcp_delegate = Delegate()
|
||||||
|
avrcp_protocol = avrcp.Protocol(avrcp_delegate)
|
||||||
|
avrcp_protocol.listen(device)
|
||||||
|
|
||||||
|
websocket_server = WebSocketServer(avrcp_protocol, avrcp_delegate)
|
||||||
|
avrcp_delegate.websocket_server = websocket_server
|
||||||
|
avrcp_protocol.on(
|
||||||
|
"start", lambda: on_avrcp_start(avrcp_protocol, websocket_server)
|
||||||
|
)
|
||||||
|
await websocket_server.start()
|
||||||
|
|
||||||
|
if len(sys.argv) >= 5:
|
||||||
|
# Connect to the peer
|
||||||
|
target_address = sys.argv[4]
|
||||||
|
print(f'=== Connecting to {target_address}...')
|
||||||
|
connection = await device.connect(
|
||||||
|
target_address, transport=BT_BR_EDR_TRANSPORT
|
||||||
|
)
|
||||||
|
print(f'=== Connected to {connection.peer_address}!')
|
||||||
|
|
||||||
|
# Request authentication
|
||||||
|
print('*** Authenticating...')
|
||||||
|
await connection.authenticate()
|
||||||
|
print('*** Authenticated')
|
||||||
|
|
||||||
|
# Enable encryption
|
||||||
|
print('*** Enabling encryption...')
|
||||||
|
await connection.encrypt()
|
||||||
|
print('*** Encryption on')
|
||||||
|
|
||||||
|
server = await avdtp.Protocol.connect(connection)
|
||||||
|
listener.set_server(connection, server)
|
||||||
|
sink = server.add_sink(codec_capabilities())
|
||||||
|
sink.on('rtp_packet', on_rtp_packet)
|
||||||
|
|
||||||
|
await avrcp_protocol.connect(connection)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Start being discoverable and connectable
|
||||||
|
await device.set_discoverable(True)
|
||||||
|
await device.set_connectable(True)
|
||||||
|
|
||||||
|
await asyncio.get_event_loop().create_future()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||||
|
asyncio.run(main())
|
||||||
@@ -590,12 +590,12 @@ async def main():
|
|||||||
def on_set_protocol_cb(protocol: int):
|
def on_set_protocol_cb(protocol: int):
|
||||||
retValue = hid_device.GetSetStatus()
|
retValue = hid_device.GetSetStatus()
|
||||||
# We do not support SET_PROTOCOL.
|
# We do not support SET_PROTOCOL.
|
||||||
print("SET_PROTOCOL report_id: " + str(protocol))
|
print(f"SET_PROTOCOL report_id: {protocol}")
|
||||||
retValue.status = hid_device.GetSetReturn.ERR_UNSUPPORTED_REQUEST
|
retValue.status = hid_device.GetSetReturn.ERR_UNSUPPORTED_REQUEST
|
||||||
return retValue
|
return retValue
|
||||||
|
|
||||||
def on_virtual_cable_unplug_cb():
|
def on_virtual_cable_unplug_cb():
|
||||||
print(f'Received Virtual Cable Unplug')
|
print('Received Virtual Cable Unplug')
|
||||||
asyncio.create_task(handle_virtual_cable_unplug())
|
asyncio.create_task(handle_virtual_cable_unplug())
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
package="com.github.google.bumble.btbench">
|
||||||
|
<uses-sdk android:minSdkVersion="30" android:targetSdkVersion="34" />
|
||||||
<!-- Request legacy Bluetooth permissions on older devices. -->
|
<!-- Request legacy Bluetooth permissions on older devices. -->
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||||
@@ -22,11 +22,10 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.BTBench"
|
android:theme="@style/Theme.BTBench"
|
||||||
tools:targetApi="31">
|
>
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
|
||||||
android:theme="@style/Theme.BTBench">
|
android:theme="@style/Theme.BTBench">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
+13
-7
@@ -28,8 +28,8 @@ private val Log = Logger.getLogger("btbench.l2cap-client")
|
|||||||
|
|
||||||
class L2capClient(
|
class L2capClient(
|
||||||
private val viewModel: AppViewModel,
|
private val viewModel: AppViewModel,
|
||||||
val bluetoothAdapter: BluetoothAdapter,
|
private val bluetoothAdapter: BluetoothAdapter,
|
||||||
val context: Context
|
private val context: Context
|
||||||
) {
|
) {
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun run() {
|
fun run() {
|
||||||
@@ -74,12 +74,18 @@ class L2capClient(
|
|||||||
gatt: BluetoothGatt?, status: Int, newState: Int
|
gatt: BluetoothGatt?, status: Int, newState: Int
|
||||||
) {
|
) {
|
||||||
if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
|
if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
|
||||||
gatt.setPreferredPhy(
|
if (viewModel.use2mPhy) {
|
||||||
BluetoothDevice.PHY_LE_2M_MASK,
|
gatt.setPreferredPhy(
|
||||||
BluetoothDevice.PHY_LE_2M_MASK,
|
BluetoothDevice.PHY_LE_2M_MASK,
|
||||||
BluetoothDevice.PHY_OPTION_NO_PREFERRED
|
BluetoothDevice.PHY_LE_2M_MASK,
|
||||||
)
|
BluetoothDevice.PHY_OPTION_NO_PREFERRED
|
||||||
|
)
|
||||||
|
}
|
||||||
gatt.readPhy()
|
gatt.readPhy()
|
||||||
|
|
||||||
|
// Request an MTU update, even though we don't use GATT, because Android
|
||||||
|
// won't request a larger link layer maximum data length otherwise.
|
||||||
|
gatt.requestMtu(517)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+34
-33
@@ -23,19 +23,20 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
val DEFAULT_RFCOMM_UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF6D3AE")
|
val DEFAULT_RFCOMM_UUID: UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF6D3AE")
|
||||||
const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
|
const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
|
||||||
const val DEFAULT_SENDER_PACKET_COUNT = 100
|
const val DEFAULT_SENDER_PACKET_COUNT = 100
|
||||||
const val DEFAULT_SENDER_PACKET_SIZE = 1024
|
const val DEFAULT_SENDER_PACKET_SIZE = 1024
|
||||||
|
const val DEFAULT_PSM = 128
|
||||||
|
|
||||||
class AppViewModel : ViewModel() {
|
class AppViewModel : ViewModel() {
|
||||||
private var preferences: SharedPreferences? = null
|
private var preferences: SharedPreferences? = null
|
||||||
var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
|
var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
|
||||||
var l2capPsm by mutableStateOf(0)
|
var l2capPsm by mutableIntStateOf(DEFAULT_PSM)
|
||||||
var use2mPhy by mutableStateOf(true)
|
var use2mPhy by mutableStateOf(true)
|
||||||
var mtu by mutableStateOf(0)
|
var mtu by mutableIntStateOf(0)
|
||||||
var rxPhy by mutableStateOf(0)
|
var rxPhy by mutableIntStateOf(0)
|
||||||
var txPhy by mutableStateOf(0)
|
var txPhy by mutableIntStateOf(0)
|
||||||
var senderPacketCountSlider by mutableFloatStateOf(0.0F)
|
var senderPacketCountSlider by mutableFloatStateOf(0.0F)
|
||||||
var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
|
var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
|
||||||
var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
|
var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
|
||||||
@@ -79,18 +80,18 @@ class AppViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateSenderPacketCountSlider() {
|
fun updateSenderPacketCountSlider() {
|
||||||
if (senderPacketCount <= 10) {
|
senderPacketCountSlider = if (senderPacketCount <= 10) {
|
||||||
senderPacketCountSlider = 0.0F
|
0.0F
|
||||||
} else if (senderPacketCount <= 50) {
|
} else if (senderPacketCount <= 50) {
|
||||||
senderPacketCountSlider = 0.2F
|
0.2F
|
||||||
} else if (senderPacketCount <= 100) {
|
} else if (senderPacketCount <= 100) {
|
||||||
senderPacketCountSlider = 0.4F
|
0.4F
|
||||||
} else if (senderPacketCount <= 500) {
|
} else if (senderPacketCount <= 500) {
|
||||||
senderPacketCountSlider = 0.6F
|
0.6F
|
||||||
} else if (senderPacketCount <= 1000) {
|
} else if (senderPacketCount <= 1000) {
|
||||||
senderPacketCountSlider = 0.8F
|
0.8F
|
||||||
} else {
|
} else {
|
||||||
senderPacketCountSlider = 1.0F
|
1.0F
|
||||||
}
|
}
|
||||||
|
|
||||||
with(preferences!!.edit()) {
|
with(preferences!!.edit()) {
|
||||||
@@ -100,18 +101,18 @@ class AppViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateSenderPacketCount() {
|
fun updateSenderPacketCount() {
|
||||||
if (senderPacketCountSlider < 0.1F) {
|
senderPacketCount = if (senderPacketCountSlider < 0.1F) {
|
||||||
senderPacketCount = 10
|
10
|
||||||
} else if (senderPacketCountSlider < 0.3F) {
|
} else if (senderPacketCountSlider < 0.3F) {
|
||||||
senderPacketCount = 50
|
50
|
||||||
} else if (senderPacketCountSlider < 0.5F) {
|
} else if (senderPacketCountSlider < 0.5F) {
|
||||||
senderPacketCount = 100
|
100
|
||||||
} else if (senderPacketCountSlider < 0.7F) {
|
} else if (senderPacketCountSlider < 0.7F) {
|
||||||
senderPacketCount = 500
|
500
|
||||||
} else if (senderPacketCountSlider < 0.9F) {
|
} else if (senderPacketCountSlider < 0.9F) {
|
||||||
senderPacketCount = 1000
|
1000
|
||||||
} else {
|
} else {
|
||||||
senderPacketCount = 10000
|
10000
|
||||||
}
|
}
|
||||||
|
|
||||||
with(preferences!!.edit()) {
|
with(preferences!!.edit()) {
|
||||||
@@ -121,18 +122,18 @@ class AppViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateSenderPacketSizeSlider() {
|
fun updateSenderPacketSizeSlider() {
|
||||||
if (senderPacketSize <= 16) {
|
senderPacketSizeSlider = if (senderPacketSize <= 16) {
|
||||||
senderPacketSizeSlider = 0.0F
|
0.0F
|
||||||
} else if (senderPacketSize <= 256) {
|
} else if (senderPacketSize <= 256) {
|
||||||
senderPacketSizeSlider = 0.02F
|
0.02F
|
||||||
} else if (senderPacketSize <= 512) {
|
} else if (senderPacketSize <= 512) {
|
||||||
senderPacketSizeSlider = 0.4F
|
0.4F
|
||||||
} else if (senderPacketSize <= 1024) {
|
} else if (senderPacketSize <= 1024) {
|
||||||
senderPacketSizeSlider = 0.6F
|
0.6F
|
||||||
} else if (senderPacketSize <= 2048) {
|
} else if (senderPacketSize <= 2048) {
|
||||||
senderPacketSizeSlider = 0.8F
|
0.8F
|
||||||
} else {
|
} else {
|
||||||
senderPacketSizeSlider = 1.0F
|
1.0F
|
||||||
}
|
}
|
||||||
|
|
||||||
with(preferences!!.edit()) {
|
with(preferences!!.edit()) {
|
||||||
@@ -142,18 +143,18 @@ class AppViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateSenderPacketSize() {
|
fun updateSenderPacketSize() {
|
||||||
if (senderPacketSizeSlider < 0.1F) {
|
senderPacketSize = if (senderPacketSizeSlider < 0.1F) {
|
||||||
senderPacketSize = 16
|
16
|
||||||
} else if (senderPacketSizeSlider < 0.3F) {
|
} else if (senderPacketSizeSlider < 0.3F) {
|
||||||
senderPacketSize = 256
|
256
|
||||||
} else if (senderPacketSizeSlider < 0.5F) {
|
} else if (senderPacketSizeSlider < 0.5F) {
|
||||||
senderPacketSize = 512
|
512
|
||||||
} else if (senderPacketSizeSlider < 0.7F) {
|
} else if (senderPacketSizeSlider < 0.7F) {
|
||||||
senderPacketSize = 1024
|
1024
|
||||||
} else if (senderPacketSizeSlider < 0.9F) {
|
} else if (senderPacketSizeSlider < 0.9F) {
|
||||||
senderPacketSize = 2048
|
2048
|
||||||
} else {
|
} else {
|
||||||
senderPacketSize = 4096
|
4096
|
||||||
}
|
}
|
||||||
|
|
||||||
with(preferences!!.edit()) {
|
with(preferences!!.edit()) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.github.google.bumble.remotehci"
|
applicationId = "com.github.google.bumble.remotehci"
|
||||||
minSdk = 26
|
minSdk = 29
|
||||||
targetSdk = 33
|
targetSdk = 33
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|||||||
+21
@@ -4,6 +4,7 @@ import android.hardware.bluetooth.V1_0.Status;
|
|||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
import android.os.RemoteException;
|
import android.os.RemoteException;
|
||||||
import android.os.ServiceManager;
|
import android.os.ServiceManager;
|
||||||
|
import android.os.Trace;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -53,6 +54,7 @@ class HciHidlHal extends android.hardware.bluetooth.V1_0.IBluetoothHciCallbacks.
|
|||||||
private final android.hardware.bluetooth.V1_0.IBluetoothHci mHciService;
|
private final android.hardware.bluetooth.V1_0.IBluetoothHci mHciService;
|
||||||
private final HciHalCallback mHciCallbacks;
|
private final HciHalCallback mHciCallbacks;
|
||||||
private int mInitializationStatus = -1;
|
private int mInitializationStatus = -1;
|
||||||
|
private final boolean mTracingEnabled = Trace.isEnabled();
|
||||||
|
|
||||||
|
|
||||||
public static HciHidlHal create(HciHalCallback hciCallbacks) {
|
public static HciHidlHal create(HciHalCallback hciCallbacks) {
|
||||||
@@ -89,6 +91,7 @@ class HciHidlHal extends android.hardware.bluetooth.V1_0.IBluetoothHciCallbacks.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map the status code.
|
// Map the status code.
|
||||||
|
Log.d(TAG, "Initialization status = " + mInitializationStatus);
|
||||||
switch (mInitializationStatus) {
|
switch (mInitializationStatus) {
|
||||||
case android.hardware.bluetooth.V1_0.Status.SUCCESS:
|
case android.hardware.bluetooth.V1_0.Status.SUCCESS:
|
||||||
return Status.SUCCESS;
|
return Status.SUCCESS;
|
||||||
@@ -108,6 +111,10 @@ class HciHidlHal extends android.hardware.bluetooth.V1_0.IBluetoothHciCallbacks.
|
|||||||
public void sendPacket(HciPacket.Type type, byte[] packet) {
|
public void sendPacket(HciPacket.Type type, byte[] packet) {
|
||||||
ArrayList<Byte> data = HciPacket.byteArrayToList(packet);
|
ArrayList<Byte> data = HciPacket.byteArrayToList(packet);
|
||||||
|
|
||||||
|
if (mTracingEnabled) {
|
||||||
|
Trace.beginAsyncSection("SEND_PACKET_TO_HAL", 1);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case COMMAND:
|
case COMMAND:
|
||||||
@@ -125,6 +132,10 @@ class HciHidlHal extends android.hardware.bluetooth.V1_0.IBluetoothHciCallbacks.
|
|||||||
} catch (RemoteException error) {
|
} catch (RemoteException error) {
|
||||||
Log.w(TAG, "failed to forward packet: " + error);
|
Log.w(TAG, "failed to forward packet: " + error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mTracingEnabled) {
|
||||||
|
Trace.endAsyncSection("SEND_PACKET_TO_HAL", 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -157,6 +168,7 @@ class HciAidlHal extends android.hardware.bluetooth.IBluetoothHciCallbacks.Stub
|
|||||||
private final android.hardware.bluetooth.IBluetoothHci mHciService;
|
private final android.hardware.bluetooth.IBluetoothHci mHciService;
|
||||||
private final HciHalCallback mHciCallbacks;
|
private final HciHalCallback mHciCallbacks;
|
||||||
private int mInitializationStatus = android.hardware.bluetooth.Status.SUCCESS;
|
private int mInitializationStatus = android.hardware.bluetooth.Status.SUCCESS;
|
||||||
|
private final boolean mTracingEnabled = Trace.isEnabled();
|
||||||
|
|
||||||
public static HciAidlHal create(HciHalCallback hciCallbacks) {
|
public static HciAidlHal create(HciHalCallback hciCallbacks) {
|
||||||
IBinder binder = ServiceManager.getService("android.hardware.bluetooth.IBluetoothHci/default");
|
IBinder binder = ServiceManager.getService("android.hardware.bluetooth.IBluetoothHci/default");
|
||||||
@@ -187,6 +199,7 @@ class HciAidlHal extends android.hardware.bluetooth.IBluetoothHciCallbacks.Stub
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map the status code.
|
// Map the status code.
|
||||||
|
Log.d(TAG, "Initialization status = " + mInitializationStatus);
|
||||||
switch (mInitializationStatus) {
|
switch (mInitializationStatus) {
|
||||||
case android.hardware.bluetooth.Status.SUCCESS:
|
case android.hardware.bluetooth.Status.SUCCESS:
|
||||||
return Status.SUCCESS;
|
return Status.SUCCESS;
|
||||||
@@ -208,6 +221,10 @@ class HciAidlHal extends android.hardware.bluetooth.IBluetoothHciCallbacks.Stub
|
|||||||
// HciHal methods.
|
// HciHal methods.
|
||||||
@Override
|
@Override
|
||||||
public void sendPacket(HciPacket.Type type, byte[] packet) {
|
public void sendPacket(HciPacket.Type type, byte[] packet) {
|
||||||
|
if (mTracingEnabled) {
|
||||||
|
Trace.beginAsyncSection("SEND_PACKET_TO_HAL", 1);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case COMMAND:
|
case COMMAND:
|
||||||
@@ -229,6 +246,10 @@ class HciAidlHal extends android.hardware.bluetooth.IBluetoothHciCallbacks.Stub
|
|||||||
} catch (RemoteException error) {
|
} catch (RemoteException error) {
|
||||||
Log.w(TAG, "failed to forward packet: " + error);
|
Log.w(TAG, "failed to forward packet: " + error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mTracingEnabled) {
|
||||||
|
Trace.endAsyncSection("SEND_PACKET_TO_HAL", 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IBluetoothHciCallbacks methods.
|
// IBluetoothHciCallbacks methods.
|
||||||
|
|||||||
+13
@@ -1,5 +1,6 @@
|
|||||||
package com.github.google.bumble.remotehci;
|
package com.github.google.bumble.remotehci;
|
||||||
|
|
||||||
|
import android.os.Trace;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -15,6 +16,7 @@ public class HciServer {
|
|||||||
private final int mPort;
|
private final int mPort;
|
||||||
private final Listener mListener;
|
private final Listener mListener;
|
||||||
private OutputStream mOutputStream;
|
private OutputStream mOutputStream;
|
||||||
|
private final boolean mTracingEnabled = Trace.isEnabled();
|
||||||
|
|
||||||
public interface Listener extends HciParser.Sink {
|
public interface Listener extends HciParser.Sink {
|
||||||
void onHostConnectionState(boolean connected);
|
void onHostConnectionState(boolean connected);
|
||||||
@@ -27,6 +29,8 @@ public class HciServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void run() throws IOException {
|
public void run() throws IOException {
|
||||||
|
Log.i(TAG, "Tracing enabled: " + mTracingEnabled);
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
try {
|
try {
|
||||||
loop();
|
loop();
|
||||||
@@ -42,6 +46,7 @@ public class HciServer {
|
|||||||
try (ServerSocket serverSocket = new ServerSocket(mPort)) {
|
try (ServerSocket serverSocket = new ServerSocket(mPort)) {
|
||||||
mListener.onMessage("Waiting for connection on port " + serverSocket.getLocalPort());
|
mListener.onMessage("Waiting for connection on port " + serverSocket.getLocalPort());
|
||||||
try (Socket clientSocket = serverSocket.accept()) {
|
try (Socket clientSocket = serverSocket.accept()) {
|
||||||
|
clientSocket.setTcpNoDelay(true);
|
||||||
mListener.onHostConnectionState(true);
|
mListener.onHostConnectionState(true);
|
||||||
mListener.onMessage("Connected");
|
mListener.onMessage("Connected");
|
||||||
HciParser parser = new HciParser(mListener);
|
HciParser parser = new HciParser(mListener);
|
||||||
@@ -72,6 +77,10 @@ public class HciServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void sendPacket(HciPacket.Type type, byte[] packet) {
|
public void sendPacket(HciPacket.Type type, byte[] packet) {
|
||||||
|
if (mTracingEnabled) {
|
||||||
|
Trace.beginAsyncSection("SEND_PACKET_FROM_HAL", 2);
|
||||||
|
}
|
||||||
|
|
||||||
// Create a combined data buffer so we can write it out in a single call.
|
// Create a combined data buffer so we can write it out in a single call.
|
||||||
byte[] data = new byte[packet.length + 1];
|
byte[] data = new byte[packet.length + 1];
|
||||||
data[0] = type.value;
|
data[0] = type.value;
|
||||||
@@ -88,5 +97,9 @@ public class HciServer {
|
|||||||
Log.d(TAG, "no client, dropping packet");
|
Log.d(TAG, "no client, dropping packet");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mTracingEnabled) {
|
||||||
|
Trace.endAsyncSection("SEND_PACKET_FROM_HAL", 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ console_scripts =
|
|||||||
bumble-ble-rpa-tool = bumble.apps.ble_rpa_tool:main
|
bumble-ble-rpa-tool = bumble.apps.ble_rpa_tool:main
|
||||||
bumble-console = bumble.apps.console:main
|
bumble-console = bumble.apps.console:main
|
||||||
bumble-controller-info = bumble.apps.controller_info:main
|
bumble-controller-info = bumble.apps.controller_info:main
|
||||||
|
bumble-controller-loopback = bumble.apps.controller_loopback:main
|
||||||
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
|
||||||
@@ -81,15 +82,15 @@ console_scripts =
|
|||||||
build =
|
build =
|
||||||
build >= 0.7
|
build >= 0.7
|
||||||
test =
|
test =
|
||||||
pytest >= 6.2
|
pytest >= 8.0
|
||||||
pytest-asyncio >= 0.17
|
pytest-asyncio == 0.21.1
|
||||||
pytest-html >= 3.2.0
|
pytest-html >= 3.2.0
|
||||||
coverage >= 6.4
|
coverage >= 6.4
|
||||||
development =
|
development =
|
||||||
black == 22.10
|
black == 22.10
|
||||||
grpcio-tools >= 1.57.0
|
grpcio-tools >= 1.57.0
|
||||||
invoke >= 1.7.3
|
invoke >= 1.7.3
|
||||||
mypy == 1.5.0
|
mypy == 1.8.0
|
||||||
nox >= 2022
|
nox >= 2022
|
||||||
pylint == 2.15.8
|
pylint == 2.15.8
|
||||||
pyyaml >= 6.0
|
pyyaml >= 6.0
|
||||||
@@ -98,7 +99,7 @@ development =
|
|||||||
types-protobuf >= 4.21.0
|
types-protobuf >= 4.21.0
|
||||||
avatar =
|
avatar =
|
||||||
pandora-avatar == 0.0.5
|
pandora-avatar == 0.0.5
|
||||||
rootcanal == 1.3.0 ; python_version>='3.10'
|
rootcanal == 1.4.0 ; python_version>='3.10'
|
||||||
documentation =
|
documentation =
|
||||||
mkdocs >= 1.4.0
|
mkdocs >= 1.4.0
|
||||||
mkdocs-material >= 8.5.6
|
mkdocs-material >= 8.5.6
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# Copyright 2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# 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 struct
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bumble import core
|
||||||
|
from bumble import device
|
||||||
|
from bumble import host
|
||||||
|
from bumble import controller
|
||||||
|
from bumble import link
|
||||||
|
from bumble import avc
|
||||||
|
from bumble import avrcp
|
||||||
|
from bumble import avctp
|
||||||
|
from bumble.transport import common
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class TwoDevices:
|
||||||
|
def __init__(self):
|
||||||
|
self.connections = [None, None]
|
||||||
|
|
||||||
|
addresses = ['F0:F1:F2:F3:F4:F5', 'F5:F4:F3:F2:F1:F0']
|
||||||
|
self.link = link.LocalLink()
|
||||||
|
self.controllers = [
|
||||||
|
controller.Controller('C1', link=self.link, public_address=addresses[0]),
|
||||||
|
controller.Controller('C2', link=self.link, public_address=addresses[1]),
|
||||||
|
]
|
||||||
|
self.devices = [
|
||||||
|
device.Device(
|
||||||
|
address=addresses[0],
|
||||||
|
host=host.Host(
|
||||||
|
self.controllers[0], common.AsyncPipeSink(self.controllers[0])
|
||||||
|
),
|
||||||
|
),
|
||||||
|
device.Device(
|
||||||
|
address=addresses[1],
|
||||||
|
host=host.Host(
|
||||||
|
self.controllers[1], common.AsyncPipeSink(self.controllers[1])
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
self.devices[0].classic_enabled = True
|
||||||
|
self.devices[1].classic_enabled = True
|
||||||
|
self.connections = [None, None]
|
||||||
|
self.protocols = [None, None]
|
||||||
|
|
||||||
|
def on_connection(self, which, connection):
|
||||||
|
self.connections[which] = connection
|
||||||
|
|
||||||
|
async def setup_connections(self):
|
||||||
|
await self.devices[0].power_on()
|
||||||
|
await self.devices[1].power_on()
|
||||||
|
|
||||||
|
self.connections = await asyncio.gather(
|
||||||
|
self.devices[0].connect(
|
||||||
|
self.devices[1].public_address, core.BT_BR_EDR_TRANSPORT
|
||||||
|
),
|
||||||
|
self.devices[1].accept(self.devices[0].public_address),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.protocols = [avrcp.Protocol(), avrcp.Protocol()]
|
||||||
|
self.protocols[0].listen(self.devices[1])
|
||||||
|
await self.protocols[1].connect(self.connections[0])
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_frame_parser():
|
||||||
|
with pytest.raises(ValueError) as error:
|
||||||
|
avc.Frame.from_bytes(bytes.fromhex("11480000"))
|
||||||
|
|
||||||
|
x = bytes.fromhex("014D0208")
|
||||||
|
frame = avc.Frame.from_bytes(x)
|
||||||
|
assert frame.subunit_type == avc.Frame.SubunitType.PANEL
|
||||||
|
assert frame.subunit_id == 7
|
||||||
|
assert frame.opcode == 8
|
||||||
|
|
||||||
|
x = bytes.fromhex("014DFF0108")
|
||||||
|
frame = avc.Frame.from_bytes(x)
|
||||||
|
assert frame.subunit_type == avc.Frame.SubunitType.PANEL
|
||||||
|
assert frame.subunit_id == 260
|
||||||
|
assert frame.opcode == 8
|
||||||
|
|
||||||
|
x = bytes.fromhex("0148000019581000000103")
|
||||||
|
|
||||||
|
frame = avc.Frame.from_bytes(x)
|
||||||
|
|
||||||
|
assert isinstance(frame, avc.CommandFrame)
|
||||||
|
assert frame.ctype == avc.CommandFrame.CommandType.STATUS
|
||||||
|
assert frame.subunit_type == avc.Frame.SubunitType.PANEL
|
||||||
|
assert frame.subunit_id == 0
|
||||||
|
assert frame.opcode == 0
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_vendor_dependent_command():
|
||||||
|
x = bytes.fromhex("0148000019581000000103")
|
||||||
|
frame = avc.Frame.from_bytes(x)
|
||||||
|
assert isinstance(frame, avc.VendorDependentCommandFrame)
|
||||||
|
assert frame.company_id == 0x1958
|
||||||
|
assert frame.vendor_dependent_data == bytes.fromhex("1000000103")
|
||||||
|
|
||||||
|
frame = avc.VendorDependentCommandFrame(
|
||||||
|
avc.CommandFrame.CommandType.STATUS,
|
||||||
|
avc.Frame.SubunitType.PANEL,
|
||||||
|
0,
|
||||||
|
0x1958,
|
||||||
|
bytes.fromhex("1000000103"),
|
||||||
|
)
|
||||||
|
assert bytes(frame) == x
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_avctp_message_assembler():
|
||||||
|
received_message = []
|
||||||
|
|
||||||
|
def on_message(transaction_label, is_response, ipid, pid, payload):
|
||||||
|
received_message.append((transaction_label, is_response, ipid, pid, payload))
|
||||||
|
|
||||||
|
assembler = avctp.MessageAssembler(on_message)
|
||||||
|
|
||||||
|
payload = bytes.fromhex("01")
|
||||||
|
assembler.on_pdu(bytes([1 << 4 | 0b00 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload)
|
||||||
|
assert received_message
|
||||||
|
assert received_message[0] == (1, False, False, 0x1122, payload)
|
||||||
|
|
||||||
|
received_message = []
|
||||||
|
payload = bytes.fromhex("010203")
|
||||||
|
assembler.on_pdu(bytes([1 << 4 | 0b01 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload)
|
||||||
|
assert len(received_message) == 0
|
||||||
|
assembler.on_pdu(bytes([1 << 4 | 0b00 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload)
|
||||||
|
assert received_message
|
||||||
|
assert received_message[0] == (1, False, False, 0x1122, payload)
|
||||||
|
|
||||||
|
received_message = []
|
||||||
|
payload = bytes.fromhex("010203")
|
||||||
|
assembler.on_pdu(
|
||||||
|
bytes([1 << 4 | 0b01 << 2 | 1 << 1 | 0, 3, 0x11, 0x22]) + payload[0:1]
|
||||||
|
)
|
||||||
|
assembler.on_pdu(
|
||||||
|
bytes([1 << 4 | 0b10 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload[1:2]
|
||||||
|
)
|
||||||
|
assembler.on_pdu(
|
||||||
|
bytes([1 << 4 | 0b11 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload[2:3]
|
||||||
|
)
|
||||||
|
assert received_message
|
||||||
|
assert received_message[0] == (1, False, False, 0x1122, payload)
|
||||||
|
|
||||||
|
# received_message = []
|
||||||
|
# parameter = bytes.fromhex("010203")
|
||||||
|
# assembler.on_pdu(struct.pack(">BBH", 0x10, 0b11, len(parameter)) + parameter)
|
||||||
|
# assert len(received_message) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_avrcp_pdu_assembler():
|
||||||
|
received_pdus = []
|
||||||
|
|
||||||
|
def on_pdu(pdu_id, parameter):
|
||||||
|
received_pdus.append((pdu_id, parameter))
|
||||||
|
|
||||||
|
assembler = avrcp.PduAssembler(on_pdu)
|
||||||
|
|
||||||
|
parameter = bytes.fromhex("01")
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b00, len(parameter)) + parameter)
|
||||||
|
assert received_pdus
|
||||||
|
assert received_pdus[0] == (0x10, parameter)
|
||||||
|
|
||||||
|
received_pdus = []
|
||||||
|
parameter = bytes.fromhex("010203")
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b01, len(parameter)) + parameter)
|
||||||
|
assert len(received_pdus) == 0
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b00, len(parameter)) + parameter)
|
||||||
|
assert received_pdus
|
||||||
|
assert received_pdus[0] == (0x10, parameter)
|
||||||
|
|
||||||
|
received_pdus = []
|
||||||
|
parameter = bytes.fromhex("010203")
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b01, 1) + parameter[0:1])
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b10, 1) + parameter[1:2])
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b11, 1) + parameter[2:3])
|
||||||
|
assert received_pdus
|
||||||
|
assert received_pdus[0] == (0x10, parameter)
|
||||||
|
|
||||||
|
received_pdus = []
|
||||||
|
parameter = bytes.fromhex("010203")
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b11, len(parameter)) + parameter)
|
||||||
|
assert len(received_pdus) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_passthrough_commands():
|
||||||
|
play_pressed = avc.PassThroughCommandFrame(
|
||||||
|
avc.CommandFrame.CommandType.CONTROL,
|
||||||
|
avc.CommandFrame.SubunitType.PANEL,
|
||||||
|
0,
|
||||||
|
avc.PassThroughCommandFrame.StateFlag.PRESSED,
|
||||||
|
avc.PassThroughCommandFrame.OperationId.PLAY,
|
||||||
|
b'',
|
||||||
|
)
|
||||||
|
|
||||||
|
play_pressed_bytes = bytes(play_pressed)
|
||||||
|
parsed = avc.Frame.from_bytes(play_pressed_bytes)
|
||||||
|
assert isinstance(parsed, avc.PassThroughCommandFrame)
|
||||||
|
assert parsed.operation_id == avc.PassThroughCommandFrame.OperationId.PLAY
|
||||||
|
assert bytes(parsed) == play_pressed_bytes
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_supported_events():
|
||||||
|
two_devices = TwoDevices()
|
||||||
|
await two_devices.setup_connections()
|
||||||
|
|
||||||
|
supported_events = await two_devices.protocols[0].get_supported_events()
|
||||||
|
assert supported_events == []
|
||||||
|
|
||||||
|
delegate1 = avrcp.Delegate([avrcp.EventId.VOLUME_CHANGED])
|
||||||
|
two_devices.protocols[0].delegate = delegate1
|
||||||
|
supported_events = await two_devices.protocols[1].get_supported_events()
|
||||||
|
assert supported_events == [avrcp.EventId.VOLUME_CHANGED]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if __name__ == '__main__':
|
||||||
|
test_frame_parser()
|
||||||
|
test_vendor_dependent_command()
|
||||||
|
test_avctp_message_assembler()
|
||||||
|
test_avrcp_pdu_assembler()
|
||||||
|
test_passthrough_commands()
|
||||||
|
test_get_supported_events()
|
||||||
+15
-6
@@ -20,6 +20,7 @@ import os
|
|||||||
import pytest
|
import pytest
|
||||||
import struct
|
import struct
|
||||||
import logging
|
import logging
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from bumble import device
|
from bumble import device
|
||||||
from bumble.profiles import csip
|
from bumble.profiles import csip
|
||||||
@@ -68,14 +69,18 @@ def test_sef():
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_csis():
|
@pytest.mark.parametrize(
|
||||||
|
'sirk_type,', [(csip.SirkType.ENCRYPTED), (csip.SirkType.PLAINTEXT)]
|
||||||
|
)
|
||||||
|
async def test_csis(sirk_type):
|
||||||
SIRK = bytes.fromhex('2f62c8ae41867d1bb619e788a2605faa')
|
SIRK = bytes.fromhex('2f62c8ae41867d1bb619e788a2605faa')
|
||||||
|
LTK = bytes.fromhex('2f62c8ae41867d1bb619e788a2605faa')
|
||||||
|
|
||||||
devices = TwoDevices()
|
devices = TwoDevices()
|
||||||
devices[0].add_service(
|
devices[0].add_service(
|
||||||
csip.CoordinatedSetIdentificationService(
|
csip.CoordinatedSetIdentificationService(
|
||||||
set_identity_resolving_key=SIRK,
|
set_identity_resolving_key=SIRK,
|
||||||
set_identity_resolving_key_type=csip.SirkType.PLAINTEXT,
|
set_identity_resolving_key_type=sirk_type,
|
||||||
coordinated_set_size=2,
|
coordinated_set_size=2,
|
||||||
set_member_lock=csip.MemberLock.UNLOCKED,
|
set_member_lock=csip.MemberLock.UNLOCKED,
|
||||||
set_member_rank=0,
|
set_member_rank=0,
|
||||||
@@ -83,15 +88,19 @@ async def test_csis():
|
|||||||
)
|
)
|
||||||
|
|
||||||
await devices.setup_connection()
|
await devices.setup_connection()
|
||||||
|
|
||||||
|
# Mock encryption.
|
||||||
|
devices.connections[0].encryption = 1
|
||||||
|
devices.connections[1].encryption = 1
|
||||||
|
devices[0].get_long_term_key = mock.AsyncMock(return_value=LTK)
|
||||||
|
devices[1].get_long_term_key = mock.AsyncMock(return_value=LTK)
|
||||||
|
|
||||||
peer = device.Peer(devices.connections[1])
|
peer = device.Peer(devices.connections[1])
|
||||||
csis_client = await peer.discover_service_and_create_proxy(
|
csis_client = await peer.discover_service_and_create_proxy(
|
||||||
csip.CoordinatedSetIdentificationProxy
|
csip.CoordinatedSetIdentificationProxy
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (
|
assert await csis_client.read_set_identity_resolving_key() == (sirk_type, SIRK)
|
||||||
await csis_client.set_identity_resolving_key.read_value()
|
|
||||||
== bytes([csip.SirkType.PLAINTEXT]) + SIRK
|
|
||||||
)
|
|
||||||
assert await csis_client.coordinated_set_size.read_value() == struct.pack('B', 2)
|
assert await csis_client.coordinated_set_size.read_value() == struct.pack('B', 2)
|
||||||
assert await csis_client.set_member_lock.read_value() == struct.pack(
|
assert await csis_client.set_member_lock.read_value() == struct.pack(
|
||||||
'B', csip.MemberLock.UNLOCKED
|
'B', csip.MemberLock.UNLOCKED
|
||||||
|
|||||||
+68
-1
@@ -29,7 +29,7 @@ from bumble.core import (
|
|||||||
ConnectionParameters,
|
ConnectionParameters,
|
||||||
)
|
)
|
||||||
from bumble.device import Connection, Device
|
from bumble.device import Connection, Device
|
||||||
from bumble.host import Host
|
from bumble.host import AclPacketQueue, Host
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
|
HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
|
||||||
HCI_COMMAND_STATUS_PENDING,
|
HCI_COMMAND_STATUS_PENDING,
|
||||||
@@ -50,6 +50,8 @@ from bumble.gatt import (
|
|||||||
GATT_APPEARANCE_CHARACTERISTIC,
|
GATT_APPEARANCE_CHARACTERISTIC,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .test_utils import TwoDevices
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -73,6 +75,13 @@ async def test_device_connect_parallel():
|
|||||||
d1 = Device(host=Host(None, None))
|
d1 = Device(host=Host(None, None))
|
||||||
d2 = Device(host=Host(None, None))
|
d2 = Device(host=Host(None, None))
|
||||||
|
|
||||||
|
def _send(packet):
|
||||||
|
pass
|
||||||
|
|
||||||
|
d0.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
|
||||||
|
d1.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
|
||||||
|
d2.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
|
||||||
|
|
||||||
# enable classic
|
# enable classic
|
||||||
d0.classic_enabled = True
|
d0.classic_enabled = True
|
||||||
d1.classic_enabled = True
|
d1.classic_enabled = True
|
||||||
@@ -405,6 +414,64 @@ async def test_extended_advertising_disconnection(auto_restart):
|
|||||||
device.start_extended_advertising.assert_not_called()
|
device.start_extended_advertising.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_remote_le_features():
|
||||||
|
devices = TwoDevices()
|
||||||
|
await devices.setup_connection()
|
||||||
|
|
||||||
|
assert (await devices.connections[0].get_remote_le_features()) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cis():
|
||||||
|
devices = TwoDevices()
|
||||||
|
await devices.setup_connection()
|
||||||
|
|
||||||
|
peripheral_cis_futures = {}
|
||||||
|
|
||||||
|
def on_cis_request(
|
||||||
|
acl_connection: Connection,
|
||||||
|
cis_handle: int,
|
||||||
|
_cig_id: int,
|
||||||
|
_cis_id: int,
|
||||||
|
):
|
||||||
|
acl_connection.abort_on(
|
||||||
|
'disconnection', devices[1].accept_cis_request(cis_handle)
|
||||||
|
)
|
||||||
|
peripheral_cis_futures[cis_handle] = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
|
devices[1].on('cis_request', on_cis_request)
|
||||||
|
devices[1].on(
|
||||||
|
'cis_establishment',
|
||||||
|
lambda cis_link: peripheral_cis_futures[cis_link.handle].set_result(None),
|
||||||
|
)
|
||||||
|
|
||||||
|
cis_handles = await devices[0].setup_cig(
|
||||||
|
cig_id=1,
|
||||||
|
cis_id=[2, 3],
|
||||||
|
sdu_interval=(0, 0),
|
||||||
|
framing=0,
|
||||||
|
max_sdu=(0, 0),
|
||||||
|
retransmission_number=0,
|
||||||
|
max_transport_latency=(0, 0),
|
||||||
|
)
|
||||||
|
assert len(cis_handles) == 2
|
||||||
|
cis_links = await devices[0].create_cis(
|
||||||
|
[
|
||||||
|
(cis_handles[0], devices.connections[0].handle),
|
||||||
|
(cis_handles[1], devices.connections[0].handle),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await asyncio.gather(*peripheral_cis_futures.values())
|
||||||
|
assert len(cis_links) == 2
|
||||||
|
|
||||||
|
# TODO: Fix Host CIS support.
|
||||||
|
# await cis_links[0].disconnect()
|
||||||
|
# await cis_links[1].disconnect()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def test_gatt_services_with_gas():
|
def test_gatt_services_with_gas():
|
||||||
device = Device(host=Host(None, None))
|
device = Device(host=Host(None, None))
|
||||||
|
|||||||
+60
-1
@@ -23,9 +23,11 @@ import pytest
|
|||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from .test_utils import TwoDevices
|
from .test_utils import TwoDevices
|
||||||
|
from bumble import core
|
||||||
|
from bumble import device
|
||||||
from bumble import hfp
|
from bumble import hfp
|
||||||
from bumble import rfcomm
|
from bumble import rfcomm
|
||||||
|
from bumble import hci
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -87,6 +89,63 @@ async def test_slc():
|
|||||||
ag_task.cancel()
|
ag_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sco_setup():
|
||||||
|
devices = TwoDevices()
|
||||||
|
|
||||||
|
# Enable Classic connections
|
||||||
|
devices[0].classic_enabled = True
|
||||||
|
devices[1].classic_enabled = True
|
||||||
|
|
||||||
|
# Start
|
||||||
|
await devices[0].power_on()
|
||||||
|
await devices[1].power_on()
|
||||||
|
|
||||||
|
connections = await asyncio.gather(
|
||||||
|
devices[0].connect(
|
||||||
|
devices[1].public_address, transport=core.BT_BR_EDR_TRANSPORT
|
||||||
|
),
|
||||||
|
devices[1].accept(devices[0].public_address),
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_sco_request(_connection: device.Connection, _link_type: int):
|
||||||
|
connections[1].abort_on(
|
||||||
|
'disconnection',
|
||||||
|
devices[1].send_command(
|
||||||
|
hci.HCI_Enhanced_Accept_Synchronous_Connection_Request_Command(
|
||||||
|
bd_addr=connections[1].peer_address,
|
||||||
|
**hfp.ESCO_PARAMETERS[
|
||||||
|
hfp.DefaultCodecParameters.ESCO_CVSD_S1
|
||||||
|
].asdict(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
devices[1].on('sco_request', on_sco_request)
|
||||||
|
|
||||||
|
sco_connections = [
|
||||||
|
asyncio.get_running_loop().create_future(),
|
||||||
|
asyncio.get_running_loop().create_future(),
|
||||||
|
]
|
||||||
|
|
||||||
|
devices[0].on(
|
||||||
|
'sco_connection', lambda sco_link: sco_connections[0].set_result(sco_link)
|
||||||
|
)
|
||||||
|
devices[1].on(
|
||||||
|
'sco_connection', lambda sco_link: sco_connections[1].set_result(sco_link)
|
||||||
|
)
|
||||||
|
|
||||||
|
await devices[0].send_command(
|
||||||
|
hci.HCI_Enhanced_Setup_Synchronous_Connection_Command(
|
||||||
|
connection_handle=connections[0].handle,
|
||||||
|
**hfp.ESCO_PARAMETERS[hfp.DefaultCodecParameters.ESCO_CVSD_S1].asdict(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.gather(*sco_connections)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def run():
|
async def run():
|
||||||
await test_slc()
|
await test_slc()
|
||||||
|
|||||||
@@ -547,6 +547,13 @@ async def test_self_smp_over_classic():
|
|||||||
MockSmpSession.send_public_key_command.assert_not_called()
|
MockSmpSession.send_public_key_command.assert_not_called()
|
||||||
MockSmpSession.send_pairing_random_command.assert_not_called()
|
MockSmpSession.send_pairing_random_command.assert_not_called()
|
||||||
|
|
||||||
|
for i in range(2):
|
||||||
|
assert (
|
||||||
|
await two_devices.devices[i].keystore.get(
|
||||||
|
str(two_devices.connections[i].peer_address)
|
||||||
|
)
|
||||||
|
).link_key
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
+5
-4
@@ -29,17 +29,18 @@ class TwoDevices:
|
|||||||
self.connections = [None, None]
|
self.connections = [None, None]
|
||||||
|
|
||||||
self.link = LocalLink()
|
self.link = LocalLink()
|
||||||
|
addresses = ['F0:F1:F2:F3:F4:F5', 'F5:F4:F3:F2:F1:F0']
|
||||||
self.controllers = [
|
self.controllers = [
|
||||||
Controller('C1', link=self.link),
|
Controller('C1', link=self.link, public_address=addresses[0]),
|
||||||
Controller('C2', link=self.link),
|
Controller('C2', link=self.link, public_address=addresses[1]),
|
||||||
]
|
]
|
||||||
self.devices = [
|
self.devices = [
|
||||||
Device(
|
Device(
|
||||||
address=Address('F0:F1:F2:F3:F4:F5'),
|
address=Address(addresses[0]),
|
||||||
host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])),
|
host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])),
|
||||||
),
|
),
|
||||||
Device(
|
Device(
|
||||||
address=Address('F5:F4:F3:F2:F1:F0'),
|
address=Address(addresses[1]),
|
||||||
host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])),
|
host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
+35
-3
@@ -12,15 +12,20 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from bumble import utils
|
|
||||||
from pyee import EventEmitter
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from pyee import EventEmitter
|
||||||
|
|
||||||
|
from bumble import utils
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def test_on() -> None:
|
def test_on() -> None:
|
||||||
emitter = EventEmitter()
|
emitter = EventEmitter()
|
||||||
with contextlib.closing(utils.EventWatcher()) as context:
|
with contextlib.closing(utils.EventWatcher()) as context:
|
||||||
@@ -33,6 +38,7 @@ def test_on() -> None:
|
|||||||
assert mock.call_count == 1
|
assert mock.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def test_on_decorator() -> None:
|
def test_on_decorator() -> None:
|
||||||
emitter = EventEmitter()
|
emitter = EventEmitter()
|
||||||
with contextlib.closing(utils.EventWatcher()) as context:
|
with contextlib.closing(utils.EventWatcher()) as context:
|
||||||
@@ -48,6 +54,7 @@ def test_on_decorator() -> None:
|
|||||||
assert mock.call_count == 1
|
assert mock.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def test_multiple_handlers() -> None:
|
def test_multiple_handlers() -> None:
|
||||||
emitter = EventEmitter()
|
emitter = EventEmitter()
|
||||||
with contextlib.closing(utils.EventWatcher()) as context:
|
with contextlib.closing(utils.EventWatcher()) as context:
|
||||||
@@ -64,6 +71,30 @@ def test_multiple_handlers() -> None:
|
|||||||
mock.assert_called_once_with('b')
|
mock.assert_called_once_with('b')
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_open_int_enums():
|
||||||
|
class Foo(utils.OpenIntEnum):
|
||||||
|
FOO = 1
|
||||||
|
BAR = 2
|
||||||
|
BLA = 3
|
||||||
|
|
||||||
|
x = Foo(1)
|
||||||
|
assert x.name == "FOO"
|
||||||
|
assert x.value == 1
|
||||||
|
assert int(x) == 1
|
||||||
|
assert x == 1
|
||||||
|
assert x + 1 == 2
|
||||||
|
|
||||||
|
x = Foo(4)
|
||||||
|
assert x.name == "Foo[4]"
|
||||||
|
assert x.value == 4
|
||||||
|
assert int(x) == 4
|
||||||
|
assert x == 4
|
||||||
|
assert x + 1 == 5
|
||||||
|
|
||||||
|
print(list(Foo))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def run_tests():
|
def run_tests():
|
||||||
test_on()
|
test_on()
|
||||||
@@ -75,3 +106,4 @@ def run_tests():
|
|||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||||
run_tests()
|
run_tests()
|
||||||
|
test_open_int_enums()
|
||||||
|
|||||||
Reference in New Issue
Block a user