forked from auracaster/bumble_mirror
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1d10bc482 | |||
| f5baf51132 | |||
| f2dc8bd84e | |||
| 090309302f | |||
| 28e6229b24 | |||
| 1b66f03dbe | |||
| e34f6b5fd3 | |||
| 8a0482c947 | |||
| 938a189f3f | |||
| 2005b4a11b | |||
| 951fdc8bdd | |||
| 12af7a526c | |||
| 8781943646 | |||
| 7fbfdb634c | |||
| 9682077f6b | |||
| 22eb405fde | |||
| 593c61973f | |||
| ccff32102f | |||
| 851d62c6c9 | |||
| a5ac5f26e2 | |||
| 090158820f | |||
| 26e6650038 | |||
| c48568aabe | |||
| 1b33c9eb74 | |||
| 6633228975 | |||
| e9cba788a4 | |||
| 98822cfc6b | |||
| 97ad7e5741 | |||
| 71df062e07 | |||
| 049f9021e9 | |||
| 50eae2ef54 | |||
| c8883a7d0f | |||
| 51321caf5b | |||
| 51a94288e2 | |||
| 8758856e8c | |||
| deba181857 | |||
| c65188dcbf | |||
| 21d607898d | |||
| 2698d4534e | |||
| bbcd64286a | |||
| 9140afbf8c | |||
| 90a682c71b | |||
| e8737a8243 | |||
| 72fceca72e | |||
| 732294abbc | |||
| dc1204531e | |||
| 962114379c | |||
| e6913a3055 | |||
| e21d122aef | |||
| 58d4ab913a | |||
| 76bca03fe3 | |||
| f1e5c9e59e | |||
| ec82242462 | |||
| a4efdd3f3e | |||
| 69c6643bb8 | |||
| b8214bf948 | |||
| a9c62c44b3 | |||
| 7d0b4ef4e0 | |||
| 313340f1c6 | |||
| e8ed69fb09 | |||
| 16d5cf6770 | |||
| a2caf1deb2 | |||
| 01bfdd2c98 | |||
| 4a60df108a | |||
| ad48109748 | |||
| 1ceeccbbc0 | |||
| 44c51c13ac | |||
| 7507be1eab | |||
| cbe9446dcf | |||
| 174930399a | |||
| 35db4a4c93 | |||
| 1f3aee5566 | |||
| 256044a789 | |||
| 6205199d7f | |||
| e554bd1033 | |||
| 38981cefa1 | |||
| f2d601f411 | |||
| 6e7c64c1de | |||
| 565d51f4db | |||
| de8f3d9c1e | |||
| cde6d48690 | |||
| 02180088b3 | |||
| 90f49267d1 | |||
| 0e6d69cd7b | |||
| 9eccc583d5 | |||
| f4aeaa6eb3 |
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
python -m pip install ".[build,test,development]"
|
python -m pip install ".[build,test,development,pandora]"
|
||||||
- name: Check
|
- name: Check
|
||||||
run: |
|
run: |
|
||||||
invoke project.pre-commit
|
invoke project.pre-commit
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
- name: Install
|
- name: Install
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
python -m pip install .[avatar]
|
python -m pip install .[avatar,pandora]
|
||||||
- name: Rootcanal
|
- name: Rootcanal
|
||||||
run: nohup python -m rootcanal > rootcanal.log &
|
run: nohup python -m rootcanal > rootcanal.log &
|
||||||
- name: Test
|
- name: Test
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
||||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -46,8 +46,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [ "3.8", "3.9", "3.10", "3.11" ]
|
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
|
||||||
rust-version: [ "1.70.0", "stable" ]
|
rust-version: [ "1.76.0", "stable" ]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- name: Check out from Git
|
- name: Check out from Git
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ dist/
|
|||||||
docs/mkdocs/site
|
docs/mkdocs/site
|
||||||
test-results.xml
|
test-results.xml
|
||||||
__pycache__
|
__pycache__
|
||||||
|
# Vim
|
||||||
|
.*.sw*
|
||||||
# generated by setuptools_scm
|
# generated by setuptools_scm
|
||||||
bumble/_version.py
|
bumble/_version.py
|
||||||
.vscode/launch.json
|
.vscode/launch.json
|
||||||
|
.vscode/settings.json
|
||||||
/.idea
|
/.idea
|
||||||
venv/
|
venv/
|
||||||
.venv/
|
.venv/
|
||||||
|
# snoop logs
|
||||||
|
out/
|
||||||
|
|||||||
Vendored
+4
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"Abortable",
|
"Abortable",
|
||||||
|
"aiohttp",
|
||||||
"altsetting",
|
"altsetting",
|
||||||
"ansiblue",
|
"ansiblue",
|
||||||
"ansicyan",
|
"ansicyan",
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
"ansired",
|
"ansired",
|
||||||
"ansiyellow",
|
"ansiyellow",
|
||||||
"appendleft",
|
"appendleft",
|
||||||
|
"ascs",
|
||||||
"ASHA",
|
"ASHA",
|
||||||
"asyncio",
|
"asyncio",
|
||||||
"ATRAC",
|
"ATRAC",
|
||||||
@@ -43,6 +45,7 @@
|
|||||||
"keyup",
|
"keyup",
|
||||||
"levelname",
|
"levelname",
|
||||||
"libc",
|
"libc",
|
||||||
|
"liblc",
|
||||||
"libusb",
|
"libusb",
|
||||||
"MITM",
|
"MITM",
|
||||||
"MSBC",
|
"MSBC",
|
||||||
@@ -78,6 +81,7 @@
|
|||||||
"unmuted",
|
"unmuted",
|
||||||
"usbmodem",
|
"usbmodem",
|
||||||
"vhci",
|
"vhci",
|
||||||
|
"wasmtime",
|
||||||
"websockets",
|
"websockets",
|
||||||
"xcursor",
|
"xcursor",
|
||||||
"ycursor"
|
"ycursor"
|
||||||
|
|||||||
+86
-24
@@ -509,9 +509,11 @@ class Ping:
|
|||||||
packet = struct.pack(
|
packet = struct.pack(
|
||||||
'>bbI',
|
'>bbI',
|
||||||
PacketType.SEQUENCE,
|
PacketType.SEQUENCE,
|
||||||
PACKET_FLAG_LAST
|
(
|
||||||
if self.current_packet_index == self.tx_packet_count - 1
|
PACKET_FLAG_LAST
|
||||||
else 0,
|
if self.current_packet_index == self.tx_packet_count - 1
|
||||||
|
else 0
|
||||||
|
),
|
||||||
self.current_packet_index,
|
self.current_packet_index,
|
||||||
) + bytes(self.tx_packet_size - 6)
|
) + bytes(self.tx_packet_size - 6)
|
||||||
logging.info(color(f'Sending packet {self.current_packet_index}', 'yellow'))
|
logging.info(color(f'Sending packet {self.current_packet_index}', 'yellow'))
|
||||||
@@ -897,14 +899,26 @@ class L2capServer(StreamedPacketIO):
|
|||||||
# RfcommClient
|
# RfcommClient
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class RfcommClient(StreamedPacketIO):
|
class RfcommClient(StreamedPacketIO):
|
||||||
def __init__(self, device, channel, uuid, l2cap_mtu, max_frame_size, window_size):
|
def __init__(
|
||||||
|
self,
|
||||||
|
device,
|
||||||
|
channel,
|
||||||
|
uuid,
|
||||||
|
l2cap_mtu,
|
||||||
|
max_frame_size,
|
||||||
|
initial_credits,
|
||||||
|
max_credits,
|
||||||
|
credits_threshold,
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.device = device
|
self.device = device
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.uuid = uuid
|
self.uuid = uuid
|
||||||
self.l2cap_mtu = l2cap_mtu
|
self.l2cap_mtu = l2cap_mtu
|
||||||
self.max_frame_size = max_frame_size
|
self.max_frame_size = max_frame_size
|
||||||
self.window_size = window_size
|
self.initial_credits = initial_credits
|
||||||
|
self.max_credits = max_credits
|
||||||
|
self.credits_threshold = credits_threshold
|
||||||
self.rfcomm_session = None
|
self.rfcomm_session = None
|
||||||
self.ready = asyncio.Event()
|
self.ready = asyncio.Event()
|
||||||
|
|
||||||
@@ -938,12 +952,17 @@ class RfcommClient(StreamedPacketIO):
|
|||||||
logging.info(color(f'### Opening session for channel {channel}...', 'yellow'))
|
logging.info(color(f'### Opening session for channel {channel}...', 'yellow'))
|
||||||
try:
|
try:
|
||||||
dlc_options = {}
|
dlc_options = {}
|
||||||
if self.max_frame_size:
|
if self.max_frame_size is not None:
|
||||||
dlc_options['max_frame_size'] = self.max_frame_size
|
dlc_options['max_frame_size'] = self.max_frame_size
|
||||||
if self.window_size:
|
if self.initial_credits is not None:
|
||||||
dlc_options['window_size'] = self.window_size
|
dlc_options['initial_credits'] = self.initial_credits
|
||||||
rfcomm_session = await rfcomm_mux.open_dlc(channel, **dlc_options)
|
rfcomm_session = await rfcomm_mux.open_dlc(channel, **dlc_options)
|
||||||
logging.info(color(f'### Session open: {rfcomm_session}', 'yellow'))
|
logging.info(color(f'### Session open: {rfcomm_session}', 'yellow'))
|
||||||
|
if self.max_credits is not None:
|
||||||
|
rfcomm_session.rx_max_credits = self.max_credits
|
||||||
|
if self.credits_threshold is not None:
|
||||||
|
rfcomm_session.rx_credits_threshold = self.credits_threshold
|
||||||
|
|
||||||
except bumble.core.ConnectionError as error:
|
except bumble.core.ConnectionError as error:
|
||||||
logging.info(color(f'!!! Session open failed: {error}', 'red'))
|
logging.info(color(f'!!! Session open failed: {error}', 'red'))
|
||||||
await rfcomm_mux.disconnect()
|
await rfcomm_mux.disconnect()
|
||||||
@@ -967,8 +986,19 @@ class RfcommClient(StreamedPacketIO):
|
|||||||
# RfcommServer
|
# RfcommServer
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class RfcommServer(StreamedPacketIO):
|
class RfcommServer(StreamedPacketIO):
|
||||||
def __init__(self, device, channel, l2cap_mtu):
|
def __init__(
|
||||||
|
self,
|
||||||
|
device,
|
||||||
|
channel,
|
||||||
|
l2cap_mtu,
|
||||||
|
max_frame_size,
|
||||||
|
initial_credits,
|
||||||
|
max_credits,
|
||||||
|
credits_threshold,
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self.max_credits = max_credits
|
||||||
|
self.credits_threshold = credits_threshold
|
||||||
self.dlc = None
|
self.dlc = None
|
||||||
self.ready = asyncio.Event()
|
self.ready = asyncio.Event()
|
||||||
|
|
||||||
@@ -979,7 +1009,12 @@ class RfcommServer(StreamedPacketIO):
|
|||||||
rfcomm_server = bumble.rfcomm.Server(device, **server_options)
|
rfcomm_server = bumble.rfcomm.Server(device, **server_options)
|
||||||
|
|
||||||
# Listen for incoming DLC connections
|
# Listen for incoming DLC connections
|
||||||
channel_number = rfcomm_server.listen(self.on_dlc, channel)
|
dlc_options = {}
|
||||||
|
if max_frame_size is not None:
|
||||||
|
dlc_options['max_frame_size'] = max_frame_size
|
||||||
|
if initial_credits is not None:
|
||||||
|
dlc_options['initial_credits'] = initial_credits
|
||||||
|
channel_number = rfcomm_server.listen(self.on_dlc, channel, **dlc_options)
|
||||||
|
|
||||||
# Setup the SDP to advertise this channel
|
# Setup the SDP to advertise this channel
|
||||||
device.sdp_service_records = make_sdp_records(channel_number)
|
device.sdp_service_records = make_sdp_records(channel_number)
|
||||||
@@ -1002,6 +1037,10 @@ class RfcommServer(StreamedPacketIO):
|
|||||||
dlc.sink = self.on_packet
|
dlc.sink = self.on_packet
|
||||||
self.io_sink = dlc.write
|
self.io_sink = dlc.write
|
||||||
self.dlc = dlc
|
self.dlc = dlc
|
||||||
|
if self.max_credits is not None:
|
||||||
|
dlc.rx_max_credits = self.max_credits
|
||||||
|
if self.credits_threshold is not None:
|
||||||
|
dlc.rx_credits_threshold = self.credits_threshold
|
||||||
|
|
||||||
async def drain(self):
|
async def drain(self):
|
||||||
assert self.dlc
|
assert self.dlc
|
||||||
@@ -1062,9 +1101,9 @@ class Central(Connection.Listener):
|
|||||||
|
|
||||||
if self.phy not in (None, HCI_LE_1M_PHY):
|
if self.phy not in (None, HCI_LE_1M_PHY):
|
||||||
# Add an connections parameters entry for this PHY.
|
# Add an connections parameters entry for this PHY.
|
||||||
self.connection_parameter_preferences[
|
self.connection_parameter_preferences[self.phy] = (
|
||||||
self.phy
|
connection_parameter_preferences
|
||||||
] = connection_parameter_preferences
|
)
|
||||||
else:
|
else:
|
||||||
self.connection_parameter_preferences = None
|
self.connection_parameter_preferences = None
|
||||||
|
|
||||||
@@ -1232,6 +1271,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
'cyan',
|
'cyan',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.connected.wait()
|
await self.connected.wait()
|
||||||
logging.info(color('### Connected', 'cyan'))
|
logging.info(color('### Connected', 'cyan'))
|
||||||
|
|
||||||
@@ -1318,7 +1358,9 @@ def create_mode_factory(ctx, default_mode):
|
|||||||
uuid=ctx.obj['rfcomm_uuid'],
|
uuid=ctx.obj['rfcomm_uuid'],
|
||||||
l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
|
l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
|
||||||
max_frame_size=ctx.obj['rfcomm_max_frame_size'],
|
max_frame_size=ctx.obj['rfcomm_max_frame_size'],
|
||||||
window_size=ctx.obj['rfcomm_window_size'],
|
initial_credits=ctx.obj['rfcomm_initial_credits'],
|
||||||
|
max_credits=ctx.obj['rfcomm_max_credits'],
|
||||||
|
credits_threshold=ctx.obj['rfcomm_credits_threshold'],
|
||||||
)
|
)
|
||||||
|
|
||||||
if mode == 'rfcomm-server':
|
if mode == 'rfcomm-server':
|
||||||
@@ -1326,6 +1368,10 @@ def create_mode_factory(ctx, default_mode):
|
|||||||
device,
|
device,
|
||||||
channel=ctx.obj['rfcomm_channel'],
|
channel=ctx.obj['rfcomm_channel'],
|
||||||
l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
|
l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
|
||||||
|
max_frame_size=ctx.obj['rfcomm_max_frame_size'],
|
||||||
|
initial_credits=ctx.obj['rfcomm_initial_credits'],
|
||||||
|
max_credits=ctx.obj['rfcomm_max_credits'],
|
||||||
|
credits_threshold=ctx.obj['rfcomm_credits_threshold'],
|
||||||
)
|
)
|
||||||
|
|
||||||
raise ValueError('invalid mode')
|
raise ValueError('invalid mode')
|
||||||
@@ -1424,9 +1470,19 @@ def create_role_factory(ctx, default_role):
|
|||||||
help='RFComm maximum frame size',
|
help='RFComm maximum frame size',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--rfcomm-window-size',
|
'--rfcomm-initial-credits',
|
||||||
type=int,
|
type=int,
|
||||||
help='RFComm window size',
|
help='RFComm initial credits',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--rfcomm-max-credits',
|
||||||
|
type=int,
|
||||||
|
help='RFComm max credits',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--rfcomm-credits-threshold',
|
||||||
|
type=int,
|
||||||
|
help='RFComm credits threshold',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--l2cap-psm',
|
'--l2cap-psm',
|
||||||
@@ -1527,7 +1583,9 @@ def bench(
|
|||||||
rfcomm_uuid,
|
rfcomm_uuid,
|
||||||
rfcomm_l2cap_mtu,
|
rfcomm_l2cap_mtu,
|
||||||
rfcomm_max_frame_size,
|
rfcomm_max_frame_size,
|
||||||
rfcomm_window_size,
|
rfcomm_initial_credits,
|
||||||
|
rfcomm_max_credits,
|
||||||
|
rfcomm_credits_threshold,
|
||||||
l2cap_psm,
|
l2cap_psm,
|
||||||
l2cap_mtu,
|
l2cap_mtu,
|
||||||
l2cap_mps,
|
l2cap_mps,
|
||||||
@@ -1542,7 +1600,9 @@ def bench(
|
|||||||
ctx.obj['rfcomm_uuid'] = rfcomm_uuid
|
ctx.obj['rfcomm_uuid'] = rfcomm_uuid
|
||||||
ctx.obj['rfcomm_l2cap_mtu'] = rfcomm_l2cap_mtu
|
ctx.obj['rfcomm_l2cap_mtu'] = rfcomm_l2cap_mtu
|
||||||
ctx.obj['rfcomm_max_frame_size'] = rfcomm_max_frame_size
|
ctx.obj['rfcomm_max_frame_size'] = rfcomm_max_frame_size
|
||||||
ctx.obj['rfcomm_window_size'] = rfcomm_window_size
|
ctx.obj['rfcomm_initial_credits'] = rfcomm_initial_credits
|
||||||
|
ctx.obj['rfcomm_max_credits'] = rfcomm_max_credits
|
||||||
|
ctx.obj['rfcomm_credits_threshold'] = rfcomm_credits_threshold
|
||||||
ctx.obj['l2cap_psm'] = l2cap_psm
|
ctx.obj['l2cap_psm'] = l2cap_psm
|
||||||
ctx.obj['l2cap_mtu'] = l2cap_mtu
|
ctx.obj['l2cap_mtu'] = l2cap_mtu
|
||||||
ctx.obj['l2cap_mps'] = l2cap_mps
|
ctx.obj['l2cap_mps'] = l2cap_mps
|
||||||
@@ -1591,8 +1651,8 @@ def central(
|
|||||||
mode_factory = create_mode_factory(ctx, 'gatt-client')
|
mode_factory = create_mode_factory(ctx, 'gatt-client')
|
||||||
classic = ctx.obj['classic']
|
classic = ctx.obj['classic']
|
||||||
|
|
||||||
asyncio.run(
|
async def run_central():
|
||||||
Central(
|
await Central(
|
||||||
transport,
|
transport,
|
||||||
peripheral_address,
|
peripheral_address,
|
||||||
classic,
|
classic,
|
||||||
@@ -1604,7 +1664,8 @@ def central(
|
|||||||
encrypt or authenticate,
|
encrypt or authenticate,
|
||||||
ctx.obj['extended_data_length'],
|
ctx.obj['extended_data_length'],
|
||||||
).run()
|
).run()
|
||||||
)
|
|
||||||
|
asyncio.run(run_central())
|
||||||
|
|
||||||
|
|
||||||
@bench.command()
|
@bench.command()
|
||||||
@@ -1615,15 +1676,16 @@ def peripheral(ctx, transport):
|
|||||||
role_factory = create_role_factory(ctx, 'receiver')
|
role_factory = create_role_factory(ctx, 'receiver')
|
||||||
mode_factory = create_mode_factory(ctx, 'gatt-server')
|
mode_factory = create_mode_factory(ctx, 'gatt-server')
|
||||||
|
|
||||||
asyncio.run(
|
async def run_peripheral():
|
||||||
Peripheral(
|
await Peripheral(
|
||||||
transport,
|
transport,
|
||||||
ctx.obj['classic'],
|
ctx.obj['classic'],
|
||||||
ctx.obj['extended_data_length'],
|
ctx.obj['extended_data_length'],
|
||||||
role_factory,
|
role_factory,
|
||||||
mode_factory,
|
mode_factory,
|
||||||
).run()
|
).run()
|
||||||
)
|
|
||||||
|
asyncio.run(run_peripheral())
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -0,0 +1,577 @@
|
|||||||
|
# Copyright 2021-2024 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import enum
|
||||||
|
import functools
|
||||||
|
from importlib import resources
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
from typing import Optional, List, cast
|
||||||
|
import weakref
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import wasmtime
|
||||||
|
import wasmtime.loader
|
||||||
|
import liblc3 # type: ignore
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import click
|
||||||
|
import aiohttp.web
|
||||||
|
|
||||||
|
import bumble
|
||||||
|
from bumble.core import AdvertisingData
|
||||||
|
from bumble.colors import color
|
||||||
|
from bumble.device import Device, DeviceConfiguration, AdvertisingParameters
|
||||||
|
from bumble.transport import open_transport
|
||||||
|
from bumble.profiles import bap
|
||||||
|
from bumble.hci import Address, CodecID, CodingFormat, HCI_IsoDataPacket
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
DEFAULT_UI_PORT = 7654
|
||||||
|
|
||||||
|
|
||||||
|
def _sink_pac_record() -> bap.PacRecord:
|
||||||
|
return bap.PacRecord(
|
||||||
|
coding_format=CodingFormat(CodecID.LC3),
|
||||||
|
codec_specific_capabilities=bap.CodecSpecificCapabilities(
|
||||||
|
supported_sampling_frequencies=(
|
||||||
|
bap.SupportedSamplingFrequency.FREQ_8000
|
||||||
|
| bap.SupportedSamplingFrequency.FREQ_16000
|
||||||
|
| bap.SupportedSamplingFrequency.FREQ_24000
|
||||||
|
| bap.SupportedSamplingFrequency.FREQ_32000
|
||||||
|
| bap.SupportedSamplingFrequency.FREQ_48000
|
||||||
|
),
|
||||||
|
supported_frame_durations=(
|
||||||
|
bap.SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
||||||
|
),
|
||||||
|
supported_audio_channel_count=[1, 2],
|
||||||
|
min_octets_per_codec_frame=26,
|
||||||
|
max_octets_per_codec_frame=240,
|
||||||
|
supported_max_codec_frames_per_sdu=2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_pac_record() -> bap.PacRecord:
|
||||||
|
return bap.PacRecord(
|
||||||
|
coding_format=CodingFormat(CodecID.LC3),
|
||||||
|
codec_specific_capabilities=bap.CodecSpecificCapabilities(
|
||||||
|
supported_sampling_frequencies=(
|
||||||
|
bap.SupportedSamplingFrequency.FREQ_8000
|
||||||
|
| bap.SupportedSamplingFrequency.FREQ_16000
|
||||||
|
| bap.SupportedSamplingFrequency.FREQ_24000
|
||||||
|
| bap.SupportedSamplingFrequency.FREQ_32000
|
||||||
|
| bap.SupportedSamplingFrequency.FREQ_48000
|
||||||
|
),
|
||||||
|
supported_frame_durations=(
|
||||||
|
bap.SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
||||||
|
),
|
||||||
|
supported_audio_channel_count=[1],
|
||||||
|
min_octets_per_codec_frame=30,
|
||||||
|
max_octets_per_codec_frame=100,
|
||||||
|
supported_max_codec_frames_per_sdu=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# WASM - liblc3
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
store = wasmtime.loader.store
|
||||||
|
_memory = cast(wasmtime.Memory, liblc3.memory)
|
||||||
|
STACK_POINTER = _memory.data_len(store)
|
||||||
|
_memory.grow(store, 1)
|
||||||
|
# Mapping wasmtime memory to linear address
|
||||||
|
memory = (ctypes.c_ubyte * _memory.data_len(store)).from_address(
|
||||||
|
ctypes.addressof(_memory.data_ptr(store).contents) # type: ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Liblc3PcmFormat(enum.IntEnum):
|
||||||
|
S16 = 0
|
||||||
|
S24 = 1
|
||||||
|
S24_3LE = 2
|
||||||
|
FLOAT = 3
|
||||||
|
|
||||||
|
|
||||||
|
MAX_DECODER_SIZE = liblc3.lc3_decoder_size(10000, 48000)
|
||||||
|
MAX_ENCODER_SIZE = liblc3.lc3_encoder_size(10000, 48000)
|
||||||
|
|
||||||
|
DECODER_STACK_POINTER = STACK_POINTER
|
||||||
|
ENCODER_STACK_POINTER = DECODER_STACK_POINTER + MAX_DECODER_SIZE * 2
|
||||||
|
DECODE_BUFFER_STACK_POINTER = ENCODER_STACK_POINTER + MAX_ENCODER_SIZE * 2
|
||||||
|
ENCODE_BUFFER_STACK_POINTER = DECODE_BUFFER_STACK_POINTER + 8192
|
||||||
|
DEFAULT_PCM_SAMPLE_RATE = 48000
|
||||||
|
DEFAULT_PCM_FORMAT = Liblc3PcmFormat.S16
|
||||||
|
DEFAULT_PCM_BYTES_PER_SAMPLE = 2
|
||||||
|
|
||||||
|
|
||||||
|
encoders: List[int] = []
|
||||||
|
decoders: List[int] = []
|
||||||
|
|
||||||
|
|
||||||
|
def setup_encoders(
|
||||||
|
sample_rate_hz: int, frame_duration_us: int, num_channels: int
|
||||||
|
) -> None:
|
||||||
|
logger.info(
|
||||||
|
f"setup_encoders {sample_rate_hz}Hz {frame_duration_us}us {num_channels}channels"
|
||||||
|
)
|
||||||
|
encoders[:num_channels] = [
|
||||||
|
liblc3.lc3_setup_encoder(
|
||||||
|
frame_duration_us,
|
||||||
|
sample_rate_hz,
|
||||||
|
DEFAULT_PCM_SAMPLE_RATE, # Input sample rate
|
||||||
|
ENCODER_STACK_POINTER + MAX_ENCODER_SIZE * i,
|
||||||
|
)
|
||||||
|
for i in range(num_channels)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def setup_decoders(
|
||||||
|
sample_rate_hz: int, frame_duration_us: int, num_channels: int
|
||||||
|
) -> None:
|
||||||
|
logger.info(
|
||||||
|
f"setup_decoders {sample_rate_hz}Hz {frame_duration_us}us {num_channels}channels"
|
||||||
|
)
|
||||||
|
decoders[:num_channels] = [
|
||||||
|
liblc3.lc3_setup_decoder(
|
||||||
|
frame_duration_us,
|
||||||
|
sample_rate_hz,
|
||||||
|
DEFAULT_PCM_SAMPLE_RATE, # Output sample rate
|
||||||
|
DECODER_STACK_POINTER + MAX_DECODER_SIZE * i,
|
||||||
|
)
|
||||||
|
for i in range(num_channels)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def decode(
|
||||||
|
frame_duration_us: int,
|
||||||
|
num_channels: int,
|
||||||
|
input_bytes: bytes,
|
||||||
|
) -> bytes:
|
||||||
|
if not input_bytes:
|
||||||
|
return b''
|
||||||
|
|
||||||
|
input_buffer_offset = DECODE_BUFFER_STACK_POINTER
|
||||||
|
input_buffer_size = len(input_bytes)
|
||||||
|
input_bytes_per_frame = input_buffer_size // num_channels
|
||||||
|
|
||||||
|
# Copy into wasm
|
||||||
|
memory[input_buffer_offset : input_buffer_offset + input_buffer_size] = input_bytes # type: ignore
|
||||||
|
|
||||||
|
output_buffer_offset = input_buffer_offset + input_buffer_size
|
||||||
|
output_buffer_size = (
|
||||||
|
liblc3.lc3_frame_samples(frame_duration_us, DEFAULT_PCM_SAMPLE_RATE)
|
||||||
|
* DEFAULT_PCM_BYTES_PER_SAMPLE
|
||||||
|
* num_channels
|
||||||
|
)
|
||||||
|
|
||||||
|
for i in range(num_channels):
|
||||||
|
res = liblc3.lc3_decode(
|
||||||
|
decoders[i],
|
||||||
|
input_buffer_offset + input_bytes_per_frame * i,
|
||||||
|
input_bytes_per_frame,
|
||||||
|
DEFAULT_PCM_FORMAT,
|
||||||
|
output_buffer_offset + i * DEFAULT_PCM_BYTES_PER_SAMPLE,
|
||||||
|
num_channels, # Stride
|
||||||
|
)
|
||||||
|
|
||||||
|
if res != 0:
|
||||||
|
logging.error(f"Parsing failed, res={res}")
|
||||||
|
|
||||||
|
# Extract decoded data from the output buffer
|
||||||
|
return bytes(
|
||||||
|
memory[output_buffer_offset : output_buffer_offset + output_buffer_size]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def encode(
|
||||||
|
sdu_length: int,
|
||||||
|
num_channels: int,
|
||||||
|
stride: int,
|
||||||
|
input_bytes: bytes,
|
||||||
|
) -> bytes:
|
||||||
|
if not input_bytes:
|
||||||
|
return b''
|
||||||
|
|
||||||
|
input_buffer_offset = ENCODE_BUFFER_STACK_POINTER
|
||||||
|
input_buffer_size = len(input_bytes)
|
||||||
|
|
||||||
|
# Copy into wasm
|
||||||
|
memory[input_buffer_offset : input_buffer_offset + input_buffer_size] = input_bytes # type: ignore
|
||||||
|
|
||||||
|
output_buffer_offset = input_buffer_offset + input_buffer_size
|
||||||
|
output_buffer_size = sdu_length
|
||||||
|
output_frame_size = output_buffer_size // num_channels
|
||||||
|
|
||||||
|
for i in range(num_channels):
|
||||||
|
res = liblc3.lc3_encode(
|
||||||
|
encoders[i],
|
||||||
|
DEFAULT_PCM_FORMAT,
|
||||||
|
input_buffer_offset + DEFAULT_PCM_BYTES_PER_SAMPLE * i,
|
||||||
|
stride,
|
||||||
|
output_frame_size,
|
||||||
|
output_buffer_offset + output_frame_size * i,
|
||||||
|
)
|
||||||
|
|
||||||
|
if res != 0:
|
||||||
|
logging.error(f"Parsing failed, res={res}")
|
||||||
|
|
||||||
|
# Extract decoded data from the output buffer
|
||||||
|
return bytes(
|
||||||
|
memory[output_buffer_offset : output_buffer_offset + output_buffer_size]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def lc3_source_task(
|
||||||
|
filename: str,
|
||||||
|
sdu_length: int,
|
||||||
|
frame_duration_us: int,
|
||||||
|
device: Device,
|
||||||
|
cis_handle: int,
|
||||||
|
) -> None:
|
||||||
|
with open(filename, 'rb') as f:
|
||||||
|
header = f.read(44)
|
||||||
|
assert header[8:12] == b'WAVE'
|
||||||
|
|
||||||
|
pcm_num_channel, pcm_sample_rate, _byte_rate, _block_align, bits_per_sample = (
|
||||||
|
struct.unpack("<HIIHH", header[22:36])
|
||||||
|
)
|
||||||
|
assert pcm_sample_rate == DEFAULT_PCM_SAMPLE_RATE
|
||||||
|
assert bits_per_sample == DEFAULT_PCM_BYTES_PER_SAMPLE * 8
|
||||||
|
|
||||||
|
frame_bytes = (
|
||||||
|
liblc3.lc3_frame_samples(frame_duration_us, DEFAULT_PCM_SAMPLE_RATE)
|
||||||
|
* DEFAULT_PCM_BYTES_PER_SAMPLE
|
||||||
|
)
|
||||||
|
packet_sequence_number = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
next_round = datetime.datetime.now() + datetime.timedelta(
|
||||||
|
microseconds=frame_duration_us
|
||||||
|
)
|
||||||
|
pcm_data = f.read(frame_bytes)
|
||||||
|
sdu = encode(sdu_length, pcm_num_channel, pcm_num_channel, pcm_data)
|
||||||
|
|
||||||
|
iso_packet = HCI_IsoDataPacket(
|
||||||
|
connection_handle=cis_handle,
|
||||||
|
data_total_length=sdu_length + 4,
|
||||||
|
packet_sequence_number=packet_sequence_number,
|
||||||
|
pb_flag=0b10,
|
||||||
|
packet_status_flag=0,
|
||||||
|
iso_sdu_length=sdu_length,
|
||||||
|
iso_sdu_fragment=sdu,
|
||||||
|
)
|
||||||
|
device.host.send_hci_packet(iso_packet)
|
||||||
|
packet_sequence_number += 1
|
||||||
|
sleep_time = next_round - datetime.datetime.now()
|
||||||
|
await asyncio.sleep(sleep_time.total_seconds())
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class UiServer:
|
||||||
|
speaker: weakref.ReferenceType[Speaker]
|
||||||
|
port: int
|
||||||
|
|
||||||
|
def __init__(self, speaker: Speaker, port: int) -> None:
|
||||||
|
self.speaker = weakref.ref(speaker)
|
||||||
|
self.port = port
|
||||||
|
self.channel_socket = None
|
||||||
|
|
||||||
|
async def start_http(self) -> None:
|
||||||
|
"""Start the UI HTTP server."""
|
||||||
|
|
||||||
|
app = aiohttp.web.Application()
|
||||||
|
app.add_routes(
|
||||||
|
[
|
||||||
|
aiohttp.web.get('/', self.get_static),
|
||||||
|
aiohttp.web.get('/index.html', self.get_static),
|
||||||
|
aiohttp.web.get('/channel', self.get_channel),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = aiohttp.web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = aiohttp.web.TCPSite(runner, 'localhost', self.port)
|
||||||
|
print('UI HTTP server at ' + color(f'http://127.0.0.1:{self.port}', 'green'))
|
||||||
|
await site.start()
|
||||||
|
|
||||||
|
async def get_static(self, request):
|
||||||
|
path = request.path
|
||||||
|
if path == '/':
|
||||||
|
path = '/index.html'
|
||||||
|
if path.endswith('.html'):
|
||||||
|
content_type = 'text/html'
|
||||||
|
elif path.endswith('.js'):
|
||||||
|
content_type = 'text/javascript'
|
||||||
|
elif path.endswith('.css'):
|
||||||
|
content_type = 'text/css'
|
||||||
|
elif path.endswith('.svg'):
|
||||||
|
content_type = 'image/svg+xml'
|
||||||
|
else:
|
||||||
|
content_type = 'text/plain'
|
||||||
|
text = (
|
||||||
|
resources.files("bumble.apps.lea_unicast")
|
||||||
|
.joinpath(pathlib.Path(path).relative_to('/'))
|
||||||
|
.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
return aiohttp.web.Response(text=text, content_type=content_type)
|
||||||
|
|
||||||
|
async def get_channel(self, request):
|
||||||
|
ws = aiohttp.web.WebSocketResponse()
|
||||||
|
await ws.prepare(request)
|
||||||
|
|
||||||
|
# Process messages until the socket is closed.
|
||||||
|
self.channel_socket = ws
|
||||||
|
async for message in ws:
|
||||||
|
if message.type == aiohttp.WSMsgType.TEXT:
|
||||||
|
logger.debug(f'<<< received message: {message.data}')
|
||||||
|
await self.on_message(message.data)
|
||||||
|
elif message.type == aiohttp.WSMsgType.ERROR:
|
||||||
|
logger.debug(
|
||||||
|
f'channel connection closed with exception {ws.exception()}'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.channel_socket = None
|
||||||
|
logger.debug('--- channel connection closed')
|
||||||
|
|
||||||
|
return ws
|
||||||
|
|
||||||
|
async def on_message(self, message_str: str):
|
||||||
|
# Parse the message as JSON
|
||||||
|
message = json.loads(message_str)
|
||||||
|
|
||||||
|
# Dispatch the message
|
||||||
|
message_type = message['type']
|
||||||
|
message_params = message.get('params', {})
|
||||||
|
handler = getattr(self, f'on_{message_type}_message')
|
||||||
|
if handler:
|
||||||
|
await handler(**message_params)
|
||||||
|
|
||||||
|
async def on_hello_message(self):
|
||||||
|
await self.send_message(
|
||||||
|
'hello',
|
||||||
|
bumble_version=bumble.__version__,
|
||||||
|
codec=self.speaker().codec,
|
||||||
|
streamState=self.speaker().stream_state.name,
|
||||||
|
)
|
||||||
|
if connection := self.speaker().connection:
|
||||||
|
await self.send_message(
|
||||||
|
'connection',
|
||||||
|
peer_address=connection.peer_address.to_string(False),
|
||||||
|
peer_name=connection.peer_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_message(self, message_type: str, **kwargs) -> None:
|
||||||
|
if self.channel_socket is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
message = {'type': message_type, 'params': kwargs}
|
||||||
|
await self.channel_socket.send_json(message)
|
||||||
|
|
||||||
|
async def send_audio(self, data: bytes) -> None:
|
||||||
|
if self.channel_socket is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.channel_socket.send_bytes(data)
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning(f'exception while sending audio packet: {error}')
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Speaker:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_config_path: Optional[str],
|
||||||
|
ui_port: int,
|
||||||
|
transport: str,
|
||||||
|
lc3_input_file_path: str,
|
||||||
|
):
|
||||||
|
self.device_config_path = device_config_path
|
||||||
|
self.transport = transport
|
||||||
|
self.lc3_input_file_path = lc3_input_file_path
|
||||||
|
|
||||||
|
# Create an HTTP server for the UI
|
||||||
|
self.ui_server = UiServer(speaker=self, port=ui_port)
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
await self.ui_server.start_http()
|
||||||
|
|
||||||
|
async with await open_transport(self.transport) as hci_transport:
|
||||||
|
# Create a device
|
||||||
|
if self.device_config_path:
|
||||||
|
device_config = DeviceConfiguration.from_file(self.device_config_path)
|
||||||
|
else:
|
||||||
|
device_config = DeviceConfiguration(
|
||||||
|
name="Bumble LE Headphone",
|
||||||
|
class_of_device=0x244418,
|
||||||
|
keystore="JsonKeyStore",
|
||||||
|
advertising_interval_min=25,
|
||||||
|
advertising_interval_max=25,
|
||||||
|
address=Address('F1:F2:F3:F4:F5:F6'),
|
||||||
|
)
|
||||||
|
|
||||||
|
device_config.le_enabled = True
|
||||||
|
device_config.cis_enabled = True
|
||||||
|
self.device = Device.from_config_with_hci(
|
||||||
|
device_config, hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
|
|
||||||
|
self.device.add_service(
|
||||||
|
bap.PublishedAudioCapabilitiesService(
|
||||||
|
supported_source_context=bap.ContextType(0xFFFF),
|
||||||
|
available_source_context=bap.ContextType(0xFFFF),
|
||||||
|
supported_sink_context=bap.ContextType(0xFFFF), # All context types
|
||||||
|
available_sink_context=bap.ContextType(0xFFFF), # All context types
|
||||||
|
sink_audio_locations=(
|
||||||
|
bap.AudioLocation.FRONT_LEFT | bap.AudioLocation.FRONT_RIGHT
|
||||||
|
),
|
||||||
|
sink_pac=[_sink_pac_record()],
|
||||||
|
source_audio_locations=bap.AudioLocation.FRONT_LEFT,
|
||||||
|
source_pac=[_source_pac_record()],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ascs = bap.AudioStreamControlService(
|
||||||
|
self.device, sink_ase_id=[1], source_ase_id=[2]
|
||||||
|
)
|
||||||
|
self.device.add_service(ascs)
|
||||||
|
|
||||||
|
advertising_data = bytes(
|
||||||
|
AdvertisingData(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
AdvertisingData.COMPLETE_LOCAL_NAME,
|
||||||
|
bytes(device_config.name, 'utf-8'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AdvertisingData.FLAGS,
|
||||||
|
bytes([AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG]),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||||
|
bytes(bap.PublishedAudioCapabilitiesService.UUID),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
) + bytes(bap.UnicastServerAdvertisingData())
|
||||||
|
|
||||||
|
def on_pdu(pdu: HCI_IsoDataPacket, ase: bap.AseStateMachine):
|
||||||
|
codec_config = ase.codec_specific_configuration
|
||||||
|
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
|
||||||
|
pcm = decode(
|
||||||
|
codec_config.frame_duration.us,
|
||||||
|
codec_config.audio_channel_allocation.channel_count,
|
||||||
|
pdu.iso_sdu_fragment,
|
||||||
|
)
|
||||||
|
self.device.abort_on('disconnection', self.ui_server.send_audio(pcm))
|
||||||
|
|
||||||
|
def on_ase_state_change(ase: bap.AseStateMachine) -> None:
|
||||||
|
if ase.state == bap.AseStateMachine.State.STREAMING:
|
||||||
|
codec_config = ase.codec_specific_configuration
|
||||||
|
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
|
||||||
|
assert ase.cis_link
|
||||||
|
if ase.role == bap.AudioRole.SOURCE:
|
||||||
|
ase.cis_link.abort_on(
|
||||||
|
'disconnection',
|
||||||
|
lc3_source_task(
|
||||||
|
filename=self.lc3_input_file_path,
|
||||||
|
sdu_length=(
|
||||||
|
codec_config.codec_frames_per_sdu
|
||||||
|
* codec_config.octets_per_codec_frame
|
||||||
|
),
|
||||||
|
frame_duration_us=codec_config.frame_duration.us,
|
||||||
|
device=self.device,
|
||||||
|
cis_handle=ase.cis_link.handle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ase.cis_link.sink = functools.partial(on_pdu, ase=ase)
|
||||||
|
elif ase.state == bap.AseStateMachine.State.CODEC_CONFIGURED:
|
||||||
|
codec_config = ase.codec_specific_configuration
|
||||||
|
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
|
||||||
|
if ase.role == bap.AudioRole.SOURCE:
|
||||||
|
setup_encoders(
|
||||||
|
codec_config.sampling_frequency.hz,
|
||||||
|
codec_config.frame_duration.us,
|
||||||
|
codec_config.audio_channel_allocation.channel_count,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
setup_decoders(
|
||||||
|
codec_config.sampling_frequency.hz,
|
||||||
|
codec_config.frame_duration.us,
|
||||||
|
codec_config.audio_channel_allocation.channel_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
for ase in ascs.ase_state_machines.values():
|
||||||
|
ase.on('state_change', functools.partial(on_ase_state_change, ase=ase))
|
||||||
|
|
||||||
|
await self.device.power_on()
|
||||||
|
await self.device.create_advertising_set(
|
||||||
|
advertising_data=advertising_data,
|
||||||
|
auto_restart=True,
|
||||||
|
advertising_parameters=AdvertisingParameters(
|
||||||
|
primary_advertising_interval_min=100,
|
||||||
|
primary_advertising_interval_max=100,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
'--ui-port',
|
||||||
|
'ui_port',
|
||||||
|
metavar='HTTP_PORT',
|
||||||
|
default=DEFAULT_UI_PORT,
|
||||||
|
show_default=True,
|
||||||
|
help='HTTP port for the UI server',
|
||||||
|
)
|
||||||
|
@click.option('--device-config', metavar='FILENAME', help='Device configuration file')
|
||||||
|
@click.argument('transport')
|
||||||
|
@click.argument('lc3_file')
|
||||||
|
def speaker(ui_port: int, device_config: str, transport: str, lc3_file: str) -> None:
|
||||||
|
"""Run the speaker."""
|
||||||
|
|
||||||
|
asyncio.run(Speaker(device_config, ui_port, transport, lc3_file).run())
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def main():
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||||
|
speaker()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main() # pylint: disable=no-value-for-parameter
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<html data-bs-theme="dark">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
|
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
||||||
|
<script src="https://unpkg.com/pcm-player"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-dark bg-primary">
|
||||||
|
<div class="container">
|
||||||
|
<span class="navbar-brand mb-0 h1">Bumble Unicast Server</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<button type="button" class="btn btn-danger" id="connect-audio" onclick="connectAudio()">Connect Audio</button>
|
||||||
|
<button class="btn btn-primary" type="button" disabled>
|
||||||
|
<span class="spinner-border spinner-border-sm" id="ws-status-spinner" aria-hidden="true"></span>
|
||||||
|
<span role="status" id="ws-status">WebSocket Connecting...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let player = null;
|
||||||
|
const wsStatus = document.getElementById("ws-status");
|
||||||
|
const wsStatusSpinner = document.getElementById("ws-status-spinner");
|
||||||
|
|
||||||
|
const socket = new WebSocket('ws://127.0.0.1:7654/channel');
|
||||||
|
socket.binaryType = "arraybuffer";
|
||||||
|
socket.onmessage = function (message) {
|
||||||
|
if (typeof message.data === 'string' || message.data instanceof String) {
|
||||||
|
console.log(`channel MESSAGE: ${message.data}`);
|
||||||
|
} else {
|
||||||
|
console.log(typeof (message.data))
|
||||||
|
// BINARY audio data.
|
||||||
|
if (player == null) return;
|
||||||
|
player.feed(message.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onopen = (message) => {
|
||||||
|
wsStatusSpinner.remove();
|
||||||
|
wsStatus.textContent = "WebSocket Connected";
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = (message) => {
|
||||||
|
wsStatus.textContent = "WebSocket Disconnected";
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectAudio() {
|
||||||
|
player = new PCMPlayer({
|
||||||
|
inputCodec: 'Int16',
|
||||||
|
channels: 2,
|
||||||
|
sampleRate: 48000,
|
||||||
|
flushTime: 10,
|
||||||
|
});
|
||||||
|
const button = document.getElementById("connect-audio")
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = "Audio Connected";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,511 @@
|
|||||||
|
# Copyright 2024 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from bumble.colors import color
|
||||||
|
from bumble.device import Device, DeviceConfiguration, Connection
|
||||||
|
from bumble import core
|
||||||
|
from bumble import hci
|
||||||
|
from bumble import rfcomm
|
||||||
|
from bumble import transport
|
||||||
|
from bumble import utils
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
DEFAULT_RFCOMM_UUID = "E6D55659-C8B4-4B85-96BB-B1143AF6D3AE"
|
||||||
|
DEFAULT_MTU = 4096
|
||||||
|
DEFAULT_CLIENT_TCP_PORT = 9544
|
||||||
|
DEFAULT_SERVER_TCP_PORT = 9545
|
||||||
|
|
||||||
|
TRACE_MAX_SIZE = 48
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Tracer:
|
||||||
|
"""
|
||||||
|
Trace data buffers transmitted from one endpoint to another, with stats.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, channel_name: str) -> None:
|
||||||
|
self.channel_name = channel_name
|
||||||
|
self.last_ts: float = 0.0
|
||||||
|
|
||||||
|
def trace_data(self, data: bytes) -> None:
|
||||||
|
now = time.time()
|
||||||
|
elapsed_s = now - self.last_ts if self.last_ts else 0
|
||||||
|
elapsed_ms = int(elapsed_s * 1000)
|
||||||
|
instant_throughput_kbps = ((len(data) / elapsed_s) / 1000) if elapsed_s else 0.0
|
||||||
|
|
||||||
|
hex_str = data[:TRACE_MAX_SIZE].hex() + (
|
||||||
|
"..." if len(data) > TRACE_MAX_SIZE else ""
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[{self.channel_name}] {len(data):4} bytes "
|
||||||
|
f"(+{elapsed_ms:4}ms, {instant_throughput_kbps: 7.2f}kB/s) "
|
||||||
|
f" {hex_str}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.last_ts = now
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class ServerBridge:
|
||||||
|
"""
|
||||||
|
RFCOMM server bridge: waits for a peer to connect an RFCOMM channel.
|
||||||
|
The RFCOMM channel may be associated with a UUID published in an SDP service
|
||||||
|
description, or simply be on a system-assigned channel number.
|
||||||
|
When the connection is made, the bridge connects a TCP socket to a remote host and
|
||||||
|
bridges the data in both directions, with flow control.
|
||||||
|
When the RFCOMM channel is closed, the bridge disconnects the TCP socket
|
||||||
|
and waits for a new channel to be connected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
READ_CHUNK_SIZE = 4096
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, channel: int, uuid: str, trace: bool, tcp_host: str, tcp_port: int
|
||||||
|
) -> None:
|
||||||
|
self.device: Optional[Device] = None
|
||||||
|
self.channel = channel
|
||||||
|
self.uuid = uuid
|
||||||
|
self.tcp_host = tcp_host
|
||||||
|
self.tcp_port = tcp_port
|
||||||
|
self.rfcomm_channel: Optional[rfcomm.DLC] = None
|
||||||
|
self.tcp_tracer: Optional[Tracer]
|
||||||
|
self.rfcomm_tracer: Optional[Tracer]
|
||||||
|
|
||||||
|
if trace:
|
||||||
|
self.tcp_tracer = Tracer(color("RFCOMM->TCP", "cyan"))
|
||||||
|
self.rfcomm_tracer = Tracer(color("TCP->RFCOMM", "magenta"))
|
||||||
|
else:
|
||||||
|
self.rfcomm_tracer = None
|
||||||
|
self.tcp_tracer = None
|
||||||
|
|
||||||
|
async def start(self, device: Device) -> None:
|
||||||
|
self.device = device
|
||||||
|
|
||||||
|
# Create and register a server
|
||||||
|
rfcomm_server = rfcomm.Server(self.device)
|
||||||
|
|
||||||
|
# Listen for incoming DLC connections
|
||||||
|
self.channel = rfcomm_server.listen(self.on_rfcomm_channel, self.channel)
|
||||||
|
|
||||||
|
# Setup the SDP to advertise this channel
|
||||||
|
service_record_handle = 0x00010001
|
||||||
|
self.device.sdp_service_records = {
|
||||||
|
service_record_handle: rfcomm.make_service_sdp_records(
|
||||||
|
service_record_handle, self.channel, core.UUID(self.uuid)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# We're ready for a connection
|
||||||
|
self.device.on("connection", self.on_connection)
|
||||||
|
await self.set_available(True)
|
||||||
|
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
(
|
||||||
|
f"### Listening for RFCOMM connection on {device.public_address}, "
|
||||||
|
f"channel {self.channel}"
|
||||||
|
),
|
||||||
|
"yellow",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_available(self, available: bool):
|
||||||
|
# Become discoverable and connectable
|
||||||
|
assert self.device
|
||||||
|
await self.device.set_connectable(available)
|
||||||
|
await self.device.set_discoverable(available)
|
||||||
|
|
||||||
|
def on_connection(self, connection):
|
||||||
|
print(color(f"@@@ Bluetooth connection: {connection}", "blue"))
|
||||||
|
connection.on("disconnection", self.on_disconnection)
|
||||||
|
|
||||||
|
# Don't accept new connections until we're disconnected
|
||||||
|
utils.AsyncRunner.spawn(self.set_available(False))
|
||||||
|
|
||||||
|
def on_disconnection(self, reason: int):
|
||||||
|
print(
|
||||||
|
color("@@@ Bluetooth disconnection:", "red"),
|
||||||
|
hci.HCI_Constant.error_name(reason),
|
||||||
|
)
|
||||||
|
|
||||||
|
# We're ready for a new connection
|
||||||
|
utils.AsyncRunner.spawn(self.set_available(True))
|
||||||
|
|
||||||
|
# Called when an RFCOMM channel is established
|
||||||
|
@utils.AsyncRunner.run_in_task()
|
||||||
|
async def on_rfcomm_channel(self, rfcomm_channel):
|
||||||
|
print(color("*** RFCOMM channel:", "cyan"), rfcomm_channel)
|
||||||
|
|
||||||
|
# Connect to the TCP server
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f"### Connecting to TCP {self.tcp_host}:{self.tcp_port}",
|
||||||
|
"yellow",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.open_connection(self.tcp_host, self.tcp_port)
|
||||||
|
except OSError:
|
||||||
|
print(color("!!! Connection failed", "red"))
|
||||||
|
await rfcomm_channel.disconnect()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pipe data from RFCOMM to TCP
|
||||||
|
def on_rfcomm_channel_closed():
|
||||||
|
print(color("*** RFCOMM channel closed", "cyan"))
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
def write_rfcomm_data(data):
|
||||||
|
if self.rfcomm_tracer:
|
||||||
|
self.rfcomm_tracer.trace_data(data)
|
||||||
|
|
||||||
|
writer.write(data)
|
||||||
|
|
||||||
|
rfcomm_channel.sink = write_rfcomm_data
|
||||||
|
rfcomm_channel.on("close", on_rfcomm_channel_closed)
|
||||||
|
|
||||||
|
# Pipe data from TCP to RFCOMM
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = await reader.read(self.READ_CHUNK_SIZE)
|
||||||
|
|
||||||
|
if len(data) == 0:
|
||||||
|
print(color("### TCP end of stream", "yellow"))
|
||||||
|
if rfcomm_channel.state == rfcomm.DLC.State.CONNECTED:
|
||||||
|
await rfcomm_channel.disconnect()
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.tcp_tracer:
|
||||||
|
self.tcp_tracer.trace_data(data)
|
||||||
|
|
||||||
|
rfcomm_channel.write(data)
|
||||||
|
await rfcomm_channel.drain()
|
||||||
|
except Exception as error:
|
||||||
|
print(f"!!! Exception: {error}")
|
||||||
|
break
|
||||||
|
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
print(color("~~~ Bye bye", "magenta"))
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class ClientBridge:
|
||||||
|
"""
|
||||||
|
RFCOMM client bridge: connects to a BR/EDR device, then waits for an inbound
|
||||||
|
TCP connection on a specified port number. When a TCP client connects, an
|
||||||
|
RFCOMM connection to the device is established, and the data is bridged in both
|
||||||
|
directions, with flow control.
|
||||||
|
When the TCP connection is closed by the client, the RFCOMM channel is
|
||||||
|
disconnected, but the connection to the device remains, ready for a new TCP client
|
||||||
|
to connect.
|
||||||
|
"""
|
||||||
|
|
||||||
|
READ_CHUNK_SIZE = 4096
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
channel: int,
|
||||||
|
uuid: str,
|
||||||
|
trace: bool,
|
||||||
|
address: str,
|
||||||
|
tcp_host: str,
|
||||||
|
tcp_port: int,
|
||||||
|
encrypt: bool,
|
||||||
|
):
|
||||||
|
self.channel = channel
|
||||||
|
self.uuid = uuid
|
||||||
|
self.trace = trace
|
||||||
|
self.address = address
|
||||||
|
self.tcp_host = tcp_host
|
||||||
|
self.tcp_port = tcp_port
|
||||||
|
self.encrypt = encrypt
|
||||||
|
self.device: Optional[Device] = None
|
||||||
|
self.connection: Optional[Connection] = None
|
||||||
|
self.rfcomm_client: Optional[rfcomm.Client]
|
||||||
|
self.rfcomm_mux: Optional[rfcomm.Multiplexer]
|
||||||
|
self.tcp_connected: bool = False
|
||||||
|
|
||||||
|
self.tcp_tracer: Optional[Tracer]
|
||||||
|
self.rfcomm_tracer: Optional[Tracer]
|
||||||
|
|
||||||
|
if trace:
|
||||||
|
self.tcp_tracer = Tracer(color("RFCOMM->TCP", "cyan"))
|
||||||
|
self.rfcomm_tracer = Tracer(color("TCP->RFCOMM", "magenta"))
|
||||||
|
else:
|
||||||
|
self.rfcomm_tracer = None
|
||||||
|
self.tcp_tracer = None
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
if self.connection:
|
||||||
|
return
|
||||||
|
|
||||||
|
print(color(f"@@@ Connecting to Bluetooth {self.address}", "blue"))
|
||||||
|
assert self.device
|
||||||
|
self.connection = await self.device.connect(
|
||||||
|
self.address, transport=core.BT_BR_EDR_TRANSPORT
|
||||||
|
)
|
||||||
|
print(color(f"@@@ Bluetooth connection: {self.connection}", "blue"))
|
||||||
|
self.connection.on("disconnection", self.on_disconnection)
|
||||||
|
|
||||||
|
if self.encrypt:
|
||||||
|
print(color("@@@ Encrypting Bluetooth connection", "blue"))
|
||||||
|
await self.connection.encrypt()
|
||||||
|
print(color("@@@ Bluetooth connection encrypted", "blue"))
|
||||||
|
|
||||||
|
self.rfcomm_client = rfcomm.Client(self.connection)
|
||||||
|
try:
|
||||||
|
self.rfcomm_mux = await self.rfcomm_client.start()
|
||||||
|
except BaseException as e:
|
||||||
|
print(color("!!! Failed to setup RFCOMM connection", "red"), e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def start(self, device: Device) -> None:
|
||||||
|
self.device = device
|
||||||
|
await device.set_connectable(False)
|
||||||
|
await device.set_discoverable(False)
|
||||||
|
|
||||||
|
# Called when a TCP connection is established
|
||||||
|
async def on_tcp_connection(reader, writer):
|
||||||
|
print(color("<<< TCP connection", "magenta"))
|
||||||
|
if self.tcp_connected:
|
||||||
|
print(
|
||||||
|
color("!!! TCP connection already active, rejecting new one", "red")
|
||||||
|
)
|
||||||
|
writer.close()
|
||||||
|
return
|
||||||
|
self.tcp_connected = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.pipe(reader, writer)
|
||||||
|
except BaseException as error:
|
||||||
|
print(color("!!! Exception while piping data:", "red"), error)
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
self.tcp_connected = False
|
||||||
|
|
||||||
|
await asyncio.start_server(
|
||||||
|
on_tcp_connection,
|
||||||
|
host=self.tcp_host if self.tcp_host != "_" else None,
|
||||||
|
port=self.tcp_port,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f"### Listening for TCP connections on port {self.tcp_port}", "magenta"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def pipe(
|
||||||
|
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||||||
|
) -> None:
|
||||||
|
# Resolve the channel number from the UUID if needed
|
||||||
|
if self.channel == 0:
|
||||||
|
await self.connect()
|
||||||
|
assert self.connection
|
||||||
|
channel = await rfcomm.find_rfcomm_channel_with_uuid(
|
||||||
|
self.connection, self.uuid
|
||||||
|
)
|
||||||
|
if channel:
|
||||||
|
print(color(f"### Found RFCOMM channel {channel}", "yellow"))
|
||||||
|
else:
|
||||||
|
print(color(f"!!! RFCOMM channel with UUID {self.uuid} not found"))
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
channel = self.channel
|
||||||
|
|
||||||
|
# Connect a new RFCOMM channel
|
||||||
|
await self.connect()
|
||||||
|
assert self.rfcomm_mux
|
||||||
|
print(color(f"*** Opening RFCOMM channel {channel}", "green"))
|
||||||
|
try:
|
||||||
|
rfcomm_channel = await self.rfcomm_mux.open_dlc(channel)
|
||||||
|
print(color(f"*** RFCOMM channel open: {rfcomm_channel}", "green"))
|
||||||
|
except Exception as error:
|
||||||
|
print(color(f"!!! RFCOMM open failed: {error}", "red"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pipe data from RFCOMM to TCP
|
||||||
|
def on_rfcomm_channel_closed():
|
||||||
|
print(color("*** RFCOMM channel closed", "green"))
|
||||||
|
|
||||||
|
def write_rfcomm_data(data):
|
||||||
|
if self.trace:
|
||||||
|
self.rfcomm_tracer.trace_data(data)
|
||||||
|
|
||||||
|
writer.write(data)
|
||||||
|
|
||||||
|
rfcomm_channel.on("close", on_rfcomm_channel_closed)
|
||||||
|
rfcomm_channel.sink = write_rfcomm_data
|
||||||
|
|
||||||
|
# Pipe data from TCP to RFCOMM
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = await reader.read(self.READ_CHUNK_SIZE)
|
||||||
|
|
||||||
|
if len(data) == 0:
|
||||||
|
print(color("### TCP end of stream", "yellow"))
|
||||||
|
if rfcomm_channel.state == rfcomm.DLC.State.CONNECTED:
|
||||||
|
await rfcomm_channel.disconnect()
|
||||||
|
self.tcp_connected = False
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.tcp_tracer:
|
||||||
|
self.tcp_tracer.trace_data(data)
|
||||||
|
|
||||||
|
rfcomm_channel.write(data)
|
||||||
|
await rfcomm_channel.drain()
|
||||||
|
except Exception as error:
|
||||||
|
print(f"!!! Exception: {error}")
|
||||||
|
break
|
||||||
|
|
||||||
|
print(color("~~~ Bye bye", "magenta"))
|
||||||
|
|
||||||
|
def on_disconnection(self, reason: int) -> None:
|
||||||
|
print(
|
||||||
|
color("@@@ Bluetooth disconnection:", "red"),
|
||||||
|
hci.HCI_Constant.error_name(reason),
|
||||||
|
)
|
||||||
|
self.connection = None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def run(device_config, hci_transport, bridge):
|
||||||
|
print("<<< connecting to HCI...")
|
||||||
|
async with await transport.open_transport_or_link(hci_transport) as (
|
||||||
|
hci_source,
|
||||||
|
hci_sink,
|
||||||
|
):
|
||||||
|
print("<<< connected")
|
||||||
|
|
||||||
|
if device_config:
|
||||||
|
device = Device.from_config_file_with_hci(
|
||||||
|
device_config, hci_source, hci_sink
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
device = Device.from_config_with_hci(
|
||||||
|
DeviceConfiguration(), hci_source, hci_sink
|
||||||
|
)
|
||||||
|
device.classic_enabled = True
|
||||||
|
|
||||||
|
# Let's go
|
||||||
|
await device.power_on()
|
||||||
|
try:
|
||||||
|
await bridge.start(device)
|
||||||
|
|
||||||
|
# Wait until the transport terminates
|
||||||
|
await hci_source.wait_for_termination()
|
||||||
|
except core.ConnectionError as error:
|
||||||
|
print(color(f"!!! Bluetooth connection failed: {error}", "red"))
|
||||||
|
except Exception as error:
|
||||||
|
print(f"Exception while running bridge: {error}")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@click.group()
|
||||||
|
@click.pass_context
|
||||||
|
@click.option(
|
||||||
|
"--device-config",
|
||||||
|
metavar="CONFIG_FILE",
|
||||||
|
help="Device configuration file",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--hci-transport", metavar="TRANSPORT_NAME", help="HCI transport", required=True
|
||||||
|
)
|
||||||
|
@click.option("--trace", is_flag=True, help="Trace bridged data to stdout")
|
||||||
|
@click.option(
|
||||||
|
"--channel",
|
||||||
|
metavar="CHANNEL_NUMER",
|
||||||
|
help="RFCOMM channel number",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--uuid",
|
||||||
|
metavar="UUID",
|
||||||
|
help="UUID for the RFCOMM channel",
|
||||||
|
default=DEFAULT_RFCOMM_UUID,
|
||||||
|
)
|
||||||
|
def cli(
|
||||||
|
context,
|
||||||
|
device_config,
|
||||||
|
hci_transport,
|
||||||
|
trace,
|
||||||
|
channel,
|
||||||
|
uuid,
|
||||||
|
):
|
||||||
|
context.ensure_object(dict)
|
||||||
|
context.obj["device_config"] = device_config
|
||||||
|
context.obj["hci_transport"] = hci_transport
|
||||||
|
context.obj["trace"] = trace
|
||||||
|
context.obj["channel"] = channel
|
||||||
|
context.obj["uuid"] = uuid
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@cli.command()
|
||||||
|
@click.pass_context
|
||||||
|
@click.option("--tcp-host", help="TCP host", default="localhost")
|
||||||
|
@click.option("--tcp-port", help="TCP port", default=DEFAULT_SERVER_TCP_PORT)
|
||||||
|
def server(context, tcp_host, tcp_port):
|
||||||
|
bridge = ServerBridge(
|
||||||
|
context.obj["channel"],
|
||||||
|
context.obj["uuid"],
|
||||||
|
context.obj["trace"],
|
||||||
|
tcp_host,
|
||||||
|
tcp_port,
|
||||||
|
)
|
||||||
|
asyncio.run(run(context.obj["device_config"], context.obj["hci_transport"], bridge))
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@cli.command()
|
||||||
|
@click.pass_context
|
||||||
|
@click.argument("bluetooth-address")
|
||||||
|
@click.option("--tcp-host", help="TCP host", default="_")
|
||||||
|
@click.option("--tcp-port", help="TCP port", default=DEFAULT_CLIENT_TCP_PORT)
|
||||||
|
@click.option("--encrypt", is_flag=True, help="Encrypt the connection")
|
||||||
|
def client(context, bluetooth_address, tcp_host, tcp_port, encrypt):
|
||||||
|
bridge = ClientBridge(
|
||||||
|
context.obj["channel"],
|
||||||
|
context.obj["uuid"],
|
||||||
|
context.obj["trace"],
|
||||||
|
bluetooth_address,
|
||||||
|
tcp_host,
|
||||||
|
tcp_port,
|
||||||
|
encrypt,
|
||||||
|
)
|
||||||
|
asyncio.run(run(context.obj["device_config"], context.obj["hci_transport"], bridge))
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logging.basicConfig(level=os.environ.get("BUMBLE_LOGLEVEL", "WARNING").upper())
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli(obj={}) # pylint: disable=no-value-for-parameter
|
||||||
+60
-15
@@ -15,7 +15,11 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
@@ -24,6 +28,14 @@ from bumble.transport.common import PacketReader
|
|||||||
from bumble.helpers import PacketTracer
|
from bumble.helpers import PacketTracer
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Classes
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class SnoopPacketReader:
|
class SnoopPacketReader:
|
||||||
'''
|
'''
|
||||||
@@ -36,12 +48,18 @@ class SnoopPacketReader:
|
|||||||
DATALINK_BSCP = 1003
|
DATALINK_BSCP = 1003
|
||||||
DATALINK_H5 = 1004
|
DATALINK_H5 = 1004
|
||||||
|
|
||||||
|
IDENTIFICATION_PATTERN = b'btsnoop\0'
|
||||||
|
TIMESTAMP_ANCHOR = datetime.datetime(2000, 1, 1)
|
||||||
|
TIMESTAMP_DELTA = 0x00E03AB44A676000
|
||||||
|
ONE_MICROSECOND = datetime.timedelta(microseconds=1)
|
||||||
|
|
||||||
def __init__(self, source):
|
def __init__(self, source):
|
||||||
self.source = source
|
self.source = source
|
||||||
|
self.at_end = False
|
||||||
|
|
||||||
# Read the header
|
# Read the header
|
||||||
identification_pattern = source.read(8)
|
identification_pattern = source.read(8)
|
||||||
if identification_pattern.hex().lower() != '6274736e6f6f7000':
|
if identification_pattern != self.IDENTIFICATION_PATTERN:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'not a valid snoop file, unexpected identification pattern'
|
'not a valid snoop file, unexpected identification pattern'
|
||||||
)
|
)
|
||||||
@@ -55,19 +73,32 @@ class SnoopPacketReader:
|
|||||||
# Read the record header
|
# Read the record header
|
||||||
header = self.source.read(24)
|
header = self.source.read(24)
|
||||||
if len(header) < 24:
|
if len(header) < 24:
|
||||||
return (0, None)
|
self.at_end = True
|
||||||
|
return (None, 0, None)
|
||||||
|
|
||||||
|
# Parse the header
|
||||||
(
|
(
|
||||||
original_length,
|
original_length,
|
||||||
included_length,
|
included_length,
|
||||||
packet_flags,
|
packet_flags,
|
||||||
_cumulative_drops,
|
_cumulative_drops,
|
||||||
_timestamp_seconds,
|
timestamp,
|
||||||
_timestamp_microsecond,
|
) = struct.unpack('>IIIIQ', header)
|
||||||
) = struct.unpack('>IIIIII', header)
|
|
||||||
|
|
||||||
# Abort on truncated packets
|
# Skip truncated packets
|
||||||
if original_length != included_length:
|
if original_length != included_length:
|
||||||
return (0, None)
|
print(
|
||||||
|
color(
|
||||||
|
f"!!! truncated packet ({included_length}/{original_length})", "red"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.source.read(included_length)
|
||||||
|
return (None, 0, None)
|
||||||
|
|
||||||
|
# Convert the timestamp to a datetime object.
|
||||||
|
ts_dt = self.TIMESTAMP_ANCHOR + datetime.timedelta(
|
||||||
|
microseconds=timestamp - self.TIMESTAMP_DELTA
|
||||||
|
)
|
||||||
|
|
||||||
if self.data_link_type == self.DATALINK_H1:
|
if self.data_link_type == self.DATALINK_H1:
|
||||||
# The packet is un-encapsulated, look at the flags to figure out its type
|
# The packet is un-encapsulated, look at the flags to figure out its type
|
||||||
@@ -89,7 +120,17 @@ class SnoopPacketReader:
|
|||||||
bytes([packet_type]) + self.source.read(included_length),
|
bytes([packet_type]) + self.source.read(included_length),
|
||||||
)
|
)
|
||||||
|
|
||||||
return (packet_flags & 1, self.source.read(included_length))
|
return (ts_dt, packet_flags & 1, self.source.read(included_length))
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Printer:
|
||||||
|
def __init__(self):
|
||||||
|
self.index = 0
|
||||||
|
|
||||||
|
def print(self, message: str) -> None:
|
||||||
|
self.index += 1
|
||||||
|
print(f"[{self.index:8}]{message}")
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -122,24 +163,28 @@ def main(format, vendors, filename):
|
|||||||
packet_reader = PacketReader(input)
|
packet_reader = PacketReader(input)
|
||||||
|
|
||||||
def read_next_packet():
|
def read_next_packet():
|
||||||
return (0, packet_reader.next_packet())
|
return (None, 0, packet_reader.next_packet())
|
||||||
|
|
||||||
else:
|
else:
|
||||||
packet_reader = SnoopPacketReader(input)
|
packet_reader = SnoopPacketReader(input)
|
||||||
read_next_packet = packet_reader.next_packet
|
read_next_packet = packet_reader.next_packet
|
||||||
|
|
||||||
tracer = PacketTracer(emit_message=print)
|
printer = Printer()
|
||||||
|
tracer = PacketTracer(emit_message=printer.print)
|
||||||
|
|
||||||
while True:
|
while not packet_reader.at_end:
|
||||||
try:
|
try:
|
||||||
(direction, packet) = read_next_packet()
|
(timestamp, direction, packet) = read_next_packet()
|
||||||
if packet is None:
|
if packet:
|
||||||
break
|
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction, timestamp)
|
||||||
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
|
else:
|
||||||
|
printer.print(color("[TRUNCATED]", "red"))
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
logger.exception()
|
||||||
print(color(f'!!! {error}', 'red'))
|
print(color(f'!!! {error}', 'red'))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||||
main() # pylint: disable=no-value-for-parameter
|
main() # pylint: disable=no-value-for-parameter
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
DEFAULT_UI_PORT = 7654
|
DEFAULT_UI_PORT = 7654
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class AudioExtractor:
|
class AudioExtractor:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from bumble.device import Device
|
|||||||
from bumble.keys import JsonKeyStore
|
from bumble.keys import JsonKeyStore
|
||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def unbond_with_keystore(keystore, address):
|
async def unbond_with_keystore(keystore, address):
|
||||||
if address is None:
|
if address is None:
|
||||||
|
|||||||
@@ -652,7 +652,9 @@ class SbcPacketSource:
|
|||||||
|
|
||||||
# Prepare for next packets
|
# Prepare for next packets
|
||||||
sequence_number += 1
|
sequence_number += 1
|
||||||
|
sequence_number &= 0xFFFF
|
||||||
timestamp += sum((frame.sample_count for frame in frames))
|
timestamp += sum((frame.sample_count for frame in frames))
|
||||||
|
timestamp &= 0xFFFFFFFF
|
||||||
frames = [frame]
|
frames = [frame]
|
||||||
frames_size = len(frame.payload)
|
frames_size = len(frame.payload)
|
||||||
else:
|
else:
|
||||||
|
|||||||
+1
-1
@@ -655,7 +655,7 @@ class ATT_Write_Command(ATT_PDU):
|
|||||||
@ATT_PDU.subclass(
|
@ATT_PDU.subclass(
|
||||||
[
|
[
|
||||||
('attribute_handle', HANDLE_FIELD_SPEC),
|
('attribute_handle', HANDLE_FIELD_SPEC),
|
||||||
('attribute_value', '*')
|
('attribute_value', '*'),
|
||||||
# ('authentication_signature', 'TODO')
|
# ('authentication_signature', 'TODO')
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
+12
-6
@@ -325,8 +325,8 @@ class MediaPacket:
|
|||||||
self.padding = padding
|
self.padding = padding
|
||||||
self.extension = extension
|
self.extension = extension
|
||||||
self.marker = marker
|
self.marker = marker
|
||||||
self.sequence_number = sequence_number
|
self.sequence_number = sequence_number & 0xFFFF
|
||||||
self.timestamp = timestamp
|
self.timestamp = timestamp & 0xFFFFFFFF
|
||||||
self.ssrc = ssrc
|
self.ssrc = ssrc
|
||||||
self.csrc_list = csrc_list
|
self.csrc_list = csrc_list
|
||||||
self.payload_type = payload_type
|
self.payload_type = payload_type
|
||||||
@@ -341,7 +341,12 @@ class MediaPacket:
|
|||||||
| len(self.csrc_list),
|
| len(self.csrc_list),
|
||||||
self.marker << 7 | self.payload_type,
|
self.marker << 7 | self.payload_type,
|
||||||
]
|
]
|
||||||
) + struct.pack('>HII', self.sequence_number, self.timestamp, self.ssrc)
|
) + struct.pack(
|
||||||
|
'>HII',
|
||||||
|
self.sequence_number,
|
||||||
|
self.timestamp,
|
||||||
|
self.ssrc,
|
||||||
|
)
|
||||||
for csrc in self.csrc_list:
|
for csrc in self.csrc_list:
|
||||||
header += struct.pack('>I', csrc)
|
header += struct.pack('>I', csrc)
|
||||||
return header + self.payload
|
return header + self.payload
|
||||||
@@ -1545,9 +1550,10 @@ class Protocol(EventEmitter):
|
|||||||
|
|
||||||
assert False # Should never reach this
|
assert False # Should never reach this
|
||||||
|
|
||||||
async def get_capabilities(
|
async def get_capabilities(self, seid: int) -> Union[
|
||||||
self, seid: int
|
Get_Capabilities_Response,
|
||||||
) -> Union[Get_Capabilities_Response, Get_All_Capabilities_Response,]:
|
Get_All_Capabilities_Response,
|
||||||
|
]:
|
||||||
if self.version > (1, 2):
|
if self.version > (1, 2):
|
||||||
return await self.send_command(Get_All_Capabilities_Command(seid))
|
return await self.send_command(Get_All_Capabilities_Command(seid))
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -1745,9 +1745,11 @@ class Protocol(pyee.EventEmitter):
|
|||||||
avc.CommandFrame.CommandType.CONTROL,
|
avc.CommandFrame.CommandType.CONTROL,
|
||||||
avc.Frame.SubunitType.PANEL,
|
avc.Frame.SubunitType.PANEL,
|
||||||
0,
|
0,
|
||||||
avc.PassThroughFrame.StateFlag.PRESSED
|
(
|
||||||
if pressed
|
avc.PassThroughFrame.StateFlag.PRESSED
|
||||||
else avc.PassThroughFrame.StateFlag.RELEASED,
|
if pressed
|
||||||
|
else avc.PassThroughFrame.StateFlag.RELEASED
|
||||||
|
),
|
||||||
key,
|
key,
|
||||||
b'',
|
b'',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -134,15 +134,15 @@ class Controller:
|
|||||||
self.hci_sink = None
|
self.hci_sink = None
|
||||||
self.link = link
|
self.link = link
|
||||||
|
|
||||||
self.central_connections: Dict[
|
self.central_connections: Dict[Address, Connection] = (
|
||||||
Address, Connection
|
{}
|
||||||
] = {} # Connections where this controller is the central
|
) # Connections where this controller is the central
|
||||||
self.peripheral_connections: Dict[
|
self.peripheral_connections: Dict[Address, Connection] = (
|
||||||
Address, Connection
|
{}
|
||||||
] = {} # Connections where this controller is the peripheral
|
) # Connections where this controller is the peripheral
|
||||||
self.classic_connections: Dict[
|
self.classic_connections: Dict[Address, Connection] = (
|
||||||
Address, Connection
|
{}
|
||||||
] = {} # Connections in BR/EDR
|
) # Connections in BR/EDR
|
||||||
self.central_cis_links: Dict[int, CisLink] = {} # CIS links by handle
|
self.central_cis_links: Dict[int, CisLink] = {} # CIS links by handle
|
||||||
self.peripheral_cis_links: Dict[int, CisLink] = {} # CIS links by handle
|
self.peripheral_cis_links: Dict[int, CisLink] = {} # CIS links by handle
|
||||||
|
|
||||||
|
|||||||
+197
-151
@@ -17,12 +17,19 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
import copy
|
||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
from contextlib import asynccontextmanager, AsyncExitStack, closing
|
import sys
|
||||||
|
from contextlib import (
|
||||||
|
asynccontextmanager,
|
||||||
|
AsyncExitStack,
|
||||||
|
closing,
|
||||||
|
AbstractAsyncContextManager,
|
||||||
|
)
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from typing import (
|
from typing import (
|
||||||
@@ -40,6 +47,7 @@ from typing import (
|
|||||||
overload,
|
overload,
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
|
|
||||||
@@ -276,12 +284,12 @@ class Advertisement:
|
|||||||
data_bytes: bytes = b''
|
data_bytes: bytes = b''
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
TX_POWER_NOT_AVAILABLE: ClassVar[
|
TX_POWER_NOT_AVAILABLE: ClassVar[int] = (
|
||||||
int
|
HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
|
||||||
] = HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
|
)
|
||||||
RSSI_NOT_AVAILABLE: ClassVar[
|
RSSI_NOT_AVAILABLE: ClassVar[int] = (
|
||||||
int
|
HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE
|
||||||
] = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE
|
)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
self.data = AdvertisingData.from_bytes(self.data_bytes)
|
self.data = AdvertisingData.from_bytes(self.data_bytes)
|
||||||
@@ -558,7 +566,9 @@ class AdvertisingParameters:
|
|||||||
)
|
)
|
||||||
primary_advertising_interval_min: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
|
primary_advertising_interval_min: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
|
||||||
primary_advertising_interval_max: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
|
primary_advertising_interval_max: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
|
||||||
primary_advertising_channel_map: HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap = (
|
primary_advertising_channel_map: (
|
||||||
|
HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap
|
||||||
|
) = (
|
||||||
AdvertisingChannelMap.CHANNEL_37
|
AdvertisingChannelMap.CHANNEL_37
|
||||||
| AdvertisingChannelMap.CHANNEL_38
|
| AdvertisingChannelMap.CHANNEL_38
|
||||||
| AdvertisingChannelMap.CHANNEL_39
|
| AdvertisingChannelMap.CHANNEL_39
|
||||||
@@ -957,8 +967,9 @@ class ScoLink(CompositeEventEmitter):
|
|||||||
acl_connection: Connection
|
acl_connection: Connection
|
||||||
handle: int
|
handle: int
|
||||||
link_type: int
|
link_type: int
|
||||||
|
sink: Optional[Callable[[HCI_SynchronousDataPacket], Any]] = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
async def disconnect(
|
async def disconnect(
|
||||||
@@ -980,8 +991,9 @@ class CisLink(CompositeEventEmitter):
|
|||||||
cis_id: int # CIS ID assigned by Central device
|
cis_id: int # CIS ID assigned by Central device
|
||||||
cig_id: int # CIG ID assigned by Central device
|
cig_id: int # CIG ID assigned by Central device
|
||||||
state: State = State.PENDING
|
state: State = State.PENDING
|
||||||
|
sink: Optional[Callable[[HCI_IsoDataPacket], Any]] = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
async def disconnect(
|
async def disconnect(
|
||||||
@@ -1138,14 +1150,12 @@ class Connection(CompositeEventEmitter):
|
|||||||
@overload
|
@overload
|
||||||
async def create_l2cap_channel(
|
async def create_l2cap_channel(
|
||||||
self, spec: l2cap.ClassicChannelSpec
|
self, spec: l2cap.ClassicChannelSpec
|
||||||
) -> l2cap.ClassicChannel:
|
) -> l2cap.ClassicChannel: ...
|
||||||
...
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
async def create_l2cap_channel(
|
async def create_l2cap_channel(
|
||||||
self, spec: l2cap.LeCreditBasedChannelSpec
|
self, spec: l2cap.LeCreditBasedChannelSpec
|
||||||
) -> l2cap.LeCreditBasedChannel:
|
) -> l2cap.LeCreditBasedChannel: ...
|
||||||
...
|
|
||||||
|
|
||||||
async def create_l2cap_channel(
|
async def create_l2cap_channel(
|
||||||
self, spec: Union[l2cap.ClassicChannelSpec, l2cap.LeCreditBasedChannelSpec]
|
self, spec: Union[l2cap.ClassicChannelSpec, l2cap.LeCreditBasedChannelSpec]
|
||||||
@@ -1252,75 +1262,47 @@ class Connection(CompositeEventEmitter):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
class DeviceConfiguration:
|
class DeviceConfiguration:
|
||||||
def __init__(self) -> None:
|
# Setup defaults
|
||||||
# Setup defaults
|
name: str = DEVICE_DEFAULT_NAME
|
||||||
self.name = DEVICE_DEFAULT_NAME
|
address: Address = Address(DEVICE_DEFAULT_ADDRESS)
|
||||||
self.address = Address(DEVICE_DEFAULT_ADDRESS)
|
class_of_device: int = DEVICE_DEFAULT_CLASS_OF_DEVICE
|
||||||
self.class_of_device = DEVICE_DEFAULT_CLASS_OF_DEVICE
|
scan_response_data: bytes = DEVICE_DEFAULT_SCAN_RESPONSE_DATA
|
||||||
self.scan_response_data = DEVICE_DEFAULT_SCAN_RESPONSE_DATA
|
advertising_interval_min: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
|
||||||
self.advertising_interval_min = DEVICE_DEFAULT_ADVERTISING_INTERVAL
|
advertising_interval_max: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
|
||||||
self.advertising_interval_max = DEVICE_DEFAULT_ADVERTISING_INTERVAL
|
le_enabled: bool = True
|
||||||
self.le_enabled = True
|
# LE host enable 2nd parameter
|
||||||
# LE host enable 2nd parameter
|
le_simultaneous_enabled: bool = False
|
||||||
self.le_simultaneous_enabled = False
|
classic_enabled: bool = False
|
||||||
self.classic_enabled = False
|
classic_sc_enabled: bool = True
|
||||||
self.classic_sc_enabled = True
|
classic_ssp_enabled: bool = True
|
||||||
self.classic_ssp_enabled = True
|
classic_smp_enabled: bool = True
|
||||||
self.classic_smp_enabled = True
|
classic_accept_any: bool = True
|
||||||
self.classic_accept_any = True
|
connectable: bool = True
|
||||||
self.connectable = True
|
discoverable: bool = True
|
||||||
self.discoverable = True
|
advertising_data: bytes = bytes(
|
||||||
self.advertising_data = bytes(
|
AdvertisingData(
|
||||||
AdvertisingData(
|
[(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(DEVICE_DEFAULT_NAME, 'utf-8'))]
|
||||||
[(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))]
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.irk = bytes(16) # This really must be changed for any level of security
|
)
|
||||||
self.keystore = None
|
irk: bytes = bytes(16) # This really must be changed for any level of security
|
||||||
|
keystore: Optional[str] = None
|
||||||
|
address_resolution_offload: bool = False
|
||||||
|
cis_enabled: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
self.gatt_services: List[Dict[str, Any]] = []
|
self.gatt_services: List[Dict[str, Any]] = []
|
||||||
self.address_resolution_offload = False
|
|
||||||
self.cis_enabled = False
|
|
||||||
|
|
||||||
def load_from_dict(self, config: Dict[str, Any]) -> None:
|
def load_from_dict(self, config: Dict[str, Any]) -> None:
|
||||||
|
config = copy.deepcopy(config)
|
||||||
|
|
||||||
# Load simple properties
|
# Load simple properties
|
||||||
self.name = config.get('name', self.name)
|
if address := config.pop('address', None):
|
||||||
if address := config.get('address', None):
|
|
||||||
self.address = Address(address)
|
self.address = Address(address)
|
||||||
self.class_of_device = config.get('class_of_device', self.class_of_device)
|
|
||||||
self.advertising_interval_min = config.get(
|
|
||||||
'advertising_interval', self.advertising_interval_min
|
|
||||||
)
|
|
||||||
self.advertising_interval_max = self.advertising_interval_min
|
|
||||||
self.keystore = config.get('keystore')
|
|
||||||
self.le_enabled = config.get('le_enabled', self.le_enabled)
|
|
||||||
self.le_simultaneous_enabled = config.get(
|
|
||||||
'le_simultaneous_enabled', self.le_simultaneous_enabled
|
|
||||||
)
|
|
||||||
self.classic_enabled = config.get('classic_enabled', self.classic_enabled)
|
|
||||||
self.classic_sc_enabled = config.get(
|
|
||||||
'classic_sc_enabled', self.classic_sc_enabled
|
|
||||||
)
|
|
||||||
self.classic_ssp_enabled = config.get(
|
|
||||||
'classic_ssp_enabled', self.classic_ssp_enabled
|
|
||||||
)
|
|
||||||
self.classic_smp_enabled = config.get(
|
|
||||||
'classic_smp_enabled', self.classic_smp_enabled
|
|
||||||
)
|
|
||||||
self.classic_accept_any = config.get(
|
|
||||||
'classic_accept_any', self.classic_accept_any
|
|
||||||
)
|
|
||||||
self.connectable = config.get('connectable', self.connectable)
|
|
||||||
self.discoverable = config.get('discoverable', self.discoverable)
|
|
||||||
self.gatt_services = config.get('gatt_services', self.gatt_services)
|
|
||||||
self.address_resolution_offload = config.get(
|
|
||||||
'address_resolution_offload', self.address_resolution_offload
|
|
||||||
)
|
|
||||||
self.cis_enabled = config.get('cis_enabled', self.cis_enabled)
|
|
||||||
|
|
||||||
# Load or synthesize an IRK
|
# Load or synthesize an IRK
|
||||||
irk = config.get('irk')
|
if irk := config.pop('irk', None):
|
||||||
if irk:
|
|
||||||
self.irk = bytes.fromhex(irk)
|
self.irk = bytes.fromhex(irk)
|
||||||
elif self.address != Address(DEVICE_DEFAULT_ADDRESS):
|
elif self.address != Address(DEVICE_DEFAULT_ADDRESS):
|
||||||
# Construct an IRK from the address bytes
|
# Construct an IRK from the address bytes
|
||||||
@@ -1332,21 +1314,53 @@ class DeviceConfiguration:
|
|||||||
# Fallback - when both IRK and address are not set, randomly generate an IRK.
|
# Fallback - when both IRK and address are not set, randomly generate an IRK.
|
||||||
self.irk = secrets.token_bytes(16)
|
self.irk = secrets.token_bytes(16)
|
||||||
|
|
||||||
|
if (name := config.pop('name', None)) is not None:
|
||||||
|
self.name = name
|
||||||
|
|
||||||
# Load advertising data
|
# Load advertising data
|
||||||
advertising_data = config.get('advertising_data')
|
if advertising_data := config.pop('advertising_data', None):
|
||||||
if advertising_data:
|
|
||||||
self.advertising_data = bytes.fromhex(advertising_data)
|
self.advertising_data = bytes.fromhex(advertising_data)
|
||||||
elif config.get('name') is not None:
|
elif name is not None:
|
||||||
self.advertising_data = bytes(
|
self.advertising_data = bytes(
|
||||||
AdvertisingData(
|
AdvertisingData(
|
||||||
[(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))]
|
[(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def load_from_file(self, filename):
|
# Load advertising interval (for backward compatibility)
|
||||||
|
if advertising_interval := config.pop('advertising_interval', None):
|
||||||
|
self.advertising_interval_min = advertising_interval
|
||||||
|
self.advertising_interval_max = advertising_interval
|
||||||
|
if (
|
||||||
|
'advertising_interval_max' in config
|
||||||
|
or 'advertising_interval_min' in config
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
'Trying to set both advertising_interval and '
|
||||||
|
'advertising_interval_min/max, advertising_interval will be'
|
||||||
|
'ignored.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load data in primitive types.
|
||||||
|
for key, value in config.items():
|
||||||
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
def load_from_file(self, filename: str) -> None:
|
||||||
with open(filename, 'r', encoding='utf-8') as file:
|
with open(filename, 'r', encoding='utf-8') as file:
|
||||||
self.load_from_dict(json.load(file))
|
self.load_from_dict(json.load(file))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls: Type[Self], filename: str) -> Self:
|
||||||
|
config = cls()
|
||||||
|
config.load_from_file(filename)
|
||||||
|
return config
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls: Type[Self], config: Dict[str, Any]) -> Self:
|
||||||
|
device_config = cls()
|
||||||
|
device_config.load_from_dict(config)
|
||||||
|
return device_config
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Decorators used with the following Device class
|
# Decorators used with the following Device class
|
||||||
@@ -1470,8 +1484,7 @@ class Device(CompositeEventEmitter):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config_file(cls, filename: str) -> Device:
|
def from_config_file(cls, filename: str) -> Device:
|
||||||
config = DeviceConfiguration()
|
config = DeviceConfiguration.from_file(filename)
|
||||||
config.load_from_file(filename)
|
|
||||||
return cls(config=config)
|
return cls(config=config)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1488,8 +1501,7 @@ class Device(CompositeEventEmitter):
|
|||||||
def from_config_file_with_hci(
|
def from_config_file_with_hci(
|
||||||
cls, filename: str, hci_source: TransportSource, hci_sink: TransportSink
|
cls, filename: str, hci_source: TransportSource, hci_sink: TransportSink
|
||||||
) -> Device:
|
) -> Device:
|
||||||
config = DeviceConfiguration()
|
config = DeviceConfiguration.from_file(filename)
|
||||||
config.load_from_file(filename)
|
|
||||||
return cls.from_config_with_hci(config, hci_source, hci_sink)
|
return cls.from_config_with_hci(config, hci_source, hci_sink)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -1529,6 +1541,12 @@ class Device(CompositeEventEmitter):
|
|||||||
Address.ANY: []
|
Address.ANY: []
|
||||||
} # Futures, by BD address OR [Futures] for Address.ANY
|
} # Futures, by BD address OR [Futures] for Address.ANY
|
||||||
|
|
||||||
|
# In Python <= 3.9 + Rust Runtime, asyncio.Lock cannot be properly initiated.
|
||||||
|
if sys.version_info >= (3, 10):
|
||||||
|
self._cis_lock = asyncio.Lock()
|
||||||
|
else:
|
||||||
|
self._cis_lock = AsyncExitStack()
|
||||||
|
|
||||||
# Own address type cache
|
# Own address type cache
|
||||||
self.connect_own_address_type = None
|
self.connect_own_address_type = None
|
||||||
|
|
||||||
@@ -1723,16 +1741,14 @@ class Device(CompositeEventEmitter):
|
|||||||
self,
|
self,
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
spec: l2cap.ClassicChannelSpec,
|
spec: l2cap.ClassicChannelSpec,
|
||||||
) -> l2cap.ClassicChannel:
|
) -> l2cap.ClassicChannel: ...
|
||||||
...
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
async def create_l2cap_channel(
|
async def create_l2cap_channel(
|
||||||
self,
|
self,
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
spec: l2cap.LeCreditBasedChannelSpec,
|
spec: l2cap.LeCreditBasedChannelSpec,
|
||||||
) -> l2cap.LeCreditBasedChannel:
|
) -> l2cap.LeCreditBasedChannel: ...
|
||||||
...
|
|
||||||
|
|
||||||
async def create_l2cap_channel(
|
async def create_l2cap_channel(
|
||||||
self,
|
self,
|
||||||
@@ -1753,16 +1769,14 @@ class Device(CompositeEventEmitter):
|
|||||||
self,
|
self,
|
||||||
spec: l2cap.ClassicChannelSpec,
|
spec: l2cap.ClassicChannelSpec,
|
||||||
handler: Optional[Callable[[l2cap.ClassicChannel], Any]] = None,
|
handler: Optional[Callable[[l2cap.ClassicChannel], Any]] = None,
|
||||||
) -> l2cap.ClassicChannelServer:
|
) -> l2cap.ClassicChannelServer: ...
|
||||||
...
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def create_l2cap_server(
|
def create_l2cap_server(
|
||||||
self,
|
self,
|
||||||
spec: l2cap.LeCreditBasedChannelSpec,
|
spec: l2cap.LeCreditBasedChannelSpec,
|
||||||
handler: Optional[Callable[[l2cap.LeCreditBasedChannel], Any]] = None,
|
handler: Optional[Callable[[l2cap.LeCreditBasedChannel], Any]] = None,
|
||||||
) -> l2cap.LeCreditBasedChannelServer:
|
) -> l2cap.LeCreditBasedChannelServer: ...
|
||||||
...
|
|
||||||
|
|
||||||
def create_l2cap_server(
|
def create_l2cap_server(
|
||||||
self,
|
self,
|
||||||
@@ -2065,7 +2079,9 @@ class Device(CompositeEventEmitter):
|
|||||||
"""Stop legacy advertising."""
|
"""Stop legacy advertising."""
|
||||||
# Disable advertising
|
# Disable advertising
|
||||||
if self.legacy_advertising_set:
|
if self.legacy_advertising_set:
|
||||||
await self.legacy_advertising_set.stop()
|
if self.legacy_advertising_set.enabled:
|
||||||
|
await self.legacy_advertising_set.stop()
|
||||||
|
await self.legacy_advertising_set.remove()
|
||||||
self.legacy_advertising_set = None
|
self.legacy_advertising_set = None
|
||||||
elif self.legacy_advertiser:
|
elif self.legacy_advertiser:
|
||||||
await self.legacy_advertiser.stop()
|
await self.legacy_advertiser.stop()
|
||||||
@@ -2110,6 +2126,20 @@ class Device(CompositeEventEmitter):
|
|||||||
Returns:
|
Returns:
|
||||||
An AdvertisingSet instance.
|
An AdvertisingSet instance.
|
||||||
"""
|
"""
|
||||||
|
# Instantiate default values
|
||||||
|
if advertising_parameters is None:
|
||||||
|
advertising_parameters = AdvertisingParameters()
|
||||||
|
|
||||||
|
if (
|
||||||
|
not advertising_parameters.advertising_event_properties.is_legacy
|
||||||
|
and advertising_data
|
||||||
|
and scan_response_data
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Extended advertisements can't have both data and scan \
|
||||||
|
response data"
|
||||||
|
)
|
||||||
|
|
||||||
# Allocate a new handle
|
# Allocate a new handle
|
||||||
try:
|
try:
|
||||||
advertising_handle = next(
|
advertising_handle = next(
|
||||||
@@ -2123,10 +2153,6 @@ class Device(CompositeEventEmitter):
|
|||||||
except StopIteration as exc:
|
except StopIteration as exc:
|
||||||
raise RuntimeError("all valid advertising handles already in use") from exc
|
raise RuntimeError("all valid advertising handles already in use") from exc
|
||||||
|
|
||||||
# Instantiate default values
|
|
||||||
if advertising_parameters is None:
|
|
||||||
advertising_parameters = AdvertisingParameters()
|
|
||||||
|
|
||||||
# Use the device's random address if a random address is needed but none was
|
# Use the device's random address if a random address is needed but none was
|
||||||
# provided.
|
# provided.
|
||||||
if (
|
if (
|
||||||
@@ -2176,7 +2202,7 @@ class Device(CompositeEventEmitter):
|
|||||||
# controller.
|
# controller.
|
||||||
await self.send_command(
|
await self.send_command(
|
||||||
HCI_LE_Remove_Advertising_Set_Command(
|
HCI_LE_Remove_Advertising_Set_Command(
|
||||||
advertising_handle=advertising_data
|
advertising_handle=advertising_handle
|
||||||
),
|
),
|
||||||
check_result=False,
|
check_result=False,
|
||||||
)
|
)
|
||||||
@@ -2207,9 +2233,6 @@ class Device(CompositeEventEmitter):
|
|||||||
if self.legacy_advertiser:
|
if self.legacy_advertiser:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if self.legacy_advertising_set and self.legacy_advertising_set.enabled:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return any(
|
return any(
|
||||||
advertising_set.enabled
|
advertising_set.enabled
|
||||||
for advertising_set in self.extended_advertising_sets.values()
|
for advertising_set in self.extended_advertising_sets.values()
|
||||||
@@ -2223,7 +2246,7 @@ class Device(CompositeEventEmitter):
|
|||||||
scan_window: int = DEVICE_DEFAULT_SCAN_WINDOW, # Scan window in ms
|
scan_window: int = DEVICE_DEFAULT_SCAN_WINDOW, # Scan window in ms
|
||||||
own_address_type: int = OwnAddressType.RANDOM,
|
own_address_type: int = OwnAddressType.RANDOM,
|
||||||
filter_duplicates: bool = False,
|
filter_duplicates: bool = False,
|
||||||
scanning_phys: Tuple[int, int] = (HCI_LE_1M_PHY, HCI_LE_CODED_PHY),
|
scanning_phys: List[int] = [HCI_LE_1M_PHY, HCI_LE_CODED_PHY],
|
||||||
) -> None:
|
) -> None:
|
||||||
# Check that the arguments are legal
|
# Check that the arguments are legal
|
||||||
if scan_interval < scan_window:
|
if scan_interval < scan_window:
|
||||||
@@ -3280,17 +3303,19 @@ class Device(CompositeEventEmitter):
|
|||||||
|
|
||||||
handler = self.on(
|
handler = self.on(
|
||||||
'remote_name',
|
'remote_name',
|
||||||
lambda address, remote_name: pending_name.set_result(remote_name)
|
lambda address, remote_name: (
|
||||||
if address == peer_address
|
pending_name.set_result(remote_name)
|
||||||
else None,
|
if address == peer_address
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
failure_handler = self.on(
|
failure_handler = self.on(
|
||||||
'remote_name_failure',
|
'remote_name_failure',
|
||||||
lambda address, error_code: pending_name.set_exception(
|
lambda address, error_code: (
|
||||||
HCI_Error(error_code)
|
pending_name.set_exception(HCI_Error(error_code))
|
||||||
)
|
if address == peer_address
|
||||||
if address == peer_address
|
else None
|
||||||
else None,
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -3395,49 +3420,71 @@ class Device(CompositeEventEmitter):
|
|||||||
for cis_handle, _ in cis_acl_pairs
|
for cis_handle, _ in cis_acl_pairs
|
||||||
}
|
}
|
||||||
|
|
||||||
@watcher.on(self, 'cis_establishment')
|
|
||||||
def on_cis_establishment(cis_link: CisLink) -> None:
|
def on_cis_establishment(cis_link: CisLink) -> None:
|
||||||
if pending_future := pending_cis_establishments.get(cis_link.handle):
|
if pending_future := pending_cis_establishments.get(cis_link.handle):
|
||||||
pending_future.set_result(cis_link)
|
pending_future.set_result(cis_link)
|
||||||
|
|
||||||
result = await self.send_command(
|
def on_cis_establishment_failure(cis_handle: int, status: int) -> None:
|
||||||
|
if pending_future := pending_cis_establishments.get(cis_handle):
|
||||||
|
pending_future.set_exception(HCI_Error(status))
|
||||||
|
|
||||||
|
watcher.on(self, 'cis_establishment', on_cis_establishment)
|
||||||
|
watcher.on(self, 'cis_establishment_failure', on_cis_establishment_failure)
|
||||||
|
await self.send_command(
|
||||||
HCI_LE_Create_CIS_Command(
|
HCI_LE_Create_CIS_Command(
|
||||||
cis_connection_handle=[p[0] for p in cis_acl_pairs],
|
cis_connection_handle=[p[0] for p in cis_acl_pairs],
|
||||||
acl_connection_handle=[p[1] for p in cis_acl_pairs],
|
acl_connection_handle=[p[1] for p in cis_acl_pairs],
|
||||||
),
|
),
|
||||||
|
check_result=True,
|
||||||
)
|
)
|
||||||
if result.status != HCI_COMMAND_STATUS_PENDING:
|
|
||||||
logger.warning(
|
|
||||||
'HCI_LE_Create_CIS_Command failed: '
|
|
||||||
f'{HCI_Constant.error_name(result.status)}'
|
|
||||||
)
|
|
||||||
raise HCI_StatusError(result)
|
|
||||||
|
|
||||||
return await asyncio.gather(*pending_cis_establishments.values())
|
return await asyncio.gather(*pending_cis_establishments.values())
|
||||||
|
|
||||||
# [LE only]
|
# [LE only]
|
||||||
@experimental('Only for testing.')
|
@experimental('Only for testing.')
|
||||||
async def accept_cis_request(self, handle: int) -> CisLink:
|
async def accept_cis_request(self, handle: int) -> CisLink:
|
||||||
result = await self.send_command(
|
"""[LE Only] Accepts an incoming CIS request.
|
||||||
HCI_LE_Accept_CIS_Request_Command(connection_handle=handle),
|
|
||||||
)
|
|
||||||
if result.status != HCI_COMMAND_STATUS_PENDING:
|
|
||||||
logger.warning(
|
|
||||||
'HCI_LE_Accept_CIS_Request_Command failed: '
|
|
||||||
f'{HCI_Constant.error_name(result.status)}'
|
|
||||||
)
|
|
||||||
raise HCI_StatusError(result)
|
|
||||||
|
|
||||||
pending_cis_establishment = asyncio.get_running_loop().create_future()
|
When the specified CIS handle is already created, this method returns the
|
||||||
|
existed CIS link object immediately.
|
||||||
|
|
||||||
with closing(EventWatcher()) as watcher:
|
Args:
|
||||||
|
handle: CIS handle to accept.
|
||||||
|
|
||||||
@watcher.on(self, 'cis_establishment')
|
Returns:
|
||||||
def on_cis_establishment(cis_link: CisLink) -> None:
|
CIS link object on the given handle.
|
||||||
if cis_link.handle == handle:
|
"""
|
||||||
pending_cis_establishment.set_result(cis_link)
|
if not (cis_link := self.cis_links.get(handle)):
|
||||||
|
raise InvalidStateError(f'No pending CIS request of handle {handle}')
|
||||||
|
|
||||||
return await pending_cis_establishment
|
# There might be multiple ASE sharing a CIS channel.
|
||||||
|
# If one of them has accepted the request, the others should just leverage it.
|
||||||
|
async with self._cis_lock:
|
||||||
|
if cis_link.state == CisLink.State.ESTABLISHED:
|
||||||
|
return cis_link
|
||||||
|
|
||||||
|
with closing(EventWatcher()) as watcher:
|
||||||
|
pending_establishment = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
|
def on_establishment() -> None:
|
||||||
|
pending_establishment.set_result(None)
|
||||||
|
|
||||||
|
def on_establishment_failure(status: int) -> None:
|
||||||
|
pending_establishment.set_exception(HCI_Error(status))
|
||||||
|
|
||||||
|
watcher.on(cis_link, 'establishment', on_establishment)
|
||||||
|
watcher.on(cis_link, 'establishment_failure', on_establishment_failure)
|
||||||
|
|
||||||
|
await self.send_command(
|
||||||
|
HCI_LE_Accept_CIS_Request_Command(connection_handle=handle),
|
||||||
|
check_result=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await pending_establishment
|
||||||
|
return cis_link
|
||||||
|
|
||||||
|
# Mypy believes this is reachable when context is an ExitStack.
|
||||||
|
raise InvalidStateError('Unreachable')
|
||||||
|
|
||||||
# [LE only]
|
# [LE only]
|
||||||
@experimental('Only for testing.')
|
@experimental('Only for testing.')
|
||||||
@@ -3446,15 +3493,10 @@ class Device(CompositeEventEmitter):
|
|||||||
handle: int,
|
handle: int,
|
||||||
reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||||
) -> None:
|
) -> None:
|
||||||
result = await self.send_command(
|
await self.send_command(
|
||||||
HCI_LE_Reject_CIS_Request_Command(connection_handle=handle, reason=reason),
|
HCI_LE_Reject_CIS_Request_Command(connection_handle=handle, reason=reason),
|
||||||
|
check_result=True,
|
||||||
)
|
)
|
||||||
if result.status != HCI_COMMAND_STATUS_PENDING:
|
|
||||||
logger.warning(
|
|
||||||
'HCI_LE_Reject_CIS_Request_Command failed: '
|
|
||||||
f'{HCI_Constant.error_name(result.status)}'
|
|
||||||
)
|
|
||||||
raise HCI_StatusError(result)
|
|
||||||
|
|
||||||
async def get_remote_le_features(self, connection: Connection) -> LeFeatureMask:
|
async def get_remote_le_features(self, connection: Connection) -> LeFeatureMask:
|
||||||
"""[LE Only] Reads remote LE supported features.
|
"""[LE Only] Reads remote LE supported features.
|
||||||
@@ -3466,19 +3508,25 @@ class Device(CompositeEventEmitter):
|
|||||||
LE features supported by the remote device.
|
LE features supported by the remote device.
|
||||||
"""
|
"""
|
||||||
with closing(EventWatcher()) as watcher:
|
with closing(EventWatcher()) as watcher:
|
||||||
read_feature_future: asyncio.Future[
|
read_feature_future: asyncio.Future[LeFeatureMask] = (
|
||||||
LeFeatureMask
|
asyncio.get_running_loop().create_future()
|
||||||
] = asyncio.get_running_loop().create_future()
|
)
|
||||||
|
|
||||||
def on_le_remote_features(handle: int, features: int):
|
def on_le_remote_features(handle: int, features: int):
|
||||||
if handle == connection.handle:
|
if handle == connection.handle:
|
||||||
read_feature_future.set_result(LeFeatureMask(features))
|
read_feature_future.set_result(LeFeatureMask(features))
|
||||||
|
|
||||||
|
def on_failure(handle: int, status: int):
|
||||||
|
if handle == connection.handle:
|
||||||
|
read_feature_future.set_exception(HCI_Error(status))
|
||||||
|
|
||||||
watcher.on(self.host, 'le_remote_features', on_le_remote_features)
|
watcher.on(self.host, 'le_remote_features', on_le_remote_features)
|
||||||
|
watcher.on(self.host, 'le_remote_features_failure', on_failure)
|
||||||
await self.send_command(
|
await self.send_command(
|
||||||
HCI_LE_Read_Remote_Features_Command(
|
HCI_LE_Read_Remote_Features_Command(
|
||||||
connection_handle=connection.handle
|
connection_handle=connection.handle
|
||||||
),
|
),
|
||||||
|
check_result=True,
|
||||||
)
|
)
|
||||||
return await read_feature_future
|
return await read_feature_future
|
||||||
|
|
||||||
@@ -3541,11 +3589,9 @@ class Device(CompositeEventEmitter):
|
|||||||
connection_handle,
|
connection_handle,
|
||||||
number_of_completed_extended_advertising_events,
|
number_of_completed_extended_advertising_events,
|
||||||
):
|
):
|
||||||
|
# Legacy advertising set is also one of extended advertising sets.
|
||||||
if not (
|
if not (
|
||||||
advertising_set := (
|
advertising_set := self.extended_advertising_sets.get(advertising_handle)
|
||||||
self.extended_advertising_sets.get(advertising_handle)
|
|
||||||
or self.legacy_advertising_set
|
|
||||||
)
|
|
||||||
):
|
):
|
||||||
logger.warning(f'advertising set {advertising_handle} not found')
|
logger.warning(f'advertising set {advertising_handle} not found')
|
||||||
return
|
return
|
||||||
@@ -4102,8 +4148,8 @@ class Device(CompositeEventEmitter):
|
|||||||
@host_event_handler
|
@host_event_handler
|
||||||
@experimental('Only for testing')
|
@experimental('Only for testing')
|
||||||
def on_sco_packet(self, sco_handle: int, packet: HCI_SynchronousDataPacket) -> None:
|
def on_sco_packet(self, sco_handle: int, packet: HCI_SynchronousDataPacket) -> None:
|
||||||
if sco_link := self.sco_links.get(sco_handle):
|
if (sco_link := self.sco_links.get(sco_handle)) and sco_link.sink:
|
||||||
sco_link.emit('pdu', packet)
|
sco_link.sink(packet)
|
||||||
|
|
||||||
# [LE only]
|
# [LE only]
|
||||||
@host_event_handler
|
@host_event_handler
|
||||||
@@ -4159,15 +4205,15 @@ class Device(CompositeEventEmitter):
|
|||||||
def on_cis_establishment_failure(self, cis_handle: int, status: int) -> None:
|
def on_cis_establishment_failure(self, cis_handle: int, status: int) -> None:
|
||||||
logger.debug(f'*** CIS Establishment Failure: cis=[0x{cis_handle:04X}] ***')
|
logger.debug(f'*** CIS Establishment Failure: cis=[0x{cis_handle:04X}] ***')
|
||||||
if cis_link := self.cis_links.pop(cis_handle):
|
if cis_link := self.cis_links.pop(cis_handle):
|
||||||
cis_link.emit('establishment_failure')
|
cis_link.emit('establishment_failure', status)
|
||||||
self.emit('cis_establishment_failure', cis_handle, status)
|
self.emit('cis_establishment_failure', cis_handle, status)
|
||||||
|
|
||||||
# [LE only]
|
# [LE only]
|
||||||
@host_event_handler
|
@host_event_handler
|
||||||
@experimental('Only for testing')
|
@experimental('Only for testing')
|
||||||
def on_iso_packet(self, handle: int, packet: HCI_IsoDataPacket) -> None:
|
def on_iso_packet(self, handle: int, packet: HCI_IsoDataPacket) -> None:
|
||||||
if cis_link := self.cis_links.get(handle):
|
if (cis_link := self.cis_links.get(handle)) and cis_link.sink:
|
||||||
cis_link.emit('pdu', packet)
|
cis_link.sink(packet)
|
||||||
|
|
||||||
@host_event_handler
|
@host_event_handler
|
||||||
@with_connection_from_handle
|
@with_connection_from_handle
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import pathlib
|
|||||||
import platform
|
import platform
|
||||||
from typing import Dict, Iterable, Optional, Type, TYPE_CHECKING
|
from typing import Dict, Iterable, Optional, Type, TYPE_CHECKING
|
||||||
|
|
||||||
from . import rtk
|
from . import rtk, intel
|
||||||
from .common import Driver
|
from .common import Driver
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -45,7 +45,7 @@ async def get_driver_for_host(host: Host) -> Optional[Driver]:
|
|||||||
found.
|
found.
|
||||||
If a "driver" HCI metadata entry is present, only that driver class will be probed.
|
If a "driver" HCI metadata entry is present, only that driver class will be probed.
|
||||||
"""
|
"""
|
||||||
driver_classes: Dict[str, Type[Driver]] = {"rtk": rtk.Driver}
|
driver_classes: Dict[str, Type[Driver]] = {"rtk": rtk.Driver, "intel": intel.Driver}
|
||||||
probe_list: Iterable[str]
|
probe_list: Iterable[str]
|
||||||
if driver_name := host.hci_metadata.get("driver"):
|
if driver_name := host.hci_metadata.get("driver"):
|
||||||
# Only probe a single driver
|
# Only probe a single driver
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Copyright 2024 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from bumble.drivers import common
|
||||||
|
from bumble.hci import (
|
||||||
|
hci_vendor_command_op_code, # type: ignore
|
||||||
|
HCI_Command,
|
||||||
|
HCI_Reset_Command,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constant
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INTEL_USB_PRODUCTS = {
|
||||||
|
# Intel AX210
|
||||||
|
(0x8087, 0x0032),
|
||||||
|
# Intel BE200
|
||||||
|
(0x8087, 0x0036),
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# HCI Commands
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
HCI_INTEL_DDC_CONFIG_WRITE_COMMAND = hci_vendor_command_op_code(0xFC8B) # type: ignore
|
||||||
|
HCI_INTEL_DDC_CONFIG_WRITE_PAYLOAD = [0x03, 0xE4, 0x02, 0x00]
|
||||||
|
|
||||||
|
HCI_Command.register_commands(globals())
|
||||||
|
|
||||||
|
|
||||||
|
@HCI_Command.command( # type: ignore
|
||||||
|
fields=[("params", "*")],
|
||||||
|
return_parameters_fields=[
|
||||||
|
("params", "*"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class Hci_Intel_DDC_Config_Write_Command(HCI_Command):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Driver(common.Driver):
|
||||||
|
def __init__(self, host):
|
||||||
|
self.host = host
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check(host):
|
||||||
|
driver = host.hci_metadata.get("driver")
|
||||||
|
if driver == "intel":
|
||||||
|
return True
|
||||||
|
|
||||||
|
vendor_id = host.hci_metadata.get("vendor_id")
|
||||||
|
product_id = host.hci_metadata.get("product_id")
|
||||||
|
|
||||||
|
if vendor_id is None or product_id is None:
|
||||||
|
logger.debug("USB metadata not sufficient")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if (vendor_id, product_id) not in INTEL_USB_PRODUCTS:
|
||||||
|
logger.debug(
|
||||||
|
f"USB device ({vendor_id:04X}, {product_id:04X}) " "not in known list"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def for_host(cls, host, force=False): # type: ignore
|
||||||
|
# Only instantiate this driver if explicitly selected
|
||||||
|
if not force and not cls.check(host):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return cls(host)
|
||||||
|
|
||||||
|
async def init_controller(self):
|
||||||
|
self.host.ready = True
|
||||||
|
await self.host.send_command(HCI_Reset_Command(), check_result=True)
|
||||||
|
await self.host.send_command(
|
||||||
|
Hci_Intel_DDC_Config_Write_Command(
|
||||||
|
params=HCI_INTEL_DDC_CONFIG_WRITE_PAYLOAD
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -36,6 +36,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Classes
|
# Classes
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class GenericAccessService(Service):
|
class GenericAccessService(Service):
|
||||||
def __init__(self, device_name, appearance=(0, 0)):
|
def __init__(self, device_name, appearance=(0, 0)):
|
||||||
|
|||||||
+8
-6
@@ -342,9 +342,11 @@ class Service(Attribute):
|
|||||||
uuid = UUID(uuid)
|
uuid = UUID(uuid)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE
|
(
|
||||||
if primary
|
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE
|
||||||
else GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
|
if primary
|
||||||
|
else GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE
|
||||||
|
),
|
||||||
Attribute.READABLE,
|
Attribute.READABLE,
|
||||||
uuid.to_pdu_bytes(),
|
uuid.to_pdu_bytes(),
|
||||||
)
|
)
|
||||||
@@ -560,9 +562,9 @@ class CharacteristicAdapter:
|
|||||||
|
|
||||||
def __init__(self, characteristic: Union[Characteristic, AttributeProxy]):
|
def __init__(self, characteristic: Union[Characteristic, AttributeProxy]):
|
||||||
self.wrapped_characteristic = characteristic
|
self.wrapped_characteristic = characteristic
|
||||||
self.subscribers: Dict[
|
self.subscribers: Dict[Callable, Callable] = (
|
||||||
Callable, Callable
|
{}
|
||||||
] = {} # Map from subscriber to proxy subscriber
|
) # Map from subscriber to proxy subscriber
|
||||||
|
|
||||||
if isinstance(characteristic, Characteristic):
|
if isinstance(characteristic, Characteristic):
|
||||||
self.read_value = self.read_encoded_value
|
self.read_value = self.read_encoded_value
|
||||||
|
|||||||
+17
-3
@@ -90,6 +90,22 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Utils
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def show_services(services: Iterable[ServiceProxy]) -> None:
|
||||||
|
for service in services:
|
||||||
|
print(color(str(service), 'cyan'))
|
||||||
|
|
||||||
|
for characteristic in service.characteristics:
|
||||||
|
print(color(' ' + str(characteristic), 'magenta'))
|
||||||
|
|
||||||
|
for descriptor in characteristic.descriptors:
|
||||||
|
print(color(' ' + str(descriptor), 'green'))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Proxies
|
# Proxies
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -352,9 +368,7 @@ class Client:
|
|||||||
if c.uuid == uuid
|
if c.uuid == uuid
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_attribute_grouping(
|
def get_attribute_grouping(self, attribute_handle: int) -> Optional[
|
||||||
self, attribute_handle: int
|
|
||||||
) -> Optional[
|
|
||||||
Union[
|
Union[
|
||||||
ServiceProxy,
|
ServiceProxy,
|
||||||
Tuple[ServiceProxy, CharacteristicProxy],
|
Tuple[ServiceProxy, CharacteristicProxy],
|
||||||
|
|||||||
@@ -445,9 +445,9 @@ class Server(EventEmitter):
|
|||||||
assert self.pending_confirmations[connection.handle] is None
|
assert self.pending_confirmations[connection.handle] is None
|
||||||
|
|
||||||
# Create a future value to hold the eventual response
|
# Create a future value to hold the eventual response
|
||||||
pending_confirmation = self.pending_confirmations[
|
pending_confirmation = self.pending_confirmations[connection.handle] = (
|
||||||
connection.handle
|
asyncio.get_running_loop().create_future()
|
||||||
] = asyncio.get_running_loop().create_future()
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.send_gatt_pdu(connection.handle, indication.to_bytes())
|
self.send_gatt_pdu(connection.handle, indication.to_bytes())
|
||||||
|
|||||||
+335
-565
@@ -23,7 +23,7 @@ import functools
|
|||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
import struct
|
import struct
|
||||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Type, Union
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Type, Union, ClassVar
|
||||||
|
|
||||||
from bumble import crypto
|
from bumble import crypto
|
||||||
from .colors import color
|
from .colors import color
|
||||||
@@ -790,538 +790,317 @@ HCI_RANDOM_DEVICE_ADDRESS_TYPE = 0x01
|
|||||||
HCI_PUBLIC_IDENTITY_ADDRESS_TYPE = 0x02
|
HCI_PUBLIC_IDENTITY_ADDRESS_TYPE = 0x02
|
||||||
HCI_RANDOM_IDENTITY_ADDRESS_TYPE = 0x03
|
HCI_RANDOM_IDENTITY_ADDRESS_TYPE = 0x03
|
||||||
|
|
||||||
# Supported Commands Flags
|
# Supported Commands Masks
|
||||||
# See Bluetooth spec @ 6.27 SUPPORTED COMMANDS
|
# See Bluetooth spec @ 6.27 SUPPORTED COMMANDS
|
||||||
HCI_SUPPORTED_COMMANDS_FLAGS = (
|
HCI_SUPPORTED_COMMANDS_MASKS = {
|
||||||
# Octet 0
|
HCI_INQUIRY_COMMAND : 1 << (0*8+0),
|
||||||
(
|
HCI_INQUIRY_CANCEL_COMMAND : 1 << (0*8+1),
|
||||||
HCI_INQUIRY_COMMAND,
|
HCI_PERIODIC_INQUIRY_MODE_COMMAND : 1 << (0*8+2),
|
||||||
HCI_INQUIRY_CANCEL_COMMAND,
|
HCI_EXIT_PERIODIC_INQUIRY_MODE_COMMAND : 1 << (0*8+3),
|
||||||
HCI_PERIODIC_INQUIRY_MODE_COMMAND,
|
HCI_CREATE_CONNECTION_COMMAND : 1 << (0*8+4),
|
||||||
HCI_EXIT_PERIODIC_INQUIRY_MODE_COMMAND,
|
HCI_DISCONNECT_COMMAND : 1 << (0*8+5),
|
||||||
HCI_CREATE_CONNECTION_COMMAND,
|
HCI_CREATE_CONNECTION_CANCEL_COMMAND : 1 << (0*8+7),
|
||||||
HCI_DISCONNECT_COMMAND,
|
HCI_ACCEPT_CONNECTION_REQUEST_COMMAND : 1 << (1*8+0),
|
||||||
None,
|
HCI_REJECT_CONNECTION_REQUEST_COMMAND : 1 << (1*8+1),
|
||||||
HCI_CREATE_CONNECTION_CANCEL_COMMAND
|
HCI_LINK_KEY_REQUEST_REPLY_COMMAND : 1 << (1*8+2),
|
||||||
),
|
HCI_LINK_KEY_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (1*8+3),
|
||||||
# Octet 1
|
HCI_PIN_CODE_REQUEST_REPLY_COMMAND : 1 << (1*8+4),
|
||||||
(
|
HCI_PIN_CODE_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (1*8+5),
|
||||||
HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
|
HCI_CHANGE_CONNECTION_PACKET_TYPE_COMMAND : 1 << (1*8+6),
|
||||||
HCI_REJECT_CONNECTION_REQUEST_COMMAND,
|
HCI_AUTHENTICATION_REQUESTED_COMMAND : 1 << (1*8+7),
|
||||||
HCI_LINK_KEY_REQUEST_REPLY_COMMAND,
|
HCI_SET_CONNECTION_ENCRYPTION_COMMAND : 1 << (2*8+0),
|
||||||
HCI_LINK_KEY_REQUEST_NEGATIVE_REPLY_COMMAND,
|
HCI_CHANGE_CONNECTION_LINK_KEY_COMMAND : 1 << (2*8+1),
|
||||||
HCI_PIN_CODE_REQUEST_REPLY_COMMAND,
|
HCI_LINK_KEY_SELECTION_COMMAND : 1 << (2*8+2),
|
||||||
HCI_PIN_CODE_REQUEST_NEGATIVE_REPLY_COMMAND,
|
HCI_REMOTE_NAME_REQUEST_COMMAND : 1 << (2*8+3),
|
||||||
HCI_CHANGE_CONNECTION_PACKET_TYPE_COMMAND,
|
HCI_REMOTE_NAME_REQUEST_CANCEL_COMMAND : 1 << (2*8+4),
|
||||||
HCI_AUTHENTICATION_REQUESTED_COMMAND
|
HCI_READ_REMOTE_SUPPORTED_FEATURES_COMMAND : 1 << (2*8+5),
|
||||||
),
|
HCI_READ_REMOTE_EXTENDED_FEATURES_COMMAND : 1 << (2*8+6),
|
||||||
# Octet 2
|
HCI_READ_REMOTE_VERSION_INFORMATION_COMMAND : 1 << (2*8+7),
|
||||||
(
|
HCI_READ_CLOCK_OFFSET_COMMAND : 1 << (3*8+0),
|
||||||
HCI_SET_CONNECTION_ENCRYPTION_COMMAND,
|
HCI_READ_LMP_HANDLE_COMMAND : 1 << (3*8+1),
|
||||||
HCI_CHANGE_CONNECTION_LINK_KEY_COMMAND,
|
HCI_HOLD_MODE_COMMAND : 1 << (4*8+1),
|
||||||
HCI_LINK_KEY_SELECTION_COMMAND,
|
HCI_SNIFF_MODE_COMMAND : 1 << (4*8+2),
|
||||||
HCI_REMOTE_NAME_REQUEST_COMMAND,
|
HCI_EXIT_SNIFF_MODE_COMMAND : 1 << (4*8+3),
|
||||||
HCI_REMOTE_NAME_REQUEST_CANCEL_COMMAND,
|
HCI_QOS_SETUP_COMMAND : 1 << (4*8+6),
|
||||||
HCI_READ_REMOTE_SUPPORTED_FEATURES_COMMAND,
|
HCI_ROLE_DISCOVERY_COMMAND : 1 << (4*8+7),
|
||||||
HCI_READ_REMOTE_EXTENDED_FEATURES_COMMAND,
|
HCI_SWITCH_ROLE_COMMAND : 1 << (5*8+0),
|
||||||
HCI_READ_REMOTE_VERSION_INFORMATION_COMMAND
|
HCI_READ_LINK_POLICY_SETTINGS_COMMAND : 1 << (5*8+1),
|
||||||
),
|
HCI_WRITE_LINK_POLICY_SETTINGS_COMMAND : 1 << (5*8+2),
|
||||||
# Octet 3
|
HCI_READ_DEFAULT_LINK_POLICY_SETTINGS_COMMAND : 1 << (5*8+3),
|
||||||
(
|
HCI_WRITE_DEFAULT_LINK_POLICY_SETTINGS_COMMAND : 1 << (5*8+4),
|
||||||
HCI_READ_CLOCK_OFFSET_COMMAND,
|
HCI_FLOW_SPECIFICATION_COMMAND : 1 << (5*8+5),
|
||||||
HCI_READ_LMP_HANDLE_COMMAND,
|
HCI_SET_EVENT_MASK_COMMAND : 1 << (5*8+6),
|
||||||
None,
|
HCI_RESET_COMMAND : 1 << (5*8+7),
|
||||||
None,
|
HCI_SET_EVENT_FILTER_COMMAND : 1 << (6*8+0),
|
||||||
None,
|
HCI_FLUSH_COMMAND : 1 << (6*8+1),
|
||||||
None,
|
HCI_READ_PIN_TYPE_COMMAND : 1 << (6*8+2),
|
||||||
None,
|
HCI_WRITE_PIN_TYPE_COMMAND : 1 << (6*8+3),
|
||||||
None
|
HCI_READ_STORED_LINK_KEY_COMMAND : 1 << (6*8+5),
|
||||||
),
|
HCI_WRITE_STORED_LINK_KEY_COMMAND : 1 << (6*8+6),
|
||||||
# Octet 4
|
HCI_DELETE_STORED_LINK_KEY_COMMAND : 1 << (6*8+7),
|
||||||
(
|
HCI_WRITE_LOCAL_NAME_COMMAND : 1 << (7*8+0),
|
||||||
None,
|
HCI_READ_LOCAL_NAME_COMMAND : 1 << (7*8+1),
|
||||||
HCI_HOLD_MODE_COMMAND,
|
HCI_READ_CONNECTION_ACCEPT_TIMEOUT_COMMAND : 1 << (7*8+2),
|
||||||
HCI_SNIFF_MODE_COMMAND,
|
HCI_WRITE_CONNECTION_ACCEPT_TIMEOUT_COMMAND : 1 << (7*8+3),
|
||||||
HCI_EXIT_SNIFF_MODE_COMMAND,
|
HCI_READ_PAGE_TIMEOUT_COMMAND : 1 << (7*8+4),
|
||||||
None,
|
HCI_WRITE_PAGE_TIMEOUT_COMMAND : 1 << (7*8+5),
|
||||||
None,
|
HCI_READ_SCAN_ENABLE_COMMAND : 1 << (7*8+6),
|
||||||
HCI_QOS_SETUP_COMMAND,
|
HCI_WRITE_SCAN_ENABLE_COMMAND : 1 << (7*8+7),
|
||||||
HCI_ROLE_DISCOVERY_COMMAND
|
HCI_READ_PAGE_SCAN_ACTIVITY_COMMAND : 1 << (8*8+0),
|
||||||
),
|
HCI_WRITE_PAGE_SCAN_ACTIVITY_COMMAND : 1 << (8*8+1),
|
||||||
# Octet 5
|
HCI_READ_INQUIRY_SCAN_ACTIVITY_COMMAND : 1 << (8*8+2),
|
||||||
(
|
HCI_WRITE_INQUIRY_SCAN_ACTIVITY_COMMAND : 1 << (8*8+3),
|
||||||
HCI_SWITCH_ROLE_COMMAND,
|
HCI_READ_AUTHENTICATION_ENABLE_COMMAND : 1 << (8*8+4),
|
||||||
HCI_READ_LINK_POLICY_SETTINGS_COMMAND,
|
HCI_WRITE_AUTHENTICATION_ENABLE_COMMAND : 1 << (8*8+5),
|
||||||
HCI_WRITE_LINK_POLICY_SETTINGS_COMMAND,
|
HCI_READ_CLASS_OF_DEVICE_COMMAND : 1 << (9*8+0),
|
||||||
HCI_READ_DEFAULT_LINK_POLICY_SETTINGS_COMMAND,
|
HCI_WRITE_CLASS_OF_DEVICE_COMMAND : 1 << (9*8+1),
|
||||||
HCI_WRITE_DEFAULT_LINK_POLICY_SETTINGS_COMMAND,
|
HCI_READ_VOICE_SETTING_COMMAND : 1 << (9*8+2),
|
||||||
HCI_FLOW_SPECIFICATION_COMMAND,
|
HCI_WRITE_VOICE_SETTING_COMMAND : 1 << (9*8+3),
|
||||||
HCI_SET_EVENT_MASK_COMMAND,
|
HCI_READ_AUTOMATIC_FLUSH_TIMEOUT_COMMAND : 1 << (9*8+4),
|
||||||
HCI_RESET_COMMAND
|
HCI_WRITE_AUTOMATIC_FLUSH_TIMEOUT_COMMAND : 1 << (9*8+5),
|
||||||
),
|
HCI_READ_NUM_BROADCAST_RETRANSMISSIONS_COMMAND : 1 << (9*8+6),
|
||||||
# Octet 6
|
HCI_WRITE_NUM_BROADCAST_RETRANSMISSIONS_COMMAND : 1 << (9*8+7),
|
||||||
(
|
HCI_READ_HOLD_MODE_ACTIVITY_COMMAND : 1 << (10*8+0),
|
||||||
HCI_SET_EVENT_FILTER_COMMAND,
|
HCI_WRITE_HOLD_MODE_ACTIVITY_COMMAND : 1 << (10*8+1),
|
||||||
HCI_FLUSH_COMMAND,
|
HCI_READ_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (10*8+2),
|
||||||
HCI_READ_PIN_TYPE_COMMAND,
|
HCI_READ_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND : 1 << (10*8+3),
|
||||||
HCI_WRITE_PIN_TYPE_COMMAND,
|
HCI_WRITE_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND : 1 << (10*8+4),
|
||||||
None,
|
HCI_SET_CONTROLLER_TO_HOST_FLOW_CONTROL_COMMAND : 1 << (10*8+5),
|
||||||
HCI_READ_STORED_LINK_KEY_COMMAND,
|
HCI_HOST_BUFFER_SIZE_COMMAND : 1 << (10*8+6),
|
||||||
HCI_WRITE_STORED_LINK_KEY_COMMAND,
|
HCI_HOST_NUMBER_OF_COMPLETED_PACKETS_COMMAND : 1 << (10*8+7),
|
||||||
HCI_DELETE_STORED_LINK_KEY_COMMAND
|
HCI_READ_LINK_SUPERVISION_TIMEOUT_COMMAND : 1 << (11*8+0),
|
||||||
),
|
HCI_WRITE_LINK_SUPERVISION_TIMEOUT_COMMAND : 1 << (11*8+1),
|
||||||
# Octet 7
|
HCI_READ_NUMBER_OF_SUPPORTED_IAC_COMMAND : 1 << (11*8+2),
|
||||||
(
|
HCI_READ_CURRENT_IAC_LAP_COMMAND : 1 << (11*8+3),
|
||||||
HCI_WRITE_LOCAL_NAME_COMMAND,
|
HCI_WRITE_CURRENT_IAC_LAP_COMMAND : 1 << (11*8+4),
|
||||||
HCI_READ_LOCAL_NAME_COMMAND,
|
HCI_SET_AFH_HOST_CHANNEL_CLASSIFICATION_COMMAND : 1 << (12*8+1),
|
||||||
HCI_READ_CONNECTION_ACCEPT_TIMEOUT_COMMAND,
|
HCI_READ_INQUIRY_SCAN_TYPE_COMMAND : 1 << (12*8+4),
|
||||||
HCI_WRITE_CONNECTION_ACCEPT_TIMEOUT_COMMAND,
|
HCI_WRITE_INQUIRY_SCAN_TYPE_COMMAND : 1 << (12*8+5),
|
||||||
HCI_READ_PAGE_TIMEOUT_COMMAND,
|
HCI_READ_INQUIRY_MODE_COMMAND : 1 << (12*8+6),
|
||||||
HCI_WRITE_PAGE_TIMEOUT_COMMAND,
|
HCI_WRITE_INQUIRY_MODE_COMMAND : 1 << (12*8+7),
|
||||||
HCI_READ_SCAN_ENABLE_COMMAND,
|
HCI_READ_PAGE_SCAN_TYPE_COMMAND : 1 << (13*8+0),
|
||||||
HCI_WRITE_SCAN_ENABLE_COMMAND
|
HCI_WRITE_PAGE_SCAN_TYPE_COMMAND : 1 << (13*8+1),
|
||||||
),
|
HCI_READ_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND : 1 << (13*8+2),
|
||||||
# Octet 8
|
HCI_WRITE_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND : 1 << (13*8+3),
|
||||||
(
|
HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND : 1 << (14*8+3),
|
||||||
HCI_READ_PAGE_SCAN_ACTIVITY_COMMAND,
|
HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND : 1 << (14*8+5),
|
||||||
HCI_WRITE_PAGE_SCAN_ACTIVITY_COMMAND,
|
HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND : 1 << (14*8+6),
|
||||||
HCI_READ_INQUIRY_SCAN_ACTIVITY_COMMAND,
|
HCI_READ_BUFFER_SIZE_COMMAND : 1 << (14*8+7),
|
||||||
HCI_WRITE_INQUIRY_SCAN_ACTIVITY_COMMAND,
|
HCI_READ_BD_ADDR_COMMAND : 1 << (15*8+1),
|
||||||
HCI_READ_AUTHENTICATION_ENABLE_COMMAND,
|
HCI_READ_FAILED_CONTACT_COUNTER_COMMAND : 1 << (15*8+2),
|
||||||
HCI_WRITE_AUTHENTICATION_ENABLE_COMMAND,
|
HCI_RESET_FAILED_CONTACT_COUNTER_COMMAND : 1 << (15*8+3),
|
||||||
None,
|
HCI_READ_LINK_QUALITY_COMMAND : 1 << (15*8+4),
|
||||||
None
|
HCI_READ_RSSI_COMMAND : 1 << (15*8+5),
|
||||||
),
|
HCI_READ_AFH_CHANNEL_MAP_COMMAND : 1 << (15*8+6),
|
||||||
# Octet 9
|
HCI_READ_CLOCK_COMMAND : 1 << (15*8+7),
|
||||||
(
|
HCI_READ_LOOPBACK_MODE_COMMAND : 1 << (16*8+0),
|
||||||
HCI_READ_CLASS_OF_DEVICE_COMMAND,
|
HCI_WRITE_LOOPBACK_MODE_COMMAND : 1 << (16*8+1),
|
||||||
HCI_WRITE_CLASS_OF_DEVICE_COMMAND,
|
HCI_ENABLE_DEVICE_UNDER_TEST_MODE_COMMAND : 1 << (16*8+2),
|
||||||
HCI_READ_VOICE_SETTING_COMMAND,
|
HCI_SETUP_SYNCHRONOUS_CONNECTION_COMMAND : 1 << (16*8+3),
|
||||||
HCI_WRITE_VOICE_SETTING_COMMAND,
|
HCI_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND : 1 << (16*8+4),
|
||||||
HCI_READ_AUTOMATIC_FLUSH_TIMEOUT_COMMAND,
|
HCI_REJECT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND : 1 << (16*8+5),
|
||||||
HCI_WRITE_AUTOMATIC_FLUSH_TIMEOUT_COMMAND,
|
HCI_READ_EXTENDED_INQUIRY_RESPONSE_COMMAND : 1 << (17*8+0),
|
||||||
HCI_READ_NUM_BROADCAST_RETRANSMISSIONS_COMMAND,
|
HCI_WRITE_EXTENDED_INQUIRY_RESPONSE_COMMAND : 1 << (17*8+1),
|
||||||
HCI_WRITE_NUM_BROADCAST_RETRANSMISSIONS_COMMAND
|
HCI_REFRESH_ENCRYPTION_KEY_COMMAND : 1 << (17*8+2),
|
||||||
),
|
HCI_SNIFF_SUBRATING_COMMAND : 1 << (17*8+4),
|
||||||
# Octet 10
|
HCI_READ_SIMPLE_PAIRING_MODE_COMMAND : 1 << (17*8+5),
|
||||||
(
|
HCI_WRITE_SIMPLE_PAIRING_MODE_COMMAND : 1 << (17*8+6),
|
||||||
HCI_READ_HOLD_MODE_ACTIVITY_COMMAND,
|
HCI_READ_LOCAL_OOB_DATA_COMMAND : 1 << (17*8+7),
|
||||||
HCI_WRITE_HOLD_MODE_ACTIVITY_COMMAND,
|
HCI_READ_INQUIRY_RESPONSE_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (18*8+0),
|
||||||
HCI_READ_TRANSMIT_POWER_LEVEL_COMMAND,
|
HCI_WRITE_INQUIRY_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (18*8+1),
|
||||||
HCI_READ_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND,
|
HCI_READ_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND : 1 << (18*8+2),
|
||||||
HCI_WRITE_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND,
|
HCI_WRITE_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND : 1 << (18*8+3),
|
||||||
HCI_SET_CONTROLLER_TO_HOST_FLOW_CONTROL_COMMAND,
|
HCI_IO_CAPABILITY_REQUEST_REPLY_COMMAND : 1 << (18*8+7),
|
||||||
HCI_HOST_BUFFER_SIZE_COMMAND,
|
HCI_USER_CONFIRMATION_REQUEST_REPLY_COMMAND : 1 << (19*8+0),
|
||||||
HCI_HOST_NUMBER_OF_COMPLETED_PACKETS_COMMAND
|
HCI_USER_CONFIRMATION_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (19*8+1),
|
||||||
),
|
HCI_USER_PASSKEY_REQUEST_REPLY_COMMAND : 1 << (19*8+2),
|
||||||
# Octet 11
|
HCI_USER_PASSKEY_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (19*8+3),
|
||||||
(
|
HCI_REMOTE_OOB_DATA_REQUEST_REPLY_COMMAND : 1 << (19*8+4),
|
||||||
HCI_READ_LINK_SUPERVISION_TIMEOUT_COMMAND,
|
HCI_WRITE_SIMPLE_PAIRING_DEBUG_MODE_COMMAND : 1 << (19*8+5),
|
||||||
HCI_WRITE_LINK_SUPERVISION_TIMEOUT_COMMAND,
|
HCI_ENHANCED_FLUSH_COMMAND : 1 << (19*8+6),
|
||||||
HCI_READ_NUMBER_OF_SUPPORTED_IAC_COMMAND,
|
HCI_REMOTE_OOB_DATA_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (19*8+7),
|
||||||
HCI_READ_CURRENT_IAC_LAP_COMMAND,
|
HCI_SEND_KEYPRESS_NOTIFICATION_COMMAND : 1 << (20*8+2),
|
||||||
HCI_WRITE_CURRENT_IAC_LAP_COMMAND,
|
HCI_IO_CAPABILITY_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (20*8+3),
|
||||||
None,
|
HCI_READ_ENCRYPTION_KEY_SIZE_COMMAND : 1 << (20*8+4),
|
||||||
None,
|
HCI_SET_EVENT_MASK_PAGE_2_COMMAND : 1 << (22*8+2),
|
||||||
None
|
HCI_READ_FLOW_CONTROL_MODE_COMMAND : 1 << (23*8+0),
|
||||||
),
|
HCI_WRITE_FLOW_CONTROL_MODE_COMMAND : 1 << (23*8+1),
|
||||||
# Octet 12
|
HCI_READ_DATA_BLOCK_SIZE_COMMAND : 1 << (23*8+2),
|
||||||
(
|
HCI_READ_ENHANCED_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (24*8+0),
|
||||||
None,
|
HCI_READ_LE_HOST_SUPPORT_COMMAND : 1 << (24*8+5),
|
||||||
HCI_SET_AFH_HOST_CHANNEL_CLASSIFICATION_COMMAND,
|
HCI_WRITE_LE_HOST_SUPPORT_COMMAND : 1 << (24*8+6),
|
||||||
None,
|
HCI_LE_SET_EVENT_MASK_COMMAND : 1 << (25*8+0),
|
||||||
None,
|
HCI_LE_READ_BUFFER_SIZE_COMMAND : 1 << (25*8+1),
|
||||||
HCI_READ_INQUIRY_SCAN_TYPE_COMMAND,
|
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND : 1 << (25*8+2),
|
||||||
HCI_WRITE_INQUIRY_SCAN_TYPE_COMMAND,
|
HCI_LE_SET_RANDOM_ADDRESS_COMMAND : 1 << (25*8+4),
|
||||||
HCI_READ_INQUIRY_MODE_COMMAND,
|
HCI_LE_SET_ADVERTISING_PARAMETERS_COMMAND : 1 << (25*8+5),
|
||||||
HCI_WRITE_INQUIRY_MODE_COMMAND
|
HCI_LE_READ_ADVERTISING_PHYSICAL_CHANNEL_TX_POWER_COMMAND : 1 << (25*8+6),
|
||||||
),
|
HCI_LE_SET_ADVERTISING_DATA_COMMAND : 1 << (25*8+7),
|
||||||
# Octet 13
|
HCI_LE_SET_SCAN_RESPONSE_DATA_COMMAND : 1 << (26*8+0),
|
||||||
(
|
HCI_LE_SET_ADVERTISING_ENABLE_COMMAND : 1 << (26*8+1),
|
||||||
HCI_READ_PAGE_SCAN_TYPE_COMMAND,
|
HCI_LE_SET_SCAN_PARAMETERS_COMMAND : 1 << (26*8+2),
|
||||||
HCI_WRITE_PAGE_SCAN_TYPE_COMMAND,
|
HCI_LE_SET_SCAN_ENABLE_COMMAND : 1 << (26*8+3),
|
||||||
HCI_READ_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND,
|
HCI_LE_CREATE_CONNECTION_COMMAND : 1 << (26*8+4),
|
||||||
HCI_WRITE_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND,
|
HCI_LE_CREATE_CONNECTION_CANCEL_COMMAND : 1 << (26*8+5),
|
||||||
None,
|
HCI_LE_READ_FILTER_ACCEPT_LIST_SIZE_COMMAND : 1 << (26*8+6),
|
||||||
None,
|
HCI_LE_CLEAR_FILTER_ACCEPT_LIST_COMMAND : 1 << (26*8+7),
|
||||||
None,
|
HCI_LE_ADD_DEVICE_TO_FILTER_ACCEPT_LIST_COMMAND : 1 << (27*8+0),
|
||||||
None,
|
HCI_LE_REMOVE_DEVICE_FROM_FILTER_ACCEPT_LIST_COMMAND : 1 << (27*8+1),
|
||||||
),
|
HCI_LE_CONNECTION_UPDATE_COMMAND : 1 << (27*8+2),
|
||||||
# Octet 14
|
HCI_LE_SET_HOST_CHANNEL_CLASSIFICATION_COMMAND : 1 << (27*8+3),
|
||||||
(
|
HCI_LE_READ_CHANNEL_MAP_COMMAND : 1 << (27*8+4),
|
||||||
None,
|
HCI_LE_READ_REMOTE_FEATURES_COMMAND : 1 << (27*8+5),
|
||||||
None,
|
HCI_LE_ENCRYPT_COMMAND : 1 << (27*8+6),
|
||||||
None,
|
HCI_LE_RAND_COMMAND : 1 << (27*8+7),
|
||||||
HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND,
|
HCI_LE_ENABLE_ENCRYPTION_COMMAND : 1 << (28*8+0),
|
||||||
None,
|
HCI_LE_LONG_TERM_KEY_REQUEST_REPLY_COMMAND : 1 << (28*8+1),
|
||||||
HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
|
HCI_LE_LONG_TERM_KEY_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (28*8+2),
|
||||||
HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND,
|
HCI_LE_READ_SUPPORTED_STATES_COMMAND : 1 << (28*8+3),
|
||||||
HCI_READ_BUFFER_SIZE_COMMAND
|
HCI_LE_RECEIVER_TEST_COMMAND : 1 << (28*8+4),
|
||||||
),
|
HCI_LE_TRANSMITTER_TEST_COMMAND : 1 << (28*8+5),
|
||||||
# Octet 15
|
HCI_LE_TEST_END_COMMAND : 1 << (28*8+6),
|
||||||
(
|
HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND : 1 << (29*8+3),
|
||||||
None,
|
HCI_ENHANCED_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND : 1 << (29*8+4),
|
||||||
HCI_READ_BD_ADDR_COMMAND,
|
HCI_READ_LOCAL_SUPPORTED_CODECS_COMMAND : 1 << (29*8+5),
|
||||||
HCI_READ_FAILED_CONTACT_COUNTER_COMMAND,
|
HCI_SET_MWS_CHANNEL_PARAMETERS_COMMAND : 1 << (29*8+6),
|
||||||
HCI_RESET_FAILED_CONTACT_COUNTER_COMMAND,
|
HCI_SET_EXTERNAL_FRAME_CONFIGURATION_COMMAND : 1 << (29*8+7),
|
||||||
HCI_READ_LINK_QUALITY_COMMAND,
|
HCI_SET_MWS_SIGNALING_COMMAND : 1 << (30*8+0),
|
||||||
HCI_READ_RSSI_COMMAND,
|
HCI_SET_MWS_TRANSPORT_LAYER_COMMAND : 1 << (30*8+1),
|
||||||
HCI_READ_AFH_CHANNEL_MAP_COMMAND,
|
HCI_SET_MWS_SCAN_FREQUENCY_TABLE_COMMAND : 1 << (30*8+2),
|
||||||
HCI_READ_CLOCK_COMMAND
|
HCI_GET_MWS_TRANSPORT_LAYER_CONFIGURATION_COMMAND : 1 << (30*8+3),
|
||||||
),
|
HCI_SET_MWS_PATTERN_CONFIGURATION_COMMAND : 1 << (30*8+4),
|
||||||
# Octet 16
|
HCI_SET_TRIGGERED_CLOCK_CAPTURE_COMMAND : 1 << (30*8+5),
|
||||||
(
|
HCI_TRUNCATED_PAGE_COMMAND : 1 << (30*8+6),
|
||||||
HCI_READ_LOOPBACK_MODE_COMMAND,
|
HCI_TRUNCATED_PAGE_CANCEL_COMMAND : 1 << (30*8+7),
|
||||||
HCI_WRITE_LOOPBACK_MODE_COMMAND,
|
HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_COMMAND : 1 << (31*8+0),
|
||||||
HCI_ENABLE_DEVICE_UNDER_TEST_MODE_COMMAND,
|
HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVE_COMMAND : 1 << (31*8+1),
|
||||||
HCI_SETUP_SYNCHRONOUS_CONNECTION_COMMAND,
|
HCI_START_SYNCHRONIZATION_TRAIN_COMMAND : 1 << (31*8+2),
|
||||||
HCI_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND,
|
HCI_RECEIVE_SYNCHRONIZATION_TRAIN_COMMAND : 1 << (31*8+3),
|
||||||
HCI_REJECT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND,
|
HCI_SET_RESERVED_LT_ADDR_COMMAND : 1 << (31*8+4),
|
||||||
None,
|
HCI_DELETE_RESERVED_LT_ADDR_COMMAND : 1 << (31*8+5),
|
||||||
None,
|
HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_DATA_COMMAND : 1 << (31*8+6),
|
||||||
),
|
HCI_READ_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND : 1 << (31*8+7),
|
||||||
# Octet 17
|
HCI_WRITE_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND : 1 << (32*8+0),
|
||||||
(
|
HCI_REMOTE_OOB_EXTENDED_DATA_REQUEST_REPLY_COMMAND : 1 << (32*8+1),
|
||||||
HCI_READ_EXTENDED_INQUIRY_RESPONSE_COMMAND,
|
HCI_READ_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND : 1 << (32*8+2),
|
||||||
HCI_WRITE_EXTENDED_INQUIRY_RESPONSE_COMMAND,
|
HCI_WRITE_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND : 1 << (32*8+3),
|
||||||
HCI_REFRESH_ENCRYPTION_KEY_COMMAND,
|
HCI_READ_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND : 1 << (32*8+4),
|
||||||
None,
|
HCI_WRITE_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND : 1 << (32*8+5),
|
||||||
HCI_SNIFF_SUBRATING_COMMAND,
|
HCI_READ_LOCAL_OOB_EXTENDED_DATA_COMMAND : 1 << (32*8+6),
|
||||||
HCI_READ_SIMPLE_PAIRING_MODE_COMMAND,
|
HCI_WRITE_SECURE_CONNECTIONS_TEST_MODE_COMMAND : 1 << (32*8+7),
|
||||||
HCI_WRITE_SIMPLE_PAIRING_MODE_COMMAND,
|
HCI_READ_EXTENDED_PAGE_TIMEOUT_COMMAND : 1 << (33*8+0),
|
||||||
HCI_READ_LOCAL_OOB_DATA_COMMAND
|
HCI_WRITE_EXTENDED_PAGE_TIMEOUT_COMMAND : 1 << (33*8+1),
|
||||||
),
|
HCI_READ_EXTENDED_INQUIRY_LENGTH_COMMAND : 1 << (33*8+2),
|
||||||
# Octet 18
|
HCI_WRITE_EXTENDED_INQUIRY_LENGTH_COMMAND : 1 << (33*8+3),
|
||||||
(
|
HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_REPLY_COMMAND : 1 << (33*8+4),
|
||||||
HCI_READ_INQUIRY_RESPONSE_TRANSMIT_POWER_LEVEL_COMMAND,
|
HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (33*8+5),
|
||||||
HCI_WRITE_INQUIRY_TRANSMIT_POWER_LEVEL_COMMAND,
|
HCI_LE_SET_DATA_LENGTH_COMMAND : 1 << (33*8+6),
|
||||||
HCI_READ_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND,
|
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND : 1 << (33*8+7),
|
||||||
HCI_WRITE_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND,
|
HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND : 1 << (34*8+0),
|
||||||
None,
|
HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMMAND : 1 << (34*8+1),
|
||||||
None,
|
HCI_LE_GENERATE_DHKEY_COMMAND : 1 << (34*8+2),
|
||||||
None,
|
HCI_LE_ADD_DEVICE_TO_RESOLVING_LIST_COMMAND : 1 << (34*8+3),
|
||||||
HCI_IO_CAPABILITY_REQUEST_REPLY_COMMAND
|
HCI_LE_REMOVE_DEVICE_FROM_RESOLVING_LIST_COMMAND : 1 << (34*8+4),
|
||||||
),
|
HCI_LE_CLEAR_RESOLVING_LIST_COMMAND : 1 << (34*8+5),
|
||||||
# Octet 19
|
HCI_LE_READ_RESOLVING_LIST_SIZE_COMMAND : 1 << (34*8+6),
|
||||||
(
|
HCI_LE_READ_PEER_RESOLVABLE_ADDRESS_COMMAND : 1 << (34*8+7),
|
||||||
HCI_USER_CONFIRMATION_REQUEST_REPLY_COMMAND,
|
HCI_LE_READ_LOCAL_RESOLVABLE_ADDRESS_COMMAND : 1 << (35*8+0),
|
||||||
HCI_USER_CONFIRMATION_REQUEST_NEGATIVE_REPLY_COMMAND,
|
HCI_LE_SET_ADDRESS_RESOLUTION_ENABLE_COMMAND : 1 << (35*8+1),
|
||||||
HCI_USER_PASSKEY_REQUEST_REPLY_COMMAND,
|
HCI_LE_SET_RESOLVABLE_PRIVATE_ADDRESS_TIMEOUT_COMMAND : 1 << (35*8+2),
|
||||||
HCI_USER_PASSKEY_REQUEST_NEGATIVE_REPLY_COMMAND,
|
HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND : 1 << (35*8+3),
|
||||||
HCI_REMOTE_OOB_DATA_REQUEST_REPLY_COMMAND,
|
HCI_LE_READ_PHY_COMMAND : 1 << (35*8+4),
|
||||||
HCI_WRITE_SIMPLE_PAIRING_DEBUG_MODE_COMMAND,
|
HCI_LE_SET_DEFAULT_PHY_COMMAND : 1 << (35*8+5),
|
||||||
HCI_ENHANCED_FLUSH_COMMAND,
|
HCI_LE_SET_PHY_COMMAND : 1 << (35*8+6),
|
||||||
HCI_REMOTE_OOB_DATA_REQUEST_NEGATIVE_REPLY_COMMAND
|
HCI_LE_RECEIVER_TEST_V2_COMMAND : 1 << (35*8+7),
|
||||||
),
|
HCI_LE_TRANSMITTER_TEST_V2_COMMAND : 1 << (36*8+0),
|
||||||
# Octet 20
|
HCI_LE_SET_ADVERTISING_SET_RANDOM_ADDRESS_COMMAND : 1 << (36*8+1),
|
||||||
(
|
HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_COMMAND : 1 << (36*8+2),
|
||||||
None,
|
HCI_LE_SET_EXTENDED_ADVERTISING_DATA_COMMAND : 1 << (36*8+3),
|
||||||
None,
|
HCI_LE_SET_EXTENDED_SCAN_RESPONSE_DATA_COMMAND : 1 << (36*8+4),
|
||||||
HCI_SEND_KEYPRESS_NOTIFICATION_COMMAND,
|
HCI_LE_SET_EXTENDED_ADVERTISING_ENABLE_COMMAND : 1 << (36*8+5),
|
||||||
HCI_IO_CAPABILITY_REQUEST_NEGATIVE_REPLY_COMMAND,
|
HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND : 1 << (36*8+6),
|
||||||
HCI_READ_ENCRYPTION_KEY_SIZE_COMMAND,
|
HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND : 1 << (36*8+7),
|
||||||
None,
|
HCI_LE_REMOVE_ADVERTISING_SET_COMMAND : 1 << (37*8+0),
|
||||||
None,
|
HCI_LE_CLEAR_ADVERTISING_SETS_COMMAND : 1 << (37*8+1),
|
||||||
None,
|
HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_COMMAND : 1 << (37*8+2),
|
||||||
),
|
HCI_LE_SET_PERIODIC_ADVERTISING_DATA_COMMAND : 1 << (37*8+3),
|
||||||
# Octet 21
|
HCI_LE_SET_PERIODIC_ADVERTISING_ENABLE_COMMAND : 1 << (37*8+4),
|
||||||
(
|
HCI_LE_SET_EXTENDED_SCAN_PARAMETERS_COMMAND : 1 << (37*8+5),
|
||||||
None,
|
HCI_LE_SET_EXTENDED_SCAN_ENABLE_COMMAND : 1 << (37*8+6),
|
||||||
None,
|
HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND : 1 << (37*8+7),
|
||||||
None,
|
HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_COMMAND : 1 << (38*8+0),
|
||||||
None,
|
HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_CANCEL_COMMAND : 1 << (38*8+1),
|
||||||
None,
|
HCI_LE_PERIODIC_ADVERTISING_TERMINATE_SYNC_COMMAND : 1 << (38*8+2),
|
||||||
None,
|
HCI_LE_ADD_DEVICE_TO_PERIODIC_ADVERTISER_LIST_COMMAND : 1 << (38*8+3),
|
||||||
None,
|
HCI_LE_REMOVE_DEVICE_FROM_PERIODIC_ADVERTISER_LIST_COMMAND : 1 << (38*8+4),
|
||||||
None,
|
HCI_LE_CLEAR_PERIODIC_ADVERTISER_LIST_COMMAND : 1 << (38*8+5),
|
||||||
),
|
HCI_LE_READ_PERIODIC_ADVERTISER_LIST_SIZE_COMMAND : 1 << (38*8+6),
|
||||||
# Octet 22
|
HCI_LE_READ_TRANSMIT_POWER_COMMAND : 1 << (38*8+7),
|
||||||
(
|
HCI_LE_READ_RF_PATH_COMPENSATION_COMMAND : 1 << (39*8+0),
|
||||||
None,
|
HCI_LE_WRITE_RF_PATH_COMPENSATION_COMMAND : 1 << (39*8+1),
|
||||||
None,
|
HCI_LE_SET_PRIVACY_MODE_COMMAND : 1 << (39*8+2),
|
||||||
HCI_SET_EVENT_MASK_PAGE_2_COMMAND,
|
HCI_LE_RECEIVER_TEST_V3_COMMAND : 1 << (39*8+3),
|
||||||
None,
|
HCI_LE_TRANSMITTER_TEST_V3_COMMAND : 1 << (39*8+4),
|
||||||
None,
|
HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_PARAMETERS_COMMAND : 1 << (39*8+5),
|
||||||
None,
|
HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_ENABLE_COMMAND : 1 << (39*8+6),
|
||||||
None,
|
HCI_LE_SET_CONNECTIONLESS_IQ_SAMPLING_ENABLE_COMMAND : 1 << (39*8+7),
|
||||||
None,
|
HCI_LE_SET_CONNECTION_CTE_RECEIVE_PARAMETERS_COMMAND : 1 << (40*8+0),
|
||||||
),
|
HCI_LE_SET_CONNECTION_CTE_TRANSMIT_PARAMETERS_COMMAND : 1 << (40*8+1),
|
||||||
# Octet 23
|
HCI_LE_CONNECTION_CTE_REQUEST_ENABLE_COMMAND : 1 << (40*8+2),
|
||||||
(
|
HCI_LE_CONNECTION_CTE_RESPONSE_ENABLE_COMMAND : 1 << (40*8+3),
|
||||||
HCI_READ_FLOW_CONTROL_MODE_COMMAND,
|
HCI_LE_READ_ANTENNA_INFORMATION_COMMAND : 1 << (40*8+4),
|
||||||
HCI_WRITE_FLOW_CONTROL_MODE_COMMAND,
|
HCI_LE_SET_PERIODIC_ADVERTISING_RECEIVE_ENABLE_COMMAND : 1 << (40*8+5),
|
||||||
HCI_READ_DATA_BLOCK_SIZE_COMMAND,
|
HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_COMMAND : 1 << (40*8+6),
|
||||||
None,
|
HCI_LE_PERIODIC_ADVERTISING_SET_INFO_TRANSFER_COMMAND : 1 << (40*8+7),
|
||||||
None,
|
HCI_LE_SET_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND : 1 << (41*8+0),
|
||||||
None,
|
HCI_LE_SET_DEFAULT_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND : 1 << (41*8+1),
|
||||||
None,
|
HCI_LE_GENERATE_DHKEY_V2_COMMAND : 1 << (41*8+2),
|
||||||
None,
|
HCI_READ_LOCAL_SIMPLE_PAIRING_OPTIONS_COMMAND : 1 << (41*8+3),
|
||||||
),
|
HCI_LE_MODIFY_SLEEP_CLOCK_ACCURACY_COMMAND : 1 << (41*8+4),
|
||||||
# Octet 24
|
HCI_LE_READ_BUFFER_SIZE_V2_COMMAND : 1 << (41*8+5),
|
||||||
(
|
HCI_LE_READ_ISO_TX_SYNC_COMMAND : 1 << (41*8+6),
|
||||||
HCI_READ_ENHANCED_TRANSMIT_POWER_LEVEL_COMMAND,
|
HCI_LE_SET_CIG_PARAMETERS_COMMAND : 1 << (41*8+7),
|
||||||
None,
|
HCI_LE_SET_CIG_PARAMETERS_TEST_COMMAND : 1 << (42*8+0),
|
||||||
None,
|
HCI_LE_CREATE_CIS_COMMAND : 1 << (42*8+1),
|
||||||
None,
|
HCI_LE_REMOVE_CIG_COMMAND : 1 << (42*8+2),
|
||||||
None,
|
HCI_LE_ACCEPT_CIS_REQUEST_COMMAND : 1 << (42*8+3),
|
||||||
HCI_READ_LE_HOST_SUPPORT_COMMAND,
|
HCI_LE_REJECT_CIS_REQUEST_COMMAND : 1 << (42*8+4),
|
||||||
HCI_WRITE_LE_HOST_SUPPORT_COMMAND,
|
HCI_LE_CREATE_BIG_COMMAND : 1 << (42*8+5),
|
||||||
None,
|
HCI_LE_CREATE_BIG_TEST_COMMAND : 1 << (42*8+6),
|
||||||
),
|
HCI_LE_TERMINATE_BIG_COMMAND : 1 << (42*8+7),
|
||||||
# Octet 25
|
HCI_LE_BIG_CREATE_SYNC_COMMAND : 1 << (43*8+0),
|
||||||
(
|
HCI_LE_BIG_TERMINATE_SYNC_COMMAND : 1 << (43*8+1),
|
||||||
HCI_LE_SET_EVENT_MASK_COMMAND,
|
HCI_LE_REQUEST_PEER_SCA_COMMAND : 1 << (43*8+2),
|
||||||
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
HCI_LE_SETUP_ISO_DATA_PATH_COMMAND : 1 << (43*8+3),
|
||||||
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
|
HCI_LE_REMOVE_ISO_DATA_PATH_COMMAND : 1 << (43*8+4),
|
||||||
None,
|
HCI_LE_ISO_TRANSMIT_TEST_COMMAND : 1 << (43*8+5),
|
||||||
HCI_LE_SET_RANDOM_ADDRESS_COMMAND,
|
HCI_LE_ISO_RECEIVE_TEST_COMMAND : 1 << (43*8+6),
|
||||||
HCI_LE_SET_ADVERTISING_PARAMETERS_COMMAND,
|
HCI_LE_ISO_READ_TEST_COUNTERS_COMMAND : 1 << (43*8+7),
|
||||||
HCI_LE_READ_ADVERTISING_PHYSICAL_CHANNEL_TX_POWER_COMMAND,
|
HCI_LE_ISO_TEST_END_COMMAND : 1 << (44*8+0),
|
||||||
HCI_LE_SET_ADVERTISING_DATA_COMMAND,
|
HCI_LE_SET_HOST_FEATURE_COMMAND : 1 << (44*8+1),
|
||||||
),
|
HCI_LE_READ_ISO_LINK_QUALITY_COMMAND : 1 << (44*8+2),
|
||||||
# Octet 26
|
HCI_LE_ENHANCED_READ_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (44*8+3),
|
||||||
(
|
HCI_LE_READ_REMOTE_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (44*8+4),
|
||||||
HCI_LE_SET_SCAN_RESPONSE_DATA_COMMAND,
|
HCI_LE_SET_PATH_LOSS_REPORTING_PARAMETERS_COMMAND : 1 << (44*8+5),
|
||||||
HCI_LE_SET_ADVERTISING_ENABLE_COMMAND,
|
HCI_LE_SET_PATH_LOSS_REPORTING_ENABLE_COMMAND : 1 << (44*8+6),
|
||||||
HCI_LE_SET_SCAN_PARAMETERS_COMMAND,
|
HCI_LE_SET_TRANSMIT_POWER_REPORTING_ENABLE_COMMAND : 1 << (44*8+7),
|
||||||
HCI_LE_SET_SCAN_ENABLE_COMMAND,
|
HCI_LE_TRANSMITTER_TEST_V4_COMMAND : 1 << (45*8+0),
|
||||||
HCI_LE_CREATE_CONNECTION_COMMAND,
|
HCI_SET_ECOSYSTEM_BASE_INTERVAL_COMMAND : 1 << (45*8+1),
|
||||||
HCI_LE_CREATE_CONNECTION_CANCEL_COMMAND,
|
HCI_READ_LOCAL_SUPPORTED_CODECS_V2_COMMAND : 1 << (45*8+2),
|
||||||
HCI_LE_READ_FILTER_ACCEPT_LIST_SIZE_COMMAND,
|
HCI_READ_LOCAL_SUPPORTED_CODEC_CAPABILITIES_COMMAND : 1 << (45*8+3),
|
||||||
HCI_LE_CLEAR_FILTER_ACCEPT_LIST_COMMAND
|
HCI_READ_LOCAL_SUPPORTED_CONTROLLER_DELAY_COMMAND : 1 << (45*8+4),
|
||||||
),
|
HCI_CONFIGURE_DATA_PATH_COMMAND : 1 << (45*8+5),
|
||||||
# Octet 27
|
HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND : 1 << (45*8+6),
|
||||||
(
|
HCI_SET_MIN_ENCRYPTION_KEY_SIZE_COMMAND : 1 << (45*8+7),
|
||||||
HCI_LE_ADD_DEVICE_TO_FILTER_ACCEPT_LIST_COMMAND,
|
HCI_LE_SET_DEFAULT_SUBRATE_COMMAND : 1 << (46*8+0),
|
||||||
HCI_LE_REMOVE_DEVICE_FROM_FILTER_ACCEPT_LIST_COMMAND,
|
HCI_LE_SUBRATE_REQUEST_COMMAND : 1 << (46*8+1),
|
||||||
HCI_LE_CONNECTION_UPDATE_COMMAND,
|
HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_V2_COMMAND : 1 << (46*8+2),
|
||||||
HCI_LE_SET_HOST_CHANNEL_CLASSIFICATION_COMMAND,
|
HCI_LE_SET_PERIODIC_ADVERTISING_SUBEVENT_DATA_COMMAND : 1 << (46*8+5),
|
||||||
HCI_LE_READ_CHANNEL_MAP_COMMAND,
|
HCI_LE_SET_PERIODIC_ADVERTISING_RESPONSE_DATA_COMMAND : 1 << (46*8+6),
|
||||||
HCI_LE_READ_REMOTE_FEATURES_COMMAND,
|
HCI_LE_SET_PERIODIC_SYNC_SUBEVENT_COMMAND : 1 << (46*8+7),
|
||||||
HCI_LE_ENCRYPT_COMMAND,
|
HCI_LE_EXTENDED_CREATE_CONNECTION_V2_COMMAND : 1 << (47*8+0),
|
||||||
HCI_LE_RAND_COMMAND
|
HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_V2_COMMAND : 1 << (47*8+1),
|
||||||
),
|
}
|
||||||
# Octet 28
|
|
||||||
(
|
|
||||||
HCI_LE_ENABLE_ENCRYPTION_COMMAND,
|
|
||||||
HCI_LE_LONG_TERM_KEY_REQUEST_REPLY_COMMAND,
|
|
||||||
HCI_LE_LONG_TERM_KEY_REQUEST_NEGATIVE_REPLY_COMMAND,
|
|
||||||
HCI_LE_READ_SUPPORTED_STATES_COMMAND,
|
|
||||||
HCI_LE_RECEIVER_TEST_COMMAND,
|
|
||||||
HCI_LE_TRANSMITTER_TEST_COMMAND,
|
|
||||||
HCI_LE_TEST_END_COMMAND,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
# Octet 29
|
|
||||||
(
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND,
|
|
||||||
HCI_ENHANCED_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND,
|
|
||||||
HCI_READ_LOCAL_SUPPORTED_CODECS_COMMAND,
|
|
||||||
HCI_SET_MWS_CHANNEL_PARAMETERS_COMMAND,
|
|
||||||
HCI_SET_EXTERNAL_FRAME_CONFIGURATION_COMMAND
|
|
||||||
),
|
|
||||||
# Octet 30
|
|
||||||
(
|
|
||||||
HCI_SET_MWS_SIGNALING_COMMAND,
|
|
||||||
HCI_SET_MWS_TRANSPORT_LAYER_COMMAND,
|
|
||||||
HCI_SET_MWS_SCAN_FREQUENCY_TABLE_COMMAND,
|
|
||||||
HCI_GET_MWS_TRANSPORT_LAYER_CONFIGURATION_COMMAND,
|
|
||||||
HCI_SET_MWS_PATTERN_CONFIGURATION_COMMAND,
|
|
||||||
HCI_SET_TRIGGERED_CLOCK_CAPTURE_COMMAND,
|
|
||||||
HCI_TRUNCATED_PAGE_COMMAND,
|
|
||||||
HCI_TRUNCATED_PAGE_CANCEL_COMMAND
|
|
||||||
),
|
|
||||||
# Octet 31
|
|
||||||
(
|
|
||||||
HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_COMMAND,
|
|
||||||
HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVE_COMMAND,
|
|
||||||
HCI_START_SYNCHRONIZATION_TRAIN_COMMAND,
|
|
||||||
HCI_RECEIVE_SYNCHRONIZATION_TRAIN_COMMAND,
|
|
||||||
HCI_SET_RESERVED_LT_ADDR_COMMAND,
|
|
||||||
HCI_DELETE_RESERVED_LT_ADDR_COMMAND,
|
|
||||||
HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_DATA_COMMAND,
|
|
||||||
HCI_READ_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND
|
|
||||||
),
|
|
||||||
# Octet 32
|
|
||||||
(
|
|
||||||
HCI_WRITE_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND,
|
|
||||||
HCI_REMOTE_OOB_EXTENDED_DATA_REQUEST_REPLY_COMMAND,
|
|
||||||
HCI_READ_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND,
|
|
||||||
HCI_WRITE_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND,
|
|
||||||
HCI_READ_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND,
|
|
||||||
HCI_WRITE_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND,
|
|
||||||
HCI_READ_LOCAL_OOB_EXTENDED_DATA_COMMAND,
|
|
||||||
HCI_WRITE_SECURE_CONNECTIONS_TEST_MODE_COMMAND
|
|
||||||
),
|
|
||||||
# Octet 33
|
|
||||||
(
|
|
||||||
HCI_READ_EXTENDED_PAGE_TIMEOUT_COMMAND,
|
|
||||||
HCI_WRITE_EXTENDED_PAGE_TIMEOUT_COMMAND,
|
|
||||||
HCI_READ_EXTENDED_INQUIRY_LENGTH_COMMAND,
|
|
||||||
HCI_WRITE_EXTENDED_INQUIRY_LENGTH_COMMAND,
|
|
||||||
HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_REPLY_COMMAND,
|
|
||||||
HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_NEGATIVE_REPLY_COMMAND,
|
|
||||||
HCI_LE_SET_DATA_LENGTH_COMMAND,
|
|
||||||
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
|
|
||||||
),
|
|
||||||
# Octet 34
|
|
||||||
(
|
|
||||||
HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
|
||||||
HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMMAND,
|
|
||||||
HCI_LE_GENERATE_DHKEY_COMMAND,
|
|
||||||
HCI_LE_ADD_DEVICE_TO_RESOLVING_LIST_COMMAND,
|
|
||||||
HCI_LE_REMOVE_DEVICE_FROM_RESOLVING_LIST_COMMAND,
|
|
||||||
HCI_LE_CLEAR_RESOLVING_LIST_COMMAND,
|
|
||||||
HCI_LE_READ_RESOLVING_LIST_SIZE_COMMAND,
|
|
||||||
HCI_LE_READ_PEER_RESOLVABLE_ADDRESS_COMMAND
|
|
||||||
),
|
|
||||||
# Octet 35
|
|
||||||
(
|
|
||||||
HCI_LE_READ_LOCAL_RESOLVABLE_ADDRESS_COMMAND,
|
|
||||||
HCI_LE_SET_ADDRESS_RESOLUTION_ENABLE_COMMAND,
|
|
||||||
HCI_LE_SET_RESOLVABLE_PRIVATE_ADDRESS_TIMEOUT_COMMAND,
|
|
||||||
HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND,
|
|
||||||
HCI_LE_READ_PHY_COMMAND,
|
|
||||||
HCI_LE_SET_DEFAULT_PHY_COMMAND,
|
|
||||||
HCI_LE_SET_PHY_COMMAND,
|
|
||||||
HCI_LE_RECEIVER_TEST_V2_COMMAND
|
|
||||||
),
|
|
||||||
# Octet 36
|
|
||||||
(
|
|
||||||
HCI_LE_TRANSMITTER_TEST_V2_COMMAND,
|
|
||||||
HCI_LE_SET_ADVERTISING_SET_RANDOM_ADDRESS_COMMAND,
|
|
||||||
HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_COMMAND,
|
|
||||||
HCI_LE_SET_EXTENDED_ADVERTISING_DATA_COMMAND,
|
|
||||||
HCI_LE_SET_EXTENDED_SCAN_RESPONSE_DATA_COMMAND,
|
|
||||||
HCI_LE_SET_EXTENDED_ADVERTISING_ENABLE_COMMAND,
|
|
||||||
HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND,
|
|
||||||
HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND,
|
|
||||||
),
|
|
||||||
# Octet 37
|
|
||||||
(
|
|
||||||
HCI_LE_REMOVE_ADVERTISING_SET_COMMAND,
|
|
||||||
HCI_LE_CLEAR_ADVERTISING_SETS_COMMAND,
|
|
||||||
HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_COMMAND,
|
|
||||||
HCI_LE_SET_PERIODIC_ADVERTISING_DATA_COMMAND,
|
|
||||||
HCI_LE_SET_PERIODIC_ADVERTISING_ENABLE_COMMAND,
|
|
||||||
HCI_LE_SET_EXTENDED_SCAN_PARAMETERS_COMMAND,
|
|
||||||
HCI_LE_SET_EXTENDED_SCAN_ENABLE_COMMAND,
|
|
||||||
HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND
|
|
||||||
),
|
|
||||||
# Octet 38
|
|
||||||
(
|
|
||||||
HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_COMMAND,
|
|
||||||
HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_CANCEL_COMMAND,
|
|
||||||
HCI_LE_PERIODIC_ADVERTISING_TERMINATE_SYNC_COMMAND,
|
|
||||||
HCI_LE_ADD_DEVICE_TO_PERIODIC_ADVERTISER_LIST_COMMAND,
|
|
||||||
HCI_LE_REMOVE_DEVICE_FROM_PERIODIC_ADVERTISER_LIST_COMMAND,
|
|
||||||
HCI_LE_CLEAR_PERIODIC_ADVERTISER_LIST_COMMAND,
|
|
||||||
HCI_LE_READ_PERIODIC_ADVERTISER_LIST_SIZE_COMMAND,
|
|
||||||
HCI_LE_READ_TRANSMIT_POWER_COMMAND
|
|
||||||
),
|
|
||||||
# Octet 39
|
|
||||||
(
|
|
||||||
HCI_LE_READ_RF_PATH_COMPENSATION_COMMAND,
|
|
||||||
HCI_LE_WRITE_RF_PATH_COMPENSATION_COMMAND,
|
|
||||||
HCI_LE_SET_PRIVACY_MODE_COMMAND,
|
|
||||||
HCI_LE_RECEIVER_TEST_V3_COMMAND,
|
|
||||||
HCI_LE_TRANSMITTER_TEST_V3_COMMAND,
|
|
||||||
HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_PARAMETERS_COMMAND,
|
|
||||||
HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_ENABLE_COMMAND,
|
|
||||||
HCI_LE_SET_CONNECTIONLESS_IQ_SAMPLING_ENABLE_COMMAND,
|
|
||||||
),
|
|
||||||
# Octet 40
|
|
||||||
(
|
|
||||||
HCI_LE_SET_CONNECTION_CTE_RECEIVE_PARAMETERS_COMMAND,
|
|
||||||
HCI_LE_SET_CONNECTION_CTE_TRANSMIT_PARAMETERS_COMMAND,
|
|
||||||
HCI_LE_CONNECTION_CTE_REQUEST_ENABLE_COMMAND,
|
|
||||||
HCI_LE_CONNECTION_CTE_RESPONSE_ENABLE_COMMAND,
|
|
||||||
HCI_LE_READ_ANTENNA_INFORMATION_COMMAND,
|
|
||||||
HCI_LE_SET_PERIODIC_ADVERTISING_RECEIVE_ENABLE_COMMAND,
|
|
||||||
HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_COMMAND,
|
|
||||||
HCI_LE_PERIODIC_ADVERTISING_SET_INFO_TRANSFER_COMMAND
|
|
||||||
),
|
|
||||||
# Octet 41
|
|
||||||
(
|
|
||||||
HCI_LE_SET_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND,
|
|
||||||
HCI_LE_SET_DEFAULT_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND,
|
|
||||||
HCI_LE_GENERATE_DHKEY_V2_COMMAND,
|
|
||||||
HCI_READ_LOCAL_SIMPLE_PAIRING_OPTIONS_COMMAND,
|
|
||||||
HCI_LE_MODIFY_SLEEP_CLOCK_ACCURACY_COMMAND,
|
|
||||||
HCI_LE_READ_BUFFER_SIZE_V2_COMMAND,
|
|
||||||
HCI_LE_READ_ISO_TX_SYNC_COMMAND,
|
|
||||||
HCI_LE_SET_CIG_PARAMETERS_COMMAND
|
|
||||||
),
|
|
||||||
# Octet 42
|
|
||||||
(
|
|
||||||
HCI_LE_SET_CIG_PARAMETERS_TEST_COMMAND,
|
|
||||||
HCI_LE_CREATE_CIS_COMMAND,
|
|
||||||
HCI_LE_REMOVE_CIG_COMMAND,
|
|
||||||
HCI_LE_ACCEPT_CIS_REQUEST_COMMAND,
|
|
||||||
HCI_LE_REJECT_CIS_REQUEST_COMMAND,
|
|
||||||
HCI_LE_CREATE_BIG_COMMAND,
|
|
||||||
HCI_LE_CREATE_BIG_TEST_COMMAND,
|
|
||||||
HCI_LE_TERMINATE_BIG_COMMAND,
|
|
||||||
),
|
|
||||||
# Octet 43
|
|
||||||
(
|
|
||||||
HCI_LE_BIG_CREATE_SYNC_COMMAND,
|
|
||||||
HCI_LE_BIG_TERMINATE_SYNC_COMMAND,
|
|
||||||
HCI_LE_REQUEST_PEER_SCA_COMMAND,
|
|
||||||
HCI_LE_SETUP_ISO_DATA_PATH_COMMAND,
|
|
||||||
HCI_LE_REMOVE_ISO_DATA_PATH_COMMAND,
|
|
||||||
HCI_LE_ISO_TRANSMIT_TEST_COMMAND,
|
|
||||||
HCI_LE_ISO_RECEIVE_TEST_COMMAND,
|
|
||||||
HCI_LE_ISO_READ_TEST_COUNTERS_COMMAND
|
|
||||||
),
|
|
||||||
# Octet 44
|
|
||||||
(
|
|
||||||
HCI_LE_ISO_TEST_END_COMMAND,
|
|
||||||
HCI_LE_SET_HOST_FEATURE_COMMAND,
|
|
||||||
HCI_LE_READ_ISO_LINK_QUALITY_COMMAND,
|
|
||||||
HCI_LE_ENHANCED_READ_TRANSMIT_POWER_LEVEL_COMMAND,
|
|
||||||
HCI_LE_READ_REMOTE_TRANSMIT_POWER_LEVEL_COMMAND,
|
|
||||||
HCI_LE_SET_PATH_LOSS_REPORTING_PARAMETERS_COMMAND,
|
|
||||||
HCI_LE_SET_PATH_LOSS_REPORTING_ENABLE_COMMAND,
|
|
||||||
HCI_LE_SET_TRANSMIT_POWER_REPORTING_ENABLE_COMMAND
|
|
||||||
),
|
|
||||||
# Octet 45
|
|
||||||
(
|
|
||||||
HCI_LE_TRANSMITTER_TEST_V4_COMMAND,
|
|
||||||
HCI_SET_ECOSYSTEM_BASE_INTERVAL_COMMAND,
|
|
||||||
HCI_READ_LOCAL_SUPPORTED_CODECS_V2_COMMAND,
|
|
||||||
HCI_READ_LOCAL_SUPPORTED_CODEC_CAPABILITIES_COMMAND,
|
|
||||||
HCI_READ_LOCAL_SUPPORTED_CONTROLLER_DELAY_COMMAND,
|
|
||||||
HCI_CONFIGURE_DATA_PATH_COMMAND,
|
|
||||||
HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND,
|
|
||||||
HCI_SET_MIN_ENCRYPTION_KEY_SIZE_COMMAND
|
|
||||||
),
|
|
||||||
# Octet 46
|
|
||||||
(
|
|
||||||
HCI_LE_SET_DEFAULT_SUBRATE_COMMAND,
|
|
||||||
HCI_LE_SUBRATE_REQUEST_COMMAND,
|
|
||||||
HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_V2_COMMAND,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
HCI_LE_SET_PERIODIC_ADVERTISING_SUBEVENT_DATA_COMMAND,
|
|
||||||
HCI_LE_SET_PERIODIC_ADVERTISING_RESPONSE_DATA_COMMAND,
|
|
||||||
HCI_LE_SET_PERIODIC_SYNC_SUBEVENT_COMMAND
|
|
||||||
),
|
|
||||||
# Octet 47
|
|
||||||
(
|
|
||||||
HCI_LE_EXTENDED_CREATE_CONNECTION_V2_COMMAND,
|
|
||||||
HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_V2_COMMAND,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# LE Supported Features
|
# LE Supported Features
|
||||||
# See Bluetooth spec @ Vol 6, Part B, 4.6 FEATURE SUPPORT
|
# See Bluetooth spec @ Vol 6, Part B, 4.6 FEATURE SUPPORT
|
||||||
@@ -2224,7 +2003,7 @@ class HCI_Packet:
|
|||||||
Abstract Base class for HCI packets
|
Abstract Base class for HCI packets
|
||||||
'''
|
'''
|
||||||
|
|
||||||
hci_packet_type: int
|
hci_packet_type: ClassVar[int]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(packet: bytes) -> HCI_Packet:
|
def from_bytes(packet: bytes) -> HCI_Packet:
|
||||||
@@ -4470,9 +4249,11 @@ class HCI_LE_Set_Extended_Scan_Parameters_Command(HCI_Command):
|
|||||||
fields.append(
|
fields.append(
|
||||||
(
|
(
|
||||||
f'{scanning_phy_str}.scan_type: ',
|
f'{scanning_phy_str}.scan_type: ',
|
||||||
'PASSIVE'
|
(
|
||||||
if self.scan_types[i] == self.PASSIVE_SCANNING
|
'PASSIVE'
|
||||||
else 'ACTIVE',
|
if self.scan_types[i] == self.PASSIVE_SCANNING
|
||||||
|
else 'ACTIVE'
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
fields.append(
|
fields.append(
|
||||||
@@ -5231,9 +5012,9 @@ class HCI_LE_Advertising_Report_Event(HCI_LE_Meta_Event):
|
|||||||
return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
|
return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
|
||||||
|
|
||||||
|
|
||||||
HCI_LE_Meta_Event.subevent_classes[
|
HCI_LE_Meta_Event.subevent_classes[HCI_LE_ADVERTISING_REPORT_EVENT] = (
|
||||||
HCI_LE_ADVERTISING_REPORT_EVENT
|
HCI_LE_Advertising_Report_Event
|
||||||
] = HCI_LE_Advertising_Report_Event
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -5485,9 +5266,9 @@ class HCI_LE_Extended_Advertising_Report_Event(HCI_LE_Meta_Event):
|
|||||||
return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
|
return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
|
||||||
|
|
||||||
|
|
||||||
HCI_LE_Meta_Event.subevent_classes[
|
HCI_LE_Meta_Event.subevent_classes[HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT] = (
|
||||||
HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT
|
HCI_LE_Extended_Advertising_Report_Event
|
||||||
] = HCI_LE_Extended_Advertising_Report_Event
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -6411,12 +6192,23 @@ class HCI_SynchronousDataPacket(HCI_Packet):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclasses.dataclass
|
||||||
class HCI_IsoDataPacket(HCI_Packet):
|
class HCI_IsoDataPacket(HCI_Packet):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ 5.4.5 HCI ISO Data Packets
|
See Bluetooth spec @ 5.4.5 HCI ISO Data Packets
|
||||||
'''
|
'''
|
||||||
|
|
||||||
hci_packet_type = HCI_ISO_DATA_PACKET
|
hci_packet_type: ClassVar[int] = HCI_ISO_DATA_PACKET
|
||||||
|
|
||||||
|
connection_handle: int
|
||||||
|
data_total_length: int
|
||||||
|
iso_sdu_fragment: bytes
|
||||||
|
pb_flag: int
|
||||||
|
ts_flag: int = 0
|
||||||
|
time_stamp: Optional[int] = None
|
||||||
|
packet_sequence_number: Optional[int] = None
|
||||||
|
iso_sdu_length: Optional[int] = None
|
||||||
|
packet_status_flag: Optional[int] = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(packet: bytes) -> HCI_IsoDataPacket:
|
def from_bytes(packet: bytes) -> HCI_IsoDataPacket:
|
||||||
@@ -6460,28 +6252,6 @@ class HCI_IsoDataPacket(HCI_Packet):
|
|||||||
iso_sdu_fragment=iso_sdu_fragment,
|
iso_sdu_fragment=iso_sdu_fragment,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
connection_handle: int,
|
|
||||||
pb_flag: int,
|
|
||||||
ts_flag: int,
|
|
||||||
data_total_length: int,
|
|
||||||
time_stamp: Optional[int],
|
|
||||||
packet_sequence_number: Optional[int],
|
|
||||||
iso_sdu_length: Optional[int],
|
|
||||||
packet_status_flag: Optional[int],
|
|
||||||
iso_sdu_fragment: bytes,
|
|
||||||
) -> None:
|
|
||||||
self.connection_handle = connection_handle
|
|
||||||
self.pb_flag = pb_flag
|
|
||||||
self.ts_flag = ts_flag
|
|
||||||
self.data_total_length = data_total_length
|
|
||||||
self.time_stamp = time_stamp
|
|
||||||
self.packet_sequence_number = packet_sequence_number
|
|
||||||
self.iso_sdu_length = iso_sdu_length
|
|
||||||
self.packet_status_flag = packet_status_flag
|
|
||||||
self.iso_sdu_fragment = iso_sdu_fragment
|
|
||||||
|
|
||||||
def __bytes__(self) -> bytes:
|
def __bytes__(self) -> bytes:
|
||||||
return self.to_bytes()
|
return self.to_bytes()
|
||||||
|
|
||||||
|
|||||||
+21
-6
@@ -18,6 +18,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, MutableMapping
|
from collections.abc import Callable, MutableMapping
|
||||||
|
import datetime
|
||||||
from typing import cast, Any, Optional
|
from typing import cast, Any, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -66,12 +67,13 @@ PSM_NAMES = {
|
|||||||
rfcomm.RFCOMM_PSM: 'RFCOMM',
|
rfcomm.RFCOMM_PSM: 'RFCOMM',
|
||||||
sdp.SDP_PSM: 'SDP',
|
sdp.SDP_PSM: 'SDP',
|
||||||
avdtp.AVDTP_PSM: 'AVDTP',
|
avdtp.AVDTP_PSM: 'AVDTP',
|
||||||
avctp.AVCTP_PSM: 'AVCTP'
|
avctp.AVCTP_PSM: 'AVCTP',
|
||||||
# TODO: add more PSM values
|
# TODO: add more PSM values
|
||||||
}
|
}
|
||||||
|
|
||||||
AVCTP_PID_NAMES = {avrcp.AVRCP_PID: 'AVRCP'}
|
AVCTP_PID_NAMES = {avrcp.AVRCP_PID: 'AVRCP'}
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PacketTracer:
|
class PacketTracer:
|
||||||
class AclStream:
|
class AclStream:
|
||||||
@@ -207,6 +209,7 @@ class PacketTracer:
|
|||||||
self.label = label
|
self.label = label
|
||||||
self.emit_message = emit_message
|
self.emit_message = emit_message
|
||||||
self.acl_streams = {} # ACL streams, by connection handle
|
self.acl_streams = {} # ACL streams, by connection handle
|
||||||
|
self.packet_timestamp: Optional[datetime.datetime] = None
|
||||||
|
|
||||||
def start_acl_stream(self, connection_handle: int) -> PacketTracer.AclStream:
|
def start_acl_stream(self, connection_handle: int) -> PacketTracer.AclStream:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -234,7 +237,10 @@ class PacketTracer:
|
|||||||
# Let the other forwarder know so it can cleanup its stream as well
|
# Let the other forwarder know so it can cleanup its stream as well
|
||||||
self.peer.end_acl_stream(connection_handle)
|
self.peer.end_acl_stream(connection_handle)
|
||||||
|
|
||||||
def on_packet(self, packet: HCI_Packet) -> None:
|
def on_packet(
|
||||||
|
self, timestamp: Optional[datetime.datetime], packet: HCI_Packet
|
||||||
|
) -> None:
|
||||||
|
self.packet_timestamp = timestamp
|
||||||
self.emit(packet)
|
self.emit(packet)
|
||||||
|
|
||||||
if packet.hci_packet_type == HCI_ACL_DATA_PACKET:
|
if packet.hci_packet_type == HCI_ACL_DATA_PACKET:
|
||||||
@@ -254,13 +260,22 @@ class PacketTracer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def emit(self, message: Any) -> None:
|
def emit(self, message: Any) -> None:
|
||||||
self.emit_message(f'[{self.label}] {message}')
|
if self.packet_timestamp:
|
||||||
|
prefix = f"[{self.packet_timestamp.strftime('%Y-%m-%d %H:%M:%S.%f')}]"
|
||||||
|
else:
|
||||||
|
prefix = ""
|
||||||
|
self.emit_message(f'{prefix}[{self.label}] {message}')
|
||||||
|
|
||||||
def trace(self, packet: HCI_Packet, direction: int = 0) -> None:
|
def trace(
|
||||||
|
self,
|
||||||
|
packet: HCI_Packet,
|
||||||
|
direction: int = 0,
|
||||||
|
timestamp: Optional[datetime.datetime] = None,
|
||||||
|
) -> None:
|
||||||
if direction == 0:
|
if direction == 0:
|
||||||
self.host_to_controller_analyzer.on_packet(packet)
|
self.host_to_controller_analyzer.on_packet(timestamp, packet)
|
||||||
else:
|
else:
|
||||||
self.controller_to_host_analyzer.on_packet(packet)
|
self.controller_to_host_analyzer.on_packet(timestamp, packet)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+1022
-80
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,7 @@ HID_INTERRUPT_PSM = 0x0013
|
|||||||
|
|
||||||
class Message:
|
class Message:
|
||||||
message_type: MessageType
|
message_type: MessageType
|
||||||
|
|
||||||
# Report types
|
# Report types
|
||||||
class ReportType(enum.IntEnum):
|
class ReportType(enum.IntEnum):
|
||||||
OTHER_REPORT = 0x00
|
OTHER_REPORT = 0x00
|
||||||
|
|||||||
+37
-33
@@ -22,7 +22,17 @@ import dataclasses
|
|||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
from typing import Any, Awaitable, Callable, Deque, Dict, Optional, cast, TYPE_CHECKING
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Awaitable,
|
||||||
|
Callable,
|
||||||
|
Deque,
|
||||||
|
Dict,
|
||||||
|
Optional,
|
||||||
|
Set,
|
||||||
|
cast,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.l2cap import L2CAP_PDU
|
from bumble.l2cap import L2CAP_PDU
|
||||||
@@ -165,7 +175,7 @@ class Host(AbortableEventEmitter):
|
|||||||
self.number_of_supported_advertising_sets = 0
|
self.number_of_supported_advertising_sets = 0
|
||||||
self.maximum_advertising_data_length = 31
|
self.maximum_advertising_data_length = 31
|
||||||
self.local_version = None
|
self.local_version = None
|
||||||
self.local_supported_commands = bytes(64)
|
self.local_supported_commands = 0
|
||||||
self.local_le_features = 0
|
self.local_le_features = 0
|
||||||
self.local_lmp_features = hci.LmpFeatureMask(0) # Classic LMP features
|
self.local_lmp_features = hci.LmpFeatureMask(0) # Classic LMP features
|
||||||
self.suggested_max_tx_octets = 251 # Max allowed
|
self.suggested_max_tx_octets = 251 # Max allowed
|
||||||
@@ -174,7 +184,7 @@ class Host(AbortableEventEmitter):
|
|||||||
self.long_term_key_provider = None
|
self.long_term_key_provider = None
|
||||||
self.link_key_provider = None
|
self.link_key_provider = None
|
||||||
self.pairing_io_capability_provider = None # Classic only
|
self.pairing_io_capability_provider = None # Classic only
|
||||||
self.snooper = None
|
self.snooper: Optional[Snooper] = None
|
||||||
|
|
||||||
# Connect to the source and sink if specified
|
# Connect to the source and sink if specified
|
||||||
if controller_source:
|
if controller_source:
|
||||||
@@ -232,7 +242,9 @@ class Host(AbortableEventEmitter):
|
|||||||
response = await self.send_command(
|
response = await self.send_command(
|
||||||
hci.HCI_Read_Local_Supported_Commands_Command(), check_result=True
|
hci.HCI_Read_Local_Supported_Commands_Command(), check_result=True
|
||||||
)
|
)
|
||||||
self.local_supported_commands = response.return_parameters.supported_commands
|
self.local_supported_commands = int.from_bytes(
|
||||||
|
response.return_parameters.supported_commands, 'little'
|
||||||
|
)
|
||||||
|
|
||||||
if self.supports_command(hci.HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
|
if self.supports_command(hci.HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
|
||||||
response = await self.send_command(
|
response = await self.send_command(
|
||||||
@@ -486,7 +498,7 @@ class Host(AbortableEventEmitter):
|
|||||||
def controller(self, controller) -> None:
|
def controller(self, controller) -> None:
|
||||||
self.set_packet_sink(controller)
|
self.set_packet_sink(controller)
|
||||||
if controller:
|
if controller:
|
||||||
controller.set_packet_sink(self)
|
self.set_packet_source(controller)
|
||||||
|
|
||||||
def set_packet_sink(self, sink: Optional[TransportSink]) -> None:
|
def set_packet_sink(self, sink: Optional[TransportSink]) -> None:
|
||||||
self.hci_sink = sink
|
self.hci_sink = sink
|
||||||
@@ -518,7 +530,9 @@ class Host(AbortableEventEmitter):
|
|||||||
|
|
||||||
# Check the return parameters if required
|
# Check the return parameters if required
|
||||||
if check_result:
|
if check_result:
|
||||||
if isinstance(response.return_parameters, int):
|
if isinstance(response, hci.HCI_Command_Status_Event):
|
||||||
|
status = response.status
|
||||||
|
elif isinstance(response.return_parameters, int):
|
||||||
status = response.return_parameters
|
status = response.return_parameters
|
||||||
elif isinstance(response.return_parameters, bytes):
|
elif isinstance(response.return_parameters, bytes):
|
||||||
# return parameters first field is a one byte status code
|
# return parameters first field is a one byte status code
|
||||||
@@ -583,31 +597,19 @@ class Host(AbortableEventEmitter):
|
|||||||
offset += data_total_length
|
offset += data_total_length
|
||||||
bytes_remaining -= data_total_length
|
bytes_remaining -= data_total_length
|
||||||
|
|
||||||
def supports_command(self, command):
|
def supports_command(self, op_code: int) -> bool:
|
||||||
# Find the support flag position for this command
|
return (
|
||||||
for octet, flags in enumerate(hci.HCI_SUPPORTED_COMMANDS_FLAGS):
|
self.local_supported_commands
|
||||||
for flag_position, value in enumerate(flags):
|
& hci.HCI_SUPPORTED_COMMANDS_MASKS.get(op_code, 0)
|
||||||
if value == command:
|
) != 0
|
||||||
# Check if the flag is set
|
|
||||||
if octet < len(self.local_supported_commands) and flag_position < 8:
|
|
||||||
return (
|
|
||||||
self.local_supported_commands[octet] & (1 << flag_position)
|
|
||||||
) != 0
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supported_commands(self):
|
def supported_commands(self) -> Set[int]:
|
||||||
commands = []
|
return set(
|
||||||
for octet, flags in enumerate(self.local_supported_commands):
|
op_code
|
||||||
if octet < len(hci.HCI_SUPPORTED_COMMANDS_FLAGS):
|
for op_code, mask in hci.HCI_SUPPORTED_COMMANDS_MASKS.items()
|
||||||
for flag in range(8):
|
if self.local_supported_commands & mask
|
||||||
if flags & (1 << flag) != 0:
|
)
|
||||||
command = hci.HCI_SUPPORTED_COMMANDS_FLAGS[octet][flag]
|
|
||||||
if command is not None:
|
|
||||||
commands.append(command)
|
|
||||||
|
|
||||||
return commands
|
|
||||||
|
|
||||||
def supports_le_features(self, feature: hci.LeFeatureMask) -> bool:
|
def supports_le_features(self, feature: hci.LeFeatureMask) -> bool:
|
||||||
return (self.local_le_features & feature) == feature
|
return (self.local_le_features & feature) == feature
|
||||||
@@ -719,14 +721,16 @@ class Host(AbortableEventEmitter):
|
|||||||
for connection_handle, num_completed_packets in zip(
|
for connection_handle, num_completed_packets in zip(
|
||||||
event.connection_handles, event.num_completed_packets
|
event.connection_handles, event.num_completed_packets
|
||||||
):
|
):
|
||||||
if not (connection := self.connections.get(connection_handle)):
|
if connection := self.connections.get(connection_handle):
|
||||||
|
connection.acl_packet_queue.on_packets_completed(num_completed_packets)
|
||||||
|
elif not (
|
||||||
|
self.cis_links.get(connection_handle)
|
||||||
|
or self.sco_links.get(connection_handle)
|
||||||
|
):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'received packet completion event for unknown handle '
|
'received packet completion event for unknown handle '
|
||||||
f'0x{connection_handle:04X}'
|
f'0x{connection_handle:04X}'
|
||||||
)
|
)
|
||||||
continue
|
|
||||||
|
|
||||||
connection.acl_packet_queue.on_packets_completed(num_completed_packets)
|
|
||||||
|
|
||||||
# Classic only
|
# Classic only
|
||||||
def on_hci_connection_request_event(self, event):
|
def on_hci_connection_request_event(self, event):
|
||||||
|
|||||||
+11
-8
@@ -25,7 +25,8 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
from .colors import color
|
from .colors import color
|
||||||
from .hci import Address
|
from .hci import Address
|
||||||
@@ -128,10 +129,10 @@ class PairingKeys:
|
|||||||
|
|
||||||
def print(self, prefix=''):
|
def print(self, prefix=''):
|
||||||
keys_dict = self.to_dict()
|
keys_dict = self.to_dict()
|
||||||
for (container_property, value) in keys_dict.items():
|
for container_property, value in keys_dict.items():
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
print(f'{prefix}{color(container_property, "cyan")}:')
|
print(f'{prefix}{color(container_property, "cyan")}:')
|
||||||
for (key_property, key_value) in value.items():
|
for key_property, key_value in value.items():
|
||||||
print(f'{prefix} {color(key_property, "green")}: {key_value}')
|
print(f'{prefix} {color(key_property, "green")}: {key_value}')
|
||||||
else:
|
else:
|
||||||
print(f'{prefix}{color(container_property, "cyan")}: {value}')
|
print(f'{prefix}{color(container_property, "cyan")}: {value}')
|
||||||
@@ -158,7 +159,7 @@ class KeyStore:
|
|||||||
async def get_resolving_keys(self):
|
async def get_resolving_keys(self):
|
||||||
all_keys = await self.get_all()
|
all_keys = await self.get_all()
|
||||||
resolving_keys = []
|
resolving_keys = []
|
||||||
for (name, keys) in all_keys:
|
for name, keys in all_keys:
|
||||||
if keys.irk is not None:
|
if keys.irk is not None:
|
||||||
if keys.address_type is None:
|
if keys.address_type is None:
|
||||||
address_type = Address.RANDOM_DEVICE_ADDRESS
|
address_type = Address.RANDOM_DEVICE_ADDRESS
|
||||||
@@ -171,7 +172,7 @@ class KeyStore:
|
|||||||
async def print(self, prefix=''):
|
async def print(self, prefix=''):
|
||||||
entries = await self.get_all()
|
entries = await self.get_all()
|
||||||
separator = ''
|
separator = ''
|
||||||
for (name, keys) in entries:
|
for name, keys in entries:
|
||||||
print(separator + prefix + color(name, 'yellow'))
|
print(separator + prefix + color(name, 'yellow'))
|
||||||
keys.print(prefix=prefix + ' ')
|
keys.print(prefix=prefix + ' ')
|
||||||
separator = '\n'
|
separator = '\n'
|
||||||
@@ -253,8 +254,10 @@ class JsonKeyStore(KeyStore):
|
|||||||
|
|
||||||
logger.debug(f'JSON keystore: {self.filename}')
|
logger.debug(f'JSON keystore: {self.filename}')
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def from_device(device: Device, filename=None) -> Optional[JsonKeyStore]:
|
def from_device(
|
||||||
|
cls: Type[Self], device: Device, filename: Optional[str] = None
|
||||||
|
) -> Self:
|
||||||
if not filename:
|
if not filename:
|
||||||
# Extract the filename from the config if there is one
|
# Extract the filename from the config if there is one
|
||||||
if device.config.keystore is not None:
|
if device.config.keystore is not None:
|
||||||
@@ -270,7 +273,7 @@ class JsonKeyStore(KeyStore):
|
|||||||
else:
|
else:
|
||||||
namespace = JsonKeyStore.DEFAULT_NAMESPACE
|
namespace = JsonKeyStore.DEFAULT_NAMESPACE
|
||||||
|
|
||||||
return JsonKeyStore(namespace, filename)
|
return cls(namespace, filename)
|
||||||
|
|
||||||
async def load(self):
|
async def load(self):
|
||||||
# Try to open the file, without failing. If the file does not exist, it
|
# Try to open the file, without failing. If the file does not exist, it
|
||||||
|
|||||||
+5
-2
@@ -70,6 +70,7 @@ L2CAP_LE_SIGNALING_CID = 0x05
|
|||||||
|
|
||||||
L2CAP_MIN_LE_MTU = 23
|
L2CAP_MIN_LE_MTU = 23
|
||||||
L2CAP_MIN_BR_EDR_MTU = 48
|
L2CAP_MIN_BR_EDR_MTU = 48
|
||||||
|
L2CAP_MAX_BR_EDR_MTU = 65535
|
||||||
|
|
||||||
L2CAP_DEFAULT_MTU = 2048 # Default value for the MTU we are willing to accept
|
L2CAP_DEFAULT_MTU = 2048 # Default value for the MTU we are willing to accept
|
||||||
|
|
||||||
@@ -832,7 +833,9 @@ class ClassicChannel(EventEmitter):
|
|||||||
|
|
||||||
# Wait for the connection to succeed or fail
|
# Wait for the connection to succeed or fail
|
||||||
try:
|
try:
|
||||||
return await self.connection_result
|
return await self.connection.abort_on(
|
||||||
|
'disconnection', self.connection_result
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
|
|
||||||
@@ -2225,7 +2228,7 @@ class ChannelManager:
|
|||||||
# Connect
|
# Connect
|
||||||
try:
|
try:
|
||||||
await channel.connect()
|
await channel.connect()
|
||||||
except Exception as e:
|
except BaseException as e:
|
||||||
del connection_channels[source_cid]
|
del connection_channels[source_cid]
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|||||||
+172
-27
@@ -34,8 +34,11 @@ from bumble.device import (
|
|||||||
DEVICE_DEFAULT_SCAN_INTERVAL,
|
DEVICE_DEFAULT_SCAN_INTERVAL,
|
||||||
DEVICE_DEFAULT_SCAN_WINDOW,
|
DEVICE_DEFAULT_SCAN_WINDOW,
|
||||||
Advertisement,
|
Advertisement,
|
||||||
|
AdvertisingParameters,
|
||||||
|
AdvertisingEventProperties,
|
||||||
AdvertisingType,
|
AdvertisingType,
|
||||||
Device,
|
Device,
|
||||||
|
Phy,
|
||||||
)
|
)
|
||||||
from bumble.gatt import Service
|
from bumble.gatt import Service
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
@@ -47,9 +50,12 @@ from bumble.hci import (
|
|||||||
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
||||||
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
||||||
from pandora.host_grpc_aio import HostServicer
|
from pandora.host_grpc_aio import HostServicer
|
||||||
|
from pandora import host_pb2
|
||||||
from pandora.host_pb2 import (
|
from pandora.host_pb2 import (
|
||||||
NOT_CONNECTABLE,
|
NOT_CONNECTABLE,
|
||||||
NOT_DISCOVERABLE,
|
NOT_DISCOVERABLE,
|
||||||
|
DISCOVERABLE_LIMITED,
|
||||||
|
DISCOVERABLE_GENERAL,
|
||||||
PRIMARY_1M,
|
PRIMARY_1M,
|
||||||
PRIMARY_CODED,
|
PRIMARY_CODED,
|
||||||
SECONDARY_1M,
|
SECONDARY_1M,
|
||||||
@@ -65,6 +71,7 @@ from pandora.host_pb2 import (
|
|||||||
ConnectResponse,
|
ConnectResponse,
|
||||||
DataTypes,
|
DataTypes,
|
||||||
DisconnectRequest,
|
DisconnectRequest,
|
||||||
|
DiscoverabilityMode,
|
||||||
InquiryResponse,
|
InquiryResponse,
|
||||||
PrimaryPhy,
|
PrimaryPhy,
|
||||||
ReadLocalAddressResponse,
|
ReadLocalAddressResponse,
|
||||||
@@ -94,6 +101,25 @@ SECONDARY_PHY_MAP: Dict[int, SecondaryPhy] = {
|
|||||||
3: SECONDARY_CODED,
|
3: SECONDARY_CODED,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PRIMARY_PHY_TO_BUMBLE_PHY_MAP: Dict[PrimaryPhy, Phy] = {
|
||||||
|
PRIMARY_1M: Phy.LE_1M,
|
||||||
|
PRIMARY_CODED: Phy.LE_CODED,
|
||||||
|
}
|
||||||
|
|
||||||
|
SECONDARY_PHY_TO_BUMBLE_PHY_MAP: Dict[SecondaryPhy, Phy] = {
|
||||||
|
SECONDARY_NONE: Phy.LE_1M,
|
||||||
|
SECONDARY_1M: Phy.LE_1M,
|
||||||
|
SECONDARY_2M: Phy.LE_2M,
|
||||||
|
SECONDARY_CODED: Phy.LE_CODED,
|
||||||
|
}
|
||||||
|
|
||||||
|
OWN_ADDRESS_MAP: Dict[host_pb2.OwnAddressType, bumble.hci.OwnAddressType] = {
|
||||||
|
host_pb2.PUBLIC: bumble.hci.OwnAddressType.PUBLIC,
|
||||||
|
host_pb2.RANDOM: bumble.hci.OwnAddressType.RANDOM,
|
||||||
|
host_pb2.RESOLVABLE_OR_PUBLIC: bumble.hci.OwnAddressType.RESOLVABLE_OR_PUBLIC,
|
||||||
|
host_pb2.RESOLVABLE_OR_RANDOM: bumble.hci.OwnAddressType.RESOLVABLE_OR_RANDOM,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class HostService(HostServicer):
|
class HostService(HostServicer):
|
||||||
waited_connections: Set[int]
|
waited_connections: Set[int]
|
||||||
@@ -261,9 +287,9 @@ class HostService(HostServicer):
|
|||||||
self.log.debug(f"WaitDisconnection: {connection_handle}")
|
self.log.debug(f"WaitDisconnection: {connection_handle}")
|
||||||
|
|
||||||
if connection := self.device.lookup_connection(connection_handle):
|
if connection := self.device.lookup_connection(connection_handle):
|
||||||
disconnection_future: asyncio.Future[
|
disconnection_future: asyncio.Future[None] = (
|
||||||
None
|
asyncio.get_running_loop().create_future()
|
||||||
] = asyncio.get_running_loop().create_future()
|
)
|
||||||
|
|
||||||
def on_disconnection(_: None) -> None:
|
def on_disconnection(_: None) -> None:
|
||||||
disconnection_future.set_result(None)
|
disconnection_future.set_result(None)
|
||||||
@@ -281,10 +307,113 @@ class HostService(HostServicer):
|
|||||||
async def Advertise(
|
async def Advertise(
|
||||||
self, request: AdvertiseRequest, context: grpc.ServicerContext
|
self, request: AdvertiseRequest, context: grpc.ServicerContext
|
||||||
) -> AsyncGenerator[AdvertiseResponse, None]:
|
) -> AsyncGenerator[AdvertiseResponse, None]:
|
||||||
if not request.legacy:
|
try:
|
||||||
raise NotImplementedError(
|
if request.legacy:
|
||||||
"TODO: add support for extended advertising in Bumble"
|
async for rsp in self.legacy_advertise(request, context):
|
||||||
|
yield rsp
|
||||||
|
else:
|
||||||
|
async for rsp in self.extended_advertise(request, context):
|
||||||
|
yield rsp
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def extended_advertise(
|
||||||
|
self, request: AdvertiseRequest, context: grpc.ServicerContext
|
||||||
|
) -> AsyncGenerator[AdvertiseResponse, None]:
|
||||||
|
advertising_data = bytes(self.unpack_data_types(request.data))
|
||||||
|
scan_response_data = bytes(self.unpack_data_types(request.scan_response_data))
|
||||||
|
scannable = len(scan_response_data) != 0
|
||||||
|
|
||||||
|
advertising_event_properties = AdvertisingEventProperties(
|
||||||
|
is_connectable=request.connectable,
|
||||||
|
is_scannable=scannable,
|
||||||
|
is_directed=request.target is not None,
|
||||||
|
is_high_duty_cycle_directed_connectable=False,
|
||||||
|
is_legacy=False,
|
||||||
|
is_anonymous=False,
|
||||||
|
include_tx_power=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
peer_address = Address.ANY
|
||||||
|
if request.target:
|
||||||
|
# Need to reverse bytes order since Bumble Address is using MSB.
|
||||||
|
target_bytes = bytes(reversed(request.target))
|
||||||
|
if request.target_variant() == "public":
|
||||||
|
peer_address = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS)
|
||||||
|
else:
|
||||||
|
peer_address = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
|
||||||
|
|
||||||
|
advertising_parameters = AdvertisingParameters(
|
||||||
|
advertising_event_properties=advertising_event_properties,
|
||||||
|
own_address_type=OWN_ADDRESS_MAP[request.own_address_type],
|
||||||
|
peer_address=peer_address,
|
||||||
|
primary_advertising_phy=PRIMARY_PHY_TO_BUMBLE_PHY_MAP[request.primary_phy],
|
||||||
|
secondary_advertising_phy=SECONDARY_PHY_TO_BUMBLE_PHY_MAP[
|
||||||
|
request.secondary_phy
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if advertising_interval := request.interval:
|
||||||
|
advertising_parameters.primary_advertising_interval_min = int(
|
||||||
|
advertising_interval
|
||||||
)
|
)
|
||||||
|
advertising_parameters.primary_advertising_interval_max = int(
|
||||||
|
advertising_interval
|
||||||
|
)
|
||||||
|
if interval_range := request.interval_range:
|
||||||
|
advertising_parameters.primary_advertising_interval_max += int(
|
||||||
|
interval_range
|
||||||
|
)
|
||||||
|
|
||||||
|
advertising_set = await self.device.create_advertising_set(
|
||||||
|
advertising_parameters=advertising_parameters,
|
||||||
|
advertising_data=advertising_data,
|
||||||
|
scan_response_data=scan_response_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
pending_connection: asyncio.Future[bumble.device.Connection] = (
|
||||||
|
asyncio.get_running_loop().create_future()
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.connectable:
|
||||||
|
|
||||||
|
def on_connection(connection: bumble.device.Connection) -> None:
|
||||||
|
if (
|
||||||
|
connection.transport == BT_LE_TRANSPORT
|
||||||
|
and connection.role == BT_PERIPHERAL_ROLE
|
||||||
|
):
|
||||||
|
pending_connection.set_result(connection)
|
||||||
|
|
||||||
|
self.device.on('connection', on_connection)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Advertise until RPC is canceled
|
||||||
|
while True:
|
||||||
|
if not advertising_set.enabled:
|
||||||
|
self.log.debug('Advertise (extended)')
|
||||||
|
await advertising_set.start()
|
||||||
|
|
||||||
|
if not request.connectable:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
connection = await pending_connection
|
||||||
|
pending_connection = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
|
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
|
||||||
|
yield AdvertiseResponse(connection=Connection(cookie=cookie))
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
self.log.debug('Stop Advertise (extended)')
|
||||||
|
await advertising_set.stop()
|
||||||
|
await advertising_set.remove()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def legacy_advertise(
|
||||||
|
self, request: AdvertiseRequest, context: grpc.ServicerContext
|
||||||
|
) -> AsyncGenerator[AdvertiseResponse, None]:
|
||||||
if advertising_interval := request.interval:
|
if advertising_interval := request.interval:
|
||||||
self.device.config.advertising_interval_min = int(advertising_interval)
|
self.device.config.advertising_interval_min = int(advertising_interval)
|
||||||
self.device.config.advertising_interval_max = int(advertising_interval)
|
self.device.config.advertising_interval_max = int(advertising_interval)
|
||||||
@@ -357,14 +486,10 @@ class HostService(HostServicer):
|
|||||||
target_bytes = bytes(reversed(request.target))
|
target_bytes = bytes(reversed(request.target))
|
||||||
if request.target_variant() == "public":
|
if request.target_variant() == "public":
|
||||||
target = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS)
|
target = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS)
|
||||||
advertising_type = (
|
advertising_type = AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
|
||||||
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
|
|
||||||
) # FIXME: HIGH_DUTY ?
|
|
||||||
else:
|
else:
|
||||||
target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
|
target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
|
||||||
advertising_type = (
|
advertising_type = AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
|
||||||
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
|
|
||||||
) # FIXME: HIGH_DUTY ?
|
|
||||||
|
|
||||||
if request.connectable:
|
if request.connectable:
|
||||||
|
|
||||||
@@ -391,9 +516,9 @@ class HostService(HostServicer):
|
|||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
pending_connection: asyncio.Future[
|
pending_connection: asyncio.Future[bumble.device.Connection] = (
|
||||||
bumble.device.Connection
|
asyncio.get_running_loop().create_future()
|
||||||
] = asyncio.get_running_loop().create_future()
|
)
|
||||||
|
|
||||||
self.log.debug('Wait for LE connection...')
|
self.log.debug('Wait for LE connection...')
|
||||||
connection = await pending_connection
|
connection = await pending_connection
|
||||||
@@ -422,23 +547,31 @@ class HostService(HostServicer):
|
|||||||
self, request: ScanRequest, context: grpc.ServicerContext
|
self, request: ScanRequest, context: grpc.ServicerContext
|
||||||
) -> AsyncGenerator[ScanningResponse, None]:
|
) -> AsyncGenerator[ScanningResponse, None]:
|
||||||
# TODO: modify `start_scanning` to accept floats instead of int for ms values
|
# TODO: modify `start_scanning` to accept floats instead of int for ms values
|
||||||
if request.phys:
|
|
||||||
raise NotImplementedError("TODO: add support for `request.phys`")
|
|
||||||
|
|
||||||
self.log.debug('Scan')
|
self.log.debug('Scan')
|
||||||
|
|
||||||
|
scanning_phys = []
|
||||||
|
if PRIMARY_1M in request.phys:
|
||||||
|
scanning_phys.append(int(Phy.LE_1M))
|
||||||
|
if PRIMARY_CODED in request.phys:
|
||||||
|
scanning_phys.append(int(Phy.LE_CODED))
|
||||||
|
if not scanning_phys:
|
||||||
|
scanning_phys = [int(Phy.LE_1M), int(Phy.LE_CODED)]
|
||||||
|
|
||||||
scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue()
|
scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue()
|
||||||
handler = self.device.on('advertisement', scan_queue.put_nowait)
|
handler = self.device.on('advertisement', scan_queue.put_nowait)
|
||||||
await self.device.start_scanning(
|
await self.device.start_scanning(
|
||||||
legacy=request.legacy,
|
legacy=request.legacy,
|
||||||
active=not request.passive,
|
active=not request.passive,
|
||||||
own_address_type=request.own_address_type,
|
own_address_type=request.own_address_type,
|
||||||
scan_interval=int(request.interval)
|
scan_interval=(
|
||||||
if request.interval
|
int(request.interval)
|
||||||
else DEVICE_DEFAULT_SCAN_INTERVAL,
|
if request.interval
|
||||||
scan_window=int(request.window)
|
else DEVICE_DEFAULT_SCAN_INTERVAL
|
||||||
if request.window
|
),
|
||||||
else DEVICE_DEFAULT_SCAN_WINDOW,
|
scan_window=(
|
||||||
|
int(request.window) if request.window else DEVICE_DEFAULT_SCAN_WINDOW
|
||||||
|
),
|
||||||
|
scanning_phys=scanning_phys,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -651,9 +784,11 @@ class HostService(HostServicer):
|
|||||||
*struct.pack('<H', dt.peripheral_connection_interval_min),
|
*struct.pack('<H', dt.peripheral_connection_interval_min),
|
||||||
*struct.pack(
|
*struct.pack(
|
||||||
'<H',
|
'<H',
|
||||||
dt.peripheral_connection_interval_max
|
(
|
||||||
if dt.peripheral_connection_interval_max
|
dt.peripheral_connection_interval_max
|
||||||
else dt.peripheral_connection_interval_min,
|
if dt.peripheral_connection_interval_max
|
||||||
|
else dt.peripheral_connection_interval_min
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
@@ -735,6 +870,16 @@ class HostService(HostServicer):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
flag_map = {
|
||||||
|
NOT_DISCOVERABLE: 0x00,
|
||||||
|
DISCOVERABLE_LIMITED: AdvertisingData.LE_LIMITED_DISCOVERABLE_MODE_FLAG,
|
||||||
|
DISCOVERABLE_GENERAL: AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG,
|
||||||
|
}
|
||||||
|
|
||||||
|
if dt.le_discoverability_mode:
|
||||||
|
flags = flag_map[dt.le_discoverability_mode]
|
||||||
|
ad_structures.append((AdvertisingData.FLAGS, flags.to_bytes(1, 'big')))
|
||||||
|
|
||||||
return AdvertisingData(ad_structures)
|
return AdvertisingData(ad_structures)
|
||||||
|
|
||||||
def pack_data_types(self, ad: AdvertisingData) -> DataTypes:
|
def pack_data_types(self, ad: AdvertisingData) -> DataTypes:
|
||||||
|
|||||||
@@ -383,9 +383,9 @@ class SecurityService(SecurityServicer):
|
|||||||
connection.transport
|
connection.transport
|
||||||
] == request.level_variant()
|
] == request.level_variant()
|
||||||
|
|
||||||
wait_for_security: asyncio.Future[
|
wait_for_security: asyncio.Future[str] = (
|
||||||
str
|
asyncio.get_running_loop().create_future()
|
||||||
] = asyncio.get_running_loop().create_future()
|
)
|
||||||
authenticate_task: Optional[asyncio.Future[None]] = None
|
authenticate_task: Optional[asyncio.Future[None]] = None
|
||||||
pair_task: Optional[asyncio.Future[None]] = None
|
pair_task: Optional[asyncio.Future[None]] = None
|
||||||
|
|
||||||
|
|||||||
+90
-12
@@ -24,8 +24,9 @@ import enum
|
|||||||
import struct
|
import struct
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, List, Union, Type, Dict, Any, Tuple, cast
|
from typing import Optional, List, Union, Type, Dict, Any, Tuple
|
||||||
|
|
||||||
|
from bumble import core
|
||||||
from bumble import colors
|
from bumble import colors
|
||||||
from bumble import device
|
from bumble import device
|
||||||
from bumble import hci
|
from bumble import hci
|
||||||
@@ -77,6 +78,10 @@ class AudioLocation(enum.IntFlag):
|
|||||||
LEFT_SURROUND = 0x04000000
|
LEFT_SURROUND = 0x04000000
|
||||||
RIGHT_SURROUND = 0x08000000
|
RIGHT_SURROUND = 0x08000000
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channel_count(self) -> int:
|
||||||
|
return bin(self.value).count('1')
|
||||||
|
|
||||||
|
|
||||||
class AudioInputType(enum.IntEnum):
|
class AudioInputType(enum.IntEnum):
|
||||||
'''Bluetooth Assigned Numbers, Section 6.12.2 - Audio Input Type'''
|
'''Bluetooth Assigned Numbers, Section 6.12.2 - Audio Input Type'''
|
||||||
@@ -217,6 +222,13 @@ class FrameDuration(enum.IntEnum):
|
|||||||
DURATION_7500_US = 0x00
|
DURATION_7500_US = 0x00
|
||||||
DURATION_10000_US = 0x01
|
DURATION_10000_US = 0x01
|
||||||
|
|
||||||
|
@property
|
||||||
|
def us(self) -> int:
|
||||||
|
return {
|
||||||
|
FrameDuration.DURATION_7500_US: 7500,
|
||||||
|
FrameDuration.DURATION_10000_US: 10000,
|
||||||
|
}[self]
|
||||||
|
|
||||||
|
|
||||||
class SupportedFrameDuration(enum.IntFlag):
|
class SupportedFrameDuration(enum.IntFlag):
|
||||||
'''Bluetooth Assigned Numbers, Section 6.12.4.2 - Frame Duration'''
|
'''Bluetooth Assigned Numbers, Section 6.12.4.2 - Frame Duration'''
|
||||||
@@ -228,6 +240,14 @@ class SupportedFrameDuration(enum.IntFlag):
|
|||||||
DURATION_10000_US_PREFERRED = 0b0010
|
DURATION_10000_US_PREFERRED = 0b0010
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementType(enum.IntEnum):
|
||||||
|
'''Basic Audio Profile, 3.5.3. Additional Audio Stream Control Service requirements'''
|
||||||
|
|
||||||
|
# fmt: off
|
||||||
|
GENERAL = 0x00
|
||||||
|
TARGETED = 0x01
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# ASE Operations
|
# ASE Operations
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -453,6 +473,34 @@ class AudioRole(enum.IntEnum):
|
|||||||
SOURCE = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.HOST_TO_CONTROLLER
|
SOURCE = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.HOST_TO_CONTROLLER
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class UnicastServerAdvertisingData:
|
||||||
|
"""Advertising Data for ASCS."""
|
||||||
|
|
||||||
|
announcement_type: AnnouncementType = AnnouncementType.TARGETED
|
||||||
|
available_audio_contexts: ContextType = ContextType.MEDIA
|
||||||
|
metadata: bytes = b''
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return bytes(
|
||||||
|
core.AdvertisingData(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
||||||
|
struct.pack(
|
||||||
|
'<2sBIB',
|
||||||
|
gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE.to_bytes(),
|
||||||
|
self.announcement_type,
|
||||||
|
self.available_audio_contexts,
|
||||||
|
len(self.metadata),
|
||||||
|
)
|
||||||
|
+ self.metadata,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Utils
|
# Utils
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -497,7 +545,7 @@ class CodecSpecificCapabilities:
|
|||||||
|
|
||||||
supported_sampling_frequencies: SupportedSamplingFrequency
|
supported_sampling_frequencies: SupportedSamplingFrequency
|
||||||
supported_frame_durations: SupportedFrameDuration
|
supported_frame_durations: SupportedFrameDuration
|
||||||
supported_audio_channel_counts: Sequence[int]
|
supported_audio_channel_count: Sequence[int]
|
||||||
min_octets_per_codec_frame: int
|
min_octets_per_codec_frame: int
|
||||||
max_octets_per_codec_frame: int
|
max_octets_per_codec_frame: int
|
||||||
supported_max_codec_frames_per_sdu: int
|
supported_max_codec_frames_per_sdu: int
|
||||||
@@ -506,7 +554,7 @@ class CodecSpecificCapabilities:
|
|||||||
def from_bytes(cls, data: bytes) -> CodecSpecificCapabilities:
|
def from_bytes(cls, data: bytes) -> CodecSpecificCapabilities:
|
||||||
offset = 0
|
offset = 0
|
||||||
# Allowed default values.
|
# Allowed default values.
|
||||||
supported_audio_channel_counts = [1]
|
supported_audio_channel_count = [1]
|
||||||
supported_max_codec_frames_per_sdu = 1
|
supported_max_codec_frames_per_sdu = 1
|
||||||
while offset < len(data):
|
while offset < len(data):
|
||||||
length, type = struct.unpack_from('BB', data, offset)
|
length, type = struct.unpack_from('BB', data, offset)
|
||||||
@@ -519,7 +567,7 @@ class CodecSpecificCapabilities:
|
|||||||
elif type == CodecSpecificCapabilities.Type.FRAME_DURATION:
|
elif type == CodecSpecificCapabilities.Type.FRAME_DURATION:
|
||||||
supported_frame_durations = SupportedFrameDuration(value)
|
supported_frame_durations = SupportedFrameDuration(value)
|
||||||
elif type == CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT:
|
elif type == CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT:
|
||||||
supported_audio_channel_counts = bits_to_channel_counts(value)
|
supported_audio_channel_count = bits_to_channel_counts(value)
|
||||||
elif type == CodecSpecificCapabilities.Type.OCTETS_PER_FRAME:
|
elif type == CodecSpecificCapabilities.Type.OCTETS_PER_FRAME:
|
||||||
min_octets_per_sample = value & 0xFFFF
|
min_octets_per_sample = value & 0xFFFF
|
||||||
max_octets_per_sample = value >> 16
|
max_octets_per_sample = value >> 16
|
||||||
@@ -530,7 +578,7 @@ class CodecSpecificCapabilities:
|
|||||||
return CodecSpecificCapabilities(
|
return CodecSpecificCapabilities(
|
||||||
supported_sampling_frequencies=supported_sampling_frequencies,
|
supported_sampling_frequencies=supported_sampling_frequencies,
|
||||||
supported_frame_durations=supported_frame_durations,
|
supported_frame_durations=supported_frame_durations,
|
||||||
supported_audio_channel_counts=supported_audio_channel_counts,
|
supported_audio_channel_count=supported_audio_channel_count,
|
||||||
min_octets_per_codec_frame=min_octets_per_sample,
|
min_octets_per_codec_frame=min_octets_per_sample,
|
||||||
max_octets_per_codec_frame=max_octets_per_sample,
|
max_octets_per_codec_frame=max_octets_per_sample,
|
||||||
supported_max_codec_frames_per_sdu=supported_max_codec_frames_per_sdu,
|
supported_max_codec_frames_per_sdu=supported_max_codec_frames_per_sdu,
|
||||||
@@ -547,7 +595,7 @@ class CodecSpecificCapabilities:
|
|||||||
self.supported_frame_durations,
|
self.supported_frame_durations,
|
||||||
2,
|
2,
|
||||||
CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT,
|
CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT,
|
||||||
channel_counts_to_bits(self.supported_audio_channel_counts),
|
channel_counts_to_bits(self.supported_audio_channel_count),
|
||||||
5,
|
5,
|
||||||
CodecSpecificCapabilities.Type.OCTETS_PER_FRAME,
|
CodecSpecificCapabilities.Type.OCTETS_PER_FRAME,
|
||||||
self.min_octets_per_codec_frame,
|
self.min_octets_per_codec_frame,
|
||||||
@@ -833,15 +881,22 @@ class AseStateMachine(gatt.Characteristic):
|
|||||||
cig_id: int,
|
cig_id: int,
|
||||||
cis_id: int,
|
cis_id: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
if cis_id == self.cis_id and self.state == self.State.ENABLING:
|
if (
|
||||||
|
cig_id == self.cig_id
|
||||||
|
and cis_id == self.cis_id
|
||||||
|
and self.state == self.State.ENABLING
|
||||||
|
):
|
||||||
acl_connection.abort_on(
|
acl_connection.abort_on(
|
||||||
'flush', self.service.device.accept_cis_request(cis_handle)
|
'flush', self.service.device.accept_cis_request(cis_handle)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_cis_establishment(self, cis_link: device.CisLink) -> None:
|
def on_cis_establishment(self, cis_link: device.CisLink) -> None:
|
||||||
if cis_link.cis_id == self.cis_id and self.state == self.State.ENABLING:
|
if (
|
||||||
self.state = self.State.STREAMING
|
cis_link.cig_id == self.cig_id
|
||||||
self.cis_link = cis_link
|
and cis_link.cis_id == self.cis_id
|
||||||
|
and self.state == self.State.ENABLING
|
||||||
|
):
|
||||||
|
cis_link.on('disconnection', self.on_cis_disconnection)
|
||||||
|
|
||||||
async def post_cis_established():
|
async def post_cis_established():
|
||||||
await self.service.device.send_command(
|
await self.service.device.send_command(
|
||||||
@@ -854,9 +909,15 @@ class AseStateMachine(gatt.Characteristic):
|
|||||||
codec_configuration=b'',
|
codec_configuration=b'',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if self.role == AudioRole.SINK:
|
||||||
|
self.state = self.State.STREAMING
|
||||||
await self.service.device.notify_subscribers(self, self.value)
|
await self.service.device.notify_subscribers(self, self.value)
|
||||||
|
|
||||||
cis_link.acl_connection.abort_on('flush', post_cis_established())
|
cis_link.acl_connection.abort_on('flush', post_cis_established())
|
||||||
|
self.cis_link = cis_link
|
||||||
|
|
||||||
|
def on_cis_disconnection(self, _reason) -> None:
|
||||||
|
self.cis_link = None
|
||||||
|
|
||||||
def on_config_codec(
|
def on_config_codec(
|
||||||
self,
|
self,
|
||||||
@@ -954,11 +1015,17 @@ class AseStateMachine(gatt.Characteristic):
|
|||||||
AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
|
AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
|
||||||
AseReasonCode.NONE,
|
AseReasonCode.NONE,
|
||||||
)
|
)
|
||||||
self.state = self.State.DISABLING
|
if self.role == AudioRole.SINK:
|
||||||
|
self.state = self.State.QOS_CONFIGURED
|
||||||
|
else:
|
||||||
|
self.state = self.State.DISABLING
|
||||||
return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
|
return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
|
||||||
|
|
||||||
def on_receiver_stop_ready(self) -> Tuple[AseResponseCode, AseReasonCode]:
|
def on_receiver_stop_ready(self) -> Tuple[AseResponseCode, AseReasonCode]:
|
||||||
if self.state != AseStateMachine.State.DISABLING:
|
if (
|
||||||
|
self.role != AudioRole.SOURCE
|
||||||
|
or self.state != AseStateMachine.State.DISABLING
|
||||||
|
):
|
||||||
return (
|
return (
|
||||||
AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
|
AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
|
||||||
AseReasonCode.NONE,
|
AseReasonCode.NONE,
|
||||||
@@ -1009,6 +1076,7 @@ class AseStateMachine(gatt.Characteristic):
|
|||||||
def state(self, new_state: State) -> None:
|
def state(self, new_state: State) -> None:
|
||||||
logger.debug(f'{self} state change -> {colors.color(new_state.name, "cyan")}')
|
logger.debug(f'{self} state change -> {colors.color(new_state.name, "cyan")}')
|
||||||
self._state = new_state
|
self._state = new_state
|
||||||
|
self.emit('state_change')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def value(self):
|
def value(self):
|
||||||
@@ -1081,6 +1149,7 @@ class AudioStreamControlService(gatt.TemplateService):
|
|||||||
|
|
||||||
ase_state_machines: Dict[int, AseStateMachine]
|
ase_state_machines: Dict[int, AseStateMachine]
|
||||||
ase_control_point: gatt.Characteristic
|
ase_control_point: gatt.Characteristic
|
||||||
|
_active_client: Optional[device.Connection] = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -1118,7 +1187,16 @@ class AudioStreamControlService(gatt.TemplateService):
|
|||||||
else:
|
else:
|
||||||
return (ase_id, AseResponseCode.INVALID_ASE_ID, AseReasonCode.NONE)
|
return (ase_id, AseResponseCode.INVALID_ASE_ID, AseReasonCode.NONE)
|
||||||
|
|
||||||
|
def _on_client_disconnected(self, _reason: int) -> None:
|
||||||
|
for ase in self.ase_state_machines.values():
|
||||||
|
ase.state = AseStateMachine.State.IDLE
|
||||||
|
self._active_client = None
|
||||||
|
|
||||||
def on_write_ase_control_point(self, connection, data):
|
def on_write_ase_control_point(self, connection, data):
|
||||||
|
if not self._active_client and connection:
|
||||||
|
self._active_client = connection
|
||||||
|
connection.once('disconnection', self._on_client_disconnected)
|
||||||
|
|
||||||
operation = ASE_Operation.from_bytes(data)
|
operation = ASE_Operation.from_bytes(data)
|
||||||
responses = []
|
responses = []
|
||||||
logger.debug(f'*** ASCS Write {operation} ***')
|
logger.debug(f'*** ASCS Write {operation} ***')
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
import struct
|
import struct
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from ..gatt_client import ProfileServiceProxy
|
from bumble.gatt_client import ServiceProxy, ProfileServiceProxy, CharacteristicProxy
|
||||||
from ..gatt import (
|
from bumble.gatt import (
|
||||||
GATT_DEVICE_INFORMATION_SERVICE,
|
GATT_DEVICE_INFORMATION_SERVICE,
|
||||||
GATT_FIRMWARE_REVISION_STRING_CHARACTERISTIC,
|
GATT_FIRMWARE_REVISION_STRING_CHARACTERISTIC,
|
||||||
GATT_HARDWARE_REVISION_STRING_CHARACTERISTIC,
|
GATT_HARDWARE_REVISION_STRING_CHARACTERISTIC,
|
||||||
@@ -59,7 +59,7 @@ class DeviceInformationService(TemplateService):
|
|||||||
firmware_revision: Optional[str] = None,
|
firmware_revision: Optional[str] = None,
|
||||||
software_revision: Optional[str] = None,
|
software_revision: Optional[str] = None,
|
||||||
system_id: Optional[Tuple[int, int]] = None, # (OUI, Manufacturer ID)
|
system_id: Optional[Tuple[int, int]] = None, # (OUI, Manufacturer ID)
|
||||||
ieee_regulatory_certification_data_list: Optional[bytes] = None
|
ieee_regulatory_certification_data_list: Optional[bytes] = None,
|
||||||
# TODO: pnp_id
|
# TODO: pnp_id
|
||||||
):
|
):
|
||||||
characteristics = [
|
characteristics = [
|
||||||
@@ -104,10 +104,19 @@ class DeviceInformationService(TemplateService):
|
|||||||
class DeviceInformationServiceProxy(ProfileServiceProxy):
|
class DeviceInformationServiceProxy(ProfileServiceProxy):
|
||||||
SERVICE_CLASS = DeviceInformationService
|
SERVICE_CLASS = DeviceInformationService
|
||||||
|
|
||||||
def __init__(self, service_proxy):
|
manufacturer_name: Optional[UTF8CharacteristicAdapter]
|
||||||
|
model_number: Optional[UTF8CharacteristicAdapter]
|
||||||
|
serial_number: Optional[UTF8CharacteristicAdapter]
|
||||||
|
hardware_revision: Optional[UTF8CharacteristicAdapter]
|
||||||
|
firmware_revision: Optional[UTF8CharacteristicAdapter]
|
||||||
|
software_revision: Optional[UTF8CharacteristicAdapter]
|
||||||
|
system_id: Optional[DelegatedCharacteristicAdapter]
|
||||||
|
ieee_regulatory_certification_data_list: Optional[CharacteristicProxy]
|
||||||
|
|
||||||
|
def __init__(self, service_proxy: ServiceProxy):
|
||||||
self.service_proxy = service_proxy
|
self.service_proxy = service_proxy
|
||||||
|
|
||||||
for (field, uuid) in (
|
for field, uuid in (
|
||||||
('manufacturer_name', GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC),
|
('manufacturer_name', GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC),
|
||||||
('model_number', GATT_MODEL_NUMBER_STRING_CHARACTERISTIC),
|
('model_number', GATT_MODEL_NUMBER_STRING_CHARACTERISTIC),
|
||||||
('serial_number', GATT_SERIAL_NUMBER_STRING_CHARACTERISTIC),
|
('serial_number', GATT_SERIAL_NUMBER_STRING_CHARACTERISTIC),
|
||||||
|
|||||||
+173
-60
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import collections
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
||||||
@@ -54,6 +55,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# fmt: off
|
# fmt: off
|
||||||
|
|
||||||
RFCOMM_PSM = 0x0003
|
RFCOMM_PSM = 0x0003
|
||||||
|
DEFAULT_RX_QUEUE_SIZE = 32
|
||||||
|
|
||||||
class FrameType(enum.IntEnum):
|
class FrameType(enum.IntEnum):
|
||||||
SABM = 0x2F # Control field [1,1,1,1,_,1,0,0] LSB-first
|
SABM = 0x2F # Control field [1,1,1,1,_,1,0,0] LSB-first
|
||||||
@@ -104,9 +106,11 @@ CRC_TABLE = bytes([
|
|||||||
0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF
|
0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF
|
||||||
])
|
])
|
||||||
|
|
||||||
RFCOMM_DEFAULT_L2CAP_MTU = 2048
|
RFCOMM_DEFAULT_L2CAP_MTU = 2048
|
||||||
RFCOMM_DEFAULT_WINDOW_SIZE = 7
|
RFCOMM_DEFAULT_INITIAL_CREDITS = 7
|
||||||
RFCOMM_DEFAULT_MAX_FRAME_SIZE = 2000
|
RFCOMM_DEFAULT_MAX_CREDITS = 32
|
||||||
|
RFCOMM_DEFAULT_CREDIT_THRESHOLD = RFCOMM_DEFAULT_MAX_CREDITS // 2
|
||||||
|
RFCOMM_DEFAULT_MAX_FRAME_SIZE = 2000
|
||||||
|
|
||||||
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1
|
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1
|
||||||
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
||||||
@@ -363,12 +367,12 @@ class RFCOMM_MCC_PN:
|
|||||||
ack_timer: int
|
ack_timer: int
|
||||||
max_frame_size: int
|
max_frame_size: int
|
||||||
max_retransmissions: int
|
max_retransmissions: int
|
||||||
window_size: int
|
initial_credits: int
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.window_size < 1 or self.window_size > 7:
|
if self.initial_credits < 1 or self.initial_credits > 7:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f'Error Recovery Window size {self.window_size} is out of range [1, 7].'
|
f'Initial credits {self.initial_credits} is out of range [1, 7].'
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -380,7 +384,7 @@ class RFCOMM_MCC_PN:
|
|||||||
ack_timer=data[3],
|
ack_timer=data[3],
|
||||||
max_frame_size=data[4] | data[5] << 8,
|
max_frame_size=data[4] | data[5] << 8,
|
||||||
max_retransmissions=data[6],
|
max_retransmissions=data[6],
|
||||||
window_size=data[7] & 0x07,
|
initial_credits=data[7] & 0x07,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __bytes__(self) -> bytes:
|
def __bytes__(self) -> bytes:
|
||||||
@@ -394,7 +398,7 @@ class RFCOMM_MCC_PN:
|
|||||||
(self.max_frame_size >> 8) & 0xFF,
|
(self.max_frame_size >> 8) & 0xFF,
|
||||||
self.max_retransmissions & 0xFF,
|
self.max_retransmissions & 0xFF,
|
||||||
# Only 3 bits are meaningful.
|
# Only 3 bits are meaningful.
|
||||||
self.window_size & 0x07,
|
self.initial_credits & 0x07,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -444,39 +448,58 @@ class DLC(EventEmitter):
|
|||||||
DISCONNECTED = 0x04
|
DISCONNECTED = 0x04
|
||||||
RESET = 0x05
|
RESET = 0x05
|
||||||
|
|
||||||
connection_result: Optional[asyncio.Future]
|
|
||||||
sink: Optional[Callable[[bytes], None]]
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
multiplexer: Multiplexer,
|
multiplexer: Multiplexer,
|
||||||
dlci: int,
|
dlci: int,
|
||||||
max_frame_size: int,
|
tx_max_frame_size: int,
|
||||||
window_size: int,
|
tx_initial_credits: int,
|
||||||
|
rx_max_frame_size: int,
|
||||||
|
rx_initial_credits: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.multiplexer = multiplexer
|
self.multiplexer = multiplexer
|
||||||
self.dlci = dlci
|
self.dlci = dlci
|
||||||
self.max_frame_size = max_frame_size
|
self.rx_max_frame_size = rx_max_frame_size
|
||||||
self.window_size = window_size
|
self.rx_initial_credits = rx_initial_credits
|
||||||
self.rx_credits = window_size
|
self.rx_max_credits = RFCOMM_DEFAULT_MAX_CREDITS
|
||||||
self.rx_threshold = window_size // 2
|
self.rx_credits = rx_initial_credits
|
||||||
self.tx_credits = window_size
|
self.rx_credits_threshold = RFCOMM_DEFAULT_CREDIT_THRESHOLD
|
||||||
|
self.tx_max_frame_size = tx_max_frame_size
|
||||||
|
self.tx_credits = tx_initial_credits
|
||||||
self.tx_buffer = b''
|
self.tx_buffer = b''
|
||||||
self.state = DLC.State.INIT
|
self.state = DLC.State.INIT
|
||||||
self.role = multiplexer.role
|
self.role = multiplexer.role
|
||||||
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
|
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
|
||||||
self.sink = None
|
self.connection_result: Optional[asyncio.Future] = None
|
||||||
self.connection_result = None
|
self.disconnection_result: Optional[asyncio.Future] = None
|
||||||
self.drained = asyncio.Event()
|
self.drained = asyncio.Event()
|
||||||
self.drained.set()
|
self.drained.set()
|
||||||
|
# Queued packets when sink is not set.
|
||||||
|
self._enqueued_rx_packets: collections.deque[bytes] = collections.deque(
|
||||||
|
maxlen=DEFAULT_RX_QUEUE_SIZE
|
||||||
|
)
|
||||||
|
self._sink: Optional[Callable[[bytes], None]] = None
|
||||||
|
|
||||||
# Compute the MTU
|
# Compute the MTU
|
||||||
max_overhead = 4 + 1 # header with 2-byte length + fcs
|
max_overhead = 4 + 1 # header with 2-byte length + fcs
|
||||||
self.mtu = min(
|
self.mtu = min(
|
||||||
max_frame_size, self.multiplexer.l2cap_channel.peer_mtu - max_overhead
|
tx_max_frame_size, self.multiplexer.l2cap_channel.peer_mtu - max_overhead
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sink(self) -> Optional[Callable[[bytes], None]]:
|
||||||
|
return self._sink
|
||||||
|
|
||||||
|
@sink.setter
|
||||||
|
def sink(self, sink: Optional[Callable[[bytes], None]]) -> None:
|
||||||
|
self._sink = sink
|
||||||
|
# Dump queued packets to sink
|
||||||
|
if sink:
|
||||||
|
for packet in self._enqueued_rx_packets:
|
||||||
|
sink(packet) # pylint: disable=not-callable
|
||||||
|
self._enqueued_rx_packets.clear()
|
||||||
|
|
||||||
def change_state(self, new_state: State) -> None:
|
def change_state(self, new_state: State) -> None:
|
||||||
logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}')
|
logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}')
|
||||||
self.state = new_state
|
self.state = new_state
|
||||||
@@ -507,20 +530,35 @@ class DLC(EventEmitter):
|
|||||||
self.emit('open')
|
self.emit('open')
|
||||||
|
|
||||||
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
|
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
if self.state != DLC.State.CONNECTING:
|
if self.state == DLC.State.CONNECTING:
|
||||||
|
# Exchange the modem status with the peer
|
||||||
|
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
|
||||||
|
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
|
||||||
|
logger.debug(f'>>> MCC MSC Command: {msc}')
|
||||||
|
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||||
|
|
||||||
|
self.change_state(DLC.State.CONNECTED)
|
||||||
|
if self.connection_result:
|
||||||
|
self.connection_result.set_result(None)
|
||||||
|
self.connection_result = None
|
||||||
|
self.multiplexer.on_dlc_open_complete(self)
|
||||||
|
elif self.state == DLC.State.DISCONNECTING:
|
||||||
|
self.change_state(DLC.State.DISCONNECTED)
|
||||||
|
if self.disconnection_result:
|
||||||
|
self.disconnection_result.set_result(None)
|
||||||
|
self.disconnection_result = None
|
||||||
|
self.multiplexer.on_dlc_disconnection(self)
|
||||||
|
self.emit('close')
|
||||||
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
color('!!! received SABM when not in CONNECTING state', 'red')
|
color(
|
||||||
|
(
|
||||||
|
'!!! received UA frame when not in '
|
||||||
|
'CONNECTING or DISCONNECTING state'
|
||||||
|
),
|
||||||
|
'red',
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
# Exchange the modem status with the peer
|
|
||||||
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
|
|
||||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
|
|
||||||
logger.debug(f'>>> MCC MSC Command: {msc}')
|
|
||||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
|
||||||
|
|
||||||
self.change_state(DLC.State.CONNECTED)
|
|
||||||
self.multiplexer.on_dlc_open_complete(self)
|
|
||||||
|
|
||||||
def on_dm_frame(self, frame: RFCOMM_Frame) -> None:
|
def on_dm_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
# TODO: handle all states
|
# TODO: handle all states
|
||||||
@@ -549,8 +587,15 @@ class DLC(EventEmitter):
|
|||||||
f'rx_credits={self.rx_credits}: {data.hex()}'
|
f'rx_credits={self.rx_credits}: {data.hex()}'
|
||||||
)
|
)
|
||||||
if data:
|
if data:
|
||||||
if self.sink:
|
if self._sink:
|
||||||
self.sink(data) # pylint: disable=not-callable
|
self._sink(data) # pylint: disable=not-callable
|
||||||
|
else:
|
||||||
|
self._enqueued_rx_packets.append(data)
|
||||||
|
if (
|
||||||
|
self._enqueued_rx_packets.maxlen
|
||||||
|
and len(self._enqueued_rx_packets) >= self._enqueued_rx_packets.maxlen
|
||||||
|
):
|
||||||
|
logger.warning(f'DLC [{self.dlci}] received packet queue is full')
|
||||||
|
|
||||||
# Update the credits
|
# Update the credits
|
||||||
if self.rx_credits > 0:
|
if self.rx_credits > 0:
|
||||||
@@ -584,6 +629,19 @@ class DLC(EventEmitter):
|
|||||||
self.connection_result = asyncio.get_running_loop().create_future()
|
self.connection_result = asyncio.get_running_loop().create_future()
|
||||||
self.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci))
|
self.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci))
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
if self.state != DLC.State.CONNECTED:
|
||||||
|
raise InvalidStateError('invalid state')
|
||||||
|
|
||||||
|
self.disconnection_result = asyncio.get_running_loop().create_future()
|
||||||
|
self.change_state(DLC.State.DISCONNECTING)
|
||||||
|
self.send_frame(
|
||||||
|
RFCOMM_Frame.disc(
|
||||||
|
c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0, dlci=self.dlci
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.disconnection_result
|
||||||
|
|
||||||
def accept(self) -> None:
|
def accept(self) -> None:
|
||||||
if self.state != DLC.State.INIT:
|
if self.state != DLC.State.INIT:
|
||||||
raise InvalidStateError('invalid state')
|
raise InvalidStateError('invalid state')
|
||||||
@@ -593,9 +651,9 @@ class DLC(EventEmitter):
|
|||||||
cl=0xE0,
|
cl=0xE0,
|
||||||
priority=7,
|
priority=7,
|
||||||
ack_timer=0,
|
ack_timer=0,
|
||||||
max_frame_size=self.max_frame_size,
|
max_frame_size=self.rx_max_frame_size,
|
||||||
max_retransmissions=0,
|
max_retransmissions=0,
|
||||||
window_size=self.window_size,
|
initial_credits=self.rx_initial_credits,
|
||||||
)
|
)
|
||||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=0, data=bytes(pn))
|
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=0, data=bytes(pn))
|
||||||
logger.debug(f'>>> PN Response: {pn}')
|
logger.debug(f'>>> PN Response: {pn}')
|
||||||
@@ -603,8 +661,8 @@ class DLC(EventEmitter):
|
|||||||
self.change_state(DLC.State.CONNECTING)
|
self.change_state(DLC.State.CONNECTING)
|
||||||
|
|
||||||
def rx_credits_needed(self) -> int:
|
def rx_credits_needed(self) -> int:
|
||||||
if self.rx_credits <= self.rx_threshold:
|
if self.rx_credits <= self.rx_credits_threshold:
|
||||||
return self.window_size - self.rx_credits
|
return self.rx_max_credits - self.rx_credits
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -664,6 +722,17 @@ class DLC(EventEmitter):
|
|||||||
async def drain(self) -> None:
|
async def drain(self) -> None:
|
||||||
await self.drained.wait()
|
await self.drained.wait()
|
||||||
|
|
||||||
|
def abort(self) -> None:
|
||||||
|
logger.debug(f'aborting DLC: {self}')
|
||||||
|
if self.connection_result:
|
||||||
|
self.connection_result.cancel()
|
||||||
|
self.connection_result = None
|
||||||
|
if self.disconnection_result:
|
||||||
|
self.disconnection_result.cancel()
|
||||||
|
self.disconnection_result = None
|
||||||
|
self.change_state(DLC.State.RESET)
|
||||||
|
self.emit('close')
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f'DLC(dlci={self.dlci},state={self.state.name})'
|
return f'DLC(dlci={self.dlci},state={self.state.name})'
|
||||||
|
|
||||||
@@ -686,7 +755,7 @@ class Multiplexer(EventEmitter):
|
|||||||
connection_result: Optional[asyncio.Future]
|
connection_result: Optional[asyncio.Future]
|
||||||
disconnection_result: Optional[asyncio.Future]
|
disconnection_result: Optional[asyncio.Future]
|
||||||
open_result: Optional[asyncio.Future]
|
open_result: Optional[asyncio.Future]
|
||||||
acceptor: Optional[Callable[[int], bool]]
|
acceptor: Optional[Callable[[int], Optional[Tuple[int, int]]]]
|
||||||
dlcs: Dict[int, DLC]
|
dlcs: Dict[int, DLC]
|
||||||
|
|
||||||
def __init__(self, l2cap_channel: l2cap.ClassicChannel, role: Role) -> None:
|
def __init__(self, l2cap_channel: l2cap.ClassicChannel, role: Role) -> None:
|
||||||
@@ -698,11 +767,15 @@ class Multiplexer(EventEmitter):
|
|||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
self.disconnection_result = None
|
self.disconnection_result = None
|
||||||
self.open_result = None
|
self.open_result = None
|
||||||
|
self.open_pn: Optional[RFCOMM_MCC_PN] = None
|
||||||
|
self.open_rx_max_credits = 0
|
||||||
self.acceptor = None
|
self.acceptor = None
|
||||||
|
|
||||||
# Become a sink for the L2CAP channel
|
# Become a sink for the L2CAP channel
|
||||||
l2cap_channel.sink = self.on_pdu
|
l2cap_channel.sink = self.on_pdu
|
||||||
|
|
||||||
|
l2cap_channel.on('close', self.on_l2cap_channel_close)
|
||||||
|
|
||||||
def change_state(self, new_state: State) -> None:
|
def change_state(self, new_state: State) -> None:
|
||||||
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
|
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
|
||||||
self.state = new_state
|
self.state = new_state
|
||||||
@@ -766,6 +839,7 @@ class Multiplexer(EventEmitter):
|
|||||||
'rfcomm',
|
'rfcomm',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self.open_result = None
|
||||||
else:
|
else:
|
||||||
logger.warning(f'unexpected state for DM: {self}')
|
logger.warning(f'unexpected state for DM: {self}')
|
||||||
|
|
||||||
@@ -803,9 +877,16 @@ class Multiplexer(EventEmitter):
|
|||||||
else:
|
else:
|
||||||
if self.acceptor:
|
if self.acceptor:
|
||||||
channel_number = pn.dlci >> 1
|
channel_number = pn.dlci >> 1
|
||||||
if self.acceptor(channel_number):
|
if dlc_params := self.acceptor(channel_number):
|
||||||
# Create a new DLC
|
# Create a new DLC
|
||||||
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size)
|
dlc = DLC(
|
||||||
|
self,
|
||||||
|
dlci=pn.dlci,
|
||||||
|
tx_max_frame_size=pn.max_frame_size,
|
||||||
|
tx_initial_credits=pn.initial_credits,
|
||||||
|
rx_max_frame_size=dlc_params[0],
|
||||||
|
rx_initial_credits=dlc_params[1],
|
||||||
|
)
|
||||||
self.dlcs[pn.dlci] = dlc
|
self.dlcs[pn.dlci] = dlc
|
||||||
|
|
||||||
# Re-emit the handshake completion event
|
# Re-emit the handshake completion event
|
||||||
@@ -823,8 +904,17 @@ class Multiplexer(EventEmitter):
|
|||||||
# Response
|
# Response
|
||||||
logger.debug(f'>>> PN Response: {pn}')
|
logger.debug(f'>>> PN Response: {pn}')
|
||||||
if self.state == Multiplexer.State.OPENING:
|
if self.state == Multiplexer.State.OPENING:
|
||||||
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size)
|
assert self.open_pn
|
||||||
|
dlc = DLC(
|
||||||
|
self,
|
||||||
|
dlci=pn.dlci,
|
||||||
|
tx_max_frame_size=pn.max_frame_size,
|
||||||
|
tx_initial_credits=pn.initial_credits,
|
||||||
|
rx_max_frame_size=self.open_pn.max_frame_size,
|
||||||
|
rx_initial_credits=self.open_pn.initial_credits,
|
||||||
|
)
|
||||||
self.dlcs[pn.dlci] = dlc
|
self.dlcs[pn.dlci] = dlc
|
||||||
|
self.open_pn = None
|
||||||
dlc.connect()
|
dlc.connect()
|
||||||
else:
|
else:
|
||||||
logger.warning('ignoring PN response')
|
logger.warning('ignoring PN response')
|
||||||
@@ -862,7 +952,7 @@ class Multiplexer(EventEmitter):
|
|||||||
self,
|
self,
|
||||||
channel: int,
|
channel: int,
|
||||||
max_frame_size: int = RFCOMM_DEFAULT_MAX_FRAME_SIZE,
|
max_frame_size: int = RFCOMM_DEFAULT_MAX_FRAME_SIZE,
|
||||||
window_size: int = RFCOMM_DEFAULT_WINDOW_SIZE,
|
initial_credits: int = RFCOMM_DEFAULT_INITIAL_CREDITS,
|
||||||
) -> DLC:
|
) -> DLC:
|
||||||
if self.state != Multiplexer.State.CONNECTED:
|
if self.state != Multiplexer.State.CONNECTED:
|
||||||
if self.state == Multiplexer.State.OPENING:
|
if self.state == Multiplexer.State.OPENING:
|
||||||
@@ -870,17 +960,19 @@ class Multiplexer(EventEmitter):
|
|||||||
|
|
||||||
raise InvalidStateError('not connected')
|
raise InvalidStateError('not connected')
|
||||||
|
|
||||||
pn = RFCOMM_MCC_PN(
|
self.open_pn = RFCOMM_MCC_PN(
|
||||||
dlci=channel << 1,
|
dlci=channel << 1,
|
||||||
cl=0xF0,
|
cl=0xF0,
|
||||||
priority=7,
|
priority=7,
|
||||||
ack_timer=0,
|
ack_timer=0,
|
||||||
max_frame_size=max_frame_size,
|
max_frame_size=max_frame_size,
|
||||||
max_retransmissions=0,
|
max_retransmissions=0,
|
||||||
window_size=window_size,
|
initial_credits=initial_credits,
|
||||||
)
|
)
|
||||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=1, data=bytes(pn))
|
mcc = RFCOMM_Frame.make_mcc(
|
||||||
logger.debug(f'>>> Sending MCC: {pn}')
|
mcc_type=MccType.PN, c_r=1, data=bytes(self.open_pn)
|
||||||
|
)
|
||||||
|
logger.debug(f'>>> Sending MCC: {self.open_pn}')
|
||||||
self.open_result = asyncio.get_running_loop().create_future()
|
self.open_result = asyncio.get_running_loop().create_future()
|
||||||
self.change_state(Multiplexer.State.OPENING)
|
self.change_state(Multiplexer.State.OPENING)
|
||||||
self.send_frame(
|
self.send_frame(
|
||||||
@@ -890,15 +982,31 @@ class Multiplexer(EventEmitter):
|
|||||||
information=mcc,
|
information=mcc,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = await self.open_result
|
return await self.open_result
|
||||||
self.open_result = None
|
|
||||||
return result
|
|
||||||
|
|
||||||
def on_dlc_open_complete(self, dlc: DLC) -> None:
|
def on_dlc_open_complete(self, dlc: DLC) -> None:
|
||||||
logger.debug(f'DLC [{dlc.dlci}] open complete')
|
logger.debug(f'DLC [{dlc.dlci}] open complete')
|
||||||
|
|
||||||
self.change_state(Multiplexer.State.CONNECTED)
|
self.change_state(Multiplexer.State.CONNECTED)
|
||||||
|
|
||||||
if self.open_result:
|
if self.open_result:
|
||||||
self.open_result.set_result(dlc)
|
self.open_result.set_result(dlc)
|
||||||
|
self.open_result = None
|
||||||
|
|
||||||
|
def on_dlc_disconnection(self, dlc: DLC) -> None:
|
||||||
|
logger.debug(f'DLC [{dlc.dlci}] disconnection')
|
||||||
|
self.dlcs.pop(dlc.dlci, None)
|
||||||
|
|
||||||
|
def on_l2cap_channel_close(self) -> None:
|
||||||
|
logger.debug('L2CAP channel closed, cleaning up')
|
||||||
|
if self.open_result:
|
||||||
|
self.open_result.cancel()
|
||||||
|
self.open_result = None
|
||||||
|
if self.disconnection_result:
|
||||||
|
self.disconnection_result.cancel()
|
||||||
|
self.disconnection_result = None
|
||||||
|
for dlc in self.dlcs.values():
|
||||||
|
dlc.abort()
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f'Multiplexer(state={self.state.name})'
|
return f'Multiplexer(state={self.state.name})'
|
||||||
@@ -957,15 +1065,13 @@ class Client:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Server(EventEmitter):
|
class Server(EventEmitter):
|
||||||
acceptors: Dict[int, Callable[[DLC], None]]
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, device: Device, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
|
self, device: Device, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.device = device
|
self.device = device
|
||||||
self.multiplexer = None
|
self.acceptors: Dict[int, Callable[[DLC], None]] = {}
|
||||||
self.acceptors = {}
|
self.dlc_configs: Dict[int, Tuple[int, int]] = {}
|
||||||
|
|
||||||
# Register ourselves with the L2CAP channel manager
|
# Register ourselves with the L2CAP channel manager
|
||||||
self.l2cap_server = device.create_l2cap_server(
|
self.l2cap_server = device.create_l2cap_server(
|
||||||
@@ -973,7 +1079,13 @@ class Server(EventEmitter):
|
|||||||
handler=self.on_connection,
|
handler=self.on_connection,
|
||||||
)
|
)
|
||||||
|
|
||||||
def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int:
|
def listen(
|
||||||
|
self,
|
||||||
|
acceptor: Callable[[DLC], None],
|
||||||
|
channel: int = 0,
|
||||||
|
max_frame_size: int = RFCOMM_DEFAULT_MAX_FRAME_SIZE,
|
||||||
|
initial_credits: int = RFCOMM_DEFAULT_INITIAL_CREDITS,
|
||||||
|
) -> int:
|
||||||
if channel:
|
if channel:
|
||||||
if channel in self.acceptors:
|
if channel in self.acceptors:
|
||||||
# Busy
|
# Busy
|
||||||
@@ -993,6 +1105,8 @@ class Server(EventEmitter):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
self.acceptors[channel] = acceptor
|
self.acceptors[channel] = acceptor
|
||||||
|
self.dlc_configs[channel] = (max_frame_size, initial_credits)
|
||||||
|
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
|
def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
|
||||||
@@ -1010,15 +1124,14 @@ class Server(EventEmitter):
|
|||||||
# Notify
|
# Notify
|
||||||
self.emit('start', multiplexer)
|
self.emit('start', multiplexer)
|
||||||
|
|
||||||
def accept_dlc(self, channel_number: int) -> bool:
|
def accept_dlc(self, channel_number: int) -> Optional[Tuple[int, int]]:
|
||||||
return channel_number in self.acceptors
|
return self.dlc_configs.get(channel_number)
|
||||||
|
|
||||||
def on_dlc(self, dlc: DLC) -> None:
|
def on_dlc(self, dlc: DLC) -> None:
|
||||||
logger.debug(f'@@@ new DLC connected: {dlc}')
|
logger.debug(f'@@@ new DLC connected: {dlc}')
|
||||||
|
|
||||||
# Let the acceptor know
|
# Let the acceptor know
|
||||||
acceptor = self.acceptors.get(dlc.dlci >> 1)
|
if acceptor := self.acceptors.get(dlc.dlci >> 1):
|
||||||
if acceptor:
|
|
||||||
acceptor(dlc)
|
acceptor(dlc)
|
||||||
|
|
||||||
def __enter__(self) -> Self:
|
def __enter__(self) -> Self:
|
||||||
|
|||||||
+13
-9
@@ -825,11 +825,13 @@ class Client:
|
|||||||
)
|
)
|
||||||
attribute_id_list = DataElement.sequence(
|
attribute_id_list = DataElement.sequence(
|
||||||
[
|
[
|
||||||
DataElement.unsigned_integer(
|
(
|
||||||
attribute_id[0], value_size=attribute_id[1]
|
DataElement.unsigned_integer(
|
||||||
|
attribute_id[0], value_size=attribute_id[1]
|
||||||
|
)
|
||||||
|
if isinstance(attribute_id, tuple)
|
||||||
|
else DataElement.unsigned_integer_16(attribute_id)
|
||||||
)
|
)
|
||||||
if isinstance(attribute_id, tuple)
|
|
||||||
else DataElement.unsigned_integer_16(attribute_id)
|
|
||||||
for attribute_id in attribute_ids
|
for attribute_id in attribute_ids
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -881,11 +883,13 @@ class Client:
|
|||||||
|
|
||||||
attribute_id_list = DataElement.sequence(
|
attribute_id_list = DataElement.sequence(
|
||||||
[
|
[
|
||||||
DataElement.unsigned_integer(
|
(
|
||||||
attribute_id[0], value_size=attribute_id[1]
|
DataElement.unsigned_integer(
|
||||||
|
attribute_id[0], value_size=attribute_id[1]
|
||||||
|
)
|
||||||
|
if isinstance(attribute_id, tuple)
|
||||||
|
else DataElement.unsigned_integer_16(attribute_id)
|
||||||
)
|
)
|
||||||
if isinstance(attribute_id, tuple)
|
|
||||||
else DataElement.unsigned_integer_16(attribute_id)
|
|
||||||
for attribute_id in attribute_ids
|
for attribute_id in attribute_ids
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -993,7 +997,7 @@ class Server:
|
|||||||
try:
|
try:
|
||||||
handler(sdp_pdu)
|
handler(sdp_pdu)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(f'{color("!!! Exception in handler:", "red")} {error}')
|
logger.exception(f'{color("!!! Exception in handler:", "red")} {error}')
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=sdp_pdu.transaction_id,
|
transaction_id=sdp_pdu.transaction_id,
|
||||||
|
|||||||
+3
-3
@@ -737,9 +737,9 @@ class Session:
|
|||||||
|
|
||||||
# Create a future that can be used to wait for the session to complete
|
# Create a future that can be used to wait for the session to complete
|
||||||
if self.is_initiator:
|
if self.is_initiator:
|
||||||
self.pairing_result: Optional[
|
self.pairing_result: Optional[asyncio.Future[None]] = (
|
||||||
asyncio.Future[None]
|
asyncio.get_running_loop().create_future()
|
||||||
] = asyncio.get_running_loop().create_future()
|
)
|
||||||
else:
|
else:
|
||||||
self.pairing_result = None
|
self.pairing_result = None
|
||||||
|
|
||||||
|
|||||||
@@ -59,15 +59,13 @@ class TransportLostError(Exception):
|
|||||||
# Typing Protocols
|
# Typing Protocols
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class TransportSink(Protocol):
|
class TransportSink(Protocol):
|
||||||
def on_packet(self, packet: bytes) -> None:
|
def on_packet(self, packet: bytes) -> None: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class TransportSource(Protocol):
|
class TransportSource(Protocol):
|
||||||
terminated: asyncio.Future[None]
|
terminated: asyncio.Future[None]
|
||||||
|
|
||||||
def set_packet_sink(self, sink: TransportSink) -> None:
|
def set_packet_sink(self, sink: TransportSink) -> None: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -168,11 +166,13 @@ class PacketReader:
|
|||||||
|
|
||||||
def __init__(self, source: io.BufferedReader) -> None:
|
def __init__(self, source: io.BufferedReader) -> None:
|
||||||
self.source = source
|
self.source = source
|
||||||
|
self.at_end = False
|
||||||
|
|
||||||
def next_packet(self) -> Optional[bytes]:
|
def next_packet(self) -> Optional[bytes]:
|
||||||
# Get the packet type
|
# Get the packet type
|
||||||
packet_type = self.source.read(1)
|
packet_type = self.source.read(1)
|
||||||
if len(packet_type) != 1:
|
if len(packet_type) != 1:
|
||||||
|
self.at_end = True
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get the packet info based on its type
|
# Get the packet info based on its type
|
||||||
@@ -425,6 +425,10 @@ class SnoopingTransport(Transport):
|
|||||||
class Source:
|
class Source:
|
||||||
sink: TransportSink
|
sink: TransportSink
|
||||||
|
|
||||||
|
@property
|
||||||
|
def metadata(self) -> dict[str, Any]:
|
||||||
|
return getattr(self.source, 'metadata', {})
|
||||||
|
|
||||||
def __init__(self, source: TransportSource, snooper: Snooper):
|
def __init__(self, source: TransportSource, snooper: Snooper):
|
||||||
self.source = source
|
self.source = source
|
||||||
self.snooper = snooper
|
self.snooper = snooper
|
||||||
|
|||||||
+110
-2
@@ -23,11 +23,24 @@ import time
|
|||||||
import usb.core
|
import usb.core
|
||||||
import usb.util
|
import usb.util
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from usb.core import Device as UsbDevice
|
||||||
|
from usb.core import USBError
|
||||||
|
from usb.util import CTRL_TYPE_CLASS, CTRL_RECIPIENT_OTHER
|
||||||
|
from usb.legacy import REQ_SET_FEATURE, REQ_CLEAR_FEATURE, CLASS_HUB
|
||||||
|
|
||||||
from .common import Transport, ParserSource
|
from .common import Transport, ParserSource
|
||||||
from .. import hci
|
from .. import hci
|
||||||
from ..colors import color
|
from ..colors import color
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constant
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
USB_PORT_FEATURE_POWER = 8
|
||||||
|
POWER_CYCLE_DELAY = 1
|
||||||
|
RESET_DELAY = 3
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -113,9 +126,10 @@ async def open_pyusb_transport(spec: str) -> Transport:
|
|||||||
self.loop.call_soon_threadsafe(self.stop_event.set)
|
self.loop.call_soon_threadsafe(self.stop_event.set)
|
||||||
|
|
||||||
class UsbPacketSource(asyncio.Protocol, ParserSource):
|
class UsbPacketSource(asyncio.Protocol, ParserSource):
|
||||||
def __init__(self, device, sco_enabled):
|
def __init__(self, device, metadata, sco_enabled):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.device = device
|
self.device = device
|
||||||
|
self.metadata = metadata
|
||||||
self.loop = asyncio.get_running_loop()
|
self.loop = asyncio.get_running_loop()
|
||||||
self.queue = asyncio.Queue()
|
self.queue = asyncio.Queue()
|
||||||
self.dequeue_task = None
|
self.dequeue_task = None
|
||||||
@@ -213,9 +227,22 @@ async def open_pyusb_transport(spec: str) -> Transport:
|
|||||||
usb_find = libusb_package.find
|
usb_find = libusb_package.find
|
||||||
|
|
||||||
# Find the device according to the spec moniker
|
# Find the device according to the spec moniker
|
||||||
|
power_cycle = False
|
||||||
|
if spec.startswith('!'):
|
||||||
|
power_cycle = True
|
||||||
|
spec = spec[1:]
|
||||||
if ':' in spec:
|
if ':' in spec:
|
||||||
vendor_id, product_id = spec.split(':')
|
vendor_id, product_id = spec.split(':')
|
||||||
device = usb_find(idVendor=int(vendor_id, 16), idProduct=int(product_id, 16))
|
device = usb_find(idVendor=int(vendor_id, 16), idProduct=int(product_id, 16))
|
||||||
|
elif '-' in spec:
|
||||||
|
|
||||||
|
def device_path(device):
|
||||||
|
if device.port_numbers:
|
||||||
|
return f'{device.bus}-{".".join(map(str, device.port_numbers))}'
|
||||||
|
else:
|
||||||
|
return str(device.bus)
|
||||||
|
|
||||||
|
device = usb_find(custom_match=lambda device: device_path(device) == spec)
|
||||||
else:
|
else:
|
||||||
device_index = int(spec)
|
device_index = int(spec)
|
||||||
devices = list(
|
devices = list(
|
||||||
@@ -235,6 +262,17 @@ async def open_pyusb_transport(spec: str) -> Transport:
|
|||||||
raise ValueError('device not found')
|
raise ValueError('device not found')
|
||||||
logger.debug(f'USB Device: {device}')
|
logger.debug(f'USB Device: {device}')
|
||||||
|
|
||||||
|
# Power Cycle the device
|
||||||
|
if power_cycle:
|
||||||
|
try:
|
||||||
|
device = await _power_cycle(device) # type: ignore
|
||||||
|
except Exception as e:
|
||||||
|
logging.debug(e)
|
||||||
|
logging.info(f"Unable to power cycle {hex(device.idVendor)} {hex(device.idProduct)}") # type: ignore
|
||||||
|
|
||||||
|
# Collect the metadata
|
||||||
|
device_metadata = {'vendor_id': device.idVendor, 'product_id': device.idProduct}
|
||||||
|
|
||||||
# Detach the kernel driver if needed
|
# Detach the kernel driver if needed
|
||||||
if device.is_kernel_driver_active(0):
|
if device.is_kernel_driver_active(0):
|
||||||
logger.debug("detaching kernel driver")
|
logger.debug("detaching kernel driver")
|
||||||
@@ -289,9 +327,79 @@ async def open_pyusb_transport(spec: str) -> Transport:
|
|||||||
# except usb.USBError:
|
# except usb.USBError:
|
||||||
# logger.warning('failed to set alternate setting')
|
# logger.warning('failed to set alternate setting')
|
||||||
|
|
||||||
packet_source = UsbPacketSource(device, sco_enabled)
|
packet_source = UsbPacketSource(device, device_metadata, sco_enabled)
|
||||||
packet_sink = UsbPacketSink(device)
|
packet_sink = UsbPacketSink(device)
|
||||||
packet_source.start()
|
packet_source.start()
|
||||||
packet_sink.start()
|
packet_sink.start()
|
||||||
|
|
||||||
return UsbTransport(device, packet_source, packet_sink)
|
return UsbTransport(device, packet_source, packet_sink)
|
||||||
|
|
||||||
|
|
||||||
|
async def _power_cycle(device: UsbDevice) -> UsbDevice:
|
||||||
|
"""
|
||||||
|
For devices connected to compatible USB hubs: Performs a power cycle on a given USB device.
|
||||||
|
This involves temporarily disabling its port on the hub and then re-enabling it.
|
||||||
|
"""
|
||||||
|
device_path = f'{device.bus}-{".".join(map(str, device.port_numbers))}' # type: ignore
|
||||||
|
hub = _find_hub_by_device_path(device_path)
|
||||||
|
|
||||||
|
if hub:
|
||||||
|
try:
|
||||||
|
device_port = device.port_numbers[-1] # type: ignore
|
||||||
|
_set_port_status(hub, device_port, False)
|
||||||
|
await asyncio.sleep(POWER_CYCLE_DELAY)
|
||||||
|
_set_port_status(hub, device_port, True)
|
||||||
|
await asyncio.sleep(RESET_DELAY)
|
||||||
|
|
||||||
|
# Device needs to be find again otherwise it will appear as disconnected
|
||||||
|
return usb.core.find(idVendor=device.idVendor, idProduct=device.idProduct) # type: ignore
|
||||||
|
except USBError as e:
|
||||||
|
logger.error(f"Adjustment needed: Please revise the udev rule for device {hex(device.idVendor)}:{hex(device.idProduct)} for proper recognition.") # type: ignore
|
||||||
|
logger.error(e)
|
||||||
|
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
def _set_port_status(device: UsbDevice, port: int, on: bool):
|
||||||
|
"""Sets the power status of a specific port on a USB hub."""
|
||||||
|
device.ctrl_transfer(
|
||||||
|
bmRequestType=CTRL_TYPE_CLASS | CTRL_RECIPIENT_OTHER,
|
||||||
|
bRequest=REQ_SET_FEATURE if on else REQ_CLEAR_FEATURE,
|
||||||
|
wIndex=port,
|
||||||
|
wValue=USB_PORT_FEATURE_POWER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_device_by_path(sys_path: str) -> Optional[UsbDevice]:
|
||||||
|
"""Finds a USB device based on its system path."""
|
||||||
|
bus_num, *port_parts = sys_path.split('-')
|
||||||
|
ports = [int(port) for port in port_parts[0].split('.')]
|
||||||
|
devices = usb.core.find(find_all=True, bus=int(bus_num))
|
||||||
|
if devices:
|
||||||
|
for device in devices:
|
||||||
|
if device.bus == int(bus_num) and list(device.port_numbers) == ports: # type: ignore
|
||||||
|
return device
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_hub_by_device_path(sys_path: str) -> Optional[UsbDevice]:
|
||||||
|
"""Finds the USB hub associated with a specific device path."""
|
||||||
|
hub_sys_path = sys_path.rsplit('.', 1)[0]
|
||||||
|
hub_device = _find_device_by_path(hub_sys_path)
|
||||||
|
|
||||||
|
if hub_device is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return hub_device if _is_hub(hub_device) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_hub(device: UsbDevice) -> bool:
|
||||||
|
"""Checks if a USB device is a hub"""
|
||||||
|
if device.bDeviceClass == CLASS_HUB: # type: ignore
|
||||||
|
return True
|
||||||
|
for config in device:
|
||||||
|
for interface in config:
|
||||||
|
if interface.bInterfaceClass == CLASS_HUB: # type: ignore
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
|
|
||||||
from .common import Transport, StreamPacketSource
|
from .common import Transport, StreamPacketSource
|
||||||
|
|
||||||
@@ -28,6 +29,13 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# A pass-through function to ease mock testing.
|
||||||
|
async def _create_server(*args, **kw_args):
|
||||||
|
await asyncio.get_running_loop().create_server(*args, **kw_args)
|
||||||
|
|
||||||
|
|
||||||
async def open_tcp_server_transport(spec: str) -> Transport:
|
async def open_tcp_server_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a TCP server transport.
|
Open a TCP server transport.
|
||||||
@@ -38,7 +46,22 @@ async def open_tcp_server_transport(spec: str) -> Transport:
|
|||||||
|
|
||||||
Example: _:9001
|
Example: _:9001
|
||||||
'''
|
'''
|
||||||
|
local_host, local_port = spec.split(':')
|
||||||
|
return await _open_tcp_server_transport_impl(
|
||||||
|
host=local_host if local_host != '_' else None, port=int(local_port)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def open_tcp_server_transport_with_socket(sock: socket.socket) -> Transport:
|
||||||
|
'''
|
||||||
|
Open a TCP server transport with an existing socket.
|
||||||
|
|
||||||
|
One reason to use this variant is to let python pick an unused port.
|
||||||
|
'''
|
||||||
|
return await _open_tcp_server_transport_impl(sock=sock)
|
||||||
|
|
||||||
|
|
||||||
|
async def _open_tcp_server_transport_impl(**kwargs) -> Transport:
|
||||||
class TcpServerTransport(Transport):
|
class TcpServerTransport(Transport):
|
||||||
async def close(self):
|
async def close(self):
|
||||||
await super().close()
|
await super().close()
|
||||||
@@ -77,13 +100,10 @@ async def open_tcp_server_transport(spec: str) -> Transport:
|
|||||||
else:
|
else:
|
||||||
logger.debug('no client, dropping packet')
|
logger.debug('no client, dropping packet')
|
||||||
|
|
||||||
local_host, local_port = spec.split(':')
|
|
||||||
packet_source = StreamPacketSource()
|
packet_source = StreamPacketSource()
|
||||||
packet_sink = TcpServerPacketSink()
|
packet_sink = TcpServerPacketSink()
|
||||||
await asyncio.get_running_loop().create_server(
|
await _create_server(
|
||||||
lambda: TcpServerProtocol(packet_source, packet_sink),
|
lambda: TcpServerProtocol(packet_source, packet_sink), **kwargs
|
||||||
host=local_host if local_host != '_' else None,
|
|
||||||
port=int(local_port),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return TcpServerTransport(packet_source, packet_sink)
|
return TcpServerTransport(packet_source, packet_sink)
|
||||||
|
|||||||
+11
-1
@@ -396,6 +396,16 @@ async def open_usb_transport(spec: str) -> Transport:
|
|||||||
break
|
break
|
||||||
device_index -= 1
|
device_index -= 1
|
||||||
device.close()
|
device.close()
|
||||||
|
elif '-' in spec:
|
||||||
|
|
||||||
|
def device_path(device):
|
||||||
|
return f'{device.getBusNumber()}-{".".join(map(str, device.getPortNumberList()))}'
|
||||||
|
|
||||||
|
for device in context.getDeviceIterator(skip_on_error=True):
|
||||||
|
if device_path(device) == spec:
|
||||||
|
found = device
|
||||||
|
break
|
||||||
|
device.close()
|
||||||
else:
|
else:
|
||||||
# Look for a compatible device by index
|
# Look for a compatible device by index
|
||||||
def device_is_bluetooth_hci(device):
|
def device_is_bluetooth_hci(device):
|
||||||
@@ -439,7 +449,7 @@ async def open_usb_transport(spec: str) -> Transport:
|
|||||||
# Look for the first interface with the right class and endpoints
|
# Look for the first interface with the right class and endpoints
|
||||||
def find_endpoints(device):
|
def find_endpoints(device):
|
||||||
# pylint: disable-next=too-many-nested-blocks
|
# pylint: disable-next=too-many-nested-blocks
|
||||||
for (configuration_index, configuration) in enumerate(device):
|
for configuration_index, configuration in enumerate(device):
|
||||||
interface = None
|
interface = None
|
||||||
for interface in configuration:
|
for interface in configuration:
|
||||||
setting = None
|
setting = None
|
||||||
|
|||||||
+10
-8
@@ -117,12 +117,12 @@ class EventWatcher:
|
|||||||
self.handlers = []
|
self.handlers = []
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def on(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]:
|
def on(
|
||||||
...
|
self, emitter: EventEmitter, event: str
|
||||||
|
) -> Callable[[_Handler], _Handler]: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def on(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler:
|
def on(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler: ...
|
||||||
...
|
|
||||||
|
|
||||||
def on(
|
def on(
|
||||||
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
|
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
|
||||||
@@ -144,12 +144,14 @@ class EventWatcher:
|
|||||||
return wrapper if handler is None else wrapper(handler)
|
return wrapper if handler is None else wrapper(handler)
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def once(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]:
|
def once(
|
||||||
...
|
self, emitter: EventEmitter, event: str
|
||||||
|
) -> Callable[[_Handler], _Handler]: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def once(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler:
|
def once(
|
||||||
...
|
self, emitter: EventEmitter, event: str, handler: _Handler
|
||||||
|
) -> _Handler: ...
|
||||||
|
|
||||||
def once(
|
def once(
|
||||||
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
|
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
|
||||||
|
|||||||
@@ -12,12 +12,25 @@ a host that send custom HCI commands that the controller may not understand.
|
|||||||
```
|
```
|
||||||
python hci_bridge.py <host-transport-spec> <controller-transport-spec> [command-short-circuit-list]
|
python hci_bridge.py <host-transport-spec> <controller-transport-spec> [command-short-circuit-list]
|
||||||
```
|
```
|
||||||
|
The command-short-circuit-list field is specified by a series of comma separated Opcode Group
|
||||||
|
Field (OGF) : OpCode Command Field (OCF) pairs. The OGF/OCF values are specified in the Blutooth
|
||||||
|
core specification.
|
||||||
|
|
||||||
|
For the commands that are listed in the short-circuit-list, the HCI bridge will always generate
|
||||||
|
a Command Complete Event for the specified op code. The return parameter will be HCI_SUCCESS.
|
||||||
|
|
||||||
|
This feature can only be used for commands that return Command Complete. Other events will not be
|
||||||
|
generated by the HCI bridge tool.
|
||||||
|
|
||||||
!!! example "UDP to Serial"
|
!!! example "UDP to Serial"
|
||||||
```
|
```
|
||||||
python hci_bridge.py udp:0.0.0.0:9000,127.0.0.1:9001 serial:/dev/tty.usbmodem0006839912171,1000000 0x3f:0x0070,0x3f:0x0074,0x3f:0x0077,0x3f:0x0078
|
python hci_bridge.py udp:0.0.0.0:9000,127.0.0.1:9001 serial:/dev/tty.usbmodem0006839912171,1000000 0x3f:0x0070,0x3f:0x0074,0x3f:0x0077,0x3f:0x0078
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In this example, the short circuit list is specified to respond to the Vendor-specific Opcode Group
|
||||||
|
Field (0x3f) commands 0x70, 0x74, 0x77, 0x78 with Command Complete. The short circuit list can be
|
||||||
|
used where the Host uses some HCI commands that are not supported/implemented by the Controller.
|
||||||
|
|
||||||
!!! example "PTY to Link Relay"
|
!!! example "PTY to Link Relay"
|
||||||
```
|
```
|
||||||
python hci_bridge.py serial:emulated_uart_pty,1000000 link-relay:ws://127.0.0.1:10723/test
|
python hci_bridge.py serial:emulated_uart_pty,1000000 link-relay:ws://127.0.0.1:10723/test
|
||||||
@@ -28,3 +41,4 @@ a host that send custom HCI commands that the controller may not understand.
|
|||||||
(through which the communication with other virtual controllers will be mediated).
|
(through which the communication with other virtual controllers will be mediated).
|
||||||
|
|
||||||
NOTE: this assumes you're running a Link Relay on port `10723`.
|
NOTE: this assumes you're running a Link Relay on port `10723`.
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ used with particular HCI controller.
|
|||||||
When the transport for an HCI controller is instantiated from a transport name,
|
When the transport for an HCI controller is instantiated from a transport name,
|
||||||
a driver may also be forced by specifying ``driver=<driver-name>`` in the optional
|
a driver may also be forced by specifying ``driver=<driver-name>`` in the optional
|
||||||
metadata portion of the transport name. For example,
|
metadata portion of the transport name. For example,
|
||||||
``usb:[driver=-rtk]0`` indicates that the ``rtk`` driver should be used with the
|
``usb:[driver=rtk]0`` indicates that the ``rtk`` driver should be used with the
|
||||||
first USB device, even if a normal probe would not have selected it based on the
|
first USB device, even if a normal probe would not have selected it based on the
|
||||||
USB vendor ID and product ID.
|
USB vendor ID and product ID.
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ The moniker for a USB transport is either:
|
|||||||
* `usb:<vendor>:<product>`
|
* `usb:<vendor>:<product>`
|
||||||
* `usb:<vendor>:<product>/<serial-number>`
|
* `usb:<vendor>:<product>/<serial-number>`
|
||||||
* `usb:<vendor>:<product>#<index>`
|
* `usb:<vendor>:<product>#<index>`
|
||||||
|
* `usb:<bus>-<port_numbers>`
|
||||||
|
|
||||||
with `<index>` as a 0-based index (0 being the first one) to select amongst all the matching devices when there are more than one.
|
with `<index>` as a 0-based index (0 being the first one) to select amongst all the matching devices when there are more than one.
|
||||||
In the `usb:<index>` form, matching devices are the ones supporting Bluetooth HCI, as declared by their Class, Subclass and Protocol.
|
In the `usb:<index>` form, matching devices are the ones supporting Bluetooth HCI, as declared by their Class, Subclass and Protocol.
|
||||||
@@ -17,6 +18,8 @@ In the `usb:<vendor>:<product>#<index>` form, matching devices are the ones with
|
|||||||
|
|
||||||
`<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal.
|
`<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal.
|
||||||
|
|
||||||
|
with `<port_numbers>` as a list of all port numbers from root separated with dots `.`
|
||||||
|
|
||||||
In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
|
In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
|
||||||
the first USB interface of the device will be used, regardless of the interface class/subclass.
|
the first USB interface of the device will be used, regardless of the interface class/subclass.
|
||||||
This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
|
This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
|
||||||
@@ -37,6 +40,9 @@ This may be useful for some devices that use a custom class/subclass but may non
|
|||||||
`usb:0B05:17CB!`
|
`usb:0B05:17CB!`
|
||||||
The BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
|
The BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
|
||||||
|
|
||||||
|
`usb:3-3.4.1`
|
||||||
|
The BT USB dongle on bus 3 on port path 3, 4, 1.
|
||||||
|
|
||||||
|
|
||||||
## Alternative
|
## Alternative
|
||||||
The library includes two different implementations of the USB transport, implemented using different python bindings for `libusb`.
|
The library includes two different implementations of the USB transport, implemented using different python bindings for `libusb`.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from bumble.utils import AsyncRunner
|
|||||||
my_work_queue1 = AsyncRunner.WorkQueue()
|
my_work_queue1 = AsyncRunner.WorkQueue()
|
||||||
my_work_queue2 = AsyncRunner.WorkQueue(create_task=False)
|
my_work_queue2 = AsyncRunner.WorkQueue(create_task=False)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@AsyncRunner.run_in_task()
|
@AsyncRunner.run_in_task()
|
||||||
async def func1(x, y):
|
async def func1(x, y):
|
||||||
@@ -60,7 +61,7 @@ async def func4(x, y):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
print("MAIN: start, loop=", asyncio.get_running_loop())
|
print("MAIN: start, loop=", asyncio.get_running_loop())
|
||||||
print("MAIN: invoke func1")
|
print("MAIN: invoke func1")
|
||||||
func1(1, 2)
|
func1(1, 2)
|
||||||
|
|||||||
@@ -21,23 +21,29 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
|
from bumble.hci import Address
|
||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
from bumble.profiles.battery_service import BatteryServiceProxy
|
from bumble.profiles.battery_service import BatteryServiceProxy
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) != 3:
|
if len(sys.argv) != 3:
|
||||||
print('Usage: battery_client.py <transport-spec> <bluetooth-address>')
|
print('Usage: battery_client.py <transport-spec> <bluetooth-address>')
|
||||||
print('example: battery_client.py usb:0 E1:CA:72:48:C4:E8')
|
print('example: battery_client.py usb:0 E1:CA:72:48:C4:E8')
|
||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport(sys.argv[1]) as (hci_source, hci_sink):
|
async with await open_transport(sys.argv[1]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create and start a device
|
# Create and start a device
|
||||||
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
|
device = Device.with_hci(
|
||||||
|
'Bumble',
|
||||||
|
Address('F0:F1:F2:F3:F4:F5'),
|
||||||
|
hci_transport.source,
|
||||||
|
hci_transport.sink,
|
||||||
|
)
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
|
|
||||||
# Connect to the peer
|
# Connect to the peer
|
||||||
|
|||||||
@@ -29,14 +29,16 @@ from bumble.profiles.battery_service import BatteryService
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) != 3:
|
if len(sys.argv) != 3:
|
||||||
print('Usage: python battery_server.py <device-config> <transport-spec>')
|
print('Usage: python battery_server.py <device-config> <transport-spec>')
|
||||||
print('example: python battery_server.py device1.json usb:0')
|
print('example: python battery_server.py device1.json usb:0')
|
||||||
return
|
return
|
||||||
|
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
|
|
||||||
# Add a Battery Service to the GATT sever
|
# Add a Battery Service to the GATT sever
|
||||||
battery_service = BatteryService(lambda _: random.randint(0, 100))
|
battery_service = BatteryService(lambda _: random.randint(0, 100))
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.device import Device, Peer
|
from bumble.device import Device, Peer
|
||||||
|
from bumble.hci import Address
|
||||||
from bumble.profiles.device_information_service import DeviceInformationServiceProxy
|
from bumble.profiles.device_information_service import DeviceInformationServiceProxy
|
||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) != 3:
|
if len(sys.argv) != 3:
|
||||||
print(
|
print(
|
||||||
'Usage: device_information_client.py <transport-spec> <bluetooth-address>'
|
'Usage: device_information_client.py <transport-spec> <bluetooth-address>'
|
||||||
@@ -35,11 +36,16 @@ async def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport(sys.argv[1]) as (hci_source, hci_sink):
|
async with await open_transport(sys.argv[1]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create and start a device
|
# Create and start a device
|
||||||
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
|
device = Device.with_hci(
|
||||||
|
'Bumble',
|
||||||
|
Address('F0:F1:F2:F3:F4:F5'),
|
||||||
|
hci_transport.source,
|
||||||
|
hci_transport.sink,
|
||||||
|
)
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
|
|
||||||
# Connect to the peer
|
# Connect to the peer
|
||||||
|
|||||||
@@ -28,14 +28,16 @@ from bumble.profiles.device_information_service import DeviceInformationService
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) != 3:
|
if len(sys.argv) != 3:
|
||||||
print('Usage: python device_info_server.py <device-config> <transport-spec>')
|
print('Usage: python device_info_server.py <device-config> <transport-spec>')
|
||||||
print('example: python device_info_server.py device1.json usb:0')
|
print('example: python device_info_server.py device1.json usb:0')
|
||||||
return
|
return
|
||||||
|
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
|
|
||||||
# Add a Device Information Service to the GATT sever
|
# Add a Device Information Service to the GATT sever
|
||||||
device_information_service = DeviceInformationService(
|
device_information_service = DeviceInformationService(
|
||||||
@@ -64,7 +66,7 @@ async def main():
|
|||||||
# Go!
|
# Go!
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
await device.start_advertising(auto_restart=True)
|
await device.start_advertising(auto_restart=True)
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -21,23 +21,29 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
|
from bumble.hci import Address
|
||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
from bumble.profiles.heart_rate_service import HeartRateServiceProxy
|
from bumble.profiles.heart_rate_service import HeartRateServiceProxy
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) != 3:
|
if len(sys.argv) != 3:
|
||||||
print('Usage: heart_rate_client.py <transport-spec> <bluetooth-address>')
|
print('Usage: heart_rate_client.py <transport-spec> <bluetooth-address>')
|
||||||
print('example: heart_rate_client.py usb:0 E1:CA:72:48:C4:E8')
|
print('example: heart_rate_client.py usb:0 E1:CA:72:48:C4:E8')
|
||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport(sys.argv[1]) as (hci_source, hci_sink):
|
async with await open_transport(sys.argv[1]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create and start a device
|
# Create and start a device
|
||||||
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
|
device = Device.with_hci(
|
||||||
|
'Bumble',
|
||||||
|
Address('F0:F1:F2:F3:F4:F5'),
|
||||||
|
hci_transport.source,
|
||||||
|
hci_transport.sink,
|
||||||
|
)
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
|
|
||||||
# Connect to the peer
|
# Connect to the peer
|
||||||
|
|||||||
@@ -33,14 +33,16 @@ from bumble.utils import AsyncRunner
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) != 3:
|
if len(sys.argv) != 3:
|
||||||
print('Usage: python heart_rate_server.py <device-config> <transport-spec>')
|
print('Usage: python heart_rate_server.py <device-config> <transport-spec>')
|
||||||
print('example: python heart_rate_server.py device1.json usb:0')
|
print('example: python heart_rate_server.py device1.json usb:0')
|
||||||
return
|
return
|
||||||
|
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
|
|
||||||
# Keep track of accumulated expended energy
|
# Keep track of accumulated expended energy
|
||||||
energy_start_time = time.time()
|
energy_start_time = time.time()
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<html data-bs-theme="dark">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
|
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
||||||
|
<script src="https://unpkg.com/pcm-player"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-dark bg-primary">
|
||||||
|
<div class="container">
|
||||||
|
<span class="navbar-brand mb-0 h1">Bumble HFP Audio Gateway</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<label class="form-label">Send AT Response</label>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input type="text" class="form-control" placeholder="AT Response" aria-label="AT response" id="at_response">
|
||||||
|
<button class="btn btn-primary" type="button"
|
||||||
|
onclick="send_at_response(document.getElementById('at_response').value)">Send</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-3">
|
||||||
|
<label class="form-label">Speaker Volume</label>
|
||||||
|
<div class="input-group mb-3 col-auto">
|
||||||
|
<input type="text" class="form-control" placeholder="0 - 15" aria-label="Speaker Volume"
|
||||||
|
id="speaker_volume">
|
||||||
|
<button class="btn btn-primary" type="button"
|
||||||
|
onclick="send_at_response(`+VGS: ${document.getElementById('speaker_volume').value}`)">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<label class="form-label">Mic Volume</label>
|
||||||
|
<div class="input-group mb-3 col-auto">
|
||||||
|
<input type="text" class="form-control" placeholder="0 - 15" aria-label="Mic Volume"
|
||||||
|
id="mic_volume">
|
||||||
|
<button class="btn btn-primary" type="button"
|
||||||
|
onclick="send_at_response(`+VGM: ${document.getElementById('mic_volume').value}`)">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<label class="form-label">Browser Gain</label>
|
||||||
|
<input type="range" class="form-range" id="browser-gain" min="0" max="2" value="1" step="0.1" onchange="setGain()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">Codec</span>
|
||||||
|
|
||||||
|
<select class="form-select" id="codec">
|
||||||
|
<option selected value="1">CVSD</option>
|
||||||
|
<option value="2">MSBC</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-primary" onclick="negotiate_codec()">Negotiate Codec</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-primary" onclick="connect_sco()">Connect SCO</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-primary" onclick="disconnect_sco()">Disconnect SCO</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-danger" onclick="connectAudio()">Connect Audio</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<h4>AG Indicators</h2>
|
||||||
|
<div class="col-3">
|
||||||
|
<label class="form-label">call</label>
|
||||||
|
<div class="input-group mb-3 col-auto">
|
||||||
|
<select class="form-select" id="call">
|
||||||
|
<option selected value="0">Inactive</option>
|
||||||
|
<option value="1">Active</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" type="button" onclick="update_ag_indicator('call')">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<label class="form-label">callsetup</label>
|
||||||
|
<div class="input-group mb-3 col-auto">
|
||||||
|
<select class="form-select" id="callsetup">
|
||||||
|
<option selected value="0">Idle</option>
|
||||||
|
<option value="1">Incoming</option>
|
||||||
|
<option value="2">Outgoing</option>
|
||||||
|
<option value="3">Remote Alerted</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" type="button"
|
||||||
|
onclick="update_ag_indicator('callsetup')">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<label class="form-label">callheld</label>
|
||||||
|
<div class="input-group mb-3 col-auto">
|
||||||
|
<select class="form-select" id="callsetup">
|
||||||
|
<option selected value="0">0</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" type="button"
|
||||||
|
onclick="update_ag_indicator('callheld')">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<label class="form-label">signal</label>
|
||||||
|
<div class="input-group mb-3 col-auto">
|
||||||
|
<select class="form-select" id="signal">
|
||||||
|
<option selected value="0">0</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="3">3</option>
|
||||||
|
<option value="4">4</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" type="button"
|
||||||
|
onclick="update_ag_indicator('signal')">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<label class="form-label">roam</label>
|
||||||
|
<div class="input-group mb-3 col-auto">
|
||||||
|
<select class="form-select" id="roam">
|
||||||
|
<option selected value="0">0</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" type="button" onclick="update_ag_indicator('roam')">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<label class="form-label">battchg</label>
|
||||||
|
<div class="input-group mb-3 col-auto">
|
||||||
|
<select class="form-select" id="battchg">
|
||||||
|
<option selected value="0">0</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="3">3</option>
|
||||||
|
<option value="4">4</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" type="button"
|
||||||
|
onclick="update_ag_indicator('battchg')">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<label class="form-label">service</label>
|
||||||
|
<div class="input-group mb-3 col-auto">
|
||||||
|
<select class="form-select" id="service">
|
||||||
|
<option selected value="0">0</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" type="button"
|
||||||
|
onclick="update_ag_indicator('service')">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" onclick="send_at_response('+BVRA: 1')">Start Voice Assistant</button>
|
||||||
|
<button class="btn btn-primary" onclick="send_at_response('+BVRA: 0')">Stop Voice Assistant</button>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
<h4>Calls</h4>
|
||||||
|
<div id="call-lists">
|
||||||
|
<template id="call-template">
|
||||||
|
<div class="row call-row">
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<label class="input-group-text">Index</label>
|
||||||
|
<input class="form-control call-index" value="1">
|
||||||
|
|
||||||
|
<label class="input-group-text">Number</label>
|
||||||
|
<input class="form-control call-number">
|
||||||
|
|
||||||
|
<label class="input-group-text">Direction</label>
|
||||||
|
<select class="form-select call-direction">
|
||||||
|
<option selected value="0">Originated</option>
|
||||||
|
<option value="1">Terminated</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label class="input-group-text">Status</label>
|
||||||
|
<select class="form-select call-status">
|
||||||
|
<option value="0">ACTIVE</option>
|
||||||
|
<option value="1">HELD</option>
|
||||||
|
<option value="2">DIALING</option>
|
||||||
|
<option value="3">ALERTING</option>
|
||||||
|
<option value="4">INCOMING</option>
|
||||||
|
<option value="5">WAITING</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary call-remover">❌</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" onclick="add_call()">➕ Add Call</button>
|
||||||
|
<button class="btn btn-primary" onclick="update_calls()">🗘 Update Calls</button>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2">
|
||||||
|
<h3>Log</h3>
|
||||||
|
<code id="log" style="white-space: pre-line;"></code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let atResponseInput = document.getElementById("at_response")
|
||||||
|
let gainInput = document.getElementById('browser-gain')
|
||||||
|
let log = document.getElementById("log")
|
||||||
|
let socket = new WebSocket('ws://localhost:8888');
|
||||||
|
let sampleRate = 0;
|
||||||
|
let player;
|
||||||
|
|
||||||
|
socket.binaryType = "arraybuffer";
|
||||||
|
socket.onopen = _ => {
|
||||||
|
log.textContent += 'SOCKET OPEN\n'
|
||||||
|
}
|
||||||
|
socket.onclose = _ => {
|
||||||
|
log.textContent += 'SOCKET CLOSED\n'
|
||||||
|
}
|
||||||
|
socket.onerror = (error) => {
|
||||||
|
log.textContent += 'SOCKET ERROR\n'
|
||||||
|
console.log(`ERROR: ${error}`)
|
||||||
|
}
|
||||||
|
socket.onmessage = function (message) {
|
||||||
|
if (typeof message.data === 'string' || message.data instanceof String) {
|
||||||
|
log.textContent += `<-- ${event.data}\n`
|
||||||
|
const jsonMessage = JSON.parse(event.data)
|
||||||
|
|
||||||
|
if (jsonMessage.type == 'speaker_volume') {
|
||||||
|
document.getElementById('speaker_volume').value = jsonMessage.level;
|
||||||
|
} else if (jsonMessage.type == 'microphone_volume') {
|
||||||
|
document.getElementById('microphone_volume').value = jsonMessage.level;
|
||||||
|
} else if (jsonMessage.type == 'sco_state_change') {
|
||||||
|
sampleRate = jsonMessage.sample_rate;
|
||||||
|
console.log(sampleRate);
|
||||||
|
if (player != null) {
|
||||||
|
player = new PCMPlayer({
|
||||||
|
inputCodec: 'Int16',
|
||||||
|
channels: 1,
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
flushTime: 7.5,
|
||||||
|
});
|
||||||
|
player.volume(gainInput.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// BINARY audio data.
|
||||||
|
if (player == null) return;
|
||||||
|
player.feed(message.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function send(message) {
|
||||||
|
if (socket && socket.readyState == WebSocket.OPEN) {
|
||||||
|
let jsonMessage = JSON.stringify(message)
|
||||||
|
log.textContent += `--> ${jsonMessage}\n`
|
||||||
|
socket.send(jsonMessage)
|
||||||
|
} else {
|
||||||
|
log.textContent += 'NOT CONNECTED\n'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send_at_response(response) {
|
||||||
|
send({ type: 'at_response', response: response })
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_ag_indicator(indicator) {
|
||||||
|
const value = document.getElementById(indicator).value
|
||||||
|
send({ type: 'ag_indicator', indicator: indicator, value: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect_sco() {
|
||||||
|
send({ type: 'connect_sco' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function negotiate_codec() {
|
||||||
|
const codec = document.getElementById('codec').value
|
||||||
|
send({ type: 'negotiate_codec', codec: codec })
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect_sco() {
|
||||||
|
send({ type: 'disconnect_sco' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function add_call() {
|
||||||
|
let callLists = document.getElementById('call-lists');
|
||||||
|
let template = document.getElementById('call-template');
|
||||||
|
|
||||||
|
let newNode = document.importNode(template.content, true);
|
||||||
|
newNode.querySelector('.call-remover').onclick = function (event) {
|
||||||
|
event.target.closest('.call-row').remove();
|
||||||
|
}
|
||||||
|
callLists.appendChild(newNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_calls() {
|
||||||
|
let callLists = document.getElementById('call-lists');
|
||||||
|
send({
|
||||||
|
type: 'update_calls',
|
||||||
|
calls: Array.from(
|
||||||
|
callLists.querySelectorAll('.call-row')).map(
|
||||||
|
function (element) {
|
||||||
|
return {
|
||||||
|
index: element.querySelector('.call-index').value,
|
||||||
|
number: element.querySelector('.call-number').value,
|
||||||
|
direction: element.querySelector('.call-direction').value,
|
||||||
|
status: element.querySelector('.call-status').value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectAudio() {
|
||||||
|
player = new PCMPlayer({
|
||||||
|
inputCodec: 'Int16',
|
||||||
|
channels: 1,
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
flushTime: 7.5,
|
||||||
|
});
|
||||||
|
player.volume(gainInput.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGain() {
|
||||||
|
if (player != null) {
|
||||||
|
player.volume(gainInput.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Bumble Phone",
|
"name": "Bumble Phone",
|
||||||
"class_of_device": 6291980
|
"class_of_device": 6291980,
|
||||||
|
"keystore": "JsonKeyStore"
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-54
@@ -1,79 +1,132 @@
|
|||||||
<html>
|
<html data-bs-theme="dark">
|
||||||
<head>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
font-family: sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
<head>
|
||||||
display: block;
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
}
|
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-dark bg-primary">
|
||||||
|
<div class="container">
|
||||||
|
<span class="navbar-brand mb-0 h1">Bumble Handsfree</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<label class="form-label">Server Port</label>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input type="text" class="form-control" aria-label="Port Number" value="8989" id="port">
|
||||||
|
<button class="btn btn-primary" type="button" onclick="connect()">Connect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-label">Dial Phone Number</label>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input type="text" class="form-control" placeholder="Phone Number" aria-label="Phone Number"
|
||||||
|
id="dial_number">
|
||||||
|
<button class="btn btn-primary" type="button"
|
||||||
|
onclick="send_at_command(`ATD${dialNumberInput.value}`)">Dial</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-label">Send AT Command</label>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input type="text" class="form-control" placeholder="AT Command" aria-label="AT command" id="at_command">
|
||||||
|
<button class="btn btn-primary" type="button"
|
||||||
|
onclick="send_at_command(document.getElementById('at_command').value)">Send</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label">Battery Level</label>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input type="text" class="form-control" placeholder="0 - 100" aria-label="Battery Level"
|
||||||
|
id="battery_level">
|
||||||
|
<button class="btn btn-primary" type="button"
|
||||||
|
onclick="send_at_command(`AT+BIEV=2,${document.getElementById('battery_level').value}`)">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label">Speaker Volume</label>
|
||||||
|
<div class="input-group mb-3 col-auto">
|
||||||
|
<input type="text" class="form-control" placeholder="0 - 15" aria-label="Speaker Volume"
|
||||||
|
id="speaker_volume">
|
||||||
|
<button class="btn btn-primary" type="button"
|
||||||
|
onclick="send_at_command(`AT+VGS=${document.getElementById('speaker_volume').value}`)">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label">Mic Volume</label>
|
||||||
|
<div class="input-group mb-3 col-auto">
|
||||||
|
<input type="text" class="form-control" placeholder="0 - 15" aria-label="Mic Volume"
|
||||||
|
id="mic_volume">
|
||||||
|
<button class="btn btn-primary" type="button"
|
||||||
|
onclick="send_at_command(`AT+VGM=${document.getElementById('mic_volume').value}`)">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" onclick="send_at_command('ATA')">Answer</button>
|
||||||
|
<button class="btn btn-primary" onclick="send_at_command('AT+CHUP')">Hang Up</button>
|
||||||
|
<button class="btn btn-primary" onclick="send_at_command('AT+BLDN')">Redial</button>
|
||||||
|
<button class="btn btn-primary" onclick="send({ type: 'query_call'})">Get Call Status</button>
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" onclick="send_at_command('AT+BVRA=1')">Start Voice Assistant</button>
|
||||||
|
<button class="btn btn-primary" onclick="send_at_command('AT+BVRA=0')">Stop Voice Assistant</button>
|
||||||
|
|
||||||
input, label {
|
|
||||||
margin: .4rem 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
Server Port <input id="port" type="text" value="8989"></input> <button onclick="connect()">Connect</button><br>
|
|
||||||
AT Command <input type="text" id="at_command" required size="10"> <button onclick="send_at_command()">Send</button><br>
|
|
||||||
Dial Phone Number <input type="text" id="dial_number" required size="10"> <button onclick="dial()">Dial</button><br>
|
|
||||||
<button onclick="answer()">Answer</button>
|
|
||||||
<button onclick="hangup()">Hang Up</button>
|
|
||||||
<button onclick="start_voice_assistant()">Start Voice Assistant</button>
|
|
||||||
<button onclick="stop_voice_assistant()">Stop Voice Assistant</button>
|
|
||||||
<hr>
|
<hr>
|
||||||
<div id="socketState"></div>
|
|
||||||
<script>
|
<div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2">
|
||||||
|
<h3>Log</h3>
|
||||||
|
<code id="log" style="white-space: pre-line;"></code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
let portInput = document.getElementById("port")
|
let portInput = document.getElementById("port")
|
||||||
let atCommandInput = document.getElementById("at_command")
|
let atCommandInput = document.getElementById("at_command")
|
||||||
let dialNumberInput = document.getElementById("dial_number")
|
let log = document.getElementById("log")
|
||||||
let socketState = document.getElementById("socketState")
|
|
||||||
let socket
|
let socket
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
socket = new WebSocket(`ws://localhost:${portInput.value}`);
|
socket = new WebSocket(`ws://localhost:${portInput.value}`);
|
||||||
socket.onopen = _ => {
|
socket.onopen = _ => {
|
||||||
socketState.innerText = 'OPEN'
|
log.textContent += 'OPEN\n'
|
||||||
}
|
}
|
||||||
socket.onclose = _ => {
|
socket.onclose = _ => {
|
||||||
socketState.innerText = 'CLOSED'
|
log.textContent += 'CLOSED\n'
|
||||||
}
|
}
|
||||||
socket.onerror = (error) => {
|
socket.onerror = (error) => {
|
||||||
socketState.innerText = 'ERROR'
|
log.textContent += 'ERROR\n'
|
||||||
console.log(`ERROR: ${error}`)
|
console.log(`ERROR: ${error}`)
|
||||||
}
|
}
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
log.textContent += `<-- ${event.data}\n`
|
||||||
|
let volume_state = JSON.parse(event.data)
|
||||||
|
volumeSetting.value = volume_state.volume_setting
|
||||||
|
changeCounter.value = volume_state.change_counter
|
||||||
|
muted.checked = volume_state.muted ? true : false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function send(message) {
|
function send(message) {
|
||||||
if (socket && socket.readyState == WebSocket.OPEN) {
|
if (socket && socket.readyState == WebSocket.OPEN) {
|
||||||
socket.send(JSON.stringify(message))
|
let jsonMessage = JSON.stringify(message)
|
||||||
|
log.textContent += `--> ${jsonMessage}\n`
|
||||||
|
socket.send(jsonMessage)
|
||||||
|
} else {
|
||||||
|
log.textContent += 'NOT CONNECTED\n'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function send_at_command() {
|
function send_at_command(command) {
|
||||||
send({ type:'at_command', command: atCommandInput.value })
|
send({ type: 'at_command', 'command': command })
|
||||||
}
|
}
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
function answer() {
|
</html>
|
||||||
send({ type:'at_command', command: 'ATA' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function hangup() {
|
|
||||||
send({ type:'at_command', command: 'AT+CHUP' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function dial() {
|
|
||||||
send({ type:'at_command', command: `ATD${dialNumberInput.value}` })
|
|
||||||
}
|
|
||||||
|
|
||||||
function start_voice_assistant() {
|
|
||||||
send(({ type:'at_command', command: 'AT+BVRA=1' }))
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop_voice_assistant() {
|
|
||||||
send(({ type:'at_command', command: 'AT+BVRA=0' }))
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -416,7 +416,7 @@ async def keyboard_device(device, command):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 4:
|
if len(sys.argv) < 4:
|
||||||
print(
|
print(
|
||||||
'Usage: python keyboard.py <device-config> <transport-spec> <command>'
|
'Usage: python keyboard.py <device-config> <transport-spec> <command>'
|
||||||
@@ -434,9 +434,11 @@ async def main():
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
# Create a device to manage the host
|
# Create a device to manage the host
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
|
|
||||||
command = sys.argv[3]
|
command = sys.argv[3]
|
||||||
if command == 'connect':
|
if command == 'connect':
|
||||||
|
|||||||
@@ -139,18 +139,20 @@ async def find_a2dp_service(connection):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 4:
|
if len(sys.argv) < 4:
|
||||||
print('Usage: run_a2dp_info.py <device-config> <transport-spec> <bt-addr>')
|
print('Usage: run_a2dp_info.py <device-config> <transport-spec> <bt-addr>')
|
||||||
print('example: run_a2dp_info.py classic1.json usb:0 14:7D:DA:4E:53:A8')
|
print('example: run_a2dp_info.py classic1.json usb:0 14:7D:DA:4E:53:A8')
|
||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create a device
|
# Create a device
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
|
|
||||||
# Start the controller
|
# Start the controller
|
||||||
@@ -187,7 +189,7 @@ async def main():
|
|||||||
client = await AVDTP_Protocol.connect(connection, avdtp_version)
|
client = await AVDTP_Protocol.connect(connection, avdtp_version)
|
||||||
|
|
||||||
# Discover all endpoints on the remote device
|
# Discover all endpoints on the remote device
|
||||||
endpoints = await client.discover_remote_endpoints()
|
endpoints = list(await client.discover_remote_endpoints())
|
||||||
print(f'@@@ Found {len(endpoints)} endpoints')
|
print(f'@@@ Found {len(endpoints)} endpoints')
|
||||||
for endpoint in endpoints:
|
for endpoint in endpoints:
|
||||||
print('@@@', endpoint)
|
print('@@@', endpoint)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import asyncio
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
@@ -41,7 +42,7 @@ from bumble.a2dp import (
|
|||||||
SbcMediaCodecInformation,
|
SbcMediaCodecInformation,
|
||||||
)
|
)
|
||||||
|
|
||||||
Context = {'output': None}
|
Context: Dict[Any, Any] = {'output': None}
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -104,7 +105,7 @@ def on_rtp_packet(packet):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 4:
|
if len(sys.argv) < 4:
|
||||||
print(
|
print(
|
||||||
'Usage: run_a2dp_sink.py <device-config> <transport-spec> <sbc-file> '
|
'Usage: run_a2dp_sink.py <device-config> <transport-spec> <sbc-file> '
|
||||||
@@ -114,14 +115,16 @@ async def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
with open(sys.argv[3], 'wb') as sbc_file:
|
with open(sys.argv[3], 'wb') as sbc_file:
|
||||||
Context['output'] = sbc_file
|
Context['output'] = sbc_file
|
||||||
|
|
||||||
# Create a device
|
# Create a device
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
|
|
||||||
# Setup the SDP to expose the sink service
|
# Setup the SDP to expose the sink service
|
||||||
@@ -162,7 +165,7 @@ async def main():
|
|||||||
await device.set_discoverable(True)
|
await device.set_discoverable(True)
|
||||||
await device.set_connectable(True)
|
await device.set_connectable(True)
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ async def stream_packets(read_function, protocol):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 4:
|
if len(sys.argv) < 4:
|
||||||
print(
|
print(
|
||||||
'Usage: run_a2dp_source.py <device-config> <transport-spec> <sbc-file> '
|
'Usage: run_a2dp_source.py <device-config> <transport-spec> <sbc-file> '
|
||||||
@@ -126,11 +126,13 @@ async def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create a device
|
# Create a device
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
|
|
||||||
# Setup the SDP to expose the SRC service
|
# Setup the SDP to expose the SRC service
|
||||||
@@ -186,7 +188,7 @@ async def main():
|
|||||||
await device.set_discoverable(True)
|
await device.set_discoverable(True)
|
||||||
await device.set_connectable(True)
|
await device.set_connectable(True)
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from bumble.transport import open_transport_or_link
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print(
|
print(
|
||||||
'Usage: run_advertiser.py <config-file> <transport-spec> [type] [address]'
|
'Usage: run_advertiser.py <config-file> <transport-spec> [type] [address]'
|
||||||
@@ -50,10 +50,12 @@ async def main():
|
|||||||
target = None
|
target = None
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
|
|
||||||
if advertising_type.is_scannable:
|
if advertising_type.is_scannable:
|
||||||
device.scan_response_data = bytes(
|
device.scan_response_data = bytes(
|
||||||
@@ -66,7 +68,7 @@ async def main():
|
|||||||
|
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
await device.start_advertising(advertising_type=advertising_type, target=target)
|
await device.start_advertising(advertising_type=advertising_type, target=target)
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ ASHA_LE_PSM_OUT_CHARACTERISTIC = UUID(
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) != 4:
|
if len(sys.argv) != 4:
|
||||||
print(
|
print(
|
||||||
'Usage: python run_asha_sink.py <device-config> <transport-spec> '
|
'Usage: python run_asha_sink.py <device-config> <transport-spec> '
|
||||||
@@ -60,8 +60,10 @@ async def main():
|
|||||||
|
|
||||||
audio_out = open(sys.argv[3], 'wb')
|
audio_out = open(sys.argv[3], 'wb')
|
||||||
|
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
|
|
||||||
# Handler for audio control commands
|
# Handler for audio control commands
|
||||||
def on_audio_control_point_write(_connection, value):
|
def on_audio_control_point_write(_connection, value):
|
||||||
@@ -197,7 +199,7 @@ async def main():
|
|||||||
await device.power_on()
|
await device.power_on()
|
||||||
await device.start_advertising(auto_restart=True)
|
await device.start_advertising(auto_restart=True)
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ class Delegate(avrcp.Delegate):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print(
|
print(
|
||||||
'Usage: run_avrcp_controller.py <device-config> <transport-spec> '
|
'Usage: run_avrcp_controller.py <device-config> <transport-spec> '
|
||||||
@@ -341,11 +341,13 @@ async def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create a device
|
# Create a device
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
|
|
||||||
# Setup the SDP to expose the sink service
|
# Setup the SDP to expose the sink service
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from bumble.sdp import (
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print(
|
print(
|
||||||
'Usage: run_classic_connect.py <device-config> <transport-spec> '
|
'Usage: run_classic_connect.py <device-config> <transport-spec> '
|
||||||
@@ -42,11 +42,13 @@ async def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create a device
|
# Create a device
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
device.le_enabled = False
|
device.le_enabled = False
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
|
|||||||
@@ -91,18 +91,20 @@ SDP_SERVICE_RECORDS = {
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print('Usage: run_classic_discoverable.py <device-config> <transport-spec>')
|
print('Usage: run_classic_discoverable.py <device-config> <transport-spec>')
|
||||||
print('example: run_classic_discoverable.py classic1.json usb:04b4:f901')
|
print('example: run_classic_discoverable.py classic1.json usb:04b4:f901')
|
||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create a device
|
# Create a device
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
device.sdp_service_records = SDP_SERVICE_RECORDS
|
device.sdp_service_records = SDP_SERVICE_RECORDS
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
@@ -111,7 +113,7 @@ async def main():
|
|||||||
await device.set_discoverable(True)
|
await device.set_discoverable(True)
|
||||||
await device.set_connectable(True)
|
await device.set_connectable(True)
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
|
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
|
from bumble.hci import Address
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
from bumble.core import DeviceClass
|
from bumble.core import DeviceClass
|
||||||
|
|
||||||
@@ -53,22 +53,27 @@ class DiscoveryListener(Device.Listener):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) != 2:
|
||||||
print('Usage: run_classic_discovery.py <transport-spec>')
|
print('Usage: run_classic_discovery.py <transport-spec>')
|
||||||
print('example: run_classic_discovery.py usb:04b4:f901')
|
print('example: run_classic_discovery.py usb:04b4:f901')
|
||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[1]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[1]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
|
device = Device.with_hci(
|
||||||
|
'Bumble',
|
||||||
|
Address('F0:F1:F2:F3:F4:F5'),
|
||||||
|
hci_transport.source,
|
||||||
|
hci_transport.sink,
|
||||||
|
)
|
||||||
device.listener = DiscoveryListener()
|
device.listener = DiscoveryListener()
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
await device.start_discovery()
|
await device.start_discovery()
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from bumble.transport import open_transport_or_link
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print(
|
print(
|
||||||
'Usage: run_connect_and_encrypt.py <device-config> <transport-spec> '
|
'Usage: run_connect_and_encrypt.py <device-config> <transport-spec> '
|
||||||
@@ -37,11 +37,13 @@ async def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create a device
|
# Create a device
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
|
|
||||||
# Connect to the peer
|
# Connect to the peer
|
||||||
@@ -56,7 +58,7 @@ async def main():
|
|||||||
print(f'!!! Encryption failed: {error}')
|
print(f'!!! Encryption failed: {error}')
|
||||||
return
|
return
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from bumble.transport import open_transport_or_link
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) != 4:
|
if len(sys.argv) != 4:
|
||||||
print(
|
print(
|
||||||
'Usage: run_controller.py <controller-address> <device-config> '
|
'Usage: run_controller.py <controller-address> <device-config> '
|
||||||
@@ -49,7 +49,7 @@ async def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
print('>>> connecting to HCI...')
|
print('>>> connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[3]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[3]) as hci_transport:
|
||||||
print('>>> connected')
|
print('>>> connected')
|
||||||
|
|
||||||
# Create a local link
|
# Create a local link
|
||||||
@@ -57,7 +57,10 @@ async def main():
|
|||||||
|
|
||||||
# Create a first controller using the packet source/sink as its host interface
|
# Create a first controller using the packet source/sink as its host interface
|
||||||
controller1 = Controller(
|
controller1 = Controller(
|
||||||
'C1', host_source=hci_source, host_sink=hci_sink, link=link
|
'C1',
|
||||||
|
host_source=hci_transport.source,
|
||||||
|
host_sink=hci_transport.sink,
|
||||||
|
link=link,
|
||||||
)
|
)
|
||||||
controller1.random_address = sys.argv[1]
|
controller1.random_address = sys.argv[1]
|
||||||
|
|
||||||
@@ -98,7 +101,7 @@ async def main():
|
|||||||
await device.start_advertising()
|
await device.start_advertising()
|
||||||
await device.start_scanning()
|
await device.start_scanning()
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ import asyncio
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
|
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
from bumble.controller import Controller
|
from bumble.controller import Controller
|
||||||
|
from bumble.hci import Address
|
||||||
from bumble.link import LocalLink
|
from bumble.link import LocalLink
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
|
|
||||||
@@ -45,14 +45,14 @@ class ScannerListener(Device.Listener):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) != 2:
|
||||||
print('Usage: run_controller.py <transport-spec>')
|
print('Usage: run_controller.py <transport-spec>')
|
||||||
print('example: run_controller_with_scanner.py serial:/dev/pts/14,1000000')
|
print('example: run_controller_with_scanner.py serial:/dev/pts/14,1000000')
|
||||||
return
|
return
|
||||||
|
|
||||||
print('>>> connecting to HCI...')
|
print('>>> connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[1]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[1]) as hci_transport:
|
||||||
print('>>> connected')
|
print('>>> connected')
|
||||||
|
|
||||||
# Create a local link
|
# Create a local link
|
||||||
@@ -60,22 +60,25 @@ async def main():
|
|||||||
|
|
||||||
# Create a first controller using the packet source/sink as its host interface
|
# Create a first controller using the packet source/sink as its host interface
|
||||||
controller1 = Controller(
|
controller1 = Controller(
|
||||||
'C1', host_source=hci_source, host_sink=hci_sink, link=link
|
'C1',
|
||||||
|
host_source=hci_transport.source,
|
||||||
|
host_sink=hci_transport.sink,
|
||||||
|
link=link,
|
||||||
|
public_address='E0:E1:E2:E3:E4:E5',
|
||||||
)
|
)
|
||||||
controller1.address = 'E0:E1:E2:E3:E4:E5'
|
|
||||||
|
|
||||||
# Create a second controller using the same link
|
# Create a second controller using the same link
|
||||||
controller2 = Controller('C2', link=link)
|
controller2 = Controller('C2', link=link)
|
||||||
|
|
||||||
# Create a device with a scanner listener
|
# Create a device with a scanner listener
|
||||||
device = Device.with_hci(
|
device = Device.with_hci(
|
||||||
'Bumble', 'F0:F1:F2:F3:F4:F5', controller2, controller2
|
'Bumble', Address('F0:F1:F2:F3:F4:F5'), controller2, controller2
|
||||||
)
|
)
|
||||||
device.listener = ScannerListener()
|
device.listener = ScannerListener()
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
await device.start_scanning()
|
await device.start_scanning()
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -20,30 +20,36 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
|
from bumble.hci import Address
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
from bumble.snoop import BtSnooper
|
from bumble.snoop import BtSnooper
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) != 3:
|
if len(sys.argv) != 3:
|
||||||
print('Usage: run_device_with_snooper.py <transport-spec> <snoop-file>')
|
print('Usage: run_device_with_snooper.py <transport-spec> <snoop-file>')
|
||||||
print('example: run_device_with_snooper.py usb:0 btsnoop.log')
|
print('example: run_device_with_snooper.py usb:0 btsnoop.log')
|
||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[1]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[1]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
|
device = Device.with_hci(
|
||||||
|
'Bumble',
|
||||||
|
Address('F0:F1:F2:F3:F4:F5'),
|
||||||
|
hci_transport.source,
|
||||||
|
hci_transport.sink,
|
||||||
|
)
|
||||||
|
|
||||||
with open(sys.argv[2], "wb") as snoop_file:
|
with open(sys.argv[2], "wb") as snoop_file:
|
||||||
device.host.snooper = BtSnooper(snoop_file)
|
device.host.snooper = BtSnooper(snoop_file)
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
await device.start_scanning()
|
await device.start_scanning()
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class Listener(Device.Listener):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print(
|
print(
|
||||||
'Usage: run_gatt_client.py <device-config> <transport-spec> '
|
'Usage: run_gatt_client.py <device-config> <transport-spec> '
|
||||||
@@ -79,11 +79,13 @@ async def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create a device to manage the host, with a custom listener
|
# Create a device to manage the host, with a custom listener
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
device.listener = Listener(device)
|
device.listener = Listener(device)
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
|
|
||||||
|
|||||||
@@ -19,21 +19,21 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
|
|
||||||
from bumble.core import ProtocolError
|
from bumble.core import ProtocolError
|
||||||
from bumble.controller import Controller
|
from bumble.controller import Controller
|
||||||
from bumble.device import Device, Peer
|
from bumble.device import Device, Peer
|
||||||
|
from bumble.hci import Address
|
||||||
from bumble.host import Host
|
from bumble.host import Host
|
||||||
from bumble.link import LocalLink
|
from bumble.link import LocalLink
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
Service,
|
Service,
|
||||||
Characteristic,
|
Characteristic,
|
||||||
Descriptor,
|
Descriptor,
|
||||||
show_services,
|
|
||||||
GATT_CHARACTERISTIC_USER_DESCRIPTION_DESCRIPTOR,
|
GATT_CHARACTERISTIC_USER_DESCRIPTION_DESCRIPTOR,
|
||||||
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
|
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
|
||||||
GATT_DEVICE_INFORMATION_SERVICE,
|
GATT_DEVICE_INFORMATION_SERVICE,
|
||||||
)
|
)
|
||||||
|
from bumble.gatt_client import show_services
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -43,7 +43,7 @@ class ServerListener(Device.Listener):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
# Create a local link
|
# Create a local link
|
||||||
link = LocalLink()
|
link = LocalLink()
|
||||||
|
|
||||||
@@ -51,14 +51,18 @@ async def main():
|
|||||||
client_controller = Controller("client controller", link=link)
|
client_controller = Controller("client controller", link=link)
|
||||||
client_host = Host()
|
client_host = Host()
|
||||||
client_host.controller = client_controller
|
client_host.controller = client_controller
|
||||||
client_device = Device("client", address='F0:F1:F2:F3:F4:F5', host=client_host)
|
client_device = Device(
|
||||||
|
"client", address=Address('F0:F1:F2:F3:F4:F5'), host=client_host
|
||||||
|
)
|
||||||
await client_device.power_on()
|
await client_device.power_on()
|
||||||
|
|
||||||
# Setup a stack for the server
|
# Setup a stack for the server
|
||||||
server_controller = Controller("server controller", link=link)
|
server_controller = Controller("server controller", link=link)
|
||||||
server_host = Host()
|
server_host = Host()
|
||||||
server_host.controller = server_controller
|
server_host.controller = server_controller
|
||||||
server_device = Device("server", address='F6:F7:F8:F9:FA:FB', host=server_host)
|
server_device = Device(
|
||||||
|
"server", address=Address('F6:F7:F8:F9:FA:FB'), host=server_host
|
||||||
|
)
|
||||||
server_device.listener = ServerListener()
|
server_device.listener = ServerListener()
|
||||||
await server_device.power_on()
|
await server_device.power_on()
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ def my_custom_write_with_error(connection, value):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print(
|
print(
|
||||||
'Usage: run_gatt_server.py <device-config> <transport-spec> '
|
'Usage: run_gatt_server.py <device-config> <transport-spec> '
|
||||||
@@ -81,11 +81,13 @@ async def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create a device to manage the host
|
# Create a device to manage the host
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
device.listener = Listener(device)
|
device.listener = Listener(device)
|
||||||
|
|
||||||
# Add a few entries to the device's GATT server
|
# Add a few entries to the device's GATT server
|
||||||
@@ -146,7 +148,7 @@ async def main():
|
|||||||
else:
|
else:
|
||||||
await device.start_advertising(auto_restart=True)
|
await device.start_advertising(auto_restart=True)
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
+213
-183
@@ -16,240 +16,270 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import websockets
|
||||||
|
|
||||||
from bumble.colors import color
|
from typing import Optional
|
||||||
|
|
||||||
import bumble.core
|
import bumble.core
|
||||||
from bumble.device import Device
|
from bumble.device import Device, ScoLink
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
BT_HANDSFREE_SERVICE,
|
|
||||||
BT_RFCOMM_PROTOCOL_ID,
|
|
||||||
BT_BR_EDR_TRANSPORT,
|
BT_BR_EDR_TRANSPORT,
|
||||||
)
|
)
|
||||||
from bumble import rfcomm, hfp
|
from bumble import hci, rfcomm, hfp
|
||||||
from bumble.hci import HCI_SynchronousDataPacket
|
|
||||||
from bumble.sdp import (
|
|
||||||
Client as SDP_Client,
|
|
||||||
DataElement,
|
|
||||||
ServiceAttribute,
|
|
||||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
||||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
|
||||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ws: Optional[websockets.WebSocketServerProtocol] = None
|
||||||
|
ag_protocol: Optional[hfp.AgProtocol] = None
|
||||||
|
source_file: Optional[io.BufferedReader] = None
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# pylint: disable-next=too-many-nested-blocks
|
|
||||||
async def list_rfcomm_channels(device, connection):
|
|
||||||
# Connect to the SDP Server
|
|
||||||
sdp_client = SDP_Client(connection)
|
|
||||||
await sdp_client.connect()
|
|
||||||
|
|
||||||
# Search for services that support the Handsfree Profile
|
def _default_configuration() -> hfp.AgConfiguration:
|
||||||
search_result = await sdp_client.search_attributes(
|
return hfp.AgConfiguration(
|
||||||
[BT_HANDSFREE_SERVICE],
|
supported_ag_features=[
|
||||||
[
|
hfp.AgFeature.HF_INDICATORS,
|
||||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
hfp.AgFeature.IN_BAND_RING_TONE_CAPABILITY,
|
||||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
hfp.AgFeature.REJECT_CALL,
|
||||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
hfp.AgFeature.CODEC_NEGOTIATION,
|
||||||
|
hfp.AgFeature.ESCO_S4_SETTINGS_SUPPORTED,
|
||||||
|
hfp.AgFeature.ENHANCED_CALL_STATUS,
|
||||||
],
|
],
|
||||||
|
supported_ag_indicators=[
|
||||||
|
hfp.AgIndicatorState.call(),
|
||||||
|
hfp.AgIndicatorState.callsetup(),
|
||||||
|
hfp.AgIndicatorState.callheld(),
|
||||||
|
hfp.AgIndicatorState.service(),
|
||||||
|
hfp.AgIndicatorState.signal(),
|
||||||
|
hfp.AgIndicatorState.roam(),
|
||||||
|
hfp.AgIndicatorState.battchg(),
|
||||||
|
],
|
||||||
|
supported_hf_indicators=[
|
||||||
|
hfp.HfIndicator.ENHANCED_SAFETY,
|
||||||
|
hfp.HfIndicator.BATTERY_LEVEL,
|
||||||
|
],
|
||||||
|
supported_ag_call_hold_operations=[],
|
||||||
|
supported_audio_codecs=[hfp.AudioCodec.CVSD, hfp.AudioCodec.MSBC],
|
||||||
)
|
)
|
||||||
print(color('==================================', 'blue'))
|
|
||||||
print(color('Handsfree Services:', 'yellow'))
|
|
||||||
rfcomm_channels = []
|
def send_message(type: str, **kwargs) -> None:
|
||||||
# pylint: disable-next=too-many-nested-blocks
|
if ws:
|
||||||
for attribute_list in search_result:
|
asyncio.create_task(ws.send(json.dumps({'type': type, **kwargs})))
|
||||||
# Look for the RFCOMM Channel number
|
|
||||||
protocol_descriptor_list = ServiceAttribute.find_attribute_in_list(
|
|
||||||
attribute_list, SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID
|
def on_speaker_volume(level: int):
|
||||||
|
send_message(type='speaker_volume', level=level)
|
||||||
|
|
||||||
|
|
||||||
|
def on_microphone_volume(level: int):
|
||||||
|
send_message(type='microphone_volume', level=level)
|
||||||
|
|
||||||
|
|
||||||
|
def on_sco_state_change(codec: int):
|
||||||
|
if codec == hfp.AudioCodec.CVSD:
|
||||||
|
sample_rate = 8000
|
||||||
|
elif codec == hfp.AudioCodec.MSBC:
|
||||||
|
sample_rate = 16000
|
||||||
|
else:
|
||||||
|
sample_rate = 0
|
||||||
|
|
||||||
|
send_message(type='sco_state_change', sample_rate=sample_rate)
|
||||||
|
|
||||||
|
|
||||||
|
def on_sco_packet(packet: hci.HCI_SynchronousDataPacket):
|
||||||
|
if ws:
|
||||||
|
asyncio.create_task(ws.send(packet.data))
|
||||||
|
if source_file and (pcm_data := source_file.read(packet.data_total_length)):
|
||||||
|
assert ag_protocol
|
||||||
|
host = ag_protocol.dlc.multiplexer.l2cap_channel.connection.device.host
|
||||||
|
host.send_hci_packet(
|
||||||
|
hci.HCI_SynchronousDataPacket(
|
||||||
|
connection_handle=packet.connection_handle,
|
||||||
|
packet_status=0,
|
||||||
|
data_total_length=len(pcm_data),
|
||||||
|
data=pcm_data,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if protocol_descriptor_list:
|
|
||||||
for protocol_descriptor in protocol_descriptor_list.value:
|
|
||||||
if len(protocol_descriptor.value) >= 2:
|
|
||||||
if protocol_descriptor.value[0].value == BT_RFCOMM_PROTOCOL_ID:
|
|
||||||
print(color('SERVICE:', 'green'))
|
|
||||||
print(
|
|
||||||
color(' RFCOMM Channel:', 'cyan'),
|
|
||||||
protocol_descriptor.value[1].value,
|
|
||||||
)
|
|
||||||
rfcomm_channels.append(protocol_descriptor.value[1].value)
|
|
||||||
|
|
||||||
# List profiles
|
|
||||||
bluetooth_profile_descriptor_list = (
|
|
||||||
ServiceAttribute.find_attribute_in_list(
|
|
||||||
attribute_list,
|
|
||||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if bluetooth_profile_descriptor_list:
|
|
||||||
if bluetooth_profile_descriptor_list.value:
|
|
||||||
if (
|
|
||||||
bluetooth_profile_descriptor_list.value[0].type
|
|
||||||
== DataElement.SEQUENCE
|
|
||||||
):
|
|
||||||
bluetooth_profile_descriptors = (
|
|
||||||
bluetooth_profile_descriptor_list.value
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Sometimes, instead of a list of lists, we just
|
|
||||||
# find a list. Fix that
|
|
||||||
bluetooth_profile_descriptors = [
|
|
||||||
bluetooth_profile_descriptor_list
|
|
||||||
]
|
|
||||||
|
|
||||||
print(color(' Profiles:', 'green'))
|
def on_hfp_state_change(connected: bool):
|
||||||
for (
|
send_message(type='hfp_state_change', connected=connected)
|
||||||
bluetooth_profile_descriptor
|
|
||||||
) in bluetooth_profile_descriptors:
|
|
||||||
version_major = (
|
|
||||||
bluetooth_profile_descriptor.value[1].value >> 8
|
|
||||||
)
|
|
||||||
version_minor = (
|
|
||||||
bluetooth_profile_descriptor.value[1].value
|
|
||||||
& 0xFF
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
' '
|
|
||||||
f'{bluetooth_profile_descriptor.value[0].value}'
|
|
||||||
f' - version {version_major}.{version_minor}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# List service classes
|
|
||||||
service_class_id_list = ServiceAttribute.find_attribute_in_list(
|
|
||||||
attribute_list, SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID
|
|
||||||
)
|
|
||||||
if service_class_id_list:
|
|
||||||
if service_class_id_list.value:
|
|
||||||
print(color(' Service Classes:', 'green'))
|
|
||||||
for service_class_id in service_class_id_list.value:
|
|
||||||
print(' ', service_class_id.value)
|
|
||||||
|
|
||||||
await sdp_client.disconnect()
|
async def ws_server(ws_client: websockets.WebSocketServerProtocol, path: str):
|
||||||
return rfcomm_channels
|
del path
|
||||||
|
global ws
|
||||||
|
ws = ws_client
|
||||||
|
|
||||||
|
async for message in ws_client:
|
||||||
|
if not ag_protocol:
|
||||||
|
continue
|
||||||
|
|
||||||
|
json_message = json.loads(message)
|
||||||
|
message_type = json_message['type']
|
||||||
|
connection = ag_protocol.dlc.multiplexer.l2cap_channel.connection
|
||||||
|
device = connection.device
|
||||||
|
|
||||||
|
try:
|
||||||
|
if message_type == 'at_response':
|
||||||
|
ag_protocol.send_response(json_message['response'])
|
||||||
|
elif message_type == 'ag_indicator':
|
||||||
|
ag_protocol.update_ag_indicator(
|
||||||
|
hfp.AgIndicator(json_message['indicator']),
|
||||||
|
int(json_message['value']),
|
||||||
|
)
|
||||||
|
elif message_type == 'negotiate_codec':
|
||||||
|
codec = hfp.AudioCodec(int(json_message['codec']))
|
||||||
|
await ag_protocol.negotiate_codec(codec)
|
||||||
|
elif message_type == 'connect_sco':
|
||||||
|
if ag_protocol.active_codec == hfp.AudioCodec.CVSD:
|
||||||
|
esco_param = hfp.ESCO_PARAMETERS[
|
||||||
|
hfp.DefaultCodecParameters.ESCO_CVSD_S4
|
||||||
|
]
|
||||||
|
elif ag_protocol.active_codec == hfp.AudioCodec.MSBC:
|
||||||
|
esco_param = hfp.ESCO_PARAMETERS[
|
||||||
|
hfp.DefaultCodecParameters.ESCO_MSBC_T2
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported codec {codec}')
|
||||||
|
|
||||||
|
await device.send_command(
|
||||||
|
hci.HCI_Enhanced_Setup_Synchronous_Connection_Command(
|
||||||
|
connection_handle=connection.handle, **esco_param.asdict()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif message_type == 'disconnect_sco':
|
||||||
|
# Copy the values to avoid iteration error.
|
||||||
|
for sco_link in list(device.sco_links.values()):
|
||||||
|
await sco_link.disconnect()
|
||||||
|
elif message_type == 'update_calls':
|
||||||
|
ag_protocol.calls = [
|
||||||
|
hfp.CallInfo(
|
||||||
|
index=int(call['index']),
|
||||||
|
direction=hfp.CallInfoDirection(int(call['direction'])),
|
||||||
|
status=hfp.CallInfoStatus(int(call['status'])),
|
||||||
|
number=call['number'],
|
||||||
|
multi_party=hfp.CallInfoMultiParty.NOT_IN_CONFERENCE,
|
||||||
|
mode=hfp.CallInfoMode.VOICE,
|
||||||
|
)
|
||||||
|
for call in json_message['calls']
|
||||||
|
]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
send_message(type='error', message=e)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 4:
|
if len(sys.argv) < 3:
|
||||||
print(
|
print(
|
||||||
'Usage: run_hfp_gateway.py <device-config> <transport-spec> '
|
'Usage: run_hfp_gateway.py <device-config> <transport-spec> '
|
||||||
'<bluetooth-address>'
|
'[bluetooth-address] [wav-file-for-source]'
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
' specifying a channel number, or "discover" to list all RFCOMM channels'
|
'example: run_hfp_gateway.py hfp_gateway.json usb:0 E1:CA:72:48:C4:E8 sample.wav'
|
||||||
)
|
)
|
||||||
print('example: run_hfp_gateway.py hfp_gateway.json usb:0 E1:CA:72:48:C4:E8')
|
|
||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create a device
|
# Create a device
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
|
|
||||||
# Connect to a peer
|
rfcomm_server = rfcomm.Server(device)
|
||||||
target_address = sys.argv[3]
|
configuration = _default_configuration()
|
||||||
print(f'=== Connecting to {target_address}...')
|
|
||||||
connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
|
|
||||||
print(f'=== Connected to {connection.peer_address}!')
|
|
||||||
|
|
||||||
# Get a list of all the Handsfree services (should only be 1)
|
def on_dlc(dlc: rfcomm.DLC):
|
||||||
channels = await list_rfcomm_channels(device, connection)
|
global ag_protocol
|
||||||
if len(channels) == 0:
|
ag_protocol = hfp.AgProtocol(dlc, configuration)
|
||||||
print('!!! no service found')
|
ag_protocol.on('speaker_volume', on_speaker_volume)
|
||||||
return
|
ag_protocol.on('microphone_volume', on_microphone_volume)
|
||||||
|
on_hfp_state_change(True)
|
||||||
|
dlc.multiplexer.l2cap_channel.on(
|
||||||
|
'close', lambda: on_hfp_state_change(False)
|
||||||
|
)
|
||||||
|
|
||||||
# Pick the first one
|
channel = rfcomm_server.listen(on_dlc)
|
||||||
channel = channels[0]
|
device.sdp_service_records = {
|
||||||
|
1: hfp.make_ag_sdp_records(1, channel, configuration)
|
||||||
|
}
|
||||||
|
|
||||||
# Request authentication
|
def on_sco_connection(sco_link: ScoLink):
|
||||||
print('*** Authenticating...')
|
assert ag_protocol
|
||||||
await connection.authenticate()
|
on_sco_state_change(ag_protocol.active_codec)
|
||||||
print('*** Authenticated')
|
sco_link.on('disconnection', lambda _: on_sco_state_change(0))
|
||||||
|
sco_link.sink = on_sco_packet
|
||||||
|
|
||||||
# Enable encryption
|
device.on('sco_connection', on_sco_connection)
|
||||||
print('*** Enabling encryption...')
|
if len(sys.argv) >= 4:
|
||||||
await connection.encrypt()
|
# Connect to a peer
|
||||||
print('*** Encryption on')
|
target_address = sys.argv[3]
|
||||||
|
print(f'=== Connecting to {target_address}...')
|
||||||
|
connection = await device.connect(
|
||||||
|
target_address, transport=BT_BR_EDR_TRANSPORT
|
||||||
|
)
|
||||||
|
print(f'=== Connected to {connection.peer_address}!')
|
||||||
|
|
||||||
# Create a client and start it
|
# Get a list of all the Handsfree services (should only be 1)
|
||||||
print('@@@ Starting to RFCOMM client...')
|
if not (hfp_record := await hfp.find_hf_sdp_record(connection)):
|
||||||
rfcomm_client = rfcomm.Client(connection)
|
print('!!! no service found')
|
||||||
rfcomm_mux = await rfcomm_client.start()
|
return
|
||||||
print('@@@ Started')
|
|
||||||
|
|
||||||
print(f'### Opening session for channel {channel}...')
|
# Pick the first one
|
||||||
try:
|
channel, version, hf_sdp_features = hfp_record
|
||||||
session = await rfcomm_mux.open_dlc(channel)
|
print(f'HF version: {version}')
|
||||||
print('### Session open', session)
|
print(f'HF features: {hf_sdp_features}')
|
||||||
except bumble.core.ConnectionError as error:
|
|
||||||
print(f'### Session open failed: {error}')
|
|
||||||
await rfcomm_mux.disconnect()
|
|
||||||
print('@@@ Disconnected from RFCOMM server')
|
|
||||||
return
|
|
||||||
|
|
||||||
def on_sco(connection_handle: int, packet: HCI_SynchronousDataPacket):
|
# Request authentication
|
||||||
# Reset packet and loopback
|
print('*** Authenticating...')
|
||||||
packet.packet_status = 0
|
await connection.authenticate()
|
||||||
device.host.send_hci_packet(packet)
|
print('*** Authenticated')
|
||||||
|
|
||||||
device.host.on('sco_packet', on_sco)
|
# Enable encryption
|
||||||
|
print('*** Enabling encryption...')
|
||||||
|
await connection.encrypt()
|
||||||
|
print('*** Encryption on')
|
||||||
|
|
||||||
# Protocol loop (just for testing at this point)
|
# Create a client and start it
|
||||||
protocol = hfp.HfpProtocol(session)
|
print('@@@ Starting to RFCOMM client...')
|
||||||
while True:
|
rfcomm_client = rfcomm.Client(connection)
|
||||||
line = await protocol.next_line()
|
rfcomm_mux = await rfcomm_client.start()
|
||||||
|
print('@@@ Started')
|
||||||
|
|
||||||
if line.startswith('AT+BRSF='):
|
print(f'### Opening session for channel {channel}...')
|
||||||
protocol.send_response_line('+BRSF: 30')
|
try:
|
||||||
protocol.send_response_line('OK')
|
session = await rfcomm_mux.open_dlc(channel)
|
||||||
elif line.startswith('AT+CIND=?'):
|
print('### Session open', session)
|
||||||
protocol.send_response_line(
|
except bumble.core.ConnectionError as error:
|
||||||
'+CIND: ("call",(0,1)),("callsetup",(0-3)),("service",(0-1)),'
|
print(f'### Session open failed: {error}')
|
||||||
'("signal",(0-5)),("roam",(0,1)),("battchg",(0-5)),'
|
await rfcomm_mux.disconnect()
|
||||||
'("callheld",(0-2))'
|
print('@@@ Disconnected from RFCOMM server')
|
||||||
)
|
return
|
||||||
protocol.send_response_line('OK')
|
|
||||||
elif line.startswith('AT+CIND?'):
|
|
||||||
protocol.send_response_line('+CIND: 0,0,1,4,1,5,0')
|
|
||||||
protocol.send_response_line('OK')
|
|
||||||
elif line.startswith('AT+CMER='):
|
|
||||||
protocol.send_response_line('OK')
|
|
||||||
elif line.startswith('AT+CHLD=?'):
|
|
||||||
protocol.send_response_line('+CHLD: 0')
|
|
||||||
protocol.send_response_line('OK')
|
|
||||||
elif line.startswith('AT+BTRH?'):
|
|
||||||
protocol.send_response_line('+BTRH: 0')
|
|
||||||
protocol.send_response_line('OK')
|
|
||||||
elif line.startswith('AT+CLIP='):
|
|
||||||
protocol.send_response_line('OK')
|
|
||||||
elif line.startswith('AT+VGS='):
|
|
||||||
protocol.send_response_line('OK')
|
|
||||||
elif line.startswith('AT+BIA='):
|
|
||||||
protocol.send_response_line('OK')
|
|
||||||
elif line.startswith('AT+BVRA='):
|
|
||||||
protocol.send_response_line(
|
|
||||||
'+BVRA: 1,1,12AA,1,1,"Message 1 from Janina"'
|
|
||||||
)
|
|
||||||
elif line.startswith('AT+XEVENT='):
|
|
||||||
protocol.send_response_line('OK')
|
|
||||||
elif line.startswith('AT+XAPL='):
|
|
||||||
protocol.send_response_line('OK')
|
|
||||||
else:
|
|
||||||
print(color('UNSUPPORTED AT COMMAND', 'red'))
|
|
||||||
protocol.send_response_line('ERROR')
|
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
on_dlc(session)
|
||||||
|
|
||||||
|
await websockets.serve(ws_server, port=8888)
|
||||||
|
|
||||||
|
if len(sys.argv) >= 5:
|
||||||
|
global source_file
|
||||||
|
source_file = open(sys.argv[4], 'rb')
|
||||||
|
# Skip header
|
||||||
|
source_file.seek(44)
|
||||||
|
|
||||||
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@@ -31,39 +32,16 @@ from bumble.transport import open_transport_or_link
|
|||||||
from bumble import hfp
|
from bumble import hfp
|
||||||
from bumble.hfp import HfProtocol
|
from bumble.hfp import HfProtocol
|
||||||
|
|
||||||
|
ws: Optional[websockets.WebSocketServerProtocol] = None
|
||||||
# -----------------------------------------------------------------------------
|
hf_protocol: Optional[HfProtocol] = None
|
||||||
class UiServer:
|
|
||||||
protocol: Optional[HfProtocol] = None
|
|
||||||
|
|
||||||
async def start(self):
|
|
||||||
"""Start a Websocket server to receive events from a web page."""
|
|
||||||
|
|
||||||
async def serve(websocket, _path):
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
message = await websocket.recv()
|
|
||||||
print('Received: ', str(message))
|
|
||||||
|
|
||||||
parsed = json.loads(message)
|
|
||||||
message_type = parsed['type']
|
|
||||||
if message_type == 'at_command':
|
|
||||||
if self.protocol is not None:
|
|
||||||
await self.protocol.execute_command(parsed['command'])
|
|
||||||
|
|
||||||
except websockets.exceptions.ConnectionClosedOK:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# pylint: disable=no-member
|
|
||||||
await websockets.serve(serve, 'localhost', 8989)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def on_dlc(dlc: rfcomm.DLC, configuration: hfp.Configuration):
|
def on_dlc(dlc: rfcomm.DLC, configuration: hfp.HfConfiguration):
|
||||||
print('*** DLC connected', dlc)
|
print('*** DLC connected', dlc)
|
||||||
protocol = HfProtocol(dlc, configuration)
|
global hf_protocol
|
||||||
UiServer.protocol = protocol
|
hf_protocol = HfProtocol(dlc, configuration)
|
||||||
asyncio.create_task(protocol.run())
|
asyncio.create_task(hf_protocol.run())
|
||||||
|
|
||||||
def on_sco_request(connection: Connection, link_type: int, protocol: HfProtocol):
|
def on_sco_request(connection: Connection, link_type: int, protocol: HfProtocol):
|
||||||
if connection == protocol.dlc.multiplexer.l2cap_channel.connection:
|
if connection == protocol.dlc.multiplexer.l2cap_channel.connection:
|
||||||
@@ -88,7 +66,7 @@ def on_dlc(dlc: rfcomm.DLC, configuration: hfp.Configuration):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
handler = functools.partial(on_sco_request, protocol=protocol)
|
handler = functools.partial(on_sco_request, protocol=hf_protocol)
|
||||||
dlc.multiplexer.l2cap_channel.connection.device.on('sco_request', handler)
|
dlc.multiplexer.l2cap_channel.connection.device.on('sco_request', handler)
|
||||||
dlc.multiplexer.l2cap_channel.once(
|
dlc.multiplexer.l2cap_channel.once(
|
||||||
'close',
|
'close',
|
||||||
@@ -97,21 +75,28 @@ def on_dlc(dlc: rfcomm.DLC, configuration: hfp.Configuration):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_ag_indicator(indicator):
|
||||||
|
global ws
|
||||||
|
if ws:
|
||||||
|
asyncio.create_task(ws.send(str(indicator)))
|
||||||
|
|
||||||
|
hf_protocol.on('ag_indicator', on_ag_indicator)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print('Usage: run_classic_hfp.py <device-config> <transport-spec>')
|
print('Usage: run_classic_hfp.py <device-config> <transport-spec>')
|
||||||
print('example: run_classic_hfp.py classic2.json usb:04b4:f901')
|
print('example: run_classic_hfp.py classic2.json usb:04b4:f901')
|
||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Hands-Free profile configuration.
|
# Hands-Free profile configuration.
|
||||||
# TODO: load configuration from file.
|
# TODO: load configuration from file.
|
||||||
configuration = hfp.Configuration(
|
configuration = hfp.HfConfiguration(
|
||||||
supported_hf_features=[
|
supported_hf_features=[
|
||||||
hfp.HfFeature.THREE_WAY_CALLING,
|
hfp.HfFeature.THREE_WAY_CALLING,
|
||||||
hfp.HfFeature.REMOTE_VOLUME_CONTROL,
|
hfp.HfFeature.REMOTE_VOLUME_CONTROL,
|
||||||
@@ -131,7 +116,9 @@ async def main():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create a device
|
# Create a device
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
|
|
||||||
# Create and register a server
|
# Create and register a server
|
||||||
@@ -143,7 +130,9 @@ async def main():
|
|||||||
|
|
||||||
# Advertise the HFP RFComm channel in the SDP
|
# Advertise the HFP RFComm channel in the SDP
|
||||||
device.sdp_service_records = {
|
device.sdp_service_records = {
|
||||||
0x00010001: hfp.sdp_records(0x00010001, channel_number, configuration)
|
0x00010001: hfp.make_hf_sdp_records(
|
||||||
|
0x00010001, channel_number, configuration
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Let's go!
|
# Let's go!
|
||||||
@@ -154,10 +143,32 @@ async def main():
|
|||||||
await device.set_connectable(True)
|
await device.set_connectable(True)
|
||||||
|
|
||||||
# Start the UI websocket server to offer a few buttons and input boxes
|
# Start the UI websocket server to offer a few buttons and input boxes
|
||||||
ui_server = UiServer()
|
async def serve(websocket: websockets.WebSocketServerProtocol, _path):
|
||||||
await ui_server.start()
|
global ws
|
||||||
|
ws = websocket
|
||||||
|
async for message in websocket:
|
||||||
|
with contextlib.suppress(websockets.exceptions.ConnectionClosedOK):
|
||||||
|
print('Received: ', str(message))
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
parsed = json.loads(message)
|
||||||
|
message_type = parsed['type']
|
||||||
|
if message_type == 'at_command':
|
||||||
|
if hf_protocol is not None:
|
||||||
|
response = str(
|
||||||
|
await hf_protocol.execute_command(
|
||||||
|
parsed['command'],
|
||||||
|
response_type=hfp.AtResponseType.MULTIPLE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await websocket.send(response)
|
||||||
|
elif message_type == 'query_call':
|
||||||
|
if hf_protocol:
|
||||||
|
response = str(await hf_protocol.query_current_calls())
|
||||||
|
await websocket.send(response)
|
||||||
|
|
||||||
|
await websockets.serve(serve, 'localhost', 8989)
|
||||||
|
|
||||||
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ HID_REPORT_MAP = bytes( # Text String, 50 Octet Report Descriptor
|
|||||||
# Default protocol mode set to report protocol
|
# Default protocol mode set to report protocol
|
||||||
protocol_mode = Message.ProtocolMode.REPORT_PROTOCOL
|
protocol_mode = Message.ProtocolMode.REPORT_PROTOCOL
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def sdp_records():
|
def sdp_records():
|
||||||
service_record_handle = 0x00010002
|
service_record_handle = 0x00010002
|
||||||
@@ -427,6 +428,7 @@ class DeviceData:
|
|||||||
# Device's live data - Mouse and Keyboard will be stored in this
|
# Device's live data - Mouse and Keyboard will be stored in this
|
||||||
deviceData = DeviceData()
|
deviceData = DeviceData()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def keyboard_device(hid_device):
|
async def keyboard_device(hid_device):
|
||||||
|
|
||||||
@@ -487,7 +489,7 @@ async def keyboard_device(hid_device):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print(
|
print(
|
||||||
'Usage: python run_hid_device.py <device-config> <transport-spec> <command>'
|
'Usage: python run_hid_device.py <device-config> <transport-spec> <command>'
|
||||||
@@ -599,11 +601,13 @@ async def main():
|
|||||||
asyncio.create_task(handle_virtual_cable_unplug())
|
asyncio.create_task(handle_virtual_cable_unplug())
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create a device
|
# Create a device
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
|
|
||||||
# Create and register HID device
|
# Create and register HID device
|
||||||
@@ -740,7 +744,7 @@ async def main():
|
|||||||
print("Executing in Web mode")
|
print("Executing in Web mode")
|
||||||
await keyboard_device(hid_device)
|
await keyboard_device(hid_device)
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ async def get_stream_reader(pipe) -> asyncio.StreamReader:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 4:
|
if len(sys.argv) < 4:
|
||||||
print(
|
print(
|
||||||
'Usage: run_hid_host.py <device-config> <transport-spec> '
|
'Usage: run_hid_host.py <device-config> <transport-spec> '
|
||||||
@@ -324,11 +324,13 @@ async def main():
|
|||||||
asyncio.create_task(handle_virtual_cable_unplug())
|
asyncio.create_task(handle_virtual_cable_unplug())
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< CONNECTED')
|
print('<<< CONNECTED')
|
||||||
|
|
||||||
# Create a device
|
# Create a device
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
|
|
||||||
# Create HID host and start it
|
# Create HID host and start it
|
||||||
@@ -557,7 +559,7 @@ async def main():
|
|||||||
# Interrupt Channel
|
# Interrupt Channel
|
||||||
await hid_host.connect_interrupt_channel()
|
await hid_host.connect_interrupt_channel()
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -57,18 +57,20 @@ def on_my_characteristic_subscription(peer, enabled):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print('Usage: run_notifier.py <device-config> <transport-spec>')
|
print('Usage: run_notifier.py <device-config> <transport-spec>')
|
||||||
print('example: run_notifier.py device1.json usb:0')
|
print('example: run_notifier.py device1.json usb:0')
|
||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create a device to manage the host
|
# Create a device to manage the host
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
device.listener = Listener(device)
|
device.listener = Listener(device)
|
||||||
|
|
||||||
# Add a few entries to the device's GATT server
|
# Add a few entries to the device's GATT server
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ async def tcp_server(tcp_port, rfcomm_session):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 5:
|
if len(sys.argv) < 5:
|
||||||
print(
|
print(
|
||||||
'Usage: run_rfcomm_client.py <device-config> <transport-spec> '
|
'Usage: run_rfcomm_client.py <device-config> <transport-spec> '
|
||||||
@@ -178,11 +178,13 @@ async def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create a device
|
# Create a device
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
|
|
||||||
@@ -192,8 +194,8 @@ async def main():
|
|||||||
connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
|
connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
|
||||||
print(f'=== Connected to {connection.peer_address}!')
|
print(f'=== Connected to {connection.peer_address}!')
|
||||||
|
|
||||||
channel = sys.argv[4]
|
channel_str = sys.argv[4]
|
||||||
if channel == 'discover':
|
if channel_str == 'discover':
|
||||||
await list_rfcomm_channels(connection)
|
await list_rfcomm_channels(connection)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -213,7 +215,7 @@ async def main():
|
|||||||
rfcomm_mux = await rfcomm_client.start()
|
rfcomm_mux = await rfcomm_client.start()
|
||||||
print('@@@ Started')
|
print('@@@ Started')
|
||||||
|
|
||||||
channel = int(channel)
|
channel = int(channel_str)
|
||||||
print(f'### Opening session for channel {channel}...')
|
print(f'### Opening session for channel {channel}...')
|
||||||
try:
|
try:
|
||||||
session = await rfcomm_mux.open_dlc(channel)
|
session = await rfcomm_mux.open_dlc(channel)
|
||||||
@@ -229,7 +231,7 @@ async def main():
|
|||||||
tcp_port = int(sys.argv[5])
|
tcp_port = int(sys.argv[5])
|
||||||
asyncio.create_task(tcp_server(tcp_port, session))
|
asyncio.create_task(tcp_server(tcp_port, session))
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class TcpServer:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 4:
|
if len(sys.argv) < 4:
|
||||||
print(
|
print(
|
||||||
'Usage: run_rfcomm_server.py <device-config> <transport-spec> '
|
'Usage: run_rfcomm_server.py <device-config> <transport-spec> '
|
||||||
@@ -124,11 +124,13 @@ async def main():
|
|||||||
uuid = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
|
uuid = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
# Create a device
|
# Create a device
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
|
|
||||||
# Create a TCP server
|
# Create a TCP server
|
||||||
@@ -153,7 +155,7 @@ async def main():
|
|||||||
await device.set_discoverable(True)
|
await device.set_discoverable(True)
|
||||||
await device.set_connectable(True)
|
await device.set_connectable(True)
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
+12
-7
@@ -20,27 +20,31 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
|
from bumble.hci import Address
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main() -> None:
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
print('Usage: run_scanner.py <transport-spec> [filter]')
|
print('Usage: run_scanner.py <transport-spec> [filter]')
|
||||||
print('example: run_scanner.py usb:0')
|
print('example: run_scanner.py usb:0')
|
||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[1]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[1]) as hci_transport:
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
filter_duplicates = len(sys.argv) == 3 and sys.argv[2] == 'filter'
|
filter_duplicates = len(sys.argv) == 3 and sys.argv[2] == 'filter'
|
||||||
|
|
||||||
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
|
device = Device.with_hci(
|
||||||
|
'Bumble',
|
||||||
|
Address('F0:F1:F2:F3:F4:F5'),
|
||||||
|
hci_transport.source,
|
||||||
|
hci_transport.sink,
|
||||||
|
)
|
||||||
|
|
||||||
@device.on('advertisement')
|
def on_adv(advertisement):
|
||||||
def _(advertisement):
|
|
||||||
address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[
|
address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[
|
||||||
advertisement.address.address_type
|
advertisement.address.address_type
|
||||||
]
|
]
|
||||||
@@ -67,10 +71,11 @@ async def main():
|
|||||||
f'{advertisement.data.to_string(separator)}'
|
f'{advertisement.data.to_string(separator)}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
device.on('advertisement', on_adv)
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
await device.start_scanning(filter_duplicates=filter_duplicates)
|
await device.start_scanning(filter_duplicates=filter_duplicates)
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -16,20 +16,28 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import io
|
||||||
import struct
|
import struct
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
from bumble.core import AdvertisingData
|
from bumble.core import AdvertisingData
|
||||||
from bumble.device import Device, CisLink, AdvertisingParameters
|
from bumble.device import Device
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
CodecID,
|
CodecID,
|
||||||
CodingFormat,
|
CodingFormat,
|
||||||
OwnAddressType,
|
|
||||||
HCI_IsoDataPacket,
|
HCI_IsoDataPacket,
|
||||||
)
|
)
|
||||||
from bumble.profiles.bap import (
|
from bumble.profiles.bap import (
|
||||||
|
AseStateMachine,
|
||||||
|
UnicastServerAdvertisingData,
|
||||||
|
CodecSpecificConfiguration,
|
||||||
CodecSpecificCapabilities,
|
CodecSpecificCapabilities,
|
||||||
ContextType,
|
ContextType,
|
||||||
AudioLocation,
|
AudioLocation,
|
||||||
@@ -45,6 +53,32 @@ from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
|
|||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
|
|
||||||
|
|
||||||
|
def _sink_pac_record() -> PacRecord:
|
||||||
|
return PacRecord(
|
||||||
|
coding_format=CodingFormat(CodecID.LC3),
|
||||||
|
codec_specific_capabilities=CodecSpecificCapabilities(
|
||||||
|
supported_sampling_frequencies=(
|
||||||
|
SupportedSamplingFrequency.FREQ_8000
|
||||||
|
| SupportedSamplingFrequency.FREQ_16000
|
||||||
|
| SupportedSamplingFrequency.FREQ_24000
|
||||||
|
| SupportedSamplingFrequency.FREQ_32000
|
||||||
|
| SupportedSamplingFrequency.FREQ_48000
|
||||||
|
),
|
||||||
|
supported_frame_durations=(
|
||||||
|
SupportedFrameDuration.DURATION_7500_US_SUPPORTED
|
||||||
|
| SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
||||||
|
),
|
||||||
|
supported_audio_channel_count=[1, 2],
|
||||||
|
min_octets_per_codec_frame=26,
|
||||||
|
max_octets_per_codec_frame=240,
|
||||||
|
supported_max_codec_frames_per_sdu=2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
file_outputs: Dict[AseStateMachine, io.BufferedWriter] = {}
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
@@ -71,49 +105,17 @@ async def main() -> None:
|
|||||||
PublishedAudioCapabilitiesService(
|
PublishedAudioCapabilitiesService(
|
||||||
supported_source_context=ContextType.PROHIBITED,
|
supported_source_context=ContextType.PROHIBITED,
|
||||||
available_source_context=ContextType.PROHIBITED,
|
available_source_context=ContextType.PROHIBITED,
|
||||||
supported_sink_context=ContextType.MEDIA,
|
supported_sink_context=ContextType(0xFF), # All context types
|
||||||
available_sink_context=ContextType.MEDIA,
|
available_sink_context=ContextType(0xFF), # All context types
|
||||||
sink_audio_locations=(
|
sink_audio_locations=(
|
||||||
AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT
|
AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT
|
||||||
),
|
),
|
||||||
sink_pac=[
|
sink_pac=[_sink_pac_record()],
|
||||||
# Codec Capability Setting 16_2
|
|
||||||
PacRecord(
|
|
||||||
coding_format=CodingFormat(CodecID.LC3),
|
|
||||||
codec_specific_capabilities=CodecSpecificCapabilities(
|
|
||||||
supported_sampling_frequencies=(
|
|
||||||
SupportedSamplingFrequency.FREQ_16000
|
|
||||||
),
|
|
||||||
supported_frame_durations=(
|
|
||||||
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
|
||||||
),
|
|
||||||
supported_audio_channel_counts=[1],
|
|
||||||
min_octets_per_codec_frame=40,
|
|
||||||
max_octets_per_codec_frame=40,
|
|
||||||
supported_max_codec_frames_per_sdu=1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
# Codec Capability Setting 24_2
|
|
||||||
PacRecord(
|
|
||||||
coding_format=CodingFormat(CodecID.LC3),
|
|
||||||
codec_specific_capabilities=CodecSpecificCapabilities(
|
|
||||||
supported_sampling_frequencies=(
|
|
||||||
SupportedSamplingFrequency.FREQ_48000
|
|
||||||
),
|
|
||||||
supported_frame_durations=(
|
|
||||||
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
|
||||||
),
|
|
||||||
supported_audio_channel_counts=[1],
|
|
||||||
min_octets_per_codec_frame=120,
|
|
||||||
max_octets_per_codec_frame=120,
|
|
||||||
supported_max_codec_frames_per_sdu=1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
device.add_service(AudioStreamControlService(device, sink_ase_id=[1, 2]))
|
ascs = AudioStreamControlService(device, sink_ase_id=[1], source_ase_id=[2])
|
||||||
|
device.add_service(ascs)
|
||||||
|
|
||||||
advertising_data = (
|
advertising_data = (
|
||||||
bytes(
|
bytes(
|
||||||
@@ -141,45 +143,59 @@ async def main() -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
+ csis.get_advertising_data()
|
+ csis.get_advertising_data()
|
||||||
)
|
+ bytes(UnicastServerAdvertisingData())
|
||||||
subprocess = await asyncio.create_subprocess_shell(
|
|
||||||
f'dlc3 | ffplay pipe:0',
|
|
||||||
stdin=asyncio.subprocess.PIPE,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
stdin = subprocess.stdin
|
def on_pdu(ase: AseStateMachine, pdu: HCI_IsoDataPacket):
|
||||||
assert stdin
|
|
||||||
|
|
||||||
# Write a fake LC3 header to dlc3.
|
|
||||||
stdin.write(
|
|
||||||
bytes([0x1C, 0xCC]) # Header.
|
|
||||||
+ struct.pack(
|
|
||||||
'<HHHHHHI',
|
|
||||||
18, # Header length.
|
|
||||||
48000 // 100, # Sampling Rate(/100Hz).
|
|
||||||
0, # Bitrate(unused).
|
|
||||||
1, # Channels.
|
|
||||||
10000 // 10, # Frame duration(/10us).
|
|
||||||
0, # RFU.
|
|
||||||
0x0FFFFFFF, # Frame counts.
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_pdu(pdu: HCI_IsoDataPacket):
|
|
||||||
# LC3 format: |frame_length(2)| + |frame(length)|.
|
# LC3 format: |frame_length(2)| + |frame(length)|.
|
||||||
|
sdu = b''
|
||||||
if pdu.iso_sdu_length:
|
if pdu.iso_sdu_length:
|
||||||
stdin.write(struct.pack('<H', pdu.iso_sdu_length))
|
sdu = struct.pack('<H', pdu.iso_sdu_length)
|
||||||
stdin.write(pdu.iso_sdu_fragment)
|
sdu += pdu.iso_sdu_fragment
|
||||||
|
file_outputs[ase].write(sdu)
|
||||||
|
|
||||||
def on_cis(cis_link: CisLink):
|
def on_ase_state_change(
|
||||||
cis_link.on('pdu', on_pdu)
|
state: AseStateMachine.State,
|
||||||
|
ase: AseStateMachine,
|
||||||
|
) -> None:
|
||||||
|
if state != AseStateMachine.State.STREAMING:
|
||||||
|
if file_output := file_outputs.pop(ase):
|
||||||
|
file_output.close()
|
||||||
|
else:
|
||||||
|
file_output = open(f'{datetime.datetime.now().isoformat()}.lc3', 'wb')
|
||||||
|
codec_configuration = ase.codec_specific_configuration
|
||||||
|
assert isinstance(codec_configuration, CodecSpecificConfiguration)
|
||||||
|
# Write a LC3 header.
|
||||||
|
file_output.write(
|
||||||
|
bytes([0x1C, 0xCC]) # Header.
|
||||||
|
+ struct.pack(
|
||||||
|
'<HHHHHHI',
|
||||||
|
18, # Header length.
|
||||||
|
codec_configuration.sampling_frequency.hz
|
||||||
|
// 100, # Sampling Rate(/100Hz).
|
||||||
|
0, # Bitrate(unused).
|
||||||
|
bin(codec_configuration.audio_channel_allocation).count(
|
||||||
|
'1'
|
||||||
|
), # Channels.
|
||||||
|
codec_configuration.frame_duration.us
|
||||||
|
// 10, # Frame duration(/10us).
|
||||||
|
0, # RFU.
|
||||||
|
0x0FFFFFFF, # Frame counts.
|
||||||
|
)
|
||||||
|
)
|
||||||
|
file_outputs[ase] = file_output
|
||||||
|
assert ase.cis_link
|
||||||
|
ase.cis_link.sink = functools.partial(on_pdu, ase)
|
||||||
|
|
||||||
device.once('cis_establishment', on_cis)
|
for ase in ascs.ase_state_machines.values():
|
||||||
|
ase.on(
|
||||||
|
'state_change',
|
||||||
|
functools.partial(on_ase_state_change, ase=ase),
|
||||||
|
)
|
||||||
|
|
||||||
advertising_set = await device.create_advertising_set(
|
await device.create_advertising_set(
|
||||||
advertising_data=advertising_data,
|
advertising_data=advertising_data,
|
||||||
|
auto_restart=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
await hci_transport.source.terminated
|
await hci_transport.source.terminated
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from bumble.hci import (
|
|||||||
OwnAddressType,
|
OwnAddressType,
|
||||||
)
|
)
|
||||||
from bumble.profiles.bap import (
|
from bumble.profiles.bap import (
|
||||||
|
UnicastServerAdvertisingData,
|
||||||
CodecSpecificCapabilities,
|
CodecSpecificCapabilities,
|
||||||
ContextType,
|
ContextType,
|
||||||
AudioLocation,
|
AudioLocation,
|
||||||
@@ -101,7 +102,7 @@ async def main() -> None:
|
|||||||
supported_frame_durations=(
|
supported_frame_durations=(
|
||||||
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
||||||
),
|
),
|
||||||
supported_audio_channel_counts=[1],
|
supported_audio_channel_count=[1],
|
||||||
min_octets_per_codec_frame=120,
|
min_octets_per_codec_frame=120,
|
||||||
max_octets_per_codec_frame=120,
|
max_octets_per_codec_frame=120,
|
||||||
supported_max_codec_frames_per_sdu=1,
|
supported_max_codec_frames_per_sdu=1,
|
||||||
@@ -151,6 +152,7 @@ async def main() -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
+ csis.get_advertising_data()
|
+ csis.get_advertising_data()
|
||||||
|
+ bytes(UnicastServerAdvertisingData())
|
||||||
)
|
)
|
||||||
|
|
||||||
await device.create_advertising_set(
|
await device.create_advertising_set(
|
||||||
|
|||||||
+7
-1
@@ -56,13 +56,19 @@ class SocketClient(private val viewModel: AppViewModel, private val socket: Blue
|
|||||||
|
|
||||||
thread {
|
thread {
|
||||||
socketDataSource.receive()
|
socketDataSource.receive()
|
||||||
|
socket.close()
|
||||||
|
sender.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.info("Startup delay: $DEFAULT_STARTUP_DELAY")
|
Log.info("Startup delay: $DEFAULT_STARTUP_DELAY")
|
||||||
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
||||||
Log.info("Starting to send")
|
Log.info("Starting to send")
|
||||||
|
|
||||||
sender.run()
|
try {
|
||||||
|
sender.run()
|
||||||
|
} catch (error: IOException) {
|
||||||
|
Log.info("run ended abruptly")
|
||||||
|
}
|
||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -1,7 +1,10 @@
|
|||||||
# Next
|
# 0.2.0
|
||||||
|
|
||||||
- Code-gen company ID table
|
- Code-gen company ID table
|
||||||
|
- Unstable support for extended advertisements
|
||||||
|
- CLI tools for downloading Realtek firmware
|
||||||
|
- PDL-generated types for HCI commands
|
||||||
|
|
||||||
# 0.1.0
|
# 0.1.0
|
||||||
|
|
||||||
- Initial release
|
- Initial release
|
||||||
|
|||||||
Generated
+1
-1
@@ -182,7 +182,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumble"
|
name = "bumble"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "bumble"
|
name = "bumble"
|
||||||
description = "Rust API for the Bumble Bluetooth stack"
|
description = "Rust API for the Bumble Bluetooth stack"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
homepage = "https://google.github.io/bumble/index.html"
|
homepage = "https://google.github.io/bumble/index.html"
|
||||||
@@ -10,7 +10,7 @@ documentation = "https://docs.rs/crate/bumble"
|
|||||||
authors = ["Marshall Pierce <marshallpierce@google.com>"]
|
authors = ["Marshall Pierce <marshallpierce@google.com>"]
|
||||||
keywords = ["bluetooth", "ble"]
|
keywords = ["bluetooth", "ble"]
|
||||||
categories = ["api-bindings", "network-programming"]
|
categories = ["api-bindings", "network-programming"]
|
||||||
rust-version = "1.70.0"
|
rust-version = "1.76.0"
|
||||||
|
|
||||||
# https://github.com/frewsxcv/cargo-all-features#options
|
# https://github.com/frewsxcv/cargo-all-features#options
|
||||||
[package.metadata.cargo-all-features]
|
[package.metadata.cargo-all-features]
|
||||||
|
|||||||
+6
-1
@@ -37,6 +37,11 @@ PYTHONPATH=..:[virtualenv site-packages] \
|
|||||||
cargo run --features bumble-tools --bin bumble -- --help
|
cargo run --features bumble-tools --bin bumble -- --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Notable subcommands:
|
||||||
|
|
||||||
|
- `firmware realtek download`: download Realtek firmware for various chipsets so that it can be automatically loaded when needed
|
||||||
|
- `usb probe`: show USB devices, highlighting the ones usable for Bluetooth
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
Run the tests:
|
Run the tests:
|
||||||
@@ -63,4 +68,4 @@ To regenerate the assigned number tables based on the Python codebase:
|
|||||||
|
|
||||||
```
|
```
|
||||||
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features dev-tools
|
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features dev-tools
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ impl Controller {
|
|||||||
/// module specifies the defaults. Must be called from a thread with a Python event loop, which
|
/// module specifies the defaults. Must be called from a thread with a Python event loop, which
|
||||||
/// should be true on `tokio::main` and `async_std::main`.
|
/// should be true on `tokio::main` and `async_std::main`.
|
||||||
///
|
///
|
||||||
/// For more info, see https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars.
|
/// For more info, see <https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars>.
|
||||||
pub async fn new(
|
pub async fn new(
|
||||||
name: &str,
|
name: &str,
|
||||||
host_source: Option<TransportSource>,
|
host_source: Option<TransportSource>,
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ impl ToPyObject for Address {
|
|||||||
|
|
||||||
/// An error meaning that the u64 value did not represent a valid BT address.
|
/// An error meaning that the u64 value did not represent a valid BT address.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct InvalidAddress(u64);
|
pub struct InvalidAddress(#[allow(unused)] u64);
|
||||||
|
|
||||||
impl TryInto<packets::Address> for Address {
|
impl TryInto<packets::Address> for Address {
|
||||||
type Error = ConversionError<InvalidAddress>;
|
type Error = ConversionError<InvalidAddress>;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ impl LeConnectionOrientedChannel {
|
|||||||
/// Must be called from a thread with a Python event loop, which should be true on
|
/// Must be called from a thread with a Python event loop, which should be true on
|
||||||
/// `tokio::main` and `async_std::main`.
|
/// `tokio::main` and `async_std::main`.
|
||||||
///
|
///
|
||||||
/// For more info, see https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars.
|
/// For more info, see <https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars>.
|
||||||
pub async fn disconnect(&mut self) -> PyResult<()> {
|
pub async fn disconnect(&mut self) -> PyResult<()> {
|
||||||
Python::with_gil(|py| {
|
Python::with_gil(|py| {
|
||||||
self.0
|
self.0
|
||||||
|
|||||||
@@ -33,18 +33,17 @@ include_package_data = True
|
|||||||
install_requires =
|
install_requires =
|
||||||
aiohttp ~= 3.8; platform_system!='Emscripten'
|
aiohttp ~= 3.8; platform_system!='Emscripten'
|
||||||
appdirs >= 1.4; platform_system!='Emscripten'
|
appdirs >= 1.4; platform_system!='Emscripten'
|
||||||
bt-test-interfaces >= 0.0.2; platform_system!='Emscripten'
|
click >= 8.1.3; platform_system!='Emscripten'
|
||||||
click == 8.1.3; platform_system!='Emscripten'
|
|
||||||
cryptography == 39; platform_system!='Emscripten'
|
cryptography == 39; platform_system!='Emscripten'
|
||||||
# Pyodide bundles a version of cryptography that is built for wasm, which may not match the
|
# Pyodide bundles a version of cryptography that is built for wasm, which may not match the
|
||||||
# versions available on PyPI. Relax the version requirement since it's better than being
|
# versions available on PyPI. Relax the version requirement since it's better than being
|
||||||
# completely unable to import the package in case of version mismatch.
|
# completely unable to import the package in case of version mismatch.
|
||||||
cryptography >= 39.0; platform_system=='Emscripten'
|
cryptography >= 39.0; platform_system=='Emscripten'
|
||||||
grpcio == 1.57.0; platform_system!='Emscripten'
|
grpcio >= 1.62.1; platform_system!='Emscripten'
|
||||||
humanize >= 4.6.0; platform_system!='Emscripten'
|
humanize >= 4.6.0; platform_system!='Emscripten'
|
||||||
libusb1 >= 2.0.1; platform_system!='Emscripten'
|
libusb1 >= 2.0.1; platform_system!='Emscripten'
|
||||||
libusb-package == 1.0.26.1; platform_system!='Emscripten'
|
libusb-package == 1.0.26.1; platform_system!='Emscripten'
|
||||||
platformdirs == 3.10.0; platform_system!='Emscripten'
|
platformdirs >= 3.10.0; platform_system!='Emscripten'
|
||||||
prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
|
prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
|
||||||
prettytable >= 3.6.0; platform_system!='Emscripten'
|
prettytable >= 3.6.0; platform_system!='Emscripten'
|
||||||
protobuf >= 3.12.4; platform_system!='Emscripten'
|
protobuf >= 3.12.4; platform_system!='Emscripten'
|
||||||
@@ -63,6 +62,7 @@ console_scripts =
|
|||||||
bumble-gatt-dump = bumble.apps.gatt_dump:main
|
bumble-gatt-dump = bumble.apps.gatt_dump:main
|
||||||
bumble-hci-bridge = bumble.apps.hci_bridge:main
|
bumble-hci-bridge = bumble.apps.hci_bridge:main
|
||||||
bumble-l2cap-bridge = bumble.apps.l2cap_bridge:main
|
bumble-l2cap-bridge = bumble.apps.l2cap_bridge:main
|
||||||
|
bumble-rfcomm-bridge = bumble.apps.rfcomm_bridge:main
|
||||||
bumble-pair = bumble.apps.pair:main
|
bumble-pair = bumble.apps.pair:main
|
||||||
bumble-scan = bumble.apps.scan:main
|
bumble-scan = bumble.apps.scan:main
|
||||||
bumble-show = bumble.apps.show:main
|
bumble-show = bumble.apps.show:main
|
||||||
@@ -82,24 +82,27 @@ console_scripts =
|
|||||||
build =
|
build =
|
||||||
build >= 0.7
|
build >= 0.7
|
||||||
test =
|
test =
|
||||||
pytest >= 8.0
|
pytest >= 8.2
|
||||||
pytest-asyncio == 0.21.1
|
pytest-asyncio >= 0.23.5
|
||||||
pytest-html >= 3.2.0
|
pytest-html >= 3.2.0
|
||||||
coverage >= 6.4
|
coverage >= 6.4
|
||||||
development =
|
development =
|
||||||
black == 22.10
|
black == 24.3
|
||||||
grpcio-tools >= 1.57.0
|
grpcio-tools >= 1.62.1
|
||||||
invoke >= 1.7.3
|
invoke >= 1.7.3
|
||||||
mypy == 1.8.0
|
mypy == 1.10.0
|
||||||
nox >= 2022
|
nox >= 2022
|
||||||
pylint == 2.15.8
|
pylint == 3.1.0
|
||||||
pyyaml >= 6.0
|
pyyaml >= 6.0
|
||||||
types-appdirs >= 1.4.3
|
types-appdirs >= 1.4.3
|
||||||
types-invoke >= 1.7.3
|
types-invoke >= 1.7.3
|
||||||
types-protobuf >= 4.21.0
|
types-protobuf >= 4.21.0
|
||||||
|
wasmtime == 20.0.0
|
||||||
avatar =
|
avatar =
|
||||||
pandora-avatar == 0.0.5
|
pandora-avatar == 0.0.9
|
||||||
rootcanal == 1.7.0 ; python_version>='3.10'
|
rootcanal == 1.10.0 ; python_version>='3.10'
|
||||||
|
pandora =
|
||||||
|
bt-test-interfaces >= 0.0.6
|
||||||
documentation =
|
documentation =
|
||||||
mkdocs >= 1.4.0
|
mkdocs >= 1.4.0
|
||||||
mkdocs-material >= 8.5.6
|
mkdocs-material >= 8.5.6
|
||||||
|
|||||||
+4
-4
@@ -72,7 +72,7 @@ def test_codec_specific_capabilities() -> None:
|
|||||||
cap = CodecSpecificCapabilities(
|
cap = CodecSpecificCapabilities(
|
||||||
supported_sampling_frequencies=SAMPLE_FREQUENCY,
|
supported_sampling_frequencies=SAMPLE_FREQUENCY,
|
||||||
supported_frame_durations=FRAME_SURATION,
|
supported_frame_durations=FRAME_SURATION,
|
||||||
supported_audio_channel_counts=AUDIO_CHANNEL_COUNTS,
|
supported_audio_channel_count=AUDIO_CHANNEL_COUNTS,
|
||||||
min_octets_per_codec_frame=40,
|
min_octets_per_codec_frame=40,
|
||||||
max_octets_per_codec_frame=40,
|
max_octets_per_codec_frame=40,
|
||||||
supported_max_codec_frames_per_sdu=1,
|
supported_max_codec_frames_per_sdu=1,
|
||||||
@@ -88,7 +88,7 @@ def test_pac_record() -> None:
|
|||||||
cap = CodecSpecificCapabilities(
|
cap = CodecSpecificCapabilities(
|
||||||
supported_sampling_frequencies=SAMPLE_FREQUENCY,
|
supported_sampling_frequencies=SAMPLE_FREQUENCY,
|
||||||
supported_frame_durations=FRAME_SURATION,
|
supported_frame_durations=FRAME_SURATION,
|
||||||
supported_audio_channel_counts=AUDIO_CHANNEL_COUNTS,
|
supported_audio_channel_count=AUDIO_CHANNEL_COUNTS,
|
||||||
min_octets_per_codec_frame=40,
|
min_octets_per_codec_frame=40,
|
||||||
max_octets_per_codec_frame=40,
|
max_octets_per_codec_frame=40,
|
||||||
supported_max_codec_frames_per_sdu=1,
|
supported_max_codec_frames_per_sdu=1,
|
||||||
@@ -216,7 +216,7 @@ async def test_pacs():
|
|||||||
supported_frame_durations=(
|
supported_frame_durations=(
|
||||||
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
||||||
),
|
),
|
||||||
supported_audio_channel_counts=[1],
|
supported_audio_channel_count=[1],
|
||||||
min_octets_per_codec_frame=40,
|
min_octets_per_codec_frame=40,
|
||||||
max_octets_per_codec_frame=40,
|
max_octets_per_codec_frame=40,
|
||||||
supported_max_codec_frames_per_sdu=1,
|
supported_max_codec_frames_per_sdu=1,
|
||||||
@@ -232,7 +232,7 @@ async def test_pacs():
|
|||||||
supported_frame_durations=(
|
supported_frame_durations=(
|
||||||
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
||||||
),
|
),
|
||||||
supported_audio_channel_counts=[1],
|
supported_audio_channel_count=[1],
|
||||||
min_octets_per_codec_frame=60,
|
min_octets_per_codec_frame=60,
|
||||||
max_octets_per_codec_frame=60,
|
max_octets_per_codec_frame=60,
|
||||||
supported_max_codec_frames_per_sdu=1,
|
supported_max_codec_frames_per_sdu=1,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from bumble.core import AdvertisingData, UUID, get_dict_key_by_value
|
from bumble.core import AdvertisingData, UUID, get_dict_key_by_value
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def test_ad_data():
|
def test_ad_data():
|
||||||
data = bytes([2, AdvertisingData.TX_POWER_LEVEL, 123])
|
data = bytes([2, AdvertisingData.TX_POWER_LEVEL, 123])
|
||||||
|
|||||||
+97
-2
@@ -16,6 +16,7 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from types import LambdaType
|
from types import LambdaType
|
||||||
@@ -35,12 +36,14 @@ from bumble.hci import (
|
|||||||
HCI_COMMAND_STATUS_PENDING,
|
HCI_COMMAND_STATUS_PENDING,
|
||||||
HCI_CREATE_CONNECTION_COMMAND,
|
HCI_CREATE_CONNECTION_COMMAND,
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
|
HCI_CONNECTION_FAILED_TO_BE_ESTABLISHED_ERROR,
|
||||||
Address,
|
Address,
|
||||||
OwnAddressType,
|
OwnAddressType,
|
||||||
HCI_Command_Complete_Event,
|
HCI_Command_Complete_Event,
|
||||||
HCI_Command_Status_Event,
|
HCI_Command_Status_Event,
|
||||||
HCI_Connection_Complete_Event,
|
HCI_Connection_Complete_Event,
|
||||||
HCI_Connection_Request_Event,
|
HCI_Connection_Request_Event,
|
||||||
|
HCI_Error,
|
||||||
HCI_Packet,
|
HCI_Packet,
|
||||||
)
|
)
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
@@ -52,6 +55,10 @@ from bumble.gatt import (
|
|||||||
|
|
||||||
from .test_utils import TwoDevices, async_barrier
|
from .test_utils import TwoDevices, async_barrier
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
_TIMEOUT = 0.1
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -214,6 +221,12 @@ async def test_device_connect_parallel():
|
|||||||
d1.host.set_packet_sink(Sink(d1_flow()))
|
d1.host.set_packet_sink(Sink(d1_flow()))
|
||||||
d2.host.set_packet_sink(Sink(d2_flow()))
|
d2.host.set_packet_sink(Sink(d2_flow()))
|
||||||
|
|
||||||
|
d1_accept_task = asyncio.create_task(d1.accept(peer_address=d0.public_address))
|
||||||
|
d2_accept_task = asyncio.create_task(d2.accept())
|
||||||
|
|
||||||
|
# Ensure that the accept tasks have started.
|
||||||
|
await async_barrier()
|
||||||
|
|
||||||
[c01, c02, a10, a20] = await asyncio.gather(
|
[c01, c02, a10, a20] = await asyncio.gather(
|
||||||
*[
|
*[
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
@@ -222,8 +235,8 @@ async def test_device_connect_parallel():
|
|||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
d0.connect(d2.public_address, transport=BT_BR_EDR_TRANSPORT)
|
d0.connect(d2.public_address, transport=BT_BR_EDR_TRANSPORT)
|
||||||
),
|
),
|
||||||
asyncio.create_task(d1.accept(peer_address=d0.public_address)),
|
d1_accept_task,
|
||||||
asyncio.create_task(d2.accept()),
|
d2_accept_task,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -385,6 +398,29 @@ async def test_get_remote_le_features():
|
|||||||
assert (await devices.connections[0].get_remote_le_features()) is not None
|
assert (await devices.connections[0].get_remote_le_features()) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_remote_le_features_failed():
|
||||||
|
devices = TwoDevices()
|
||||||
|
await devices.setup_connection()
|
||||||
|
|
||||||
|
def on_hci_le_read_remote_features_complete_event(event):
|
||||||
|
devices[0].host.emit(
|
||||||
|
'le_remote_features_failure',
|
||||||
|
event.connection_handle,
|
||||||
|
HCI_CONNECTION_FAILED_TO_BE_ESTABLISHED_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
devices[0].host.on_hci_le_read_remote_features_complete_event = (
|
||||||
|
on_hci_le_read_remote_features_complete_event
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HCI_Error):
|
||||||
|
await asyncio.wait_for(
|
||||||
|
devices.connections[0].get_remote_le_features(), _TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cis():
|
async def test_cis():
|
||||||
@@ -433,6 +469,65 @@ async def test_cis():
|
|||||||
await cis_links[1].disconnect()
|
await cis_links[1].disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cis_setup_failure():
|
||||||
|
devices = TwoDevices()
|
||||||
|
await devices.setup_connection()
|
||||||
|
|
||||||
|
cis_requests = asyncio.Queue()
|
||||||
|
|
||||||
|
def on_cis_request(
|
||||||
|
acl_connection: Connection,
|
||||||
|
cis_handle: int,
|
||||||
|
cig_id: int,
|
||||||
|
cis_id: int,
|
||||||
|
):
|
||||||
|
del acl_connection, cig_id, cis_id
|
||||||
|
cis_requests.put_nowait(cis_handle)
|
||||||
|
|
||||||
|
devices[1].on('cis_request', on_cis_request)
|
||||||
|
|
||||||
|
cis_handles = await devices[0].setup_cig(
|
||||||
|
cig_id=1,
|
||||||
|
cis_id=[2],
|
||||||
|
sdu_interval=(0, 0),
|
||||||
|
framing=0,
|
||||||
|
max_sdu=(0, 0),
|
||||||
|
retransmission_number=0,
|
||||||
|
max_transport_latency=(0, 0),
|
||||||
|
)
|
||||||
|
assert len(cis_handles) == 1
|
||||||
|
|
||||||
|
cis_create_task = asyncio.create_task(
|
||||||
|
devices[0].create_cis(
|
||||||
|
[
|
||||||
|
(cis_handles[0], devices.connections[0].handle),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_hci_le_cis_established_event(host, event):
|
||||||
|
host.emit(
|
||||||
|
'cis_establishment_failure',
|
||||||
|
event.connection_handle,
|
||||||
|
HCI_CONNECTION_FAILED_TO_BE_ESTABLISHED_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
device.host.on_hci_le_cis_established_event = functools.partial(
|
||||||
|
on_hci_le_cis_established_event, device.host
|
||||||
|
)
|
||||||
|
|
||||||
|
cis_request = await asyncio.wait_for(cis_requests.get(), _TIMEOUT)
|
||||||
|
|
||||||
|
with pytest.raises(HCI_Error):
|
||||||
|
await asyncio.wait_for(devices[1].accept_cis_request(cis_request), _TIMEOUT)
|
||||||
|
|
||||||
|
with pytest.raises(HCI_Error):
|
||||||
|
await asyncio.wait_for(cis_create_task, _TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def test_gatt_services_with_gas():
|
def test_gatt_services_with_gas():
|
||||||
device = Device(host=Host(None, None))
|
device = Device(host=Host(None, None))
|
||||||
|
|||||||
+444
-26
@@ -19,8 +19,9 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
from typing import Tuple
|
from typing import Tuple, Optional
|
||||||
|
|
||||||
from .test_utils import TwoDevices
|
from .test_utils import TwoDevices
|
||||||
from bumble import core
|
from bumble import core
|
||||||
@@ -35,10 +36,94 @@ from bumble import hci
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _default_hf_configuration() -> hfp.HfConfiguration:
|
||||||
|
return hfp.HfConfiguration(
|
||||||
|
supported_hf_features=[
|
||||||
|
hfp.HfFeature.CODEC_NEGOTIATION,
|
||||||
|
hfp.HfFeature.ESCO_S4_SETTINGS_SUPPORTED,
|
||||||
|
hfp.HfFeature.HF_INDICATORS,
|
||||||
|
hfp.HfFeature.ENHANCED_CALL_STATUS,
|
||||||
|
hfp.HfFeature.THREE_WAY_CALLING,
|
||||||
|
hfp.HfFeature.CLI_PRESENTATION_CAPABILITY,
|
||||||
|
],
|
||||||
|
supported_hf_indicators=[
|
||||||
|
hfp.HfIndicator.ENHANCED_SAFETY,
|
||||||
|
hfp.HfIndicator.BATTERY_LEVEL,
|
||||||
|
],
|
||||||
|
supported_audio_codecs=[
|
||||||
|
hfp.AudioCodec.CVSD,
|
||||||
|
hfp.AudioCodec.MSBC,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _default_hf_sdp_features() -> hfp.HfSdpFeature:
|
||||||
|
return (
|
||||||
|
hfp.HfSdpFeature.WIDE_BAND
|
||||||
|
| hfp.HfSdpFeature.THREE_WAY_CALLING
|
||||||
|
| hfp.HfSdpFeature.CLI_PRESENTATION_CAPABILITY
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _default_ag_configuration() -> hfp.AgConfiguration:
|
||||||
|
return hfp.AgConfiguration(
|
||||||
|
supported_ag_features=[
|
||||||
|
hfp.AgFeature.HF_INDICATORS,
|
||||||
|
hfp.AgFeature.IN_BAND_RING_TONE_CAPABILITY,
|
||||||
|
hfp.AgFeature.REJECT_CALL,
|
||||||
|
hfp.AgFeature.CODEC_NEGOTIATION,
|
||||||
|
hfp.AgFeature.ESCO_S4_SETTINGS_SUPPORTED,
|
||||||
|
hfp.AgFeature.ENHANCED_CALL_STATUS,
|
||||||
|
hfp.AgFeature.THREE_WAY_CALLING,
|
||||||
|
],
|
||||||
|
supported_ag_indicators=[
|
||||||
|
hfp.AgIndicatorState.call(),
|
||||||
|
hfp.AgIndicatorState.service(),
|
||||||
|
hfp.AgIndicatorState.callsetup(),
|
||||||
|
hfp.AgIndicatorState.callsetup(),
|
||||||
|
hfp.AgIndicatorState.signal(),
|
||||||
|
hfp.AgIndicatorState.roam(),
|
||||||
|
hfp.AgIndicatorState.battchg(),
|
||||||
|
],
|
||||||
|
supported_hf_indicators=[
|
||||||
|
hfp.HfIndicator.ENHANCED_SAFETY,
|
||||||
|
hfp.HfIndicator.BATTERY_LEVEL,
|
||||||
|
],
|
||||||
|
supported_ag_call_hold_operations=[
|
||||||
|
hfp.CallHoldOperation.ADD_HELD_CALL,
|
||||||
|
hfp.CallHoldOperation.HOLD_ALL_ACTIVE_CALLS,
|
||||||
|
hfp.CallHoldOperation.HOLD_ALL_CALLS_EXCEPT,
|
||||||
|
hfp.CallHoldOperation.RELEASE_ALL_ACTIVE_CALLS,
|
||||||
|
hfp.CallHoldOperation.RELEASE_ALL_HELD_CALLS,
|
||||||
|
hfp.CallHoldOperation.RELEASE_SPECIFIC_CALL,
|
||||||
|
hfp.CallHoldOperation.CONNECT_TWO_CALLS,
|
||||||
|
],
|
||||||
|
supported_audio_codecs=[hfp.AudioCodec.CVSD, hfp.AudioCodec.MSBC],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _default_ag_sdp_features() -> hfp.AgSdpFeature:
|
||||||
|
return (
|
||||||
|
hfp.AgSdpFeature.WIDE_BAND
|
||||||
|
| hfp.AgSdpFeature.IN_BAND_RING_TONE_CAPABILITY
|
||||||
|
| hfp.AgSdpFeature.THREE_WAY_CALLING
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def make_hfp_connections(
|
async def make_hfp_connections(
|
||||||
hf_config: hfp.Configuration,
|
hf_config: Optional[hfp.HfConfiguration] = None,
|
||||||
) -> Tuple[hfp.HfProtocol, hfp.HfpProtocol]:
|
ag_config: Optional[hfp.AgConfiguration] = None,
|
||||||
|
):
|
||||||
|
if not hf_config:
|
||||||
|
hf_config = _default_hf_configuration()
|
||||||
|
if not ag_config:
|
||||||
|
ag_config = _default_ag_configuration()
|
||||||
|
|
||||||
# Setup devices
|
# Setup devices
|
||||||
devices = TwoDevices()
|
devices = TwoDevices()
|
||||||
await devices.setup_connection()
|
await devices.setup_connection()
|
||||||
@@ -55,38 +140,371 @@ async def make_hfp_connections(
|
|||||||
|
|
||||||
# Setup HFP connection
|
# Setup HFP connection
|
||||||
hf = hfp.HfProtocol(client_dlc, hf_config)
|
hf = hfp.HfProtocol(client_dlc, hf_config)
|
||||||
ag = hfp.HfpProtocol(server_dlc)
|
ag = hfp.AgProtocol(server_dlc, ag_config)
|
||||||
return hf, ag
|
|
||||||
|
await hf.initiate_slc()
|
||||||
|
return (hf, ag)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def hfp_connections():
|
||||||
|
hf, ag = await make_hfp_connections()
|
||||||
|
hf_loop_task = asyncio.create_task(hf.run())
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield (hf, ag)
|
||||||
|
finally:
|
||||||
|
# Close the coroutine.
|
||||||
|
hf.unsolicited_queue.put_nowait(None)
|
||||||
|
await hf_loop_task
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_slc():
|
async def test_slc_with_minimal_features():
|
||||||
hf_config = hfp.Configuration(
|
hf, ag = await make_hfp_connections(
|
||||||
supported_hf_features=[], supported_hf_indicators=[], supported_audio_codecs=[]
|
hfp.HfConfiguration(
|
||||||
)
|
supported_audio_codecs=[],
|
||||||
hf, ag = await make_hfp_connections(hf_config)
|
supported_hf_features=[],
|
||||||
|
supported_hf_indicators=[],
|
||||||
async def ag_loop():
|
),
|
||||||
while line := await ag.next_line():
|
hfp.AgConfiguration(
|
||||||
if line.startswith('AT+BRSF'):
|
supported_ag_call_hold_operations=[],
|
||||||
ag.send_response_line('+BRSF: 0')
|
supported_ag_features=[],
|
||||||
elif line.startswith('AT+CIND=?'):
|
supported_ag_indicators=[
|
||||||
ag.send_response_line(
|
hfp.AgIndicatorState(
|
||||||
'+CIND: ("call",(0,1)),("callsetup",(0-3)),("service",(0-1)),'
|
indicator=hfp.AgIndicator.CALL,
|
||||||
'("signal",(0-5)),("roam",(0,1)),("battchg",(0-5)),'
|
supported_values={0, 1},
|
||||||
'("callheld",(0-2))'
|
current_status=0,
|
||||||
)
|
)
|
||||||
elif line.startswith('AT+CIND?'):
|
],
|
||||||
ag.send_response_line('+CIND: 0,0,1,4,1,5,0')
|
supported_hf_indicators=[],
|
||||||
ag.send_response_line('OK')
|
supported_audio_codecs=[],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
ag_task = asyncio.create_task(ag_loop())
|
assert hf.supported_ag_features == ag.supported_ag_features
|
||||||
|
assert hf.supported_hf_features == ag.supported_hf_features
|
||||||
|
assert hf.supported_ag_call_hold_operations == ag.supported_ag_call_hold_operations
|
||||||
|
for a, b in zip(hf.ag_indicators, ag.ag_indicators):
|
||||||
|
assert a.indicator == b.indicator
|
||||||
|
assert a.current_status == b.current_status
|
||||||
|
|
||||||
await hf.initiate_slc()
|
|
||||||
ag_task.cancel()
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_slc(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
|
||||||
|
assert hf.supported_ag_features == ag.supported_ag_features
|
||||||
|
assert hf.supported_hf_features == ag.supported_hf_features
|
||||||
|
assert hf.supported_ag_call_hold_operations == ag.supported_ag_call_hold_operations
|
||||||
|
for a, b in zip(hf.ag_indicators, ag.ag_indicators):
|
||||||
|
assert a.indicator == b.indicator
|
||||||
|
assert a.current_status == b.current_status
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ag_indicator(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
|
||||||
|
future = asyncio.get_running_loop().create_future()
|
||||||
|
hf.on('ag_indicator', future.set_result)
|
||||||
|
|
||||||
|
ag.update_ag_indicator(hfp.AgIndicator.CALL, 1)
|
||||||
|
|
||||||
|
indicator: hfp.AgIndicatorState = await future
|
||||||
|
assert indicator.current_status == 1
|
||||||
|
assert indicator.indicator == hfp.AgIndicator.CALL
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hf_indicator(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
|
||||||
|
future = asyncio.get_running_loop().create_future()
|
||||||
|
ag.on('hf_indicator', future.set_result)
|
||||||
|
|
||||||
|
await hf.execute_command('AT+BIEV=2,100')
|
||||||
|
|
||||||
|
indicator: hfp.HfIndicatorState = await future
|
||||||
|
assert indicator.current_status == 100
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_codec_negotiation(
|
||||||
|
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
|
||||||
|
):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
|
||||||
|
futures = [
|
||||||
|
asyncio.get_running_loop().create_future(),
|
||||||
|
asyncio.get_running_loop().create_future(),
|
||||||
|
]
|
||||||
|
hf.on('codec_negotiation', futures[0].set_result)
|
||||||
|
ag.on('codec_negotiation', futures[1].set_result)
|
||||||
|
await ag.negotiate_codec(hfp.AudioCodec.MSBC)
|
||||||
|
|
||||||
|
assert await futures[0] == await futures[1]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dial(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
NUMBER = 'ATD123456789'
|
||||||
|
|
||||||
|
future = asyncio.get_running_loop().create_future()
|
||||||
|
ag.on('dial', future.set_result)
|
||||||
|
await hf.execute_command(f'ATD{NUMBER}')
|
||||||
|
|
||||||
|
number: str = await future
|
||||||
|
assert number == NUMBER
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_answer(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
|
||||||
|
future = asyncio.get_running_loop().create_future()
|
||||||
|
ag.on('answer', lambda: future.set_result(None))
|
||||||
|
await hf.answer_incoming_call()
|
||||||
|
|
||||||
|
await future
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_incoming_call(
|
||||||
|
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
|
||||||
|
):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
|
||||||
|
future = asyncio.get_running_loop().create_future()
|
||||||
|
ag.on('hang_up', lambda: future.set_result(None))
|
||||||
|
await hf.reject_incoming_call()
|
||||||
|
|
||||||
|
await future
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terminate_call(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
|
||||||
|
future = asyncio.get_running_loop().create_future()
|
||||||
|
ag.on('hang_up', lambda: future.set_result(None))
|
||||||
|
await hf.terminate_call()
|
||||||
|
|
||||||
|
await future
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_calls_without_calls(
|
||||||
|
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
|
||||||
|
):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
|
||||||
|
assert await hf.query_current_calls() == []
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_calls_with_calls(
|
||||||
|
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
|
||||||
|
):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
ag.calls.append(
|
||||||
|
hfp.CallInfo(
|
||||||
|
index=1,
|
||||||
|
direction=hfp.CallInfoDirection.MOBILE_ORIGINATED_CALL,
|
||||||
|
status=hfp.CallInfoStatus.ACTIVE,
|
||||||
|
mode=hfp.CallInfoMode.VOICE,
|
||||||
|
multi_party=hfp.CallInfoMultiParty.NOT_IN_CONFERENCE,
|
||||||
|
number='123456789',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await hf.query_current_calls() == ag.calls
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"operation,",
|
||||||
|
(
|
||||||
|
hfp.CallHoldOperation.RELEASE_ALL_HELD_CALLS,
|
||||||
|
hfp.CallHoldOperation.RELEASE_ALL_ACTIVE_CALLS,
|
||||||
|
hfp.CallHoldOperation.HOLD_ALL_ACTIVE_CALLS,
|
||||||
|
hfp.CallHoldOperation.ADD_HELD_CALL,
|
||||||
|
hfp.CallHoldOperation.CONNECT_TWO_CALLS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def test_hold_call_without_call_index(
|
||||||
|
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol],
|
||||||
|
operation: hfp.CallHoldOperation,
|
||||||
|
):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
call_hold_future = asyncio.get_running_loop().create_future()
|
||||||
|
ag.on("call_hold", lambda op, index: call_hold_future.set_result((op, index)))
|
||||||
|
|
||||||
|
await hf.execute_command(f"AT+CHLD={operation.value}")
|
||||||
|
|
||||||
|
assert (await call_hold_future) == (operation, None)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"operation,",
|
||||||
|
(
|
||||||
|
hfp.CallHoldOperation.RELEASE_SPECIFIC_CALL,
|
||||||
|
hfp.CallHoldOperation.HOLD_ALL_CALLS_EXCEPT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def test_hold_call_with_call_index(
|
||||||
|
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol],
|
||||||
|
operation: hfp.CallHoldOperation,
|
||||||
|
):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
call_hold_future = asyncio.get_running_loop().create_future()
|
||||||
|
ag.on("call_hold", lambda op, index: call_hold_future.set_result((op, index)))
|
||||||
|
ag.calls.append(
|
||||||
|
hfp.CallInfo(
|
||||||
|
index=1,
|
||||||
|
direction=hfp.CallInfoDirection.MOBILE_ORIGINATED_CALL,
|
||||||
|
status=hfp.CallInfoStatus.ACTIVE,
|
||||||
|
mode=hfp.CallInfoMode.VOICE,
|
||||||
|
multi_party=hfp.CallInfoMultiParty.NOT_IN_CONFERENCE,
|
||||||
|
number='123456789',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await hf.execute_command(f"AT+CHLD={operation.value.replace('x', '1')}")
|
||||||
|
|
||||||
|
assert (await call_hold_future) == (operation, 1)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ring(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
ring_future = asyncio.get_running_loop().create_future()
|
||||||
|
hf.on("ring", lambda: ring_future.set_result(None))
|
||||||
|
|
||||||
|
ag.send_ring()
|
||||||
|
|
||||||
|
await ring_future
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_speaker_volume(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
speaker_volume_future = asyncio.get_running_loop().create_future()
|
||||||
|
hf.on("speaker_volume", speaker_volume_future.set_result)
|
||||||
|
|
||||||
|
ag.set_speaker_volume(10)
|
||||||
|
|
||||||
|
assert await speaker_volume_future == 10
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_microphone_volume(
|
||||||
|
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
|
||||||
|
):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
microphone_volume_future = asyncio.get_running_loop().create_future()
|
||||||
|
hf.on("microphone_volume", microphone_volume_future.set_result)
|
||||||
|
|
||||||
|
ag.set_microphone_volume(10)
|
||||||
|
|
||||||
|
assert await microphone_volume_future == 10
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cli_notification(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
cli_notification_future = asyncio.get_running_loop().create_future()
|
||||||
|
hf.on("cli_notification", cli_notification_future.set_result)
|
||||||
|
|
||||||
|
ag.send_cli_notification(
|
||||||
|
hfp.CallLineIdentification(number="\"123456789\"", type=129, alpha="\"Bumble\"")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await cli_notification_future == hfp.CallLineIdentification(
|
||||||
|
number="123456789", type=129, alpha="Bumble", subaddr="", satype=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_voice_recognition_from_hf(
|
||||||
|
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
|
||||||
|
):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
voice_recognition_future = asyncio.get_running_loop().create_future()
|
||||||
|
ag.on("voice_recognition", voice_recognition_future.set_result)
|
||||||
|
|
||||||
|
await hf.execute_command("AT+BVRA=1")
|
||||||
|
|
||||||
|
assert await voice_recognition_future == hfp.VoiceRecognitionState.ENABLE
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_voice_recognition_from_ag(
|
||||||
|
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
|
||||||
|
):
|
||||||
|
hf, ag = hfp_connections
|
||||||
|
voice_recognition_future = asyncio.get_running_loop().create_future()
|
||||||
|
hf.on("voice_recognition", voice_recognition_future.set_result)
|
||||||
|
|
||||||
|
ag.send_response("+BVRA: 1")
|
||||||
|
|
||||||
|
assert await voice_recognition_future == hfp.VoiceRecognitionState.ENABLE
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hf_sdp_record():
|
||||||
|
devices = TwoDevices()
|
||||||
|
await devices.setup_connection()
|
||||||
|
|
||||||
|
devices[0].sdp_service_records[1] = hfp.make_hf_sdp_records(
|
||||||
|
1, 2, _default_hf_configuration(), hfp.ProfileVersion.V1_8
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await hfp.find_hf_sdp_record(devices.connections[1]) == (
|
||||||
|
2,
|
||||||
|
hfp.ProfileVersion.V1_8,
|
||||||
|
_default_hf_sdp_features(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ag_sdp_record():
|
||||||
|
devices = TwoDevices()
|
||||||
|
await devices.setup_connection()
|
||||||
|
|
||||||
|
devices[0].sdp_service_records[1] = hfp.make_ag_sdp_records(
|
||||||
|
1, 2, _default_ag_configuration(), hfp.ProfileVersion.V1_8
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await hfp.find_ag_sdp_record(devices.connections[1]) == (
|
||||||
|
2,
|
||||||
|
hfp.ProfileVersion.V1_8,
|
||||||
|
_default_ag_sdp_features(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
+31
-1
@@ -32,6 +32,8 @@ from bumble.rfcomm import (
|
|||||||
RFCOMM_PSM,
|
RFCOMM_PSM,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_TIMEOUT = 0.1
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def basic_frame_check(x):
|
def basic_frame_check(x):
|
||||||
@@ -60,7 +62,7 @@ def test_frames():
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_basic_connection() -> None:
|
async def test_connection_and_disconnection() -> None:
|
||||||
devices = test_utils.TwoDevices()
|
devices = test_utils.TwoDevices()
|
||||||
await devices.setup_connection()
|
await devices.setup_connection()
|
||||||
|
|
||||||
@@ -81,6 +83,34 @@ async def test_basic_connection() -> None:
|
|||||||
dlcs[1].write(b'Lorem ipsum dolor sit amet')
|
dlcs[1].write(b'Lorem ipsum dolor sit amet')
|
||||||
assert await queues[0].get() == b'Lorem ipsum dolor sit amet'
|
assert await queues[0].get() == b'Lorem ipsum dolor sit amet'
|
||||||
|
|
||||||
|
closed = asyncio.Event()
|
||||||
|
dlcs[1].on('close', closed.set)
|
||||||
|
await dlcs[1].disconnect()
|
||||||
|
await closed.wait()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_receive_pdu_before_open_dlc_returns() -> None:
|
||||||
|
devices = await test_utils.TwoDevices.create_with_connection()
|
||||||
|
DATA = b'123'
|
||||||
|
|
||||||
|
accept_future: asyncio.Future[DLC] = asyncio.get_running_loop().create_future()
|
||||||
|
channel = Server(devices[0]).listen(acceptor=accept_future.set_result)
|
||||||
|
|
||||||
|
assert devices.connections[1]
|
||||||
|
multiplexer = await Client(devices.connections[1]).start()
|
||||||
|
open_dlc_task = asyncio.create_task(multiplexer.open_dlc(channel))
|
||||||
|
|
||||||
|
dlc_responder = await accept_future
|
||||||
|
dlc_responder.write(DATA)
|
||||||
|
|
||||||
|
dlc_initiator = await open_dlc_task
|
||||||
|
dlc_initiator_queue = asyncio.Queue() # type: ignore[var-annotated]
|
||||||
|
dlc_initiator.sink = dlc_initiator_queue.put_nowait
|
||||||
|
|
||||||
|
assert await asyncio.wait_for(dlc_initiator_queue.get(), timeout=_TIMEOUT) == DATA
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
+6
-6
@@ -316,13 +316,13 @@ async def _test_self_smp_with_configs(pairing_config1, pairing_config2):
|
|||||||
|
|
||||||
# Set up the pairing configs
|
# Set up the pairing configs
|
||||||
if pairing_config1:
|
if pairing_config1:
|
||||||
two_devices.devices[
|
two_devices.devices[0].pairing_config_factory = (
|
||||||
0
|
lambda connection: pairing_config1
|
||||||
].pairing_config_factory = lambda connection: pairing_config1
|
)
|
||||||
if pairing_config2:
|
if pairing_config2:
|
||||||
two_devices.devices[
|
two_devices.devices[1].pairing_config_factory = (
|
||||||
1
|
lambda connection: pairing_config2
|
||||||
].pairing_config_factory = lambda connection: pairing_config2
|
)
|
||||||
|
|
||||||
# Pair
|
# Pair
|
||||||
await two_devices.devices[0].pair(connection)
|
await two_devices.devices[0].pair(connection)
|
||||||
|
|||||||
+8
-1
@@ -16,7 +16,8 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import List, Optional
|
from typing import List, Optional, Type
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
from bumble.controller import Controller
|
from bumble.controller import Controller
|
||||||
from bumble.link import LocalLink
|
from bumble.link import LocalLink
|
||||||
@@ -81,6 +82,12 @@ class TwoDevices:
|
|||||||
def __getitem__(self, index: int) -> Device:
|
def __getitem__(self, index: int) -> Device:
|
||||||
return self.devices[index]
|
return self.devices[index]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create_with_connection(cls: Type[Self]) -> Self:
|
||||||
|
devices = cls()
|
||||||
|
await devices.setup_connection()
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def async_barrier():
|
async def async_barrier():
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user