forked from auracaster/bumble_mirror
Compare commits
146 Commits
v0.0.154
...
gbg/multi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2478d45673 | ||
|
|
1bc7d94111 | ||
|
|
6432414cd5 | ||
|
|
179064ba15 | ||
|
|
783b2d70a5 | ||
|
|
80824f3fc1 | ||
|
|
56139c622f | ||
|
|
da02f6a39b | ||
|
|
548d5597c0 | ||
|
|
7fd65d2412 | ||
|
|
05a54a4af9 | ||
|
|
1e00c8f456 | ||
|
|
90d165aa01 | ||
|
|
01603ca9e4 | ||
|
|
a1b6eb61f2 | ||
|
|
25f300d3ec | ||
|
|
41fe63df06 | ||
|
|
b312170d5f | ||
|
|
cf7f2e8f44 | ||
|
|
d292083ed1 | ||
|
|
9b11142b45 | ||
|
|
acdbc4d7b9 | ||
|
|
838d10a09d | ||
|
|
3852aa056b | ||
|
|
ae77e4528f | ||
|
|
9303f4fc5b | ||
|
|
8be9f4cb0e | ||
|
|
1ea12b1bf7 | ||
|
|
65e6d68355 | ||
|
|
9732eb8836 | ||
|
|
5ae668bc70 | ||
|
|
fd4d1bcca3 | ||
|
|
0a251c9f8e | ||
|
|
351d77be59 | ||
|
|
0e2fc80509 | ||
|
|
8f3fdecb93 | ||
|
|
249a205d8e | ||
|
|
7485801222 | ||
|
|
4678e59737 | ||
|
|
952d351c00 | ||
|
|
901eb55b0e | ||
|
|
727586e40e | ||
|
|
3aa678a58e | ||
|
|
fc7c1a8113 | ||
|
|
f62a0bbe75 | ||
|
|
7341172739 | ||
|
|
91b9fbe450 | ||
|
|
e6b566b848 | ||
|
|
2527a711dc | ||
|
|
5fba6b1cae | ||
|
|
43e632f83c | ||
|
|
623298b0e9 | ||
|
|
85a61dc39d | ||
|
|
6e8c44b5e6 | ||
|
|
ec4dcc174e | ||
|
|
b247aca3b4 | ||
|
|
6226bfd196 | ||
|
|
71e11b7cf8 | ||
|
|
800c62fdb6 | ||
|
|
640b9cd53a | ||
|
|
f4add16aea | ||
|
|
2bfec3c4ed | ||
|
|
9963b51c04 | ||
|
|
2af3494d8c | ||
|
|
fe28473ba8 | ||
|
|
53d66bc74a | ||
|
|
e2c1ad5342 | ||
|
|
6399c5fb04 | ||
|
|
784cf4f26a | ||
|
|
0301b1a999 | ||
|
|
3ab2cd5e71 | ||
|
|
6ea669531a | ||
|
|
cbbada4748 | ||
|
|
152b8d1233 | ||
|
|
bdad225033 | ||
|
|
8eeb58e467 | ||
|
|
91971433d2 | ||
|
|
a0a4bd457f | ||
|
|
4ffc050eed | ||
|
|
60678419a0 | ||
|
|
648dcc9305 | ||
|
|
190529184e | ||
|
|
46eb81466d | ||
|
|
9c70c487b9 | ||
|
|
43234d7c3e | ||
|
|
dbf878dc3f | ||
|
|
f6c0bd88d7 | ||
|
|
8440b7fbf1 | ||
|
|
808ab54135 | ||
|
|
52b29ad680 | ||
|
|
d41bf9c587 | ||
|
|
b758825164 | ||
|
|
779dfe5473 | ||
|
|
afb21220e2 | ||
|
|
f9a4c7518e | ||
|
|
bad2fdf69f | ||
|
|
a84df469cd | ||
|
|
03e33e39bd | ||
|
|
753fb69272 | ||
|
|
81a5f3a395 | ||
|
|
696a8d82fd | ||
|
|
5f294b1fea | ||
|
|
2d8f5e80fb | ||
|
|
7a042db78e | ||
|
|
41ce311836 | ||
|
|
03538d0f8a | ||
|
|
86bc222dc0 | ||
|
|
e8d285fdab | ||
|
|
852c933c92 | ||
|
|
7867a99a54 | ||
|
|
6cd14bb503 | ||
|
|
532b99ffea | ||
|
|
d80f40ff5d | ||
|
|
e9dc0d6855 | ||
|
|
b18104c9a7 | ||
|
|
50d1884365 | ||
|
|
78581cc36f | ||
|
|
b2c635768f | ||
|
|
bd8236a501 | ||
|
|
56594a0c2f | ||
|
|
4d2e821e50 | ||
|
|
7f987dc3cd | ||
|
|
689745040f | ||
|
|
809d4a18f5 | ||
|
|
54be8b328a | ||
|
|
57b469198a | ||
|
|
4d74339c04 | ||
|
|
39db278f2e | ||
|
|
a1327e910b | ||
|
|
ab4390fbde | ||
|
|
a118792279 | ||
|
|
df848b0f24 | ||
|
|
27fbb58447 | ||
|
|
7ec57d6d6a | ||
|
|
de706e9671 | ||
|
|
c425b87549 | ||
|
|
371ea07442 | ||
|
|
121b0a6a93 | ||
|
|
55a01033a0 | ||
|
|
7b7ef85b14 | ||
|
|
e6a623db93 | ||
|
|
b6e1d569d3 | ||
|
|
6826f68478 | ||
|
|
f80c83d0b3 | ||
|
|
3de35193bc | ||
|
|
740a2e0ca0 |
4
.github/workflows/code-check.yml
vendored
4
.github/workflows/code-check.yml
vendored
@@ -14,6 +14,10 @@ jobs:
|
|||||||
check:
|
check:
|
||||||
name: Check Code
|
name: Check Code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||||
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out from Git
|
- name: Check out from Git
|
||||||
|
|||||||
41
.github/workflows/python-build-test.yml
vendored
41
.github/workflows/python-build-test.yml
vendored
@@ -12,11 +12,11 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.8", "3.9", "3.10"]
|
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
||||||
|
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -41,3 +41,38 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
inv build
|
inv build
|
||||||
inv build.mkdocs
|
inv build.mkdocs
|
||||||
|
|
||||||
|
build-rust:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: [ "3.8", "3.9", "3.10", "3.11" ]
|
||||||
|
rust-version: [ "1.70.0", "stable" ]
|
||||||
|
fail-fast: false
|
||||||
|
steps:
|
||||||
|
- name: Check out from Git
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install ".[build,test,development,documentation]"
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
components: clippy,rustfmt
|
||||||
|
toolchain: ${{ matrix.rust-version }}
|
||||||
|
- name: Rust Build
|
||||||
|
run: cd rust && cargo build --all-targets && cargo build --all-features --all-targets
|
||||||
|
# Lints after build so what clippy needs is already built
|
||||||
|
- name: Rust Lints
|
||||||
|
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings && cargo clippy --all-features --all-targets -- --deny warnings
|
||||||
|
- name: Rust Tests
|
||||||
|
run: cd rust && cargo test
|
||||||
|
# At some point, hook up publishing the binary. For now, just make sure it builds.
|
||||||
|
# Once we're ready to publish binaries, this should be built with `--release`.
|
||||||
|
- name: Build Bumble CLI
|
||||||
|
run: cd rust && cargo build --features bumble-tools --bin bumble
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ __pycache__
|
|||||||
# generated by setuptools_scm
|
# generated by setuptools_scm
|
||||||
bumble/_version.py
|
bumble/_version.py
|
||||||
.vscode/launch.json
|
.vscode/launch.json
|
||||||
|
/.idea
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class ServerBridge:
|
|||||||
asyncio.create_task(self.pipe.l2cap_channel.disconnect())
|
asyncio.create_task(self.pipe.l2cap_channel.disconnect())
|
||||||
|
|
||||||
def data_received(self, data):
|
def data_received(self, data):
|
||||||
print(f'<<< Received on TCP: {len(data)}')
|
print(color(f'<<< [TCP DATA]: {len(data)} bytes', 'blue'))
|
||||||
self.pipe.l2cap_channel.write(data)
|
self.pipe.l2cap_channel.write(data)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -123,6 +123,7 @@ class ServerBridge:
|
|||||||
await self.l2cap_channel.disconnect()
|
await self.l2cap_channel.disconnect()
|
||||||
|
|
||||||
def on_l2cap_close(self):
|
def on_l2cap_close(self):
|
||||||
|
print(color('*** L2CAP channel closed', 'red'))
|
||||||
self.l2cap_channel = None
|
self.l2cap_channel = None
|
||||||
if self.tcp_transport is not None:
|
if self.tcp_transport is not None:
|
||||||
self.tcp_transport.close()
|
self.tcp_transport.close()
|
||||||
|
|||||||
48
apps/pair.py
48
apps/pair.py
@@ -157,6 +157,26 @@ class Delegate(PairingDelegate):
|
|||||||
self.print(f'### PIN: {number:0{digits}}')
|
self.print(f'### PIN: {number:0{digits}}')
|
||||||
self.print('###-----------------------------------')
|
self.print('###-----------------------------------')
|
||||||
|
|
||||||
|
async def get_string(self, max_length: int):
|
||||||
|
await self.update_peer_name()
|
||||||
|
|
||||||
|
# Prompt a PIN (for legacy pairing in classic)
|
||||||
|
self.print('###-----------------------------------')
|
||||||
|
self.print(f'### Pairing with {self.peer_name}')
|
||||||
|
self.print('###-----------------------------------')
|
||||||
|
count = 0
|
||||||
|
while True:
|
||||||
|
response = await self.prompt('>>> Enter PIN (1-6 chars):')
|
||||||
|
if len(response) == 0:
|
||||||
|
count += 1
|
||||||
|
if count > 3:
|
||||||
|
self.print('too many tries, stopping the pairing')
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.print('no PIN was entered, try again')
|
||||||
|
continue
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def get_peer_name(peer, mode):
|
async def get_peer_name(peer, mode):
|
||||||
@@ -207,7 +227,7 @@ def on_connection(connection, request):
|
|||||||
|
|
||||||
# Listen for pairing events
|
# Listen for pairing events
|
||||||
connection.on('pairing_start', on_pairing_start)
|
connection.on('pairing_start', on_pairing_start)
|
||||||
connection.on('pairing', on_pairing)
|
connection.on('pairing', lambda keys: on_pairing(connection.peer_address, keys))
|
||||||
connection.on('pairing_failure', on_pairing_failure)
|
connection.on('pairing_failure', on_pairing_failure)
|
||||||
|
|
||||||
# Listen for encryption changes
|
# Listen for encryption changes
|
||||||
@@ -242,9 +262,9 @@ def on_pairing_start():
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def on_pairing(keys):
|
def on_pairing(address, keys):
|
||||||
print(color('***-----------------------------------', 'cyan'))
|
print(color('***-----------------------------------', 'cyan'))
|
||||||
print(color('*** Paired!', 'cyan'))
|
print(color(f'*** Paired! (peer identity={address})', 'cyan'))
|
||||||
keys.print(prefix=color('*** ', 'cyan'))
|
keys.print(prefix=color('*** ', 'cyan'))
|
||||||
print(color('***-----------------------------------', 'cyan'))
|
print(color('***-----------------------------------', 'cyan'))
|
||||||
Waiter.instance.terminate()
|
Waiter.instance.terminate()
|
||||||
@@ -283,17 +303,6 @@ async def pair(
|
|||||||
# Create a device to manage the host
|
# Create a device to manage the host
|
||||||
device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
|
||||||
|
|
||||||
# Set a custom keystore if specified on the command line
|
|
||||||
if keystore_file:
|
|
||||||
device.keystore = JsonKeyStore(namespace=None, filename=keystore_file)
|
|
||||||
|
|
||||||
# Print the existing keys before pairing
|
|
||||||
if print_keys and device.keystore:
|
|
||||||
print(color('@@@-----------------------------------', 'blue'))
|
|
||||||
print(color('@@@ Pairing Keys:', 'blue'))
|
|
||||||
await device.keystore.print(prefix=color('@@@ ', 'blue'))
|
|
||||||
print(color('@@@-----------------------------------', 'blue'))
|
|
||||||
|
|
||||||
# Expose a GATT characteristic that can be used to trigger pairing by
|
# Expose a GATT characteristic that can be used to trigger pairing by
|
||||||
# responding with an authentication error when read
|
# responding with an authentication error when read
|
||||||
if mode == 'le':
|
if mode == 'le':
|
||||||
@@ -323,6 +332,17 @@ async def pair(
|
|||||||
# Get things going
|
# Get things going
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
|
|
||||||
|
# Set a custom keystore if specified on the command line
|
||||||
|
if keystore_file:
|
||||||
|
device.keystore = JsonKeyStore.from_device(device, filename=keystore_file)
|
||||||
|
|
||||||
|
# Print the existing keys before pairing
|
||||||
|
if print_keys and device.keystore:
|
||||||
|
print(color('@@@-----------------------------------', 'blue'))
|
||||||
|
print(color('@@@ Pairing Keys:', 'blue'))
|
||||||
|
await device.keystore.print(prefix=color('@@@ ', 'blue'))
|
||||||
|
print(color('@@@-----------------------------------', 'blue'))
|
||||||
|
|
||||||
# Set up a pairing config factory
|
# Set up a pairing config factory
|
||||||
device.pairing_config_factory = lambda connection: PairingConfig(
|
device.pairing_config_factory = lambda connection: PairingConfig(
|
||||||
sc, mitm, bond, Delegate(mode, connection, io, prompt)
|
sc, mitm, bond, Delegate(mode, connection, io, prompt)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import click
|
import click
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
from bumble.pandora import PandoraDevice, serve
|
from bumble.pandora import PandoraDevice, serve
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
BUMBLE_SERVER_GRPC_PORT = 7999
|
BUMBLE_SERVER_GRPC_PORT = 7999
|
||||||
ROOTCANAL_PORT_CUTTLEFISH = 7300
|
ROOTCANAL_PORT_CUTTLEFISH = 7300
|
||||||
@@ -18,13 +20,30 @@ ROOTCANAL_PORT_CUTTLEFISH = 7300
|
|||||||
help='HCI transport',
|
help='HCI transport',
|
||||||
default=f'tcp-client:127.0.0.1:<rootcanal-port>',
|
default=f'tcp-client:127.0.0.1:<rootcanal-port>',
|
||||||
)
|
)
|
||||||
def main(grpc_port: int, rootcanal_port: int, transport: str) -> None:
|
@click.option(
|
||||||
|
'--config',
|
||||||
|
help='Bumble json configuration file',
|
||||||
|
)
|
||||||
|
def main(grpc_port: int, rootcanal_port: int, transport: str, config: str) -> None:
|
||||||
if '<rootcanal-port>' in transport:
|
if '<rootcanal-port>' in transport:
|
||||||
transport = transport.replace('<rootcanal-port>', str(rootcanal_port))
|
transport = transport.replace('<rootcanal-port>', str(rootcanal_port))
|
||||||
device = PandoraDevice({'transport': transport})
|
|
||||||
|
bumble_config = retrieve_config(config)
|
||||||
|
if 'transport' not in bumble_config.keys():
|
||||||
|
bumble_config.update({'transport': transport})
|
||||||
|
device = PandoraDevice(bumble_config)
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
asyncio.run(serve(device, port=grpc_port))
|
asyncio.run(serve(device, port=grpc_port))
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve_config(config: str) -> Dict[str, Any]:
|
||||||
|
if not config:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with open(config, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main() # pylint: disable=no-value-for-parameter
|
main() # pylint: disable=no-value-for-parameter
|
||||||
|
|||||||
11
apps/scan.py
11
apps/scan.py
@@ -133,15 +133,16 @@ async def scan(
|
|||||||
'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
|
'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await device.power_on()
|
||||||
|
|
||||||
if keystore_file:
|
if keystore_file:
|
||||||
keystore = JsonKeyStore(namespace=None, filename=keystore_file)
|
device.keystore = JsonKeyStore.from_device(device, filename=keystore_file)
|
||||||
device.keystore = keystore
|
|
||||||
else:
|
|
||||||
resolver = None
|
|
||||||
|
|
||||||
if device.keystore:
|
if device.keystore:
|
||||||
resolving_keys = await device.keystore.get_resolving_keys()
|
resolving_keys = await device.keystore.get_resolving_keys()
|
||||||
resolver = AddressResolver(resolving_keys)
|
resolver = AddressResolver(resolving_keys)
|
||||||
|
else:
|
||||||
|
resolver = None
|
||||||
|
|
||||||
printer = AdvertisementPrinter(min_rssi, resolver)
|
printer = AdvertisementPrinter(min_rssi, resolver)
|
||||||
if raw:
|
if raw:
|
||||||
@@ -149,8 +150,6 @@ async def scan(
|
|||||||
else:
|
else:
|
||||||
device.on('advertisement', printer.on_advertisement)
|
device.on('advertisement', printer.on_advertisement)
|
||||||
|
|
||||||
await device.power_on()
|
|
||||||
|
|
||||||
if phy is None:
|
if phy is None:
|
||||||
scanning_phys = [HCI_LE_1M_PHY, HCI_LE_CODED_PHY]
|
scanning_phys = [HCI_LE_1M_PHY, HCI_LE_CODED_PHY]
|
||||||
else:
|
else:
|
||||||
|
|||||||
15
apps/show.py
15
apps/show.py
@@ -102,9 +102,21 @@ class SnoopPacketReader:
|
|||||||
default='h4',
|
default='h4',
|
||||||
help='Format of the input file',
|
help='Format of the input file',
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
'--vendors',
|
||||||
|
type=click.Choice(['android', 'zephyr']),
|
||||||
|
multiple=True,
|
||||||
|
help='Support vendor-specific commands (list one or more)',
|
||||||
|
)
|
||||||
@click.argument('filename')
|
@click.argument('filename')
|
||||||
# pylint: disable=redefined-builtin
|
# pylint: disable=redefined-builtin
|
||||||
def main(format, filename):
|
def main(format, vendors, filename):
|
||||||
|
for vendor in vendors:
|
||||||
|
if vendor == 'android':
|
||||||
|
import bumble.vendor.android.hci
|
||||||
|
elif vendor == 'zephyr':
|
||||||
|
import bumble.vendor.zephyr.hci
|
||||||
|
|
||||||
input = open(filename, 'rb')
|
input = open(filename, 'rb')
|
||||||
if format == 'h4':
|
if format == 'h4':
|
||||||
packet_reader = PacketReader(input)
|
packet_reader = PacketReader(input)
|
||||||
@@ -124,7 +136,6 @@ def main(format, filename):
|
|||||||
if packet is None:
|
if packet is None:
|
||||||
break
|
break
|
||||||
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
|
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
|
||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
print(color(f'!!! {error}', 'red'))
|
print(color(f'!!! {error}', 'red'))
|
||||||
|
|
||||||
|
|||||||
0
apps/speaker/__init__.py
Normal file
0
apps/speaker/__init__.py
Normal file
42
apps/speaker/logo.svg
Normal file
42
apps/speaker/logo.svg
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!-- Created with Vectornator for iOS (http://vectornator.io/) --><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg height="100%" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" xmlns:vectornator="http://vectornator.io" version="1.1" viewBox="0 0 745 744.634">
|
||||||
|
<metadata>
|
||||||
|
<vectornator:setting key="DimensionsVisible" value="1"/>
|
||||||
|
<vectornator:setting key="PencilOnly" value="0"/>
|
||||||
|
<vectornator:setting key="SnapToPoints" value="0"/>
|
||||||
|
<vectornator:setting key="OutlineMode" value="0"/>
|
||||||
|
<vectornator:setting key="CMYKEnabledKey" value="0"/>
|
||||||
|
<vectornator:setting key="RulersVisible" value="1"/>
|
||||||
|
<vectornator:setting key="SnapToEdges" value="0"/>
|
||||||
|
<vectornator:setting key="GuidesVisible" value="1"/>
|
||||||
|
<vectornator:setting key="DisplayWhiteBackground" value="0"/>
|
||||||
|
<vectornator:setting key="doHistoryDisabled" value="0"/>
|
||||||
|
<vectornator:setting key="SnapToGuides" value="1"/>
|
||||||
|
<vectornator:setting key="TimeLapseWatermarkDisabled" value="0"/>
|
||||||
|
<vectornator:setting key="Units" value="Pixels"/>
|
||||||
|
<vectornator:setting key="DynamicGuides" value="0"/>
|
||||||
|
<vectornator:setting key="IsolateActiveLayer" value="0"/>
|
||||||
|
<vectornator:setting key="SnapToGrid" value="0"/>
|
||||||
|
</metadata>
|
||||||
|
<defs/>
|
||||||
|
<g id="Layer 1" vectornator:layerName="Layer 1">
|
||||||
|
<path stroke="#000000" stroke-width="18.6464" d="M368.753+729.441L58.8847+550.539L58.8848+192.734L368.753+13.8313L678.621+192.734L678.621+550.539L368.753+729.441Z" fill="#0082fc" stroke-linecap="butt" fill-opacity="0.307489" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<g opacity="1">
|
||||||
|
<g opacity="1">
|
||||||
|
<path stroke="#000000" stroke-width="20" d="M292.873+289.256L442.872+289.256L442.872+539.254L292.873+539.254L292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#000000" stroke-width="20" d="M292.873+289.256C292.873+247.835+326.452+214.257+367.873+214.257C409.294+214.257+442.872+247.835+442.872+289.256C442.872+330.677+409.294+364.256+367.873+364.256C326.452+364.256+292.873+330.677+292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#000000" stroke-width="20" d="M292.873+539.254C292.873+497.833+326.452+464.255+367.873+464.255C409.294+464.255+442.872+497.833+442.872+539.254C442.872+580.675+409.294+614.254+367.873+614.254C326.452+614.254+292.873+580.675+292.873+539.254Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#0082fc" stroke-width="0.1" d="M302.873+289.073L432.872+289.073L432.872+539.072L302.873+539.072L302.873+289.073Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<path stroke="#000000" stroke-width="0.1" d="M103.161+309.167L226.956+443.903L366.671+309.604L103.161+309.167Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#000000" stroke-width="0.1" d="M383.411+307.076L508.887+440.112L650.5+307.507L383.411+307.076Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#000000" stroke-width="20" d="M522.045+154.808L229.559+448.882L83.8397+300.104L653.666+302.936L511.759+444.785L223.101+156.114" fill="none" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#000000" stroke-width="61.8698" d="M295.857+418.738L438.9+418.738" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#000000" stroke-width="61.8698" d="M295.857+521.737L438.9+521.737" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<g opacity="1">
|
||||||
|
<path stroke="#0082fc" stroke-width="0.1" d="M367.769+667.024L367.821+616.383L403.677+616.336C383.137+626.447+368.263+638.69+367.769+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
<path stroke="#0082fc" stroke-width="0.1" d="M367.836+667.024L367.784+616.383L331.928+616.336C352.468+626.447+367.341+638.69+367.836+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
76
apps/speaker/speaker.css
Normal file
76
apps/speaker/speaker.css
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
body, h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controlsDiv {
|
||||||
|
margin: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#connectionText {
|
||||||
|
background-color: rgb(239, 89, 75);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#startButton {
|
||||||
|
padding: 4px;
|
||||||
|
margin: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fftCanvas {
|
||||||
|
border-radius: 16px;
|
||||||
|
margin: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bandwidthCanvas {
|
||||||
|
border: grey;
|
||||||
|
border-style: solid;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#streamStateText {
|
||||||
|
background-color: rgb(93, 165, 93);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#connectionStateText {
|
||||||
|
background-color: rgb(112, 146, 206);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#propertiesTable {
|
||||||
|
border: grey;
|
||||||
|
border-style: solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
margin: 6px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding-left: 6px;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties td:nth-child(even) {
|
||||||
|
background-color: #d6eeee;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties td:nth-child(odd) {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties tr td:nth-child(2) { width: 150px; }
|
||||||
34
apps/speaker/speaker.html
Normal file
34
apps/speaker/speaker.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Bumble Speaker</title>
|
||||||
|
<script src="speaker.js"></script>
|
||||||
|
<link rel="stylesheet" href="speaker.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1><img src="logo.svg" width=100 height=100 style="vertical-align:middle" alt=""/>Bumble Virtual Speaker</h1>
|
||||||
|
<div id="connectionText"></div>
|
||||||
|
<div id="speaker">
|
||||||
|
<table><tr>
|
||||||
|
<td>
|
||||||
|
<table id="propertiesTable" class="properties">
|
||||||
|
<tr><td>Codec</td><td><span id="codecText"></span></td></tr>
|
||||||
|
<tr><td>Packets</td><td><span id="packetsReceivedText"></span></td></tr>
|
||||||
|
<tr><td>Bytes</td><td><span id="bytesReceivedText"></span></td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<canvas id="bandwidthCanvas" width="500", height="100">Bandwidth Graph</canvas>
|
||||||
|
</td>
|
||||||
|
</tr></table>
|
||||||
|
<span id="streamStateText">IDLE</span>
|
||||||
|
<span id="connectionStateText">NOT CONNECTED</span>
|
||||||
|
<div id="controlsDiv">
|
||||||
|
<button id="audioOnButton">Audio On</button>
|
||||||
|
<span id="audioSupportMessageText"></span>
|
||||||
|
</div>
|
||||||
|
<canvas id="fftCanvas" width="1024", height="300">Audio Frequencies Animation</canvas>
|
||||||
|
<audio id="audio"></audio>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
315
apps/speaker/speaker.js
Normal file
315
apps/speaker/speaker.js
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const channelUrl = ((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + "/channel";
|
||||||
|
let channelSocket;
|
||||||
|
let connectionText;
|
||||||
|
let codecText;
|
||||||
|
let packetsReceivedText;
|
||||||
|
let bytesReceivedText;
|
||||||
|
let streamStateText;
|
||||||
|
let connectionStateText;
|
||||||
|
let controlsDiv;
|
||||||
|
let audioOnButton;
|
||||||
|
let mediaSource;
|
||||||
|
let sourceBuffer;
|
||||||
|
let audioElement;
|
||||||
|
let audioContext;
|
||||||
|
let audioAnalyzer;
|
||||||
|
let audioFrequencyBinCount;
|
||||||
|
let audioFrequencyData;
|
||||||
|
let packetsReceived = 0;
|
||||||
|
let bytesReceived = 0;
|
||||||
|
let audioState = "stopped";
|
||||||
|
let streamState = "IDLE";
|
||||||
|
let audioSupportMessageText;
|
||||||
|
let fftCanvas;
|
||||||
|
let fftCanvasContext;
|
||||||
|
let bandwidthCanvas;
|
||||||
|
let bandwidthCanvasContext;
|
||||||
|
let bandwidthBinCount;
|
||||||
|
let bandwidthBins = [];
|
||||||
|
|
||||||
|
const FFT_WIDTH = 800;
|
||||||
|
const FFT_HEIGHT = 256;
|
||||||
|
const BANDWIDTH_WIDTH = 500;
|
||||||
|
const BANDWIDTH_HEIGHT = 100;
|
||||||
|
|
||||||
|
function hexToBytes(hex) {
|
||||||
|
return Uint8Array.from(hex.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
initUI();
|
||||||
|
initMediaSource();
|
||||||
|
initAudioElement();
|
||||||
|
initAnalyzer();
|
||||||
|
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initUI() {
|
||||||
|
controlsDiv = document.getElementById("controlsDiv");
|
||||||
|
controlsDiv.style.visibility = "hidden";
|
||||||
|
connectionText = document.getElementById("connectionText");
|
||||||
|
audioOnButton = document.getElementById("audioOnButton");
|
||||||
|
codecText = document.getElementById("codecText");
|
||||||
|
packetsReceivedText = document.getElementById("packetsReceivedText");
|
||||||
|
bytesReceivedText = document.getElementById("bytesReceivedText");
|
||||||
|
streamStateText = document.getElementById("streamStateText");
|
||||||
|
connectionStateText = document.getElementById("connectionStateText");
|
||||||
|
audioSupportMessageText = document.getElementById("audioSupportMessageText");
|
||||||
|
|
||||||
|
audioOnButton.onclick = () => startAudio();
|
||||||
|
|
||||||
|
setConnectionText("");
|
||||||
|
|
||||||
|
requestAnimationFrame(onAnimationFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMediaSource() {
|
||||||
|
mediaSource = new MediaSource();
|
||||||
|
mediaSource.onsourceopen = onMediaSourceOpen;
|
||||||
|
mediaSource.onsourceclose = onMediaSourceClose;
|
||||||
|
mediaSource.onsourceended = onMediaSourceEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAudioElement() {
|
||||||
|
audioElement = document.getElementById("audio");
|
||||||
|
audioElement.src = URL.createObjectURL(mediaSource);
|
||||||
|
// audioElement.controls = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAnalyzer() {
|
||||||
|
fftCanvas = document.getElementById("fftCanvas");
|
||||||
|
fftCanvas.width = FFT_WIDTH
|
||||||
|
fftCanvas.height = FFT_HEIGHT
|
||||||
|
fftCanvasContext = fftCanvas.getContext('2d');
|
||||||
|
fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
|
||||||
|
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
|
||||||
|
|
||||||
|
bandwidthCanvas = document.getElementById("bandwidthCanvas");
|
||||||
|
bandwidthCanvas.width = BANDWIDTH_WIDTH
|
||||||
|
bandwidthCanvas.height = BANDWIDTH_HEIGHT
|
||||||
|
bandwidthCanvasContext = bandwidthCanvas.getContext('2d');
|
||||||
|
bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
|
||||||
|
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAnalyzer() {
|
||||||
|
// FFT
|
||||||
|
if (audioElement.captureStream !== undefined) {
|
||||||
|
audioContext = new AudioContext();
|
||||||
|
audioAnalyzer = audioContext.createAnalyser();
|
||||||
|
audioAnalyzer.fftSize = 128;
|
||||||
|
audioFrequencyBinCount = audioAnalyzer.frequencyBinCount;
|
||||||
|
audioFrequencyData = new Uint8Array(audioFrequencyBinCount);
|
||||||
|
const stream = audioElement.captureStream();
|
||||||
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
source.connect(audioAnalyzer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bandwidth
|
||||||
|
bandwidthBinCount = BANDWIDTH_WIDTH / 2;
|
||||||
|
bandwidthBins = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConnectionText(message) {
|
||||||
|
connectionText.innerText = message;
|
||||||
|
if (message.length == 0) {
|
||||||
|
connectionText.style.display = "none";
|
||||||
|
} else {
|
||||||
|
connectionText.style.display = "inline-block";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStreamState(state) {
|
||||||
|
streamState = state;
|
||||||
|
streamStateText.innerText = streamState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAnimationFrame() {
|
||||||
|
// FFT
|
||||||
|
if (audioAnalyzer !== undefined) {
|
||||||
|
audioAnalyzer.getByteFrequencyData(audioFrequencyData);
|
||||||
|
fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
|
||||||
|
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
|
||||||
|
const barCount = audioFrequencyBinCount;
|
||||||
|
const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1;
|
||||||
|
for (let bar = 0; bar < barCount; bar++) {
|
||||||
|
const barHeight = audioFrequencyData[bar];
|
||||||
|
fftCanvasContext.fillStyle = `rgb(${barHeight / 256 * 200 + 50}, 50, ${50 + 2 * bar})`;
|
||||||
|
fftCanvasContext.fillRect(bar * (barWidth + 1), FFT_HEIGHT - barHeight, barWidth, barHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bandwidth
|
||||||
|
bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
|
||||||
|
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
|
||||||
|
bandwidthCanvasContext.fillStyle = `rgb(100, 100, 100)`;
|
||||||
|
for (let t = 0; t < bandwidthBins.length; t++) {
|
||||||
|
const lineHeight = (bandwidthBins[t] / 1000) * BANDWIDTH_HEIGHT;
|
||||||
|
bandwidthCanvasContext.fillRect(t * 2, BANDWIDTH_HEIGHT - lineHeight, 2, lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display again at the next frame
|
||||||
|
requestAnimationFrame(onAnimationFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMediaSourceOpen() {
|
||||||
|
console.log(this.readyState);
|
||||||
|
sourceBuffer = mediaSource.addSourceBuffer("audio/aac");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMediaSourceClose() {
|
||||||
|
console.log(this.readyState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMediaSourceEnd() {
|
||||||
|
console.log(this.readyState);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startAudio() {
|
||||||
|
try {
|
||||||
|
console.log("starting audio...");
|
||||||
|
audioOnButton.disabled = true;
|
||||||
|
audioState = "starting";
|
||||||
|
await audioElement.play();
|
||||||
|
console.log("audio started");
|
||||||
|
audioState = "playing";
|
||||||
|
startAnalyzer();
|
||||||
|
} catch(error) {
|
||||||
|
console.error(`play failed: ${error}`);
|
||||||
|
audioState = "stopped";
|
||||||
|
audioOnButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioPacket(packet) {
|
||||||
|
if (audioState != "stopped") {
|
||||||
|
// Queue the audio packet.
|
||||||
|
sourceBuffer.appendBuffer(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
packetsReceived += 1;
|
||||||
|
packetsReceivedText.innerText = packetsReceived;
|
||||||
|
bytesReceived += packet.byteLength;
|
||||||
|
bytesReceivedText.innerText = bytesReceived;
|
||||||
|
|
||||||
|
bandwidthBins[bandwidthBins.length] = packet.byteLength;
|
||||||
|
if (bandwidthBins.length > bandwidthBinCount) {
|
||||||
|
bandwidthBins.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChannelOpen() {
|
||||||
|
console.log('channel OPEN');
|
||||||
|
setConnectionText("");
|
||||||
|
controlsDiv.style.visibility = "visible";
|
||||||
|
|
||||||
|
// Handshake with the backend.
|
||||||
|
sendMessage({
|
||||||
|
type: "hello"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChannelClose() {
|
||||||
|
console.log('channel CLOSED');
|
||||||
|
setConnectionText("Connection to CLI app closed, restart it and reload this page.");
|
||||||
|
controlsDiv.style.visibility = "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChannelError(error) {
|
||||||
|
console.log(`channel ERROR: ${error}`);
|
||||||
|
setConnectionText(`Connection to CLI app error ({${error}}), restart it and reload this page.`);
|
||||||
|
controlsDiv.style.visibility = "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChannelMessage(message) {
|
||||||
|
if (typeof message.data === 'string' || message.data instanceof String) {
|
||||||
|
// JSON message.
|
||||||
|
const jsonMessage = JSON.parse(message.data);
|
||||||
|
console.log(`channel MESSAGE: ${message.data}`);
|
||||||
|
|
||||||
|
// Dispatch the message.
|
||||||
|
const handlerName = `on${jsonMessage.type.charAt(0).toUpperCase()}${jsonMessage.type.slice(1)}Message`
|
||||||
|
const handler = messageHandlers[handlerName];
|
||||||
|
if (handler !== undefined) {
|
||||||
|
const params = jsonMessage.params;
|
||||||
|
if (params === undefined) {
|
||||||
|
params = {};
|
||||||
|
}
|
||||||
|
handler(params);
|
||||||
|
} else {
|
||||||
|
console.warn(`unhandled message: ${jsonMessage.type}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// BINARY audio data.
|
||||||
|
onAudioPacket(message.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHelloMessage(params) {
|
||||||
|
codecText.innerText = params.codec;
|
||||||
|
if (params.codec != "aac") {
|
||||||
|
audioOnButton.disabled = true;
|
||||||
|
audioSupportMessageText.innerText = "Only AAC can be played, audio will be disabled";
|
||||||
|
audioSupportMessageText.style.display = "inline-block";
|
||||||
|
} else {
|
||||||
|
audioSupportMessageText.innerText = "";
|
||||||
|
audioSupportMessageText.style.display = "none";
|
||||||
|
}
|
||||||
|
if (params.streamState) {
|
||||||
|
setStreamState(params.streamState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStartMessage(params) {
|
||||||
|
setStreamState("STARTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStopMessage(params) {
|
||||||
|
setStreamState("STOPPED");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSuspendMessage(params) {
|
||||||
|
setStreamState("SUSPENDED");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConnectionMessage(params) {
|
||||||
|
connectionStateText.innerText = `CONNECTED: ${params.peer_name} (${params.peer_address})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDisconnectionMessage(params) {
|
||||||
|
connectionStateText.innerText = "DISCONNECTED";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage(message) {
|
||||||
|
channelSocket.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
console.log("connecting to CLI app");
|
||||||
|
|
||||||
|
channelSocket = new WebSocket(channelUrl);
|
||||||
|
channelSocket.binaryType = "arraybuffer";
|
||||||
|
channelSocket.onopen = onChannelOpen;
|
||||||
|
channelSocket.onclose = onChannelClose;
|
||||||
|
channelSocket.onerror = onChannelError;
|
||||||
|
channelSocket.onmessage = onChannelMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageHandlers = {
|
||||||
|
onHelloMessage,
|
||||||
|
onStartMessage,
|
||||||
|
onStopMessage,
|
||||||
|
onSuspendMessage,
|
||||||
|
onConnectionMessage,
|
||||||
|
onDisconnectionMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onload = (event) => {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
}());
|
||||||
737
apps/speaker/speaker.py
Normal file
737
apps/speaker/speaker.py
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
# Copyright 2021-2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import asyncio.subprocess
|
||||||
|
from importlib import resources
|
||||||
|
import enum
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
import weakref
|
||||||
|
|
||||||
|
import click
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
import bumble
|
||||||
|
from bumble.colors import color
|
||||||
|
from bumble.core import BT_BR_EDR_TRANSPORT, CommandTimeoutError
|
||||||
|
from bumble.device import Connection, Device, DeviceConfiguration
|
||||||
|
from bumble.hci import HCI_StatusError
|
||||||
|
from bumble.pairing import PairingConfig
|
||||||
|
from bumble.sdp import ServiceAttribute
|
||||||
|
from bumble.transport import open_transport
|
||||||
|
from bumble.avdtp import (
|
||||||
|
AVDTP_AUDIO_MEDIA_TYPE,
|
||||||
|
Listener,
|
||||||
|
MediaCodecCapabilities,
|
||||||
|
MediaPacket,
|
||||||
|
Protocol,
|
||||||
|
)
|
||||||
|
from bumble.a2dp import (
|
||||||
|
MPEG_2_AAC_LC_OBJECT_TYPE,
|
||||||
|
make_audio_sink_service_sdp_records,
|
||||||
|
A2DP_SBC_CODEC_TYPE,
|
||||||
|
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||||
|
SBC_MONO_CHANNEL_MODE,
|
||||||
|
SBC_DUAL_CHANNEL_MODE,
|
||||||
|
SBC_SNR_ALLOCATION_METHOD,
|
||||||
|
SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||||
|
SBC_STEREO_CHANNEL_MODE,
|
||||||
|
SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||||
|
SbcMediaCodecInformation,
|
||||||
|
AacMediaCodecInformation,
|
||||||
|
)
|
||||||
|
from bumble.utils import AsyncRunner
|
||||||
|
from bumble.codecs import AacAudioRtpPacket
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
DEFAULT_UI_PORT = 7654
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class AudioExtractor:
|
||||||
|
@staticmethod
|
||||||
|
def create(codec: str):
|
||||||
|
if codec == 'aac':
|
||||||
|
return AacAudioExtractor()
|
||||||
|
if codec == 'sbc':
|
||||||
|
return SbcAudioExtractor()
|
||||||
|
|
||||||
|
def extract_audio(self, packet: MediaPacket) -> bytes:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class AacAudioExtractor:
|
||||||
|
def extract_audio(self, packet: MediaPacket) -> bytes:
|
||||||
|
return AacAudioRtpPacket(packet.payload).to_adts()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class SbcAudioExtractor:
|
||||||
|
def extract_audio(self, packet: MediaPacket) -> bytes:
|
||||||
|
# header = packet.payload[0]
|
||||||
|
# fragmented = header >> 7
|
||||||
|
# start = (header >> 6) & 0x01
|
||||||
|
# last = (header >> 5) & 0x01
|
||||||
|
# number_of_frames = header & 0x0F
|
||||||
|
|
||||||
|
# TODO: support fragmented payloads
|
||||||
|
return packet.payload[1:]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Output:
|
||||||
|
async def start(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def suspend(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def on_connection(self, connection: Connection) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def on_disconnection(self, reason: int) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_rtp_packet(self, packet: MediaPacket) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class FileOutput(Output):
|
||||||
|
filename: str
|
||||||
|
codec: str
|
||||||
|
extractor: AudioExtractor
|
||||||
|
|
||||||
|
def __init__(self, filename, codec):
|
||||||
|
self.filename = filename
|
||||||
|
self.codec = codec
|
||||||
|
self.file = open(filename, 'wb')
|
||||||
|
self.extractor = AudioExtractor.create(codec)
|
||||||
|
|
||||||
|
def on_rtp_packet(self, packet: MediaPacket) -> None:
|
||||||
|
self.file.write(self.extractor.extract_audio(packet))
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class QueuedOutput(Output):
|
||||||
|
MAX_QUEUE_SIZE = 32768
|
||||||
|
|
||||||
|
packets: asyncio.Queue
|
||||||
|
extractor: AudioExtractor
|
||||||
|
packet_pump_task: Optional[asyncio.Task]
|
||||||
|
started: bool
|
||||||
|
|
||||||
|
def __init__(self, extractor):
|
||||||
|
self.extractor = extractor
|
||||||
|
self.packets = asyncio.Queue()
|
||||||
|
self.packet_pump_task = None
|
||||||
|
self.started = False
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
if self.started:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.packet_pump_task = asyncio.create_task(self.pump_packets())
|
||||||
|
|
||||||
|
async def pump_packets(self):
|
||||||
|
while True:
|
||||||
|
packet = await self.packets.get()
|
||||||
|
await self.on_audio_packet(packet)
|
||||||
|
|
||||||
|
async def on_audio_packet(self, packet: bytes) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_rtp_packet(self, packet: MediaPacket) -> None:
|
||||||
|
if self.packets.qsize() > self.MAX_QUEUE_SIZE:
|
||||||
|
logger.debug("queue full, dropping")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.packets.put_nowait(self.extractor.extract_audio(packet))
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class WebSocketOutput(QueuedOutput):
|
||||||
|
def __init__(self, codec, send_audio, send_message):
|
||||||
|
super().__init__(AudioExtractor.create(codec))
|
||||||
|
self.send_audio = send_audio
|
||||||
|
self.send_message = send_message
|
||||||
|
|
||||||
|
async def on_connection(self, connection: Connection) -> None:
|
||||||
|
try:
|
||||||
|
await connection.request_remote_name()
|
||||||
|
except HCI_StatusError:
|
||||||
|
pass
|
||||||
|
peer_name = '' if connection.peer_name is None else connection.peer_name
|
||||||
|
peer_address = str(connection.peer_address).replace('/P', '')
|
||||||
|
await self.send_message(
|
||||||
|
'connection',
|
||||||
|
peer_address=peer_address,
|
||||||
|
peer_name=peer_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_disconnection(self, reason) -> None:
|
||||||
|
await self.send_message('disconnection')
|
||||||
|
|
||||||
|
async def on_audio_packet(self, packet: bytes) -> None:
|
||||||
|
await self.send_audio(packet)
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
await super().start()
|
||||||
|
await self.send_message('start')
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
await super().stop()
|
||||||
|
await self.send_message('stop')
|
||||||
|
|
||||||
|
async def suspend(self):
|
||||||
|
await super().suspend()
|
||||||
|
await self.send_message('suspend')
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class FfplayOutput(QueuedOutput):
|
||||||
|
MAX_QUEUE_SIZE = 32768
|
||||||
|
|
||||||
|
subprocess: Optional[asyncio.subprocess.Process]
|
||||||
|
ffplay_task: Optional[asyncio.Task]
|
||||||
|
|
||||||
|
def __init__(self, codec: str) -> None:
|
||||||
|
super().__init__(AudioExtractor.create(codec))
|
||||||
|
self.subprocess = None
|
||||||
|
self.ffplay_task = None
|
||||||
|
self.codec = codec
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
if self.started:
|
||||||
|
return
|
||||||
|
|
||||||
|
await super().start()
|
||||||
|
|
||||||
|
self.subprocess = await asyncio.create_subprocess_shell(
|
||||||
|
f'ffplay -f {self.codec} pipe:0',
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.ffplay_task = asyncio.create_task(self.monitor_ffplay())
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
# TODO
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def suspend(self):
|
||||||
|
# TODO
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def monitor_ffplay(self):
|
||||||
|
async def read_stream(name, stream):
|
||||||
|
while True:
|
||||||
|
data = await stream.read()
|
||||||
|
logger.debug(f'{name}:', data)
|
||||||
|
|
||||||
|
await asyncio.wait(
|
||||||
|
[
|
||||||
|
asyncio.create_task(
|
||||||
|
read_stream('[ffplay stdout]', self.subprocess.stdout)
|
||||||
|
),
|
||||||
|
asyncio.create_task(
|
||||||
|
read_stream('[ffplay stderr]', self.subprocess.stderr)
|
||||||
|
),
|
||||||
|
asyncio.create_task(self.subprocess.wait()),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger.debug("FFPLAY done")
|
||||||
|
|
||||||
|
async def on_audio_packet(self, packet):
|
||||||
|
try:
|
||||||
|
self.subprocess.stdin.write(packet)
|
||||||
|
except Exception:
|
||||||
|
logger.warning('!!!! exception while sending audio to ffplay pipe')
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
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 = web.Application()
|
||||||
|
app.add_routes(
|
||||||
|
[
|
||||||
|
web.get('/', self.get_static),
|
||||||
|
web.get('/speaker.html', self.get_static),
|
||||||
|
web.get('/speaker.js', self.get_static),
|
||||||
|
web.get('/speaker.css', self.get_static),
|
||||||
|
web.get('/logo.svg', self.get_static),
|
||||||
|
web.get('/channel', self.get_channel),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = 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 = '/speaker.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.speaker")
|
||||||
|
.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 = 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=str(connection.peer_address).replace('/P', ''),
|
||||||
|
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:
|
||||||
|
class StreamState(enum.Enum):
|
||||||
|
IDLE = 0
|
||||||
|
STOPPED = 1
|
||||||
|
STARTED = 2
|
||||||
|
SUSPENDED = 3
|
||||||
|
|
||||||
|
def __init__(self, device_config, transport, codec, discover, outputs, ui_port):
|
||||||
|
self.device_config = device_config
|
||||||
|
self.transport = transport
|
||||||
|
self.codec = codec
|
||||||
|
self.discover = discover
|
||||||
|
self.ui_port = ui_port
|
||||||
|
self.device = None
|
||||||
|
self.connection = None
|
||||||
|
self.listener = None
|
||||||
|
self.packets_received = 0
|
||||||
|
self.bytes_received = 0
|
||||||
|
self.stream_state = Speaker.StreamState.IDLE
|
||||||
|
self.outputs = []
|
||||||
|
for output in outputs:
|
||||||
|
if output == '@ffplay':
|
||||||
|
self.outputs.append(FfplayOutput(codec))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Default to FileOutput
|
||||||
|
self.outputs.append(FileOutput(output, codec))
|
||||||
|
|
||||||
|
# Create an HTTP server for the UI
|
||||||
|
self.ui_server = UiServer(speaker=self, port=ui_port)
|
||||||
|
|
||||||
|
def sdp_records(self) -> Dict[int, List[ServiceAttribute]]:
|
||||||
|
service_record_handle = 0x00010001
|
||||||
|
return {
|
||||||
|
service_record_handle: make_audio_sink_service_sdp_records(
|
||||||
|
service_record_handle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def codec_capabilities(self) -> MediaCodecCapabilities:
|
||||||
|
if self.codec == 'aac':
|
||||||
|
return self.aac_codec_capabilities()
|
||||||
|
|
||||||
|
if self.codec == 'sbc':
|
||||||
|
return self.sbc_codec_capabilities()
|
||||||
|
|
||||||
|
raise RuntimeError('unsupported codec')
|
||||||
|
|
||||||
|
def aac_codec_capabilities(self) -> MediaCodecCapabilities:
|
||||||
|
return MediaCodecCapabilities(
|
||||||
|
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||||
|
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||||
|
media_codec_information=AacMediaCodecInformation.from_lists(
|
||||||
|
object_types=[MPEG_2_AAC_LC_OBJECT_TYPE],
|
||||||
|
sampling_frequencies=[48000, 44100],
|
||||||
|
channels=[1, 2],
|
||||||
|
vbr=1,
|
||||||
|
bitrate=256000,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def sbc_codec_capabilities(self) -> MediaCodecCapabilities:
|
||||||
|
return MediaCodecCapabilities(
|
||||||
|
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||||
|
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
||||||
|
media_codec_information=SbcMediaCodecInformation.from_lists(
|
||||||
|
sampling_frequencies=[48000, 44100, 32000, 16000],
|
||||||
|
channel_modes=[
|
||||||
|
SBC_MONO_CHANNEL_MODE,
|
||||||
|
SBC_DUAL_CHANNEL_MODE,
|
||||||
|
SBC_STEREO_CHANNEL_MODE,
|
||||||
|
SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||||
|
],
|
||||||
|
block_lengths=[4, 8, 12, 16],
|
||||||
|
subbands=[4, 8],
|
||||||
|
allocation_methods=[
|
||||||
|
SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||||
|
SBC_SNR_ALLOCATION_METHOD,
|
||||||
|
],
|
||||||
|
minimum_bitpool_value=2,
|
||||||
|
maximum_bitpool_value=53,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def dispatch_to_outputs(self, function):
|
||||||
|
for output in self.outputs:
|
||||||
|
await function(output)
|
||||||
|
|
||||||
|
def on_bluetooth_connection(self, connection):
|
||||||
|
print(f'Connection: {connection}')
|
||||||
|
self.connection = connection
|
||||||
|
connection.on('disconnection', self.on_bluetooth_disconnection)
|
||||||
|
AsyncRunner.spawn(
|
||||||
|
self.dispatch_to_outputs(lambda output: output.on_connection(connection))
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_bluetooth_disconnection(self, reason):
|
||||||
|
print(f'Disconnection ({reason})')
|
||||||
|
self.connection = None
|
||||||
|
AsyncRunner.spawn(self.advertise())
|
||||||
|
AsyncRunner.spawn(
|
||||||
|
self.dispatch_to_outputs(lambda output: output.on_disconnection(reason))
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_avdtp_connection(self, protocol):
|
||||||
|
print('Audio Stream Open')
|
||||||
|
|
||||||
|
# Add a sink endpoint to the server
|
||||||
|
sink = protocol.add_sink(self.codec_capabilities())
|
||||||
|
sink.on('start', self.on_sink_start)
|
||||||
|
sink.on('stop', self.on_sink_stop)
|
||||||
|
sink.on('suspend', self.on_sink_suspend)
|
||||||
|
sink.on('configuration', lambda: self.on_sink_configuration(sink.configuration))
|
||||||
|
sink.on('rtp_packet', self.on_rtp_packet)
|
||||||
|
sink.on('rtp_channel_open', self.on_rtp_channel_open)
|
||||||
|
sink.on('rtp_channel_close', self.on_rtp_channel_close)
|
||||||
|
|
||||||
|
# Listen for close events
|
||||||
|
protocol.on('close', self.on_avdtp_close)
|
||||||
|
|
||||||
|
# Discover all endpoints on the remote device is requested
|
||||||
|
if self.discover:
|
||||||
|
AsyncRunner.spawn(self.discover_remote_endpoints(protocol))
|
||||||
|
|
||||||
|
def on_avdtp_close(self):
|
||||||
|
print("Audio Stream Closed")
|
||||||
|
|
||||||
|
def on_sink_start(self):
|
||||||
|
print("Sink Started\u001b[0K")
|
||||||
|
self.stream_state = self.StreamState.STARTED
|
||||||
|
AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.start()))
|
||||||
|
|
||||||
|
def on_sink_stop(self):
|
||||||
|
print("Sink Stopped\u001b[0K")
|
||||||
|
self.stream_state = self.StreamState.STOPPED
|
||||||
|
AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.stop()))
|
||||||
|
|
||||||
|
def on_sink_suspend(self):
|
||||||
|
print("Sink Suspended\u001b[0K")
|
||||||
|
self.stream_state = self.StreamState.SUSPENDED
|
||||||
|
AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.suspend()))
|
||||||
|
|
||||||
|
def on_sink_configuration(self, config):
|
||||||
|
print("Sink Configuration:")
|
||||||
|
print('\n'.join([" " + str(capability) for capability in config]))
|
||||||
|
|
||||||
|
def on_rtp_channel_open(self):
|
||||||
|
print("RTP Channel Open")
|
||||||
|
|
||||||
|
def on_rtp_channel_close(self):
|
||||||
|
print("RTP Channel Closed")
|
||||||
|
self.stream_state = self.StreamState.IDLE
|
||||||
|
|
||||||
|
def on_rtp_packet(self, packet):
|
||||||
|
self.packets_received += 1
|
||||||
|
self.bytes_received += len(packet.payload)
|
||||||
|
print(
|
||||||
|
f'[{self.bytes_received} bytes in {self.packets_received} packets] {packet}',
|
||||||
|
end='\r',
|
||||||
|
)
|
||||||
|
|
||||||
|
for output in self.outputs:
|
||||||
|
output.on_rtp_packet(packet)
|
||||||
|
|
||||||
|
async def advertise(self):
|
||||||
|
await self.device.set_discoverable(True)
|
||||||
|
await self.device.set_connectable(True)
|
||||||
|
|
||||||
|
async def connect(self, address):
|
||||||
|
# Connect to the source
|
||||||
|
print(f'=== Connecting to {address}...')
|
||||||
|
connection = await self.device.connect(address, transport=BT_BR_EDR_TRANSPORT)
|
||||||
|
print(f'=== Connected to {connection.peer_address}')
|
||||||
|
|
||||||
|
# Request authentication
|
||||||
|
print('*** Authenticating...')
|
||||||
|
await connection.authenticate()
|
||||||
|
print('*** Authenticated')
|
||||||
|
|
||||||
|
# Enable encryption
|
||||||
|
print('*** Enabling encryption...')
|
||||||
|
await connection.encrypt()
|
||||||
|
print('*** Encryption on')
|
||||||
|
|
||||||
|
protocol = await Protocol.connect(connection)
|
||||||
|
self.listener.set_server(connection, protocol)
|
||||||
|
self.on_avdtp_connection(protocol)
|
||||||
|
|
||||||
|
async def discover_remote_endpoints(self, protocol):
|
||||||
|
endpoints = await protocol.discover_remote_endpoints()
|
||||||
|
print(f'@@@ Found {len(endpoints)} endpoints')
|
||||||
|
for endpoint in endpoints:
|
||||||
|
print('@@@', endpoint)
|
||||||
|
|
||||||
|
async def run(self, connect_address):
|
||||||
|
await self.ui_server.start_http()
|
||||||
|
self.outputs.append(
|
||||||
|
WebSocketOutput(
|
||||||
|
self.codec, self.ui_server.send_audio, self.ui_server.send_message
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async with await open_transport(self.transport) as (hci_source, hci_sink):
|
||||||
|
# Create a device
|
||||||
|
device_config = DeviceConfiguration()
|
||||||
|
if self.device_config:
|
||||||
|
device_config.load_from_file(self.device_config)
|
||||||
|
else:
|
||||||
|
device_config.name = "Bumble Speaker"
|
||||||
|
device_config.class_of_device = 0x240414
|
||||||
|
device_config.keystore = "JsonKeyStore"
|
||||||
|
|
||||||
|
device_config.classic_enabled = True
|
||||||
|
device_config.le_enabled = False
|
||||||
|
self.device = Device.from_config_with_hci(
|
||||||
|
device_config, hci_source, hci_sink
|
||||||
|
)
|
||||||
|
|
||||||
|
# Setup the SDP to expose the sink service
|
||||||
|
self.device.sdp_service_records = self.sdp_records()
|
||||||
|
|
||||||
|
# Don't require MITM when pairing.
|
||||||
|
self.device.pairing_config_factory = lambda connection: PairingConfig(
|
||||||
|
mitm=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start the controller
|
||||||
|
await self.device.power_on()
|
||||||
|
|
||||||
|
# Print some of the config/properties
|
||||||
|
print("Speaker Name:", color(device_config.name, 'yellow'))
|
||||||
|
print(
|
||||||
|
"Speaker Bluetooth Address:",
|
||||||
|
color(
|
||||||
|
self.device.public_address.to_string(with_type_qualifier=False),
|
||||||
|
'yellow',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Listen for Bluetooth connections
|
||||||
|
self.device.on('connection', self.on_bluetooth_connection)
|
||||||
|
|
||||||
|
# Create a listener to wait for AVDTP connections
|
||||||
|
self.listener = Listener(Listener.create_registrar(self.device))
|
||||||
|
self.listener.on('connection', self.on_avdtp_connection)
|
||||||
|
|
||||||
|
print(f'Speaker ready to play, codec={color(self.codec, "cyan")}')
|
||||||
|
|
||||||
|
if connect_address:
|
||||||
|
# Connect to the source
|
||||||
|
try:
|
||||||
|
await self.connect(connect_address)
|
||||||
|
except CommandTimeoutError:
|
||||||
|
print(color("Connection timed out", "red"))
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# Start being discoverable and connectable
|
||||||
|
print("Waiting for connection...")
|
||||||
|
await self.advertise()
|
||||||
|
|
||||||
|
await hci_source.wait_for_termination()
|
||||||
|
|
||||||
|
for output in self.outputs:
|
||||||
|
await output.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@click.group()
|
||||||
|
@click.pass_context
|
||||||
|
def speaker_cli(ctx, device_config):
|
||||||
|
ctx.ensure_object(dict)
|
||||||
|
ctx.obj['device_config'] = device_config
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
'--codec', type=click.Choice(['sbc', 'aac']), default='aac', show_default=True
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--discover', is_flag=True, help='Discover remote endpoints once connected'
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--output',
|
||||||
|
multiple=True,
|
||||||
|
metavar='NAME',
|
||||||
|
help=(
|
||||||
|
'Send audio to this named output '
|
||||||
|
'(may be used more than once for multiple outputs)'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@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(
|
||||||
|
'--connect',
|
||||||
|
'connect_address',
|
||||||
|
metavar='ADDRESS_OR_NAME',
|
||||||
|
help='Address or name to connect to',
|
||||||
|
)
|
||||||
|
@click.option('--device-config', metavar='FILENAME', help='Device configuration file')
|
||||||
|
@click.argument('transport')
|
||||||
|
def speaker(
|
||||||
|
transport, codec, connect_address, discover, output, ui_port, device_config
|
||||||
|
):
|
||||||
|
"""Run the speaker."""
|
||||||
|
|
||||||
|
if '@ffplay' in output:
|
||||||
|
# Check if ffplay is installed
|
||||||
|
try:
|
||||||
|
subprocess.run(['ffplay', '-version'], capture_output=True, check=True)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(
|
||||||
|
color('ffplay not installed, @ffplay output will be disabled', 'yellow')
|
||||||
|
)
|
||||||
|
output = list(filter(lambda x: x != '@ffplay', output))
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
Speaker(device_config, transport, codec, discover, output, ui_port).run(
|
||||||
|
connect_address
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def main():
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||||
|
speaker()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main() # pylint: disable=no-value-for-parameter
|
||||||
@@ -22,40 +22,58 @@ import click
|
|||||||
|
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
from bumble.keys import JsonKeyStore
|
from bumble.keys import JsonKeyStore
|
||||||
|
from bumble.transport import open_transport
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def unbond_with_keystore(keystore, address):
|
||||||
|
if address is None:
|
||||||
|
return await keystore.print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await keystore.delete(address)
|
||||||
|
except KeyError:
|
||||||
|
print('!!! pairing not found')
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def unbond(keystore_file, device_config, address):
|
async def unbond(keystore_file, device_config, hci_transport, address):
|
||||||
# Create a device to manage the host
|
# With a keystore file, we can instantiate the keystore directly
|
||||||
device = Device.from_config_file(device_config)
|
|
||||||
|
|
||||||
# Get all entries in the keystore
|
|
||||||
if keystore_file:
|
if keystore_file:
|
||||||
keystore = JsonKeyStore(None, keystore_file)
|
return await unbond_with_keystore(JsonKeyStore(None, keystore_file), address)
|
||||||
else:
|
|
||||||
keystore = device.keystore
|
|
||||||
|
|
||||||
if keystore is None:
|
# Without a keystore file, we need to obtain the keystore from the device
|
||||||
print('no keystore')
|
async with await open_transport(hci_transport) as (hci_source, hci_sink):
|
||||||
return
|
# Create a device to manage the host
|
||||||
|
device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
|
||||||
|
|
||||||
if address is None:
|
# Power-on the device to ensure we have a key store
|
||||||
await keystore.print()
|
await device.power_on()
|
||||||
else:
|
|
||||||
try:
|
return await unbond_with_keystore(device.keystore, address)
|
||||||
await keystore.delete(address)
|
|
||||||
except KeyError:
|
|
||||||
print('!!! pairing not found')
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.option('--keystore-file', help='File in which to store the pairing keys')
|
@click.option('--keystore-file', help='File in which the pairing keys are stored')
|
||||||
@click.argument('device-config')
|
@click.option('--hci-transport', help='HCI transport for the controller')
|
||||||
|
@click.argument('device-config', required=False)
|
||||||
@click.argument('address', required=False)
|
@click.argument('address', required=False)
|
||||||
def main(keystore_file, device_config, address):
|
def main(keystore_file, hci_transport, device_config, address):
|
||||||
|
"""
|
||||||
|
Remove pairing keys for a device, given its address.
|
||||||
|
|
||||||
|
If no keystore file is specified, the --hci-transport option must be used to
|
||||||
|
connect to a controller, so that the keystore for that controller can be
|
||||||
|
instantiated.
|
||||||
|
If no address is passed, the existing pairing keys for all addresses are printed.
|
||||||
|
"""
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||||
asyncio.run(unbond(keystore_file, device_config, address))
|
|
||||||
|
if not keystore_file and not hci_transport:
|
||||||
|
print('either --keystore-file or --hci-transport must be specified.')
|
||||||
|
return
|
||||||
|
|
||||||
|
asyncio.run(unbond(keystore_file, device_config, hci_transport, address))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -432,6 +432,7 @@ class AacMediaCodecInformation(
|
|||||||
cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies
|
cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies
|
||||||
),
|
),
|
||||||
channels=sum(cls.CHANNELS_BITS[x] for x in channels),
|
channels=sum(cls.CHANNELS_BITS[x] for x in channels),
|
||||||
|
rfa=0,
|
||||||
vbr=vbr,
|
vbr=vbr,
|
||||||
bitrate=bitrate,
|
bitrate=bitrate,
|
||||||
)
|
)
|
||||||
|
|||||||
85
bumble/at.py
Normal file
85
bumble/at.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Copyright 2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
from typing import List, Union
|
||||||
|
|
||||||
|
|
||||||
|
def tokenize_parameters(buffer: bytes) -> List[bytes]:
|
||||||
|
"""Split input parameters into tokens.
|
||||||
|
Removes space characters outside of double quote blocks:
|
||||||
|
T-rec-V-25 - 5.2.1 Command line general format: "Space characters (IA5 2/0)
|
||||||
|
are ignored [..], unless they are embedded in numeric or string constants"
|
||||||
|
Raises ValueError in case of invalid input string."""
|
||||||
|
|
||||||
|
tokens = []
|
||||||
|
in_quotes = False
|
||||||
|
token = bytearray()
|
||||||
|
for b in buffer:
|
||||||
|
char = bytearray([b])
|
||||||
|
|
||||||
|
if in_quotes:
|
||||||
|
token.extend(char)
|
||||||
|
if char == b'\"':
|
||||||
|
in_quotes = False
|
||||||
|
tokens.append(token[1:-1])
|
||||||
|
token = bytearray()
|
||||||
|
else:
|
||||||
|
if char == b' ':
|
||||||
|
pass
|
||||||
|
elif char == b',' or char == b')':
|
||||||
|
tokens.append(token)
|
||||||
|
tokens.append(char)
|
||||||
|
token = bytearray()
|
||||||
|
elif char == b'(':
|
||||||
|
if len(token) > 0:
|
||||||
|
raise ValueError("open_paren following regular character")
|
||||||
|
tokens.append(char)
|
||||||
|
elif char == b'"':
|
||||||
|
if len(token) > 0:
|
||||||
|
raise ValueError("quote following regular character")
|
||||||
|
in_quotes = True
|
||||||
|
token.extend(char)
|
||||||
|
else:
|
||||||
|
token.extend(char)
|
||||||
|
|
||||||
|
tokens.append(token)
|
||||||
|
return [bytes(token) for token in tokens if len(token) > 0]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_parameters(buffer: bytes) -> List[Union[bytes, list]]:
|
||||||
|
"""Parse the parameters using the comma and parenthesis separators.
|
||||||
|
Raises ValueError in case of invalid input string."""
|
||||||
|
|
||||||
|
tokens = tokenize_parameters(buffer)
|
||||||
|
accumulator: List[list] = [[]]
|
||||||
|
current: Union[bytes, list] = bytes()
|
||||||
|
|
||||||
|
for token in tokens:
|
||||||
|
if token == b',':
|
||||||
|
accumulator[-1].append(current)
|
||||||
|
current = bytes()
|
||||||
|
elif token == b'(':
|
||||||
|
accumulator.append([])
|
||||||
|
elif token == b')':
|
||||||
|
if len(accumulator) < 2:
|
||||||
|
raise ValueError("close_paren without matching open_paren")
|
||||||
|
accumulator[-1].append(current)
|
||||||
|
current = accumulator.pop()
|
||||||
|
else:
|
||||||
|
current = token
|
||||||
|
|
||||||
|
accumulator[-1].append(current)
|
||||||
|
if len(accumulator) > 1:
|
||||||
|
raise ValueError("missing close_paren")
|
||||||
|
return accumulator[0]
|
||||||
@@ -1207,7 +1207,7 @@ class DelayReport_Reject(Simple_Reject):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Protocol:
|
class Protocol(EventEmitter):
|
||||||
SINGLE_PACKET = 0
|
SINGLE_PACKET = 0
|
||||||
START_PACKET = 1
|
START_PACKET = 1
|
||||||
CONTINUE_PACKET = 2
|
CONTINUE_PACKET = 2
|
||||||
@@ -1234,6 +1234,7 @@ class Protocol:
|
|||||||
return protocol
|
return protocol
|
||||||
|
|
||||||
def __init__(self, l2cap_channel, version=(1, 3)):
|
def __init__(self, l2cap_channel, version=(1, 3)):
|
||||||
|
super().__init__()
|
||||||
self.l2cap_channel = l2cap_channel
|
self.l2cap_channel = l2cap_channel
|
||||||
self.version = version
|
self.version = version
|
||||||
self.rtx_sig_timer = AVDTP_DEFAULT_RTX_SIG_TIMER
|
self.rtx_sig_timer = AVDTP_DEFAULT_RTX_SIG_TIMER
|
||||||
@@ -1250,6 +1251,7 @@ class Protocol:
|
|||||||
# Register to receive PDUs from the channel
|
# Register to receive PDUs from the channel
|
||||||
l2cap_channel.sink = self.on_pdu
|
l2cap_channel.sink = self.on_pdu
|
||||||
l2cap_channel.on('open', self.on_l2cap_channel_open)
|
l2cap_channel.on('open', self.on_l2cap_channel_open)
|
||||||
|
l2cap_channel.on('close', self.on_l2cap_channel_close)
|
||||||
|
|
||||||
def get_local_endpoint_by_seid(self, seid):
|
def get_local_endpoint_by_seid(self, seid):
|
||||||
if 0 < seid <= len(self.local_endpoints):
|
if 0 < seid <= len(self.local_endpoints):
|
||||||
@@ -1392,11 +1394,18 @@ class Protocol:
|
|||||||
|
|
||||||
def on_l2cap_connection(self, channel):
|
def on_l2cap_connection(self, channel):
|
||||||
# Forward the channel to the endpoint that's expecting it
|
# Forward the channel to the endpoint that's expecting it
|
||||||
if self.channel_acceptor:
|
if self.channel_acceptor is None:
|
||||||
self.channel_acceptor.on_l2cap_connection(channel)
|
logger.warning(color('!!! l2cap connection with no acceptor', 'red'))
|
||||||
|
return
|
||||||
|
self.channel_acceptor.on_l2cap_connection(channel)
|
||||||
|
|
||||||
def on_l2cap_channel_open(self):
|
def on_l2cap_channel_open(self):
|
||||||
logger.debug(color('<<< L2CAP channel open', 'magenta'))
|
logger.debug(color('<<< L2CAP channel open', 'magenta'))
|
||||||
|
self.emit('open')
|
||||||
|
|
||||||
|
def on_l2cap_channel_close(self):
|
||||||
|
logger.debug(color('<<< L2CAP channel close', 'magenta'))
|
||||||
|
self.emit('close')
|
||||||
|
|
||||||
def send_message(self, transaction_label, message):
|
def send_message(self, transaction_label, message):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -1651,6 +1660,10 @@ class Listener(EventEmitter):
|
|||||||
def set_server(self, connection, server):
|
def set_server(self, connection, server):
|
||||||
self.servers[connection.handle] = server
|
self.servers[connection.handle] = server
|
||||||
|
|
||||||
|
def remove_server(self, connection):
|
||||||
|
if connection.handle in self.servers:
|
||||||
|
del self.servers[connection.handle]
|
||||||
|
|
||||||
def __init__(self, registrar, version=(1, 3)):
|
def __init__(self, registrar, version=(1, 3)):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.version = version
|
self.version = version
|
||||||
@@ -1669,11 +1682,17 @@ class Listener(EventEmitter):
|
|||||||
else:
|
else:
|
||||||
# This is a new command/response channel
|
# This is a new command/response channel
|
||||||
def on_channel_open():
|
def on_channel_open():
|
||||||
|
logger.debug('setting up new Protocol for the connection')
|
||||||
server = Protocol(channel, self.version)
|
server = Protocol(channel, self.version)
|
||||||
self.set_server(channel.connection, server)
|
self.set_server(channel.connection, server)
|
||||||
self.emit('connection', server)
|
self.emit('connection', server)
|
||||||
|
|
||||||
|
def on_channel_close():
|
||||||
|
logger.debug('removing Protocol for the connection')
|
||||||
|
self.remove_server(channel.connection)
|
||||||
|
|
||||||
channel.on('open', on_channel_open)
|
channel.on('open', on_channel_open)
|
||||||
|
channel.on('close', on_channel_close)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -1967,11 +1986,12 @@ class DiscoveredStreamEndPoint(StreamEndPoint, StreamEndPointProxy):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class LocalStreamEndPoint(StreamEndPoint):
|
class LocalStreamEndPoint(StreamEndPoint, EventEmitter):
|
||||||
def __init__(
|
def __init__(
|
||||||
self, protocol, seid, media_type, tsep, capabilities, configuration=None
|
self, protocol, seid, media_type, tsep, capabilities, configuration=None
|
||||||
):
|
):
|
||||||
super().__init__(seid, media_type, tsep, 0, capabilities)
|
StreamEndPoint.__init__(self, seid, media_type, tsep, 0, capabilities)
|
||||||
|
EventEmitter.__init__(self)
|
||||||
self.protocol = protocol
|
self.protocol = protocol
|
||||||
self.configuration = configuration if configuration is not None else []
|
self.configuration = configuration if configuration is not None else []
|
||||||
self.stream = None
|
self.stream = None
|
||||||
@@ -1988,40 +2008,47 @@ class LocalStreamEndPoint(StreamEndPoint):
|
|||||||
def on_reconfigure_command(self, command):
|
def on_reconfigure_command(self, command):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def on_set_configuration_command(self, configuration):
|
||||||
|
logger.debug(
|
||||||
|
'<<< received configuration: '
|
||||||
|
f'{",".join([str(capability) for capability in configuration])}'
|
||||||
|
)
|
||||||
|
self.configuration = configuration
|
||||||
|
self.emit('configuration')
|
||||||
|
|
||||||
def on_get_configuration_command(self):
|
def on_get_configuration_command(self):
|
||||||
return Get_Configuration_Response(self.configuration)
|
return Get_Configuration_Response(self.configuration)
|
||||||
|
|
||||||
def on_open_command(self):
|
def on_open_command(self):
|
||||||
pass
|
self.emit('open')
|
||||||
|
|
||||||
def on_start_command(self):
|
def on_start_command(self):
|
||||||
pass
|
self.emit('start')
|
||||||
|
|
||||||
def on_suspend_command(self):
|
def on_suspend_command(self):
|
||||||
pass
|
self.emit('suspend')
|
||||||
|
|
||||||
def on_close_command(self):
|
def on_close_command(self):
|
||||||
pass
|
self.emit('close')
|
||||||
|
|
||||||
def on_abort_command(self):
|
def on_abort_command(self):
|
||||||
pass
|
self.emit('abort')
|
||||||
|
|
||||||
def on_rtp_channel_open(self):
|
def on_rtp_channel_open(self):
|
||||||
pass
|
self.emit('rtp_channel_open')
|
||||||
|
|
||||||
def on_rtp_channel_close(self):
|
def on_rtp_channel_close(self):
|
||||||
pass
|
self.emit('rtp_channel_close')
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class LocalSource(LocalStreamEndPoint, EventEmitter):
|
class LocalSource(LocalStreamEndPoint):
|
||||||
def __init__(self, protocol, seid, codec_capabilities, packet_pump):
|
def __init__(self, protocol, seid, codec_capabilities, packet_pump):
|
||||||
capabilities = [
|
capabilities = [
|
||||||
ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
|
ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
|
||||||
codec_capabilities,
|
codec_capabilities,
|
||||||
]
|
]
|
||||||
LocalStreamEndPoint.__init__(
|
super().__init__(
|
||||||
self,
|
|
||||||
protocol,
|
protocol,
|
||||||
seid,
|
seid,
|
||||||
codec_capabilities.media_type,
|
codec_capabilities.media_type,
|
||||||
@@ -2029,14 +2056,13 @@ class LocalSource(LocalStreamEndPoint, EventEmitter):
|
|||||||
capabilities,
|
capabilities,
|
||||||
capabilities,
|
capabilities,
|
||||||
)
|
)
|
||||||
EventEmitter.__init__(self)
|
|
||||||
self.packet_pump = packet_pump
|
self.packet_pump = packet_pump
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
if self.packet_pump:
|
if self.packet_pump:
|
||||||
return await self.packet_pump.start(self.stream.rtp_channel)
|
return await self.packet_pump.start(self.stream.rtp_channel)
|
||||||
|
|
||||||
self.emit('start', self.stream.rtp_channel)
|
self.emit('start')
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
if self.packet_pump:
|
if self.packet_pump:
|
||||||
@@ -2044,11 +2070,6 @@ class LocalSource(LocalStreamEndPoint, EventEmitter):
|
|||||||
|
|
||||||
self.emit('stop')
|
self.emit('stop')
|
||||||
|
|
||||||
def on_set_configuration_command(self, configuration):
|
|
||||||
# For now, blindly accept the configuration
|
|
||||||
logger.debug(f'<<< received source configuration: {configuration}')
|
|
||||||
self.configuration = configuration
|
|
||||||
|
|
||||||
def on_start_command(self):
|
def on_start_command(self):
|
||||||
asyncio.create_task(self.start())
|
asyncio.create_task(self.start())
|
||||||
|
|
||||||
@@ -2057,30 +2078,28 @@ class LocalSource(LocalStreamEndPoint, EventEmitter):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class LocalSink(LocalStreamEndPoint, EventEmitter):
|
class LocalSink(LocalStreamEndPoint):
|
||||||
def __init__(self, protocol, seid, codec_capabilities):
|
def __init__(self, protocol, seid, codec_capabilities):
|
||||||
capabilities = [
|
capabilities = [
|
||||||
ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
|
ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
|
||||||
codec_capabilities,
|
codec_capabilities,
|
||||||
]
|
]
|
||||||
LocalStreamEndPoint.__init__(
|
super().__init__(
|
||||||
self,
|
|
||||||
protocol,
|
protocol,
|
||||||
seid,
|
seid,
|
||||||
codec_capabilities.media_type,
|
codec_capabilities.media_type,
|
||||||
AVDTP_TSEP_SNK,
|
AVDTP_TSEP_SNK,
|
||||||
capabilities,
|
capabilities,
|
||||||
)
|
)
|
||||||
EventEmitter.__init__(self)
|
|
||||||
|
|
||||||
def on_set_configuration_command(self, configuration):
|
|
||||||
# For now, blindly accept the configuration
|
|
||||||
logger.debug(f'<<< received sink configuration: {configuration}')
|
|
||||||
self.configuration = configuration
|
|
||||||
|
|
||||||
def on_rtp_channel_open(self):
|
def on_rtp_channel_open(self):
|
||||||
logger.debug(color('<<< RTP channel open', 'magenta'))
|
logger.debug(color('<<< RTP channel open', 'magenta'))
|
||||||
self.stream.rtp_channel.sink = self.on_avdtp_packet
|
self.stream.rtp_channel.sink = self.on_avdtp_packet
|
||||||
|
super().on_rtp_channel_open()
|
||||||
|
|
||||||
|
def on_rtp_channel_close(self):
|
||||||
|
logger.debug(color('<<< RTP channel close', 'magenta'))
|
||||||
|
super().on_rtp_channel_close()
|
||||||
|
|
||||||
def on_avdtp_packet(self, packet):
|
def on_avdtp_packet(self, packet):
|
||||||
rtp_packet = MediaPacket.from_bytes(packet)
|
rtp_packet = MediaPacket.from_bytes(packet)
|
||||||
|
|||||||
381
bumble/codecs.py
Normal file
381
bumble/codecs.py
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
# Copyright 2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class BitReader:
|
||||||
|
"""Simple but not optimized bit stream reader."""
|
||||||
|
|
||||||
|
data: bytes
|
||||||
|
bytes_position: int
|
||||||
|
bit_position: int
|
||||||
|
cache: int
|
||||||
|
bits_cached: int
|
||||||
|
|
||||||
|
def __init__(self, data: bytes):
|
||||||
|
self.data = data
|
||||||
|
self.byte_position = 0
|
||||||
|
self.bit_position = 0
|
||||||
|
self.cache = 0
|
||||||
|
self.bits_cached = 0
|
||||||
|
|
||||||
|
def read(self, bits: int) -> int:
|
||||||
|
""" "Read up to 32 bits."""
|
||||||
|
|
||||||
|
if bits > 32:
|
||||||
|
raise ValueError('maximum read size is 32')
|
||||||
|
|
||||||
|
if self.bits_cached >= bits:
|
||||||
|
# We have enough bits.
|
||||||
|
self.bits_cached -= bits
|
||||||
|
self.bit_position += bits
|
||||||
|
return (self.cache >> self.bits_cached) & ((1 << bits) - 1)
|
||||||
|
|
||||||
|
# Read more cache, up to 32 bits
|
||||||
|
feed_bytes = self.data[self.byte_position : self.byte_position + 4]
|
||||||
|
feed_size = len(feed_bytes)
|
||||||
|
feed_int = int.from_bytes(feed_bytes, byteorder='big')
|
||||||
|
if 8 * feed_size + self.bits_cached < bits:
|
||||||
|
raise ValueError('trying to read past the data')
|
||||||
|
self.byte_position += feed_size
|
||||||
|
|
||||||
|
# Combine the new cache and the old cache
|
||||||
|
cache = self.cache & ((1 << self.bits_cached) - 1)
|
||||||
|
new_bits = bits - self.bits_cached
|
||||||
|
self.bits_cached = 8 * feed_size - new_bits
|
||||||
|
result = (feed_int >> self.bits_cached) | (cache << new_bits)
|
||||||
|
self.cache = feed_int
|
||||||
|
|
||||||
|
self.bit_position += bits
|
||||||
|
return result
|
||||||
|
|
||||||
|
def read_bytes(self, count: int):
|
||||||
|
if self.bit_position + 8 * count > 8 * len(self.data):
|
||||||
|
raise ValueError('not enough data')
|
||||||
|
|
||||||
|
if self.bit_position % 8:
|
||||||
|
# Not byte aligned
|
||||||
|
result = bytearray(count)
|
||||||
|
for i in range(count):
|
||||||
|
result[i] = self.read(8)
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
# Byte aligned
|
||||||
|
self.byte_position = self.bit_position // 8
|
||||||
|
self.bits_cached = 0
|
||||||
|
self.cache = 0
|
||||||
|
offset = self.bit_position // 8
|
||||||
|
self.bit_position += 8 * count
|
||||||
|
return self.data[offset : offset + count]
|
||||||
|
|
||||||
|
def bits_left(self) -> int:
|
||||||
|
return (8 * len(self.data)) - self.bit_position
|
||||||
|
|
||||||
|
def skip(self, bits: int) -> None:
|
||||||
|
# Slow, but simple...
|
||||||
|
while bits:
|
||||||
|
if bits > 32:
|
||||||
|
self.read(32)
|
||||||
|
bits -= 32
|
||||||
|
else:
|
||||||
|
self.read(bits)
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class AacAudioRtpPacket:
|
||||||
|
"""AAC payload encapsulated in an RTP packet payload"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def latm_value(reader: BitReader) -> int:
|
||||||
|
bytes_for_value = reader.read(2)
|
||||||
|
value = 0
|
||||||
|
for _ in range(bytes_for_value + 1):
|
||||||
|
value = value * 256 + reader.read(8)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def program_config_element(reader: BitReader):
|
||||||
|
raise ValueError('program_config_element not supported')
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GASpecificConfig:
|
||||||
|
def __init__(
|
||||||
|
self, reader: BitReader, channel_configuration: int, audio_object_type: int
|
||||||
|
) -> None:
|
||||||
|
# GASpecificConfig - ISO/EIC 14496-3 Table 4.1
|
||||||
|
frame_length_flag = reader.read(1)
|
||||||
|
depends_on_core_coder = reader.read(1)
|
||||||
|
if depends_on_core_coder:
|
||||||
|
self.core_coder_delay = reader.read(14)
|
||||||
|
extension_flag = reader.read(1)
|
||||||
|
if not channel_configuration:
|
||||||
|
AacAudioRtpPacket.program_config_element(reader)
|
||||||
|
if audio_object_type in (6, 20):
|
||||||
|
self.layer_nr = reader.read(3)
|
||||||
|
if extension_flag:
|
||||||
|
if audio_object_type == 22:
|
||||||
|
num_of_sub_frame = reader.read(5)
|
||||||
|
layer_length = reader.read(11)
|
||||||
|
if audio_object_type in (17, 19, 20, 23):
|
||||||
|
aac_section_data_resilience_flags = reader.read(1)
|
||||||
|
aac_scale_factor_data_resilience_flags = reader.read(1)
|
||||||
|
aac_spectral_data_resilience_flags = reader.read(1)
|
||||||
|
extension_flag_3 = reader.read(1)
|
||||||
|
if extension_flag_3 == 1:
|
||||||
|
raise ValueError('extensionFlag3 == 1 not supported')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def audio_object_type(reader: BitReader):
|
||||||
|
# GetAudioObjectType - ISO/EIC 14496-3 Table 1.16
|
||||||
|
audio_object_type = reader.read(5)
|
||||||
|
if audio_object_type == 31:
|
||||||
|
audio_object_type = 32 + reader.read(6)
|
||||||
|
|
||||||
|
return audio_object_type
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioSpecificConfig:
|
||||||
|
audio_object_type: int
|
||||||
|
sampling_frequency_index: int
|
||||||
|
sampling_frequency: int
|
||||||
|
channel_configuration: int
|
||||||
|
sbr_present_flag: int
|
||||||
|
ps_present_flag: int
|
||||||
|
extension_audio_object_type: int
|
||||||
|
extension_sampling_frequency_index: int
|
||||||
|
extension_sampling_frequency: int
|
||||||
|
extension_channel_configuration: int
|
||||||
|
|
||||||
|
SAMPLING_FREQUENCIES = [
|
||||||
|
96000,
|
||||||
|
88200,
|
||||||
|
64000,
|
||||||
|
48000,
|
||||||
|
44100,
|
||||||
|
32000,
|
||||||
|
24000,
|
||||||
|
22050,
|
||||||
|
16000,
|
||||||
|
12000,
|
||||||
|
11025,
|
||||||
|
8000,
|
||||||
|
7350,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, reader: BitReader) -> None:
|
||||||
|
# AudioSpecificConfig - ISO/EIC 14496-3 Table 1.15
|
||||||
|
self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
|
||||||
|
self.sampling_frequency_index = reader.read(4)
|
||||||
|
if self.sampling_frequency_index == 0xF:
|
||||||
|
self.sampling_frequency = reader.read(24)
|
||||||
|
else:
|
||||||
|
self.sampling_frequency = self.SAMPLING_FREQUENCIES[
|
||||||
|
self.sampling_frequency_index
|
||||||
|
]
|
||||||
|
self.channel_configuration = reader.read(4)
|
||||||
|
self.sbr_present_flag = -1
|
||||||
|
self.ps_present_flag = -1
|
||||||
|
if self.audio_object_type in (5, 29):
|
||||||
|
self.extension_audio_object_type = 5
|
||||||
|
self.sbc_present_flag = 1
|
||||||
|
if self.audio_object_type == 29:
|
||||||
|
self.ps_present_flag = 1
|
||||||
|
self.extension_sampling_frequency_index = reader.read(4)
|
||||||
|
if self.extension_sampling_frequency_index == 0xF:
|
||||||
|
self.extension_sampling_frequency = reader.read(24)
|
||||||
|
else:
|
||||||
|
self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[
|
||||||
|
self.extension_sampling_frequency_index
|
||||||
|
]
|
||||||
|
self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
|
||||||
|
if self.audio_object_type == 22:
|
||||||
|
self.extension_channel_configuration = reader.read(4)
|
||||||
|
else:
|
||||||
|
self.extension_audio_object_type = 0
|
||||||
|
|
||||||
|
if self.audio_object_type in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23):
|
||||||
|
ga_specific_config = AacAudioRtpPacket.GASpecificConfig(
|
||||||
|
reader, self.channel_configuration, self.audio_object_type
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f'audioObjectType {self.audio_object_type} not supported'
|
||||||
|
)
|
||||||
|
|
||||||
|
# if self.extension_audio_object_type != 5 and bits_to_decode >= 16:
|
||||||
|
# sync_extension_type = reader.read(11)
|
||||||
|
# if sync_extension_type == 0x2B7:
|
||||||
|
# self.extension_audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
|
||||||
|
# if self.extension_audio_object_type == 5:
|
||||||
|
# self.sbr_present_flag = reader.read(1)
|
||||||
|
# if self.sbr_present_flag:
|
||||||
|
# self.extension_sampling_frequency_index = reader.read(4)
|
||||||
|
# if self.extension_sampling_frequency_index == 0xF:
|
||||||
|
# self.extension_sampling_frequency = reader.read(24)
|
||||||
|
# else:
|
||||||
|
# self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index]
|
||||||
|
# if bits_to_decode >= 12:
|
||||||
|
# sync_extension_type = reader.read(11)
|
||||||
|
# if sync_extension_type == 0x548:
|
||||||
|
# self.ps_present_flag = reader.read(1)
|
||||||
|
# elif self.extension_audio_object_type == 22:
|
||||||
|
# self.sbr_present_flag = reader.read(1)
|
||||||
|
# if self.sbr_present_flag:
|
||||||
|
# self.extension_sampling_frequency_index = reader.read(4)
|
||||||
|
# if self.extension_sampling_frequency_index == 0xF:
|
||||||
|
# self.extension_sampling_frequency = reader.read(24)
|
||||||
|
# else:
|
||||||
|
# self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index]
|
||||||
|
# self.extension_channel_configuration = reader.read(4)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StreamMuxConfig:
|
||||||
|
other_data_present: int
|
||||||
|
other_data_len_bits: int
|
||||||
|
audio_specific_config: AacAudioRtpPacket.AudioSpecificConfig
|
||||||
|
|
||||||
|
def __init__(self, reader: BitReader) -> None:
|
||||||
|
# StreamMuxConfig - ISO/EIC 14496-3 Table 1.42
|
||||||
|
audio_mux_version = reader.read(1)
|
||||||
|
if audio_mux_version == 1:
|
||||||
|
audio_mux_version_a = reader.read(1)
|
||||||
|
else:
|
||||||
|
audio_mux_version_a = 0
|
||||||
|
if audio_mux_version_a != 0:
|
||||||
|
raise ValueError('audioMuxVersionA != 0 not supported')
|
||||||
|
if audio_mux_version == 1:
|
||||||
|
tara_buffer_fullness = AacAudioRtpPacket.latm_value(reader)
|
||||||
|
stream_cnt = 0
|
||||||
|
all_streams_same_time_framing = reader.read(1)
|
||||||
|
num_sub_frames = reader.read(6)
|
||||||
|
num_program = reader.read(4)
|
||||||
|
if num_program != 0:
|
||||||
|
raise ValueError('num_program != 0 not supported')
|
||||||
|
num_layer = reader.read(3)
|
||||||
|
if num_layer != 0:
|
||||||
|
raise ValueError('num_layer != 0 not supported')
|
||||||
|
if audio_mux_version == 0:
|
||||||
|
self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
|
||||||
|
reader
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
asc_len = AacAudioRtpPacket.latm_value(reader)
|
||||||
|
marker = reader.bit_position
|
||||||
|
self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
|
||||||
|
reader
|
||||||
|
)
|
||||||
|
audio_specific_config_len = reader.bit_position - marker
|
||||||
|
if asc_len < audio_specific_config_len:
|
||||||
|
raise ValueError('audio_specific_config_len > asc_len')
|
||||||
|
asc_len -= audio_specific_config_len
|
||||||
|
reader.skip(asc_len)
|
||||||
|
frame_length_type = reader.read(3)
|
||||||
|
if frame_length_type == 0:
|
||||||
|
latm_buffer_fullness = reader.read(8)
|
||||||
|
elif frame_length_type == 1:
|
||||||
|
frame_length = reader.read(9)
|
||||||
|
else:
|
||||||
|
raise ValueError(f'frame_length_type {frame_length_type} not supported')
|
||||||
|
|
||||||
|
self.other_data_present = reader.read(1)
|
||||||
|
if self.other_data_present:
|
||||||
|
if audio_mux_version == 1:
|
||||||
|
self.other_data_len_bits = AacAudioRtpPacket.latm_value(reader)
|
||||||
|
else:
|
||||||
|
self.other_data_len_bits = 0
|
||||||
|
while True:
|
||||||
|
self.other_data_len_bits *= 256
|
||||||
|
other_data_len_esc = reader.read(1)
|
||||||
|
self.other_data_len_bits += reader.read(8)
|
||||||
|
if other_data_len_esc == 0:
|
||||||
|
break
|
||||||
|
crc_check_present = reader.read(1)
|
||||||
|
if crc_check_present:
|
||||||
|
crc_checksum = reader.read(8)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioMuxElement:
|
||||||
|
payload: bytes
|
||||||
|
stream_mux_config: AacAudioRtpPacket.StreamMuxConfig
|
||||||
|
|
||||||
|
def __init__(self, reader: BitReader, mux_config_present: int):
|
||||||
|
if mux_config_present == 0:
|
||||||
|
raise ValueError('muxConfigPresent == 0 not supported')
|
||||||
|
|
||||||
|
# AudioMuxElement - ISO/EIC 14496-3 Table 1.41
|
||||||
|
use_same_stream_mux = reader.read(1)
|
||||||
|
if use_same_stream_mux:
|
||||||
|
raise ValueError('useSameStreamMux == 1 not supported')
|
||||||
|
self.stream_mux_config = AacAudioRtpPacket.StreamMuxConfig(reader)
|
||||||
|
|
||||||
|
# We only support:
|
||||||
|
# allStreamsSameTimeFraming == 1
|
||||||
|
# audioMuxVersionA == 0,
|
||||||
|
# numProgram == 0
|
||||||
|
# numSubFrames == 0
|
||||||
|
# numLayer == 0
|
||||||
|
|
||||||
|
mux_slot_length_bytes = 0
|
||||||
|
while True:
|
||||||
|
tmp = reader.read(8)
|
||||||
|
mux_slot_length_bytes += tmp
|
||||||
|
if tmp != 255:
|
||||||
|
break
|
||||||
|
|
||||||
|
self.payload = reader.read_bytes(mux_slot_length_bytes)
|
||||||
|
|
||||||
|
if self.stream_mux_config.other_data_present:
|
||||||
|
reader.skip(self.stream_mux_config.other_data_len_bits)
|
||||||
|
|
||||||
|
# ByteAlign
|
||||||
|
while reader.bit_position % 8:
|
||||||
|
reader.read(1)
|
||||||
|
|
||||||
|
def __init__(self, data: bytes) -> None:
|
||||||
|
# Parse the bit stream
|
||||||
|
reader = BitReader(data)
|
||||||
|
self.audio_mux_element = self.AudioMuxElement(reader, mux_config_present=1)
|
||||||
|
|
||||||
|
def to_adts(self):
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
sampling_frequency_index = (
|
||||||
|
self.audio_mux_element.stream_mux_config.audio_specific_config.sampling_frequency_index
|
||||||
|
)
|
||||||
|
channel_configuration = (
|
||||||
|
self.audio_mux_element.stream_mux_config.audio_specific_config.channel_configuration
|
||||||
|
)
|
||||||
|
frame_size = len(self.audio_mux_element.payload)
|
||||||
|
return (
|
||||||
|
bytes(
|
||||||
|
[
|
||||||
|
0xFF,
|
||||||
|
0xF1, # 0xF9 (MPEG2)
|
||||||
|
0x40
|
||||||
|
| (sampling_frequency_index << 2)
|
||||||
|
| (channel_configuration >> 2),
|
||||||
|
((channel_configuration & 0x3) << 6) | ((frame_size + 7) >> 11),
|
||||||
|
((frame_size + 7) >> 3) & 0xFF,
|
||||||
|
(((frame_size + 7) << 5) & 0xFF) | 0x1F,
|
||||||
|
0xFC,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ self.audio_mux_element.payload
|
||||||
|
)
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import itertools
|
import itertools
|
||||||
@@ -58,8 +60,10 @@ from bumble.hci import (
|
|||||||
HCI_Packet,
|
HCI_Packet,
|
||||||
HCI_Role_Change_Event,
|
HCI_Role_Change_Event,
|
||||||
)
|
)
|
||||||
from typing import Optional, Union, Dict
|
from typing import Optional, Union, Dict, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble.transport.common import TransportSink, TransportSource
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -104,7 +108,7 @@ class Controller:
|
|||||||
self,
|
self,
|
||||||
name,
|
name,
|
||||||
host_source=None,
|
host_source=None,
|
||||||
host_sink=None,
|
host_sink: Optional[TransportSink] = None,
|
||||||
link=None,
|
link=None,
|
||||||
public_address: Optional[Union[bytes, str, Address]] = None,
|
public_address: Optional[Union[bytes, str, Address]] = None,
|
||||||
):
|
):
|
||||||
@@ -188,6 +192,8 @@ class Controller:
|
|||||||
if link:
|
if link:
|
||||||
link.add_controller(self)
|
link.add_controller(self)
|
||||||
|
|
||||||
|
self.terminated = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def host(self):
|
def host(self):
|
||||||
return self.hci_sink
|
return self.hci_sink
|
||||||
@@ -288,10 +294,9 @@ class Controller:
|
|||||||
if self.host:
|
if self.host:
|
||||||
self.host.on_packet(packet.to_bytes())
|
self.host.on_packet(packet.to_bytes())
|
||||||
|
|
||||||
# This method allow the controller to emulate the same API as a transport source
|
# This method allows the controller to emulate the same API as a transport source
|
||||||
async def wait_for_termination(self):
|
async def wait_for_termination(self):
|
||||||
# For now, just wait forever
|
await self.terminated
|
||||||
await asyncio.get_running_loop().create_future()
|
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# Link connections
|
# Link connections
|
||||||
@@ -654,7 +659,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_create_connection_command(self, command):
|
def on_hci_create_connection_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.1.5 Create Connection command
|
See Bluetooth spec Vol 4, Part E - 7.1.5 Create Connection command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
if self.link is None:
|
if self.link is None:
|
||||||
@@ -685,7 +690,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_disconnect_command(self, command):
|
def on_hci_disconnect_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.1.6 Disconnect Command
|
See Bluetooth spec Vol 4, Part E - 7.1.6 Disconnect Command
|
||||||
'''
|
'''
|
||||||
# First, say that the disconnection is pending
|
# First, say that the disconnection is pending
|
||||||
self.send_hci_packet(
|
self.send_hci_packet(
|
||||||
@@ -719,7 +724,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_accept_connection_request_command(self, command):
|
def on_hci_accept_connection_request_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.1.8 Accept Connection Request command
|
See Bluetooth spec Vol 4, Part E - 7.1.8 Accept Connection Request command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
if self.link is None:
|
if self.link is None:
|
||||||
@@ -735,7 +740,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_switch_role_command(self, command):
|
def on_hci_switch_role_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.2.8 Switch Role command
|
See Bluetooth spec Vol 4, Part E - 7.2.8 Switch Role command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
if self.link is None:
|
if self.link is None:
|
||||||
@@ -751,21 +756,21 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_set_event_mask_command(self, command):
|
def on_hci_set_event_mask_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.1 Set Event Mask Command
|
See Bluetooth spec Vol 4, Part E - 7.3.1 Set Event Mask Command
|
||||||
'''
|
'''
|
||||||
self.event_mask = command.event_mask
|
self.event_mask = command.event_mask
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_reset_command(self, _command):
|
def on_hci_reset_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.2 Reset Command
|
See Bluetooth spec Vol 4, Part E - 7.3.2 Reset Command
|
||||||
'''
|
'''
|
||||||
# TODO: cleanup what needs to be reset
|
# TODO: cleanup what needs to be reset
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_write_local_name_command(self, command):
|
def on_hci_write_local_name_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.11 Write Local Name Command
|
See Bluetooth spec Vol 4, Part E - 7.3.11 Write Local Name Command
|
||||||
'''
|
'''
|
||||||
local_name = command.local_name
|
local_name = command.local_name
|
||||||
if len(local_name):
|
if len(local_name):
|
||||||
@@ -780,7 +785,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_read_local_name_command(self, _command):
|
def on_hci_read_local_name_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.12 Read Local Name Command
|
See Bluetooth spec Vol 4, Part E - 7.3.12 Read Local Name Command
|
||||||
'''
|
'''
|
||||||
local_name = bytes(self.local_name, 'utf-8')[:248]
|
local_name = bytes(self.local_name, 'utf-8')[:248]
|
||||||
if len(local_name) < 248:
|
if len(local_name) < 248:
|
||||||
@@ -790,19 +795,19 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_read_class_of_device_command(self, _command):
|
def on_hci_read_class_of_device_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.25 Read Class of Device Command
|
See Bluetooth spec Vol 4, Part E - 7.3.25 Read Class of Device Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS, 0, 0, 0])
|
return bytes([HCI_SUCCESS, 0, 0, 0])
|
||||||
|
|
||||||
def on_hci_write_class_of_device_command(self, _command):
|
def on_hci_write_class_of_device_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.26 Write Class of Device Command
|
See Bluetooth spec Vol 4, Part E - 7.3.26 Write Class of Device Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_read_synchronous_flow_control_enable_command(self, _command):
|
def on_hci_read_synchronous_flow_control_enable_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.36 Read Synchronous Flow Control Enable
|
See Bluetooth spec Vol 4, Part E - 7.3.36 Read Synchronous Flow Control Enable
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
if self.sync_flow_control:
|
if self.sync_flow_control:
|
||||||
@@ -813,7 +818,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_write_synchronous_flow_control_enable_command(self, command):
|
def on_hci_write_synchronous_flow_control_enable_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.37 Write Synchronous Flow Control Enable
|
See Bluetooth spec Vol 4, Part E - 7.3.37 Write Synchronous Flow Control Enable
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
ret = HCI_SUCCESS
|
ret = HCI_SUCCESS
|
||||||
@@ -825,41 +830,59 @@ class Controller:
|
|||||||
ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR
|
ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR
|
||||||
return bytes([ret])
|
return bytes([ret])
|
||||||
|
|
||||||
|
def on_hci_set_controller_to_host_flow_control_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.3.38 Set Controller To Host Flow Control
|
||||||
|
Command
|
||||||
|
'''
|
||||||
|
# For now we just accept the command but ignore the values.
|
||||||
|
# TODO: respect the passed in values.
|
||||||
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
|
def on_hci_host_buffer_size_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.3.39 Host Buffer Size Command
|
||||||
|
'''
|
||||||
|
# For now we just accept the command but ignore the values.
|
||||||
|
# TODO: respect the passed in values.
|
||||||
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_write_extended_inquiry_response_command(self, _command):
|
def on_hci_write_extended_inquiry_response_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.59 Write Simple Pairing Mode Command
|
See Bluetooth spec Vol 4, Part E - 7.3.56 Write Extended Inquiry Response
|
||||||
|
Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_write_simple_pairing_mode_command(self, _command):
|
def on_hci_write_simple_pairing_mode_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.59 Write Simple Pairing Mode Command
|
See Bluetooth spec Vol 4, Part E - 7.3.59 Write Simple Pairing Mode Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_set_event_mask_page_2_command(self, command):
|
def on_hci_set_event_mask_page_2_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.69 Set Event Mask Page 2 Command
|
See Bluetooth spec Vol 4, Part E - 7.3.69 Set Event Mask Page 2 Command
|
||||||
'''
|
'''
|
||||||
self.event_mask_page_2 = command.event_mask_page_2
|
self.event_mask_page_2 = command.event_mask_page_2
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_read_le_host_support_command(self, _command):
|
def on_hci_read_le_host_support_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.78 Write LE Host Support Command
|
See Bluetooth spec Vol 4, Part E - 7.3.78 Write LE Host Support Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS, 1, 0])
|
return bytes([HCI_SUCCESS, 1, 0])
|
||||||
|
|
||||||
def on_hci_write_le_host_support_command(self, _command):
|
def on_hci_write_le_host_support_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.79 Write LE Host Support Command
|
See Bluetooth spec Vol 4, Part E - 7.3.79 Write LE Host Support Command
|
||||||
'''
|
'''
|
||||||
# TODO / Just ignore for now
|
# TODO / Just ignore for now
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_write_authenticated_payload_timeout_command(self, command):
|
def on_hci_write_authenticated_payload_timeout_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.94 Write Authenticated Payload Timeout
|
See Bluetooth spec Vol 4, Part E - 7.3.94 Write Authenticated Payload Timeout
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
# TODO
|
# TODO
|
||||||
@@ -867,7 +890,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_read_local_version_information_command(self, _command):
|
def on_hci_read_local_version_information_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.4.1 Read Local Version Information Command
|
See Bluetooth spec Vol 4, Part E - 7.4.1 Read Local Version Information Command
|
||||||
'''
|
'''
|
||||||
return struct.pack(
|
return struct.pack(
|
||||||
'<BBHBHH',
|
'<BBHBHH',
|
||||||
@@ -881,19 +904,19 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_read_local_supported_commands_command(self, _command):
|
def on_hci_read_local_supported_commands_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.4.2 Read Local Supported Commands Command
|
See Bluetooth spec Vol 4, Part E - 7.4.2 Read Local Supported Commands Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS]) + self.supported_commands
|
return bytes([HCI_SUCCESS]) + self.supported_commands
|
||||||
|
|
||||||
def on_hci_read_local_supported_features_command(self, _command):
|
def on_hci_read_local_supported_features_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.4.3 Read Local Supported Features Command
|
See Bluetooth spec Vol 4, Part E - 7.4.3 Read Local Supported Features Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS]) + self.lmp_features
|
return bytes([HCI_SUCCESS]) + self.lmp_features
|
||||||
|
|
||||||
def on_hci_read_bd_addr_command(self, _command):
|
def on_hci_read_bd_addr_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.4.6 Read BD_ADDR Command
|
See Bluetooth spec Vol 4, Part E - 7.4.6 Read BD_ADDR Command
|
||||||
'''
|
'''
|
||||||
bd_addr = (
|
bd_addr = (
|
||||||
self._public_address.to_bytes()
|
self._public_address.to_bytes()
|
||||||
@@ -904,14 +927,14 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_set_event_mask_command(self, command):
|
def on_hci_le_set_event_mask_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.1 LE Set Event Mask Command
|
See Bluetooth spec Vol 4, Part E - 7.8.1 LE Set Event Mask Command
|
||||||
'''
|
'''
|
||||||
self.le_event_mask = command.le_event_mask
|
self.le_event_mask = command.le_event_mask
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_read_buffer_size_command(self, _command):
|
def on_hci_le_read_buffer_size_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.2 LE Read Buffer Size Command
|
See Bluetooth spec Vol 4, Part E - 7.8.2 LE Read Buffer Size Command
|
||||||
'''
|
'''
|
||||||
return struct.pack(
|
return struct.pack(
|
||||||
'<BHB',
|
'<BHB',
|
||||||
@@ -922,49 +945,49 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_read_local_supported_features_command(self, _command):
|
def on_hci_le_read_local_supported_features_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.3 LE Read Local Supported Features
|
See Bluetooth spec Vol 4, Part E - 7.8.3 LE Read Local Supported Features
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS]) + self.le_features
|
return bytes([HCI_SUCCESS]) + self.le_features
|
||||||
|
|
||||||
def on_hci_le_set_random_address_command(self, command):
|
def on_hci_le_set_random_address_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.4 LE Set Random Address Command
|
See Bluetooth spec Vol 4, Part E - 7.8.4 LE Set Random Address Command
|
||||||
'''
|
'''
|
||||||
self.random_address = command.random_address
|
self.random_address = command.random_address
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_set_advertising_parameters_command(self, command):
|
def on_hci_le_set_advertising_parameters_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.5 LE Set Advertising Parameters Command
|
See Bluetooth spec Vol 4, Part E - 7.8.5 LE Set Advertising Parameters Command
|
||||||
'''
|
'''
|
||||||
self.advertising_parameters = command
|
self.advertising_parameters = command
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_read_advertising_physical_channel_tx_power_command(self, _command):
|
def on_hci_le_read_advertising_physical_channel_tx_power_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.6 LE Read Advertising Physical Channel
|
See Bluetooth spec Vol 4, Part E - 7.8.6 LE Read Advertising Physical Channel
|
||||||
Tx Power Command
|
Tx Power Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS, self.advertising_channel_tx_power])
|
return bytes([HCI_SUCCESS, self.advertising_channel_tx_power])
|
||||||
|
|
||||||
def on_hci_le_set_advertising_data_command(self, command):
|
def on_hci_le_set_advertising_data_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.7 LE Set Advertising Data Command
|
See Bluetooth spec Vol 4, Part E - 7.8.7 LE Set Advertising Data Command
|
||||||
'''
|
'''
|
||||||
self.advertising_data = command.advertising_data
|
self.advertising_data = command.advertising_data
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_set_scan_response_data_command(self, command):
|
def on_hci_le_set_scan_response_data_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.8 LE Set Scan Response Data Command
|
See Bluetooth spec Vol 4, Part E - 7.8.8 LE Set Scan Response Data Command
|
||||||
'''
|
'''
|
||||||
self.le_scan_response_data = command.scan_response_data
|
self.le_scan_response_data = command.scan_response_data
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_set_advertising_enable_command(self, command):
|
def on_hci_le_set_advertising_enable_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.9 LE Set Advertising Enable Command
|
See Bluetooth spec Vol 4, Part E - 7.8.9 LE Set Advertising Enable Command
|
||||||
'''
|
'''
|
||||||
if command.advertising_enable:
|
if command.advertising_enable:
|
||||||
self.start_advertising()
|
self.start_advertising()
|
||||||
@@ -975,7 +998,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_set_scan_parameters_command(self, command):
|
def on_hci_le_set_scan_parameters_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.10 LE Set Scan Parameters Command
|
See Bluetooth spec Vol 4, Part E - 7.8.10 LE Set Scan Parameters Command
|
||||||
'''
|
'''
|
||||||
self.le_scan_type = command.le_scan_type
|
self.le_scan_type = command.le_scan_type
|
||||||
self.le_scan_interval = command.le_scan_interval
|
self.le_scan_interval = command.le_scan_interval
|
||||||
@@ -986,7 +1009,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_set_scan_enable_command(self, command):
|
def on_hci_le_set_scan_enable_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.11 LE Set Scan Enable Command
|
See Bluetooth spec Vol 4, Part E - 7.8.11 LE Set Scan Enable Command
|
||||||
'''
|
'''
|
||||||
self.le_scan_enable = command.le_scan_enable
|
self.le_scan_enable = command.le_scan_enable
|
||||||
self.filter_duplicates = command.filter_duplicates
|
self.filter_duplicates = command.filter_duplicates
|
||||||
@@ -994,7 +1017,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_create_connection_command(self, command):
|
def on_hci_le_create_connection_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.12 LE Create Connection Command
|
See Bluetooth spec Vol 4, Part E - 7.8.12 LE Create Connection Command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
if not self.link:
|
if not self.link:
|
||||||
@@ -1027,40 +1050,40 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_create_connection_cancel_command(self, _command):
|
def on_hci_le_create_connection_cancel_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.13 LE Create Connection Cancel Command
|
See Bluetooth spec Vol 4, Part E - 7.8.13 LE Create Connection Cancel Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_read_filter_accept_list_size_command(self, _command):
|
def on_hci_le_read_filter_accept_list_size_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.14 LE Read Filter Accept List Size
|
See Bluetooth spec Vol 4, Part E - 7.8.14 LE Read Filter Accept List Size
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS, self.filter_accept_list_size])
|
return bytes([HCI_SUCCESS, self.filter_accept_list_size])
|
||||||
|
|
||||||
def on_hci_le_clear_filter_accept_list_command(self, _command):
|
def on_hci_le_clear_filter_accept_list_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.15 LE Clear Filter Accept List Command
|
See Bluetooth spec Vol 4, Part E - 7.8.15 LE Clear Filter Accept List Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_add_device_to_filter_accept_list_command(self, _command):
|
def on_hci_le_add_device_to_filter_accept_list_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.16 LE Add Device To Filter Accept List
|
See Bluetooth spec Vol 4, Part E - 7.8.16 LE Add Device To Filter Accept List
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_remove_device_from_filter_accept_list_command(self, _command):
|
def on_hci_le_remove_device_from_filter_accept_list_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.17 LE Remove Device From Filter Accept
|
See Bluetooth spec Vol 4, Part E - 7.8.17 LE Remove Device From Filter Accept
|
||||||
List Command
|
List Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_read_remote_features_command(self, command):
|
def on_hci_le_read_remote_features_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.21 LE Read Remote Features Command
|
See Bluetooth spec Vol 4, Part E - 7.8.21 LE Read Remote Features Command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
# First, say that the command is pending
|
# First, say that the command is pending
|
||||||
@@ -1083,13 +1106,13 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_rand_command(self, _command):
|
def on_hci_le_rand_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.23 LE Rand Command
|
See Bluetooth spec Vol 4, Part E - 7.8.23 LE Rand Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS]) + struct.pack('Q', random.randint(0, 1 << 64))
|
return bytes([HCI_SUCCESS]) + struct.pack('Q', random.randint(0, 1 << 64))
|
||||||
|
|
||||||
def on_hci_le_enable_encryption_command(self, command):
|
def on_hci_le_enable_encryption_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.24 LE Enable Encryption Command
|
See Bluetooth spec Vol 4, Part E - 7.8.24 LE Enable Encryption Command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
# Check the parameters
|
# Check the parameters
|
||||||
@@ -1122,13 +1145,13 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_read_supported_states_command(self, _command):
|
def on_hci_le_read_supported_states_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.27 LE Read Supported States Command
|
See Bluetooth spec Vol 4, Part E - 7.8.27 LE Read Supported States Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS]) + self.le_states
|
return bytes([HCI_SUCCESS]) + self.le_states
|
||||||
|
|
||||||
def on_hci_le_read_suggested_default_data_length_command(self, _command):
|
def on_hci_le_read_suggested_default_data_length_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.34 LE Read Suggested Default Data Length
|
See Bluetooth spec Vol 4, Part E - 7.8.34 LE Read Suggested Default Data Length
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
return struct.pack(
|
return struct.pack(
|
||||||
@@ -1140,7 +1163,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_write_suggested_default_data_length_command(self, command):
|
def on_hci_le_write_suggested_default_data_length_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.35 LE Write Suggested Default Data Length
|
See Bluetooth spec Vol 4, Part E - 7.8.35 LE Write Suggested Default Data Length
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
self.suggested_max_tx_octets, self.suggested_max_tx_time = struct.unpack(
|
self.suggested_max_tx_octets, self.suggested_max_tx_time = struct.unpack(
|
||||||
@@ -1150,33 +1173,33 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_read_local_p_256_public_key_command(self, _command):
|
def on_hci_le_read_local_p_256_public_key_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.36 LE Read P-256 Public Key Command
|
See Bluetooth spec Vol 4, Part E - 7.8.36 LE Read P-256 Public Key Command
|
||||||
'''
|
'''
|
||||||
# TODO create key and send HCI_LE_Read_Local_P-256_Public_Key_Complete event
|
# TODO create key and send HCI_LE_Read_Local_P-256_Public_Key_Complete event
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_add_device_to_resolving_list_command(self, _command):
|
def on_hci_le_add_device_to_resolving_list_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.38 LE Add Device To Resolving List
|
See Bluetooth spec Vol 4, Part E - 7.8.38 LE Add Device To Resolving List
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_clear_resolving_list_command(self, _command):
|
def on_hci_le_clear_resolving_list_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.40 LE Clear Resolving List Command
|
See Bluetooth spec Vol 4, Part E - 7.8.40 LE Clear Resolving List Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_read_resolving_list_size_command(self, _command):
|
def on_hci_le_read_resolving_list_size_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.41 LE Read Resolving List Size Command
|
See Bluetooth spec Vol 4, Part E - 7.8.41 LE Read Resolving List Size Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS, self.resolving_list_size])
|
return bytes([HCI_SUCCESS, self.resolving_list_size])
|
||||||
|
|
||||||
def on_hci_le_set_address_resolution_enable_command(self, command):
|
def on_hci_le_set_address_resolution_enable_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.44 LE Set Address Resolution Enable
|
See Bluetooth spec Vol 4, Part E - 7.8.44 LE Set Address Resolution Enable
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
ret = HCI_SUCCESS
|
ret = HCI_SUCCESS
|
||||||
@@ -1190,7 +1213,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_set_resolvable_private_address_timeout_command(self, command):
|
def on_hci_le_set_resolvable_private_address_timeout_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.45 LE Set Resolvable Private Address
|
See Bluetooth spec Vol 4, Part E - 7.8.45 LE Set Resolvable Private Address
|
||||||
Timeout Command
|
Timeout Command
|
||||||
'''
|
'''
|
||||||
self.le_rpa_timeout = command.rpa_timeout
|
self.le_rpa_timeout = command.rpa_timeout
|
||||||
@@ -1198,7 +1221,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_read_maximum_data_length_command(self, _command):
|
def on_hci_le_read_maximum_data_length_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.46 LE Read Maximum Data Length Command
|
See Bluetooth spec Vol 4, Part E - 7.8.46 LE Read Maximum Data Length Command
|
||||||
'''
|
'''
|
||||||
return struct.pack(
|
return struct.pack(
|
||||||
'<BHHHH',
|
'<BHHHH',
|
||||||
@@ -1211,7 +1234,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_read_phy_command(self, command):
|
def on_hci_le_read_phy_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.47 LE Read PHY Command
|
See Bluetooth spec Vol 4, Part E - 7.8.47 LE Read PHY Command
|
||||||
'''
|
'''
|
||||||
return struct.pack(
|
return struct.pack(
|
||||||
'<BHBB',
|
'<BHBB',
|
||||||
@@ -1223,7 +1246,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_set_default_phy_command(self, command):
|
def on_hci_le_set_default_phy_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.48 LE Set Default PHY Command
|
See Bluetooth spec Vol 4, Part E - 7.8.48 LE Set Default PHY Command
|
||||||
'''
|
'''
|
||||||
self.default_phy = {
|
self.default_phy = {
|
||||||
'all_phys': command.all_phys,
|
'all_phys': command.all_phys,
|
||||||
@@ -1234,6 +1257,6 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_read_transmit_power_command(self, _command):
|
def on_hci_le_read_transmit_power_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.74 LE Read Transmit Power Command
|
See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command
|
||||||
'''
|
'''
|
||||||
return struct.pack('<BBB', HCI_SUCCESS, 0, 0)
|
return struct.pack('<BBB', HCI_SUCCESS, 0, 0)
|
||||||
|
|||||||
101
bumble/core.py
101
bumble/core.py
@@ -17,7 +17,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import struct
|
import struct
|
||||||
from typing import List, Optional, Tuple, Union, cast
|
from typing import List, Optional, Tuple, Union, cast, Dict
|
||||||
|
|
||||||
from .company_ids import COMPANY_IDENTIFIERS
|
from .company_ids import COMPANY_IDENTIFIERS
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ def bit_flags_to_strings(bits, bit_flag_names):
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
def name_or_number(dictionary, number, width=2):
|
def name_or_number(dictionary: Dict[int, str], number: int, width: int = 2) -> str:
|
||||||
name = dictionary.get(number)
|
name = dictionary.get(number)
|
||||||
if name is not None:
|
if name is not None:
|
||||||
return name
|
return name
|
||||||
@@ -78,7 +78,13 @@ def get_dict_key_by_value(dictionary, value):
|
|||||||
class BaseError(Exception):
|
class BaseError(Exception):
|
||||||
"""Base class for errors with an error code, error name and namespace"""
|
"""Base class for errors with an error code, error name and namespace"""
|
||||||
|
|
||||||
def __init__(self, error_code, error_namespace='', error_name='', details=''):
|
def __init__(
|
||||||
|
self,
|
||||||
|
error_code: int | None,
|
||||||
|
error_namespace: str = '',
|
||||||
|
error_name: str = '',
|
||||||
|
details: str = '',
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.error_code = error_code
|
self.error_code = error_code
|
||||||
self.error_namespace = error_namespace
|
self.error_namespace = error_namespace
|
||||||
@@ -90,12 +96,14 @@ class BaseError(Exception):
|
|||||||
namespace = f'{self.error_namespace}/'
|
namespace = f'{self.error_namespace}/'
|
||||||
else:
|
else:
|
||||||
namespace = ''
|
namespace = ''
|
||||||
if self.error_name:
|
error_text = {
|
||||||
name = f'{self.error_name} [0x{self.error_code:X}]'
|
(True, True): f'{self.error_name} [0x{self.error_code:X}]',
|
||||||
else:
|
(True, False): self.error_name,
|
||||||
name = f'0x{self.error_code:X}'
|
(False, True): f'0x{self.error_code:X}',
|
||||||
|
(False, False): '',
|
||||||
|
}[(self.error_name != '', self.error_code is not None)]
|
||||||
|
|
||||||
return f'{type(self).__name__}({namespace}{name})'
|
return f'{type(self).__name__}({namespace}{error_text})'
|
||||||
|
|
||||||
|
|
||||||
class ProtocolError(BaseError):
|
class ProtocolError(BaseError):
|
||||||
@@ -134,6 +142,10 @@ class ConnectionError(BaseError): # pylint: disable=redefined-builtin
|
|||||||
self.peer_address = peer_address
|
self.peer_address = peer_address
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionParameterUpdateError(BaseError):
|
||||||
|
"""Connection Parameter Update Error"""
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# UUID
|
# UUID
|
||||||
#
|
#
|
||||||
@@ -562,11 +574,82 @@ class DeviceClass:
|
|||||||
PERIPHERAL_HANDHELD_GESTURAL_INPUT_DEVICE_MINOR_DEVICE_CLASS: 'Handheld gestural input device'
|
PERIPHERAL_HANDHELD_GESTURAL_INPUT_DEVICE_MINOR_DEVICE_CLASS: 'Handheld gestural input device'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WEARABLE_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00
|
||||||
|
WEARABLE_WRISTWATCH_MINOR_DEVICE_CLASS = 0x01
|
||||||
|
WEARABLE_PAGER_MINOR_DEVICE_CLASS = 0x02
|
||||||
|
WEARABLE_JACKET_MINOR_DEVICE_CLASS = 0x03
|
||||||
|
WEARABLE_HELMET_MINOR_DEVICE_CLASS = 0x04
|
||||||
|
WEARABLE_GLASSES_MINOR_DEVICE_CLASS = 0x05
|
||||||
|
|
||||||
|
WEARABLE_MINOR_DEVICE_CLASS_NAMES = {
|
||||||
|
WEARABLE_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized',
|
||||||
|
WEARABLE_WRISTWATCH_MINOR_DEVICE_CLASS: 'Wristwatch',
|
||||||
|
WEARABLE_PAGER_MINOR_DEVICE_CLASS: 'Pager',
|
||||||
|
WEARABLE_JACKET_MINOR_DEVICE_CLASS: 'Jacket',
|
||||||
|
WEARABLE_HELMET_MINOR_DEVICE_CLASS: 'Helmet',
|
||||||
|
WEARABLE_GLASSES_MINOR_DEVICE_CLASS: 'Glasses',
|
||||||
|
}
|
||||||
|
|
||||||
|
TOY_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00
|
||||||
|
TOY_ROBOT_MINOR_DEVICE_CLASS = 0x01
|
||||||
|
TOY_VEHICLE_MINOR_DEVICE_CLASS = 0x02
|
||||||
|
TOY_DOLL_ACTION_FIGURE_MINOR_DEVICE_CLASS = 0x03
|
||||||
|
TOY_CONTROLLER_MINOR_DEVICE_CLASS = 0x04
|
||||||
|
TOY_GAME_MINOR_DEVICE_CLASS = 0x05
|
||||||
|
|
||||||
|
TOY_MINOR_DEVICE_CLASS_NAMES = {
|
||||||
|
TOY_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized',
|
||||||
|
TOY_ROBOT_MINOR_DEVICE_CLASS: 'Robot',
|
||||||
|
TOY_VEHICLE_MINOR_DEVICE_CLASS: 'Vehicle',
|
||||||
|
TOY_DOLL_ACTION_FIGURE_MINOR_DEVICE_CLASS: 'Doll/Action figure',
|
||||||
|
TOY_CONTROLLER_MINOR_DEVICE_CLASS: 'Controller',
|
||||||
|
TOY_GAME_MINOR_DEVICE_CLASS: 'Game',
|
||||||
|
}
|
||||||
|
|
||||||
|
HEALTH_UNDEFINED_MINOR_DEVICE_CLASS = 0x00
|
||||||
|
HEALTH_BLOOD_PRESSURE_MONITOR_MINOR_DEVICE_CLASS = 0x01
|
||||||
|
HEALTH_THERMOMETER_MINOR_DEVICE_CLASS = 0x02
|
||||||
|
HEALTH_WEIGHING_SCALE_MINOR_DEVICE_CLASS = 0x03
|
||||||
|
HEALTH_GLUCOSE_METER_MINOR_DEVICE_CLASS = 0x04
|
||||||
|
HEALTH_PULSE_OXIMETER_MINOR_DEVICE_CLASS = 0x05
|
||||||
|
HEALTH_HEART_PULSE_RATE_MONITOR_MINOR_DEVICE_CLASS = 0x06
|
||||||
|
HEALTH_HEALTH_DATA_DISPLAY_MINOR_DEVICE_CLASS = 0x07
|
||||||
|
HEALTH_STEP_COUNTER_MINOR_DEVICE_CLASS = 0x08
|
||||||
|
HEALTH_BODY_COMPOSITION_ANALYZER_MINOR_DEVICE_CLASS = 0x09
|
||||||
|
HEALTH_PEAK_FLOW_MONITOR_MINOR_DEVICE_CLASS = 0x0A
|
||||||
|
HEALTH_MEDICATION_MONITOR_MINOR_DEVICE_CLASS = 0x0B
|
||||||
|
HEALTH_KNEE_PROSTHESIS_MINOR_DEVICE_CLASS = 0x0C
|
||||||
|
HEALTH_ANKLE_PROSTHESIS_MINOR_DEVICE_CLASS = 0x0D
|
||||||
|
HEALTH_GENERIC_HEALTH_MANAGER_MINOR_DEVICE_CLASS = 0x0E
|
||||||
|
HEALTH_PERSONAL_MOBILITY_DEVICE_MINOR_DEVICE_CLASS = 0x0F
|
||||||
|
|
||||||
|
HEALTH_MINOR_DEVICE_CLASS_NAMES = {
|
||||||
|
HEALTH_UNDEFINED_MINOR_DEVICE_CLASS: 'Undefined',
|
||||||
|
HEALTH_BLOOD_PRESSURE_MONITOR_MINOR_DEVICE_CLASS: 'Blood Pressure Monitor',
|
||||||
|
HEALTH_THERMOMETER_MINOR_DEVICE_CLASS: 'Thermometer',
|
||||||
|
HEALTH_WEIGHING_SCALE_MINOR_DEVICE_CLASS: 'Weighing Scale',
|
||||||
|
HEALTH_GLUCOSE_METER_MINOR_DEVICE_CLASS: 'Glucose Meter',
|
||||||
|
HEALTH_PULSE_OXIMETER_MINOR_DEVICE_CLASS: 'Pulse Oximeter',
|
||||||
|
HEALTH_HEART_PULSE_RATE_MONITOR_MINOR_DEVICE_CLASS: 'Heart/Pulse Rate Monitor',
|
||||||
|
HEALTH_HEALTH_DATA_DISPLAY_MINOR_DEVICE_CLASS: 'Health Data Display',
|
||||||
|
HEALTH_STEP_COUNTER_MINOR_DEVICE_CLASS: 'Step Counter',
|
||||||
|
HEALTH_BODY_COMPOSITION_ANALYZER_MINOR_DEVICE_CLASS: 'Body Composition Analyzer',
|
||||||
|
HEALTH_PEAK_FLOW_MONITOR_MINOR_DEVICE_CLASS: 'Peak Flow Monitor',
|
||||||
|
HEALTH_MEDICATION_MONITOR_MINOR_DEVICE_CLASS: 'Medication Monitor',
|
||||||
|
HEALTH_KNEE_PROSTHESIS_MINOR_DEVICE_CLASS: 'Knee Prosthesis',
|
||||||
|
HEALTH_ANKLE_PROSTHESIS_MINOR_DEVICE_CLASS: 'Ankle Prosthesis',
|
||||||
|
HEALTH_GENERIC_HEALTH_MANAGER_MINOR_DEVICE_CLASS: 'Generic Health Manager',
|
||||||
|
HEALTH_PERSONAL_MOBILITY_DEVICE_MINOR_DEVICE_CLASS: 'Personal Mobility Device',
|
||||||
|
}
|
||||||
|
|
||||||
MINOR_DEVICE_CLASS_NAMES = {
|
MINOR_DEVICE_CLASS_NAMES = {
|
||||||
COMPUTER_MAJOR_DEVICE_CLASS: COMPUTER_MINOR_DEVICE_CLASS_NAMES,
|
COMPUTER_MAJOR_DEVICE_CLASS: COMPUTER_MINOR_DEVICE_CLASS_NAMES,
|
||||||
PHONE_MAJOR_DEVICE_CLASS: PHONE_MINOR_DEVICE_CLASS_NAMES,
|
PHONE_MAJOR_DEVICE_CLASS: PHONE_MINOR_DEVICE_CLASS_NAMES,
|
||||||
AUDIO_VIDEO_MAJOR_DEVICE_CLASS: AUDIO_VIDEO_MINOR_DEVICE_CLASS_NAMES,
|
AUDIO_VIDEO_MAJOR_DEVICE_CLASS: AUDIO_VIDEO_MINOR_DEVICE_CLASS_NAMES,
|
||||||
PERIPHERAL_MAJOR_DEVICE_CLASS: PERIPHERAL_MINOR_DEVICE_CLASS_NAMES
|
PERIPHERAL_MAJOR_DEVICE_CLASS: PERIPHERAL_MINOR_DEVICE_CLASS_NAMES,
|
||||||
|
WEARABLE_MAJOR_DEVICE_CLASS: WEARABLE_MINOR_DEVICE_CLASS_NAMES,
|
||||||
|
TOY_MAJOR_DEVICE_CLASS: TOY_MINOR_DEVICE_CLASS_NAMES,
|
||||||
|
HEALTH_MAJOR_DEVICE_CLASS: HEALTH_MINOR_DEVICE_CLASS_NAMES,
|
||||||
}
|
}
|
||||||
|
|
||||||
# fmt: on
|
# fmt: on
|
||||||
|
|||||||
@@ -23,22 +23,18 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
import operator
|
import operator
|
||||||
import platform
|
|
||||||
|
|
||||||
if platform.system() != 'Emscripten':
|
import secrets
|
||||||
import secrets
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
generate_private_key,
|
||||||
generate_private_key,
|
ECDH,
|
||||||
ECDH,
|
EllipticCurvePublicNumbers,
|
||||||
EllipticCurvePublicNumbers,
|
EllipticCurvePrivateNumbers,
|
||||||
EllipticCurvePrivateNumbers,
|
SECP256R1,
|
||||||
SECP256R1,
|
)
|
||||||
)
|
from cryptography.hazmat.primitives import cmac
|
||||||
from cryptography.hazmat.primitives import cmac
|
|
||||||
else:
|
|
||||||
# TODO: implement stubs
|
|
||||||
pass
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
|
|||||||
279
bumble/device.py
279
bumble/device.py
@@ -23,7 +23,18 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager, AsyncExitStack
|
from contextlib import asynccontextmanager, AsyncExitStack
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple, Type, Union
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Callable,
|
||||||
|
ClassVar,
|
||||||
|
Dict,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Tuple,
|
||||||
|
Type,
|
||||||
|
Union,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
from .colors import color
|
from .colors import color
|
||||||
from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
|
from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
|
||||||
@@ -86,6 +97,7 @@ from .hci import (
|
|||||||
HCI_LE_Extended_Create_Connection_Command,
|
HCI_LE_Extended_Create_Connection_Command,
|
||||||
HCI_LE_Rand_Command,
|
HCI_LE_Rand_Command,
|
||||||
HCI_LE_Read_PHY_Command,
|
HCI_LE_Read_PHY_Command,
|
||||||
|
HCI_LE_Set_Address_Resolution_Enable_Command,
|
||||||
HCI_LE_Set_Advertising_Data_Command,
|
HCI_LE_Set_Advertising_Data_Command,
|
||||||
HCI_LE_Set_Advertising_Enable_Command,
|
HCI_LE_Set_Advertising_Enable_Command,
|
||||||
HCI_LE_Set_Advertising_Parameters_Command,
|
HCI_LE_Set_Advertising_Parameters_Command,
|
||||||
@@ -129,6 +141,7 @@ from .core import (
|
|||||||
BT_LE_TRANSPORT,
|
BT_LE_TRANSPORT,
|
||||||
BT_PERIPHERAL_ROLE,
|
BT_PERIPHERAL_ROLE,
|
||||||
AdvertisingData,
|
AdvertisingData,
|
||||||
|
ConnectionParameterUpdateError,
|
||||||
CommandTimeoutError,
|
CommandTimeoutError,
|
||||||
ConnectionPHY,
|
ConnectionPHY,
|
||||||
InvalidStateError,
|
InvalidStateError,
|
||||||
@@ -151,6 +164,9 @@ from . import sdp
|
|||||||
from . import l2cap
|
from . import l2cap
|
||||||
from . import core
|
from . import core
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .transport.common import TransportSource, TransportSink
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -651,7 +667,7 @@ class Connection(CompositeEventEmitter):
|
|||||||
def is_incomplete(self) -> bool:
|
def is_incomplete(self) -> bool:
|
||||||
return self.handle is None
|
return self.handle is None
|
||||||
|
|
||||||
def send_l2cap_pdu(self, cid, pdu):
|
def send_l2cap_pdu(self, cid: int, pdu: bytes) -> None:
|
||||||
self.device.send_l2cap_pdu(self.handle, cid, pdu)
|
self.device.send_l2cap_pdu(self.handle, cid, pdu)
|
||||||
|
|
||||||
def create_l2cap_connector(self, psm):
|
def create_l2cap_connector(self, psm):
|
||||||
@@ -708,6 +724,7 @@ class Connection(CompositeEventEmitter):
|
|||||||
connection_interval_max,
|
connection_interval_max,
|
||||||
max_latency,
|
max_latency,
|
||||||
supervision_timeout,
|
supervision_timeout,
|
||||||
|
use_l2cap=False,
|
||||||
):
|
):
|
||||||
return await self.device.update_connection_parameters(
|
return await self.device.update_connection_parameters(
|
||||||
self,
|
self,
|
||||||
@@ -715,6 +732,7 @@ class Connection(CompositeEventEmitter):
|
|||||||
connection_interval_max,
|
connection_interval_max,
|
||||||
max_latency,
|
max_latency,
|
||||||
supervision_timeout,
|
supervision_timeout,
|
||||||
|
use_l2cap=use_l2cap,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_phy(self, tx_phys=None, rx_phys=None, phy_options=None):
|
async def set_phy(self, tx_phys=None, rx_phys=None, phy_options=None):
|
||||||
@@ -778,6 +796,7 @@ class DeviceConfiguration:
|
|||||||
self.irk = bytes(16) # This really must be changed for any level of security
|
self.irk = bytes(16) # This really must be changed for any level of security
|
||||||
self.keystore = None
|
self.keystore = None
|
||||||
self.gatt_services: List[Dict[str, Any]] = []
|
self.gatt_services: List[Dict[str, Any]] = []
|
||||||
|
self.address_resolution_offload = False
|
||||||
|
|
||||||
def load_from_dict(self, config: Dict[str, Any]) -> None:
|
def load_from_dict(self, config: Dict[str, Any]) -> None:
|
||||||
# Load simple properties
|
# Load simple properties
|
||||||
@@ -940,7 +959,13 @@ class Device(CompositeEventEmitter):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def with_hci(cls, name, address, hci_source, hci_sink):
|
def with_hci(
|
||||||
|
cls,
|
||||||
|
name: str,
|
||||||
|
address: Address,
|
||||||
|
hci_source: TransportSource,
|
||||||
|
hci_sink: TransportSink,
|
||||||
|
) -> Device:
|
||||||
'''
|
'''
|
||||||
Create a Device instance with a Host configured to communicate with a controller
|
Create a Device instance with a Host configured to communicate with a controller
|
||||||
through an HCI source/sink
|
through an HCI source/sink
|
||||||
@@ -949,18 +974,29 @@ class Device(CompositeEventEmitter):
|
|||||||
return cls(name=name, address=address, host=host)
|
return cls(name=name, address=address, host=host)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config_file(cls, filename):
|
def from_config_file(cls, filename: str) -> Device:
|
||||||
config = DeviceConfiguration()
|
config = DeviceConfiguration()
|
||||||
config.load_from_file(filename)
|
config.load_from_file(filename)
|
||||||
return cls(config=config)
|
return cls(config=config)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config_file_with_hci(cls, filename, hci_source, hci_sink):
|
def from_config_with_hci(
|
||||||
config = DeviceConfiguration()
|
cls,
|
||||||
config.load_from_file(filename)
|
config: DeviceConfiguration,
|
||||||
|
hci_source: TransportSource,
|
||||||
|
hci_sink: TransportSink,
|
||||||
|
) -> Device:
|
||||||
host = Host(controller_source=hci_source, controller_sink=hci_sink)
|
host = Host(controller_source=hci_source, controller_sink=hci_sink)
|
||||||
return cls(config=config, host=host)
|
return cls(config=config, host=host)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config_file_with_hci(
|
||||||
|
cls, filename: str, hci_source: TransportSource, hci_sink: TransportSink
|
||||||
|
) -> Device:
|
||||||
|
config = DeviceConfiguration()
|
||||||
|
config.load_from_file(filename)
|
||||||
|
return cls.from_config_with_hci(config, hci_source, hci_sink)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
@@ -1025,6 +1061,7 @@ class Device(CompositeEventEmitter):
|
|||||||
self.discoverable = config.discoverable
|
self.discoverable = config.discoverable
|
||||||
self.connectable = config.connectable
|
self.connectable = config.connectable
|
||||||
self.classic_accept_any = config.classic_accept_any
|
self.classic_accept_any = config.classic_accept_any
|
||||||
|
self.address_resolution_offload = config.address_resolution_offload
|
||||||
|
|
||||||
for service in config.gatt_services:
|
for service in config.gatt_services:
|
||||||
characteristics = []
|
characteristics = []
|
||||||
@@ -1089,7 +1126,7 @@ class Device(CompositeEventEmitter):
|
|||||||
return self._host
|
return self._host
|
||||||
|
|
||||||
@host.setter
|
@host.setter
|
||||||
def host(self, host):
|
def host(self, host: Host) -> None:
|
||||||
# Unsubscribe from events from the current host
|
# Unsubscribe from events from the current host
|
||||||
if self._host:
|
if self._host:
|
||||||
for event_name in device_host_event_handlers:
|
for event_name in device_host_event_handlers:
|
||||||
@@ -1176,7 +1213,7 @@ class Device(CompositeEventEmitter):
|
|||||||
connection, psm, max_credits, mtu, mps
|
connection, psm, max_credits, mtu, mps
|
||||||
)
|
)
|
||||||
|
|
||||||
def send_l2cap_pdu(self, connection_handle, cid, pdu):
|
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
||||||
self.host.send_l2cap_pdu(connection_handle, cid, pdu)
|
self.host.send_l2cap_pdu(connection_handle, cid, pdu)
|
||||||
|
|
||||||
async def send_command(self, command, check_result=False):
|
async def send_command(self, command, check_result=False):
|
||||||
@@ -1252,31 +1289,16 @@ class Device(CompositeEventEmitter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Load the address resolving list
|
# Load the address resolving list
|
||||||
if self.keystore and self.host.supports_command(
|
if self.keystore:
|
||||||
HCI_LE_CLEAR_RESOLVING_LIST_COMMAND
|
await self.refresh_resolving_list()
|
||||||
):
|
|
||||||
await self.send_command(HCI_LE_Clear_Resolving_List_Command()) # type: ignore[call-arg]
|
|
||||||
|
|
||||||
resolving_keys = await self.keystore.get_resolving_keys()
|
# Enable address resolution
|
||||||
for irk, address in resolving_keys:
|
if self.address_resolution_offload:
|
||||||
await self.send_command(
|
await self.send_command(
|
||||||
HCI_LE_Add_Device_To_Resolving_List_Command(
|
HCI_LE_Set_Address_Resolution_Enable_Command(
|
||||||
peer_identity_address_type=address.address_type,
|
address_resolution_enable=1
|
||||||
peer_identity_address=address,
|
) # type: ignore[call-arg]
|
||||||
peer_irk=irk,
|
)
|
||||||
local_irk=self.irk,
|
|
||||||
) # type: ignore[call-arg]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Enable address resolution
|
|
||||||
# await self.send_command(
|
|
||||||
# HCI_LE_Set_Address_Resolution_Enable_Command(
|
|
||||||
# address_resolution_enable=1)
|
|
||||||
# )
|
|
||||||
# )
|
|
||||||
|
|
||||||
# Create a host-side address resolver
|
|
||||||
self.address_resolver = smp.AddressResolver(resolving_keys)
|
|
||||||
|
|
||||||
if self.classic_enabled:
|
if self.classic_enabled:
|
||||||
await self.send_command(
|
await self.send_command(
|
||||||
@@ -1306,6 +1328,26 @@ class Device(CompositeEventEmitter):
|
|||||||
await self.host.flush()
|
await self.host.flush()
|
||||||
self.powered_on = False
|
self.powered_on = False
|
||||||
|
|
||||||
|
async def refresh_resolving_list(self) -> None:
|
||||||
|
assert self.keystore is not None
|
||||||
|
|
||||||
|
resolving_keys = await self.keystore.get_resolving_keys()
|
||||||
|
# Create a host-side address resolver
|
||||||
|
self.address_resolver = smp.AddressResolver(resolving_keys)
|
||||||
|
|
||||||
|
if self.address_resolution_offload:
|
||||||
|
await self.send_command(HCI_LE_Clear_Resolving_List_Command()) # type: ignore[call-arg]
|
||||||
|
|
||||||
|
for irk, address in resolving_keys:
|
||||||
|
await self.send_command(
|
||||||
|
HCI_LE_Add_Device_To_Resolving_List_Command(
|
||||||
|
peer_identity_address_type=address.address_type,
|
||||||
|
peer_identity_address=address,
|
||||||
|
peer_irk=irk,
|
||||||
|
local_irk=self.irk,
|
||||||
|
) # type: ignore[call-arg]
|
||||||
|
)
|
||||||
|
|
||||||
def supports_le_feature(self, feature):
|
def supports_le_feature(self, feature):
|
||||||
return self.host.supports_le_feature(feature)
|
return self.host.supports_le_feature(feature)
|
||||||
|
|
||||||
@@ -2071,11 +2113,30 @@ class Device(CompositeEventEmitter):
|
|||||||
supervision_timeout,
|
supervision_timeout,
|
||||||
min_ce_length=0,
|
min_ce_length=0,
|
||||||
max_ce_length=0,
|
max_ce_length=0,
|
||||||
):
|
use_l2cap=False,
|
||||||
|
) -> None:
|
||||||
'''
|
'''
|
||||||
NOTE: the name of the parameters may look odd, but it just follows the names
|
NOTE: the name of the parameters may look odd, but it just follows the names
|
||||||
used in the Bluetooth spec.
|
used in the Bluetooth spec.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
if use_l2cap:
|
||||||
|
if connection.role != BT_PERIPHERAL_ROLE:
|
||||||
|
raise InvalidStateError(
|
||||||
|
'only peripheral can update connection parameters with l2cap'
|
||||||
|
)
|
||||||
|
l2cap_result = (
|
||||||
|
await self.l2cap_channel_manager.update_connection_parameters(
|
||||||
|
connection,
|
||||||
|
connection_interval_min,
|
||||||
|
connection_interval_max,
|
||||||
|
max_latency,
|
||||||
|
supervision_timeout,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if l2cap_result != l2cap.L2CAP_CONNECTION_PARAMETERS_ACCEPTED_RESULT:
|
||||||
|
raise ConnectionParameterUpdateError(l2cap_result)
|
||||||
|
|
||||||
result = await self.send_command(
|
result = await self.send_command(
|
||||||
HCI_LE_Connection_Update_Command(
|
HCI_LE_Connection_Update_Command(
|
||||||
connection_handle=connection.handle,
|
connection_handle=connection.handle,
|
||||||
@@ -2085,7 +2146,7 @@ class Device(CompositeEventEmitter):
|
|||||||
supervision_timeout=supervision_timeout,
|
supervision_timeout=supervision_timeout,
|
||||||
min_ce_length=min_ce_length,
|
min_ce_length=min_ce_length,
|
||||||
max_ce_length=max_ce_length,
|
max_ce_length=max_ce_length,
|
||||||
)
|
) # type: ignore[call-arg]
|
||||||
)
|
)
|
||||||
if result.status != HCI_Command_Status_Event.PENDING:
|
if result.status != HCI_Command_Status_Event.PENDING:
|
||||||
raise HCI_StatusError(result)
|
raise HCI_StatusError(result)
|
||||||
@@ -2226,9 +2287,11 @@ class Device(CompositeEventEmitter):
|
|||||||
def request_pairing(self, connection):
|
def request_pairing(self, connection):
|
||||||
return self.smp_manager.request_pairing(connection)
|
return self.smp_manager.request_pairing(connection)
|
||||||
|
|
||||||
async def get_long_term_key(self, connection_handle, rand, ediv):
|
async def get_long_term_key(
|
||||||
|
self, connection_handle: int, rand: bytes, ediv: int
|
||||||
|
) -> Optional[bytes]:
|
||||||
if (connection := self.lookup_connection(connection_handle)) is None:
|
if (connection := self.lookup_connection(connection_handle)) is None:
|
||||||
return
|
return None
|
||||||
|
|
||||||
# Start by looking for the key in an SMP session
|
# Start by looking for the key in an SMP session
|
||||||
ltk = self.smp_manager.get_long_term_key(connection, rand, ediv)
|
ltk = self.smp_manager.get_long_term_key(connection, rand, ediv)
|
||||||
@@ -2248,19 +2311,24 @@ class Device(CompositeEventEmitter):
|
|||||||
|
|
||||||
if connection.role == BT_PERIPHERAL_ROLE and keys.ltk_peripheral:
|
if connection.role == BT_PERIPHERAL_ROLE and keys.ltk_peripheral:
|
||||||
return keys.ltk_peripheral.value
|
return keys.ltk_peripheral.value
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_link_key(self, address: Address) -> Optional[bytes]:
|
async def get_link_key(self, address: Address) -> Optional[bytes]:
|
||||||
# Look for the key in the keystore
|
if self.keystore is None:
|
||||||
if self.keystore is not None:
|
return None
|
||||||
keys = await self.keystore.get(str(address))
|
|
||||||
if keys is not None:
|
|
||||||
logger.debug('found keys in the key store')
|
|
||||||
if keys.link_key is None:
|
|
||||||
logger.warning('no link key')
|
|
||||||
return None
|
|
||||||
|
|
||||||
return keys.link_key.value
|
# Look for the key in the keystore
|
||||||
return None
|
keys = await self.keystore.get(str(address))
|
||||||
|
if keys is None:
|
||||||
|
logger.debug(f'no keys found for {address}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.debug('found keys in the key store')
|
||||||
|
if keys.link_key is None:
|
||||||
|
logger.warning('no link key')
|
||||||
|
return None
|
||||||
|
|
||||||
|
return keys.link_key.value
|
||||||
|
|
||||||
# [Classic only]
|
# [Classic only]
|
||||||
async def authenticate(self, connection):
|
async def authenticate(self, connection):
|
||||||
@@ -2379,6 +2447,18 @@ class Device(CompositeEventEmitter):
|
|||||||
'connection_encryption_failure', on_encryption_failure
|
'connection_encryption_failure', on_encryption_failure
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def update_keys(self, address: str, keys: PairingKeys) -> None:
|
||||||
|
if self.keystore is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.keystore.update(address, keys)
|
||||||
|
await self.refresh_resolving_list()
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning(f'!!! error while storing keys: {error}')
|
||||||
|
else:
|
||||||
|
self.emit('key_store_update')
|
||||||
|
|
||||||
# [Classic only]
|
# [Classic only]
|
||||||
async def switch_role(self, connection: Connection, role: int):
|
async def switch_role(self, connection: Connection, role: int):
|
||||||
pending_role_change = asyncio.get_running_loop().create_future()
|
pending_role_change = asyncio.get_running_loop().create_future()
|
||||||
@@ -2441,7 +2521,7 @@ class Device(CompositeEventEmitter):
|
|||||||
|
|
||||||
if result.status != HCI_COMMAND_STATUS_PENDING:
|
if result.status != HCI_COMMAND_STATUS_PENDING:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'HCI_Set_Connection_Encryption_Command failed: '
|
'HCI_Remote_Name_Request_Command failed: '
|
||||||
f'{HCI_Constant.error_name(result.status)}'
|
f'{HCI_Constant.error_name(result.status)}'
|
||||||
)
|
)
|
||||||
raise HCI_StatusError(result)
|
raise HCI_StatusError(result)
|
||||||
@@ -2473,13 +2553,7 @@ class Device(CompositeEventEmitter):
|
|||||||
value=link_key, authenticated=authenticated
|
value=link_key, authenticated=authenticated
|
||||||
)
|
)
|
||||||
|
|
||||||
async def store_keys():
|
self.abort_on('flush', self.update_keys(str(bd_addr), pairing_keys))
|
||||||
try:
|
|
||||||
await self.keystore.update(str(bd_addr), pairing_keys)
|
|
||||||
except Exception as error:
|
|
||||||
logger.warning(f'!!! error while storing keys: {error}')
|
|
||||||
|
|
||||||
self.abort_on('flush', store_keys())
|
|
||||||
|
|
||||||
if connection := self.find_connection_by_bd_addr(
|
if connection := self.find_connection_by_bd_addr(
|
||||||
bd_addr, transport=BT_BR_EDR_TRANSPORT
|
bd_addr, transport=BT_BR_EDR_TRANSPORT
|
||||||
@@ -2731,20 +2805,6 @@ class Device(CompositeEventEmitter):
|
|||||||
)
|
)
|
||||||
connection.emit('connection_authentication_failure', error)
|
connection.emit('connection_authentication_failure', error)
|
||||||
|
|
||||||
@host_event_handler
|
|
||||||
@with_connection_from_address
|
|
||||||
def on_ssp_complete(self, connection):
|
|
||||||
# On Secure Simple Pairing complete, in case:
|
|
||||||
# - Connection isn't already authenticated
|
|
||||||
# - AND we are not the initiator of the authentication
|
|
||||||
# We must trigger authentication to know if we are truly authenticated
|
|
||||||
if not connection.authenticating and not connection.authenticated:
|
|
||||||
logger.debug(
|
|
||||||
f'*** Trigger Connection Authentication: [0x{connection.handle:04X}] '
|
|
||||||
f'{connection.peer_address}'
|
|
||||||
)
|
|
||||||
asyncio.create_task(connection.authenticate())
|
|
||||||
|
|
||||||
# [Classic only]
|
# [Classic only]
|
||||||
@host_event_handler
|
@host_event_handler
|
||||||
@with_connection_from_address
|
@with_connection_from_address
|
||||||
@@ -2847,18 +2907,22 @@ class Device(CompositeEventEmitter):
|
|||||||
method = methods[peer_io_capability][io_capability]
|
method = methods[peer_io_capability][io_capability]
|
||||||
|
|
||||||
async def reply() -> None:
|
async def reply() -> None:
|
||||||
if await connection.abort_on('disconnection', method()):
|
try:
|
||||||
await self.host.send_command(
|
if await connection.abort_on('disconnection', method()):
|
||||||
HCI_User_Confirmation_Request_Reply_Command( # type: ignore[call-arg]
|
await self.host.send_command(
|
||||||
bd_addr=connection.peer_address
|
HCI_User_Confirmation_Request_Reply_Command( # type: ignore[call-arg]
|
||||||
)
|
bd_addr=connection.peer_address
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
await self.host.send_command(
|
|
||||||
HCI_User_Confirmation_Request_Negative_Reply_Command( # type: ignore[call-arg]
|
|
||||||
bd_addr=connection.peer_address
|
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning(f'exception while confirming: {error}')
|
||||||
|
|
||||||
|
await self.host.send_command(
|
||||||
|
HCI_User_Confirmation_Request_Negative_Reply_Command( # type: ignore[call-arg]
|
||||||
|
bd_addr=connection.peer_address
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
AsyncRunner.spawn(reply())
|
AsyncRunner.spawn(reply())
|
||||||
|
|
||||||
@@ -2870,21 +2934,25 @@ class Device(CompositeEventEmitter):
|
|||||||
pairing_config = self.pairing_config_factory(connection)
|
pairing_config = self.pairing_config_factory(connection)
|
||||||
|
|
||||||
async def reply() -> None:
|
async def reply() -> None:
|
||||||
number = await connection.abort_on(
|
try:
|
||||||
'disconnection', pairing_config.delegate.get_number()
|
number = await connection.abort_on(
|
||||||
|
'disconnection', pairing_config.delegate.get_number()
|
||||||
|
)
|
||||||
|
if number is not None:
|
||||||
|
await self.host.send_command(
|
||||||
|
HCI_User_Passkey_Request_Reply_Command( # type: ignore[call-arg]
|
||||||
|
bd_addr=connection.peer_address, numeric_value=number
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning(f'exception while asking for pass-key: {error}')
|
||||||
|
|
||||||
|
await self.host.send_command(
|
||||||
|
HCI_User_Passkey_Request_Negative_Reply_Command( # type: ignore[call-arg]
|
||||||
|
bd_addr=connection.peer_address
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if number is not None:
|
|
||||||
await self.host.send_command(
|
|
||||||
HCI_User_Passkey_Request_Reply_Command( # type: ignore[call-arg]
|
|
||||||
bd_addr=connection.peer_address, numeric_value=number
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await self.host.send_command(
|
|
||||||
HCI_User_Passkey_Request_Negative_Reply_Command( # type: ignore[call-arg]
|
|
||||||
bd_addr=connection.peer_address
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
AsyncRunner.spawn(reply())
|
AsyncRunner.spawn(reply())
|
||||||
|
|
||||||
@@ -3091,10 +3159,31 @@ class Device(CompositeEventEmitter):
|
|||||||
connection.emit('role_change_failure', error)
|
connection.emit('role_change_failure', error)
|
||||||
self.emit('role_change_failure', address, error)
|
self.emit('role_change_failure', address, error)
|
||||||
|
|
||||||
|
# [Classic only]
|
||||||
|
@host_event_handler
|
||||||
|
@with_connection_from_address
|
||||||
|
def on_classic_pairing(self, connection: Connection) -> None:
|
||||||
|
connection.emit('classic_pairing')
|
||||||
|
|
||||||
|
# [Classic only]
|
||||||
|
@host_event_handler
|
||||||
|
@with_connection_from_address
|
||||||
|
def on_classic_pairing_failure(self, connection: Connection, status) -> None:
|
||||||
|
connection.emit('classic_pairing_failure', status)
|
||||||
|
|
||||||
def on_pairing_start(self, connection: Connection) -> None:
|
def on_pairing_start(self, connection: Connection) -> None:
|
||||||
connection.emit('pairing_start')
|
connection.emit('pairing_start')
|
||||||
|
|
||||||
def on_pairing(self, connection: Connection, keys: PairingKeys, sc: bool) -> None:
|
def on_pairing(
|
||||||
|
self,
|
||||||
|
connection: Connection,
|
||||||
|
identity_address: Optional[Address],
|
||||||
|
keys: PairingKeys,
|
||||||
|
sc: bool,
|
||||||
|
) -> None:
|
||||||
|
if identity_address is not None:
|
||||||
|
connection.peer_resolvable_address = connection.peer_address
|
||||||
|
connection.peer_address = identity_address
|
||||||
connection.sc = sc
|
connection.sc = sc
|
||||||
connection.authenticated = True
|
connection.authenticated = True
|
||||||
connection.emit('pairing', keys)
|
connection.emit('pairing', keys)
|
||||||
@@ -3130,7 +3219,7 @@ class Device(CompositeEventEmitter):
|
|||||||
|
|
||||||
@host_event_handler
|
@host_event_handler
|
||||||
@with_connection_from_handle
|
@with_connection_from_handle
|
||||||
def on_l2cap_pdu(self, connection, cid, pdu):
|
def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes):
|
||||||
self.l2cap_channel_manager.on_pdu(connection, cid, pdu)
|
self.l2cap_channel_manager.on_pdu(connection, cid, pdu)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
91
bumble/drivers/__init__.py
Normal file
91
bumble/drivers/__init__.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Copyright 2021-2022 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.
|
||||||
|
"""
|
||||||
|
Drivers that can be used to customize the interaction between a host and a controller,
|
||||||
|
like loading firmware after a cold start.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import abc
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
import platform
|
||||||
|
from . import rtk
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Classes
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Driver(abc.ABC):
|
||||||
|
"""Base class for drivers."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def for_host(_host):
|
||||||
|
"""Return a driver instance for a host.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Host object for which a driver should be created.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A Driver instance if a driver should be instantiated for this host, or
|
||||||
|
None if no driver instance of this class is needed.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def init_controller(self):
|
||||||
|
"""Initialize the controller."""
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Functions
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def get_driver_for_host(host):
|
||||||
|
"""Probe all known diver classes until one returns a valid instance for a host,
|
||||||
|
or none is found.
|
||||||
|
"""
|
||||||
|
if driver := await rtk.Driver.for_host(host):
|
||||||
|
logger.debug("Instantiated RTK driver")
|
||||||
|
return driver
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def project_data_dir() -> pathlib.Path:
|
||||||
|
"""
|
||||||
|
Returns:
|
||||||
|
A path to an OS-specific directory for bumble data. The directory is created if
|
||||||
|
it doesn't exist.
|
||||||
|
"""
|
||||||
|
import platformdirs
|
||||||
|
|
||||||
|
if platform.system() == 'Darwin':
|
||||||
|
# platformdirs doesn't handle macOS right: it doesn't assemble a bundle id
|
||||||
|
# out of author & project
|
||||||
|
return platformdirs.user_data_path(
|
||||||
|
appname='com.google.bumble', ensure_exists=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# windows and linux don't use the com qualifier
|
||||||
|
return platformdirs.user_data_path(
|
||||||
|
appname='bumble', appauthor='google', ensure_exists=True
|
||||||
|
)
|
||||||
659
bumble/drivers/rtk.py
Normal file
659
bumble/drivers/rtk.py
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
# Copyright 2021-2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""
|
||||||
|
Support for Realtek USB dongles.
|
||||||
|
Based on various online bits of information, including the Linux kernel.
|
||||||
|
(see `drivers/bluetooth/btrtl.c`)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import asyncio
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import platform
|
||||||
|
import struct
|
||||||
|
from typing import Tuple
|
||||||
|
import weakref
|
||||||
|
|
||||||
|
|
||||||
|
from bumble.hci import (
|
||||||
|
hci_vendor_command_op_code,
|
||||||
|
STATUS_SPEC,
|
||||||
|
HCI_SUCCESS,
|
||||||
|
HCI_Command,
|
||||||
|
HCI_Reset_Command,
|
||||||
|
HCI_Read_Local_Version_Information_Command,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
RTK_ROM_LMP_8723A = 0x1200
|
||||||
|
RTK_ROM_LMP_8723B = 0x8723
|
||||||
|
RTK_ROM_LMP_8821A = 0x8821
|
||||||
|
RTK_ROM_LMP_8761A = 0x8761
|
||||||
|
RTK_ROM_LMP_8822B = 0x8822
|
||||||
|
RTK_ROM_LMP_8852A = 0x8852
|
||||||
|
RTK_CONFIG_MAGIC = 0x8723AB55
|
||||||
|
|
||||||
|
RTK_EPATCH_SIGNATURE = b"Realtech"
|
||||||
|
|
||||||
|
RTK_FRAGMENT_LENGTH = 252
|
||||||
|
|
||||||
|
RTK_FIRMWARE_DIR_ENV = "BUMBLE_RTK_FIRMWARE_DIR"
|
||||||
|
RTK_LINUX_FIRMWARE_DIR = "/lib/firmware/rtl_bt"
|
||||||
|
|
||||||
|
|
||||||
|
class RtlProjectId(enum.IntEnum):
|
||||||
|
PROJECT_ID_8723A = 0
|
||||||
|
PROJECT_ID_8723B = 1
|
||||||
|
PROJECT_ID_8821A = 2
|
||||||
|
PROJECT_ID_8761A = 3
|
||||||
|
PROJECT_ID_8822B = 8
|
||||||
|
PROJECT_ID_8723D = 9
|
||||||
|
PROJECT_ID_8821C = 10
|
||||||
|
PROJECT_ID_8822C = 13
|
||||||
|
PROJECT_ID_8761B = 14
|
||||||
|
PROJECT_ID_8852A = 18
|
||||||
|
PROJECT_ID_8852B = 20
|
||||||
|
PROJECT_ID_8852C = 25
|
||||||
|
|
||||||
|
|
||||||
|
RTK_PROJECT_ID_TO_ROM = {
|
||||||
|
0: RTK_ROM_LMP_8723A,
|
||||||
|
1: RTK_ROM_LMP_8723B,
|
||||||
|
2: RTK_ROM_LMP_8821A,
|
||||||
|
3: RTK_ROM_LMP_8761A,
|
||||||
|
8: RTK_ROM_LMP_8822B,
|
||||||
|
9: RTK_ROM_LMP_8723B,
|
||||||
|
10: RTK_ROM_LMP_8821A,
|
||||||
|
13: RTK_ROM_LMP_8822B,
|
||||||
|
14: RTK_ROM_LMP_8761A,
|
||||||
|
18: RTK_ROM_LMP_8852A,
|
||||||
|
20: RTK_ROM_LMP_8852A,
|
||||||
|
25: RTK_ROM_LMP_8852A,
|
||||||
|
}
|
||||||
|
|
||||||
|
# List of USB (VendorID, ProductID) for Realtek-based devices.
|
||||||
|
RTK_USB_PRODUCTS = {
|
||||||
|
# Realtek 8723AE
|
||||||
|
(0x0930, 0x021D),
|
||||||
|
(0x13D3, 0x3394),
|
||||||
|
# Realtek 8723BE
|
||||||
|
(0x0489, 0xE085),
|
||||||
|
(0x0489, 0xE08B),
|
||||||
|
(0x04F2, 0xB49F),
|
||||||
|
(0x13D3, 0x3410),
|
||||||
|
(0x13D3, 0x3416),
|
||||||
|
(0x13D3, 0x3459),
|
||||||
|
(0x13D3, 0x3494),
|
||||||
|
# Realtek 8723BU
|
||||||
|
(0x7392, 0xA611),
|
||||||
|
# Realtek 8723DE
|
||||||
|
(0x0BDA, 0xB009),
|
||||||
|
(0x2FF8, 0xB011),
|
||||||
|
# Realtek 8761BUV
|
||||||
|
(0x0B05, 0x190E),
|
||||||
|
(0x0BDA, 0x8771),
|
||||||
|
(0x2230, 0x0016),
|
||||||
|
(0x2357, 0x0604),
|
||||||
|
(0x2550, 0x8761),
|
||||||
|
(0x2B89, 0x8761),
|
||||||
|
(0x7392, 0xC611),
|
||||||
|
(0x0BDA, 0x877B),
|
||||||
|
# Realtek 8821AE
|
||||||
|
(0x0B05, 0x17DC),
|
||||||
|
(0x13D3, 0x3414),
|
||||||
|
(0x13D3, 0x3458),
|
||||||
|
(0x13D3, 0x3461),
|
||||||
|
(0x13D3, 0x3462),
|
||||||
|
# Realtek 8821CE
|
||||||
|
(0x0BDA, 0xB00C),
|
||||||
|
(0x0BDA, 0xC822),
|
||||||
|
(0x13D3, 0x3529),
|
||||||
|
# Realtek 8822BE
|
||||||
|
(0x0B05, 0x185C),
|
||||||
|
(0x13D3, 0x3526),
|
||||||
|
# Realtek 8822CE
|
||||||
|
(0x04C5, 0x161F),
|
||||||
|
(0x04CA, 0x4005),
|
||||||
|
(0x0B05, 0x18EF),
|
||||||
|
(0x0BDA, 0xB00C),
|
||||||
|
(0x0BDA, 0xC123),
|
||||||
|
(0x0BDA, 0xC822),
|
||||||
|
(0x0CB5, 0xC547),
|
||||||
|
(0x1358, 0xC123),
|
||||||
|
(0x13D3, 0x3548),
|
||||||
|
(0x13D3, 0x3549),
|
||||||
|
(0x13D3, 0x3553),
|
||||||
|
(0x13D3, 0x3555),
|
||||||
|
(0x2FF8, 0x3051),
|
||||||
|
# Realtek 8822CU
|
||||||
|
(0x13D3, 0x3549),
|
||||||
|
# Realtek 8852AE
|
||||||
|
(0x04C5, 0x165C),
|
||||||
|
(0x04CA, 0x4006),
|
||||||
|
(0x0BDA, 0x2852),
|
||||||
|
(0x0BDA, 0x385A),
|
||||||
|
(0x0BDA, 0x4852),
|
||||||
|
(0x0BDA, 0xC852),
|
||||||
|
(0x0CB8, 0xC549),
|
||||||
|
# Realtek 8852BE
|
||||||
|
(0x0BDA, 0x887B),
|
||||||
|
(0x0CB8, 0xC559),
|
||||||
|
(0x13D3, 0x3571),
|
||||||
|
# Realtek 8852CE
|
||||||
|
(0x04C5, 0x1675),
|
||||||
|
(0x04CA, 0x4007),
|
||||||
|
(0x0CB8, 0xC558),
|
||||||
|
(0x13D3, 0x3586),
|
||||||
|
(0x13D3, 0x3587),
|
||||||
|
(0x13D3, 0x3592),
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# HCI Commands
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
HCI_RTK_READ_ROM_VERSION_COMMAND = hci_vendor_command_op_code(0x6D)
|
||||||
|
HCI_RTK_DOWNLOAD_COMMAND = hci_vendor_command_op_code(0x20)
|
||||||
|
HCI_RTK_DROP_FIRMWARE_COMMAND = hci_vendor_command_op_code(0x66)
|
||||||
|
HCI_Command.register_commands(globals())
|
||||||
|
|
||||||
|
|
||||||
|
@HCI_Command.command(return_parameters_fields=[("status", STATUS_SPEC), ("version", 1)])
|
||||||
|
class HCI_RTK_Read_ROM_Version_Command(HCI_Command):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[("index", 1), ("payload", RTK_FRAGMENT_LENGTH)],
|
||||||
|
return_parameters_fields=[("status", STATUS_SPEC), ("index", 1)],
|
||||||
|
)
|
||||||
|
class HCI_RTK_Download_Command(HCI_Command):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@HCI_Command.command()
|
||||||
|
class HCI_RTK_Drop_Firmware_Command(HCI_Command):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Firmware:
|
||||||
|
def __init__(self, firmware):
|
||||||
|
extension_sig = bytes([0x51, 0x04, 0xFD, 0x77])
|
||||||
|
|
||||||
|
if not firmware.startswith(RTK_EPATCH_SIGNATURE):
|
||||||
|
raise ValueError("Firmware does not start with epatch signature")
|
||||||
|
|
||||||
|
if not firmware.endswith(extension_sig):
|
||||||
|
raise ValueError("Firmware does not end with extension sig")
|
||||||
|
|
||||||
|
# The firmware should start with a 14 byte header.
|
||||||
|
epatch_header_size = 14
|
||||||
|
if len(firmware) < epatch_header_size:
|
||||||
|
raise ValueError("Firmware too short")
|
||||||
|
|
||||||
|
# Look for the "project ID", starting from the end.
|
||||||
|
offset = len(firmware) - len(extension_sig)
|
||||||
|
project_id = -1
|
||||||
|
while offset >= epatch_header_size:
|
||||||
|
length, opcode = firmware[offset - 2 : offset]
|
||||||
|
offset -= 2
|
||||||
|
|
||||||
|
if opcode == 0xFF:
|
||||||
|
# End
|
||||||
|
break
|
||||||
|
|
||||||
|
if length == 0:
|
||||||
|
raise ValueError("Invalid 0-length instruction")
|
||||||
|
|
||||||
|
if opcode == 0 and length == 1:
|
||||||
|
project_id = firmware[offset - 1]
|
||||||
|
break
|
||||||
|
|
||||||
|
offset -= length
|
||||||
|
|
||||||
|
if project_id < 0:
|
||||||
|
raise ValueError("Project ID not found")
|
||||||
|
|
||||||
|
self.project_id = project_id
|
||||||
|
|
||||||
|
# Read the patch tables info.
|
||||||
|
self.version, num_patches = struct.unpack("<IH", firmware[8:14])
|
||||||
|
self.patches = []
|
||||||
|
|
||||||
|
# The patches tables are laid out as:
|
||||||
|
# <ChipID_1><ChipID_2>...<ChipID_N> (16 bits each)
|
||||||
|
# <PatchLength_1><PatchLength_2>...<PatchLength_N> (16 bits each)
|
||||||
|
# <PatchOffset_1><PatchOffset_2>...<PatchOffset_N> (32 bits each)
|
||||||
|
if epatch_header_size + 8 * num_patches > len(firmware):
|
||||||
|
raise ValueError("Firmware too short")
|
||||||
|
chip_id_table_offset = epatch_header_size
|
||||||
|
patch_length_table_offset = chip_id_table_offset + 2 * num_patches
|
||||||
|
patch_offset_table_offset = chip_id_table_offset + 4 * num_patches
|
||||||
|
for patch_index in range(num_patches):
|
||||||
|
chip_id_offset = chip_id_table_offset + 2 * patch_index
|
||||||
|
(chip_id,) = struct.unpack_from("<H", firmware, chip_id_offset)
|
||||||
|
(patch_length,) = struct.unpack_from(
|
||||||
|
"<H", firmware, patch_length_table_offset + 2 * patch_index
|
||||||
|
)
|
||||||
|
(patch_offset,) = struct.unpack_from(
|
||||||
|
"<I", firmware, patch_offset_table_offset + 4 * patch_index
|
||||||
|
)
|
||||||
|
if patch_offset + patch_length > len(firmware):
|
||||||
|
raise ValueError("Firmware too short")
|
||||||
|
|
||||||
|
# Get the SVN version for the patch
|
||||||
|
(svn_version,) = struct.unpack_from(
|
||||||
|
"<I", firmware, patch_offset + patch_length - 8
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a payload with the patch, replacing the last 4 bytes with
|
||||||
|
# the firmware version.
|
||||||
|
self.patches.append(
|
||||||
|
(
|
||||||
|
chip_id,
|
||||||
|
firmware[patch_offset : patch_offset + patch_length - 4]
|
||||||
|
+ struct.pack("<I", self.version),
|
||||||
|
svn_version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Driver:
|
||||||
|
@dataclass
|
||||||
|
class DriverInfo:
|
||||||
|
rom: int
|
||||||
|
hci: Tuple[int, int]
|
||||||
|
config_needed: bool
|
||||||
|
has_rom_version: bool
|
||||||
|
has_msft_ext: bool = False
|
||||||
|
fw_name: str = ""
|
||||||
|
config_name: str = ""
|
||||||
|
|
||||||
|
DRIVER_INFOS = [
|
||||||
|
# 8723A
|
||||||
|
DriverInfo(
|
||||||
|
rom=RTK_ROM_LMP_8723A,
|
||||||
|
hci=(0x0B, 0x06),
|
||||||
|
config_needed=False,
|
||||||
|
has_rom_version=False,
|
||||||
|
fw_name="rtl8723a_fw.bin",
|
||||||
|
config_name="",
|
||||||
|
),
|
||||||
|
# 8723B
|
||||||
|
DriverInfo(
|
||||||
|
rom=RTK_ROM_LMP_8723B,
|
||||||
|
hci=(0x0B, 0x06),
|
||||||
|
config_needed=False,
|
||||||
|
has_rom_version=True,
|
||||||
|
fw_name="rtl8723b_fw.bin",
|
||||||
|
config_name="rtl8723b_config.bin",
|
||||||
|
),
|
||||||
|
# 8723D
|
||||||
|
DriverInfo(
|
||||||
|
rom=RTK_ROM_LMP_8723B,
|
||||||
|
hci=(0x0D, 0x08),
|
||||||
|
config_needed=True,
|
||||||
|
has_rom_version=True,
|
||||||
|
fw_name="rtl8723d_fw.bin",
|
||||||
|
config_name="rtl8723d_config.bin",
|
||||||
|
),
|
||||||
|
# 8821A
|
||||||
|
DriverInfo(
|
||||||
|
rom=RTK_ROM_LMP_8821A,
|
||||||
|
hci=(0x0A, 0x06),
|
||||||
|
config_needed=False,
|
||||||
|
has_rom_version=True,
|
||||||
|
fw_name="rtl8821a_fw.bin",
|
||||||
|
config_name="rtl8821a_config.bin",
|
||||||
|
),
|
||||||
|
# 8821C
|
||||||
|
DriverInfo(
|
||||||
|
rom=RTK_ROM_LMP_8821A,
|
||||||
|
hci=(0x0C, 0x08),
|
||||||
|
config_needed=False,
|
||||||
|
has_rom_version=True,
|
||||||
|
has_msft_ext=True,
|
||||||
|
fw_name="rtl8821c_fw.bin",
|
||||||
|
config_name="rtl8821c_config.bin",
|
||||||
|
),
|
||||||
|
# 8761A
|
||||||
|
DriverInfo(
|
||||||
|
rom=RTK_ROM_LMP_8761A,
|
||||||
|
hci=(0x0A, 0x06),
|
||||||
|
config_needed=False,
|
||||||
|
has_rom_version=True,
|
||||||
|
fw_name="rtl8761a_fw.bin",
|
||||||
|
config_name="rtl8761a_config.bin",
|
||||||
|
),
|
||||||
|
# 8761BU
|
||||||
|
DriverInfo(
|
||||||
|
rom=RTK_ROM_LMP_8761A,
|
||||||
|
hci=(0x0B, 0x0A),
|
||||||
|
config_needed=False,
|
||||||
|
has_rom_version=True,
|
||||||
|
fw_name="rtl8761bu_fw.bin",
|
||||||
|
config_name="rtl8761bu_config.bin",
|
||||||
|
),
|
||||||
|
# 8822C
|
||||||
|
DriverInfo(
|
||||||
|
rom=RTK_ROM_LMP_8822B,
|
||||||
|
hci=(0x0C, 0x0A),
|
||||||
|
config_needed=False,
|
||||||
|
has_rom_version=True,
|
||||||
|
has_msft_ext=True,
|
||||||
|
fw_name="rtl8822cu_fw.bin",
|
||||||
|
config_name="rtl8822cu_config.bin",
|
||||||
|
),
|
||||||
|
# 8822B
|
||||||
|
DriverInfo(
|
||||||
|
rom=RTK_ROM_LMP_8822B,
|
||||||
|
hci=(0x0B, 0x07),
|
||||||
|
config_needed=True,
|
||||||
|
has_rom_version=True,
|
||||||
|
has_msft_ext=True,
|
||||||
|
fw_name="rtl8822b_fw.bin",
|
||||||
|
config_name="rtl8822b_config.bin",
|
||||||
|
),
|
||||||
|
# 8852A
|
||||||
|
DriverInfo(
|
||||||
|
rom=RTK_ROM_LMP_8852A,
|
||||||
|
hci=(0x0A, 0x0B),
|
||||||
|
config_needed=False,
|
||||||
|
has_rom_version=True,
|
||||||
|
has_msft_ext=True,
|
||||||
|
fw_name="rtl8852au_fw.bin",
|
||||||
|
config_name="rtl8852au_config.bin",
|
||||||
|
),
|
||||||
|
# 8852B
|
||||||
|
DriverInfo(
|
||||||
|
rom=RTK_ROM_LMP_8852A,
|
||||||
|
hci=(0xB, 0xB),
|
||||||
|
config_needed=False,
|
||||||
|
has_rom_version=True,
|
||||||
|
has_msft_ext=True,
|
||||||
|
fw_name="rtl8852bu_fw.bin",
|
||||||
|
config_name="rtl8852bu_config.bin",
|
||||||
|
),
|
||||||
|
# 8852C
|
||||||
|
DriverInfo(
|
||||||
|
rom=RTK_ROM_LMP_8852A,
|
||||||
|
hci=(0x0C, 0x0C),
|
||||||
|
config_needed=False,
|
||||||
|
has_rom_version=True,
|
||||||
|
has_msft_ext=True,
|
||||||
|
fw_name="rtl8852cu_fw.bin",
|
||||||
|
config_name="rtl8852cu_config.bin",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
POST_DROP_DELAY = 0.2
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def find_driver_info(hci_version, hci_subversion, lmp_subversion):
|
||||||
|
for driver_info in Driver.DRIVER_INFOS:
|
||||||
|
if driver_info.rom == lmp_subversion and driver_info.hci == (
|
||||||
|
hci_subversion,
|
||||||
|
hci_version,
|
||||||
|
):
|
||||||
|
return driver_info
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def find_binary_path(file_name):
|
||||||
|
# First check if an environment variable is set
|
||||||
|
if RTK_FIRMWARE_DIR_ENV in os.environ:
|
||||||
|
if (
|
||||||
|
path := pathlib.Path(os.environ[RTK_FIRMWARE_DIR_ENV]) / file_name
|
||||||
|
).is_file():
|
||||||
|
logger.debug(f"{file_name} found in env dir")
|
||||||
|
return path
|
||||||
|
|
||||||
|
# When the environment variable is set, don't look elsewhere
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Then, look where the firmware download tool writes by default
|
||||||
|
if (path := rtk_firmware_dir() / file_name).is_file():
|
||||||
|
logger.debug(f"{file_name} found in project data dir")
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Then, look in the package's driver directory
|
||||||
|
if (path := pathlib.Path(__file__).parent / "rtk_fw" / file_name).is_file():
|
||||||
|
logger.debug(f"{file_name} found in package dir")
|
||||||
|
return path
|
||||||
|
|
||||||
|
# On Linux, check the system's FW directory
|
||||||
|
if (
|
||||||
|
platform.system() == "Linux"
|
||||||
|
and (path := pathlib.Path(RTK_LINUX_FIRMWARE_DIR) / file_name).is_file()
|
||||||
|
):
|
||||||
|
logger.debug(f"{file_name} found in Linux system FW dir")
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Finally look in the current directory
|
||||||
|
if (path := pathlib.Path.cwd() / file_name).is_file():
|
||||||
|
logger.debug(f"{file_name} found in CWD")
|
||||||
|
return path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check(host):
|
||||||
|
if not host.hci_metadata:
|
||||||
|
logger.debug("USB metadata not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
vendor_id = host.hci_metadata.get("vendor_id", None)
|
||||||
|
product_id = host.hci_metadata.get("product_id", None)
|
||||||
|
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 RTK_USB_PRODUCTS:
|
||||||
|
logger.debug(
|
||||||
|
f"USB device ({vendor_id:04X}, {product_id:04X}) " "not in known list"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def driver_info_for_host(cls, host):
|
||||||
|
response = await host.send_command(
|
||||||
|
HCI_Read_Local_Version_Information_Command(), check_result=True
|
||||||
|
)
|
||||||
|
local_version = response.return_parameters
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"looking for a driver: 0x{local_version.lmp_subversion:04X} "
|
||||||
|
f"(0x{local_version.hci_version:02X}, "
|
||||||
|
f"0x{local_version.hci_subversion:04X})"
|
||||||
|
)
|
||||||
|
|
||||||
|
driver_info = cls.find_driver_info(
|
||||||
|
local_version.hci_version,
|
||||||
|
local_version.hci_subversion,
|
||||||
|
local_version.lmp_subversion,
|
||||||
|
)
|
||||||
|
if driver_info is None:
|
||||||
|
# TODO: it seems that the Linux driver will send command (0x3f, 0x66)
|
||||||
|
# in this case and then re-read the local version, then re-match.
|
||||||
|
logger.debug("firmware already loaded or no known driver for this device")
|
||||||
|
|
||||||
|
return driver_info
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def for_host(cls, host, force=False):
|
||||||
|
# Check that a driver is needed for this host
|
||||||
|
if not force and not cls.check(host):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get the driver info
|
||||||
|
driver_info = await cls.driver_info_for_host(host)
|
||||||
|
if driver_info is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Load the firmware
|
||||||
|
firmware_path = cls.find_binary_path(driver_info.fw_name)
|
||||||
|
if not firmware_path:
|
||||||
|
logger.warning(f"Firmware file {driver_info.fw_name} not found")
|
||||||
|
logger.warning("See https://google.github.io/bumble/drivers/realtek.html")
|
||||||
|
return None
|
||||||
|
with open(firmware_path, "rb") as firmware_file:
|
||||||
|
firmware = firmware_file.read()
|
||||||
|
|
||||||
|
# Load the config
|
||||||
|
config = None
|
||||||
|
if driver_info.config_name:
|
||||||
|
config_path = cls.find_binary_path(driver_info.config_name)
|
||||||
|
if config_path:
|
||||||
|
with open(config_path, "rb") as config_file:
|
||||||
|
config = config_file.read()
|
||||||
|
if driver_info.config_needed and not config:
|
||||||
|
logger.warning("Config needed, but no config file available")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return cls(host, driver_info, firmware, config)
|
||||||
|
|
||||||
|
def __init__(self, host, driver_info, firmware, config):
|
||||||
|
self.host = weakref.proxy(host)
|
||||||
|
self.driver_info = driver_info
|
||||||
|
self.firmware = firmware
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def drop_firmware(host):
|
||||||
|
host.send_hci_packet(HCI_RTK_Drop_Firmware_Command())
|
||||||
|
|
||||||
|
# Wait for the command to be effective (no response is sent)
|
||||||
|
await asyncio.sleep(Driver.POST_DROP_DELAY)
|
||||||
|
|
||||||
|
async def download_for_rtl8723a(self):
|
||||||
|
# Check that the firmware image does not include an epatch signature.
|
||||||
|
if RTK_EPATCH_SIGNATURE in self.firmware:
|
||||||
|
logger.warning(
|
||||||
|
"epatch signature found in firmware, it is probably the wrong firmware"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# TODO: load the firmware
|
||||||
|
|
||||||
|
async def download_for_rtl8723b(self):
|
||||||
|
if self.driver_info.has_rom_version:
|
||||||
|
response = await self.host.send_command(
|
||||||
|
HCI_RTK_Read_ROM_Version_Command(), check_result=True
|
||||||
|
)
|
||||||
|
if response.return_parameters.status != HCI_SUCCESS:
|
||||||
|
logger.warning("can't get ROM version")
|
||||||
|
return
|
||||||
|
rom_version = response.return_parameters.version
|
||||||
|
logger.debug(f"ROM version before download: {rom_version:04X}")
|
||||||
|
else:
|
||||||
|
rom_version = 0
|
||||||
|
|
||||||
|
firmware = Firmware(self.firmware)
|
||||||
|
logger.debug(f"firmware: project_id=0x{firmware.project_id:04X}")
|
||||||
|
for patch in firmware.patches:
|
||||||
|
if patch[0] == rom_version + 1:
|
||||||
|
logger.debug(f"using patch {patch[0]}")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.warning("no valid patch found for rom version {rom_version}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Append the config if there is one.
|
||||||
|
if self.config:
|
||||||
|
payload = patch[1] + self.config
|
||||||
|
else:
|
||||||
|
payload = patch[1]
|
||||||
|
|
||||||
|
# Download the payload, one fragment at a time.
|
||||||
|
fragment_count = math.ceil(len(payload) / RTK_FRAGMENT_LENGTH)
|
||||||
|
for fragment_index in range(fragment_count):
|
||||||
|
# NOTE: the Linux driver somehow adds 1 to the index after it wraps around.
|
||||||
|
# That's odd, but we"ll do the same here.
|
||||||
|
download_index = fragment_index & 0x7F
|
||||||
|
if download_index >= 0x80:
|
||||||
|
download_index += 1
|
||||||
|
if fragment_index == fragment_count - 1:
|
||||||
|
download_index |= 0x80 # End marker.
|
||||||
|
fragment_offset = fragment_index * RTK_FRAGMENT_LENGTH
|
||||||
|
fragment = payload[fragment_offset : fragment_offset + RTK_FRAGMENT_LENGTH]
|
||||||
|
logger.debug(f"downloading fragment {fragment_index}")
|
||||||
|
await self.host.send_command(
|
||||||
|
HCI_RTK_Download_Command(
|
||||||
|
index=download_index, payload=fragment, check_result=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug("download complete!")
|
||||||
|
|
||||||
|
# Read the version again
|
||||||
|
response = await self.host.send_command(
|
||||||
|
HCI_RTK_Read_ROM_Version_Command(), check_result=True
|
||||||
|
)
|
||||||
|
if response.return_parameters.status != HCI_SUCCESS:
|
||||||
|
logger.warning("can't get ROM version")
|
||||||
|
else:
|
||||||
|
rom_version = response.return_parameters.version
|
||||||
|
logger.debug(f"ROM version after download: {rom_version:04X}")
|
||||||
|
|
||||||
|
async def download_firmware(self):
|
||||||
|
if self.driver_info.rom == RTK_ROM_LMP_8723A:
|
||||||
|
return await self.download_for_rtl8723a()
|
||||||
|
|
||||||
|
if self.driver_info.rom in (
|
||||||
|
RTK_ROM_LMP_8723B,
|
||||||
|
RTK_ROM_LMP_8821A,
|
||||||
|
RTK_ROM_LMP_8761A,
|
||||||
|
RTK_ROM_LMP_8822B,
|
||||||
|
RTK_ROM_LMP_8852A,
|
||||||
|
):
|
||||||
|
return await self.download_for_rtl8723b()
|
||||||
|
|
||||||
|
raise ValueError("ROM not supported")
|
||||||
|
|
||||||
|
async def init_controller(self):
|
||||||
|
await self.download_firmware()
|
||||||
|
await self.host.send_command(HCI_Reset_Command(), check_result=True)
|
||||||
|
logger.info(f"loaded FW image {self.driver_info.fw_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def rtk_firmware_dir() -> pathlib.Path:
|
||||||
|
"""
|
||||||
|
Returns:
|
||||||
|
A path to a subdir of the project data dir for Realtek firmware.
|
||||||
|
The directory is created if it doesn't exist.
|
||||||
|
"""
|
||||||
|
from bumble.drivers import project_data_dir
|
||||||
|
|
||||||
|
p = project_data_dir() / "firmware" / "realtek"
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
@@ -283,8 +283,7 @@ class IncludedServiceDeclaration(Attribute):
|
|||||||
f'IncludedServiceDefinition(handle=0x{self.handle:04X}, '
|
f'IncludedServiceDefinition(handle=0x{self.handle:04X}, '
|
||||||
f'group_starting_handle=0x{self.service.handle:04X}, '
|
f'group_starting_handle=0x{self.service.handle:04X}, '
|
||||||
f'group_ending_handle=0x{self.service.end_group_handle:04X}, '
|
f'group_ending_handle=0x{self.service.end_group_handle:04X}, '
|
||||||
f'uuid={self.service.uuid}, '
|
f'uuid={self.service.uuid})'
|
||||||
f'{self.service.properties!s})'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -309,31 +308,33 @@ class Characteristic(Attribute):
|
|||||||
AUTHENTICATED_SIGNED_WRITES = 0x40
|
AUTHENTICATED_SIGNED_WRITES = 0x40
|
||||||
EXTENDED_PROPERTIES = 0x80
|
EXTENDED_PROPERTIES = 0x80
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def from_string(properties_str: str) -> Characteristic.Properties:
|
def from_string(cls, properties_str: str) -> Characteristic.Properties:
|
||||||
property_names: List[str] = []
|
|
||||||
for property in Characteristic.Properties:
|
|
||||||
if property.name is None:
|
|
||||||
raise TypeError()
|
|
||||||
property_names.append(property.name)
|
|
||||||
|
|
||||||
def string_to_property(property_string) -> Characteristic.Properties:
|
|
||||||
for property in zip(Characteristic.Properties, property_names):
|
|
||||||
if property_string == property[1]:
|
|
||||||
return property[0]
|
|
||||||
raise TypeError(f"Unable to convert {property_string} to Property")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return functools.reduce(
|
return functools.reduce(
|
||||||
lambda x, y: x | string_to_property(y),
|
lambda x, y: x | cls[y],
|
||||||
properties_str.split(","),
|
properties_str.replace("|", ",").split(","),
|
||||||
Characteristic.Properties(0),
|
Characteristic.Properties(0),
|
||||||
)
|
)
|
||||||
except TypeError:
|
except (TypeError, KeyError):
|
||||||
|
# The check for `p.name is not None` here is needed because for InFlag
|
||||||
|
# enums, the .name property can be None, when the enum value is 0,
|
||||||
|
# so the type hint for .name is Optional[str].
|
||||||
|
enum_list: List[str] = [p.name for p in cls if p.name is not None]
|
||||||
|
enum_list_str = ",".join(enum_list)
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by commas: {','.join(property_names)}\nGot: {properties_str}"
|
f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by , or |: {enum_list_str}\nGot: {properties_str}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
# NOTE: we override this method to offer a consistent result between python
|
||||||
|
# versions: the value returned by IntFlag.__str__() changed in version 11.
|
||||||
|
return '|'.join(
|
||||||
|
flag.name
|
||||||
|
for flag in Characteristic.Properties
|
||||||
|
if self.value & flag.value and flag.name is not None
|
||||||
|
)
|
||||||
|
|
||||||
# For backwards compatibility these are defined here
|
# For backwards compatibility these are defined here
|
||||||
# For new code, please use Characteristic.Properties.X
|
# For new code, please use Characteristic.Properties.X
|
||||||
BROADCAST = Properties.BROADCAST
|
BROADCAST = Properties.BROADCAST
|
||||||
@@ -373,7 +374,7 @@ class Characteristic(Attribute):
|
|||||||
f'Characteristic(handle=0x{self.handle:04X}, '
|
f'Characteristic(handle=0x{self.handle:04X}, '
|
||||||
f'end=0x{self.end_group_handle:04X}, '
|
f'end=0x{self.end_group_handle:04X}, '
|
||||||
f'uuid={self.uuid}, '
|
f'uuid={self.uuid}, '
|
||||||
f'{self.properties!s})'
|
f'{self.properties})'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -401,7 +402,7 @@ class CharacteristicDeclaration(Attribute):
|
|||||||
f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
|
f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
|
||||||
f'value_handle=0x{self.value_handle:04X}, '
|
f'value_handle=0x{self.value_handle:04X}, '
|
||||||
f'uuid={self.characteristic.uuid}, '
|
f'uuid={self.characteristic.uuid}, '
|
||||||
f'{self.characteristic.properties!s})'
|
f'{self.characteristic.properties})'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
955
bumble/hci.py
955
bumble/hci.py
File diff suppressed because it is too large
Load Diff
770
bumble/hfp.py
770
bumble/hfp.py
@@ -1,4 +1,4 @@
|
|||||||
# Copyright 2021-2022 Google LLC
|
# Copyright 2023 Google LLC
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -15,11 +15,35 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
import collections.abc
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import collections
|
import dataclasses
|
||||||
|
import enum
|
||||||
|
import traceback
|
||||||
|
import warnings
|
||||||
|
from typing import Dict, List, Union, Set, TYPE_CHECKING
|
||||||
|
|
||||||
from .colors import color
|
from . import at
|
||||||
|
from . import rfcomm
|
||||||
|
|
||||||
|
from bumble.colors import color
|
||||||
|
from bumble.core import (
|
||||||
|
ProtocolError,
|
||||||
|
BT_GENERIC_AUDIO_SERVICE,
|
||||||
|
BT_HANDSFREE_SERVICE,
|
||||||
|
BT_L2CAP_PROTOCOL_ID,
|
||||||
|
BT_RFCOMM_PROTOCOL_ID,
|
||||||
|
)
|
||||||
|
from bumble.sdp import (
|
||||||
|
DataElement,
|
||||||
|
ServiceAttribute,
|
||||||
|
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||||
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -27,6 +51,15 @@ from .colors import color
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Error
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class HfpProtocolError(ProtocolError):
|
||||||
|
def __init__(self, error_name: str = '', details: str = ''):
|
||||||
|
super().__init__(None, 'hfp', error_name, details)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Protocol Support
|
# Protocol Support
|
||||||
@@ -34,7 +67,13 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class HfpProtocol:
|
class HfpProtocol:
|
||||||
def __init__(self, dlc):
|
dlc: rfcomm.DLC
|
||||||
|
buffer: str
|
||||||
|
lines: collections.deque
|
||||||
|
lines_available: asyncio.Event
|
||||||
|
|
||||||
|
def __init__(self, dlc: rfcomm.DLC) -> None:
|
||||||
|
warnings.warn("See HfProtocol", DeprecationWarning)
|
||||||
self.dlc = dlc
|
self.dlc = dlc
|
||||||
self.buffer = ''
|
self.buffer = ''
|
||||||
self.lines = collections.deque()
|
self.lines = collections.deque()
|
||||||
@@ -42,7 +81,7 @@ class HfpProtocol:
|
|||||||
|
|
||||||
dlc.sink = self.feed
|
dlc.sink = self.feed
|
||||||
|
|
||||||
def feed(self, data):
|
def feed(self, data: Union[bytes, str]) -> None:
|
||||||
# Convert the data to a string if needed
|
# Convert the data to a string if needed
|
||||||
if isinstance(data, bytes):
|
if isinstance(data, bytes):
|
||||||
data = data.decode('utf-8')
|
data = data.decode('utf-8')
|
||||||
@@ -57,19 +96,19 @@ class HfpProtocol:
|
|||||||
if len(line) > 0:
|
if len(line) > 0:
|
||||||
self.on_line(line)
|
self.on_line(line)
|
||||||
|
|
||||||
def on_line(self, line):
|
def on_line(self, line: str) -> None:
|
||||||
self.lines.append(line)
|
self.lines.append(line)
|
||||||
self.lines_available.set()
|
self.lines_available.set()
|
||||||
|
|
||||||
def send_command_line(self, line):
|
def send_command_line(self, line: str) -> None:
|
||||||
logger.debug(color(f'>>> {line}', 'yellow'))
|
logger.debug(color(f'>>> {line}', 'yellow'))
|
||||||
self.dlc.write(line + '\r')
|
self.dlc.write(line + '\r')
|
||||||
|
|
||||||
def send_response_line(self, line):
|
def send_response_line(self, line: str) -> None:
|
||||||
logger.debug(color(f'>>> {line}', 'yellow'))
|
logger.debug(color(f'>>> {line}', 'yellow'))
|
||||||
self.dlc.write('\r\n' + line + '\r\n')
|
self.dlc.write('\r\n' + line + '\r\n')
|
||||||
|
|
||||||
async def next_line(self):
|
async def next_line(self) -> str:
|
||||||
await self.lines_available.wait()
|
await self.lines_available.wait()
|
||||||
line = self.lines.popleft()
|
line = self.lines.popleft()
|
||||||
if not self.lines:
|
if not self.lines:
|
||||||
@@ -77,19 +116,706 @@ class HfpProtocol:
|
|||||||
logger.debug(color(f'<<< {line}', 'green'))
|
logger.debug(color(f'<<< {line}', 'green'))
|
||||||
return line
|
return line
|
||||||
|
|
||||||
async def initialize_service(self):
|
|
||||||
# Perform Service Level Connection Initialization
|
|
||||||
self.send_command_line('AT+BRSF=2072') # Retrieve Supported Features
|
|
||||||
await (self.next_line())
|
|
||||||
await (self.next_line())
|
|
||||||
|
|
||||||
self.send_command_line('AT+CIND=?')
|
# -----------------------------------------------------------------------------
|
||||||
await (self.next_line())
|
# Normative protocol definitions
|
||||||
await (self.next_line())
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
self.send_command_line('AT+CIND?')
|
|
||||||
await (self.next_line())
|
|
||||||
await (self.next_line())
|
|
||||||
|
|
||||||
self.send_command_line('AT+CMER=3,0,0,1')
|
# HF supported features (AT+BRSF=) (normative).
|
||||||
await (self.next_line())
|
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||||
|
# and 3GPP 27.007
|
||||||
|
class HfFeature(enum.IntFlag):
|
||||||
|
EC_NR = 0x001 # Echo Cancel & Noise reduction
|
||||||
|
THREE_WAY_CALLING = 0x002
|
||||||
|
CLI_PRESENTATION_CAPABILITY = 0x004
|
||||||
|
VOICE_RECOGNITION_ACTIVATION = 0x008
|
||||||
|
REMOTE_VOLUME_CONTROL = 0x010
|
||||||
|
ENHANCED_CALL_STATUS = 0x020
|
||||||
|
ENHANCED_CALL_CONTROL = 0x040
|
||||||
|
CODEC_NEGOTIATION = 0x080
|
||||||
|
HF_INDICATORS = 0x100
|
||||||
|
ESCO_S4_SETTINGS_SUPPORTED = 0x200
|
||||||
|
ENHANCED_VOICE_RECOGNITION_STATUS = 0x400
|
||||||
|
VOICE_RECOGNITION_TEST = 0x800
|
||||||
|
|
||||||
|
|
||||||
|
# AG supported features (+BRSF:) (normative).
|
||||||
|
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||||
|
# and 3GPP 27.007
|
||||||
|
class AgFeature(enum.IntFlag):
|
||||||
|
THREE_WAY_CALLING = 0x001
|
||||||
|
EC_NR = 0x002 # Echo Cancel & Noise reduction
|
||||||
|
VOICE_RECOGNITION_FUNCTION = 0x004
|
||||||
|
IN_BAND_RING_TONE_CAPABILITY = 0x008
|
||||||
|
VOICE_TAG = 0x010 # Attach a number to voice tag
|
||||||
|
REJECT_CALL = 0x020 # Ability to reject a call
|
||||||
|
ENHANCED_CALL_STATUS = 0x040
|
||||||
|
ENHANCED_CALL_CONTROL = 0x080
|
||||||
|
EXTENDED_ERROR_RESULT_CODES = 0x100
|
||||||
|
CODEC_NEGOTIATION = 0x200
|
||||||
|
HF_INDICATORS = 0x400
|
||||||
|
ESCO_S4_SETTINGS_SUPPORTED = 0x800
|
||||||
|
ENHANCED_VOICE_RECOGNITION_STATUS = 0x1000
|
||||||
|
VOICE_RECOGNITION_TEST = 0x2000
|
||||||
|
|
||||||
|
|
||||||
|
# Audio Codec IDs (normative).
|
||||||
|
# Hands-Free Profile v1.8, 10 Appendix B
|
||||||
|
class AudioCodec(enum.IntEnum):
|
||||||
|
CVSD = 0x01 # Support for CVSD audio codec
|
||||||
|
MSBC = 0x02 # Support for mSBC audio codec
|
||||||
|
|
||||||
|
|
||||||
|
# HF Indicators (normative).
|
||||||
|
# Bluetooth Assigned Numbers, 6.10.1 HF Indicators
|
||||||
|
class HfIndicator(enum.IntEnum):
|
||||||
|
ENHANCED_SAFETY = 0x01 # Enhanced safety feature
|
||||||
|
BATTERY_LEVEL = 0x02 # Battery level feature
|
||||||
|
|
||||||
|
|
||||||
|
# Call Hold supported operations (normative).
|
||||||
|
# AT Commands Reference Guide, 3.5.2.3.12 +CHLD - Call Holding Services
|
||||||
|
class CallHoldOperation(enum.IntEnum):
|
||||||
|
RELEASE_ALL_HELD_CALLS = 0 # Release all held calls
|
||||||
|
RELEASE_ALL_ACTIVE_CALLS = 1 # Release all active calls, accept other
|
||||||
|
HOLD_ALL_ACTIVE_CALLS = 2 # Place all active calls on hold, accept other
|
||||||
|
ADD_HELD_CALL = 3 # Adds a held call to conversation
|
||||||
|
|
||||||
|
|
||||||
|
# Response Hold status (normative).
|
||||||
|
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||||
|
# and 3GPP 27.007
|
||||||
|
class ResponseHoldStatus(enum.IntEnum):
|
||||||
|
INC_CALL_HELD = 0 # Put incoming call on hold
|
||||||
|
HELD_CALL_ACC = 1 # Accept a held incoming call
|
||||||
|
HELD_CALL_REJ = 2 # Reject a held incoming call
|
||||||
|
|
||||||
|
|
||||||
|
# Values for the Call Setup AG indicator (normative).
|
||||||
|
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||||
|
# and 3GPP 27.007
|
||||||
|
class CallSetupAgIndicator(enum.IntEnum):
|
||||||
|
NOT_IN_CALL_SETUP = 0
|
||||||
|
INCOMING_CALL_PROCESS = 1
|
||||||
|
OUTGOING_CALL_SETUP = 2
|
||||||
|
REMOTE_ALERTED = 3 # Remote party alerted in an outgoing call
|
||||||
|
|
||||||
|
|
||||||
|
# Values for the Call Held AG indicator (normative).
|
||||||
|
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||||
|
# and 3GPP 27.007
|
||||||
|
class CallHeldAgIndicator(enum.IntEnum):
|
||||||
|
NO_CALLS_HELD = 0
|
||||||
|
# Call is placed on hold or active/held calls swapped
|
||||||
|
# (The AG has both an active AND a held call)
|
||||||
|
CALL_ON_HOLD_AND_ACTIVE_CALL = 1
|
||||||
|
CALL_ON_HOLD_NO_ACTIVE_CALL = 2 # Call on hold, no active call
|
||||||
|
|
||||||
|
|
||||||
|
# Call Info direction (normative).
|
||||||
|
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
|
||||||
|
class CallInfoDirection(enum.IntEnum):
|
||||||
|
MOBILE_ORIGINATED_CALL = 0
|
||||||
|
MOBILE_TERMINATED_CALL = 1
|
||||||
|
|
||||||
|
|
||||||
|
# Call Info status (normative).
|
||||||
|
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
|
||||||
|
class CallInfoStatus(enum.IntEnum):
|
||||||
|
ACTIVE = 0
|
||||||
|
HELD = 1
|
||||||
|
DIALING = 2
|
||||||
|
ALERTING = 3
|
||||||
|
INCOMING = 4
|
||||||
|
WAITING = 5
|
||||||
|
|
||||||
|
|
||||||
|
# Call Info mode (normative).
|
||||||
|
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
|
||||||
|
class CallInfoMode(enum.IntEnum):
|
||||||
|
VOICE = 0
|
||||||
|
DATA = 1
|
||||||
|
FAX = 2
|
||||||
|
UNKNOWN = 9
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Hands-Free Control Interoperability Requirements
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Response codes.
|
||||||
|
RESPONSE_CODES = [
|
||||||
|
"+APLSIRI",
|
||||||
|
"+BAC",
|
||||||
|
"+BCC",
|
||||||
|
"+BCS",
|
||||||
|
"+BIA",
|
||||||
|
"+BIEV",
|
||||||
|
"+BIND",
|
||||||
|
"+BINP",
|
||||||
|
"+BLDN",
|
||||||
|
"+BRSF",
|
||||||
|
"+BTRH",
|
||||||
|
"+BVRA",
|
||||||
|
"+CCWA",
|
||||||
|
"+CHLD",
|
||||||
|
"+CHUP",
|
||||||
|
"+CIND",
|
||||||
|
"+CLCC",
|
||||||
|
"+CLIP",
|
||||||
|
"+CMEE",
|
||||||
|
"+CMER",
|
||||||
|
"+CNUM",
|
||||||
|
"+COPS",
|
||||||
|
"+IPHONEACCEV",
|
||||||
|
"+NREC",
|
||||||
|
"+VGM",
|
||||||
|
"+VGS",
|
||||||
|
"+VTS",
|
||||||
|
"+XAPL",
|
||||||
|
"A",
|
||||||
|
"D",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Unsolicited responses and statuses.
|
||||||
|
UNSOLICITED_CODES = [
|
||||||
|
"+APLSIRI",
|
||||||
|
"+BCS",
|
||||||
|
"+BIND",
|
||||||
|
"+BSIR",
|
||||||
|
"+BTRH",
|
||||||
|
"+BVRA",
|
||||||
|
"+CCWA",
|
||||||
|
"+CIEV",
|
||||||
|
"+CLIP",
|
||||||
|
"+VGM",
|
||||||
|
"+VGS",
|
||||||
|
"BLACKLISTED",
|
||||||
|
"BUSY",
|
||||||
|
"DELAYED",
|
||||||
|
"NO ANSWER",
|
||||||
|
"NO CARRIER",
|
||||||
|
"RING",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Status codes
|
||||||
|
STATUS_CODES = [
|
||||||
|
"+CME ERROR",
|
||||||
|
"BLACKLISTED",
|
||||||
|
"BUSY",
|
||||||
|
"DELAYED",
|
||||||
|
"ERROR",
|
||||||
|
"NO ANSWER",
|
||||||
|
"NO CARRIER",
|
||||||
|
"OK",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class Configuration:
|
||||||
|
supported_hf_features: List[HfFeature]
|
||||||
|
supported_hf_indicators: List[HfIndicator]
|
||||||
|
supported_audio_codecs: List[AudioCodec]
|
||||||
|
|
||||||
|
|
||||||
|
class AtResponseType(enum.Enum):
|
||||||
|
"""Indicate if a response is expected from an AT command, and if multiple
|
||||||
|
responses are accepted."""
|
||||||
|
|
||||||
|
NONE = 0
|
||||||
|
SINGLE = 1
|
||||||
|
MULTIPLE = 2
|
||||||
|
|
||||||
|
|
||||||
|
class AtResponse:
|
||||||
|
code: str
|
||||||
|
parameters: list
|
||||||
|
|
||||||
|
def __init__(self, response: bytearray):
|
||||||
|
code_and_parameters = response.split(b':')
|
||||||
|
parameters = (
|
||||||
|
code_and_parameters[1] if len(code_and_parameters) > 1 else bytearray()
|
||||||
|
)
|
||||||
|
self.code = code_and_parameters[0].decode()
|
||||||
|
self.parameters = at.parse_parameters(parameters)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class AgIndicatorState:
|
||||||
|
description: str
|
||||||
|
index: int
|
||||||
|
supported_values: Set[int]
|
||||||
|
current_status: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HfIndicatorState:
|
||||||
|
supported: bool = False
|
||||||
|
enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class HfProtocol:
|
||||||
|
"""Implementation for the Hands-Free side of the Hands-Free profile.
|
||||||
|
Reference specification Hands-Free Profile v1.8"""
|
||||||
|
|
||||||
|
supported_hf_features: int
|
||||||
|
supported_audio_codecs: List[AudioCodec]
|
||||||
|
|
||||||
|
supported_ag_features: int
|
||||||
|
supported_ag_call_hold_operations: List[CallHoldOperation]
|
||||||
|
|
||||||
|
ag_indicators: List[AgIndicatorState]
|
||||||
|
hf_indicators: Dict[HfIndicator, HfIndicatorState]
|
||||||
|
|
||||||
|
dlc: rfcomm.DLC
|
||||||
|
command_lock: asyncio.Lock
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
response_queue: asyncio.Queue[AtResponse]
|
||||||
|
unsolicited_queue: asyncio.Queue[AtResponse]
|
||||||
|
else:
|
||||||
|
response_queue: asyncio.Queue
|
||||||
|
unsolicited_queue: asyncio.Queue
|
||||||
|
read_buffer: bytearray
|
||||||
|
|
||||||
|
def __init__(self, dlc: rfcomm.DLC, configuration: Configuration):
|
||||||
|
# Configure internal state.
|
||||||
|
self.dlc = dlc
|
||||||
|
self.command_lock = asyncio.Lock()
|
||||||
|
self.response_queue = asyncio.Queue()
|
||||||
|
self.unsolicited_queue = asyncio.Queue()
|
||||||
|
self.read_buffer = bytearray()
|
||||||
|
|
||||||
|
# Build local features.
|
||||||
|
self.supported_hf_features = sum(configuration.supported_hf_features)
|
||||||
|
self.supported_audio_codecs = configuration.supported_audio_codecs
|
||||||
|
|
||||||
|
self.hf_indicators = {
|
||||||
|
indicator: HfIndicatorState()
|
||||||
|
for indicator in configuration.supported_hf_indicators
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clear remote features.
|
||||||
|
self.supported_ag_features = 0
|
||||||
|
self.supported_ag_call_hold_operations = []
|
||||||
|
self.ag_indicators = []
|
||||||
|
|
||||||
|
# Bind the AT reader to the RFCOMM channel.
|
||||||
|
self.dlc.sink = self._read_at
|
||||||
|
|
||||||
|
def supports_hf_feature(self, feature: HfFeature) -> bool:
|
||||||
|
return (self.supported_hf_features & feature) != 0
|
||||||
|
|
||||||
|
def supports_ag_feature(self, feature: AgFeature) -> bool:
|
||||||
|
return (self.supported_ag_features & feature) != 0
|
||||||
|
|
||||||
|
# Read AT messages from the RFCOMM channel.
|
||||||
|
# Enqueue AT commands, responses, unsolicited responses to their
|
||||||
|
# respective queues, and set the corresponding event.
|
||||||
|
def _read_at(self, data: bytes):
|
||||||
|
# Append to the read buffer.
|
||||||
|
self.read_buffer.extend(data)
|
||||||
|
|
||||||
|
# Locate header and trailer.
|
||||||
|
header = self.read_buffer.find(b'\r\n')
|
||||||
|
trailer = self.read_buffer.find(b'\r\n', header + 2)
|
||||||
|
if header == -1 or trailer == -1:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Isolate the AT response code and parameters.
|
||||||
|
raw_response = self.read_buffer[header + 2 : trailer]
|
||||||
|
response = AtResponse(raw_response)
|
||||||
|
logger.debug(f"<<< {raw_response.decode()}")
|
||||||
|
|
||||||
|
# Consume the response bytes.
|
||||||
|
self.read_buffer = self.read_buffer[trailer + 2 :]
|
||||||
|
|
||||||
|
# Forward the received code to the correct queue.
|
||||||
|
if self.command_lock.locked() and (
|
||||||
|
response.code in STATUS_CODES or response.code in RESPONSE_CODES
|
||||||
|
):
|
||||||
|
self.response_queue.put_nowait(response)
|
||||||
|
elif response.code in UNSOLICITED_CODES:
|
||||||
|
self.unsolicited_queue.put_nowait(response)
|
||||||
|
else:
|
||||||
|
logger.warning(f"dropping unexpected response with code '{response.code}'")
|
||||||
|
|
||||||
|
# Send an AT command and wait for the peer response.
|
||||||
|
# Wait for the AT responses sent by the peer, to the status code.
|
||||||
|
# Raises asyncio.TimeoutError if the status is not received
|
||||||
|
# after a timeout (default 1 second).
|
||||||
|
# Raises ProtocolError if the status is not OK.
|
||||||
|
async def execute_command(
|
||||||
|
self,
|
||||||
|
cmd: str,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
response_type: AtResponseType = AtResponseType.NONE,
|
||||||
|
) -> Union[None, AtResponse, List[AtResponse]]:
|
||||||
|
async with self.command_lock:
|
||||||
|
logger.debug(f">>> {cmd}")
|
||||||
|
self.dlc.write(cmd + '\r')
|
||||||
|
responses: List[AtResponse] = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
self.response_queue.get(), timeout=timeout
|
||||||
|
)
|
||||||
|
if result.code == 'OK':
|
||||||
|
if response_type == AtResponseType.SINGLE and len(responses) != 1:
|
||||||
|
raise HfpProtocolError("NO ANSWER")
|
||||||
|
|
||||||
|
if response_type == AtResponseType.MULTIPLE:
|
||||||
|
return responses
|
||||||
|
if response_type == AtResponseType.SINGLE:
|
||||||
|
return responses[0]
|
||||||
|
return None
|
||||||
|
if result.code in STATUS_CODES:
|
||||||
|
raise HfpProtocolError(result.code)
|
||||||
|
responses.append(result)
|
||||||
|
|
||||||
|
# 4.2.1 Service Level Connection Initialization.
|
||||||
|
async def initiate_slc(self):
|
||||||
|
# 4.2.1.1 Supported features exchange
|
||||||
|
# First, in the initialization procedure, the HF shall send the
|
||||||
|
# AT+BRSF=<HF supported features> command to the AG to both notify
|
||||||
|
# the AG of the supported features in the HF, as well as to retrieve the
|
||||||
|
# supported features in the AG using the +BRSF result code.
|
||||||
|
response = await self.execute_command(
|
||||||
|
f"AT+BRSF={self.supported_hf_features}", response_type=AtResponseType.SINGLE
|
||||||
|
)
|
||||||
|
|
||||||
|
self.supported_ag_features = int(response.parameters[0])
|
||||||
|
logger.info(f"supported AG features: {self.supported_ag_features}")
|
||||||
|
for feature in AgFeature:
|
||||||
|
if self.supports_ag_feature(feature):
|
||||||
|
logger.info(f" - {feature.name}")
|
||||||
|
|
||||||
|
# 4.2.1.2 Codec Negotiation
|
||||||
|
# Secondly, in the initialization procedure, if the HF supports the
|
||||||
|
# Codec Negotiation feature, it shall check if the AT+BRSF command
|
||||||
|
# response from the AG has indicated that it supports the Codec
|
||||||
|
# Negotiation feature.
|
||||||
|
if self.supports_hf_feature(
|
||||||
|
HfFeature.CODEC_NEGOTIATION
|
||||||
|
) and self.supports_ag_feature(AgFeature.CODEC_NEGOTIATION):
|
||||||
|
# If both the HF and AG do support the Codec Negotiation feature
|
||||||
|
# then the HF shall send the AT+BAC=<HF available codecs> command to
|
||||||
|
# the AG to notify the AG of the available codecs in the HF.
|
||||||
|
codecs = [str(c) for c in self.supported_audio_codecs]
|
||||||
|
await self.execute_command(f"AT+BAC={','.join(codecs)}")
|
||||||
|
|
||||||
|
# 4.2.1.3 AG Indicators
|
||||||
|
# After having retrieved the supported features in the AG, the HF shall
|
||||||
|
# determine which indicators are supported by the AG, as well as the
|
||||||
|
# ordering of the supported indicators. This is because, according to
|
||||||
|
# the 3GPP 27.007 specification [2], the AG may support additional
|
||||||
|
# indicators not provided for by the Hands-Free Profile, and because the
|
||||||
|
# ordering of the indicators is implementation specific. The HF uses
|
||||||
|
# the AT+CIND=? Test command to retrieve information about the supported
|
||||||
|
# indicators and their ordering.
|
||||||
|
response = await self.execute_command(
|
||||||
|
"AT+CIND=?", response_type=AtResponseType.SINGLE
|
||||||
|
)
|
||||||
|
|
||||||
|
self.ag_indicators = []
|
||||||
|
for index, indicator in enumerate(response.parameters):
|
||||||
|
description = indicator[0].decode()
|
||||||
|
supported_values = []
|
||||||
|
for value in indicator[1]:
|
||||||
|
value = value.split(b'-')
|
||||||
|
value = [int(v) for v in value]
|
||||||
|
value_min = value[0]
|
||||||
|
value_max = value[1] if len(value) > 1 else value[0]
|
||||||
|
supported_values.extend([v for v in range(value_min, value_max + 1)])
|
||||||
|
|
||||||
|
self.ag_indicators.append(
|
||||||
|
AgIndicatorState(description, index, set(supported_values), 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Once the HF has the necessary supported indicator and ordering
|
||||||
|
# information, it shall retrieve the current status of the indicators
|
||||||
|
# in the AG using the AT+CIND? Read command.
|
||||||
|
response = await self.execute_command(
|
||||||
|
"AT+CIND?", response_type=AtResponseType.SINGLE
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, indicator in enumerate(response.parameters):
|
||||||
|
self.ag_indicators[index].current_status = int(indicator)
|
||||||
|
|
||||||
|
# After having retrieved the status of the indicators in the AG, the HF
|
||||||
|
# shall then enable the "Indicators status update" function in the AG by
|
||||||
|
# issuing the AT+CMER command, to which the AG shall respond with OK.
|
||||||
|
await self.execute_command("AT+CMER=3,,,1")
|
||||||
|
|
||||||
|
if self.supports_hf_feature(
|
||||||
|
HfFeature.THREE_WAY_CALLING
|
||||||
|
) and self.supports_ag_feature(HfFeature.THREE_WAY_CALLING):
|
||||||
|
# After the HF has enabled the “Indicators status update” function in
|
||||||
|
# the AG, and if the “Call waiting and 3-way calling” bit was set in the
|
||||||
|
# supported features bitmap by both the HF and the AG, the HF shall
|
||||||
|
# issue the AT+CHLD=? test command to retrieve the information about how
|
||||||
|
# the call hold and multiparty services are supported in the AG. The HF
|
||||||
|
# shall not issue the AT+CHLD=? test command in case either the HF or
|
||||||
|
# the AG does not support the "Three-way calling" feature.
|
||||||
|
response = await self.execute_command(
|
||||||
|
"AT+CHLD=?", response_type=AtResponseType.SINGLE
|
||||||
|
)
|
||||||
|
|
||||||
|
self.supported_ag_call_hold_operations = [
|
||||||
|
CallHoldOperation(int(operation))
|
||||||
|
for operation in response.parameters[0]
|
||||||
|
if not b'x' in operation
|
||||||
|
]
|
||||||
|
|
||||||
|
# 4.2.1.4 HF Indicators
|
||||||
|
# If the HF supports the HF indicator feature, it shall check the +BRSF
|
||||||
|
# response to see if the AG also supports the HF Indicator feature.
|
||||||
|
if self.supports_hf_feature(
|
||||||
|
HfFeature.HF_INDICATORS
|
||||||
|
) and self.supports_ag_feature(AgFeature.HF_INDICATORS):
|
||||||
|
# If both the HF and AG support the HF Indicator feature, then the HF
|
||||||
|
# shall send the AT+BIND=<HF supported HF indicators> command to the AG
|
||||||
|
# to notify the AG of the supported indicators’ assigned numbers in the
|
||||||
|
# HF. The AG shall respond with OK
|
||||||
|
indicators = [str(i) for i in self.hf_indicators.keys()]
|
||||||
|
await self.execute_command(f"AT+BIND={','.join(indicators)}")
|
||||||
|
|
||||||
|
# After having provided the AG with the HF indicators it supports,
|
||||||
|
# the HF shall send the AT+BIND=? to request HF indicators supported
|
||||||
|
# by the AG. The AG shall reply with the +BIND response listing all
|
||||||
|
# HF indicators that it supports followed by an OK.
|
||||||
|
response = await self.execute_command(
|
||||||
|
"AT+BIND=?", response_type=AtResponseType.SINGLE
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("supported HF indicators:")
|
||||||
|
for indicator in response.parameters[0]:
|
||||||
|
indicator = HfIndicator(int(indicator))
|
||||||
|
logger.info(f" - {indicator.name}")
|
||||||
|
if indicator in self.hf_indicators:
|
||||||
|
self.hf_indicators[indicator].supported = True
|
||||||
|
|
||||||
|
# Once the HF receives the supported HF indicators list from the AG,
|
||||||
|
# the HF shall send the AT+BIND? command to determine which HF
|
||||||
|
# indicators are enabled. The AG shall respond with one or more
|
||||||
|
# +BIND responses. The AG shall terminate the list with OK.
|
||||||
|
# (See Section 4.36.1.3).
|
||||||
|
responses = await self.execute_command(
|
||||||
|
"AT+BIND?", response_type=AtResponseType.MULTIPLE
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("enabled HF indicators:")
|
||||||
|
for response in responses:
|
||||||
|
indicator = HfIndicator(int(response.parameters[0]))
|
||||||
|
enabled = int(response.parameters[1]) != 0
|
||||||
|
logger.info(f" - {indicator.name}: {enabled}")
|
||||||
|
if indicator in self.hf_indicators:
|
||||||
|
self.hf_indicators[indicator].enabled = True
|
||||||
|
|
||||||
|
logger.info("SLC setup completed")
|
||||||
|
|
||||||
|
# 4.11.2 Audio Connection Setup by HF
|
||||||
|
async def setup_audio_connection(self):
|
||||||
|
# When the HF triggers the establishment of the Codec Connection it
|
||||||
|
# shall send the AT command AT+BCC to the AG. The AG shall respond with
|
||||||
|
# OK if it will start the Codec Connection procedure, and with ERROR
|
||||||
|
# if it cannot start the Codec Connection procedure.
|
||||||
|
await self.execute_command("AT+BCC")
|
||||||
|
|
||||||
|
# 4.11.3 Codec Connection Setup
|
||||||
|
async def setup_codec_connection(self, codec_id: int):
|
||||||
|
# The AG shall send a +BCS=<Codec ID> unsolicited response to the HF.
|
||||||
|
# The HF shall then respond to the incoming unsolicited response with
|
||||||
|
# the AT command AT+BCS=<Codec ID>. The ID shall be the same as in the
|
||||||
|
# unsolicited response code as long as the ID is supported.
|
||||||
|
# If the received ID is not available, the HF shall respond with
|
||||||
|
# AT+BAC with its available codecs.
|
||||||
|
if codec_id not in self.supported_audio_codecs:
|
||||||
|
codecs = [str(c) for c in self.supported_audio_codecs]
|
||||||
|
await self.execute_command(f"AT+BAC={','.join(codecs)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.execute_command(f"AT+BCS={codec_id}")
|
||||||
|
|
||||||
|
# After sending the OK response, the AG shall open the
|
||||||
|
# Synchronous Connection with the settings that are determined by the
|
||||||
|
# ID. The HF shall be ready to accept the synchronous connection
|
||||||
|
# establishment as soon as it has sent the AT commands AT+BCS=<Codec ID>.
|
||||||
|
|
||||||
|
logger.info("codec connection setup completed")
|
||||||
|
|
||||||
|
# 4.13.1 Answer Incoming Call from the HF – In-Band Ringing
|
||||||
|
async def answer_incoming_call(self):
|
||||||
|
# The user accepts the incoming voice call by using the proper means
|
||||||
|
# provided by the HF. The HF shall then send the ATA command
|
||||||
|
# (see Section 4.34) to the AG. The AG shall then begin the procedure for
|
||||||
|
# accepting the incoming call.
|
||||||
|
await self.execute_command("ATA")
|
||||||
|
|
||||||
|
# 4.14.1 Reject an Incoming Call from the HF
|
||||||
|
async def reject_incoming_call(self):
|
||||||
|
# The user rejects the incoming call by using the User Interface on the
|
||||||
|
# Hands-Free unit. The HF shall then send the AT+CHUP command
|
||||||
|
# (see Section 4.34) to the AG. This may happen at any time during the
|
||||||
|
# procedures described in Sections 4.13.1 and 4.13.2.
|
||||||
|
await self.execute_command("AT+CHUP")
|
||||||
|
|
||||||
|
# 4.15.1 Terminate a Call Process from the HF
|
||||||
|
async def terminate_call(self):
|
||||||
|
# The user may abort the ongoing call process using whatever means
|
||||||
|
# provided by the Hands-Free unit. The HF shall send AT+CHUP command
|
||||||
|
# (see Section 4.34) to the AG, and the AG shall then start the
|
||||||
|
# procedure to terminate or interrupt the current call procedure.
|
||||||
|
# The AG shall then send the OK indication followed by the +CIEV result
|
||||||
|
# code, with the value indicating (call=0).
|
||||||
|
await self.execute_command("AT+CHUP")
|
||||||
|
|
||||||
|
async def update_ag_indicator(self, index: int, value: int):
|
||||||
|
self.ag_indicators[index].current_status = value
|
||||||
|
logger.info(
|
||||||
|
f"AG indicator updated: {self.ag_indicators[index].description}, {value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_unsolicited(self):
|
||||||
|
"""Handle unsolicited result codes sent by the audio gateway."""
|
||||||
|
result = await self.unsolicited_queue.get()
|
||||||
|
if result.code == "+BCS":
|
||||||
|
await self.setup_codec_connection(int(result.parameters[0]))
|
||||||
|
elif result.code == "+CIEV":
|
||||||
|
await self.update_ag_indicator(
|
||||||
|
int(result.parameters[0]), int(result.parameters[1])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.info(f"unhandled unsolicited response {result.code}")
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""Main rountine for the Hands-Free side of the HFP protocol.
|
||||||
|
Initiates the service level connection then loops handling
|
||||||
|
unsolicited AG responses."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.initiate_slc()
|
||||||
|
while True:
|
||||||
|
await self.handle_unsolicited()
|
||||||
|
except Exception:
|
||||||
|
logger.error("HFP-HF protocol failed with the following error:")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Normative SDP definitions
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# Profile version (normative).
|
||||||
|
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
|
||||||
|
class ProfileVersion(enum.IntEnum):
|
||||||
|
V1_5 = 0x0105
|
||||||
|
V1_6 = 0x0106
|
||||||
|
V1_7 = 0x0107
|
||||||
|
V1_8 = 0x0108
|
||||||
|
V1_9 = 0x0109
|
||||||
|
|
||||||
|
|
||||||
|
# HF supported features (normative).
|
||||||
|
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
|
||||||
|
class HfSdpFeature(enum.IntFlag):
|
||||||
|
EC_NR = 0x01 # Echo Cancel & Noise reduction
|
||||||
|
THREE_WAY_CALLING = 0x02
|
||||||
|
CLI_PRESENTATION_CAPABILITY = 0x04
|
||||||
|
VOICE_RECOGNITION_ACTIVATION = 0x08
|
||||||
|
REMOTE_VOLUME_CONTROL = 0x10
|
||||||
|
WIDE_BAND = 0x20 # Wide band speech
|
||||||
|
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
|
||||||
|
VOICE_RECOGNITION_TEST = 0x80
|
||||||
|
|
||||||
|
|
||||||
|
# AG supported features (normative).
|
||||||
|
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
|
||||||
|
class AgSdpFeature(enum.IntFlag):
|
||||||
|
THREE_WAY_CALLING = 0x01
|
||||||
|
EC_NR = 0x02 # Echo Cancel & Noise reduction
|
||||||
|
VOICE_RECOGNITION_FUNCTION = 0x04
|
||||||
|
IN_BAND_RING_TONE_CAPABILITY = 0x08
|
||||||
|
VOICE_TAG = 0x10 # Attach a number to voice tag
|
||||||
|
WIDE_BAND = 0x20 # Wide band speech
|
||||||
|
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
|
||||||
|
VOICE_RECOGNITION_TEST = 0x80
|
||||||
|
|
||||||
|
|
||||||
|
def sdp_records(
|
||||||
|
service_record_handle: int, rfcomm_channel: int, configuration: Configuration
|
||||||
|
) -> List[ServiceAttribute]:
|
||||||
|
"""Generate the SDP record for HFP Hands-Free support.
|
||||||
|
The record exposes the features supported in the input configuration,
|
||||||
|
and the allocated RFCOMM channel."""
|
||||||
|
|
||||||
|
hf_supported_features = 0
|
||||||
|
|
||||||
|
if HfFeature.EC_NR in configuration.supported_hf_features:
|
||||||
|
hf_supported_features |= HfSdpFeature.EC_NR
|
||||||
|
if HfFeature.THREE_WAY_CALLING in configuration.supported_hf_features:
|
||||||
|
hf_supported_features |= HfSdpFeature.THREE_WAY_CALLING
|
||||||
|
if HfFeature.CLI_PRESENTATION_CAPABILITY in configuration.supported_hf_features:
|
||||||
|
hf_supported_features |= HfSdpFeature.CLI_PRESENTATION_CAPABILITY
|
||||||
|
if HfFeature.VOICE_RECOGNITION_ACTIVATION in configuration.supported_hf_features:
|
||||||
|
hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_ACTIVATION
|
||||||
|
if HfFeature.REMOTE_VOLUME_CONTROL in configuration.supported_hf_features:
|
||||||
|
hf_supported_features |= HfSdpFeature.REMOTE_VOLUME_CONTROL
|
||||||
|
if (
|
||||||
|
HfFeature.ENHANCED_VOICE_RECOGNITION_STATUS
|
||||||
|
in configuration.supported_hf_features
|
||||||
|
):
|
||||||
|
hf_supported_features |= HfSdpFeature.ENHANCED_VOICE_RECOGNITION_STATUS
|
||||||
|
if HfFeature.VOICE_RECOGNITION_TEST in configuration.supported_hf_features:
|
||||||
|
hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_TEST
|
||||||
|
|
||||||
|
if AudioCodec.MSBC in configuration.supported_audio_codecs:
|
||||||
|
hf_supported_features |= HfSdpFeature.WIDE_BAND
|
||||||
|
|
||||||
|
return [
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||||
|
DataElement.unsigned_integer_32(service_record_handle),
|
||||||
|
),
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
|
DataElement.uuid(BT_HANDSFREE_SERVICE),
|
||||||
|
DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
|
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
|
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
|
||||||
|
DataElement.unsigned_integer_8(rfcomm_channel),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
|
DataElement.uuid(BT_HANDSFREE_SERVICE),
|
||||||
|
DataElement.unsigned_integer_16(ProfileVersion.V1_8),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
|
||||||
|
DataElement.unsigned_integer_16(hf_supported_features),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|||||||
115
bumble/host.py
115
bumble/host.py
@@ -15,22 +15,24 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import collections
|
import collections
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
|
from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable
|
||||||
|
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.l2cap import L2CAP_PDU
|
from bumble.l2cap import L2CAP_PDU
|
||||||
from bumble.snoop import Snooper
|
from bumble.snoop import Snooper
|
||||||
|
from bumble import drivers
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from .hci import (
|
from .hci import (
|
||||||
Address,
|
Address,
|
||||||
HCI_ACL_DATA_PACKET,
|
HCI_ACL_DATA_PACKET,
|
||||||
HCI_COMMAND_COMPLETE_EVENT,
|
|
||||||
HCI_COMMAND_PACKET,
|
HCI_COMMAND_PACKET,
|
||||||
|
HCI_COMMAND_COMPLETE_EVENT,
|
||||||
HCI_EVENT_PACKET,
|
HCI_EVENT_PACKET,
|
||||||
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
||||||
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
|
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
|
||||||
@@ -44,8 +46,11 @@ from .hci import (
|
|||||||
HCI_VERSION_BLUETOOTH_CORE_4_0,
|
HCI_VERSION_BLUETOOTH_CORE_4_0,
|
||||||
HCI_AclDataPacket,
|
HCI_AclDataPacket,
|
||||||
HCI_AclDataPacketAssembler,
|
HCI_AclDataPacketAssembler,
|
||||||
|
HCI_Command,
|
||||||
|
HCI_Command_Complete_Event,
|
||||||
HCI_Constant,
|
HCI_Constant,
|
||||||
HCI_Error,
|
HCI_Error,
|
||||||
|
HCI_Event,
|
||||||
HCI_LE_Long_Term_Key_Request_Negative_Reply_Command,
|
HCI_LE_Long_Term_Key_Request_Negative_Reply_Command,
|
||||||
HCI_LE_Long_Term_Key_Request_Reply_Command,
|
HCI_LE_Long_Term_Key_Request_Reply_Command,
|
||||||
HCI_LE_Read_Buffer_Size_Command,
|
HCI_LE_Read_Buffer_Size_Command,
|
||||||
@@ -65,12 +70,16 @@ from .hci import (
|
|||||||
)
|
)
|
||||||
from .core import (
|
from .core import (
|
||||||
BT_BR_EDR_TRANSPORT,
|
BT_BR_EDR_TRANSPORT,
|
||||||
BT_CENTRAL_ROLE,
|
|
||||||
BT_LE_TRANSPORT,
|
BT_LE_TRANSPORT,
|
||||||
ConnectionPHY,
|
ConnectionPHY,
|
||||||
ConnectionParameters,
|
ConnectionParameters,
|
||||||
|
InvalidStateError,
|
||||||
)
|
)
|
||||||
from .utils import AbortableEventEmitter
|
from .utils import AbortableEventEmitter
|
||||||
|
from .transport.common import TransportLostError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .transport.common import TransportSink, TransportSource
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -94,27 +103,39 @@ HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Connection:
|
class Connection:
|
||||||
def __init__(self, host, handle, peer_address, transport):
|
def __init__(self, host: Host, handle: int, peer_address: Address, transport: int):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.handle = handle
|
self.handle = handle
|
||||||
self.peer_address = peer_address
|
self.peer_address = peer_address
|
||||||
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
|
|
||||||
def on_hci_acl_data_packet(self, packet):
|
def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
|
||||||
self.assembler.feed_packet(packet)
|
self.assembler.feed_packet(packet)
|
||||||
|
|
||||||
def on_acl_pdu(self, pdu):
|
def on_acl_pdu(self, pdu: bytes) -> None:
|
||||||
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
||||||
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
|
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Host(AbortableEventEmitter):
|
class Host(AbortableEventEmitter):
|
||||||
def __init__(self, controller_source=None, controller_sink=None):
|
connections: Dict[int, Connection]
|
||||||
|
acl_packet_queue: collections.deque[HCI_AclDataPacket]
|
||||||
|
hci_sink: TransportSink
|
||||||
|
long_term_key_provider: Optional[
|
||||||
|
Callable[[int, bytes, int], Awaitable[Optional[bytes]]]
|
||||||
|
]
|
||||||
|
link_key_provider: Optional[Callable[[Address], Awaitable[Optional[bytes]]]]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
controller_source: Optional[TransportSource] = None,
|
||||||
|
controller_sink: Optional[TransportSink] = None,
|
||||||
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.hci_sink = None
|
self.hci_metadata = None
|
||||||
self.ready = False # True when we can accept incoming packets
|
self.ready = False # True when we can accept incoming packets
|
||||||
self.reset_done = False
|
self.reset_done = False
|
||||||
self.connections = {} # Connections, by connection handle
|
self.connections = {} # Connections, by connection handle
|
||||||
@@ -140,6 +161,9 @@ class Host(AbortableEventEmitter):
|
|||||||
# Connect to the source and sink if specified
|
# Connect to the source and sink if specified
|
||||||
if controller_source:
|
if controller_source:
|
||||||
controller_source.set_packet_sink(self)
|
controller_source.set_packet_sink(self)
|
||||||
|
self.hci_metadata = getattr(
|
||||||
|
controller_source, 'metadata', self.hci_metadata
|
||||||
|
)
|
||||||
if controller_sink:
|
if controller_sink:
|
||||||
self.set_packet_sink(controller_sink)
|
self.set_packet_sink(controller_sink)
|
||||||
|
|
||||||
@@ -169,7 +193,7 @@ class Host(AbortableEventEmitter):
|
|||||||
self.emit('flush')
|
self.emit('flush')
|
||||||
self.command_semaphore.release()
|
self.command_semaphore.release()
|
||||||
|
|
||||||
async def reset(self):
|
async def reset(self, driver_factory=drivers.get_driver_for_host):
|
||||||
if self.ready:
|
if self.ready:
|
||||||
self.ready = False
|
self.ready = False
|
||||||
await self.flush()
|
await self.flush()
|
||||||
@@ -177,6 +201,15 @@ class Host(AbortableEventEmitter):
|
|||||||
await self.send_command(HCI_Reset_Command(), check_result=True)
|
await self.send_command(HCI_Reset_Command(), check_result=True)
|
||||||
self.ready = True
|
self.ready = True
|
||||||
|
|
||||||
|
# Instantiate and init a driver for the host if needed.
|
||||||
|
# NOTE: we don't keep a reference to the driver here, because we don't
|
||||||
|
# currently have a need for the driver later on. But if the driver interface
|
||||||
|
# evolves, it may be required, then, to store a reference to the driver in
|
||||||
|
# an object property.
|
||||||
|
if driver_factory is not None:
|
||||||
|
if driver := await driver_factory(self):
|
||||||
|
await driver.init_controller()
|
||||||
|
|
||||||
response = await self.send_command(
|
response = await self.send_command(
|
||||||
HCI_Read_Local_Supported_Commands_Command(), check_result=True
|
HCI_Read_Local_Supported_Commands_Command(), check_result=True
|
||||||
)
|
)
|
||||||
@@ -281,7 +314,7 @@ class Host(AbortableEventEmitter):
|
|||||||
self.reset_done = True
|
self.reset_done = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def controller(self):
|
def controller(self) -> TransportSink:
|
||||||
return self.hci_sink
|
return self.hci_sink
|
||||||
|
|
||||||
@controller.setter
|
@controller.setter
|
||||||
@@ -290,14 +323,13 @@ class Host(AbortableEventEmitter):
|
|||||||
if controller:
|
if controller:
|
||||||
controller.set_packet_sink(self)
|
controller.set_packet_sink(self)
|
||||||
|
|
||||||
def set_packet_sink(self, sink):
|
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||||
self.hci_sink = sink
|
self.hci_sink = sink
|
||||||
|
|
||||||
def send_hci_packet(self, packet):
|
def send_hci_packet(self, packet: HCI_Packet) -> None:
|
||||||
if self.snooper:
|
if self.snooper:
|
||||||
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
|
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
|
||||||
|
self.hci_sink.on_packet(bytes(packet))
|
||||||
self.hci_sink.on_packet(packet.to_bytes())
|
|
||||||
|
|
||||||
async def send_command(self, command, check_result=False):
|
async def send_command(self, command, check_result=False):
|
||||||
logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: {command}')
|
logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: {command}')
|
||||||
@@ -334,7 +366,7 @@ class Host(AbortableEventEmitter):
|
|||||||
return response
|
return response
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f'{color("!!! Exception while sending HCI packet:", "red")} {error}'
|
f'{color("!!! Exception while sending command:", "red")} {error}'
|
||||||
)
|
)
|
||||||
raise error
|
raise error
|
||||||
finally:
|
finally:
|
||||||
@@ -342,14 +374,14 @@ class Host(AbortableEventEmitter):
|
|||||||
self.pending_response = None
|
self.pending_response = None
|
||||||
|
|
||||||
# Use this method to send a command from a task
|
# Use this method to send a command from a task
|
||||||
def send_command_sync(self, command):
|
def send_command_sync(self, command: HCI_Command) -> None:
|
||||||
async def send_command(command):
|
async def send_command(command: HCI_Command) -> None:
|
||||||
await self.send_command(command)
|
await self.send_command(command)
|
||||||
|
|
||||||
asyncio.create_task(send_command(command))
|
asyncio.create_task(send_command(command))
|
||||||
|
|
||||||
def send_l2cap_pdu(self, connection_handle, cid, pdu):
|
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
||||||
l2cap_pdu = L2CAP_PDU(cid, pdu).to_bytes()
|
l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
|
||||||
|
|
||||||
# Send the data to the controller via ACL packets
|
# Send the data to the controller via ACL packets
|
||||||
bytes_remaining = len(l2cap_pdu)
|
bytes_remaining = len(l2cap_pdu)
|
||||||
@@ -373,7 +405,7 @@ class Host(AbortableEventEmitter):
|
|||||||
offset += data_total_length
|
offset += data_total_length
|
||||||
bytes_remaining -= data_total_length
|
bytes_remaining -= data_total_length
|
||||||
|
|
||||||
def queue_acl_packet(self, acl_packet):
|
def queue_acl_packet(self, acl_packet: HCI_AclDataPacket) -> None:
|
||||||
self.acl_packet_queue.appendleft(acl_packet)
|
self.acl_packet_queue.appendleft(acl_packet)
|
||||||
self.check_acl_packet_queue()
|
self.check_acl_packet_queue()
|
||||||
|
|
||||||
@@ -383,7 +415,7 @@ class Host(AbortableEventEmitter):
|
|||||||
f'{len(self.acl_packet_queue)} in queue'
|
f'{len(self.acl_packet_queue)} in queue'
|
||||||
)
|
)
|
||||||
|
|
||||||
def check_acl_packet_queue(self):
|
def check_acl_packet_queue(self) -> None:
|
||||||
# Send all we can (TODO: support different LE/Classic limits)
|
# Send all we can (TODO: support different LE/Classic limits)
|
||||||
while (
|
while (
|
||||||
len(self.acl_packet_queue) > 0
|
len(self.acl_packet_queue) > 0
|
||||||
@@ -429,47 +461,53 @@ class Host(AbortableEventEmitter):
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Packet Sink protocol (packets coming from the controller via HCI)
|
# Packet Sink protocol (packets coming from the controller via HCI)
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
hci_packet = HCI_Packet.from_bytes(packet)
|
hci_packet = HCI_Packet.from_bytes(packet)
|
||||||
if self.ready or (
|
if self.ready or (
|
||||||
hci_packet.hci_packet_type == HCI_EVENT_PACKET
|
isinstance(hci_packet, HCI_Command_Complete_Event)
|
||||||
and hci_packet.event_code == HCI_COMMAND_COMPLETE_EVENT
|
|
||||||
and hci_packet.command_opcode == HCI_RESET_COMMAND
|
and hci_packet.command_opcode == HCI_RESET_COMMAND
|
||||||
):
|
):
|
||||||
self.on_hci_packet(hci_packet)
|
self.on_hci_packet(hci_packet)
|
||||||
else:
|
else:
|
||||||
logger.debug('reset not done, ignoring packet from controller')
|
logger.debug('reset not done, ignoring packet from controller')
|
||||||
|
|
||||||
def on_hci_packet(self, packet):
|
def on_transport_lost(self):
|
||||||
|
# Called by the source when the transport has been lost.
|
||||||
|
if self.pending_response:
|
||||||
|
self.pending_response.set_exception(TransportLostError('transport lost'))
|
||||||
|
|
||||||
|
self.emit('flush')
|
||||||
|
|
||||||
|
def on_hci_packet(self, packet: HCI_Packet) -> None:
|
||||||
logger.debug(f'{color("### CONTROLLER -> HOST", "green")}: {packet}')
|
logger.debug(f'{color("### CONTROLLER -> HOST", "green")}: {packet}')
|
||||||
|
|
||||||
if self.snooper:
|
if self.snooper:
|
||||||
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
|
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
|
||||||
|
|
||||||
# If the packet is a command, invoke the handler for this packet
|
# If the packet is a command, invoke the handler for this packet
|
||||||
if packet.hci_packet_type == HCI_COMMAND_PACKET:
|
if isinstance(packet, HCI_Command):
|
||||||
self.on_hci_command_packet(packet)
|
self.on_hci_command_packet(packet)
|
||||||
elif packet.hci_packet_type == HCI_EVENT_PACKET:
|
elif isinstance(packet, HCI_Event):
|
||||||
self.on_hci_event_packet(packet)
|
self.on_hci_event_packet(packet)
|
||||||
elif packet.hci_packet_type == HCI_ACL_DATA_PACKET:
|
elif isinstance(packet, HCI_AclDataPacket):
|
||||||
self.on_hci_acl_data_packet(packet)
|
self.on_hci_acl_data_packet(packet)
|
||||||
else:
|
else:
|
||||||
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
|
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
|
||||||
|
|
||||||
def on_hci_command_packet(self, command):
|
def on_hci_command_packet(self, command: HCI_Command) -> None:
|
||||||
logger.warning(f'!!! unexpected command packet: {command}')
|
logger.warning(f'!!! unexpected command packet: {command}')
|
||||||
|
|
||||||
def on_hci_event_packet(self, event):
|
def on_hci_event_packet(self, event: HCI_Event) -> None:
|
||||||
handler_name = f'on_{event.name.lower()}'
|
handler_name = f'on_{event.name.lower()}'
|
||||||
handler = getattr(self, handler_name, self.on_hci_event)
|
handler = getattr(self, handler_name, self.on_hci_event)
|
||||||
handler(event)
|
handler(event)
|
||||||
|
|
||||||
def on_hci_acl_data_packet(self, packet):
|
def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
|
||||||
# Look for the connection to which this data belongs
|
# Look for the connection to which this data belongs
|
||||||
if connection := self.connections.get(packet.connection_handle):
|
if connection := self.connections.get(packet.connection_handle):
|
||||||
connection.on_hci_acl_data_packet(packet)
|
connection.on_hci_acl_data_packet(packet)
|
||||||
|
|
||||||
def on_l2cap_pdu(self, connection, cid, pdu):
|
def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
|
||||||
self.emit('l2cap_pdu', connection.handle, cid, pdu)
|
self.emit('l2cap_pdu', connection.handle, cid, pdu)
|
||||||
|
|
||||||
def on_command_processed(self, event):
|
def on_command_processed(self, event):
|
||||||
@@ -807,6 +845,10 @@ class Host(AbortableEventEmitter):
|
|||||||
f'simple pairing complete for {event.bd_addr}: '
|
f'simple pairing complete for {event.bd_addr}: '
|
||||||
f'status={HCI_Constant.status_name(event.status)}'
|
f'status={HCI_Constant.status_name(event.status)}'
|
||||||
)
|
)
|
||||||
|
if event.status == HCI_SUCCESS:
|
||||||
|
self.emit('classic_pairing', event.bd_addr)
|
||||||
|
else:
|
||||||
|
self.emit('classic_pairing_failure', event.bd_addr, event.status)
|
||||||
|
|
||||||
def on_hci_pin_code_request_event(self, event):
|
def on_hci_pin_code_request_event(self, event):
|
||||||
self.emit('pin_code_request', event.bd_addr)
|
self.emit('pin_code_request', event.bd_addr)
|
||||||
@@ -887,7 +929,12 @@ class Host(AbortableEventEmitter):
|
|||||||
if event.status != HCI_SUCCESS:
|
if event.status != HCI_SUCCESS:
|
||||||
self.emit('remote_name_failure', event.bd_addr, event.status)
|
self.emit('remote_name_failure', event.bd_addr, event.status)
|
||||||
else:
|
else:
|
||||||
self.emit('remote_name', event.bd_addr, event.remote_name)
|
utf8_name = event.remote_name
|
||||||
|
terminator = utf8_name.find(0)
|
||||||
|
if terminator >= 0:
|
||||||
|
utf8_name = utf8_name[0:terminator]
|
||||||
|
|
||||||
|
self.emit('remote_name', event.bd_addr, utf8_name)
|
||||||
|
|
||||||
def on_hci_remote_host_supported_features_notification_event(self, event):
|
def on_hci_remote_host_supported_features_notification_event(self, event):
|
||||||
self.emit(
|
self.emit(
|
||||||
|
|||||||
118
bumble/keys.py
118
bumble/keys.py
@@ -190,10 +190,44 @@ class KeyStore:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class JsonKeyStore(KeyStore):
|
class JsonKeyStore(KeyStore):
|
||||||
|
"""
|
||||||
|
KeyStore implementation that is backed by a JSON file.
|
||||||
|
|
||||||
|
This implementation supports storing a hierarchy of key sets in a single file.
|
||||||
|
A key set is a representation of a PairingKeys object. Each key set is stored
|
||||||
|
in a map, with the address of paired peer as the key. Maps are themselves grouped
|
||||||
|
into namespaces, grouping pairing keys by controller addresses.
|
||||||
|
The JSON object model looks like:
|
||||||
|
{
|
||||||
|
"<namespace>": {
|
||||||
|
"peer-address": {
|
||||||
|
"address_type": <n>,
|
||||||
|
"irk" : {
|
||||||
|
"authenticated": <true/false>,
|
||||||
|
"value": "hex-encoded-key"
|
||||||
|
},
|
||||||
|
... other keys ...
|
||||||
|
},
|
||||||
|
... other peers ...
|
||||||
|
}
|
||||||
|
... other namespaces ...
|
||||||
|
}
|
||||||
|
|
||||||
|
A namespace is typically the BD_ADDR of a controller, since that is a convenient
|
||||||
|
unique identifier, but it may be something else.
|
||||||
|
A special namespace, called the "default" namespace, is used when instantiating this
|
||||||
|
class without a namespace. With the default namespace, reading from a file will
|
||||||
|
load an existing namespace if there is only one, which may be convenient for reading
|
||||||
|
from a file with a single key set and for which the namespace isn't known. If the
|
||||||
|
file does not include any existing key set, or if there are more than one and none
|
||||||
|
has the default name, a new one will be created with the name "__DEFAULT__".
|
||||||
|
"""
|
||||||
|
|
||||||
APP_NAME = 'Bumble'
|
APP_NAME = 'Bumble'
|
||||||
APP_AUTHOR = 'Google'
|
APP_AUTHOR = 'Google'
|
||||||
KEYS_DIR = 'Pairing'
|
KEYS_DIR = 'Pairing'
|
||||||
DEFAULT_NAMESPACE = '__DEFAULT__'
|
DEFAULT_NAMESPACE = '__DEFAULT__'
|
||||||
|
DEFAULT_BASE_NAME = "keys"
|
||||||
|
|
||||||
def __init__(self, namespace, filename=None):
|
def __init__(self, namespace, filename=None):
|
||||||
self.namespace = namespace if namespace is not None else self.DEFAULT_NAMESPACE
|
self.namespace = namespace if namespace is not None else self.DEFAULT_NAMESPACE
|
||||||
@@ -208,8 +242,9 @@ class JsonKeyStore(KeyStore):
|
|||||||
self.directory_name = os.path.join(
|
self.directory_name = os.path.join(
|
||||||
appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR), self.KEYS_DIR
|
appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR), self.KEYS_DIR
|
||||||
)
|
)
|
||||||
|
base_name = self.DEFAULT_BASE_NAME if namespace is None else self.namespace
|
||||||
json_filename = (
|
json_filename = (
|
||||||
f'{self.namespace}.json'.lower().replace(':', '-').replace('/p', '-p')
|
f'{base_name}.json'.lower().replace(':', '-').replace('/p', '-p')
|
||||||
)
|
)
|
||||||
self.filename = os.path.join(self.directory_name, json_filename)
|
self.filename = os.path.join(self.directory_name, json_filename)
|
||||||
else:
|
else:
|
||||||
@@ -219,11 +254,13 @@ class JsonKeyStore(KeyStore):
|
|||||||
logger.debug(f'JSON keystore: {self.filename}')
|
logger.debug(f'JSON keystore: {self.filename}')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_device(device: Device) -> Optional[JsonKeyStore]:
|
def from_device(device: Device, filename=None) -> Optional[JsonKeyStore]:
|
||||||
if not device.config.keystore:
|
if not filename:
|
||||||
return None
|
# Extract the filename from the config if there is one
|
||||||
|
if device.config.keystore is not None:
|
||||||
params = device.config.keystore.split(':', 1)[1:]
|
params = device.config.keystore.split(':', 1)[1:]
|
||||||
|
if params:
|
||||||
|
filename = params[0]
|
||||||
|
|
||||||
# Use a namespace based on the device address
|
# Use a namespace based on the device address
|
||||||
if device.public_address not in (Address.ANY, Address.ANY_RANDOM):
|
if device.public_address not in (Address.ANY, Address.ANY_RANDOM):
|
||||||
@@ -232,19 +269,31 @@ class JsonKeyStore(KeyStore):
|
|||||||
namespace = str(device.random_address)
|
namespace = str(device.random_address)
|
||||||
else:
|
else:
|
||||||
namespace = JsonKeyStore.DEFAULT_NAMESPACE
|
namespace = JsonKeyStore.DEFAULT_NAMESPACE
|
||||||
if params:
|
|
||||||
filename = params[0]
|
|
||||||
else:
|
|
||||||
filename = None
|
|
||||||
|
|
||||||
return JsonKeyStore(namespace, filename)
|
return JsonKeyStore(namespace, filename)
|
||||||
|
|
||||||
async def load(self):
|
async def load(self):
|
||||||
|
# Try to open the file, without failing. If the file does not exist, it
|
||||||
|
# will be created upon saving.
|
||||||
try:
|
try:
|
||||||
with open(self.filename, 'r', encoding='utf-8') as json_file:
|
with open(self.filename, 'r', encoding='utf-8') as json_file:
|
||||||
return json.load(json_file)
|
db = json.load(json_file)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return {}
|
db = {}
|
||||||
|
|
||||||
|
# First, look for a namespace match
|
||||||
|
if self.namespace in db:
|
||||||
|
return (db, db[self.namespace])
|
||||||
|
|
||||||
|
# Then, if the namespace is the default namespace, and there's
|
||||||
|
# only one entry in the db, use that
|
||||||
|
if self.namespace == self.DEFAULT_NAMESPACE and len(db) == 1:
|
||||||
|
return next(iter(db.items()))
|
||||||
|
|
||||||
|
# Finally, just create an empty key map for the namespace
|
||||||
|
key_map = {}
|
||||||
|
db[self.namespace] = key_map
|
||||||
|
return (db, key_map)
|
||||||
|
|
||||||
async def save(self, db):
|
async def save(self, db):
|
||||||
# Create the directory if it doesn't exist
|
# Create the directory if it doesn't exist
|
||||||
@@ -260,53 +309,30 @@ class JsonKeyStore(KeyStore):
|
|||||||
os.replace(temp_filename, self.filename)
|
os.replace(temp_filename, self.filename)
|
||||||
|
|
||||||
async def delete(self, name: str) -> None:
|
async def delete(self, name: str) -> None:
|
||||||
db = await self.load()
|
db, key_map = await self.load()
|
||||||
|
del key_map[name]
|
||||||
namespace = db.get(self.namespace)
|
|
||||||
if namespace is None:
|
|
||||||
raise KeyError(name)
|
|
||||||
|
|
||||||
del namespace[name]
|
|
||||||
await self.save(db)
|
await self.save(db)
|
||||||
|
|
||||||
async def update(self, name, keys):
|
async def update(self, name, keys):
|
||||||
db = await self.load()
|
db, key_map = await self.load()
|
||||||
|
key_map.setdefault(name, {}).update(keys.to_dict())
|
||||||
namespace = db.setdefault(self.namespace, {})
|
|
||||||
namespace.setdefault(name, {}).update(keys.to_dict())
|
|
||||||
|
|
||||||
await self.save(db)
|
await self.save(db)
|
||||||
|
|
||||||
async def get_all(self):
|
async def get_all(self):
|
||||||
db = await self.load()
|
_, key_map = await self.load()
|
||||||
|
return [(name, PairingKeys.from_dict(keys)) for (name, keys) in key_map.items()]
|
||||||
namespace = db.get(self.namespace)
|
|
||||||
if namespace is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
return [
|
|
||||||
(name, PairingKeys.from_dict(keys)) for (name, keys) in namespace.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
async def delete_all(self):
|
async def delete_all(self):
|
||||||
db = await self.load()
|
db, key_map = await self.load()
|
||||||
|
key_map.clear()
|
||||||
db.pop(self.namespace, None)
|
|
||||||
|
|
||||||
await self.save(db)
|
await self.save(db)
|
||||||
|
|
||||||
async def get(self, name: str) -> Optional[PairingKeys]:
|
async def get(self, name: str) -> Optional[PairingKeys]:
|
||||||
db = await self.load()
|
_, key_map = await self.load()
|
||||||
|
if name not in key_map:
|
||||||
namespace = db.get(self.namespace)
|
|
||||||
if namespace is None:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
keys = namespace.get(name)
|
return PairingKeys.from_dict(key_map[name])
|
||||||
if keys is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return PairingKeys.from_dict(keys)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
385
bumble/l2cap.py
385
bumble/l2cap.py
@@ -22,7 +22,20 @@ import struct
|
|||||||
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
from typing import Dict, Type
|
from typing import (
|
||||||
|
Dict,
|
||||||
|
Type,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Tuple,
|
||||||
|
Callable,
|
||||||
|
Any,
|
||||||
|
Union,
|
||||||
|
Deque,
|
||||||
|
Iterable,
|
||||||
|
SupportsBytes,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
from .colors import color
|
from .colors import color
|
||||||
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
|
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
|
||||||
@@ -33,6 +46,10 @@ from .hci import (
|
|||||||
name_or_number,
|
name_or_number,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble.device import Connection
|
||||||
|
from bumble.host import Host
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -155,7 +172,7 @@ class L2CAP_PDU:
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(data):
|
def from_bytes(data: bytes) -> L2CAP_PDU:
|
||||||
# Sanity check
|
# Sanity check
|
||||||
if len(data) < 4:
|
if len(data) < 4:
|
||||||
raise ValueError('not enough data for L2CAP header')
|
raise ValueError('not enough data for L2CAP header')
|
||||||
@@ -165,18 +182,18 @@ class L2CAP_PDU:
|
|||||||
|
|
||||||
return L2CAP_PDU(l2cap_pdu_cid, l2cap_pdu_payload)
|
return L2CAP_PDU(l2cap_pdu_cid, l2cap_pdu_payload)
|
||||||
|
|
||||||
def to_bytes(self):
|
def to_bytes(self) -> bytes:
|
||||||
header = struct.pack('<HH', len(self.payload), self.cid)
|
header = struct.pack('<HH', len(self.payload), self.cid)
|
||||||
return header + self.payload
|
return header + self.payload
|
||||||
|
|
||||||
def __init__(self, cid, payload):
|
def __init__(self, cid: int, payload: bytes) -> None:
|
||||||
self.cid = cid
|
self.cid = cid
|
||||||
self.payload = payload
|
self.payload = payload
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self) -> bytes:
|
||||||
return self.to_bytes()
|
return self.to_bytes()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f'{color("L2CAP", "green")} [CID={self.cid}]: {self.payload.hex()}'
|
return f'{color("L2CAP", "green")} [CID={self.cid}]: {self.payload.hex()}'
|
||||||
|
|
||||||
|
|
||||||
@@ -188,10 +205,10 @@ class L2CAP_Control_Frame:
|
|||||||
|
|
||||||
classes: Dict[int, Type[L2CAP_Control_Frame]] = {}
|
classes: Dict[int, Type[L2CAP_Control_Frame]] = {}
|
||||||
code = 0
|
code = 0
|
||||||
name = None
|
name: str
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(pdu):
|
def from_bytes(pdu: bytes) -> L2CAP_Control_Frame:
|
||||||
code = pdu[0]
|
code = pdu[0]
|
||||||
|
|
||||||
cls = L2CAP_Control_Frame.classes.get(code)
|
cls = L2CAP_Control_Frame.classes.get(code)
|
||||||
@@ -216,11 +233,11 @@ class L2CAP_Control_Frame:
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def code_name(code):
|
def code_name(code: int) -> str:
|
||||||
return name_or_number(L2CAP_CONTROL_FRAME_NAMES, code)
|
return name_or_number(L2CAP_CONTROL_FRAME_NAMES, code)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def decode_configuration_options(data):
|
def decode_configuration_options(data: bytes) -> List[Tuple[int, bytes]]:
|
||||||
options = []
|
options = []
|
||||||
while len(data) >= 2:
|
while len(data) >= 2:
|
||||||
value_type = data[0]
|
value_type = data[0]
|
||||||
@@ -232,7 +249,7 @@ class L2CAP_Control_Frame:
|
|||||||
return options
|
return options
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encode_configuration_options(options):
|
def encode_configuration_options(options: List[Tuple[int, bytes]]) -> bytes:
|
||||||
return b''.join(
|
return b''.join(
|
||||||
[bytes([option[0], len(option[1])]) + option[1] for option in options]
|
[bytes([option[0], len(option[1])]) + option[1] for option in options]
|
||||||
)
|
)
|
||||||
@@ -256,29 +273,30 @@ class L2CAP_Control_Frame:
|
|||||||
|
|
||||||
return inner
|
return inner
|
||||||
|
|
||||||
def __init__(self, pdu=None, **kwargs):
|
def __init__(self, pdu=None, **kwargs) -> None:
|
||||||
self.identifier = kwargs.get('identifier', 0)
|
self.identifier = kwargs.get('identifier', 0)
|
||||||
if hasattr(self, 'fields') and kwargs:
|
if hasattr(self, 'fields'):
|
||||||
HCI_Object.init_from_fields(self, self.fields, kwargs)
|
if kwargs:
|
||||||
if pdu is None:
|
HCI_Object.init_from_fields(self, self.fields, kwargs)
|
||||||
data = HCI_Object.dict_to_bytes(kwargs, self.fields)
|
if pdu is None:
|
||||||
pdu = (
|
data = HCI_Object.dict_to_bytes(kwargs, self.fields)
|
||||||
bytes([self.code, self.identifier])
|
pdu = (
|
||||||
+ struct.pack('<H', len(data))
|
bytes([self.code, self.identifier])
|
||||||
+ data
|
+ struct.pack('<H', len(data))
|
||||||
)
|
+ data
|
||||||
|
)
|
||||||
self.pdu = pdu
|
self.pdu = pdu
|
||||||
|
|
||||||
def init_from_bytes(self, pdu, offset):
|
def init_from_bytes(self, pdu, offset):
|
||||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
||||||
|
|
||||||
def to_bytes(self):
|
def to_bytes(self) -> bytes:
|
||||||
return self.pdu
|
return self.pdu
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self) -> bytes:
|
||||||
return self.to_bytes()
|
return self.to_bytes()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
result = f'{color(self.name, "yellow")} [ID={self.identifier}]'
|
result = f'{color(self.name, "yellow")} [ID={self.identifier}]'
|
||||||
if fields := getattr(self, 'fields', None):
|
if fields := getattr(self, 'fields', None):
|
||||||
result += ':\n' + HCI_Object.format_fields(self.__dict__, fields, ' ')
|
result += ':\n' + HCI_Object.format_fields(self.__dict__, fields, ' ')
|
||||||
@@ -315,7 +333,7 @@ class L2CAP_Command_Reject(L2CAP_Control_Frame):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def reason_name(reason):
|
def reason_name(reason: int) -> str:
|
||||||
return name_or_number(L2CAP_Command_Reject.REASON_NAMES, reason)
|
return name_or_number(L2CAP_Command_Reject.REASON_NAMES, reason)
|
||||||
|
|
||||||
|
|
||||||
@@ -343,7 +361,7 @@ class L2CAP_Connection_Request(L2CAP_Control_Frame):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_psm(data, offset=0):
|
def parse_psm(data: bytes, offset: int = 0) -> Tuple[int, int]:
|
||||||
psm_length = 2
|
psm_length = 2
|
||||||
psm = data[offset] | data[offset + 1] << 8
|
psm = data[offset] | data[offset + 1] << 8
|
||||||
|
|
||||||
@@ -355,7 +373,7 @@ class L2CAP_Connection_Request(L2CAP_Control_Frame):
|
|||||||
return offset + psm_length, psm
|
return offset + psm_length, psm
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def serialize_psm(psm):
|
def serialize_psm(psm: int) -> bytes:
|
||||||
serialized = struct.pack('<H', psm & 0xFFFF)
|
serialized = struct.pack('<H', psm & 0xFFFF)
|
||||||
psm >>= 16
|
psm >>= 16
|
||||||
while psm:
|
while psm:
|
||||||
@@ -405,7 +423,7 @@ class L2CAP_Connection_Response(L2CAP_Control_Frame):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def result_name(result):
|
def result_name(result: int) -> str:
|
||||||
return name_or_number(L2CAP_Connection_Response.RESULT_NAMES, result)
|
return name_or_number(L2CAP_Connection_Response.RESULT_NAMES, result)
|
||||||
|
|
||||||
|
|
||||||
@@ -452,7 +470,7 @@ class L2CAP_Configure_Response(L2CAP_Control_Frame):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def result_name(result):
|
def result_name(result: int) -> str:
|
||||||
return name_or_number(L2CAP_Configure_Response.RESULT_NAMES, result)
|
return name_or_number(L2CAP_Configure_Response.RESULT_NAMES, result)
|
||||||
|
|
||||||
|
|
||||||
@@ -529,7 +547,7 @@ class L2CAP_Information_Request(L2CAP_Control_Frame):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def info_type_name(info_type):
|
def info_type_name(info_type: int) -> str:
|
||||||
return name_or_number(L2CAP_Information_Request.INFO_TYPE_NAMES, info_type)
|
return name_or_number(L2CAP_Information_Request.INFO_TYPE_NAMES, info_type)
|
||||||
|
|
||||||
|
|
||||||
@@ -556,7 +574,7 @@ class L2CAP_Information_Response(L2CAP_Control_Frame):
|
|||||||
RESULT_NAMES = {SUCCESS: 'SUCCESS', NOT_SUPPORTED: 'NOT_SUPPORTED'}
|
RESULT_NAMES = {SUCCESS: 'SUCCESS', NOT_SUPPORTED: 'NOT_SUPPORTED'}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def result_name(result):
|
def result_name(result: int) -> str:
|
||||||
return name_or_number(L2CAP_Information_Response.RESULT_NAMES, result)
|
return name_or_number(L2CAP_Information_Response.RESULT_NAMES, result)
|
||||||
|
|
||||||
|
|
||||||
@@ -588,6 +606,8 @@ class L2CAP_LE_Credit_Based_Connection_Request(L2CAP_Control_Frame):
|
|||||||
(CODE 0x14)
|
(CODE 0x14)
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
source_cid: int
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@L2CAP_Control_Frame.subclass(
|
@L2CAP_Control_Frame.subclass(
|
||||||
@@ -640,7 +660,7 @@ class L2CAP_LE_Credit_Based_Connection_Response(L2CAP_Control_Frame):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def result_name(result):
|
def result_name(result: int) -> str:
|
||||||
return name_or_number(
|
return name_or_number(
|
||||||
L2CAP_LE_Credit_Based_Connection_Response.RESULT_NAMES, result
|
L2CAP_LE_Credit_Based_Connection_Response.RESULT_NAMES, result
|
||||||
)
|
)
|
||||||
@@ -701,7 +721,22 @@ class Channel(EventEmitter):
|
|||||||
WAIT_CONTROL_IND: 'WAIT_CONTROL_IND',
|
WAIT_CONTROL_IND: 'WAIT_CONTROL_IND',
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, manager, connection, signaling_cid, psm, source_cid, mtu):
|
connection_result: Optional[asyncio.Future[None]]
|
||||||
|
disconnection_result: Optional[asyncio.Future[None]]
|
||||||
|
response: Optional[asyncio.Future[bytes]]
|
||||||
|
sink: Optional[Callable[[bytes], Any]]
|
||||||
|
state: int
|
||||||
|
connection: Connection
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
manager: ChannelManager,
|
||||||
|
connection: Connection,
|
||||||
|
signaling_cid: int,
|
||||||
|
psm: int,
|
||||||
|
source_cid: int,
|
||||||
|
mtu: int,
|
||||||
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
@@ -716,19 +751,19 @@ class Channel(EventEmitter):
|
|||||||
self.disconnection_result = None
|
self.disconnection_result = None
|
||||||
self.sink = None
|
self.sink = None
|
||||||
|
|
||||||
def change_state(self, new_state):
|
def change_state(self, new_state: int) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'{self} state change -> {color(Channel.STATE_NAMES[new_state], "cyan")}'
|
f'{self} state change -> {color(Channel.STATE_NAMES[new_state], "cyan")}'
|
||||||
)
|
)
|
||||||
self.state = new_state
|
self.state = new_state
|
||||||
|
|
||||||
def send_pdu(self, pdu):
|
def send_pdu(self, pdu: SupportsBytes | bytes) -> None:
|
||||||
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
|
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
|
||||||
|
|
||||||
def send_control_frame(self, frame):
|
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
|
||||||
self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
|
self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
|
||||||
|
|
||||||
async def send_request(self, request):
|
async def send_request(self, request: SupportsBytes) -> bytes:
|
||||||
# Check that there isn't already a request pending
|
# Check that there isn't already a request pending
|
||||||
if self.response:
|
if self.response:
|
||||||
raise InvalidStateError('request already pending')
|
raise InvalidStateError('request already pending')
|
||||||
@@ -739,7 +774,7 @@ class Channel(EventEmitter):
|
|||||||
self.send_pdu(request)
|
self.send_pdu(request)
|
||||||
return await self.response
|
return await self.response
|
||||||
|
|
||||||
def on_pdu(self, pdu):
|
def on_pdu(self, pdu: bytes) -> None:
|
||||||
if self.response:
|
if self.response:
|
||||||
self.response.set_result(pdu)
|
self.response.set_result(pdu)
|
||||||
self.response = None
|
self.response = None
|
||||||
@@ -751,7 +786,7 @@ class Channel(EventEmitter):
|
|||||||
color('received pdu without a pending request or sink', 'red')
|
color('received pdu without a pending request or sink', 'red')
|
||||||
)
|
)
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self) -> None:
|
||||||
if self.state != Channel.CLOSED:
|
if self.state != Channel.CLOSED:
|
||||||
raise InvalidStateError('invalid state')
|
raise InvalidStateError('invalid state')
|
||||||
|
|
||||||
@@ -778,7 +813,7 @@ class Channel(EventEmitter):
|
|||||||
finally:
|
finally:
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
|
|
||||||
async def disconnect(self):
|
async def disconnect(self) -> None:
|
||||||
if self.state != Channel.OPEN:
|
if self.state != Channel.OPEN:
|
||||||
raise InvalidStateError('invalid state')
|
raise InvalidStateError('invalid state')
|
||||||
|
|
||||||
@@ -796,12 +831,12 @@ class Channel(EventEmitter):
|
|||||||
self.disconnection_result = asyncio.get_running_loop().create_future()
|
self.disconnection_result = asyncio.get_running_loop().create_future()
|
||||||
return await self.disconnection_result
|
return await self.disconnection_result
|
||||||
|
|
||||||
def abort(self):
|
def abort(self) -> None:
|
||||||
if self.state == self.OPEN:
|
if self.state == self.OPEN:
|
||||||
self.change_state(self.CLOSED)
|
self.change_state(self.CLOSED)
|
||||||
self.emit('close')
|
self.emit('close')
|
||||||
|
|
||||||
def send_configure_request(self):
|
def send_configure_request(self) -> None:
|
||||||
options = L2CAP_Control_Frame.encode_configuration_options(
|
options = L2CAP_Control_Frame.encode_configuration_options(
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
@@ -819,7 +854,7 @@ class Channel(EventEmitter):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_connection_request(self, request):
|
def on_connection_request(self, request) -> None:
|
||||||
self.destination_cid = request.source_cid
|
self.destination_cid = request.source_cid
|
||||||
self.change_state(Channel.WAIT_CONNECT)
|
self.change_state(Channel.WAIT_CONNECT)
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
@@ -858,7 +893,7 @@ class Channel(EventEmitter):
|
|||||||
)
|
)
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
|
|
||||||
def on_configure_request(self, request):
|
def on_configure_request(self, request) -> None:
|
||||||
if self.state not in (
|
if self.state not in (
|
||||||
Channel.WAIT_CONFIG,
|
Channel.WAIT_CONFIG,
|
||||||
Channel.WAIT_CONFIG_REQ,
|
Channel.WAIT_CONFIG_REQ,
|
||||||
@@ -896,7 +931,7 @@ class Channel(EventEmitter):
|
|||||||
elif self.state == Channel.WAIT_CONFIG_REQ_RSP:
|
elif self.state == Channel.WAIT_CONFIG_REQ_RSP:
|
||||||
self.change_state(Channel.WAIT_CONFIG_RSP)
|
self.change_state(Channel.WAIT_CONFIG_RSP)
|
||||||
|
|
||||||
def on_configure_response(self, response):
|
def on_configure_response(self, response) -> None:
|
||||||
if response.result == L2CAP_Configure_Response.SUCCESS:
|
if response.result == L2CAP_Configure_Response.SUCCESS:
|
||||||
if self.state == Channel.WAIT_CONFIG_REQ_RSP:
|
if self.state == Channel.WAIT_CONFIG_REQ_RSP:
|
||||||
self.change_state(Channel.WAIT_CONFIG_REQ)
|
self.change_state(Channel.WAIT_CONFIG_REQ)
|
||||||
@@ -930,7 +965,7 @@ class Channel(EventEmitter):
|
|||||||
)
|
)
|
||||||
# TODO: decide how to fail gracefully
|
# TODO: decide how to fail gracefully
|
||||||
|
|
||||||
def on_disconnection_request(self, request):
|
def on_disconnection_request(self, request) -> None:
|
||||||
if self.state in (Channel.OPEN, Channel.WAIT_DISCONNECT):
|
if self.state in (Channel.OPEN, Channel.WAIT_DISCONNECT):
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
L2CAP_Disconnection_Response(
|
L2CAP_Disconnection_Response(
|
||||||
@@ -945,7 +980,7 @@ class Channel(EventEmitter):
|
|||||||
else:
|
else:
|
||||||
logger.warning(color('invalid state', 'red'))
|
logger.warning(color('invalid state', 'red'))
|
||||||
|
|
||||||
def on_disconnection_response(self, response):
|
def on_disconnection_response(self, response) -> None:
|
||||||
if self.state != Channel.WAIT_DISCONNECT:
|
if self.state != Channel.WAIT_DISCONNECT:
|
||||||
logger.warning(color('invalid state', 'red'))
|
logger.warning(color('invalid state', 'red'))
|
||||||
return
|
return
|
||||||
@@ -964,7 +999,7 @@ class Channel(EventEmitter):
|
|||||||
self.emit('close')
|
self.emit('close')
|
||||||
self.manager.on_channel_closed(self)
|
self.manager.on_channel_closed(self)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'Channel({self.source_cid}->{self.destination_cid}, '
|
f'Channel({self.source_cid}->{self.destination_cid}, '
|
||||||
f'PSM={self.psm}, '
|
f'PSM={self.psm}, '
|
||||||
@@ -995,25 +1030,32 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
CONNECTION_ERROR: 'CONNECTION_ERROR',
|
CONNECTION_ERROR: 'CONNECTION_ERROR',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
out_queue: Deque[bytes]
|
||||||
|
connection_result: Optional[asyncio.Future[LeConnectionOrientedChannel]]
|
||||||
|
disconnection_result: Optional[asyncio.Future[None]]
|
||||||
|
out_sdu: Optional[bytes]
|
||||||
|
state: int
|
||||||
|
connection: Connection
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def state_name(state):
|
def state_name(state: int) -> str:
|
||||||
return name_or_number(LeConnectionOrientedChannel.STATE_NAMES, state)
|
return name_or_number(LeConnectionOrientedChannel.STATE_NAMES, state)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
manager,
|
manager: ChannelManager,
|
||||||
connection,
|
connection: Connection,
|
||||||
le_psm,
|
le_psm: int,
|
||||||
source_cid,
|
source_cid: int,
|
||||||
destination_cid,
|
destination_cid: int,
|
||||||
mtu,
|
mtu: int,
|
||||||
mps,
|
mps: int,
|
||||||
credits, # pylint: disable=redefined-builtin
|
credits: int, # pylint: disable=redefined-builtin
|
||||||
peer_mtu,
|
peer_mtu: int,
|
||||||
peer_mps,
|
peer_mps: int,
|
||||||
peer_credits,
|
peer_credits: int,
|
||||||
connected,
|
connected: bool,
|
||||||
):
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
@@ -1045,7 +1087,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
else:
|
else:
|
||||||
self.state = LeConnectionOrientedChannel.INIT
|
self.state = LeConnectionOrientedChannel.INIT
|
||||||
|
|
||||||
def change_state(self, new_state):
|
def change_state(self, new_state: int) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
|
f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
|
||||||
)
|
)
|
||||||
@@ -1056,13 +1098,13 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
elif new_state == self.DISCONNECTED:
|
elif new_state == self.DISCONNECTED:
|
||||||
self.emit('close')
|
self.emit('close')
|
||||||
|
|
||||||
def send_pdu(self, pdu):
|
def send_pdu(self, pdu: SupportsBytes | bytes) -> None:
|
||||||
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
|
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
|
||||||
|
|
||||||
def send_control_frame(self, frame):
|
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
|
||||||
self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
|
self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self) -> LeConnectionOrientedChannel:
|
||||||
# Check that we're in the right state
|
# Check that we're in the right state
|
||||||
if self.state != self.INIT:
|
if self.state != self.INIT:
|
||||||
raise InvalidStateError('not in a connectable state')
|
raise InvalidStateError('not in a connectable state')
|
||||||
@@ -1090,7 +1132,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
# Wait for the connection to succeed or fail
|
# Wait for the connection to succeed or fail
|
||||||
return await self.connection_result
|
return await self.connection_result
|
||||||
|
|
||||||
async def disconnect(self):
|
async def disconnect(self) -> None:
|
||||||
# Check that we're connected
|
# Check that we're connected
|
||||||
if self.state != self.CONNECTED:
|
if self.state != self.CONNECTED:
|
||||||
raise InvalidStateError('not connected')
|
raise InvalidStateError('not connected')
|
||||||
@@ -1110,11 +1152,11 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
self.disconnection_result = asyncio.get_running_loop().create_future()
|
self.disconnection_result = asyncio.get_running_loop().create_future()
|
||||||
return await self.disconnection_result
|
return await self.disconnection_result
|
||||||
|
|
||||||
def abort(self):
|
def abort(self) -> None:
|
||||||
if self.state == self.CONNECTED:
|
if self.state == self.CONNECTED:
|
||||||
self.change_state(self.DISCONNECTED)
|
self.change_state(self.DISCONNECTED)
|
||||||
|
|
||||||
def on_pdu(self, pdu):
|
def on_pdu(self, pdu: bytes) -> None:
|
||||||
if self.sink is None:
|
if self.sink is None:
|
||||||
logger.warning('received pdu without a sink')
|
logger.warning('received pdu without a sink')
|
||||||
return
|
return
|
||||||
@@ -1180,7 +1222,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
self.in_sdu = None
|
self.in_sdu = None
|
||||||
self.in_sdu_length = 0
|
self.in_sdu_length = 0
|
||||||
|
|
||||||
def on_connection_response(self, response):
|
def on_connection_response(self, response) -> None:
|
||||||
# Look for a matching pending response result
|
# Look for a matching pending response result
|
||||||
if self.connection_result is None:
|
if self.connection_result is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -1214,14 +1256,14 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
# Cleanup
|
# Cleanup
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
|
|
||||||
def on_credits(self, credits): # pylint: disable=redefined-builtin
|
def on_credits(self, credits: int) -> None: # pylint: disable=redefined-builtin
|
||||||
self.credits += credits
|
self.credits += credits
|
||||||
logger.debug(f'received {credits} credits, total = {self.credits}')
|
logger.debug(f'received {credits} credits, total = {self.credits}')
|
||||||
|
|
||||||
# Try to send more data if we have any queued up
|
# Try to send more data if we have any queued up
|
||||||
self.process_output()
|
self.process_output()
|
||||||
|
|
||||||
def on_disconnection_request(self, request):
|
def on_disconnection_request(self, request) -> None:
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
L2CAP_Disconnection_Response(
|
L2CAP_Disconnection_Response(
|
||||||
identifier=request.identifier,
|
identifier=request.identifier,
|
||||||
@@ -1232,7 +1274,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
self.change_state(self.DISCONNECTED)
|
self.change_state(self.DISCONNECTED)
|
||||||
self.flush_output()
|
self.flush_output()
|
||||||
|
|
||||||
def on_disconnection_response(self, response):
|
def on_disconnection_response(self, response) -> None:
|
||||||
if self.state != self.DISCONNECTING:
|
if self.state != self.DISCONNECTING:
|
||||||
logger.warning(color('invalid state', 'red'))
|
logger.warning(color('invalid state', 'red'))
|
||||||
return
|
return
|
||||||
@@ -1249,11 +1291,11 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
self.disconnection_result.set_result(None)
|
self.disconnection_result.set_result(None)
|
||||||
self.disconnection_result = None
|
self.disconnection_result = None
|
||||||
|
|
||||||
def flush_output(self):
|
def flush_output(self) -> None:
|
||||||
self.out_queue.clear()
|
self.out_queue.clear()
|
||||||
self.out_sdu = None
|
self.out_sdu = None
|
||||||
|
|
||||||
def process_output(self):
|
def process_output(self) -> None:
|
||||||
while self.credits > 0:
|
while self.credits > 0:
|
||||||
if self.out_sdu is not None:
|
if self.out_sdu is not None:
|
||||||
# Finish the current SDU
|
# Finish the current SDU
|
||||||
@@ -1296,7 +1338,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
self.drained.set()
|
self.drained.set()
|
||||||
return
|
return
|
||||||
|
|
||||||
def write(self, data):
|
def write(self, data: bytes) -> None:
|
||||||
if self.state != self.CONNECTED:
|
if self.state != self.CONNECTED:
|
||||||
logger.warning('not connected, dropping data')
|
logger.warning('not connected, dropping data')
|
||||||
return
|
return
|
||||||
@@ -1311,18 +1353,18 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
# Send what we can
|
# Send what we can
|
||||||
self.process_output()
|
self.process_output()
|
||||||
|
|
||||||
async def drain(self):
|
async def drain(self) -> None:
|
||||||
await self.drained.wait()
|
await self.drained.wait()
|
||||||
|
|
||||||
def pause_reading(self):
|
def pause_reading(self) -> None:
|
||||||
# TODO: not implemented yet
|
# TODO: not implemented yet
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def resume_reading(self):
|
def resume_reading(self) -> None:
|
||||||
# TODO: not implemented yet
|
# TODO: not implemented yet
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'CoC({self.source_cid}->{self.destination_cid}, '
|
f'CoC({self.source_cid}->{self.destination_cid}, '
|
||||||
f'State={self.state_name(self.state)}, '
|
f'State={self.state_name(self.state)}, '
|
||||||
@@ -1335,9 +1377,23 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class ChannelManager:
|
class ChannelManager:
|
||||||
|
identifiers: Dict[int, int]
|
||||||
|
channels: Dict[int, Dict[int, Union[Channel, LeConnectionOrientedChannel]]]
|
||||||
|
servers: Dict[int, Callable[[Channel], Any]]
|
||||||
|
le_coc_channels: Dict[int, Dict[int, LeConnectionOrientedChannel]]
|
||||||
|
le_coc_servers: Dict[
|
||||||
|
int, Tuple[Callable[[LeConnectionOrientedChannel], Any], int, int, int]
|
||||||
|
]
|
||||||
|
le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request]
|
||||||
|
fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]]
|
||||||
|
_host: Optional[Host]
|
||||||
|
connection_parameters_update_response: Optional[asyncio.Future[int]]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, extended_features=(), connectionless_mtu=L2CAP_DEFAULT_CONNECTIONLESS_MTU
|
self,
|
||||||
):
|
extended_features: Iterable[int] = (),
|
||||||
|
connectionless_mtu: int = L2CAP_DEFAULT_CONNECTIONLESS_MTU,
|
||||||
|
) -> None:
|
||||||
self._host = None
|
self._host = None
|
||||||
self.identifiers = {} # Incrementing identifier values by connection
|
self.identifiers = {} # Incrementing identifier values by connection
|
||||||
self.channels = {} # All channels, mapped by connection and source cid
|
self.channels = {} # All channels, mapped by connection and source cid
|
||||||
@@ -1353,33 +1409,35 @@ class ChannelManager:
|
|||||||
self.le_coc_requests = {} # LE CoC connection requests, by identifier
|
self.le_coc_requests = {} # LE CoC connection requests, by identifier
|
||||||
self.extended_features = extended_features
|
self.extended_features = extended_features
|
||||||
self.connectionless_mtu = connectionless_mtu
|
self.connectionless_mtu = connectionless_mtu
|
||||||
|
self.connection_parameters_update_response = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def host(self):
|
def host(self) -> Host:
|
||||||
|
assert self._host
|
||||||
return self._host
|
return self._host
|
||||||
|
|
||||||
@host.setter
|
@host.setter
|
||||||
def host(self, host):
|
def host(self, host: Host) -> None:
|
||||||
if self._host is not None:
|
if self._host is not None:
|
||||||
self._host.remove_listener('disconnection', self.on_disconnection)
|
self._host.remove_listener('disconnection', self.on_disconnection)
|
||||||
self._host = host
|
self._host = host
|
||||||
if host is not None:
|
if host is not None:
|
||||||
host.on('disconnection', self.on_disconnection)
|
host.on('disconnection', self.on_disconnection)
|
||||||
|
|
||||||
def find_channel(self, connection_handle, cid):
|
def find_channel(self, connection_handle: int, cid: int):
|
||||||
if connection_channels := self.channels.get(connection_handle):
|
if connection_channels := self.channels.get(connection_handle):
|
||||||
return connection_channels.get(cid)
|
return connection_channels.get(cid)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def find_le_coc_channel(self, connection_handle, cid):
|
def find_le_coc_channel(self, connection_handle: int, cid: int):
|
||||||
if connection_channels := self.le_coc_channels.get(connection_handle):
|
if connection_channels := self.le_coc_channels.get(connection_handle):
|
||||||
return connection_channels.get(cid)
|
return connection_channels.get(cid)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_free_br_edr_cid(channels):
|
def find_free_br_edr_cid(channels: Iterable[int]) -> int:
|
||||||
# Pick the smallest valid CID that's not already in the list
|
# Pick the smallest valid CID that's not already in the list
|
||||||
# (not necessarily the most efficient algorithm, but the list of CID is
|
# (not necessarily the most efficient algorithm, but the list of CID is
|
||||||
# very small in practice)
|
# very small in practice)
|
||||||
@@ -1392,7 +1450,7 @@ class ChannelManager:
|
|||||||
raise RuntimeError('no free CID available')
|
raise RuntimeError('no free CID available')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_free_le_cid(channels):
|
def find_free_le_cid(channels: Iterable[int]) -> int:
|
||||||
# Pick the smallest valid CID that's not already in the list
|
# Pick the smallest valid CID that's not already in the list
|
||||||
# (not necessarily the most efficient algorithm, but the list of CID is
|
# (not necessarily the most efficient algorithm, but the list of CID is
|
||||||
# very small in practice)
|
# very small in practice)
|
||||||
@@ -1405,7 +1463,7 @@ class ChannelManager:
|
|||||||
raise RuntimeError('no free CID')
|
raise RuntimeError('no free CID')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_le_coc_parameters(max_credits, mtu, mps):
|
def check_le_coc_parameters(max_credits: int, mtu: int, mps: int) -> None:
|
||||||
if (
|
if (
|
||||||
max_credits < 1
|
max_credits < 1
|
||||||
or max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
|
or max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
|
||||||
@@ -1419,19 +1477,21 @@ class ChannelManager:
|
|||||||
):
|
):
|
||||||
raise ValueError('MPS out of range')
|
raise ValueError('MPS out of range')
|
||||||
|
|
||||||
def next_identifier(self, connection):
|
def next_identifier(self, connection: Connection) -> int:
|
||||||
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
|
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
|
||||||
self.identifiers[connection.handle] = identifier
|
self.identifiers[connection.handle] = identifier
|
||||||
return identifier
|
return identifier
|
||||||
|
|
||||||
def register_fixed_channel(self, cid, handler):
|
def register_fixed_channel(
|
||||||
|
self, cid: int, handler: Callable[[int, bytes], Any]
|
||||||
|
) -> None:
|
||||||
self.fixed_channels[cid] = handler
|
self.fixed_channels[cid] = handler
|
||||||
|
|
||||||
def deregister_fixed_channel(self, cid):
|
def deregister_fixed_channel(self, cid: int) -> None:
|
||||||
if cid in self.fixed_channels:
|
if cid in self.fixed_channels:
|
||||||
del self.fixed_channels[cid]
|
del self.fixed_channels[cid]
|
||||||
|
|
||||||
def register_server(self, psm, server):
|
def register_server(self, psm: int, server: Callable[[Channel], Any]) -> int:
|
||||||
if psm == 0:
|
if psm == 0:
|
||||||
# Find a free PSM
|
# Find a free PSM
|
||||||
for candidate in range(
|
for candidate in range(
|
||||||
@@ -1465,12 +1525,12 @@ class ChannelManager:
|
|||||||
|
|
||||||
def register_le_coc_server(
|
def register_le_coc_server(
|
||||||
self,
|
self,
|
||||||
psm,
|
psm: int,
|
||||||
server,
|
server: Callable[[LeConnectionOrientedChannel], Any],
|
||||||
max_credits=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
|
max_credits: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
|
||||||
mtu=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
|
mtu: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
|
||||||
mps=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
|
mps: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
|
||||||
):
|
) -> int:
|
||||||
self.check_le_coc_parameters(max_credits, mtu, mps)
|
self.check_le_coc_parameters(max_credits, mtu, mps)
|
||||||
|
|
||||||
if psm == 0:
|
if psm == 0:
|
||||||
@@ -1498,7 +1558,7 @@ class ChannelManager:
|
|||||||
|
|
||||||
return psm
|
return psm
|
||||||
|
|
||||||
def on_disconnection(self, connection_handle, _reason):
|
def on_disconnection(self, connection_handle: int, _reason: int) -> None:
|
||||||
logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
|
logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
|
||||||
if connection_handle in self.channels:
|
if connection_handle in self.channels:
|
||||||
for _, channel in self.channels[connection_handle].items():
|
for _, channel in self.channels[connection_handle].items():
|
||||||
@@ -1511,7 +1571,7 @@ class ChannelManager:
|
|||||||
if connection_handle in self.identifiers:
|
if connection_handle in self.identifiers:
|
||||||
del self.identifiers[connection_handle]
|
del self.identifiers[connection_handle]
|
||||||
|
|
||||||
def send_pdu(self, connection, cid, pdu):
|
def send_pdu(self, connection, cid: int, pdu: SupportsBytes | bytes) -> None:
|
||||||
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
|
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'{color(">>> Sending L2CAP PDU", "blue")} '
|
f'{color(">>> Sending L2CAP PDU", "blue")} '
|
||||||
@@ -1520,14 +1580,16 @@ class ChannelManager:
|
|||||||
)
|
)
|
||||||
self.host.send_l2cap_pdu(connection.handle, cid, bytes(pdu))
|
self.host.send_l2cap_pdu(connection.handle, cid, bytes(pdu))
|
||||||
|
|
||||||
def on_pdu(self, connection, cid, pdu):
|
def on_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
|
||||||
if cid in (L2CAP_SIGNALING_CID, L2CAP_LE_SIGNALING_CID):
|
if cid in (L2CAP_SIGNALING_CID, L2CAP_LE_SIGNALING_CID):
|
||||||
# Parse the L2CAP payload into a Control Frame object
|
# Parse the L2CAP payload into a Control Frame object
|
||||||
control_frame = L2CAP_Control_Frame.from_bytes(pdu)
|
control_frame = L2CAP_Control_Frame.from_bytes(pdu)
|
||||||
|
|
||||||
self.on_control_frame(connection, cid, control_frame)
|
self.on_control_frame(connection, cid, control_frame)
|
||||||
elif cid in self.fixed_channels:
|
elif cid in self.fixed_channels:
|
||||||
self.fixed_channels[cid](connection.handle, pdu)
|
handler = self.fixed_channels[cid]
|
||||||
|
assert handler is not None
|
||||||
|
handler(connection.handle, pdu)
|
||||||
else:
|
else:
|
||||||
if (channel := self.find_channel(connection.handle, cid)) is None:
|
if (channel := self.find_channel(connection.handle, cid)) is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -1539,7 +1601,9 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel.on_pdu(pdu)
|
channel.on_pdu(pdu)
|
||||||
|
|
||||||
def send_control_frame(self, connection, cid, control_frame):
|
def send_control_frame(
|
||||||
|
self, connection: Connection, cid: int, control_frame: L2CAP_Control_Frame
|
||||||
|
) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'{color(">>> Sending L2CAP Signaling Control Frame", "blue")} '
|
f'{color(">>> Sending L2CAP Signaling Control Frame", "blue")} '
|
||||||
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
|
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
|
||||||
@@ -1547,7 +1611,9 @@ class ChannelManager:
|
|||||||
)
|
)
|
||||||
self.host.send_l2cap_pdu(connection.handle, cid, bytes(control_frame))
|
self.host.send_l2cap_pdu(connection.handle, cid, bytes(control_frame))
|
||||||
|
|
||||||
def on_control_frame(self, connection, cid, control_frame):
|
def on_control_frame(
|
||||||
|
self, connection: Connection, cid: int, control_frame: L2CAP_Control_Frame
|
||||||
|
) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'{color("<<< Received L2CAP Signaling Control Frame", "green")} '
|
f'{color("<<< Received L2CAP Signaling Control Frame", "green")} '
|
||||||
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
|
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
|
||||||
@@ -1584,10 +1650,14 @@ class ChannelManager:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_l2cap_command_reject(self, _connection, _cid, packet):
|
def on_l2cap_command_reject(
|
||||||
|
self, _connection: Connection, _cid: int, packet
|
||||||
|
) -> None:
|
||||||
logger.warning(f'{color("!!! Command rejected:", "red")} {packet.reason}')
|
logger.warning(f'{color("!!! Command rejected:", "red")} {packet.reason}')
|
||||||
|
|
||||||
def on_l2cap_connection_request(self, connection, cid, request):
|
def on_l2cap_connection_request(
|
||||||
|
self, connection: Connection, cid: int, request
|
||||||
|
) -> None:
|
||||||
# Check if there's a server for this PSM
|
# Check if there's a server for this PSM
|
||||||
server = self.servers.get(request.psm)
|
server = self.servers.get(request.psm)
|
||||||
if server:
|
if server:
|
||||||
@@ -1639,7 +1709,9 @@ class ChannelManager:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_l2cap_connection_response(self, connection, cid, response):
|
def on_l2cap_connection_response(
|
||||||
|
self, connection: Connection, cid: int, response
|
||||||
|
) -> None:
|
||||||
if (
|
if (
|
||||||
channel := self.find_channel(connection.handle, response.source_cid)
|
channel := self.find_channel(connection.handle, response.source_cid)
|
||||||
) is None:
|
) is None:
|
||||||
@@ -1654,7 +1726,9 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel.on_connection_response(response)
|
channel.on_connection_response(response)
|
||||||
|
|
||||||
def on_l2cap_configure_request(self, connection, cid, request):
|
def on_l2cap_configure_request(
|
||||||
|
self, connection: Connection, cid: int, request
|
||||||
|
) -> None:
|
||||||
if (
|
if (
|
||||||
channel := self.find_channel(connection.handle, request.destination_cid)
|
channel := self.find_channel(connection.handle, request.destination_cid)
|
||||||
) is None:
|
) is None:
|
||||||
@@ -1669,7 +1743,9 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel.on_configure_request(request)
|
channel.on_configure_request(request)
|
||||||
|
|
||||||
def on_l2cap_configure_response(self, connection, cid, response):
|
def on_l2cap_configure_response(
|
||||||
|
self, connection: Connection, cid: int, response
|
||||||
|
) -> None:
|
||||||
if (
|
if (
|
||||||
channel := self.find_channel(connection.handle, response.source_cid)
|
channel := self.find_channel(connection.handle, response.source_cid)
|
||||||
) is None:
|
) is None:
|
||||||
@@ -1684,7 +1760,9 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel.on_configure_response(response)
|
channel.on_configure_response(response)
|
||||||
|
|
||||||
def on_l2cap_disconnection_request(self, connection, cid, request):
|
def on_l2cap_disconnection_request(
|
||||||
|
self, connection: Connection, cid: int, request
|
||||||
|
) -> None:
|
||||||
if (
|
if (
|
||||||
channel := self.find_channel(connection.handle, request.destination_cid)
|
channel := self.find_channel(connection.handle, request.destination_cid)
|
||||||
) is None:
|
) is None:
|
||||||
@@ -1699,7 +1777,9 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel.on_disconnection_request(request)
|
channel.on_disconnection_request(request)
|
||||||
|
|
||||||
def on_l2cap_disconnection_response(self, connection, cid, response):
|
def on_l2cap_disconnection_response(
|
||||||
|
self, connection: Connection, cid: int, response
|
||||||
|
) -> None:
|
||||||
if (
|
if (
|
||||||
channel := self.find_channel(connection.handle, response.source_cid)
|
channel := self.find_channel(connection.handle, response.source_cid)
|
||||||
) is None:
|
) is None:
|
||||||
@@ -1714,7 +1794,7 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel.on_disconnection_response(response)
|
channel.on_disconnection_response(response)
|
||||||
|
|
||||||
def on_l2cap_echo_request(self, connection, cid, request):
|
def on_l2cap_echo_request(self, connection: Connection, cid: int, request) -> None:
|
||||||
logger.debug(f'<<< Echo request: data={request.data.hex()}')
|
logger.debug(f'<<< Echo request: data={request.data.hex()}')
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
connection,
|
connection,
|
||||||
@@ -1722,11 +1802,15 @@ class ChannelManager:
|
|||||||
L2CAP_Echo_Response(identifier=request.identifier, data=request.data),
|
L2CAP_Echo_Response(identifier=request.identifier, data=request.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_l2cap_echo_response(self, _connection, _cid, response):
|
def on_l2cap_echo_response(
|
||||||
|
self, _connection: Connection, _cid: int, response
|
||||||
|
) -> None:
|
||||||
logger.debug(f'<<< Echo response: data={response.data.hex()}')
|
logger.debug(f'<<< Echo response: data={response.data.hex()}')
|
||||||
# TODO notify listeners
|
# TODO notify listeners
|
||||||
|
|
||||||
def on_l2cap_information_request(self, connection, cid, request):
|
def on_l2cap_information_request(
|
||||||
|
self, connection: Connection, cid: int, request
|
||||||
|
) -> None:
|
||||||
if request.info_type == L2CAP_Information_Request.CONNECTIONLESS_MTU:
|
if request.info_type == L2CAP_Information_Request.CONNECTIONLESS_MTU:
|
||||||
result = L2CAP_Information_Response.SUCCESS
|
result = L2CAP_Information_Response.SUCCESS
|
||||||
data = self.connectionless_mtu.to_bytes(2, 'little')
|
data = self.connectionless_mtu.to_bytes(2, 'little')
|
||||||
@@ -1750,7 +1834,9 @@ class ChannelManager:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_l2cap_connection_parameter_update_request(self, connection, cid, request):
|
def on_l2cap_connection_parameter_update_request(
|
||||||
|
self, connection: Connection, cid: int, request
|
||||||
|
):
|
||||||
if connection.role == BT_CENTRAL_ROLE:
|
if connection.role == BT_CENTRAL_ROLE:
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
connection,
|
connection,
|
||||||
@@ -1769,7 +1855,7 @@ class ChannelManager:
|
|||||||
supervision_timeout=request.timeout,
|
supervision_timeout=request.timeout,
|
||||||
min_ce_length=0,
|
min_ce_length=0,
|
||||||
max_ce_length=0,
|
max_ce_length=0,
|
||||||
)
|
) # type: ignore[call-arg]
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
@@ -1781,11 +1867,49 @@ class ChannelManager:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_l2cap_connection_parameter_update_response(self, connection, cid, response):
|
async def update_connection_parameters(
|
||||||
# TODO: check response
|
self,
|
||||||
pass
|
connection: Connection,
|
||||||
|
interval_min: int,
|
||||||
|
interval_max: int,
|
||||||
|
latency: int,
|
||||||
|
timeout: int,
|
||||||
|
) -> int:
|
||||||
|
# Check that there isn't already a request pending
|
||||||
|
if self.connection_parameters_update_response:
|
||||||
|
raise InvalidStateError('request already pending')
|
||||||
|
self.connection_parameters_update_response = (
|
||||||
|
asyncio.get_running_loop().create_future()
|
||||||
|
)
|
||||||
|
self.send_control_frame(
|
||||||
|
connection,
|
||||||
|
L2CAP_LE_SIGNALING_CID,
|
||||||
|
L2CAP_Connection_Parameter_Update_Request(
|
||||||
|
interval_min=interval_min,
|
||||||
|
interval_max=interval_max,
|
||||||
|
latency=latency,
|
||||||
|
timeout=timeout,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return await self.connection_parameters_update_response
|
||||||
|
|
||||||
def on_l2cap_le_credit_based_connection_request(self, connection, cid, request):
|
def on_l2cap_connection_parameter_update_response(
|
||||||
|
self, connection: Connection, cid: int, response
|
||||||
|
) -> None:
|
||||||
|
if self.connection_parameters_update_response:
|
||||||
|
self.connection_parameters_update_response.set_result(response.result)
|
||||||
|
self.connection_parameters_update_response = None
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
color(
|
||||||
|
'received l2cap_connection_parameter_update_response without a pending request',
|
||||||
|
'red',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_l2cap_le_credit_based_connection_request(
|
||||||
|
self, connection: Connection, cid: int, request
|
||||||
|
) -> None:
|
||||||
if request.le_psm in self.le_coc_servers:
|
if request.le_psm in self.le_coc_servers:
|
||||||
(server, max_credits, mtu, mps) = self.le_coc_servers[request.le_psm]
|
(server, max_credits, mtu, mps) = self.le_coc_servers[request.le_psm]
|
||||||
|
|
||||||
@@ -1887,7 +2011,9 @@ class ChannelManager:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_l2cap_le_credit_based_connection_response(self, connection, _cid, response):
|
def on_l2cap_le_credit_based_connection_response(
|
||||||
|
self, connection: Connection, _cid: int, response
|
||||||
|
) -> None:
|
||||||
# Find the pending request by identifier
|
# Find the pending request by identifier
|
||||||
request = self.le_coc_requests.get(response.identifier)
|
request = self.le_coc_requests.get(response.identifier)
|
||||||
if request is None:
|
if request is None:
|
||||||
@@ -1910,7 +2036,9 @@ class ChannelManager:
|
|||||||
# Process the response
|
# Process the response
|
||||||
channel.on_connection_response(response)
|
channel.on_connection_response(response)
|
||||||
|
|
||||||
def on_l2cap_le_flow_control_credit(self, connection, _cid, credit):
|
def on_l2cap_le_flow_control_credit(
|
||||||
|
self, connection: Connection, _cid: int, credit
|
||||||
|
) -> None:
|
||||||
channel = self.find_le_coc_channel(connection.handle, credit.cid)
|
channel = self.find_le_coc_channel(connection.handle, credit.cid)
|
||||||
if channel is None:
|
if channel is None:
|
||||||
logger.warning(f'received credits for an unknown channel (cid={credit.cid}')
|
logger.warning(f'received credits for an unknown channel (cid={credit.cid}')
|
||||||
@@ -1918,13 +2046,15 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel.on_credits(credit.credits)
|
channel.on_credits(credit.credits)
|
||||||
|
|
||||||
def on_channel_closed(self, channel):
|
def on_channel_closed(self, channel: Channel) -> None:
|
||||||
connection_channels = self.channels.get(channel.connection.handle)
|
connection_channels = self.channels.get(channel.connection.handle)
|
||||||
if connection_channels:
|
if connection_channels:
|
||||||
if channel.source_cid in connection_channels:
|
if channel.source_cid in connection_channels:
|
||||||
del connection_channels[channel.source_cid]
|
del connection_channels[channel.source_cid]
|
||||||
|
|
||||||
async def open_le_coc(self, connection, psm, max_credits, mtu, mps):
|
async def open_le_coc(
|
||||||
|
self, connection: Connection, psm: int, max_credits: int, mtu: int, mps: int
|
||||||
|
) -> LeConnectionOrientedChannel:
|
||||||
self.check_le_coc_parameters(max_credits, mtu, mps)
|
self.check_le_coc_parameters(max_credits, mtu, mps)
|
||||||
|
|
||||||
# Find a free CID for the new channel
|
# Find a free CID for the new channel
|
||||||
@@ -1965,7 +2095,7 @@ class ChannelManager:
|
|||||||
|
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
async def connect(self, connection, psm):
|
async def connect(self, connection: Connection, psm: int) -> Channel:
|
||||||
# NOTE: this implementation hard-codes BR/EDR
|
# NOTE: this implementation hard-codes BR/EDR
|
||||||
|
|
||||||
# Find a free CID for a new channel
|
# Find a free CID for a new channel
|
||||||
@@ -1984,7 +2114,8 @@ class ChannelManager:
|
|||||||
# Connect
|
# Connect
|
||||||
try:
|
try:
|
||||||
await channel.connect()
|
await channel.connect()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
del connection_channels[source_cid]
|
del connection_channels[source_cid]
|
||||||
|
raise e
|
||||||
|
|
||||||
return channel
|
return channel
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import enum
|
|||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from .hci import (
|
from .hci import (
|
||||||
|
Address,
|
||||||
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
||||||
HCI_DISPLAY_ONLY_IO_CAPABILITY,
|
HCI_DISPLAY_ONLY_IO_CAPABILITY,
|
||||||
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
|
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
|
||||||
@@ -168,21 +169,28 @@ class PairingDelegate:
|
|||||||
class PairingConfig:
|
class PairingConfig:
|
||||||
"""Configuration for the Pairing protocol."""
|
"""Configuration for the Pairing protocol."""
|
||||||
|
|
||||||
|
class AddressType(enum.IntEnum):
|
||||||
|
PUBLIC = Address.PUBLIC_DEVICE_ADDRESS
|
||||||
|
RANDOM = Address.RANDOM_DEVICE_ADDRESS
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
sc: bool = True,
|
sc: bool = True,
|
||||||
mitm: bool = True,
|
mitm: bool = True,
|
||||||
bonding: bool = True,
|
bonding: bool = True,
|
||||||
delegate: Optional[PairingDelegate] = None,
|
delegate: Optional[PairingDelegate] = None,
|
||||||
|
identity_address_type: Optional[AddressType] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.sc = sc
|
self.sc = sc
|
||||||
self.mitm = mitm
|
self.mitm = mitm
|
||||||
self.bonding = bonding
|
self.bonding = bonding
|
||||||
self.delegate = delegate or PairingDelegate()
|
self.delegate = delegate or PairingDelegate()
|
||||||
|
self.identity_address_type = identity_address_type
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'PairingConfig(sc={self.sc}, '
|
f'PairingConfig(sc={self.sc}, '
|
||||||
f'mitm={self.mitm}, bonding={self.bonding}, '
|
f'mitm={self.mitm}, bonding={self.bonding}, '
|
||||||
|
f'identity_address_type={self.identity_address_type}, '
|
||||||
f'delegate[{self.delegate.io_capability}])'
|
f'delegate[{self.delegate.io_capability}])'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from bumble.pairing import PairingDelegate
|
from bumble.pairing import PairingConfig, PairingDelegate
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ from typing import Any, Dict
|
|||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
io_capability: PairingDelegate.IoCapability = PairingDelegate.NO_OUTPUT_NO_INPUT
|
io_capability: PairingDelegate.IoCapability = PairingDelegate.NO_OUTPUT_NO_INPUT
|
||||||
|
identity_address_type: PairingConfig.AddressType = PairingConfig.AddressType.RANDOM
|
||||||
pairing_sc_enable: bool = True
|
pairing_sc_enable: bool = True
|
||||||
pairing_mitm_enable: bool = True
|
pairing_mitm_enable: bool = True
|
||||||
pairing_bonding_enable: bool = True
|
pairing_bonding_enable: bool = True
|
||||||
@@ -35,6 +36,12 @@ class Config:
|
|||||||
'io_capability', 'no_output_no_input'
|
'io_capability', 'no_output_no_input'
|
||||||
).upper()
|
).upper()
|
||||||
self.io_capability = getattr(PairingDelegate, io_capability_name)
|
self.io_capability = getattr(PairingDelegate, io_capability_name)
|
||||||
|
identity_address_type_name: str = config.get(
|
||||||
|
'identity_address_type', 'random'
|
||||||
|
).upper()
|
||||||
|
self.identity_address_type = getattr(
|
||||||
|
PairingConfig.AddressType, identity_address_type_name
|
||||||
|
)
|
||||||
self.pairing_sc_enable = config.get('pairing_sc_enable', True)
|
self.pairing_sc_enable = config.get('pairing_sc_enable', True)
|
||||||
self.pairing_mitm_enable = config.get('pairing_mitm_enable', True)
|
self.pairing_mitm_enable = config.get('pairing_mitm_enable', True)
|
||||||
self.pairing_bonding_enable = config.get('pairing_bonding_enable', True)
|
self.pairing_bonding_enable = config.get('pairing_bonding_enable', True)
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ from bumble.sdp import (
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# Default rootcanal HCI TCP address
|
||||||
|
ROOTCANAL_HCI_ADDRESS = "localhost:6402"
|
||||||
|
|
||||||
|
|
||||||
class PandoraDevice:
|
class PandoraDevice:
|
||||||
"""
|
"""
|
||||||
Small wrapper around a Bumble device and it's HCI transport.
|
Small wrapper around a Bumble device and it's HCI transport.
|
||||||
@@ -53,7 +57,9 @@ class PandoraDevice:
|
|||||||
def __init__(self, config: Dict[str, Any]) -> None:
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.device = _make_device(config)
|
self.device = _make_device(config)
|
||||||
self._hci_name = config.get('transport', '')
|
self._hci_name = config.get(
|
||||||
|
'transport', f"tcp-client:{config.get('tcp', ROOTCANAL_HCI_ADDRESS)}"
|
||||||
|
)
|
||||||
self._hci = None
|
self._hci = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ from bumble.hci import (
|
|||||||
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||||
Address,
|
Address,
|
||||||
)
|
)
|
||||||
from google.protobuf import any_pb2, empty_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 pandora.host_grpc_aio import HostServicer
|
from pandora.host_grpc_aio import HostServicer
|
||||||
from pandora.host_pb2 import (
|
from pandora.host_pb2 import (
|
||||||
NOT_CONNECTABLE,
|
NOT_CONNECTABLE,
|
||||||
@@ -111,7 +112,7 @@ class HostService(HostServicer):
|
|||||||
async def FactoryReset(
|
async def FactoryReset(
|
||||||
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
||||||
) -> empty_pb2.Empty:
|
) -> empty_pb2.Empty:
|
||||||
self.log.info('FactoryReset')
|
self.log.debug('FactoryReset')
|
||||||
|
|
||||||
# delete all bonds
|
# delete all bonds
|
||||||
if self.device.keystore is not None:
|
if self.device.keystore is not None:
|
||||||
@@ -125,7 +126,7 @@ class HostService(HostServicer):
|
|||||||
async def Reset(
|
async def Reset(
|
||||||
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
||||||
) -> empty_pb2.Empty:
|
) -> empty_pb2.Empty:
|
||||||
self.log.info('Reset')
|
self.log.debug('Reset')
|
||||||
|
|
||||||
# clear service.
|
# clear service.
|
||||||
self.waited_connections.clear()
|
self.waited_connections.clear()
|
||||||
@@ -138,7 +139,7 @@ class HostService(HostServicer):
|
|||||||
async def ReadLocalAddress(
|
async def ReadLocalAddress(
|
||||||
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
||||||
) -> ReadLocalAddressResponse:
|
) -> ReadLocalAddressResponse:
|
||||||
self.log.info('ReadLocalAddress')
|
self.log.debug('ReadLocalAddress')
|
||||||
return ReadLocalAddressResponse(
|
return ReadLocalAddressResponse(
|
||||||
address=bytes(reversed(bytes(self.device.public_address)))
|
address=bytes(reversed(bytes(self.device.public_address)))
|
||||||
)
|
)
|
||||||
@@ -151,7 +152,7 @@ class HostService(HostServicer):
|
|||||||
address = Address(
|
address = Address(
|
||||||
bytes(reversed(request.address)), address_type=Address.PUBLIC_DEVICE_ADDRESS
|
bytes(reversed(request.address)), address_type=Address.PUBLIC_DEVICE_ADDRESS
|
||||||
)
|
)
|
||||||
self.log.info(f"Connect to {address}")
|
self.log.debug(f"Connect to {address}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
connection = await self.device.connect(
|
connection = await self.device.connect(
|
||||||
@@ -166,7 +167,7 @@ class HostService(HostServicer):
|
|||||||
return ConnectResponse(connection_already_exists=empty_pb2.Empty())
|
return ConnectResponse(connection_already_exists=empty_pb2.Empty())
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
self.log.info(f"Connect to {address} done (handle={connection.handle})")
|
self.log.debug(f"Connect to {address} done (handle={connection.handle})")
|
||||||
|
|
||||||
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
|
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
|
||||||
return ConnectResponse(connection=Connection(cookie=cookie))
|
return ConnectResponse(connection=Connection(cookie=cookie))
|
||||||
@@ -185,7 +186,7 @@ class HostService(HostServicer):
|
|||||||
if address in (Address.NIL, Address.ANY):
|
if address in (Address.NIL, Address.ANY):
|
||||||
raise ValueError('Invalid address')
|
raise ValueError('Invalid address')
|
||||||
|
|
||||||
self.log.info(f"WaitConnection from {address}...")
|
self.log.debug(f"WaitConnection from {address}...")
|
||||||
|
|
||||||
connection = self.device.find_connection_by_bd_addr(
|
connection = self.device.find_connection_by_bd_addr(
|
||||||
address, transport=BT_BR_EDR_TRANSPORT
|
address, transport=BT_BR_EDR_TRANSPORT
|
||||||
@@ -200,7 +201,7 @@ class HostService(HostServicer):
|
|||||||
# save connection has waited and respond.
|
# save connection has waited and respond.
|
||||||
self.waited_connections.add(id(connection))
|
self.waited_connections.add(id(connection))
|
||||||
|
|
||||||
self.log.info(
|
self.log.debug(
|
||||||
f"WaitConnection from {address} done (handle={connection.handle})"
|
f"WaitConnection from {address} done (handle={connection.handle})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -215,7 +216,7 @@ class HostService(HostServicer):
|
|||||||
if address in (Address.NIL, Address.ANY):
|
if address in (Address.NIL, Address.ANY):
|
||||||
raise ValueError('Invalid address')
|
raise ValueError('Invalid address')
|
||||||
|
|
||||||
self.log.info(f"ConnectLE to {address}...")
|
self.log.debug(f"ConnectLE to {address}...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
connection = await self.device.connect(
|
connection = await self.device.connect(
|
||||||
@@ -232,7 +233,7 @@ class HostService(HostServicer):
|
|||||||
return ConnectLEResponse(connection_already_exists=empty_pb2.Empty())
|
return ConnectLEResponse(connection_already_exists=empty_pb2.Empty())
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
self.log.info(f"ConnectLE to {address} done (handle={connection.handle})")
|
self.log.debug(f"ConnectLE to {address} done (handle={connection.handle})")
|
||||||
|
|
||||||
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
|
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
|
||||||
return ConnectLEResponse(connection=Connection(cookie=cookie))
|
return ConnectLEResponse(connection=Connection(cookie=cookie))
|
||||||
@@ -242,12 +243,12 @@ class HostService(HostServicer):
|
|||||||
self, request: DisconnectRequest, context: grpc.ServicerContext
|
self, request: DisconnectRequest, context: grpc.ServicerContext
|
||||||
) -> empty_pb2.Empty:
|
) -> empty_pb2.Empty:
|
||||||
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
||||||
self.log.info(f"Disconnect: {connection_handle}")
|
self.log.debug(f"Disconnect: {connection_handle}")
|
||||||
|
|
||||||
self.log.info("Disconnecting...")
|
self.log.debug("Disconnecting...")
|
||||||
if connection := self.device.lookup_connection(connection_handle):
|
if connection := self.device.lookup_connection(connection_handle):
|
||||||
await connection.disconnect(HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR)
|
await connection.disconnect(HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR)
|
||||||
self.log.info("Disconnected")
|
self.log.debug("Disconnected")
|
||||||
|
|
||||||
return empty_pb2.Empty()
|
return empty_pb2.Empty()
|
||||||
|
|
||||||
@@ -256,7 +257,7 @@ class HostService(HostServicer):
|
|||||||
self, request: WaitDisconnectionRequest, context: grpc.ServicerContext
|
self, request: WaitDisconnectionRequest, context: grpc.ServicerContext
|
||||||
) -> empty_pb2.Empty:
|
) -> empty_pb2.Empty:
|
||||||
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
||||||
self.log.info(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[
|
||||||
@@ -269,7 +270,7 @@ class HostService(HostServicer):
|
|||||||
connection.on('disconnection', on_disconnection)
|
connection.on('disconnection', on_disconnection)
|
||||||
try:
|
try:
|
||||||
await disconnection_future
|
await disconnection_future
|
||||||
self.log.info("Disconnected")
|
self.log.debug("Disconnected")
|
||||||
finally:
|
finally:
|
||||||
connection.remove_listener('disconnection', on_disconnection) # type: ignore
|
connection.remove_listener('disconnection', on_disconnection) # type: ignore
|
||||||
|
|
||||||
@@ -377,7 +378,7 @@ class HostService(HostServicer):
|
|||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
if not self.device.is_advertising:
|
if not self.device.is_advertising:
|
||||||
self.log.info('Advertise')
|
self.log.debug('Advertise')
|
||||||
await self.device.start_advertising(
|
await self.device.start_advertising(
|
||||||
target=target,
|
target=target,
|
||||||
advertising_type=advertising_type,
|
advertising_type=advertising_type,
|
||||||
@@ -392,10 +393,10 @@ class HostService(HostServicer):
|
|||||||
bumble.device.Connection
|
bumble.device.Connection
|
||||||
] = asyncio.get_running_loop().create_future()
|
] = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
self.log.info('Wait for LE connection...')
|
self.log.debug('Wait for LE connection...')
|
||||||
connection = await pending_connection
|
connection = await pending_connection
|
||||||
|
|
||||||
self.log.info(
|
self.log.debug(
|
||||||
f"Advertise: Connected to {connection.peer_address} (handle={connection.handle})"
|
f"Advertise: Connected to {connection.peer_address} (handle={connection.handle})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -409,7 +410,7 @@ class HostService(HostServicer):
|
|||||||
self.device.remove_listener('connection', on_connection) # type: ignore
|
self.device.remove_listener('connection', on_connection) # type: ignore
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.log.info('Stop advertising')
|
self.log.debug('Stop advertising')
|
||||||
await self.device.abort_on('flush', self.device.stop_advertising())
|
await self.device.abort_on('flush', self.device.stop_advertising())
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
@@ -422,7 +423,7 @@ class HostService(HostServicer):
|
|||||||
if request.phys:
|
if request.phys:
|
||||||
raise NotImplementedError("TODO: add support for `request.phys`")
|
raise NotImplementedError("TODO: add support for `request.phys`")
|
||||||
|
|
||||||
self.log.info('Scan')
|
self.log.debug('Scan')
|
||||||
|
|
||||||
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)
|
||||||
@@ -469,7 +470,7 @@ class HostService(HostServicer):
|
|||||||
finally:
|
finally:
|
||||||
self.device.remove_listener('advertisement', handler) # type: ignore
|
self.device.remove_listener('advertisement', handler) # type: ignore
|
||||||
try:
|
try:
|
||||||
self.log.info('Stop scanning')
|
self.log.debug('Stop scanning')
|
||||||
await self.device.abort_on('flush', self.device.stop_scanning())
|
await self.device.abort_on('flush', self.device.stop_scanning())
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
@@ -478,7 +479,7 @@ class HostService(HostServicer):
|
|||||||
async def Inquiry(
|
async def Inquiry(
|
||||||
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
||||||
) -> AsyncGenerator[InquiryResponse, None]:
|
) -> AsyncGenerator[InquiryResponse, None]:
|
||||||
self.log.info('Inquiry')
|
self.log.debug('Inquiry')
|
||||||
|
|
||||||
inquiry_queue: asyncio.Queue[
|
inquiry_queue: asyncio.Queue[
|
||||||
Optional[Tuple[Address, int, AdvertisingData, int]]
|
Optional[Tuple[Address, int, AdvertisingData, int]]
|
||||||
@@ -509,7 +510,7 @@ class HostService(HostServicer):
|
|||||||
self.device.remove_listener('inquiry_complete', complete_handler) # type: ignore
|
self.device.remove_listener('inquiry_complete', complete_handler) # type: ignore
|
||||||
self.device.remove_listener('inquiry_result', result_handler) # type: ignore
|
self.device.remove_listener('inquiry_result', result_handler) # type: ignore
|
||||||
try:
|
try:
|
||||||
self.log.info('Stop inquiry')
|
self.log.debug('Stop inquiry')
|
||||||
await self.device.abort_on('flush', self.device.stop_discovery())
|
await self.device.abort_on('flush', self.device.stop_discovery())
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
@@ -518,7 +519,7 @@ class HostService(HostServicer):
|
|||||||
async def SetDiscoverabilityMode(
|
async def SetDiscoverabilityMode(
|
||||||
self, request: SetDiscoverabilityModeRequest, context: grpc.ServicerContext
|
self, request: SetDiscoverabilityModeRequest, context: grpc.ServicerContext
|
||||||
) -> empty_pb2.Empty:
|
) -> empty_pb2.Empty:
|
||||||
self.log.info("SetDiscoverabilityMode")
|
self.log.debug("SetDiscoverabilityMode")
|
||||||
await self.device.set_discoverable(request.mode != NOT_DISCOVERABLE)
|
await self.device.set_discoverable(request.mode != NOT_DISCOVERABLE)
|
||||||
return empty_pb2.Empty()
|
return empty_pb2.Empty()
|
||||||
|
|
||||||
@@ -526,7 +527,7 @@ class HostService(HostServicer):
|
|||||||
async def SetConnectabilityMode(
|
async def SetConnectabilityMode(
|
||||||
self, request: SetConnectabilityModeRequest, context: grpc.ServicerContext
|
self, request: SetConnectabilityModeRequest, context: grpc.ServicerContext
|
||||||
) -> empty_pb2.Empty:
|
) -> empty_pb2.Empty:
|
||||||
self.log.info("SetConnectabilityMode")
|
self.log.debug("SetConnectabilityMode")
|
||||||
await self.device.set_connectable(request.mode != NOT_CONNECTABLE)
|
await self.device.set_connectable(request.mode != NOT_CONNECTABLE)
|
||||||
return empty_pb2.Empty()
|
return empty_pb2.Empty()
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,9 @@ from bumble.device import Connection as BumbleConnection, Device
|
|||||||
from bumble.hci import HCI_Error
|
from bumble.hci import HCI_Error
|
||||||
from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate
|
from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from google.protobuf import (
|
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
||||||
any_pb2,
|
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
||||||
empty_pb2,
|
from google.protobuf import wrappers_pb2 # pytype: disable=pyi-error
|
||||||
wrappers_pb2,
|
|
||||||
) # pytype: disable=pyi-error
|
|
||||||
from google.protobuf.wrappers_pb2 import BoolValue # pytype: disable=pyi-error
|
|
||||||
from pandora.host_pb2 import Connection
|
from pandora.host_pb2 import Connection
|
||||||
from pandora.security_grpc_aio import SecurityServicer, SecurityStorageServicer
|
from pandora.security_grpc_aio import SecurityServicer, SecurityStorageServicer
|
||||||
from pandora.security_pb2 import (
|
from pandora.security_pb2 import (
|
||||||
@@ -102,7 +99,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
return ev
|
return ev
|
||||||
|
|
||||||
async def confirm(self, auto: bool = False) -> bool:
|
async def confirm(self, auto: bool = False) -> bool:
|
||||||
self.log.info(
|
self.log.debug(
|
||||||
f"Pairing event: `just_works` (io_capability: {self.io_capability})"
|
f"Pairing event: `just_works` (io_capability: {self.io_capability})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -117,7 +114,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
return answer.confirm
|
return answer.confirm
|
||||||
|
|
||||||
async def compare_numbers(self, number: int, digits: int = 6) -> bool:
|
async def compare_numbers(self, number: int, digits: int = 6) -> bool:
|
||||||
self.log.info(
|
self.log.debug(
|
||||||
f"Pairing event: `numeric_comparison` (io_capability: {self.io_capability})"
|
f"Pairing event: `numeric_comparison` (io_capability: {self.io_capability})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -132,7 +129,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
return answer.confirm
|
return answer.confirm
|
||||||
|
|
||||||
async def get_number(self) -> Optional[int]:
|
async def get_number(self) -> Optional[int]:
|
||||||
self.log.info(
|
self.log.debug(
|
||||||
f"Pairing event: `passkey_entry_request` (io_capability: {self.io_capability})"
|
f"Pairing event: `passkey_entry_request` (io_capability: {self.io_capability})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -149,7 +146,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
return answer.passkey
|
return answer.passkey
|
||||||
|
|
||||||
async def get_string(self, max_length: int) -> Optional[str]:
|
async def get_string(self, max_length: int) -> Optional[str]:
|
||||||
self.log.info(
|
self.log.debug(
|
||||||
f"Pairing event: `pin_code_request` (io_capability: {self.io_capability})"
|
f"Pairing event: `pin_code_request` (io_capability: {self.io_capability})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -180,7 +177,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
self.log.info(
|
self.log.debug(
|
||||||
f"Pairing event: `passkey_entry_notification` (io_capability: {self.io_capability})"
|
f"Pairing event: `passkey_entry_notification` (io_capability: {self.io_capability})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -235,6 +232,7 @@ class SecurityService(SecurityServicer):
|
|||||||
sc=config.pairing_sc_enable,
|
sc=config.pairing_sc_enable,
|
||||||
mitm=config.pairing_mitm_enable,
|
mitm=config.pairing_mitm_enable,
|
||||||
bonding=config.pairing_bonding_enable,
|
bonding=config.pairing_bonding_enable,
|
||||||
|
identity_address_type=config.identity_address_type,
|
||||||
delegate=PairingDelegate(
|
delegate=PairingDelegate(
|
||||||
connection,
|
connection,
|
||||||
self,
|
self,
|
||||||
@@ -250,7 +248,7 @@ class SecurityService(SecurityServicer):
|
|||||||
async def OnPairing(
|
async def OnPairing(
|
||||||
self, request: AsyncIterator[PairingEventAnswer], context: grpc.ServicerContext
|
self, request: AsyncIterator[PairingEventAnswer], context: grpc.ServicerContext
|
||||||
) -> AsyncGenerator[PairingEvent, None]:
|
) -> AsyncGenerator[PairingEvent, None]:
|
||||||
self.log.info('OnPairing')
|
self.log.debug('OnPairing')
|
||||||
|
|
||||||
if self.event_queue is not None:
|
if self.event_queue is not None:
|
||||||
raise RuntimeError('already streaming pairing events')
|
raise RuntimeError('already streaming pairing events')
|
||||||
@@ -276,7 +274,7 @@ class SecurityService(SecurityServicer):
|
|||||||
self, request: SecureRequest, context: grpc.ServicerContext
|
self, request: SecureRequest, context: grpc.ServicerContext
|
||||||
) -> SecureResponse:
|
) -> SecureResponse:
|
||||||
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
||||||
self.log.info(f"Secure: {connection_handle}")
|
self.log.debug(f"Secure: {connection_handle}")
|
||||||
|
|
||||||
connection = self.device.lookup_connection(connection_handle)
|
connection = self.device.lookup_connection(connection_handle)
|
||||||
assert connection
|
assert connection
|
||||||
@@ -294,7 +292,7 @@ class SecurityService(SecurityServicer):
|
|||||||
# trigger pairing if needed
|
# trigger pairing if needed
|
||||||
if self.need_pairing(connection, level):
|
if self.need_pairing(connection, level):
|
||||||
try:
|
try:
|
||||||
self.log.info('Pair...')
|
self.log.debug('Pair...')
|
||||||
|
|
||||||
if (
|
if (
|
||||||
connection.transport == BT_LE_TRANSPORT
|
connection.transport == BT_LE_TRANSPORT
|
||||||
@@ -312,7 +310,7 @@ class SecurityService(SecurityServicer):
|
|||||||
else:
|
else:
|
||||||
await connection.pair()
|
await connection.pair()
|
||||||
|
|
||||||
self.log.info('Paired')
|
self.log.debug('Paired')
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
self.log.warning("Connection died during encryption")
|
self.log.warning("Connection died during encryption")
|
||||||
return SecureResponse(connection_died=empty_pb2.Empty())
|
return SecureResponse(connection_died=empty_pb2.Empty())
|
||||||
@@ -323,9 +321,9 @@ class SecurityService(SecurityServicer):
|
|||||||
# trigger authentication if needed
|
# trigger authentication if needed
|
||||||
if self.need_authentication(connection, level):
|
if self.need_authentication(connection, level):
|
||||||
try:
|
try:
|
||||||
self.log.info('Authenticate...')
|
self.log.debug('Authenticate...')
|
||||||
await connection.authenticate()
|
await connection.authenticate()
|
||||||
self.log.info('Authenticated')
|
self.log.debug('Authenticated')
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
self.log.warning("Connection died during authentication")
|
self.log.warning("Connection died during authentication")
|
||||||
return SecureResponse(connection_died=empty_pb2.Empty())
|
return SecureResponse(connection_died=empty_pb2.Empty())
|
||||||
@@ -336,9 +334,9 @@ class SecurityService(SecurityServicer):
|
|||||||
# trigger encryption if needed
|
# trigger encryption if needed
|
||||||
if self.need_encryption(connection, level):
|
if self.need_encryption(connection, level):
|
||||||
try:
|
try:
|
||||||
self.log.info('Encrypt...')
|
self.log.debug('Encrypt...')
|
||||||
await connection.encrypt()
|
await connection.encrypt()
|
||||||
self.log.info('Encrypted')
|
self.log.debug('Encrypted')
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
self.log.warning("Connection died during encryption")
|
self.log.warning("Connection died during encryption")
|
||||||
return SecureResponse(connection_died=empty_pb2.Empty())
|
return SecureResponse(connection_died=empty_pb2.Empty())
|
||||||
@@ -356,7 +354,7 @@ class SecurityService(SecurityServicer):
|
|||||||
self, request: WaitSecurityRequest, context: grpc.ServicerContext
|
self, request: WaitSecurityRequest, context: grpc.ServicerContext
|
||||||
) -> WaitSecurityResponse:
|
) -> WaitSecurityResponse:
|
||||||
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
||||||
self.log.info(f"WaitSecurity: {connection_handle}")
|
self.log.debug(f"WaitSecurity: {connection_handle}")
|
||||||
|
|
||||||
connection = self.device.lookup_connection(connection_handle)
|
connection = self.device.lookup_connection(connection_handle)
|
||||||
assert connection
|
assert connection
|
||||||
@@ -393,7 +391,7 @@ class SecurityService(SecurityServicer):
|
|||||||
|
|
||||||
def set_failure(name: str) -> Callable[..., None]:
|
def set_failure(name: str) -> Callable[..., None]:
|
||||||
def wrapper(*args: Any) -> None:
|
def wrapper(*args: Any) -> None:
|
||||||
self.log.info(f'Wait for security: error `{name}`: {args}')
|
self.log.debug(f'Wait for security: error `{name}`: {args}')
|
||||||
wait_for_security.set_result(name)
|
wait_for_security.set_result(name)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -401,13 +399,13 @@ class SecurityService(SecurityServicer):
|
|||||||
def try_set_success(*_: Any) -> None:
|
def try_set_success(*_: Any) -> None:
|
||||||
assert connection
|
assert connection
|
||||||
if self.reached_security_level(connection, level):
|
if self.reached_security_level(connection, level):
|
||||||
self.log.info('Wait for security: done')
|
self.log.debug('Wait for security: done')
|
||||||
wait_for_security.set_result('success')
|
wait_for_security.set_result('success')
|
||||||
|
|
||||||
def on_encryption_change(*_: Any) -> None:
|
def on_encryption_change(*_: Any) -> None:
|
||||||
assert connection
|
assert connection
|
||||||
if self.reached_security_level(connection, level):
|
if self.reached_security_level(connection, level):
|
||||||
self.log.info('Wait for security: done')
|
self.log.debug('Wait for security: done')
|
||||||
wait_for_security.set_result('success')
|
wait_for_security.set_result('success')
|
||||||
elif (
|
elif (
|
||||||
connection.transport == BT_BR_EDR_TRANSPORT
|
connection.transport == BT_BR_EDR_TRANSPORT
|
||||||
@@ -425,6 +423,8 @@ class SecurityService(SecurityServicer):
|
|||||||
'pairing': try_set_success,
|
'pairing': try_set_success,
|
||||||
'connection_authentication': try_set_success,
|
'connection_authentication': try_set_success,
|
||||||
'connection_encryption_change': on_encryption_change,
|
'connection_encryption_change': on_encryption_change,
|
||||||
|
'classic_pairing': try_set_success,
|
||||||
|
'classic_pairing_failure': set_failure('pairing_failure'),
|
||||||
}
|
}
|
||||||
|
|
||||||
# register event handlers
|
# register event handlers
|
||||||
@@ -435,7 +435,7 @@ class SecurityService(SecurityServicer):
|
|||||||
if self.reached_security_level(connection, level):
|
if self.reached_security_level(connection, level):
|
||||||
return WaitSecurityResponse(success=empty_pb2.Empty())
|
return WaitSecurityResponse(success=empty_pb2.Empty())
|
||||||
|
|
||||||
self.log.info('Wait for security...')
|
self.log.debug('Wait for security...')
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
kwargs[await wait_for_security] = empty_pb2.Empty()
|
kwargs[await wait_for_security] = empty_pb2.Empty()
|
||||||
|
|
||||||
@@ -445,12 +445,12 @@ class SecurityService(SecurityServicer):
|
|||||||
|
|
||||||
# wait for `authenticate` to finish if any
|
# wait for `authenticate` to finish if any
|
||||||
if authenticate_task is not None:
|
if authenticate_task is not None:
|
||||||
self.log.info('Wait for authentication...')
|
self.log.debug('Wait for authentication...')
|
||||||
try:
|
try:
|
||||||
await authenticate_task # type: ignore
|
await authenticate_task # type: ignore
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
self.log.info('Authenticated')
|
self.log.debug('Authenticated')
|
||||||
|
|
||||||
return WaitSecurityResponse(**kwargs)
|
return WaitSecurityResponse(**kwargs)
|
||||||
|
|
||||||
@@ -506,21 +506,21 @@ class SecurityStorageService(SecurityStorageServicer):
|
|||||||
self, request: IsBondedRequest, context: grpc.ServicerContext
|
self, request: IsBondedRequest, context: grpc.ServicerContext
|
||||||
) -> wrappers_pb2.BoolValue:
|
) -> wrappers_pb2.BoolValue:
|
||||||
address = utils.address_from_request(request, request.WhichOneof("address"))
|
address = utils.address_from_request(request, request.WhichOneof("address"))
|
||||||
self.log.info(f"IsBonded: {address}")
|
self.log.debug(f"IsBonded: {address}")
|
||||||
|
|
||||||
if self.device.keystore is not None:
|
if self.device.keystore is not None:
|
||||||
is_bonded = await self.device.keystore.get(str(address)) is not None
|
is_bonded = await self.device.keystore.get(str(address)) is not None
|
||||||
else:
|
else:
|
||||||
is_bonded = False
|
is_bonded = False
|
||||||
|
|
||||||
return BoolValue(value=is_bonded)
|
return wrappers_pb2.BoolValue(value=is_bonded)
|
||||||
|
|
||||||
@utils.rpc
|
@utils.rpc
|
||||||
async def DeleteBond(
|
async def DeleteBond(
|
||||||
self, request: DeleteBondRequest, context: grpc.ServicerContext
|
self, request: DeleteBondRequest, context: grpc.ServicerContext
|
||||||
) -> empty_pb2.Empty:
|
) -> empty_pb2.Empty:
|
||||||
address = utils.address_from_request(request, request.WhichOneof("address"))
|
address = utils.address_from_request(request, request.WhichOneof("address"))
|
||||||
self.log.info(f"DeleteBond: {address}")
|
self.log.debug(f"DeleteBond: {address}")
|
||||||
|
|
||||||
if self.device.keystore is not None:
|
if self.device.keystore is not None:
|
||||||
with suppress(KeyError):
|
with suppress(KeyError):
|
||||||
|
|||||||
410
bumble/rfcomm.py
410
bumble/rfcomm.py
@@ -15,14 +15,37 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import enum
|
||||||
|
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
||||||
|
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
|
|
||||||
from . import core
|
from . import core, l2cap
|
||||||
from .colors import color
|
from .colors import color
|
||||||
from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError
|
from .core import (
|
||||||
|
UUID,
|
||||||
|
BT_RFCOMM_PROTOCOL_ID,
|
||||||
|
BT_BR_EDR_TRANSPORT,
|
||||||
|
BT_L2CAP_PROTOCOL_ID,
|
||||||
|
InvalidStateError,
|
||||||
|
ProtocolError,
|
||||||
|
)
|
||||||
|
from .sdp import (
|
||||||
|
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||||
|
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_PUBLIC_BROWSE_ROOT,
|
||||||
|
DataElement,
|
||||||
|
ServiceAttribute,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble.device import Device, Connection
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -105,7 +128,51 @@ RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def compute_fcs(buffer):
|
def make_service_sdp_records(
|
||||||
|
service_record_handle: int, channel: int, uuid: Optional[UUID] = None
|
||||||
|
) -> List[ServiceAttribute]:
|
||||||
|
"""
|
||||||
|
Create SDP records for an RFComm service given a channel number and an
|
||||||
|
optional UUID. A Service Class Attribute is included only if the UUID is not None.
|
||||||
|
"""
|
||||||
|
records = [
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||||
|
DataElement.unsigned_integer_32(service_record_handle),
|
||||||
|
),
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||||
|
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
|
||||||
|
),
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
|
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
|
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
|
||||||
|
DataElement.unsigned_integer_8(channel),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
if uuid:
|
||||||
|
records.append(
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
|
DataElement.sequence([DataElement.uuid(uuid)]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def compute_fcs(buffer: bytes) -> int:
|
||||||
result = 0xFF
|
result = 0xFF
|
||||||
for byte in buffer:
|
for byte in buffer:
|
||||||
result = CRC_TABLE[result ^ byte]
|
result = CRC_TABLE[result ^ byte]
|
||||||
@@ -114,7 +181,15 @@ def compute_fcs(buffer):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class RFCOMM_Frame:
|
class RFCOMM_Frame:
|
||||||
def __init__(self, frame_type, c_r, dlci, p_f, information=b'', with_credits=False):
|
def __init__(
|
||||||
|
self,
|
||||||
|
frame_type: int,
|
||||||
|
c_r: int,
|
||||||
|
dlci: int,
|
||||||
|
p_f: int,
|
||||||
|
information: bytes = b'',
|
||||||
|
with_credits: bool = False,
|
||||||
|
) -> None:
|
||||||
self.type = frame_type
|
self.type = frame_type
|
||||||
self.c_r = c_r
|
self.c_r = c_r
|
||||||
self.dlci = dlci
|
self.dlci = dlci
|
||||||
@@ -136,13 +211,13 @@ class RFCOMM_Frame:
|
|||||||
else:
|
else:
|
||||||
self.fcs = compute_fcs(bytes([self.address, self.control]) + self.length)
|
self.fcs = compute_fcs(bytes([self.address, self.control]) + self.length)
|
||||||
|
|
||||||
def type_name(self):
|
def type_name(self) -> str:
|
||||||
return RFCOMM_FRAME_TYPE_NAMES[self.type]
|
return RFCOMM_FRAME_TYPE_NAMES[self.type]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_mcc(data):
|
def parse_mcc(data) -> Tuple[int, bool, bytes]:
|
||||||
mcc_type = data[0] >> 2
|
mcc_type = data[0] >> 2
|
||||||
c_r = (data[0] >> 1) & 1
|
c_r = bool((data[0] >> 1) & 1)
|
||||||
length = data[1]
|
length = data[1]
|
||||||
if data[1] & 1:
|
if data[1] & 1:
|
||||||
length >>= 1
|
length >>= 1
|
||||||
@@ -154,36 +229,36 @@ class RFCOMM_Frame:
|
|||||||
return (mcc_type, c_r, value)
|
return (mcc_type, c_r, value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def make_mcc(mcc_type, c_r, data):
|
def make_mcc(mcc_type: int, c_r: int, data: bytes) -> bytes:
|
||||||
return (
|
return (
|
||||||
bytes([(mcc_type << 2 | c_r << 1 | 1) & 0xFF, (len(data) & 0x7F) << 1 | 1])
|
bytes([(mcc_type << 2 | c_r << 1 | 1) & 0xFF, (len(data) & 0x7F) << 1 | 1])
|
||||||
+ data
|
+ data
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def sabm(c_r, dlci):
|
def sabm(c_r: int, dlci: int):
|
||||||
return RFCOMM_Frame(RFCOMM_SABM_FRAME, c_r, dlci, 1)
|
return RFCOMM_Frame(RFCOMM_SABM_FRAME, c_r, dlci, 1)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def ua(c_r, dlci):
|
def ua(c_r: int, dlci: int):
|
||||||
return RFCOMM_Frame(RFCOMM_UA_FRAME, c_r, dlci, 1)
|
return RFCOMM_Frame(RFCOMM_UA_FRAME, c_r, dlci, 1)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def dm(c_r, dlci):
|
def dm(c_r: int, dlci: int):
|
||||||
return RFCOMM_Frame(RFCOMM_DM_FRAME, c_r, dlci, 1)
|
return RFCOMM_Frame(RFCOMM_DM_FRAME, c_r, dlci, 1)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def disc(c_r, dlci):
|
def disc(c_r: int, dlci: int):
|
||||||
return RFCOMM_Frame(RFCOMM_DISC_FRAME, c_r, dlci, 1)
|
return RFCOMM_Frame(RFCOMM_DISC_FRAME, c_r, dlci, 1)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def uih(c_r, dlci, information, p_f=0):
|
def uih(c_r: int, dlci: int, information: bytes, p_f: int = 0):
|
||||||
return RFCOMM_Frame(
|
return RFCOMM_Frame(
|
||||||
RFCOMM_UIH_FRAME, c_r, dlci, p_f, information, with_credits=(p_f == 1)
|
RFCOMM_UIH_FRAME, c_r, dlci, p_f, information, with_credits=(p_f == 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(data):
|
def from_bytes(data: bytes) -> RFCOMM_Frame:
|
||||||
# Extract fields
|
# Extract fields
|
||||||
dlci = (data[0] >> 2) & 0x3F
|
dlci = (data[0] >> 2) & 0x3F
|
||||||
c_r = (data[0] >> 1) & 0x01
|
c_r = (data[0] >> 1) & 0x01
|
||||||
@@ -206,7 +281,7 @@ class RFCOMM_Frame:
|
|||||||
|
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self) -> bytes:
|
||||||
return (
|
return (
|
||||||
bytes([self.address, self.control])
|
bytes([self.address, self.control])
|
||||||
+ self.length
|
+ self.length
|
||||||
@@ -214,7 +289,7 @@ class RFCOMM_Frame:
|
|||||||
+ bytes([self.fcs])
|
+ bytes([self.fcs])
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'{color(self.type_name(), "yellow")}'
|
f'{color(self.type_name(), "yellow")}'
|
||||||
f'(c/r={self.c_r},'
|
f'(c/r={self.c_r},'
|
||||||
@@ -227,16 +302,24 @@ class RFCOMM_Frame:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class RFCOMM_MCC_PN:
|
class RFCOMM_MCC_PN:
|
||||||
|
dlci: int
|
||||||
|
cl: int
|
||||||
|
priority: int
|
||||||
|
ack_timer: int
|
||||||
|
max_frame_size: int
|
||||||
|
max_retransmissions: int
|
||||||
|
window_size: int
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
dlci,
|
dlci: int,
|
||||||
cl,
|
cl: int,
|
||||||
priority,
|
priority: int,
|
||||||
ack_timer,
|
ack_timer: int,
|
||||||
max_frame_size,
|
max_frame_size: int,
|
||||||
max_retransmissions,
|
max_retransmissions: int,
|
||||||
window_size,
|
window_size: int,
|
||||||
):
|
) -> None:
|
||||||
self.dlci = dlci
|
self.dlci = dlci
|
||||||
self.cl = cl
|
self.cl = cl
|
||||||
self.priority = priority
|
self.priority = priority
|
||||||
@@ -246,7 +329,7 @@ class RFCOMM_MCC_PN:
|
|||||||
self.window_size = window_size
|
self.window_size = window_size
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(data):
|
def from_bytes(data: bytes) -> RFCOMM_MCC_PN:
|
||||||
return RFCOMM_MCC_PN(
|
return RFCOMM_MCC_PN(
|
||||||
dlci=data[0],
|
dlci=data[0],
|
||||||
cl=data[1],
|
cl=data[1],
|
||||||
@@ -257,7 +340,7 @@ class RFCOMM_MCC_PN:
|
|||||||
window_size=data[7],
|
window_size=data[7],
|
||||||
)
|
)
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self) -> bytes:
|
||||||
return bytes(
|
return bytes(
|
||||||
[
|
[
|
||||||
self.dlci & 0xFF,
|
self.dlci & 0xFF,
|
||||||
@@ -271,7 +354,7 @@ class RFCOMM_MCC_PN:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'PN(dlci={self.dlci},'
|
f'PN(dlci={self.dlci},'
|
||||||
f'cl={self.cl},'
|
f'cl={self.cl},'
|
||||||
@@ -285,7 +368,16 @@ class RFCOMM_MCC_PN:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class RFCOMM_MCC_MSC:
|
class RFCOMM_MCC_MSC:
|
||||||
def __init__(self, dlci, fc, rtc, rtr, ic, dv):
|
dlci: int
|
||||||
|
fc: int
|
||||||
|
rtc: int
|
||||||
|
rtr: int
|
||||||
|
ic: int
|
||||||
|
dv: int
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, dlci: int, fc: int, rtc: int, rtr: int, ic: int, dv: int
|
||||||
|
) -> None:
|
||||||
self.dlci = dlci
|
self.dlci = dlci
|
||||||
self.fc = fc
|
self.fc = fc
|
||||||
self.rtc = rtc
|
self.rtc = rtc
|
||||||
@@ -294,7 +386,7 @@ class RFCOMM_MCC_MSC:
|
|||||||
self.dv = dv
|
self.dv = dv
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(data):
|
def from_bytes(data: bytes) -> RFCOMM_MCC_MSC:
|
||||||
return RFCOMM_MCC_MSC(
|
return RFCOMM_MCC_MSC(
|
||||||
dlci=data[0] >> 2,
|
dlci=data[0] >> 2,
|
||||||
fc=data[1] >> 1 & 1,
|
fc=data[1] >> 1 & 1,
|
||||||
@@ -304,7 +396,7 @@ class RFCOMM_MCC_MSC:
|
|||||||
dv=data[1] >> 7 & 1,
|
dv=data[1] >> 7 & 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self) -> bytes:
|
||||||
return bytes(
|
return bytes(
|
||||||
[
|
[
|
||||||
(self.dlci << 2) | 3,
|
(self.dlci << 2) | 3,
|
||||||
@@ -317,7 +409,7 @@ class RFCOMM_MCC_MSC:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'MSC(dlci={self.dlci},'
|
f'MSC(dlci={self.dlci},'
|
||||||
f'fc={self.fc},'
|
f'fc={self.fc},'
|
||||||
@@ -330,24 +422,24 @@ class RFCOMM_MCC_MSC:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class DLC(EventEmitter):
|
class DLC(EventEmitter):
|
||||||
# States
|
class State(enum.IntEnum):
|
||||||
INIT = 0x00
|
INIT = 0x00
|
||||||
CONNECTING = 0x01
|
CONNECTING = 0x01
|
||||||
CONNECTED = 0x02
|
CONNECTED = 0x02
|
||||||
DISCONNECTING = 0x03
|
DISCONNECTING = 0x03
|
||||||
DISCONNECTED = 0x04
|
DISCONNECTED = 0x04
|
||||||
RESET = 0x05
|
RESET = 0x05
|
||||||
|
|
||||||
STATE_NAMES = {
|
connection_result: Optional[asyncio.Future]
|
||||||
INIT: 'INIT',
|
sink: Optional[Callable[[bytes], None]]
|
||||||
CONNECTING: 'CONNECTING',
|
|
||||||
CONNECTED: 'CONNECTED',
|
|
||||||
DISCONNECTING: 'DISCONNECTING',
|
|
||||||
DISCONNECTED: 'DISCONNECTED',
|
|
||||||
RESET: 'RESET',
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, multiplexer, dlci, max_frame_size, initial_tx_credits):
|
def __init__(
|
||||||
|
self,
|
||||||
|
multiplexer: Multiplexer,
|
||||||
|
dlci: int,
|
||||||
|
max_frame_size: int,
|
||||||
|
initial_tx_credits: int,
|
||||||
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.multiplexer = multiplexer
|
self.multiplexer = multiplexer
|
||||||
self.dlci = dlci
|
self.dlci = dlci
|
||||||
@@ -355,9 +447,9 @@ class DLC(EventEmitter):
|
|||||||
self.rx_threshold = self.rx_credits // 2
|
self.rx_threshold = self.rx_credits // 2
|
||||||
self.tx_credits = initial_tx_credits
|
self.tx_credits = initial_tx_credits
|
||||||
self.tx_buffer = b''
|
self.tx_buffer = b''
|
||||||
self.state = DLC.INIT
|
self.state = DLC.State.INIT
|
||||||
self.role = multiplexer.role
|
self.role = multiplexer.role
|
||||||
self.c_r = 1 if self.role == Multiplexer.INITIATOR else 0
|
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
|
||||||
self.sink = None
|
self.sink = None
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
|
|
||||||
@@ -367,25 +459,19 @@ class DLC(EventEmitter):
|
|||||||
max_frame_size, self.multiplexer.l2cap_channel.mtu - max_overhead
|
max_frame_size, self.multiplexer.l2cap_channel.mtu - max_overhead
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
def change_state(self, new_state: State) -> None:
|
||||||
def state_name(state):
|
logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}')
|
||||||
return DLC.STATE_NAMES[state]
|
|
||||||
|
|
||||||
def change_state(self, new_state):
|
|
||||||
logger.debug(
|
|
||||||
f'{self} state change -> {color(self.state_name(new_state), "magenta")}'
|
|
||||||
)
|
|
||||||
self.state = new_state
|
self.state = new_state
|
||||||
|
|
||||||
def send_frame(self, frame):
|
def send_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
self.multiplexer.send_frame(frame)
|
self.multiplexer.send_frame(frame)
|
||||||
|
|
||||||
def on_frame(self, frame):
|
def on_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
|
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
|
||||||
handler(frame)
|
handler(frame)
|
||||||
|
|
||||||
def on_sabm_frame(self, _frame):
|
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
if self.state != DLC.CONNECTING:
|
if self.state != DLC.State.CONNECTING:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
color('!!! received SABM when not in CONNECTING state', 'red')
|
color('!!! received SABM when not in CONNECTING state', 'red')
|
||||||
)
|
)
|
||||||
@@ -401,11 +487,11 @@ class DLC(EventEmitter):
|
|||||||
logger.debug(f'>>> MCC MSC Command: {msc}')
|
logger.debug(f'>>> MCC MSC Command: {msc}')
|
||||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||||
|
|
||||||
self.change_state(DLC.CONNECTED)
|
self.change_state(DLC.State.CONNECTED)
|
||||||
self.emit('open')
|
self.emit('open')
|
||||||
|
|
||||||
def on_ua_frame(self, _frame):
|
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
if self.state != DLC.CONNECTING:
|
if self.state != DLC.State.CONNECTING:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
color('!!! received SABM when not in CONNECTING state', 'red')
|
color('!!! received SABM when not in CONNECTING state', 'red')
|
||||||
)
|
)
|
||||||
@@ -419,18 +505,18 @@ class DLC(EventEmitter):
|
|||||||
logger.debug(f'>>> MCC MSC Command: {msc}')
|
logger.debug(f'>>> MCC MSC Command: {msc}')
|
||||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||||
|
|
||||||
self.change_state(DLC.CONNECTED)
|
self.change_state(DLC.State.CONNECTED)
|
||||||
self.multiplexer.on_dlc_open_complete(self)
|
self.multiplexer.on_dlc_open_complete(self)
|
||||||
|
|
||||||
def on_dm_frame(self, frame):
|
def on_dm_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
# TODO: handle all states
|
# TODO: handle all states
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_disc_frame(self, _frame):
|
def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
# TODO: handle all states
|
# TODO: handle all states
|
||||||
self.send_frame(RFCOMM_Frame.ua(c_r=1 - self.c_r, dlci=self.dlci))
|
self.send_frame(RFCOMM_Frame.ua(c_r=1 - self.c_r, dlci=self.dlci))
|
||||||
|
|
||||||
def on_uih_frame(self, frame):
|
def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
data = frame.information
|
data = frame.information
|
||||||
if frame.p_f == 1:
|
if frame.p_f == 1:
|
||||||
# With credits
|
# With credits
|
||||||
@@ -460,10 +546,10 @@ class DLC(EventEmitter):
|
|||||||
# Check if there's anything to send (including credits)
|
# Check if there's anything to send (including credits)
|
||||||
self.process_tx()
|
self.process_tx()
|
||||||
|
|
||||||
def on_ui_frame(self, frame):
|
def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_mcc_msc(self, c_r, msc):
|
def on_mcc_msc(self, c_r: bool, msc: RFCOMM_MCC_MSC) -> None:
|
||||||
if c_r:
|
if c_r:
|
||||||
# Command
|
# Command
|
||||||
logger.debug(f'<<< MCC MSC Command: {msc}')
|
logger.debug(f'<<< MCC MSC Command: {msc}')
|
||||||
@@ -477,16 +563,16 @@ class DLC(EventEmitter):
|
|||||||
# Response
|
# Response
|
||||||
logger.debug(f'<<< MCC MSC Response: {msc}')
|
logger.debug(f'<<< MCC MSC Response: {msc}')
|
||||||
|
|
||||||
def connect(self):
|
def connect(self) -> None:
|
||||||
if self.state != DLC.INIT:
|
if self.state != DLC.State.INIT:
|
||||||
raise InvalidStateError('invalid state')
|
raise InvalidStateError('invalid state')
|
||||||
|
|
||||||
self.change_state(DLC.CONNECTING)
|
self.change_state(DLC.State.CONNECTING)
|
||||||
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))
|
||||||
|
|
||||||
def accept(self):
|
def accept(self) -> None:
|
||||||
if self.state != DLC.INIT:
|
if self.state != DLC.State.INIT:
|
||||||
raise InvalidStateError('invalid state')
|
raise InvalidStateError('invalid state')
|
||||||
|
|
||||||
pn = RFCOMM_MCC_PN(
|
pn = RFCOMM_MCC_PN(
|
||||||
@@ -501,15 +587,15 @@ class DLC(EventEmitter):
|
|||||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=0, data=bytes(pn))
|
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=0, data=bytes(pn))
|
||||||
logger.debug(f'>>> PN Response: {pn}')
|
logger.debug(f'>>> PN Response: {pn}')
|
||||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||||
self.change_state(DLC.CONNECTING)
|
self.change_state(DLC.State.CONNECTING)
|
||||||
|
|
||||||
def rx_credits_needed(self):
|
def rx_credits_needed(self) -> int:
|
||||||
if self.rx_credits <= self.rx_threshold:
|
if self.rx_credits <= self.rx_threshold:
|
||||||
return RFCOMM_DEFAULT_INITIAL_RX_CREDITS - self.rx_credits
|
return RFCOMM_DEFAULT_INITIAL_RX_CREDITS - self.rx_credits
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def process_tx(self):
|
def process_tx(self) -> None:
|
||||||
# Send anything we can (or an empty frame if we need to send rx credits)
|
# Send anything we can (or an empty frame if we need to send rx credits)
|
||||||
rx_credits_needed = self.rx_credits_needed()
|
rx_credits_needed = self.rx_credits_needed()
|
||||||
while (self.tx_buffer and self.tx_credits > 0) or rx_credits_needed > 0:
|
while (self.tx_buffer and self.tx_credits > 0) or rx_credits_needed > 0:
|
||||||
@@ -547,7 +633,7 @@ class DLC(EventEmitter):
|
|||||||
rx_credits_needed = 0
|
rx_credits_needed = 0
|
||||||
|
|
||||||
# Stream protocol
|
# Stream protocol
|
||||||
def write(self, data):
|
def write(self, data: Union[bytes, str]) -> None:
|
||||||
# We can only send bytes
|
# We can only send bytes
|
||||||
if not isinstance(data, bytes):
|
if not isinstance(data, bytes):
|
||||||
if isinstance(data, str):
|
if isinstance(data, str):
|
||||||
@@ -559,44 +645,40 @@ class DLC(EventEmitter):
|
|||||||
self.tx_buffer += data
|
self.tx_buffer += data
|
||||||
self.process_tx()
|
self.process_tx()
|
||||||
|
|
||||||
def drain(self):
|
def drain(self) -> None:
|
||||||
# TODO
|
# TODO
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f'DLC(dlci={self.dlci},state={self.state_name(self.state)})'
|
return f'DLC(dlci={self.dlci},state={self.state.name})'
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Multiplexer(EventEmitter):
|
class Multiplexer(EventEmitter):
|
||||||
# Roles
|
class Role(enum.IntEnum):
|
||||||
INITIATOR = 0x00
|
INITIATOR = 0x00
|
||||||
RESPONDER = 0x01
|
RESPONDER = 0x01
|
||||||
|
|
||||||
# States
|
class State(enum.IntEnum):
|
||||||
INIT = 0x00
|
INIT = 0x00
|
||||||
CONNECTING = 0x01
|
CONNECTING = 0x01
|
||||||
CONNECTED = 0x02
|
CONNECTED = 0x02
|
||||||
OPENING = 0x03
|
OPENING = 0x03
|
||||||
DISCONNECTING = 0x04
|
DISCONNECTING = 0x04
|
||||||
DISCONNECTED = 0x05
|
DISCONNECTED = 0x05
|
||||||
RESET = 0x06
|
RESET = 0x06
|
||||||
|
|
||||||
STATE_NAMES = {
|
connection_result: Optional[asyncio.Future]
|
||||||
INIT: 'INIT',
|
disconnection_result: Optional[asyncio.Future]
|
||||||
CONNECTING: 'CONNECTING',
|
open_result: Optional[asyncio.Future]
|
||||||
CONNECTED: 'CONNECTED',
|
acceptor: Optional[Callable[[int], bool]]
|
||||||
OPENING: 'OPENING',
|
dlcs: Dict[int, DLC]
|
||||||
DISCONNECTING: 'DISCONNECTING',
|
|
||||||
DISCONNECTED: 'DISCONNECTED',
|
|
||||||
RESET: 'RESET',
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, l2cap_channel, role):
|
def __init__(self, l2cap_channel: l2cap.Channel, role: Role) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.role = role
|
self.role = role
|
||||||
self.l2cap_channel = l2cap_channel
|
self.l2cap_channel = l2cap_channel
|
||||||
self.state = Multiplexer.INIT
|
self.state = Multiplexer.State.INIT
|
||||||
self.dlcs = {} # DLCs, by DLCI
|
self.dlcs = {} # DLCs, by DLCI
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
self.disconnection_result = None
|
self.disconnection_result = None
|
||||||
@@ -606,21 +688,15 @@ class Multiplexer(EventEmitter):
|
|||||||
# 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
|
||||||
|
|
||||||
@staticmethod
|
def change_state(self, new_state: State) -> None:
|
||||||
def state_name(state):
|
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
|
||||||
return Multiplexer.STATE_NAMES[state]
|
|
||||||
|
|
||||||
def change_state(self, new_state):
|
|
||||||
logger.debug(
|
|
||||||
f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
|
|
||||||
)
|
|
||||||
self.state = new_state
|
self.state = new_state
|
||||||
|
|
||||||
def send_frame(self, frame):
|
def send_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
logger.debug(f'>>> Multiplexer sending {frame}')
|
logger.debug(f'>>> Multiplexer sending {frame}')
|
||||||
self.l2cap_channel.send_pdu(frame)
|
self.l2cap_channel.send_pdu(frame)
|
||||||
|
|
||||||
def on_pdu(self, pdu):
|
def on_pdu(self, pdu: bytes) -> None:
|
||||||
frame = RFCOMM_Frame.from_bytes(pdu)
|
frame = RFCOMM_Frame.from_bytes(pdu)
|
||||||
logger.debug(f'<<< Multiplexer received {frame}')
|
logger.debug(f'<<< Multiplexer received {frame}')
|
||||||
|
|
||||||
@@ -640,32 +716,32 @@ class Multiplexer(EventEmitter):
|
|||||||
return
|
return
|
||||||
dlc.on_frame(frame)
|
dlc.on_frame(frame)
|
||||||
|
|
||||||
def on_frame(self, frame):
|
def on_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
|
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
|
||||||
handler(frame)
|
handler(frame)
|
||||||
|
|
||||||
def on_sabm_frame(self, _frame):
|
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
if self.state != Multiplexer.INIT:
|
if self.state != Multiplexer.State.INIT:
|
||||||
logger.debug('not in INIT state, ignoring SABM')
|
logger.debug('not in INIT state, ignoring SABM')
|
||||||
return
|
return
|
||||||
self.change_state(Multiplexer.CONNECTED)
|
self.change_state(Multiplexer.State.CONNECTED)
|
||||||
self.send_frame(RFCOMM_Frame.ua(c_r=1, dlci=0))
|
self.send_frame(RFCOMM_Frame.ua(c_r=1, dlci=0))
|
||||||
|
|
||||||
def on_ua_frame(self, _frame):
|
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
if self.state == Multiplexer.CONNECTING:
|
if self.state == Multiplexer.State.CONNECTING:
|
||||||
self.change_state(Multiplexer.CONNECTED)
|
self.change_state(Multiplexer.State.CONNECTED)
|
||||||
if self.connection_result:
|
if self.connection_result:
|
||||||
self.connection_result.set_result(0)
|
self.connection_result.set_result(0)
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
elif self.state == Multiplexer.DISCONNECTING:
|
elif self.state == Multiplexer.State.DISCONNECTING:
|
||||||
self.change_state(Multiplexer.DISCONNECTED)
|
self.change_state(Multiplexer.State.DISCONNECTED)
|
||||||
if self.disconnection_result:
|
if self.disconnection_result:
|
||||||
self.disconnection_result.set_result(None)
|
self.disconnection_result.set_result(None)
|
||||||
self.disconnection_result = None
|
self.disconnection_result = None
|
||||||
|
|
||||||
def on_dm_frame(self, _frame):
|
def on_dm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
if self.state == Multiplexer.OPENING:
|
if self.state == Multiplexer.State.OPENING:
|
||||||
self.change_state(Multiplexer.CONNECTED)
|
self.change_state(Multiplexer.State.CONNECTED)
|
||||||
if self.open_result:
|
if self.open_result:
|
||||||
self.open_result.set_exception(
|
self.open_result.set_exception(
|
||||||
core.ConnectionError(
|
core.ConnectionError(
|
||||||
@@ -678,13 +754,15 @@ class Multiplexer(EventEmitter):
|
|||||||
else:
|
else:
|
||||||
logger.warning(f'unexpected state for DM: {self}')
|
logger.warning(f'unexpected state for DM: {self}')
|
||||||
|
|
||||||
def on_disc_frame(self, _frame):
|
def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
self.change_state(Multiplexer.DISCONNECTED)
|
self.change_state(Multiplexer.State.DISCONNECTED)
|
||||||
self.send_frame(
|
self.send_frame(
|
||||||
RFCOMM_Frame.ua(c_r=0 if self.role == Multiplexer.INITIATOR else 1, dlci=0)
|
RFCOMM_Frame.ua(
|
||||||
|
c_r=0 if self.role == Multiplexer.Role.INITIATOR else 1, dlci=0
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_uih_frame(self, frame):
|
def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
(mcc_type, c_r, value) = RFCOMM_Frame.parse_mcc(frame.information)
|
(mcc_type, c_r, value) = RFCOMM_Frame.parse_mcc(frame.information)
|
||||||
|
|
||||||
if mcc_type == RFCOMM_MCC_PN_TYPE:
|
if mcc_type == RFCOMM_MCC_PN_TYPE:
|
||||||
@@ -694,11 +772,11 @@ class Multiplexer(EventEmitter):
|
|||||||
mcs = RFCOMM_MCC_MSC.from_bytes(value)
|
mcs = RFCOMM_MCC_MSC.from_bytes(value)
|
||||||
self.on_mcc_msc(c_r, mcs)
|
self.on_mcc_msc(c_r, mcs)
|
||||||
|
|
||||||
def on_ui_frame(self, frame):
|
def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_mcc_pn(self, c_r, pn):
|
def on_mcc_pn(self, c_r: bool, pn: RFCOMM_MCC_PN) -> None:
|
||||||
if c_r == 1:
|
if c_r:
|
||||||
# Command
|
# Command
|
||||||
logger.debug(f'<<< PN Command: {pn}')
|
logger.debug(f'<<< PN Command: {pn}')
|
||||||
|
|
||||||
@@ -729,45 +807,45 @@ class Multiplexer(EventEmitter):
|
|||||||
else:
|
else:
|
||||||
# Response
|
# Response
|
||||||
logger.debug(f'>>> PN Response: {pn}')
|
logger.debug(f'>>> PN Response: {pn}')
|
||||||
if self.state == Multiplexer.OPENING:
|
if self.state == Multiplexer.State.OPENING:
|
||||||
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size)
|
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size)
|
||||||
self.dlcs[pn.dlci] = dlc
|
self.dlcs[pn.dlci] = dlc
|
||||||
dlc.connect()
|
dlc.connect()
|
||||||
else:
|
else:
|
||||||
logger.warning('ignoring PN response')
|
logger.warning('ignoring PN response')
|
||||||
|
|
||||||
def on_mcc_msc(self, c_r, msc):
|
def on_mcc_msc(self, c_r: bool, msc: RFCOMM_MCC_MSC) -> None:
|
||||||
dlc = self.dlcs.get(msc.dlci)
|
dlc = self.dlcs.get(msc.dlci)
|
||||||
if dlc is None:
|
if dlc is None:
|
||||||
logger.warning(f'no dlc for DLCI {msc.dlci}')
|
logger.warning(f'no dlc for DLCI {msc.dlci}')
|
||||||
return
|
return
|
||||||
dlc.on_mcc_msc(c_r, msc)
|
dlc.on_mcc_msc(c_r, msc)
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self) -> None:
|
||||||
if self.state != Multiplexer.INIT:
|
if self.state != Multiplexer.State.INIT:
|
||||||
raise InvalidStateError('invalid state')
|
raise InvalidStateError('invalid state')
|
||||||
|
|
||||||
self.change_state(Multiplexer.CONNECTING)
|
self.change_state(Multiplexer.State.CONNECTING)
|
||||||
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=1, dlci=0))
|
self.send_frame(RFCOMM_Frame.sabm(c_r=1, dlci=0))
|
||||||
return await self.connection_result
|
return await self.connection_result
|
||||||
|
|
||||||
async def disconnect(self):
|
async def disconnect(self) -> None:
|
||||||
if self.state != Multiplexer.CONNECTED:
|
if self.state != Multiplexer.State.CONNECTED:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.disconnection_result = asyncio.get_running_loop().create_future()
|
self.disconnection_result = asyncio.get_running_loop().create_future()
|
||||||
self.change_state(Multiplexer.DISCONNECTING)
|
self.change_state(Multiplexer.State.DISCONNECTING)
|
||||||
self.send_frame(
|
self.send_frame(
|
||||||
RFCOMM_Frame.disc(
|
RFCOMM_Frame.disc(
|
||||||
c_r=1 if self.role == Multiplexer.INITIATOR else 0, dlci=0
|
c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0, dlci=0
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await self.disconnection_result
|
await self.disconnection_result
|
||||||
|
|
||||||
async def open_dlc(self, channel):
|
async def open_dlc(self, channel: int) -> DLC:
|
||||||
if self.state != Multiplexer.CONNECTED:
|
if self.state != Multiplexer.State.CONNECTED:
|
||||||
if self.state == Multiplexer.OPENING:
|
if self.state == Multiplexer.State.OPENING:
|
||||||
raise InvalidStateError('open already in progress')
|
raise InvalidStateError('open already in progress')
|
||||||
|
|
||||||
raise InvalidStateError('not connected')
|
raise InvalidStateError('not connected')
|
||||||
@@ -784,10 +862,10 @@ class Multiplexer(EventEmitter):
|
|||||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=1, data=bytes(pn))
|
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=1, data=bytes(pn))
|
||||||
logger.debug(f'>>> Sending MCC: {pn}')
|
logger.debug(f'>>> Sending MCC: {pn}')
|
||||||
self.open_result = asyncio.get_running_loop().create_future()
|
self.open_result = asyncio.get_running_loop().create_future()
|
||||||
self.change_state(Multiplexer.OPENING)
|
self.change_state(Multiplexer.State.OPENING)
|
||||||
self.send_frame(
|
self.send_frame(
|
||||||
RFCOMM_Frame.uih(
|
RFCOMM_Frame.uih(
|
||||||
c_r=1 if self.role == Multiplexer.INITIATOR else 0,
|
c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0,
|
||||||
dlci=0,
|
dlci=0,
|
||||||
information=mcc,
|
information=mcc,
|
||||||
)
|
)
|
||||||
@@ -796,25 +874,28 @@ class Multiplexer(EventEmitter):
|
|||||||
self.open_result = None
|
self.open_result = None
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def on_dlc_open_complete(self, dlc):
|
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.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)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f'Multiplexer(state={self.state_name(self.state)})'
|
return f'Multiplexer(state={self.state.name})'
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Client:
|
class Client:
|
||||||
def __init__(self, device, connection):
|
multiplexer: Optional[Multiplexer]
|
||||||
|
l2cap_channel: Optional[l2cap.Channel]
|
||||||
|
|
||||||
|
def __init__(self, device: Device, connection: Connection) -> None:
|
||||||
self.device = device
|
self.device = device
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.l2cap_channel = None
|
self.l2cap_channel = None
|
||||||
self.multiplexer = None
|
self.multiplexer = None
|
||||||
|
|
||||||
async def start(self):
|
async def start(self) -> Multiplexer:
|
||||||
# Create a new L2CAP connection
|
# Create a new L2CAP connection
|
||||||
try:
|
try:
|
||||||
self.l2cap_channel = await self.device.l2cap_channel_manager.connect(
|
self.l2cap_channel = await self.device.l2cap_channel_manager.connect(
|
||||||
@@ -824,15 +905,18 @@ class Client:
|
|||||||
logger.warning(f'L2CAP connection failed: {error}')
|
logger.warning(f'L2CAP connection failed: {error}')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
assert self.l2cap_channel is not None
|
||||||
# Create a mutliplexer to manage DLCs with the server
|
# Create a mutliplexer to manage DLCs with the server
|
||||||
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.INITIATOR)
|
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.Role.INITIATOR)
|
||||||
|
|
||||||
# Connect the multiplexer
|
# Connect the multiplexer
|
||||||
await self.multiplexer.connect()
|
await self.multiplexer.connect()
|
||||||
|
|
||||||
return self.multiplexer
|
return self.multiplexer
|
||||||
|
|
||||||
async def shutdown(self):
|
async def shutdown(self) -> None:
|
||||||
|
if self.multiplexer is None:
|
||||||
|
return
|
||||||
# Disconnect the multiplexer
|
# Disconnect the multiplexer
|
||||||
await self.multiplexer.disconnect()
|
await self.multiplexer.disconnect()
|
||||||
self.multiplexer = None
|
self.multiplexer = None
|
||||||
@@ -843,7 +927,9 @@ class Client:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Server(EventEmitter):
|
class Server(EventEmitter):
|
||||||
def __init__(self, device):
|
acceptors: Dict[int, Callable[[DLC], None]]
|
||||||
|
|
||||||
|
def __init__(self, device: Device) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.device = device
|
self.device = device
|
||||||
self.multiplexer = None
|
self.multiplexer = None
|
||||||
@@ -852,7 +938,7 @@ class Server(EventEmitter):
|
|||||||
# Register ourselves with the L2CAP channel manager
|
# Register ourselves with the L2CAP channel manager
|
||||||
device.register_l2cap_server(RFCOMM_PSM, self.on_connection)
|
device.register_l2cap_server(RFCOMM_PSM, self.on_connection)
|
||||||
|
|
||||||
def listen(self, acceptor, channel=0):
|
def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int:
|
||||||
if channel:
|
if channel:
|
||||||
if channel in self.acceptors:
|
if channel in self.acceptors:
|
||||||
# Busy
|
# Busy
|
||||||
@@ -874,25 +960,25 @@ class Server(EventEmitter):
|
|||||||
self.acceptors[channel] = acceptor
|
self.acceptors[channel] = acceptor
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
def on_connection(self, l2cap_channel):
|
def on_connection(self, l2cap_channel: l2cap.Channel) -> None:
|
||||||
logger.debug(f'+++ new L2CAP connection: {l2cap_channel}')
|
logger.debug(f'+++ new L2CAP connection: {l2cap_channel}')
|
||||||
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel))
|
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel))
|
||||||
|
|
||||||
def on_l2cap_channel_open(self, l2cap_channel):
|
def on_l2cap_channel_open(self, l2cap_channel: l2cap.Channel) -> None:
|
||||||
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
|
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
|
||||||
|
|
||||||
# Create a new multiplexer for the channel
|
# Create a new multiplexer for the channel
|
||||||
multiplexer = Multiplexer(l2cap_channel, Multiplexer.RESPONDER)
|
multiplexer = Multiplexer(l2cap_channel, Multiplexer.Role.RESPONDER)
|
||||||
multiplexer.acceptor = self.accept_dlc
|
multiplexer.acceptor = self.accept_dlc
|
||||||
multiplexer.on('dlc', self.on_dlc)
|
multiplexer.on('dlc', self.on_dlc)
|
||||||
|
|
||||||
# Notify
|
# Notify
|
||||||
self.emit('start', multiplexer)
|
self.emit('start', multiplexer)
|
||||||
|
|
||||||
def accept_dlc(self, channel_number):
|
def accept_dlc(self, channel_number: int) -> bool:
|
||||||
return channel_number in self.acceptors
|
return channel_number in self.acceptors
|
||||||
|
|
||||||
def on_dlc(self, dlc):
|
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
|
||||||
|
|||||||
115
bumble/sdp.py
115
bumble/sdp.py
@@ -18,13 +18,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Dict, List, Type
|
from typing import Dict, List, Type, Optional, Tuple, Union, NewType, TYPE_CHECKING
|
||||||
|
|
||||||
from . import core
|
from . import core, l2cap
|
||||||
from .colors import color
|
from .colors import color
|
||||||
from .core import InvalidStateError
|
from .core import InvalidStateError
|
||||||
from .hci import HCI_Object, name_or_number, key_with_value
|
from .hci import HCI_Object, name_or_number, key_with_value
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .device import Device, Connection
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -94,6 +97,10 @@ SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID = 0X000B
|
|||||||
SDP_ICON_URL_ATTRIBUTE_ID = 0X000C
|
SDP_ICON_URL_ATTRIBUTE_ID = 0X000C
|
||||||
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
|
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
|
||||||
|
|
||||||
|
# Attribute Identifier (cf. Assigned Numbers for Service Discovery)
|
||||||
|
# used by AVRCP, HFP and A2DP
|
||||||
|
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID = 0x0311
|
||||||
|
|
||||||
SDP_ATTRIBUTE_ID_NAMES = {
|
SDP_ATTRIBUTE_ID_NAMES = {
|
||||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID: 'SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID',
|
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID: 'SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID',
|
||||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID: 'SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID',
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID: 'SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID',
|
||||||
@@ -462,7 +469,7 @@ class ServiceAttribute:
|
|||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list_from_data_elements(elements):
|
def list_from_data_elements(elements: List[DataElement]) -> List[ServiceAttribute]:
|
||||||
attribute_list = []
|
attribute_list = []
|
||||||
for i in range(0, len(elements) // 2):
|
for i in range(0, len(elements) // 2):
|
||||||
attribute_id, attribute_value = elements[2 * i : 2 * (i + 1)]
|
attribute_id, attribute_value = elements[2 * i : 2 * (i + 1)]
|
||||||
@@ -474,7 +481,9 @@ class ServiceAttribute:
|
|||||||
return attribute_list
|
return attribute_list
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_attribute_in_list(attribute_list, attribute_id):
|
def find_attribute_in_list(
|
||||||
|
attribute_list: List[ServiceAttribute], attribute_id: int
|
||||||
|
) -> Optional[DataElement]:
|
||||||
return next(
|
return next(
|
||||||
(
|
(
|
||||||
attribute.value
|
attribute.value
|
||||||
@@ -489,7 +498,7 @@ class ServiceAttribute:
|
|||||||
return name_or_number(SDP_ATTRIBUTE_ID_NAMES, id_code)
|
return name_or_number(SDP_ATTRIBUTE_ID_NAMES, id_code)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_uuid_in_value(uuid, value):
|
def is_uuid_in_value(uuid: core.UUID, value: DataElement) -> bool:
|
||||||
# Find if a uuid matches a value, either directly or recursing into sequences
|
# Find if a uuid matches a value, either directly or recursing into sequences
|
||||||
if value.type == DataElement.UUID:
|
if value.type == DataElement.UUID:
|
||||||
return value.value == uuid
|
return value.value == uuid
|
||||||
@@ -543,7 +552,9 @@ class SDP_PDU:
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_service_record_handle_list_preceded_by_count(data, offset):
|
def parse_service_record_handle_list_preceded_by_count(
|
||||||
|
data: bytes, offset: int
|
||||||
|
) -> Tuple[int, List[int]]:
|
||||||
count = struct.unpack_from('>H', data, offset - 2)[0]
|
count = struct.unpack_from('>H', data, offset - 2)[0]
|
||||||
handle_list = [
|
handle_list = [
|
||||||
struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
|
struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
|
||||||
@@ -641,6 +652,10 @@ class SDP_ServiceSearchRequest(SDP_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.5.1 SDP_ServiceSearchRequest PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.5.1 SDP_ServiceSearchRequest PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
service_search_pattern: DataElement
|
||||||
|
maximum_service_record_count: int
|
||||||
|
continuation_state: bytes
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass(
|
||||||
@@ -659,6 +674,11 @@ class SDP_ServiceSearchResponse(SDP_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
service_record_handle_list: List[int]
|
||||||
|
total_service_record_count: int
|
||||||
|
current_service_record_count: int
|
||||||
|
continuation_state: bytes
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass(
|
||||||
@@ -674,6 +694,11 @@ class SDP_ServiceAttributeRequest(SDP_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.6.1 SDP_ServiceAttributeRequest PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.6.1 SDP_ServiceAttributeRequest PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
service_record_handle: int
|
||||||
|
maximum_attribute_byte_count: int
|
||||||
|
attribute_id_list: DataElement
|
||||||
|
continuation_state: bytes
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass(
|
||||||
@@ -688,6 +713,10 @@ class SDP_ServiceAttributeResponse(SDP_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.6.2 SDP_ServiceAttributeResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.6.2 SDP_ServiceAttributeResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
attribute_list_byte_count: int
|
||||||
|
attribute_list: bytes
|
||||||
|
continuation_state: bytes
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass(
|
||||||
@@ -703,6 +732,11 @@ class SDP_ServiceSearchAttributeRequest(SDP_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.7.1 SDP_ServiceSearchAttributeRequest PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.7.1 SDP_ServiceSearchAttributeRequest PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
service_search_pattern: DataElement
|
||||||
|
maximum_attribute_byte_count: int
|
||||||
|
attribute_id_list: DataElement
|
||||||
|
continuation_state: bytes
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass(
|
||||||
@@ -717,26 +751,34 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
attribute_list_byte_count: int
|
||||||
|
attribute_list: bytes
|
||||||
|
continuation_state: bytes
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Client:
|
class Client:
|
||||||
def __init__(self, device):
|
channel: Optional[l2cap.Channel]
|
||||||
|
|
||||||
|
def __init__(self, device: Device) -> None:
|
||||||
self.device = device
|
self.device = device
|
||||||
self.pending_request = None
|
self.pending_request = None
|
||||||
self.channel = None
|
self.channel = None
|
||||||
|
|
||||||
async def connect(self, connection):
|
async def connect(self, connection: Connection) -> None:
|
||||||
result = await self.device.l2cap_channel_manager.connect(connection, SDP_PSM)
|
result = await self.device.l2cap_channel_manager.connect(connection, SDP_PSM)
|
||||||
self.channel = result
|
self.channel = result
|
||||||
|
|
||||||
async def disconnect(self):
|
async def disconnect(self) -> None:
|
||||||
if self.channel:
|
if self.channel:
|
||||||
await self.channel.disconnect()
|
await self.channel.disconnect()
|
||||||
self.channel = None
|
self.channel = None
|
||||||
|
|
||||||
async def search_services(self, uuids):
|
async def search_services(self, uuids: List[core.UUID]) -> List[int]:
|
||||||
if self.pending_request is not None:
|
if self.pending_request is not None:
|
||||||
raise InvalidStateError('request already pending')
|
raise InvalidStateError('request already pending')
|
||||||
|
if self.channel is None:
|
||||||
|
raise InvalidStateError('L2CAP not connected')
|
||||||
|
|
||||||
service_search_pattern = DataElement.sequence(
|
service_search_pattern = DataElement.sequence(
|
||||||
[DataElement.uuid(uuid) for uuid in uuids]
|
[DataElement.uuid(uuid) for uuid in uuids]
|
||||||
@@ -766,9 +808,13 @@ class Client:
|
|||||||
|
|
||||||
return service_record_handle_list
|
return service_record_handle_list
|
||||||
|
|
||||||
async def search_attributes(self, uuids, attribute_ids):
|
async def search_attributes(
|
||||||
|
self, uuids: List[core.UUID], attribute_ids: List[Union[int, Tuple[int, int]]]
|
||||||
|
) -> List[List[ServiceAttribute]]:
|
||||||
if self.pending_request is not None:
|
if self.pending_request is not None:
|
||||||
raise InvalidStateError('request already pending')
|
raise InvalidStateError('request already pending')
|
||||||
|
if self.channel is None:
|
||||||
|
raise InvalidStateError('L2CAP not connected')
|
||||||
|
|
||||||
service_search_pattern = DataElement.sequence(
|
service_search_pattern = DataElement.sequence(
|
||||||
[DataElement.uuid(uuid) for uuid in uuids]
|
[DataElement.uuid(uuid) for uuid in uuids]
|
||||||
@@ -819,9 +865,15 @@ class Client:
|
|||||||
if sequence.type == DataElement.SEQUENCE
|
if sequence.type == DataElement.SEQUENCE
|
||||||
]
|
]
|
||||||
|
|
||||||
async def get_attributes(self, service_record_handle, attribute_ids):
|
async def get_attributes(
|
||||||
|
self,
|
||||||
|
service_record_handle: int,
|
||||||
|
attribute_ids: List[Union[int, Tuple[int, int]]],
|
||||||
|
) -> List[ServiceAttribute]:
|
||||||
if self.pending_request is not None:
|
if self.pending_request is not None:
|
||||||
raise InvalidStateError('request already pending')
|
raise InvalidStateError('request already pending')
|
||||||
|
if self.channel is None:
|
||||||
|
raise InvalidStateError('L2CAP not connected')
|
||||||
|
|
||||||
attribute_id_list = DataElement.sequence(
|
attribute_id_list = DataElement.sequence(
|
||||||
[
|
[
|
||||||
@@ -869,21 +921,25 @@ class Client:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Server:
|
class Server:
|
||||||
CONTINUATION_STATE = bytes([0x01, 0x43])
|
CONTINUATION_STATE = bytes([0x01, 0x43])
|
||||||
|
channel: Optional[l2cap.Channel]
|
||||||
|
Service = NewType('Service', List[ServiceAttribute])
|
||||||
|
service_records: Dict[int, Service]
|
||||||
|
current_response: Union[None, bytes, Tuple[int, List[int]]]
|
||||||
|
|
||||||
def __init__(self, device):
|
def __init__(self, device: Device) -> None:
|
||||||
self.device = device
|
self.device = device
|
||||||
self.service_records = {} # Service records maps, by record handle
|
self.service_records = {} # Service records maps, by record handle
|
||||||
self.channel = None
|
self.channel = None
|
||||||
self.current_response = None
|
self.current_response = None
|
||||||
|
|
||||||
def register(self, l2cap_channel_manager):
|
def register(self, l2cap_channel_manager: l2cap.ChannelManager) -> None:
|
||||||
l2cap_channel_manager.register_server(SDP_PSM, self.on_connection)
|
l2cap_channel_manager.register_server(SDP_PSM, self.on_connection)
|
||||||
|
|
||||||
def send_response(self, response):
|
def send_response(self, response):
|
||||||
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
|
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
|
||||||
self.channel.send_pdu(response)
|
self.channel.send_pdu(response)
|
||||||
|
|
||||||
def match_services(self, search_pattern):
|
def match_services(self, search_pattern: DataElement) -> Dict[int, Service]:
|
||||||
# Find the services for which the attributes in the pattern is a subset of the
|
# Find the services for which the attributes in the pattern is a subset of the
|
||||||
# service's attribute values (NOTE: the value search recurses into sequences)
|
# service's attribute values (NOTE: the value search recurses into sequences)
|
||||||
matching_services = {}
|
matching_services = {}
|
||||||
@@ -953,7 +1009,9 @@ class Server:
|
|||||||
return (payload, continuation_state)
|
return (payload, continuation_state)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_service_attributes(service, attribute_ids):
|
def get_service_attributes(
|
||||||
|
service: Service, attribute_ids: List[DataElement]
|
||||||
|
) -> DataElement:
|
||||||
attributes = []
|
attributes = []
|
||||||
for attribute_id in attribute_ids:
|
for attribute_id in attribute_ids:
|
||||||
if attribute_id.value_size == 4:
|
if attribute_id.value_size == 4:
|
||||||
@@ -978,10 +1036,10 @@ class Server:
|
|||||||
|
|
||||||
return attribute_list
|
return attribute_list
|
||||||
|
|
||||||
def on_sdp_service_search_request(self, request):
|
def on_sdp_service_search_request(self, request: SDP_ServiceSearchRequest) -> None:
|
||||||
# Check if this is a continuation
|
# Check if this is a continuation
|
||||||
if len(request.continuation_state) > 1:
|
if len(request.continuation_state) > 1:
|
||||||
if not self.current_response:
|
if self.current_response is None:
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
@@ -1010,6 +1068,7 @@ class Server:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Respond, keeping any unsent handles for later
|
# Respond, keeping any unsent handles for later
|
||||||
|
assert isinstance(self.current_response, tuple)
|
||||||
service_record_handles = self.current_response[1][
|
service_record_handles = self.current_response[1][
|
||||||
: request.maximum_service_record_count
|
: request.maximum_service_record_count
|
||||||
]
|
]
|
||||||
@@ -1033,10 +1092,12 @@ class Server:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_sdp_service_attribute_request(self, request):
|
def on_sdp_service_attribute_request(
|
||||||
|
self, request: SDP_ServiceAttributeRequest
|
||||||
|
) -> None:
|
||||||
# Check if this is a continuation
|
# Check if this is a continuation
|
||||||
if len(request.continuation_state) > 1:
|
if len(request.continuation_state) > 1:
|
||||||
if not self.current_response:
|
if self.current_response is None:
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
@@ -1069,22 +1130,24 @@ class Server:
|
|||||||
self.current_response = bytes(attribute_list)
|
self.current_response = bytes(attribute_list)
|
||||||
|
|
||||||
# Respond, keeping any pending chunks for later
|
# Respond, keeping any pending chunks for later
|
||||||
attribute_list, continuation_state = self.get_next_response_payload(
|
attribute_list_response, continuation_state = self.get_next_response_payload(
|
||||||
request.maximum_attribute_byte_count
|
request.maximum_attribute_byte_count
|
||||||
)
|
)
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ServiceAttributeResponse(
|
SDP_ServiceAttributeResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
attribute_list_byte_count=len(attribute_list),
|
attribute_list_byte_count=len(attribute_list_response),
|
||||||
attribute_list=attribute_list,
|
attribute_list=attribute_list,
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_sdp_service_search_attribute_request(self, request):
|
def on_sdp_service_search_attribute_request(
|
||||||
|
self, request: SDP_ServiceSearchAttributeRequest
|
||||||
|
) -> None:
|
||||||
# Check if this is a continuation
|
# Check if this is a continuation
|
||||||
if len(request.continuation_state) > 1:
|
if len(request.continuation_state) > 1:
|
||||||
if not self.current_response:
|
if self.current_response is None:
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
@@ -1114,13 +1177,13 @@ class Server:
|
|||||||
self.current_response = bytes(attribute_lists)
|
self.current_response = bytes(attribute_lists)
|
||||||
|
|
||||||
# Respond, keeping any pending chunks for later
|
# Respond, keeping any pending chunks for later
|
||||||
attribute_lists, continuation_state = self.get_next_response_payload(
|
attribute_lists_response, continuation_state = self.get_next_response_payload(
|
||||||
request.maximum_attribute_byte_count
|
request.maximum_attribute_byte_count
|
||||||
)
|
)
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ServiceSearchAttributeResponse(
|
SDP_ServiceSearchAttributeResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
attribute_lists_byte_count=len(attribute_lists),
|
attribute_lists_byte_count=len(attribute_lists_response),
|
||||||
attribute_lists=attribute_lists,
|
attribute_lists=attribute_lists,
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
)
|
)
|
||||||
|
|||||||
222
bumble/smp.py
222
bumble/smp.py
@@ -25,6 +25,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import enum
|
||||||
import secrets
|
import secrets
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
@@ -553,20 +554,16 @@ class AddressResolver:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Session:
|
class PairingMethod(enum.IntEnum):
|
||||||
# Pairing methods
|
|
||||||
JUST_WORKS = 0
|
JUST_WORKS = 0
|
||||||
NUMERIC_COMPARISON = 1
|
NUMERIC_COMPARISON = 1
|
||||||
PASSKEY = 2
|
PASSKEY = 2
|
||||||
OOB = 3
|
OOB = 3
|
||||||
|
CTKD_OVER_CLASSIC = 4
|
||||||
|
|
||||||
PAIRING_METHOD_NAMES = {
|
|
||||||
JUST_WORKS: 'JUST_WORKS',
|
|
||||||
NUMERIC_COMPARISON: 'NUMERIC_COMPARISON',
|
|
||||||
PASSKEY: 'PASSKEY',
|
|
||||||
OOB: 'OOB',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Session:
|
||||||
# I/O Capability to pairing method decision matrix
|
# I/O Capability to pairing method decision matrix
|
||||||
#
|
#
|
||||||
# See Bluetooth spec @ Vol 3, part H - Table 2.8: Mapping of IO Capabilities to Key
|
# See Bluetooth spec @ Vol 3, part H - Table 2.8: Mapping of IO Capabilities to Key
|
||||||
@@ -581,47 +578,50 @@ class Session:
|
|||||||
# (False).
|
# (False).
|
||||||
PAIRING_METHODS = {
|
PAIRING_METHODS = {
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: {
|
SMP_DISPLAY_ONLY_IO_CAPABILITY: {
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
|
SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: JUST_WORKS,
|
SMP_DISPLAY_YES_NO_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
|
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
|
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PASSKEY, True, False),
|
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
|
||||||
},
|
},
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: {
|
SMP_DISPLAY_YES_NO_IO_CAPABILITY: {
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
|
SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (JUST_WORKS, NUMERIC_COMPARISON),
|
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
|
PairingMethod.JUST_WORKS,
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
),
|
||||||
|
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
|
||||||
|
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (
|
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (
|
||||||
(PASSKEY, True, False),
|
(PairingMethod.PASSKEY, True, False),
|
||||||
NUMERIC_COMPARISON,
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: {
|
SMP_KEYBOARD_ONLY_IO_CAPABILITY: {
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: (PASSKEY, False, True),
|
SMP_DISPLAY_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (PASSKEY, False, True),
|
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, False, False),
|
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, False),
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
|
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PASSKEY, False, True),
|
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
|
||||||
},
|
},
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: {
|
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: {
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
|
SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: JUST_WORKS,
|
SMP_DISPLAY_YES_NO_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: JUST_WORKS,
|
SMP_KEYBOARD_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
|
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: JUST_WORKS,
|
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
},
|
},
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: {
|
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: {
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: (PASSKEY, False, True),
|
SMP_DISPLAY_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (
|
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (
|
||||||
(PASSKEY, False, True),
|
(PairingMethod.PASSKEY, False, True),
|
||||||
NUMERIC_COMPARISON,
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
),
|
),
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
|
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
|
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (
|
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (
|
||||||
(PASSKEY, True, False),
|
(PairingMethod.PASSKEY, True, False),
|
||||||
NUMERIC_COMPARISON,
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -664,7 +664,7 @@ class Session:
|
|||||||
self.passkey_ready = asyncio.Event()
|
self.passkey_ready = asyncio.Event()
|
||||||
self.passkey_step = 0
|
self.passkey_step = 0
|
||||||
self.passkey_display = False
|
self.passkey_display = False
|
||||||
self.pairing_method = 0
|
self.pairing_method: PairingMethod = PairingMethod.JUST_WORKS
|
||||||
self.pairing_config = pairing_config
|
self.pairing_config = pairing_config
|
||||||
self.wait_before_continuing: Optional[asyncio.Future[None]] = None
|
self.wait_before_continuing: Optional[asyncio.Future[None]] = None
|
||||||
self.completed = False
|
self.completed = False
|
||||||
@@ -769,19 +769,23 @@ class Session:
|
|||||||
def decide_pairing_method(
|
def decide_pairing_method(
|
||||||
self, auth_req: int, initiator_io_capability: int, responder_io_capability: int
|
self, auth_req: int, initiator_io_capability: int, responder_io_capability: int
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if self.connection.transport == BT_BR_EDR_TRANSPORT:
|
||||||
|
self.pairing_method = PairingMethod.CTKD_OVER_CLASSIC
|
||||||
|
return
|
||||||
if (not self.mitm) and (auth_req & SMP_MITM_AUTHREQ == 0):
|
if (not self.mitm) and (auth_req & SMP_MITM_AUTHREQ == 0):
|
||||||
self.pairing_method = self.JUST_WORKS
|
self.pairing_method = PairingMethod.JUST_WORKS
|
||||||
return
|
return
|
||||||
|
|
||||||
details = self.PAIRING_METHODS[initiator_io_capability][responder_io_capability] # type: ignore[index]
|
details = self.PAIRING_METHODS[initiator_io_capability][responder_io_capability] # type: ignore[index]
|
||||||
if isinstance(details, tuple) and len(details) == 2:
|
if isinstance(details, tuple) and len(details) == 2:
|
||||||
# One entry for legacy pairing and one for secure connections
|
# One entry for legacy pairing and one for secure connections
|
||||||
details = details[1 if self.sc else 0]
|
details = details[1 if self.sc else 0]
|
||||||
if isinstance(details, int):
|
if isinstance(details, PairingMethod):
|
||||||
# Just a method ID
|
# Just a method ID
|
||||||
self.pairing_method = details
|
self.pairing_method = details
|
||||||
else:
|
else:
|
||||||
# PASSKEY method, with a method ID and display/input flags
|
# PASSKEY method, with a method ID and display/input flags
|
||||||
|
assert isinstance(details[0], PairingMethod)
|
||||||
self.pairing_method = details[0]
|
self.pairing_method = details[0]
|
||||||
self.passkey_display = details[1 if self.is_initiator else 2]
|
self.passkey_display = details[1 if self.is_initiator else 2]
|
||||||
|
|
||||||
@@ -858,10 +862,13 @@ class Session:
|
|||||||
self.tk = self.passkey.to_bytes(16, byteorder='little')
|
self.tk = self.passkey.to_bytes(16, byteorder='little')
|
||||||
logger.debug(f'TK from passkey = {self.tk.hex()}')
|
logger.debug(f'TK from passkey = {self.tk.hex()}')
|
||||||
|
|
||||||
self.connection.abort_on(
|
try:
|
||||||
'disconnection',
|
self.connection.abort_on(
|
||||||
self.pairing_config.delegate.display_number(self.passkey, digits=6),
|
'disconnection',
|
||||||
)
|
self.pairing_config.delegate.display_number(self.passkey, digits=6),
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning(f'exception while displaying number: {error}')
|
||||||
|
|
||||||
def input_passkey(self, next_steps: Optional[Callable[[], None]] = None) -> None:
|
def input_passkey(self, next_steps: Optional[Callable[[], None]] = None) -> None:
|
||||||
# Prompt the user for the passkey displayed on the peer
|
# Prompt the user for the passkey displayed on the peer
|
||||||
@@ -929,9 +936,12 @@ class Session:
|
|||||||
if self.sc:
|
if self.sc:
|
||||||
|
|
||||||
async def next_steps() -> None:
|
async def next_steps() -> None:
|
||||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
if self.pairing_method in (
|
||||||
|
PairingMethod.JUST_WORKS,
|
||||||
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
):
|
||||||
z = 0
|
z = 0
|
||||||
elif self.pairing_method == self.PASSKEY:
|
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||||
# We need a passkey
|
# We need a passkey
|
||||||
await self.passkey_ready.wait()
|
await self.passkey_ready.wait()
|
||||||
assert self.passkey
|
assert self.passkey
|
||||||
@@ -983,6 +993,19 @@ class Session:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def send_identity_address_command(self) -> None:
|
||||||
|
identity_address = {
|
||||||
|
None: self.connection.self_address,
|
||||||
|
Address.PUBLIC_DEVICE_ADDRESS: self.manager.device.public_address,
|
||||||
|
Address.RANDOM_DEVICE_ADDRESS: self.manager.device.random_address,
|
||||||
|
}[self.pairing_config.identity_address_type]
|
||||||
|
self.send_command(
|
||||||
|
SMP_Identity_Address_Information_Command(
|
||||||
|
addr_type=identity_address.address_type,
|
||||||
|
bd_addr=identity_address,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def start_encryption(self, key: bytes) -> None:
|
def start_encryption(self, key: bytes) -> None:
|
||||||
# We can now encrypt the connection with the short term key, so that we can
|
# We can now encrypt the connection with the short term key, so that we can
|
||||||
# distribute the long term and/or other keys over an encrypted connection
|
# distribute the long term and/or other keys over an encrypted connection
|
||||||
@@ -1006,6 +1029,7 @@ class Session:
|
|||||||
self.ltk = crypto.h6(ilk, b'brle')
|
self.ltk = crypto.h6(ilk, b'brle')
|
||||||
|
|
||||||
def distribute_keys(self) -> None:
|
def distribute_keys(self) -> None:
|
||||||
|
|
||||||
# Distribute the keys as required
|
# Distribute the keys as required
|
||||||
if self.is_initiator:
|
if self.is_initiator:
|
||||||
# CTKD: Derive LTK from LinkKey
|
# CTKD: Derive LTK from LinkKey
|
||||||
@@ -1035,12 +1059,7 @@ class Session:
|
|||||||
identity_resolving_key=self.manager.device.irk
|
identity_resolving_key=self.manager.device.irk
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.send_command(
|
self.send_identity_address_command()
|
||||||
SMP_Identity_Address_Information_Command(
|
|
||||||
addr_type=self.connection.self_address.address_type,
|
|
||||||
bd_addr=self.connection.self_address,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Distribute CSRK
|
# Distribute CSRK
|
||||||
csrk = bytes(16) # FIXME: testing
|
csrk = bytes(16) # FIXME: testing
|
||||||
@@ -1084,12 +1103,7 @@ class Session:
|
|||||||
identity_resolving_key=self.manager.device.irk
|
identity_resolving_key=self.manager.device.irk
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.send_command(
|
self.send_identity_address_command()
|
||||||
SMP_Identity_Address_Information_Command(
|
|
||||||
addr_type=self.connection.self_address.address_type,
|
|
||||||
bd_addr=self.connection.self_address,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Distribute CSRK
|
# Distribute CSRK
|
||||||
csrk = bytes(16) # FIXME: testing
|
csrk = bytes(16) # FIXME: testing
|
||||||
@@ -1224,7 +1238,7 @@ class Session:
|
|||||||
# Create an object to hold the keys
|
# Create an object to hold the keys
|
||||||
keys = PairingKeys()
|
keys = PairingKeys()
|
||||||
keys.address_type = peer_address.address_type
|
keys.address_type = peer_address.address_type
|
||||||
authenticated = self.pairing_method != self.JUST_WORKS
|
authenticated = self.pairing_method != PairingMethod.JUST_WORKS
|
||||||
if self.sc or self.connection.transport == BT_BR_EDR_TRANSPORT:
|
if self.sc or self.connection.transport == BT_BR_EDR_TRANSPORT:
|
||||||
keys.ltk = PairingKeys.Key(value=self.ltk, authenticated=authenticated)
|
keys.ltk = PairingKeys.Key(value=self.ltk, authenticated=authenticated)
|
||||||
else:
|
else:
|
||||||
@@ -1258,7 +1272,7 @@ class Session:
|
|||||||
keys.link_key = PairingKeys.Key(
|
keys.link_key = PairingKeys.Key(
|
||||||
value=self.link_key, authenticated=authenticated
|
value=self.link_key, authenticated=authenticated
|
||||||
)
|
)
|
||||||
self.manager.on_pairing(self, peer_address, keys)
|
await self.manager.on_pairing(self, peer_address, keys)
|
||||||
|
|
||||||
def on_pairing_failure(self, reason: int) -> None:
|
def on_pairing_failure(self, reason: int) -> None:
|
||||||
logger.warning(f'pairing failure ({error_name(reason)})')
|
logger.warning(f'pairing failure ({error_name(reason)})')
|
||||||
@@ -1300,7 +1314,11 @@ class Session:
|
|||||||
self, command: SMP_Pairing_Request_Command
|
self, command: SMP_Pairing_Request_Command
|
||||||
) -> None:
|
) -> None:
|
||||||
# Check if the request should proceed
|
# Check if the request should proceed
|
||||||
accepted = await self.pairing_config.delegate.accept()
|
try:
|
||||||
|
accepted = await self.pairing_config.delegate.accept()
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning(f'exception while accepting: {error}')
|
||||||
|
accepted = False
|
||||||
if not accepted:
|
if not accepted:
|
||||||
logger.debug('pairing rejected by delegate')
|
logger.debug('pairing rejected by delegate')
|
||||||
self.send_pairing_failed(SMP_PAIRING_NOT_SUPPORTED_ERROR)
|
self.send_pairing_failed(SMP_PAIRING_NOT_SUPPORTED_ERROR)
|
||||||
@@ -1323,9 +1341,7 @@ class Session:
|
|||||||
self.decide_pairing_method(
|
self.decide_pairing_method(
|
||||||
command.auth_req, command.io_capability, self.io_capability
|
command.auth_req, command.io_capability, self.io_capability
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(f'pairing method: {self.pairing_method.name}')
|
||||||
f'pairing method: {self.PAIRING_METHOD_NAMES[self.pairing_method]}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Key distribution
|
# Key distribution
|
||||||
(
|
(
|
||||||
@@ -1341,7 +1357,7 @@ class Session:
|
|||||||
|
|
||||||
# Display a passkey if we need to
|
# Display a passkey if we need to
|
||||||
if not self.sc:
|
if not self.sc:
|
||||||
if self.pairing_method == self.PASSKEY and self.passkey_display:
|
if self.pairing_method == PairingMethod.PASSKEY and self.passkey_display:
|
||||||
self.display_passkey()
|
self.display_passkey()
|
||||||
|
|
||||||
# Respond
|
# Respond
|
||||||
@@ -1382,9 +1398,7 @@ class Session:
|
|||||||
self.decide_pairing_method(
|
self.decide_pairing_method(
|
||||||
command.auth_req, self.io_capability, command.io_capability
|
command.auth_req, self.io_capability, command.io_capability
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(f'pairing method: {self.pairing_method.name}')
|
||||||
f'pairing method: {self.PAIRING_METHOD_NAMES[self.pairing_method]}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Key distribution
|
# Key distribution
|
||||||
if (
|
if (
|
||||||
@@ -1400,13 +1414,16 @@ class Session:
|
|||||||
self.compute_peer_expected_distributions(self.responder_key_distribution)
|
self.compute_peer_expected_distributions(self.responder_key_distribution)
|
||||||
|
|
||||||
# Start phase 2
|
# Start phase 2
|
||||||
if self.sc:
|
if self.pairing_method == PairingMethod.CTKD_OVER_CLASSIC:
|
||||||
if self.pairing_method == self.PASSKEY:
|
# Authentication is already done in SMP, so remote shall start keys distribution immediately
|
||||||
|
return
|
||||||
|
elif self.sc:
|
||||||
|
if self.pairing_method == PairingMethod.PASSKEY:
|
||||||
self.display_or_input_passkey()
|
self.display_or_input_passkey()
|
||||||
|
|
||||||
self.send_public_key_command()
|
self.send_public_key_command()
|
||||||
else:
|
else:
|
||||||
if self.pairing_method == self.PASSKEY:
|
if self.pairing_method == PairingMethod.PASSKEY:
|
||||||
self.display_or_input_passkey(self.send_pairing_confirm_command)
|
self.display_or_input_passkey(self.send_pairing_confirm_command)
|
||||||
else:
|
else:
|
||||||
self.send_pairing_confirm_command()
|
self.send_pairing_confirm_command()
|
||||||
@@ -1418,7 +1435,10 @@ class Session:
|
|||||||
self.send_pairing_random_command()
|
self.send_pairing_random_command()
|
||||||
else:
|
else:
|
||||||
# If the method is PASSKEY, now is the time to input the code
|
# If the method is PASSKEY, now is the time to input the code
|
||||||
if self.pairing_method == self.PASSKEY and not self.passkey_display:
|
if (
|
||||||
|
self.pairing_method == PairingMethod.PASSKEY
|
||||||
|
and not self.passkey_display
|
||||||
|
):
|
||||||
self.input_passkey(self.send_pairing_confirm_command)
|
self.input_passkey(self.send_pairing_confirm_command)
|
||||||
else:
|
else:
|
||||||
self.send_pairing_confirm_command()
|
self.send_pairing_confirm_command()
|
||||||
@@ -1426,11 +1446,14 @@ class Session:
|
|||||||
def on_smp_pairing_confirm_command_secure_connections(
|
def on_smp_pairing_confirm_command_secure_connections(
|
||||||
self, _: SMP_Pairing_Confirm_Command
|
self, _: SMP_Pairing_Confirm_Command
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
if self.pairing_method in (
|
||||||
|
PairingMethod.JUST_WORKS,
|
||||||
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
):
|
||||||
if self.is_initiator:
|
if self.is_initiator:
|
||||||
self.r = crypto.r()
|
self.r = crypto.r()
|
||||||
self.send_pairing_random_command()
|
self.send_pairing_random_command()
|
||||||
elif self.pairing_method == self.PASSKEY:
|
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||||
if self.is_initiator:
|
if self.is_initiator:
|
||||||
self.send_pairing_random_command()
|
self.send_pairing_random_command()
|
||||||
else:
|
else:
|
||||||
@@ -1486,13 +1509,16 @@ class Session:
|
|||||||
def on_smp_pairing_random_command_secure_connections(
|
def on_smp_pairing_random_command_secure_connections(
|
||||||
self, command: SMP_Pairing_Random_Command
|
self, command: SMP_Pairing_Random_Command
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.pairing_method == self.PASSKEY and self.passkey is None:
|
if self.pairing_method == PairingMethod.PASSKEY and self.passkey is None:
|
||||||
logger.warning('no passkey entered, ignoring command')
|
logger.warning('no passkey entered, ignoring command')
|
||||||
return
|
return
|
||||||
|
|
||||||
# pylint: disable=too-many-return-statements
|
# pylint: disable=too-many-return-statements
|
||||||
if self.is_initiator:
|
if self.is_initiator:
|
||||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
if self.pairing_method in (
|
||||||
|
PairingMethod.JUST_WORKS,
|
||||||
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
):
|
||||||
assert self.confirm_value
|
assert self.confirm_value
|
||||||
# Check that the random value matches what was committed to earlier
|
# Check that the random value matches what was committed to earlier
|
||||||
confirm_verifier = crypto.f4(
|
confirm_verifier = crypto.f4(
|
||||||
@@ -1502,7 +1528,7 @@ class Session:
|
|||||||
self.confirm_value, confirm_verifier, SMP_CONFIRM_VALUE_FAILED_ERROR
|
self.confirm_value, confirm_verifier, SMP_CONFIRM_VALUE_FAILED_ERROR
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
elif self.pairing_method == self.PASSKEY:
|
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||||
assert self.passkey and self.confirm_value
|
assert self.passkey and self.confirm_value
|
||||||
# Check that the random value matches what was committed to earlier
|
# Check that the random value matches what was committed to earlier
|
||||||
confirm_verifier = crypto.f4(
|
confirm_verifier = crypto.f4(
|
||||||
@@ -1525,9 +1551,12 @@ class Session:
|
|||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
if self.pairing_method in (
|
||||||
|
PairingMethod.JUST_WORKS,
|
||||||
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
):
|
||||||
self.send_pairing_random_command()
|
self.send_pairing_random_command()
|
||||||
elif self.pairing_method == self.PASSKEY:
|
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||||
assert self.passkey and self.confirm_value
|
assert self.passkey and self.confirm_value
|
||||||
# Check that the random value matches what was committed to earlier
|
# Check that the random value matches what was committed to earlier
|
||||||
confirm_verifier = crypto.f4(
|
confirm_verifier = crypto.f4(
|
||||||
@@ -1558,10 +1587,13 @@ class Session:
|
|||||||
(mac_key, self.ltk) = crypto.f5(self.dh_key, self.na, self.nb, a, b)
|
(mac_key, self.ltk) = crypto.f5(self.dh_key, self.na, self.nb, a, b)
|
||||||
|
|
||||||
# Compute the DH Key checks
|
# Compute the DH Key checks
|
||||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
if self.pairing_method in (
|
||||||
|
PairingMethod.JUST_WORKS,
|
||||||
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
):
|
||||||
ra = bytes(16)
|
ra = bytes(16)
|
||||||
rb = ra
|
rb = ra
|
||||||
elif self.pairing_method == self.PASSKEY:
|
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||||
assert self.passkey
|
assert self.passkey
|
||||||
ra = self.passkey.to_bytes(16, byteorder='little')
|
ra = self.passkey.to_bytes(16, byteorder='little')
|
||||||
rb = ra
|
rb = ra
|
||||||
@@ -1585,13 +1617,16 @@ class Session:
|
|||||||
self.wait_before_continuing.set_result(None)
|
self.wait_before_continuing.set_result(None)
|
||||||
|
|
||||||
# Prompt the user for confirmation if needed
|
# Prompt the user for confirmation if needed
|
||||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
if self.pairing_method in (
|
||||||
|
PairingMethod.JUST_WORKS,
|
||||||
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
):
|
||||||
# Compute the 6-digit code
|
# Compute the 6-digit code
|
||||||
code = crypto.g2(self.pka, self.pkb, self.na, self.nb) % 1000000
|
code = crypto.g2(self.pka, self.pkb, self.na, self.nb) % 1000000
|
||||||
|
|
||||||
# Ask for user confirmation
|
# Ask for user confirmation
|
||||||
self.wait_before_continuing = asyncio.get_running_loop().create_future()
|
self.wait_before_continuing = asyncio.get_running_loop().create_future()
|
||||||
if self.pairing_method == self.JUST_WORKS:
|
if self.pairing_method == PairingMethod.JUST_WORKS:
|
||||||
self.prompt_user_for_confirmation(next_steps)
|
self.prompt_user_for_confirmation(next_steps)
|
||||||
else:
|
else:
|
||||||
self.prompt_user_for_numeric_comparison(code, next_steps)
|
self.prompt_user_for_numeric_comparison(code, next_steps)
|
||||||
@@ -1628,13 +1663,16 @@ class Session:
|
|||||||
if self.is_initiator:
|
if self.is_initiator:
|
||||||
self.send_pairing_confirm_command()
|
self.send_pairing_confirm_command()
|
||||||
else:
|
else:
|
||||||
if self.pairing_method == self.PASSKEY:
|
if self.pairing_method == PairingMethod.PASSKEY:
|
||||||
self.display_or_input_passkey()
|
self.display_or_input_passkey()
|
||||||
|
|
||||||
# Send our public key back to the initiator
|
# Send our public key back to the initiator
|
||||||
self.send_public_key_command()
|
self.send_public_key_command()
|
||||||
|
|
||||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
if self.pairing_method in (
|
||||||
|
PairingMethod.JUST_WORKS,
|
||||||
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
):
|
||||||
# We can now send the confirmation value
|
# We can now send the confirmation value
|
||||||
self.send_pairing_confirm_command()
|
self.send_pairing_confirm_command()
|
||||||
|
|
||||||
@@ -1789,23 +1827,17 @@ class Manager(EventEmitter):
|
|||||||
def on_session_start(self, session: Session) -> None:
|
def on_session_start(self, session: Session) -> None:
|
||||||
self.device.on_pairing_start(session.connection)
|
self.device.on_pairing_start(session.connection)
|
||||||
|
|
||||||
def on_pairing(
|
async def on_pairing(
|
||||||
self, session: Session, identity_address: Optional[Address], keys: PairingKeys
|
self, session: Session, identity_address: Optional[Address], keys: PairingKeys
|
||||||
) -> None:
|
) -> None:
|
||||||
# Store the keys in the key store
|
# Store the keys in the key store
|
||||||
if self.device.keystore and identity_address is not None:
|
if self.device.keystore and identity_address is not None:
|
||||||
|
self.device.abort_on(
|
||||||
async def store_keys():
|
'flush', self.device.update_keys(str(identity_address), keys)
|
||||||
try:
|
)
|
||||||
assert self.device.keystore
|
|
||||||
await self.device.keystore.update(str(identity_address), keys)
|
|
||||||
except Exception as error:
|
|
||||||
logger.warning(f'!!! error while storing keys: {error}')
|
|
||||||
|
|
||||||
self.device.abort_on('flush', store_keys())
|
|
||||||
|
|
||||||
# Notify the device
|
# Notify the device
|
||||||
self.device.on_pairing(session.connection, keys, session.sc)
|
self.device.on_pairing(session.connection, identity_address, keys, session.sc)
|
||||||
|
|
||||||
def on_pairing_failure(self, session: Session, reason: int) -> None:
|
def on_pairing_failure(self, session: Session, reason: int) -> None:
|
||||||
self.device.on_pairing_failure(session.connection, reason)
|
self.device.on_pairing_failure(session.connection, reason)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from .common import Transport, AsyncPipeSink, SnoopingTransport
|
from .common import Transport, AsyncPipeSink, SnoopingTransport
|
||||||
from ..controller import Controller
|
|
||||||
from ..snoop import create_snooper
|
from ..snoop import create_snooper
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -69,6 +68,7 @@ async def open_transport(name: str) -> Transport:
|
|||||||
* usb
|
* usb
|
||||||
* pyusb
|
* pyusb
|
||||||
* android-emulator
|
* android-emulator
|
||||||
|
* android-netsim
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return _wrap_transport(await _open_transport(name))
|
return _wrap_transport(await _open_transport(name))
|
||||||
@@ -118,7 +118,8 @@ async def _open_transport(name: str) -> Transport:
|
|||||||
if scheme == 'file':
|
if scheme == 'file':
|
||||||
from .file import open_file_transport
|
from .file import open_file_transport
|
||||||
|
|
||||||
return await open_file_transport(spec[0] if spec else None)
|
assert spec is not None
|
||||||
|
return await open_file_transport(spec[0])
|
||||||
|
|
||||||
if scheme == 'vhci':
|
if scheme == 'vhci':
|
||||||
from .vhci import open_vhci_transport
|
from .vhci import open_vhci_transport
|
||||||
@@ -133,12 +134,14 @@ async def _open_transport(name: str) -> Transport:
|
|||||||
if scheme == 'usb':
|
if scheme == 'usb':
|
||||||
from .usb import open_usb_transport
|
from .usb import open_usb_transport
|
||||||
|
|
||||||
return await open_usb_transport(spec[0] if spec else None)
|
assert spec is not None
|
||||||
|
return await open_usb_transport(spec[0])
|
||||||
|
|
||||||
if scheme == 'pyusb':
|
if scheme == 'pyusb':
|
||||||
from .pyusb import open_pyusb_transport
|
from .pyusb import open_pyusb_transport
|
||||||
|
|
||||||
return await open_pyusb_transport(spec[0] if spec else None)
|
assert spec is not None
|
||||||
|
return await open_pyusb_transport(spec[0])
|
||||||
|
|
||||||
if scheme == 'android-emulator':
|
if scheme == 'android-emulator':
|
||||||
from .android_emulator import open_android_emulator_transport
|
from .android_emulator import open_android_emulator_transport
|
||||||
@@ -167,6 +170,7 @@ async def open_transport_or_link(name: str) -> Transport:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
if name.startswith('link-relay:'):
|
if name.startswith('link-relay:'):
|
||||||
|
from ..controller import Controller
|
||||||
from ..link import RemoteLink # lazy import
|
from ..link import RemoteLink # lazy import
|
||||||
|
|
||||||
link = RemoteLink(name[11:])
|
link = RemoteLink(name[11:])
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import grpc.aio
|
import grpc.aio
|
||||||
|
|
||||||
from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink
|
from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink, Transport
|
||||||
|
|
||||||
# pylint: disable=no-name-in-module
|
# pylint: disable=no-name-in-module
|
||||||
from .grpc_protobuf.emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
|
from .grpc_protobuf.emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
|
||||||
@@ -33,7 +33,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_android_emulator_transport(spec):
|
async def open_android_emulator_transport(spec: str | None) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a transport connection to an Android emulator via its gRPC interface.
|
Open a transport connection to an Android emulator via its gRPC interface.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
@@ -66,7 +66,7 @@ async def open_android_emulator_transport(spec):
|
|||||||
# Parse the parameters
|
# Parse the parameters
|
||||||
mode = 'host'
|
mode = 'host'
|
||||||
server_host = 'localhost'
|
server_host = 'localhost'
|
||||||
server_port = 8554
|
server_port = '8554'
|
||||||
if spec is not None:
|
if spec is not None:
|
||||||
params = spec.split(',')
|
params = spec.split(',')
|
||||||
for param in params:
|
for param in params:
|
||||||
@@ -82,6 +82,7 @@ async def open_android_emulator_transport(spec):
|
|||||||
logger.debug(f'connecting to gRPC server at {server_address}')
|
logger.debug(f'connecting to gRPC server at {server_address}')
|
||||||
channel = grpc.aio.insecure_channel(server_address)
|
channel = grpc.aio.insecure_channel(server_address)
|
||||||
|
|
||||||
|
service: EmulatedBluetoothServiceStub | VhciForwardingServiceStub
|
||||||
if mode == 'host':
|
if mode == 'host':
|
||||||
# Connect as a host
|
# Connect as a host
|
||||||
service = EmulatedBluetoothServiceStub(channel)
|
service = EmulatedBluetoothServiceStub(channel)
|
||||||
|
|||||||
@@ -121,7 +121,9 @@ def publish_grpc_port(grpc_port) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_android_netsim_controller_transport(server_host, server_port):
|
async def open_android_netsim_controller_transport(
|
||||||
|
server_host: str | None, server_port: int
|
||||||
|
) -> Transport:
|
||||||
if not server_port:
|
if not server_port:
|
||||||
raise ValueError('invalid port')
|
raise ValueError('invalid port')
|
||||||
if server_host == '_' or not server_host:
|
if server_host == '_' or not server_host:
|
||||||
|
|||||||
@@ -20,11 +20,12 @@ import contextlib
|
|||||||
import struct
|
import struct
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import ContextManager
|
import io
|
||||||
|
from typing import ContextManager, Tuple, Optional, Protocol, Dict
|
||||||
|
|
||||||
from .. import hci
|
from bumble import hci
|
||||||
from ..colors import color
|
from bumble.colors import color
|
||||||
from ..snoop import Snooper
|
from bumble.snoop import Snooper
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -36,7 +37,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Information needed to parse HCI packets with a generic parser:
|
# Information needed to parse HCI packets with a generic parser:
|
||||||
# For each packet type, the info represents:
|
# For each packet type, the info represents:
|
||||||
# (length-size, length-offset, unpack-type)
|
# (length-size, length-offset, unpack-type)
|
||||||
HCI_PACKET_INFO = {
|
HCI_PACKET_INFO: Dict[int, Tuple[int, int, str]] = {
|
||||||
hci.HCI_COMMAND_PACKET: (1, 2, 'B'),
|
hci.HCI_COMMAND_PACKET: (1, 2, 'B'),
|
||||||
hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'),
|
hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'),
|
||||||
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
|
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
|
||||||
@@ -45,33 +46,54 @@ HCI_PACKET_INFO = {
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PacketPump:
|
# Errors
|
||||||
'''
|
# -----------------------------------------------------------------------------
|
||||||
Pump HCI packets from a reader to a sink
|
class TransportLostError(Exception):
|
||||||
'''
|
"""
|
||||||
|
The Transport has been lost/disconnected.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, reader, sink):
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Typing Protocols
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class TransportSink(Protocol):
|
||||||
|
def on_packet(self, packet: bytes) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class TransportSource(Protocol):
|
||||||
|
terminated: asyncio.Future[None]
|
||||||
|
|
||||||
|
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class PacketPump:
|
||||||
|
"""
|
||||||
|
Pump HCI packets from a reader to a sink.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, reader: AsyncPacketReader, sink: TransportSink) -> None:
|
||||||
self.reader = reader
|
self.reader = reader
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
|
|
||||||
async def run(self):
|
async def run(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Get a packet from the source
|
|
||||||
packet = hci.HCI_Packet.from_bytes(await self.reader.next_packet())
|
|
||||||
|
|
||||||
# Deliver the packet to the sink
|
# Deliver the packet to the sink
|
||||||
self.sink.on_packet(packet)
|
self.sink.on_packet(await self.reader.next_packet())
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(f'!!! {error}')
|
logger.warning(f'!!! {error}')
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PacketParser:
|
class PacketParser:
|
||||||
'''
|
"""
|
||||||
In-line parser that accepts data and emits 'on_packet' when a full packet has been
|
In-line parser that accepts data and emits 'on_packet' when a full packet has been
|
||||||
parsed
|
parsed.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
# pylint: disable=attribute-defined-outside-init
|
# pylint: disable=attribute-defined-outside-init
|
||||||
|
|
||||||
@@ -79,18 +101,22 @@ class PacketParser:
|
|||||||
NEED_LENGTH = 1
|
NEED_LENGTH = 1
|
||||||
NEED_BODY = 2
|
NEED_BODY = 2
|
||||||
|
|
||||||
def __init__(self, sink=None):
|
sink: Optional[TransportSink]
|
||||||
|
extended_packet_info: Dict[int, Tuple[int, int, str]]
|
||||||
|
packet_info: Optional[Tuple[int, int, str]] = None
|
||||||
|
|
||||||
|
def __init__(self, sink: Optional[TransportSink] = None) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
self.extended_packet_info = {}
|
self.extended_packet_info = {}
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
def reset(self):
|
def reset(self) -> None:
|
||||||
self.state = PacketParser.NEED_TYPE
|
self.state = PacketParser.NEED_TYPE
|
||||||
self.bytes_needed = 1
|
self.bytes_needed = 1
|
||||||
self.packet = bytearray()
|
self.packet = bytearray()
|
||||||
self.packet_info = None
|
self.packet_info = None
|
||||||
|
|
||||||
def feed_data(self, data):
|
def feed_data(self, data: bytes) -> None:
|
||||||
data_offset = 0
|
data_offset = 0
|
||||||
data_left = len(data)
|
data_left = len(data)
|
||||||
while data_left and self.bytes_needed:
|
while data_left and self.bytes_needed:
|
||||||
@@ -111,6 +137,7 @@ class PacketParser:
|
|||||||
self.state = PacketParser.NEED_LENGTH
|
self.state = PacketParser.NEED_LENGTH
|
||||||
self.bytes_needed = self.packet_info[0] + self.packet_info[1]
|
self.bytes_needed = self.packet_info[0] + self.packet_info[1]
|
||||||
elif self.state == PacketParser.NEED_LENGTH:
|
elif self.state == PacketParser.NEED_LENGTH:
|
||||||
|
assert self.packet_info is not None
|
||||||
body_length = struct.unpack_from(
|
body_length = struct.unpack_from(
|
||||||
self.packet_info[2], self.packet, 1 + self.packet_info[1]
|
self.packet_info[2], self.packet, 1 + self.packet_info[1]
|
||||||
)[0]
|
)[0]
|
||||||
@@ -128,20 +155,20 @@ class PacketParser:
|
|||||||
)
|
)
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
def set_packet_sink(self, sink):
|
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PacketReader:
|
class PacketReader:
|
||||||
'''
|
"""
|
||||||
Reader that reads HCI packets from a sync source
|
Reader that reads HCI packets from a sync source.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
def __init__(self, source):
|
def __init__(self, source: io.BufferedReader) -> None:
|
||||||
self.source = source
|
self.source = source
|
||||||
|
|
||||||
def next_packet(self):
|
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:
|
||||||
@@ -150,7 +177,7 @@ class PacketReader:
|
|||||||
# Get the packet info based on its type
|
# Get the packet info based on its type
|
||||||
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
||||||
if packet_info is None:
|
if packet_info is None:
|
||||||
raise ValueError(f'invalid packet type {packet_type} found')
|
raise ValueError(f'invalid packet type {packet_type[0]} found')
|
||||||
|
|
||||||
# Read the header (that includes the length)
|
# Read the header (that includes the length)
|
||||||
header_size = packet_info[0] + packet_info[1]
|
header_size = packet_info[0] + packet_info[1]
|
||||||
@@ -169,21 +196,21 @@ class PacketReader:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class AsyncPacketReader:
|
class AsyncPacketReader:
|
||||||
'''
|
"""
|
||||||
Reader that reads HCI packets from an async source
|
Reader that reads HCI packets from an async source.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
def __init__(self, source):
|
def __init__(self, source: asyncio.StreamReader) -> None:
|
||||||
self.source = source
|
self.source = source
|
||||||
|
|
||||||
async def next_packet(self):
|
async def next_packet(self) -> bytes:
|
||||||
# Get the packet type
|
# Get the packet type
|
||||||
packet_type = await self.source.readexactly(1)
|
packet_type = await self.source.readexactly(1)
|
||||||
|
|
||||||
# Get the packet info based on its type
|
# Get the packet info based on its type
|
||||||
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
||||||
if packet_info is None:
|
if packet_info is None:
|
||||||
raise ValueError(f'invalid packet type {packet_type} found')
|
raise ValueError(f'invalid packet type {packet_type[0]} found')
|
||||||
|
|
||||||
# Read the header (that includes the length)
|
# Read the header (that includes the length)
|
||||||
header_size = packet_info[0] + packet_info[1]
|
header_size = packet_info[0] + packet_info[1]
|
||||||
@@ -198,15 +225,15 @@ class AsyncPacketReader:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class AsyncPipeSink:
|
class AsyncPipeSink:
|
||||||
'''
|
"""
|
||||||
Sink that forwards packets asynchronously to another sink
|
Sink that forwards packets asynchronously to another sink.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
def __init__(self, sink):
|
def __init__(self, sink: TransportSink) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
self.loop = asyncio.get_running_loop()
|
self.loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.loop.call_soon(self.sink.on_packet, packet)
|
self.loop.call_soon(self.sink.on_packet, packet)
|
||||||
|
|
||||||
|
|
||||||
@@ -216,35 +243,48 @@ class ParserSource:
|
|||||||
Base class designed to be subclassed by transport-specific source classes
|
Base class designed to be subclassed by transport-specific source classes
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
terminated: asyncio.Future[None]
|
||||||
|
parser: PacketParser
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
self.parser = PacketParser()
|
self.parser = PacketParser()
|
||||||
self.terminated = asyncio.get_running_loop().create_future()
|
self.terminated = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
def set_packet_sink(self, sink):
|
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||||
self.parser.set_packet_sink(sink)
|
self.parser.set_packet_sink(sink)
|
||||||
|
|
||||||
async def wait_for_termination(self):
|
def on_transport_lost(self) -> None:
|
||||||
|
self.terminated.set_result(None)
|
||||||
|
if self.parser.sink:
|
||||||
|
if hasattr(self.parser.sink, 'on_transport_lost'):
|
||||||
|
self.parser.sink.on_transport_lost()
|
||||||
|
|
||||||
|
async def wait_for_termination(self) -> None:
|
||||||
|
"""
|
||||||
|
Convenience method for backward compatibility. Prefer using the `terminated`
|
||||||
|
attribute instead.
|
||||||
|
"""
|
||||||
return await self.terminated
|
return await self.terminated
|
||||||
|
|
||||||
def close(self):
|
def close(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class StreamPacketSource(asyncio.Protocol, ParserSource):
|
class StreamPacketSource(asyncio.Protocol, ParserSource):
|
||||||
def data_received(self, data):
|
def data_received(self, data: bytes) -> None:
|
||||||
self.parser.feed_data(data)
|
self.parser.feed_data(data)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class StreamPacketSink:
|
class StreamPacketSink:
|
||||||
def __init__(self, transport):
|
def __init__(self, transport: asyncio.WriteTransport) -> None:
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.transport.write(packet)
|
self.transport.write(packet)
|
||||||
|
|
||||||
def close(self):
|
def close(self) -> None:
|
||||||
self.transport.close()
|
self.transport.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -264,7 +304,7 @@ class Transport:
|
|||||||
...
|
...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, source, sink):
|
def __init__(self, source: TransportSource, sink: TransportSink) -> None:
|
||||||
self.source = source
|
self.source = source
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
|
|
||||||
@@ -278,19 +318,23 @@ class Transport:
|
|||||||
return iter((self.source, self.sink))
|
return iter((self.source, self.sink))
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
self.source.close()
|
if hasattr(self.source, 'close'):
|
||||||
self.sink.close()
|
self.source.close()
|
||||||
|
if hasattr(self.sink, 'close'):
|
||||||
|
self.sink.close()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PumpedPacketSource(ParserSource):
|
class PumpedPacketSource(ParserSource):
|
||||||
def __init__(self, receive):
|
pump_task: Optional[asyncio.Task[None]]
|
||||||
|
|
||||||
|
def __init__(self, receive) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.receive_function = receive
|
self.receive_function = receive
|
||||||
self.pump_task = None
|
self.pump_task = None
|
||||||
|
|
||||||
def start(self):
|
def start(self) -> None:
|
||||||
async def pump_packets():
|
async def pump_packets() -> None:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
packet = await self.receive_function()
|
packet = await self.receive_function()
|
||||||
@@ -300,12 +344,12 @@ class PumpedPacketSource(ParserSource):
|
|||||||
break
|
break
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(f'exception while waiting for packet: {error}')
|
logger.warning(f'exception while waiting for packet: {error}')
|
||||||
self.terminated.set_result(error)
|
self.terminated.set_exception(error)
|
||||||
break
|
break
|
||||||
|
|
||||||
self.pump_task = asyncio.create_task(pump_packets())
|
self.pump_task = asyncio.create_task(pump_packets())
|
||||||
|
|
||||||
def close(self):
|
def close(self) -> None:
|
||||||
if self.pump_task:
|
if self.pump_task:
|
||||||
self.pump_task.cancel()
|
self.pump_task.cancel()
|
||||||
|
|
||||||
@@ -317,7 +361,7 @@ class PumpedPacketSink:
|
|||||||
self.packet_queue = asyncio.Queue()
|
self.packet_queue = asyncio.Queue()
|
||||||
self.pump_task = None
|
self.pump_task = None
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.packet_queue.put_nowait(packet)
|
self.packet_queue.put_nowait(packet)
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
@@ -342,15 +386,23 @@ class PumpedPacketSink:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PumpedTransport(Transport):
|
class PumpedTransport(Transport):
|
||||||
def __init__(self, source, sink, close_function):
|
source: PumpedPacketSource
|
||||||
|
sink: PumpedPacketSink
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source: PumpedPacketSource,
|
||||||
|
sink: PumpedPacketSink,
|
||||||
|
close_function,
|
||||||
|
) -> None:
|
||||||
super().__init__(source, sink)
|
super().__init__(source, sink)
|
||||||
self.close_function = close_function
|
self.close_function = close_function
|
||||||
|
|
||||||
def start(self):
|
def start(self) -> None:
|
||||||
self.source.start()
|
self.source.start()
|
||||||
self.sink.start()
|
self.sink.start()
|
||||||
|
|
||||||
async def close(self):
|
async def close(self) -> None:
|
||||||
await super().close()
|
await super().close()
|
||||||
await self.close_function()
|
await self.close_function()
|
||||||
|
|
||||||
@@ -375,31 +427,38 @@ class SnoopingTransport(Transport):
|
|||||||
raise RuntimeError('unexpected code path') # Satisfy the type checker
|
raise RuntimeError('unexpected code path') # Satisfy the type checker
|
||||||
|
|
||||||
class Source:
|
class Source:
|
||||||
def __init__(self, source, snooper):
|
sink: TransportSink
|
||||||
|
|
||||||
|
def __init__(self, source: TransportSource, snooper: Snooper):
|
||||||
self.source = source
|
self.source = source
|
||||||
self.snooper = snooper
|
self.snooper = snooper
|
||||||
self.sink = None
|
self.terminated = source.terminated
|
||||||
|
|
||||||
def set_packet_sink(self, sink):
|
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
self.source.set_packet_sink(self)
|
self.source.set_packet_sink(self)
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.snooper.snoop(packet, Snooper.Direction.CONTROLLER_TO_HOST)
|
self.snooper.snoop(packet, Snooper.Direction.CONTROLLER_TO_HOST)
|
||||||
if self.sink:
|
if self.sink:
|
||||||
self.sink.on_packet(packet)
|
self.sink.on_packet(packet)
|
||||||
|
|
||||||
class Sink:
|
class Sink:
|
||||||
def __init__(self, sink, snooper):
|
def __init__(self, sink: TransportSink, snooper: Snooper) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
self.snooper = snooper
|
self.snooper = snooper
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.snooper.snoop(packet, Snooper.Direction.HOST_TO_CONTROLLER)
|
self.snooper.snoop(packet, Snooper.Direction.HOST_TO_CONTROLLER)
|
||||||
if self.sink:
|
if self.sink:
|
||||||
self.sink.on_packet(packet)
|
self.sink.on_packet(packet)
|
||||||
|
|
||||||
def __init__(self, transport, snooper, close_snooper=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
transport: Transport,
|
||||||
|
snooper: Snooper,
|
||||||
|
close_snooper=None,
|
||||||
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
self.Source(transport.source, snooper), self.Sink(transport.sink, snooper)
|
self.Source(transport.source, snooper), self.Sink(transport.sink, snooper)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_file_transport(spec):
|
async def open_file_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a File transport (typically not for a real file, but for a PTY or other unix
|
Open a File transport (typically not for a real file, but for a PTY or other unix
|
||||||
virtual files).
|
virtual files).
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_hci_socket_transport(spec):
|
async def open_hci_socket_transport(spec: str | None) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open an HCI Socket (only available on some platforms).
|
Open an HCI Socket (only available on some platforms).
|
||||||
The parameter string is either empty (to use the first/default Bluetooth adapter)
|
The parameter string is either empty (to use the first/default Bluetooth adapter)
|
||||||
@@ -47,7 +47,7 @@ async def open_hci_socket_transport(spec):
|
|||||||
hci_socket = socket.socket(
|
hci_socket = socket.socket(
|
||||||
socket.AF_BLUETOOTH,
|
socket.AF_BLUETOOTH,
|
||||||
socket.SOCK_RAW | socket.SOCK_NONBLOCK,
|
socket.SOCK_RAW | socket.SOCK_NONBLOCK,
|
||||||
socket.BTPROTO_HCI,
|
socket.BTPROTO_HCI, # type: ignore
|
||||||
)
|
)
|
||||||
except AttributeError as error:
|
except AttributeError as error:
|
||||||
# Not supported on this platform
|
# Not supported on this platform
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_pty_transport(spec):
|
async def open_pty_transport(spec: str | None) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a PTY transport.
|
Open a PTY transport.
|
||||||
The parameter string may be empty, or a path name where a symbolic link
|
The parameter string may be empty, or a path name where a symbolic link
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_pyusb_transport(spec):
|
async def open_pyusb_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a USB transport. [Implementation based on PyUSB]
|
Open a USB transport. [Implementation based on PyUSB]
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_serial_transport(spec):
|
async def open_serial_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a serial port transport.
|
Open a serial port transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_tcp_client_transport(spec):
|
async def open_tcp_client_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a TCP client transport.
|
Open a TCP client transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
@@ -39,7 +39,7 @@ async def open_tcp_client_transport(spec):
|
|||||||
class TcpPacketSource(StreamPacketSource):
|
class TcpPacketSource(StreamPacketSource):
|
||||||
def connection_lost(self, exc):
|
def connection_lost(self, exc):
|
||||||
logger.debug(f'connection lost: {exc}')
|
logger.debug(f'connection lost: {exc}')
|
||||||
self.terminated.set_result(exc)
|
self.on_transport_lost()
|
||||||
|
|
||||||
remote_host, remote_port = spec.split(':')
|
remote_host, remote_port = spec.split(':')
|
||||||
tcp_transport, packet_source = await asyncio.get_running_loop().create_connection(
|
tcp_transport, packet_source = await asyncio.get_running_loop().create_connection(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_tcp_server_transport(spec):
|
async def open_tcp_server_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a TCP server transport.
|
Open a TCP server transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
@@ -42,7 +43,7 @@ async def open_tcp_server_transport(spec):
|
|||||||
async def close(self):
|
async def close(self):
|
||||||
await super().close()
|
await super().close()
|
||||||
|
|
||||||
class TcpServerProtocol:
|
class TcpServerProtocol(asyncio.BaseProtocol):
|
||||||
def __init__(self, packet_source, packet_sink):
|
def __init__(self, packet_source, packet_sink):
|
||||||
self.packet_source = packet_source
|
self.packet_source = packet_source
|
||||||
self.packet_sink = packet_sink
|
self.packet_sink = packet_sink
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_udp_transport(spec):
|
async def open_udp_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a UDP transport.
|
Open a UDP transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ def load_libusb():
|
|||||||
usb1.loadLibrary(libusb_dll)
|
usb1.loadLibrary(libusb_dll)
|
||||||
|
|
||||||
|
|
||||||
async def open_usb_transport(spec):
|
async def open_usb_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a USB transport.
|
Open a USB transport.
|
||||||
The moniker string has this syntax:
|
The moniker string has this syntax:
|
||||||
@@ -206,10 +206,11 @@ async def open_usb_transport(spec):
|
|||||||
logger.debug('OUT transfer likely already completed')
|
logger.debug('OUT transfer likely already completed')
|
||||||
|
|
||||||
class UsbPacketSource(asyncio.Protocol, ParserSource):
|
class UsbPacketSource(asyncio.Protocol, ParserSource):
|
||||||
def __init__(self, context, device, acl_in, events_in):
|
def __init__(self, context, device, metadata, acl_in, events_in):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.context = context
|
self.context = context
|
||||||
self.device = device
|
self.device = device
|
||||||
|
self.metadata = metadata
|
||||||
self.acl_in = acl_in
|
self.acl_in = acl_in
|
||||||
self.events_in = events_in
|
self.events_in = events_in
|
||||||
self.loop = asyncio.get_running_loop()
|
self.loop = asyncio.get_running_loop()
|
||||||
@@ -510,6 +511,10 @@ async def open_usb_transport(spec):
|
|||||||
f'events_in=0x{events_in:02X}, '
|
f'events_in=0x{events_in:02X}, '
|
||||||
)
|
)
|
||||||
|
|
||||||
|
device_metadata = {
|
||||||
|
'vendor_id': found.getVendorID(),
|
||||||
|
'product_id': found.getProductID(),
|
||||||
|
}
|
||||||
device = found.open()
|
device = found.open()
|
||||||
|
|
||||||
# Auto-detach the kernel driver if supported
|
# Auto-detach the kernel driver if supported
|
||||||
@@ -535,7 +540,7 @@ async def open_usb_transport(spec):
|
|||||||
except usb1.USBError:
|
except usb1.USBError:
|
||||||
logger.warning('failed to set configuration')
|
logger.warning('failed to set configuration')
|
||||||
|
|
||||||
source = UsbPacketSource(context, device, acl_in, events_in)
|
source = UsbPacketSource(context, device, device_metadata, acl_in, events_in)
|
||||||
sink = UsbPacketSink(device, acl_out)
|
sink = UsbPacketSink(device, acl_out)
|
||||||
return UsbTransport(context, device, interface, setting, source, sink)
|
return UsbTransport(context, device, interface, setting, source, sink)
|
||||||
except usb1.USBError as error:
|
except usb1.USBError as error:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from .common import Transport
|
||||||
from .file import open_file_transport
|
from .file import open_file_transport
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -26,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_vhci_transport(spec):
|
async def open_vhci_transport(spec: str | None) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a VHCI transport (only available on some platforms).
|
Open a VHCI transport (only available on some platforms).
|
||||||
The parameter string is either empty (to use the default VHCI device
|
The parameter string is either empty (to use the default VHCI device
|
||||||
@@ -42,15 +43,15 @@ async def open_vhci_transport(spec):
|
|||||||
# Override the source's `data_received` method so that we can
|
# Override the source's `data_received` method so that we can
|
||||||
# filter out the vendor packet that is received just after the
|
# filter out the vendor packet that is received just after the
|
||||||
# initial open
|
# initial open
|
||||||
def vhci_data_received(data):
|
def vhci_data_received(data: bytes) -> None:
|
||||||
if len(data) > 0 and data[0] == HCI_VENDOR_PKT:
|
if len(data) > 0 and data[0] == HCI_VENDOR_PKT:
|
||||||
if len(data) == 4:
|
if len(data) == 4:
|
||||||
hci_index = data[2] << 8 | data[3]
|
hci_index = data[2] << 8 | data[3]
|
||||||
logger.info(f'HCI index {hci_index}')
|
logger.info(f'HCI index {hci_index}')
|
||||||
else:
|
else:
|
||||||
transport.source.parser.feed_data(data)
|
transport.source.parser.feed_data(data) # type: ignore
|
||||||
|
|
||||||
transport.source.data_received = vhci_data_received
|
transport.source.data_received = vhci_data_received # type: ignore
|
||||||
|
|
||||||
# Write the initial config
|
# Write the initial config
|
||||||
transport.sink.on_packet(bytes([HCI_VENDOR_PKT, HCI_BREDR]))
|
transport.sink.on_packet(bytes([HCI_VENDOR_PKT, HCI_BREDR]))
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
import websockets
|
import websockets.client
|
||||||
|
|
||||||
from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport
|
from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport, Transport
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_ws_client_transport(spec):
|
async def open_ws_client_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a WebSocket client transport.
|
Open a WebSocket client transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
@@ -38,7 +38,7 @@ async def open_ws_client_transport(spec):
|
|||||||
|
|
||||||
remote_host, remote_port = spec.split(':')
|
remote_host, remote_port = spec.split(':')
|
||||||
uri = f'ws://{remote_host}:{remote_port}'
|
uri = f'ws://{remote_host}:{remote_port}'
|
||||||
websocket = await websockets.connect(uri)
|
websocket = await websockets.client.connect(uri)
|
||||||
|
|
||||||
transport = PumpedTransport(
|
transport = PumpedTransport(
|
||||||
PumpedPacketSource(websocket.recv),
|
PumpedPacketSource(websocket.recv),
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_ws_server_transport(spec):
|
async def open_ws_server_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a WebSocket server transport.
|
Open a WebSocket server transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
@@ -43,7 +42,7 @@ async def open_ws_server_transport(spec):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
source = ParserSource()
|
source = ParserSource()
|
||||||
sink = PumpedPacketSink(self.send_packet)
|
sink = PumpedPacketSink(self.send_packet)
|
||||||
self.connection = asyncio.get_running_loop().create_future()
|
self.connection = None
|
||||||
self.server = None
|
self.server = None
|
||||||
|
|
||||||
super().__init__(source, sink)
|
super().__init__(source, sink)
|
||||||
@@ -63,7 +62,7 @@ async def open_ws_server_transport(spec):
|
|||||||
f'new connection on {connection.local_address} '
|
f'new connection on {connection.local_address} '
|
||||||
f'from {connection.remote_address}'
|
f'from {connection.remote_address}'
|
||||||
)
|
)
|
||||||
self.connection.set_result(connection)
|
self.connection = connection
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
try:
|
try:
|
||||||
async for packet in connection:
|
async for packet in connection:
|
||||||
@@ -74,12 +73,14 @@ async def open_ws_server_transport(spec):
|
|||||||
except websockets.WebSocketException as error:
|
except websockets.WebSocketException as error:
|
||||||
logger.debug(f'exception while receiving packet: {error}')
|
logger.debug(f'exception while receiving packet: {error}')
|
||||||
|
|
||||||
# Wait for a new connection
|
# We're now disconnected
|
||||||
self.connection = asyncio.get_running_loop().create_future()
|
self.connection = None
|
||||||
|
|
||||||
async def send_packet(self, packet):
|
async def send_packet(self, packet):
|
||||||
connection = await self.connection
|
if self.connection is None:
|
||||||
return await connection.send(packet)
|
logger.debug('no connection, dropping packet')
|
||||||
|
return
|
||||||
|
return await self.connection.send(packet)
|
||||||
|
|
||||||
local_host, local_port = spec.split(':')
|
local_host, local_port = spec.split(':')
|
||||||
transport = WsServerTransport()
|
transport = WsServerTransport()
|
||||||
|
|||||||
0
bumble/vendor/__init__.py
vendored
Normal file
0
bumble/vendor/__init__.py
vendored
Normal file
0
bumble/vendor/android/__init__.py
vendored
Normal file
0
bumble/vendor/android/__init__.py
vendored
Normal file
318
bumble/vendor/android/hci.py
vendored
Normal file
318
bumble/vendor/android/hci.py
vendored
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
# Copyright 2021-2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from bumble.hci import (
|
||||||
|
name_or_number,
|
||||||
|
hci_vendor_command_op_code,
|
||||||
|
Address,
|
||||||
|
HCI_Constant,
|
||||||
|
HCI_Object,
|
||||||
|
HCI_Command,
|
||||||
|
HCI_Vendor_Event,
|
||||||
|
STATUS_SPEC,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Android Vendor Specific Commands and Events.
|
||||||
|
# Only a subset of the commands are implemented here currently.
|
||||||
|
#
|
||||||
|
# pylint: disable-next=line-too-long
|
||||||
|
# See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#chip-capabilities-and-configuration
|
||||||
|
HCI_LE_GET_VENDOR_CAPABILITIES_COMMAND = hci_vendor_command_op_code(0x153)
|
||||||
|
HCI_LE_APCF_COMMAND = hci_vendor_command_op_code(0x157)
|
||||||
|
HCI_GET_CONTROLLER_ACTIVITY_ENERGY_INFO_COMMAND = hci_vendor_command_op_code(0x159)
|
||||||
|
HCI_A2DP_HARDWARE_OFFLOAD_COMMAND = hci_vendor_command_op_code(0x15D)
|
||||||
|
HCI_BLUETOOTH_QUALITY_REPORT_COMMAND = hci_vendor_command_op_code(0x15E)
|
||||||
|
HCI_DYNAMIC_AUDIO_BUFFER_COMMAND = hci_vendor_command_op_code(0x15F)
|
||||||
|
|
||||||
|
HCI_BLUETOOTH_QUALITY_REPORT_EVENT = 0x58
|
||||||
|
|
||||||
|
HCI_Command.register_commands(globals())
|
||||||
|
HCI_Vendor_Event.register_subevents(globals())
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('max_advt_instances', 1),
|
||||||
|
('offloaded_resolution_of_private_address', 1),
|
||||||
|
('total_scan_results_storage', 2),
|
||||||
|
('max_irk_list_sz', 1),
|
||||||
|
('filtering_support', 1),
|
||||||
|
('max_filter', 1),
|
||||||
|
('activity_energy_info_support', 1),
|
||||||
|
('version_supported', 2),
|
||||||
|
('total_num_of_advt_tracked', 2),
|
||||||
|
('extended_scan_support', 1),
|
||||||
|
('debug_logging_supported', 1),
|
||||||
|
('le_address_generation_offloading_support', 1),
|
||||||
|
('a2dp_source_offload_capability_mask', 4),
|
||||||
|
('bluetooth_quality_report_support', 1),
|
||||||
|
('dynamic_audio_buffer_support', 4),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class HCI_LE_Get_Vendor_Capabilities_Command(HCI_Command):
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
'''
|
||||||
|
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#vendor-specific-capabilities
|
||||||
|
'''
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse_return_parameters(cls, parameters):
|
||||||
|
# There are many versions of this data structure, so we need to parse until
|
||||||
|
# there are no more bytes to parse, and leave un-signal parameters set to
|
||||||
|
# None (older versions)
|
||||||
|
nones = {field: None for field, _ in cls.return_parameters_fields}
|
||||||
|
return_parameters = HCI_Object(cls.return_parameters_fields, **nones)
|
||||||
|
|
||||||
|
try:
|
||||||
|
offset = 0
|
||||||
|
for field in cls.return_parameters_fields:
|
||||||
|
field_name, field_type = field
|
||||||
|
field_value, field_size = HCI_Object.parse_field(
|
||||||
|
parameters, offset, field_type
|
||||||
|
)
|
||||||
|
setattr(return_parameters, field_name, field_value)
|
||||||
|
offset += field_size
|
||||||
|
except struct.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return return_parameters
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
'opcode',
|
||||||
|
{
|
||||||
|
'size': 1,
|
||||||
|
'mapper': lambda x: HCI_LE_APCF_Command.opcode_name(x),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
('payload', '*'),
|
||||||
|
],
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
(
|
||||||
|
'opcode',
|
||||||
|
{
|
||||||
|
'size': 1,
|
||||||
|
'mapper': lambda x: HCI_LE_APCF_Command.opcode_name(x),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
('payload', '*'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_LE_APCF_Command(HCI_Command):
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
'''
|
||||||
|
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_apcf_command
|
||||||
|
|
||||||
|
NOTE: the subcommand-specific payloads are left as opaque byte arrays in this
|
||||||
|
implementation. A future enhancement may define subcommand-specific data structures.
|
||||||
|
'''
|
||||||
|
|
||||||
|
# APCF Subcommands
|
||||||
|
# TODO: use the OpenIntEnum class (when upcoming PR is merged)
|
||||||
|
APCF_ENABLE = 0x00
|
||||||
|
APCF_SET_FILTERING_PARAMETERS = 0x01
|
||||||
|
APCF_BROADCASTER_ADDRESS = 0x02
|
||||||
|
APCF_SERVICE_UUID = 0x03
|
||||||
|
APCF_SERVICE_SOLICITATION_UUID = 0x04
|
||||||
|
APCF_LOCAL_NAME = 0x05
|
||||||
|
APCF_MANUFACTURER_DATA = 0x06
|
||||||
|
APCF_SERVICE_DATA = 0x07
|
||||||
|
APCF_TRANSPORT_DISCOVERY_SERVICE = 0x08
|
||||||
|
APCF_AD_TYPE_FILTER = 0x09
|
||||||
|
APCF_READ_EXTENDED_FEATURES = 0xFF
|
||||||
|
|
||||||
|
OPCODE_NAMES = {
|
||||||
|
APCF_ENABLE: 'APCF_ENABLE',
|
||||||
|
APCF_SET_FILTERING_PARAMETERS: 'APCF_SET_FILTERING_PARAMETERS',
|
||||||
|
APCF_BROADCASTER_ADDRESS: 'APCF_BROADCASTER_ADDRESS',
|
||||||
|
APCF_SERVICE_UUID: 'APCF_SERVICE_UUID',
|
||||||
|
APCF_SERVICE_SOLICITATION_UUID: 'APCF_SERVICE_SOLICITATION_UUID',
|
||||||
|
APCF_LOCAL_NAME: 'APCF_LOCAL_NAME',
|
||||||
|
APCF_MANUFACTURER_DATA: 'APCF_MANUFACTURER_DATA',
|
||||||
|
APCF_SERVICE_DATA: 'APCF_SERVICE_DATA',
|
||||||
|
APCF_TRANSPORT_DISCOVERY_SERVICE: 'APCF_TRANSPORT_DISCOVERY_SERVICE',
|
||||||
|
APCF_AD_TYPE_FILTER: 'APCF_AD_TYPE_FILTER',
|
||||||
|
APCF_READ_EXTENDED_FEATURES: 'APCF_READ_EXTENDED_FEATURES',
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def opcode_name(cls, opcode):
|
||||||
|
return name_or_number(cls.OPCODE_NAMES, opcode)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('total_tx_time_ms', 4),
|
||||||
|
('total_rx_time_ms', 4),
|
||||||
|
('total_idle_time_ms', 4),
|
||||||
|
('total_energy_used', 4),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_Get_Controller_Activity_Energy_Info_Command(HCI_Command):
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
'''
|
||||||
|
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_get_controller_activity_energy_info
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
'opcode',
|
||||||
|
{
|
||||||
|
'size': 1,
|
||||||
|
'mapper': lambda x: HCI_A2DP_Hardware_Offload_Command.opcode_name(x),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
('payload', '*'),
|
||||||
|
],
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
(
|
||||||
|
'opcode',
|
||||||
|
{
|
||||||
|
'size': 1,
|
||||||
|
'mapper': lambda x: HCI_A2DP_Hardware_Offload_Command.opcode_name(x),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
('payload', '*'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_A2DP_Hardware_Offload_Command(HCI_Command):
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
'''
|
||||||
|
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#a2dp-hardware-offload-support
|
||||||
|
|
||||||
|
NOTE: the subcommand-specific payloads are left as opaque byte arrays in this
|
||||||
|
implementation. A future enhancement may define subcommand-specific data structures.
|
||||||
|
'''
|
||||||
|
|
||||||
|
# A2DP Hardware Offload Subcommands
|
||||||
|
# TODO: use the OpenIntEnum class (when upcoming PR is merged)
|
||||||
|
START_A2DP_OFFLOAD = 0x01
|
||||||
|
STOP_A2DP_OFFLOAD = 0x02
|
||||||
|
|
||||||
|
OPCODE_NAMES = {
|
||||||
|
START_A2DP_OFFLOAD: 'START_A2DP_OFFLOAD',
|
||||||
|
STOP_A2DP_OFFLOAD: 'STOP_A2DP_OFFLOAD',
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def opcode_name(cls, opcode):
|
||||||
|
return name_or_number(cls.OPCODE_NAMES, opcode)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
'opcode',
|
||||||
|
{
|
||||||
|
'size': 1,
|
||||||
|
'mapper': lambda x: HCI_Dynamic_Audio_Buffer_Command.opcode_name(x),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
('payload', '*'),
|
||||||
|
],
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
(
|
||||||
|
'opcode',
|
||||||
|
{
|
||||||
|
'size': 1,
|
||||||
|
'mapper': lambda x: HCI_Dynamic_Audio_Buffer_Command.opcode_name(x),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
('payload', '*'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_Dynamic_Audio_Buffer_Command(HCI_Command):
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
'''
|
||||||
|
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#dynamic-audio-buffer-command
|
||||||
|
|
||||||
|
NOTE: the subcommand-specific payloads are left as opaque byte arrays in this
|
||||||
|
implementation. A future enhancement may define subcommand-specific data structures.
|
||||||
|
'''
|
||||||
|
|
||||||
|
# Dynamic Audio Buffer Subcommands
|
||||||
|
# TODO: use the OpenIntEnum class (when upcoming PR is merged)
|
||||||
|
GET_AUDIO_BUFFER_TIME_CAPABILITY = 0x01
|
||||||
|
|
||||||
|
OPCODE_NAMES = {
|
||||||
|
GET_AUDIO_BUFFER_TIME_CAPABILITY: 'GET_AUDIO_BUFFER_TIME_CAPABILITY',
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def opcode_name(cls, opcode):
|
||||||
|
return name_or_number(cls.OPCODE_NAMES, opcode)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Vendor_Event.event(
|
||||||
|
fields=[
|
||||||
|
('quality_report_id', 1),
|
||||||
|
('packet_types', 1),
|
||||||
|
('connection_handle', 2),
|
||||||
|
('connection_role', {'size': 1, 'mapper': HCI_Constant.role_name}),
|
||||||
|
('tx_power_level', -1),
|
||||||
|
('rssi', -1),
|
||||||
|
('snr', 1),
|
||||||
|
('unused_afh_channel_count', 1),
|
||||||
|
('afh_select_unideal_channel_count', 1),
|
||||||
|
('lsto', 2),
|
||||||
|
('connection_piconet_clock', 4),
|
||||||
|
('retransmission_count', 4),
|
||||||
|
('no_rx_count', 4),
|
||||||
|
('nak_count', 4),
|
||||||
|
('last_tx_ack_timestamp', 4),
|
||||||
|
('flow_off_count', 4),
|
||||||
|
('last_flow_on_timestamp', 4),
|
||||||
|
('buffer_overflow_bytes', 4),
|
||||||
|
('buffer_underflow_bytes', 4),
|
||||||
|
('bdaddr', Address.parse_address),
|
||||||
|
('cal_failed_item_count', 1),
|
||||||
|
('tx_total_packets', 4),
|
||||||
|
('tx_unacked_packets', 4),
|
||||||
|
('tx_flushed_packets', 4),
|
||||||
|
('tx_last_subevent_packets', 4),
|
||||||
|
('crc_error_packets', 4),
|
||||||
|
('rx_duplicate_packets', 4),
|
||||||
|
('vendor_specific_parameters', '*'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class HCI_Bluetooth_Quality_Report_Event(HCI_Vendor_Event):
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
'''
|
||||||
|
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#bluetooth-quality-report-sub-event
|
||||||
|
'''
|
||||||
0
bumble/vendor/zephyr/__init__.py
vendored
Normal file
0
bumble/vendor/zephyr/__init__.py
vendored
Normal file
88
bumble/vendor/zephyr/hci.py
vendored
Normal file
88
bumble/vendor/zephyr/hci.py
vendored
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Copyright 2021-2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from bumble.hci import (
|
||||||
|
hci_vendor_command_op_code,
|
||||||
|
HCI_Command,
|
||||||
|
STATUS_SPEC,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Zephyr RTOS Vendor Specific Commands and Events.
|
||||||
|
# Only a subset of the commands are implemented here currently.
|
||||||
|
#
|
||||||
|
# pylint: disable-next=line-too-long
|
||||||
|
# See https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
|
||||||
|
HCI_WRITE_TX_POWER_LEVEL_COMMAND = hci_vendor_command_op_code(0x000E)
|
||||||
|
HCI_READ_TX_POWER_LEVEL_COMMAND = hci_vendor_command_op_code(0x000F)
|
||||||
|
|
||||||
|
HCI_Command.register_commands(globals())
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class TX_Power_Level_Command:
|
||||||
|
'''
|
||||||
|
Base class for read and write TX power level HCI commands
|
||||||
|
'''
|
||||||
|
|
||||||
|
TX_POWER_HANDLE_TYPE_ADV = 0x00
|
||||||
|
TX_POWER_HANDLE_TYPE_SCAN = 0x01
|
||||||
|
TX_POWER_HANDLE_TYPE_CONN = 0x02
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[('handle_type', 1), ('connection_handle', 2), ('tx_power_level', -1)],
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('handle_type', 1),
|
||||||
|
('connection_handle', 2),
|
||||||
|
('selected_tx_power_level', -1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_Write_Tx_Power_Level_Command(HCI_Command, TX_Power_Level_Command):
|
||||||
|
'''
|
||||||
|
Write TX power level. See BT_HCI_OP_VS_WRITE_TX_POWER_LEVEL in
|
||||||
|
https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
|
||||||
|
|
||||||
|
Power level is in dB. Connection handle for TX_POWER_HANDLE_TYPE_ADV and
|
||||||
|
TX_POWER_HANDLE_TYPE_SCAN should be zero.
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[('handle_type', 1), ('connection_handle', 2)],
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('handle_type', 1),
|
||||||
|
('connection_handle', 2),
|
||||||
|
('tx_power_level', -1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_Read_Tx_Power_Level_Command(HCI_Command, TX_Power_Level_Command):
|
||||||
|
'''
|
||||||
|
Read TX power level. See BT_HCI_OP_VS_READ_TX_POWER_LEVEL in
|
||||||
|
https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
|
||||||
|
|
||||||
|
Power level is in dB. Connection handle for TX_POWER_HANDLE_TYPE_ADV and
|
||||||
|
TX_POWER_HANDLE_TYPE_SCAN should be zero.
|
||||||
|
'''
|
||||||
@@ -36,6 +36,9 @@ nav:
|
|||||||
- HCI Socket: transports/hci_socket.md
|
- HCI Socket: transports/hci_socket.md
|
||||||
- Android Emulator: transports/android_emulator.md
|
- Android Emulator: transports/android_emulator.md
|
||||||
- File: transports/file.md
|
- File: transports/file.md
|
||||||
|
- Drivers:
|
||||||
|
- Overview: drivers/index.md
|
||||||
|
- Realtek: drivers/realtek.md
|
||||||
- API:
|
- API:
|
||||||
- Guide: api/guide.md
|
- Guide: api/guide.md
|
||||||
- Examples: api/examples.md
|
- Examples: api/examples.md
|
||||||
@@ -44,6 +47,7 @@ nav:
|
|||||||
- Overview: apps_and_tools/index.md
|
- Overview: apps_and_tools/index.md
|
||||||
- Console: apps_and_tools/console.md
|
- Console: apps_and_tools/console.md
|
||||||
- Bench: apps_and_tools/bench.md
|
- Bench: apps_and_tools/bench.md
|
||||||
|
- Speaker: apps_and_tools/speaker.md
|
||||||
- HCI Bridge: apps_and_tools/hci_bridge.md
|
- HCI Bridge: apps_and_tools/hci_bridge.md
|
||||||
- Golden Gate Bridge: apps_and_tools/gg_bridge.md
|
- Golden Gate Bridge: apps_and_tools/gg_bridge.md
|
||||||
- Show: apps_and_tools/show.md
|
- Show: apps_and_tools/show.md
|
||||||
@@ -60,6 +64,7 @@ nav:
|
|||||||
- Linux: platforms/linux.md
|
- Linux: platforms/linux.md
|
||||||
- Windows: platforms/windows.md
|
- Windows: platforms/windows.md
|
||||||
- Android: platforms/android.md
|
- Android: platforms/android.md
|
||||||
|
- Zephyr: platforms/zephyr.md
|
||||||
- Examples:
|
- Examples:
|
||||||
- Overview: examples/index.md
|
- Overview: examples/index.md
|
||||||
|
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ These include:
|
|||||||
* [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets
|
* [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets
|
||||||
* [Golden Gate Bridge](gg_bridge.md) - a bridge between GATT and UDP to use with the Golden Gate "stack tool"
|
* [Golden Gate Bridge](gg_bridge.md) - a bridge between GATT and UDP to use with the Golden Gate "stack tool"
|
||||||
* [Show](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form
|
* [Show](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form
|
||||||
|
* [Speaker](speaker.md) - Virtual Bluetooth speaker, with a command line and browser-based UI.
|
||||||
* [Link Relay](link_relay.md) - WebSocket relay for virtual RemoteLink instances to communicate with each other.
|
* [Link Relay](link_relay.md) - WebSocket relay for virtual RemoteLink instances to communicate with each other.
|
||||||
|
|||||||
86
docs/mkdocs/src/apps_and_tools/speaker.md
Normal file
86
docs/mkdocs/src/apps_and_tools/speaker.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
SPEAKER APP
|
||||||
|
===========
|
||||||
|
|
||||||
|
{ width=400 height=320 }
|
||||||
|
|
||||||
|
The Speaker app is virtual Bluetooth speaker (A2DP sink).
|
||||||
|
The app runs as a command-line executable, but also offers an optional simple
|
||||||
|
web-browser-based user interface.
|
||||||
|
|
||||||
|
# General Usage
|
||||||
|
You can invoke the app either as `bumble-speaker` when installed as command
|
||||||
|
from `pip`, or `python3 apps/speaker/speaker.py` when running from a source
|
||||||
|
distribution.
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: speaker.py [OPTIONS] TRANSPORT
|
||||||
|
|
||||||
|
Run the speaker.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--codec [sbc|aac] [default: aac]
|
||||||
|
--discover Discover remote endpoints once connected
|
||||||
|
--output NAME Send audio to this named output (may be used more
|
||||||
|
than once for multiple outputs)
|
||||||
|
--ui-port HTTP_PORT HTTP port for the UI server [default: 7654]
|
||||||
|
--connect ADDRESS_OR_NAME Address or name to connect to
|
||||||
|
--device-config FILENAME Device configuration file
|
||||||
|
--help Show this message and exit.
|
||||||
|
```
|
||||||
|
|
||||||
|
# Connection
|
||||||
|
By default, the virtual speaker will wait for another device (like a phone or
|
||||||
|
computer) to connect to it (and possibly pair). Alternatively, the speaker can
|
||||||
|
be told to initiate a connection to a remote device, using the `--connect`
|
||||||
|
option.
|
||||||
|
|
||||||
|
# Outputs
|
||||||
|
The speaker can have one or more outputs. By default, the only output is a text
|
||||||
|
display on the console, as well as a browser-based user interface if connected.
|
||||||
|
In addition, a file output can be used, in which case the received audio data is
|
||||||
|
saved to a specified file.
|
||||||
|
Finally, if the host computer on which your are running the application has `ffplay`
|
||||||
|
as an available command line executable, the `@ffplay` output can be selected, in
|
||||||
|
which case the received audio will be played on the computer's builtin speakers via
|
||||||
|
a pipe to `ffplay`. (see the [ffplay documentation](https://www.ffmpeg.org/ffplay.html)
|
||||||
|
for details)
|
||||||
|
|
||||||
|
# Web User Interface
|
||||||
|
When the speaker app starts, it prints out on the console the local URL at which you
|
||||||
|
may point a browser (Chrome recommended for full functionality). The console line
|
||||||
|
specifying the local UI URL will look like:
|
||||||
|
```
|
||||||
|
UI HTTP server at http://127.0.0.1:7654
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, the web UI will show the status of the connection, as well as a realtime
|
||||||
|
graph of the received audio bandwidth.
|
||||||
|
In order to also hear the received audio, you need to click the `Audio on` button
|
||||||
|
(this is due to the fact that most browsers will require some user interface with the
|
||||||
|
page before granting access to the audio output APIs).
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
|
||||||
|
In the following examples, we use a single USB Bluetooth controllers `usb:0`. Other
|
||||||
|
transports can be used of course.
|
||||||
|
|
||||||
|
!!! example "Start the speaker and wait for a connection"
|
||||||
|
```
|
||||||
|
$ bumble-speaker usb:0
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! example "Start the speaker and save the AAC audio to a file named `audio.aac`."
|
||||||
|
```
|
||||||
|
$ bumble-speaker --output audio.aac usb:0
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! example "Start the speaker and save the SBC audio to a file named `audio.sbc`."
|
||||||
|
```
|
||||||
|
$ bumble-speaker --codec sbc --output audio.sbc usb:0
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! example "Start the speaker and connect it to a phone at address `B8:7B:C5:05:57:ED`."
|
||||||
|
```
|
||||||
|
$ bumble-speaker --connect B8:7B:C5:05:57:ED usb:0
|
||||||
|
```
|
||||||
|
|
||||||
BIN
docs/mkdocs/src/downloads/zephyr/hci_usb.zip
Normal file
BIN
docs/mkdocs/src/downloads/zephyr/hci_usb.zip
Normal file
Binary file not shown.
10
docs/mkdocs/src/drivers/index.md
Normal file
10
docs/mkdocs/src/drivers/index.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
DRIVERS
|
||||||
|
=======
|
||||||
|
|
||||||
|
Some Bluetooth controllers require a driver to function properly.
|
||||||
|
This may include, for instance, loading a Firmware image or patch,
|
||||||
|
loading a configuration.
|
||||||
|
|
||||||
|
Drivers included in the module are:
|
||||||
|
|
||||||
|
* [Realtek](realtek.md): Loading of Firmware and Config for Realtek USB dongles.
|
||||||
62
docs/mkdocs/src/drivers/realtek.md
Normal file
62
docs/mkdocs/src/drivers/realtek.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
REALTEK DRIVER
|
||||||
|
==============
|
||||||
|
|
||||||
|
This driver supports loading firmware images and optional config data to
|
||||||
|
USB dongles with a Realtek chipset.
|
||||||
|
A number of USB dongles are supported, but likely not all.
|
||||||
|
When using a USB dongle, the USB product ID and manufacturer ID are used
|
||||||
|
to find whether a matching set of firmware image and config data
|
||||||
|
is needed for that specific model. If a match exists, the driver will try
|
||||||
|
load the firmware image and, if needed, config data.
|
||||||
|
The driver will look for those files by name, in order, in:
|
||||||
|
|
||||||
|
* The directory specified by the environment variable `BUMBLE_RTK_FIRMWARE_DIR`
|
||||||
|
if set.
|
||||||
|
* The directory `<package-dir>/drivers/rtk_fw` where `<package-dir>` is the directory
|
||||||
|
where the `bumble` package is installed.
|
||||||
|
* The current directory.
|
||||||
|
|
||||||
|
|
||||||
|
Obtaining Firmware Images and Config Data
|
||||||
|
-----------------------------------------
|
||||||
|
|
||||||
|
Firmware images and config data may be obtained from a variety of online
|
||||||
|
sources.
|
||||||
|
To facilitate finding a downloading the, the utility program `bumble-rtk-fw-download`
|
||||||
|
may be used.
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: bumble-rtk-fw-download [OPTIONS]
|
||||||
|
|
||||||
|
Download RTK firmware images and configs.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--output-dir TEXT Output directory where the files will be
|
||||||
|
saved [default: .]
|
||||||
|
--source [linux-kernel|realtek-opensource|linux-from-scratch]
|
||||||
|
[default: linux-kernel]
|
||||||
|
--single TEXT Only download a single image set, by its
|
||||||
|
base name
|
||||||
|
--force Overwrite files if they already exist
|
||||||
|
--parse Parse the FW image after saving
|
||||||
|
--help Show this message and exit.
|
||||||
|
```
|
||||||
|
|
||||||
|
Utility
|
||||||
|
-------
|
||||||
|
|
||||||
|
The `bumble-rtk-util` utility may be used to interact with a Realtek USB dongle
|
||||||
|
and/or firmware images.
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: bumble-rtk-util [OPTIONS] COMMAND [ARGS]...
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--help Show this message and exit.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
drop Drop a firmware image from the USB dongle.
|
||||||
|
info Get the firmware info from a USB dongle.
|
||||||
|
load Load a firmware image into the USB dongle.
|
||||||
|
parse Parse a firmware image.
|
||||||
|
```
|
||||||
BIN
docs/mkdocs/src/images/speaker_screenshot.png
Normal file
BIN
docs/mkdocs/src/images/speaker_screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
@@ -9,3 +9,4 @@ For platform-specific information, see the following pages:
|
|||||||
* :material-linux: Linux - see the [Linux platform page](linux.md)
|
* :material-linux: Linux - see the [Linux platform page](linux.md)
|
||||||
* :material-microsoft-windows: Windows - see the [Windows platform page](windows.md)
|
* :material-microsoft-windows: Windows - see the [Windows platform page](windows.md)
|
||||||
* :material-android: Android - see the [Android platform page](android.md)
|
* :material-android: Android - see the [Android platform page](android.md)
|
||||||
|
* :material-memory: Zephyr - see the [Zephyr platform page](zephyr.md)
|
||||||
|
|||||||
51
docs/mkdocs/src/platforms/zephyr.md
Normal file
51
docs/mkdocs/src/platforms/zephyr.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
:material-memory: ZEPHYR PLATFORM
|
||||||
|
=================================
|
||||||
|
|
||||||
|
Set TX Power on nRF52840
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
The Nordic nRF52840 supports Zephyr's vendor specific HCI command for setting TX
|
||||||
|
power during advertising, connection, or scanning. With the example [HCI
|
||||||
|
USB](https://docs.zephyrproject.org/latest/samples/bluetooth/hci_usb/README.html)
|
||||||
|
application, an [nRF52840
|
||||||
|
dongle](https://www.nordicsemi.com/Products/Development-
|
||||||
|
hardware/nRF52840-Dongle) can be used as a Bumble controller.
|
||||||
|
|
||||||
|
To add dynamic TX power support to the HCI USB application, add the following to
|
||||||
|
`zephyr/samples/bluetooth/hci_usb/prj.conf` and build.
|
||||||
|
|
||||||
|
```
|
||||||
|
CONFIG_BT_CTLR_ADVANCED_FEATURES=y
|
||||||
|
CONFIG_BT_CTLR_CONN_RSSI=y
|
||||||
|
CONFIG_BT_CTLR_TX_PWR_DYNAMIC_CONTROL=y
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, a prebuilt firmware application can be downloaded here:
|
||||||
|
[hci_usb.zip](../downloads/zephyr/hci_usb.zip).
|
||||||
|
|
||||||
|
Put the nRF52840 dongle into bootloader mode by pressing the RESET button. The
|
||||||
|
LED should pulse red. Load the firmware application with the `nrfutil` tool:
|
||||||
|
|
||||||
|
```
|
||||||
|
nrfutil dfu usb-serial -pkg hci_usb.zip -p /dev/ttyACM0
|
||||||
|
```
|
||||||
|
|
||||||
|
The vendor specific HCI commands to read and write TX power are defined in
|
||||||
|
`bumble/vendor/zephyr/hci.py` and may be used as such:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bumble.vendor.zephyr.hci import HCI_Write_Tx_Power_Level_Command
|
||||||
|
|
||||||
|
# set advertising power to -4 dB
|
||||||
|
response = await host.send_command(
|
||||||
|
HCI_Write_Tx_Power_Level_Command(
|
||||||
|
handle_type=HCI_Write_Tx_Power_Level_Command.TX_POWER_HANDLE_TYPE_ADV,
|
||||||
|
connection_handle=0,
|
||||||
|
tx_power_level=-4,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.return_parameters.status == HCI_SUCCESS:
|
||||||
|
print(f"TX power set to {response.return_parameters.selected_tx_power_level}")
|
||||||
|
|
||||||
|
```
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
UDP TRANSPORT
|
WEBSOCKET CLIENT TRANSPORT
|
||||||
=============
|
==========================
|
||||||
|
|
||||||
The UDP transport is a UDP socket, receiving packets on a specified port number, and sending packets to a specified host and port number.
|
The WebSocket Client transport is WebSocket connection to a WebSocket server over which HCI packets
|
||||||
|
are sent and received.
|
||||||
|
|
||||||
## Moniker
|
## Moniker
|
||||||
The moniker syntax for a UDP transport is: `udp:<local-host>:<local-port>,<remote-host>:<remote-port>`.
|
The moniker syntax for a WebSocket Client transport is: `ws-client:<ws-url>`
|
||||||
|
|
||||||
!!! example
|
!!! example
|
||||||
`udp:0.0.0.0:9000,127.0.0.1:9001`
|
`ws-client:ws://localhost:1234/some/path`
|
||||||
UDP transport where packets are received on port `9000` and sent to `127.0.0.1` on port `9001`
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
UDP TRANSPORT
|
WEBSOCKET SERVER TRANSPORT
|
||||||
=============
|
==========================
|
||||||
|
|
||||||
The UDP transport is a UDP socket, receiving packets on a specified port number, and sending packets to a specified host and port number.
|
The WebSocket Server transport is WebSocket server that accepts connections from a WebSocket
|
||||||
|
client. HCI packets are sent and received over the connection.
|
||||||
|
|
||||||
## Moniker
|
## Moniker
|
||||||
The moniker syntax for a UDP transport is: `udp:<local-host>:<local-port>,<remote-host>:<remote-port>`.
|
The moniker syntax for a WebSocket Server transport is: `ws-server:<host>:<port>`,
|
||||||
|
where `<host>` may be the address of a local network interface, or `_`to accept connections on all local network interfaces. `<port>` is the TCP port number on which to accept connections.
|
||||||
|
|
||||||
|
|
||||||
!!! example
|
!!! example
|
||||||
`udp:0.0.0.0:9000,127.0.0.1:9001`
|
`ws-server:_:9001`
|
||||||
UDP transport where packets are received on port `9000` and sent to `127.0.0.1` on port `9001`
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ channels:
|
|||||||
- defaults
|
- defaults
|
||||||
- conda-forge
|
- conda-forge
|
||||||
dependencies:
|
dependencies:
|
||||||
- pip=20
|
- pip=23
|
||||||
- python=3.8
|
- python=3.8
|
||||||
- pip:
|
- pip:
|
||||||
- --editable .[development,documentation,test]
|
- --editable .[development,documentation,test]
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from bumble.colors import color
|
|||||||
|
|
||||||
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.core import BT_BR_EDR_TRANSPORT, BT_L2CAP_PROTOCOL_ID
|
from bumble.core import BT_BR_EDR_TRANSPORT, BT_L2CAP_PROTOCOL_ID, CommandTimeoutError
|
||||||
from bumble.sdp import (
|
from bumble.sdp import (
|
||||||
Client as SDP_Client,
|
Client as SDP_Client,
|
||||||
SDP_PUBLIC_BROWSE_ROOT,
|
SDP_PUBLIC_BROWSE_ROOT,
|
||||||
@@ -48,62 +48,70 @@ 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_source, hci_sink)
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
|
device.le_enabled = False
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
|
|
||||||
async def connect(target_address):
|
async def connect(target_address):
|
||||||
print(f'=== Connecting to {target_address}...')
|
print(f'=== Connecting to {target_address}...')
|
||||||
connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
|
try:
|
||||||
print(f'=== Connected to {connection.peer_address}!')
|
connection = await device.connect(
|
||||||
|
target_address, transport=BT_BR_EDR_TRANSPORT
|
||||||
# Connect to the SDP Server
|
|
||||||
sdp_client = SDP_Client(device)
|
|
||||||
await sdp_client.connect(connection)
|
|
||||||
|
|
||||||
# List all services in the root browse group
|
|
||||||
service_record_handles = await sdp_client.search_services(
|
|
||||||
[SDP_PUBLIC_BROWSE_ROOT]
|
|
||||||
)
|
|
||||||
print(color('\n==================================', 'blue'))
|
|
||||||
print(color('SERVICES:', 'yellow'), service_record_handles)
|
|
||||||
|
|
||||||
# For each service in the root browse group, get all its attributes
|
|
||||||
for service_record_handle in service_record_handles:
|
|
||||||
attributes = await sdp_client.get_attributes(
|
|
||||||
service_record_handle, [SDP_ALL_ATTRIBUTES_RANGE]
|
|
||||||
)
|
|
||||||
print(color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow'))
|
|
||||||
for attribute in attributes:
|
|
||||||
print(' ', attribute.to_string(with_colors=True))
|
|
||||||
|
|
||||||
# Search for services with an L2CAP service attribute
|
|
||||||
search_result = await sdp_client.search_attributes(
|
|
||||||
[BT_L2CAP_PROTOCOL_ID], [SDP_ALL_ATTRIBUTES_RANGE]
|
|
||||||
)
|
|
||||||
print(color('\n==================================', 'blue'))
|
|
||||||
print(color('SEARCH RESULTS:', 'yellow'))
|
|
||||||
for attribute_list in search_result:
|
|
||||||
print(color('SERVICE:', 'green'))
|
|
||||||
print(
|
|
||||||
' '
|
|
||||||
+ '\n '.join(
|
|
||||||
[
|
|
||||||
attribute.to_string(with_colors=True)
|
|
||||||
for attribute in attribute_list
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
except CommandTimeoutError:
|
||||||
|
print('!!! Connection timed out')
|
||||||
|
return
|
||||||
|
print(f'=== Connected to {connection.peer_address}!')
|
||||||
|
|
||||||
|
# Connect to the SDP Server
|
||||||
|
sdp_client = SDP_Client(device)
|
||||||
|
await sdp_client.connect(connection)
|
||||||
|
|
||||||
|
# List all services in the root browse group
|
||||||
|
service_record_handles = await sdp_client.search_services(
|
||||||
|
[SDP_PUBLIC_BROWSE_ROOT]
|
||||||
)
|
)
|
||||||
|
print(color('\n==================================', 'blue'))
|
||||||
|
print(color('SERVICES:', 'yellow'), service_record_handles)
|
||||||
|
|
||||||
await sdp_client.disconnect()
|
# For each service in the root browse group, get all its attributes
|
||||||
await hci_source.wait_for_termination()
|
for service_record_handle in service_record_handles:
|
||||||
|
attributes = await sdp_client.get_attributes(
|
||||||
|
service_record_handle, [SDP_ALL_ATTRIBUTES_RANGE]
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow')
|
||||||
|
)
|
||||||
|
for attribute in attributes:
|
||||||
|
print(' ', attribute.to_string(with_colors=True))
|
||||||
|
|
||||||
# Connect to a peer
|
# Search for services with an L2CAP service attribute
|
||||||
target_addresses = sys.argv[3:]
|
search_result = await sdp_client.search_attributes(
|
||||||
await asyncio.wait(
|
[BT_L2CAP_PROTOCOL_ID], [SDP_ALL_ATTRIBUTES_RANGE]
|
||||||
[
|
)
|
||||||
asyncio.create_task(connect(target_address))
|
print(color('\n==================================', 'blue'))
|
||||||
for target_address in target_addresses
|
print(color('SEARCH RESULTS:', 'yellow'))
|
||||||
]
|
for attribute_list in search_result:
|
||||||
)
|
print(color('SERVICE:', 'green'))
|
||||||
|
print(
|
||||||
|
' '
|
||||||
|
+ '\n '.join(
|
||||||
|
[
|
||||||
|
attribute.to_string(with_colors=True)
|
||||||
|
for attribute in attribute_list
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await sdp_client.disconnect()
|
||||||
|
|
||||||
|
# Connect to a peer
|
||||||
|
target_addresses = sys.argv[3:]
|
||||||
|
await asyncio.wait(
|
||||||
|
[
|
||||||
|
asyncio.create_task(connect(target_address))
|
||||||
|
for target_address in target_addresses
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ from bumble.core import (
|
|||||||
BT_RFCOMM_PROTOCOL_ID,
|
BT_RFCOMM_PROTOCOL_ID,
|
||||||
BT_BR_EDR_TRANSPORT,
|
BT_BR_EDR_TRANSPORT,
|
||||||
)
|
)
|
||||||
from bumble.rfcomm import Client
|
from bumble import rfcomm, hfp
|
||||||
from bumble.sdp import (
|
from bumble.sdp import (
|
||||||
Client as SDP_Client,
|
Client as SDP_Client,
|
||||||
DataElement,
|
DataElement,
|
||||||
@@ -39,7 +39,9 @@ from bumble.sdp import (
|
|||||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
)
|
)
|
||||||
from bumble.hfp import HfpProtocol
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -181,7 +183,7 @@ async def main():
|
|||||||
|
|
||||||
# Create a client and start it
|
# Create a client and start it
|
||||||
print('@@@ Starting to RFCOMM client...')
|
print('@@@ Starting to RFCOMM client...')
|
||||||
rfcomm_client = Client(device, connection)
|
rfcomm_client = rfcomm.Client(device, connection)
|
||||||
rfcomm_mux = await rfcomm_client.start()
|
rfcomm_mux = await rfcomm_client.start()
|
||||||
print('@@@ Started')
|
print('@@@ Started')
|
||||||
|
|
||||||
@@ -196,7 +198,7 @@ async def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Protocol loop (just for testing at this point)
|
# Protocol loop (just for testing at this point)
|
||||||
protocol = HfpProtocol(session)
|
protocol = hfp.HfpProtocol(session)
|
||||||
while True:
|
while True:
|
||||||
line = await protocol.next_line()
|
line = await protocol.next_line()
|
||||||
|
|
||||||
|
|||||||
@@ -21,82 +21,22 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import websockets
|
import websockets
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
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.rfcomm import Server as RfcommServer
|
from bumble.rfcomm import Server as RfcommServer
|
||||||
from bumble.sdp import (
|
from bumble import hfp
|
||||||
DataElement,
|
from bumble.hfp import HfProtocol
|
||||||
ServiceAttribute,
|
|
||||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
|
||||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
|
||||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
||||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
||||||
)
|
|
||||||
from bumble.core import (
|
|
||||||
BT_GENERIC_AUDIO_SERVICE,
|
|
||||||
BT_HANDSFREE_SERVICE,
|
|
||||||
BT_L2CAP_PROTOCOL_ID,
|
|
||||||
BT_RFCOMM_PROTOCOL_ID,
|
|
||||||
)
|
|
||||||
from bumble.hfp import HfpProtocol
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
def make_sdp_records(rfcomm_channel):
|
|
||||||
return {
|
|
||||||
0x00010001: [
|
|
||||||
ServiceAttribute(
|
|
||||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
|
||||||
DataElement.unsigned_integer_32(0x00010001),
|
|
||||||
),
|
|
||||||
ServiceAttribute(
|
|
||||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
|
||||||
DataElement.sequence(
|
|
||||||
[
|
|
||||||
DataElement.uuid(BT_HANDSFREE_SERVICE),
|
|
||||||
DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ServiceAttribute(
|
|
||||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
||||||
DataElement.sequence(
|
|
||||||
[
|
|
||||||
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
|
|
||||||
DataElement.sequence(
|
|
||||||
[
|
|
||||||
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
|
|
||||||
DataElement.unsigned_integer_8(rfcomm_channel),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ServiceAttribute(
|
|
||||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
||||||
DataElement.sequence(
|
|
||||||
[
|
|
||||||
DataElement.sequence(
|
|
||||||
[
|
|
||||||
DataElement.uuid(BT_HANDSFREE_SERVICE),
|
|
||||||
DataElement.unsigned_integer_16(0x0105),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class UiServer:
|
class UiServer:
|
||||||
protocol = None
|
protocol: Optional[HfProtocol] = None
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
# Start a Websocket server to receive events from a web page
|
"""Start a Websocket server to receive events from a web page."""
|
||||||
|
|
||||||
async def serve(websocket, _path):
|
async def serve(websocket, _path):
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -107,7 +47,7 @@ class UiServer:
|
|||||||
message_type = parsed['type']
|
message_type = parsed['type']
|
||||||
if message_type == 'at_command':
|
if message_type == 'at_command':
|
||||||
if self.protocol is not None:
|
if self.protocol is not None:
|
||||||
self.protocol.send_command_line(parsed['command'])
|
await self.protocol.execute_command(parsed['command'])
|
||||||
|
|
||||||
except websockets.exceptions.ConnectionClosedOK:
|
except websockets.exceptions.ConnectionClosedOK:
|
||||||
pass
|
pass
|
||||||
@@ -117,19 +57,11 @@ class UiServer:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def protocol_loop(protocol):
|
def on_dlc(dlc, configuration: hfp.Configuration):
|
||||||
await protocol.initialize_service()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
await (protocol.next_line())
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
def on_dlc(dlc):
|
|
||||||
print('*** DLC connected', dlc)
|
print('*** DLC connected', dlc)
|
||||||
protocol = HfpProtocol(dlc)
|
protocol = HfProtocol(dlc, configuration)
|
||||||
UiServer.protocol = protocol
|
UiServer.protocol = protocol
|
||||||
asyncio.create_task(protocol_loop(protocol))
|
asyncio.create_task(protocol.run())
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -143,6 +75,27 @@ async def main():
|
|||||||
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_source, hci_sink):
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
|
# Hands-Free profile configuration.
|
||||||
|
# TODO: load configuration from file.
|
||||||
|
configuration = hfp.Configuration(
|
||||||
|
supported_hf_features=[
|
||||||
|
hfp.HfFeature.THREE_WAY_CALLING,
|
||||||
|
hfp.HfFeature.REMOTE_VOLUME_CONTROL,
|
||||||
|
hfp.HfFeature.ENHANCED_CALL_STATUS,
|
||||||
|
hfp.HfFeature.ENHANCED_CALL_CONTROL,
|
||||||
|
hfp.HfFeature.CODEC_NEGOTIATION,
|
||||||
|
hfp.HfFeature.HF_INDICATORS,
|
||||||
|
hfp.HfFeature.ESCO_S4_SETTINGS_SUPPORTED,
|
||||||
|
],
|
||||||
|
supported_hf_indicators=[
|
||||||
|
hfp.HfIndicator.BATTERY_LEVEL,
|
||||||
|
],
|
||||||
|
supported_audio_codecs=[
|
||||||
|
hfp.AudioCodec.CVSD,
|
||||||
|
hfp.AudioCodec.MSBC,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
# 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_source, hci_sink)
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
@@ -151,11 +104,13 @@ async def main():
|
|||||||
rfcomm_server = RfcommServer(device)
|
rfcomm_server = RfcommServer(device)
|
||||||
|
|
||||||
# Listen for incoming DLC connections
|
# Listen for incoming DLC connections
|
||||||
channel_number = rfcomm_server.listen(on_dlc)
|
channel_number = rfcomm_server.listen(lambda dlc: on_dlc(dlc, configuration))
|
||||||
print(f'### Listening for connection on channel {channel_number}')
|
print(f'### Listening for connection on channel {channel_number}')
|
||||||
|
|
||||||
# Advertise the HFP RFComm channel in the SDP
|
# Advertise the HFP RFComm channel in the SDP
|
||||||
device.sdp_service_records = make_sdp_records(channel_number)
|
device.sdp_service_records = {
|
||||||
|
0x00010001: hfp.sdp_records(0x00010001, channel_number, configuration)
|
||||||
|
}
|
||||||
|
|
||||||
# Let's go!
|
# Let's go!
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
|
|||||||
@@ -20,83 +20,109 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from bumble.core import UUID
|
||||||
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.core import BT_L2CAP_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID, UUID
|
|
||||||
from bumble.rfcomm import Server
|
from bumble.rfcomm import Server
|
||||||
from bumble.sdp import (
|
from bumble.utils import AsyncRunner
|
||||||
DataElement,
|
from bumble.rfcomm import make_service_sdp_records
|
||||||
ServiceAttribute,
|
|
||||||
SDP_PUBLIC_BROWSE_ROOT,
|
|
||||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
|
||||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
|
||||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
|
||||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def sdp_records(channel):
|
def sdp_records(channel, uuid):
|
||||||
|
service_record_handle = 0x00010001
|
||||||
return {
|
return {
|
||||||
0x00010001: [
|
service_record_handle: make_service_sdp_records(
|
||||||
ServiceAttribute(
|
service_record_handle, channel, UUID(uuid)
|
||||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
)
|
||||||
DataElement.unsigned_integer_32(0x00010001),
|
|
||||||
),
|
|
||||||
ServiceAttribute(
|
|
||||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
|
||||||
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
|
|
||||||
),
|
|
||||||
ServiceAttribute(
|
|
||||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
|
||||||
DataElement.sequence(
|
|
||||||
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ServiceAttribute(
|
|
||||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
||||||
DataElement.sequence(
|
|
||||||
[
|
|
||||||
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
|
|
||||||
DataElement.sequence(
|
|
||||||
[
|
|
||||||
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
|
|
||||||
DataElement.unsigned_integer_8(channel),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def on_dlc(dlc):
|
def on_rfcomm_session(rfcomm_session, tcp_server):
|
||||||
print('*** DLC connected', dlc)
|
print('*** RFComm session connected', rfcomm_session)
|
||||||
dlc.sink = lambda data: on_rfcomm_data_received(dlc, data)
|
tcp_server.attach_session(rfcomm_session)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def on_rfcomm_data_received(dlc, data):
|
class TcpServerProtocol(asyncio.Protocol):
|
||||||
print(f'<<< Data received: {data.hex()}')
|
def __init__(self, server):
|
||||||
try:
|
self.server = server
|
||||||
message = data.decode('utf-8')
|
|
||||||
print(f'<<< Message = {message}')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Echo everything back
|
def connection_made(self, transport):
|
||||||
dlc.write(data)
|
peer_name = transport.get_extra_info('peer_name')
|
||||||
|
print(f'<<< TCP Server: connection from {peer_name}')
|
||||||
|
if self.server:
|
||||||
|
self.server.tcp_transport = transport
|
||||||
|
else:
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
def connection_lost(self, exc):
|
||||||
|
print('<<< TCP Server: connection lost')
|
||||||
|
if self.server:
|
||||||
|
self.server.tcp_transport = None
|
||||||
|
|
||||||
|
def data_received(self, data):
|
||||||
|
print(f'<<< TCP Server: data received: {len(data)} bytes - {data.hex()}')
|
||||||
|
if self.server:
|
||||||
|
self.server.tcp_data_received(data)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class TcpServer:
|
||||||
|
def __init__(self, port):
|
||||||
|
self.rfcomm_session = None
|
||||||
|
self.tcp_transport = None
|
||||||
|
AsyncRunner.spawn(self.run(port))
|
||||||
|
|
||||||
|
def attach_session(self, rfcomm_session):
|
||||||
|
if self.rfcomm_session:
|
||||||
|
self.rfcomm_session.sink = None
|
||||||
|
|
||||||
|
self.rfcomm_session = rfcomm_session
|
||||||
|
rfcomm_session.sink = self.rfcomm_data_received
|
||||||
|
|
||||||
|
def rfcomm_data_received(self, data):
|
||||||
|
print(f'<<< RFCOMM Data: {data.hex()}')
|
||||||
|
if self.tcp_transport:
|
||||||
|
self.tcp_transport.write(data)
|
||||||
|
else:
|
||||||
|
print('!!! no TCP connection, dropping data')
|
||||||
|
|
||||||
|
def tcp_data_received(self, data):
|
||||||
|
if self.rfcomm_session:
|
||||||
|
self.rfcomm_session.write(data)
|
||||||
|
else:
|
||||||
|
print('!!! no RFComm session, dropping data')
|
||||||
|
|
||||||
|
async def run(self, port):
|
||||||
|
print(f'$$$ Starting TCP server on port {port}')
|
||||||
|
|
||||||
|
server = await asyncio.get_running_loop().create_server(
|
||||||
|
lambda: TcpServerProtocol(self), '127.0.0.1', port
|
||||||
|
)
|
||||||
|
|
||||||
|
async with server:
|
||||||
|
await server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main():
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 4:
|
||||||
print('Usage: run_rfcomm_server.py <device-config> <transport-spec>')
|
print(
|
||||||
print('example: run_rfcomm_server.py classic2.json usb:04b4:f901')
|
'Usage: run_rfcomm_server.py <device-config> <transport-spec> '
|
||||||
|
'<tcp-port> [<uuid>]'
|
||||||
|
)
|
||||||
|
print('example: run_rfcomm_server.py classic2.json usb:0 8888')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
tcp_port = int(sys.argv[3])
|
||||||
|
|
||||||
|
if len(sys.argv) >= 5:
|
||||||
|
uuid = sys.argv[4]
|
||||||
|
else:
|
||||||
|
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_source, hci_sink):
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
@@ -105,15 +131,20 @@ async def main():
|
|||||||
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_source, hci_sink)
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
|
|
||||||
# Create and register a server
|
# Create a TCP server
|
||||||
|
tcp_server = TcpServer(tcp_port)
|
||||||
|
|
||||||
|
# Create and register an RFComm server
|
||||||
rfcomm_server = Server(device)
|
rfcomm_server = Server(device)
|
||||||
|
|
||||||
# Listen for incoming DLC connections
|
# Listen for incoming DLC connections
|
||||||
channel_number = rfcomm_server.listen(on_dlc)
|
channel_number = rfcomm_server.listen(
|
||||||
print(f'### Listening for connection on channel {channel_number}')
|
lambda session: on_rfcomm_session(session, tcp_server)
|
||||||
|
)
|
||||||
|
print(f'### Listening for RFComm connections on channel {channel_number}')
|
||||||
|
|
||||||
# Setup the SDP to advertise this channel
|
# Setup the SDP to advertise this channel
|
||||||
device.sdp_service_records = sdp_records(channel_number)
|
device.sdp_service_records = sdp_records(channel_number, uuid)
|
||||||
|
|
||||||
# Start the controller
|
# Start the controller
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ async def main():
|
|||||||
print(
|
print(
|
||||||
f'>>> {color(advertisement.address, address_color)} '
|
f'>>> {color(advertisement.address, address_color)} '
|
||||||
f'[{color(address_type_string, type_color)}]'
|
f'[{color(address_type_string, type_color)}]'
|
||||||
f'{address_qualifier}:{separator}RSSI:{advertisement.rssi}'
|
f'{address_qualifier}:{separator}RSSI: {advertisement.rssi}'
|
||||||
f'{separator}'
|
f'{separator}'
|
||||||
f'{advertisement.data.to_string(separator)}'
|
f'{advertisement.data.to_string(separator)}'
|
||||||
)
|
)
|
||||||
|
|||||||
5
examples/speaker.json
Normal file
5
examples/speaker.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "Bumble Speaker",
|
||||||
|
"class_of_device": 2360324,
|
||||||
|
"keystore": "JsonKeyStore"
|
||||||
|
}
|
||||||
2
rust/.gitignore
vendored
Normal file
2
rust/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
/.idea
|
||||||
7
rust/CHANGELOG.md
Normal file
7
rust/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Next
|
||||||
|
|
||||||
|
- Code-gen company ID table
|
||||||
|
|
||||||
|
# 0.1.0
|
||||||
|
|
||||||
|
- Initial release
|
||||||
1817
rust/Cargo.lock
generated
Normal file
1817
rust/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
77
rust/Cargo.toml
Normal file
77
rust/Cargo.toml
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
[package]
|
||||||
|
name = "bumble"
|
||||||
|
description = "Rust API for the Bumble Bluetooth stack"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
homepage = "https://google.github.io/bumble/index.html"
|
||||||
|
repository = "https://github.com/google/bumble"
|
||||||
|
documentation = "https://docs.rs/crate/bumble"
|
||||||
|
authors = ["Marshall Pierce <marshallpierce@google.com>"]
|
||||||
|
keywords = ["bluetooth", "ble"]
|
||||||
|
categories = ["api-bindings", "network-programming"]
|
||||||
|
rust-version = "1.70.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pyo3 = { version = "0.18.3", features = ["macros"] }
|
||||||
|
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime"] }
|
||||||
|
tokio = { version = "1.28.2", features = ["macros", "signal"] }
|
||||||
|
nom = "7.1.3"
|
||||||
|
strum = "0.25.0"
|
||||||
|
strum_macros = "0.25.0"
|
||||||
|
hex = "0.4.3"
|
||||||
|
itertools = "0.11.0"
|
||||||
|
lazy_static = "1.4.0"
|
||||||
|
thiserror = "1.0.41"
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
anyhow = { version = "1.0.71", optional = true }
|
||||||
|
clap = { version = "4.3.3", features = ["derive"], optional = true }
|
||||||
|
directories = { version = "5.0.1", optional = true }
|
||||||
|
env_logger = { version = "0.10.0", optional = true }
|
||||||
|
futures = { version = "0.3.28", optional = true }
|
||||||
|
log = { version = "0.4.19", optional = true }
|
||||||
|
owo-colors = { version = "3.5.0", optional = true }
|
||||||
|
reqwest = { version = "0.11.20", features = ["blocking"], optional = true }
|
||||||
|
rusb = { version = "0.9.2", optional = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1.28.2", features = ["full"] }
|
||||||
|
tempfile = "3.6.0"
|
||||||
|
nix = "0.26.2"
|
||||||
|
anyhow = "1.0.71"
|
||||||
|
pyo3 = { version = "0.18.3", features = ["macros", "anyhow"] }
|
||||||
|
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime", "attributes", "testing"] }
|
||||||
|
rusb = "0.9.2"
|
||||||
|
rand = "0.8.5"
|
||||||
|
clap = { version = "4.3.3", features = ["derive"] }
|
||||||
|
owo-colors = "3.5.0"
|
||||||
|
log = "0.4.19"
|
||||||
|
env_logger = "0.10.0"
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
rustdoc-args = ["--generate-link-to-definition"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "gen-assigned-numbers"
|
||||||
|
path = "tools/gen_assigned_numbers.rs"
|
||||||
|
required-features = ["bumble-codegen"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "bumble"
|
||||||
|
path = "src/main.rs"
|
||||||
|
required-features = ["bumble-tools"]
|
||||||
|
|
||||||
|
# test entry point that uses pyo3_asyncio's test harness
|
||||||
|
[[test]]
|
||||||
|
name = "pytests"
|
||||||
|
path = "pytests/pytests.rs"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
[features]
|
||||||
|
anyhow = ["pyo3/anyhow"]
|
||||||
|
pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"]
|
||||||
|
bumble-codegen = ["dep:anyhow"]
|
||||||
|
# separate feature for CLI so that dependencies don't spend time building these
|
||||||
|
bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger", "dep:futures"]
|
||||||
|
default = []
|
||||||
66
rust/README.md
Normal file
66
rust/README.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# What is this?
|
||||||
|
|
||||||
|
Rust wrappers around the [Bumble](https://github.com/google/bumble) Python API.
|
||||||
|
|
||||||
|
Method calls are mapped to the equivalent Python, and return types adapted where
|
||||||
|
relevant.
|
||||||
|
|
||||||
|
See the CLI in `src/main.rs` or the `examples` directory for how to use the
|
||||||
|
Bumble API.
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
Set up a virtualenv for Bumble, or otherwise have an isolated Python environment
|
||||||
|
for Bumble and its dependencies.
|
||||||
|
|
||||||
|
Due to Python being
|
||||||
|
[picky about how its sys path is set up](https://github.com/PyO3/pyo3/issues/1741,
|
||||||
|
it's necessary to explicitly point to the virtualenv's `site-packages`. Use
|
||||||
|
suitable virtualenv paths as appropriate for your OS, as seen here running
|
||||||
|
the `battery_client` example:
|
||||||
|
|
||||||
|
```
|
||||||
|
PYTHONPATH=..:~/.virtualenvs/bumble/lib/python3.10/site-packages/ \
|
||||||
|
cargo run --example battery_client -- \
|
||||||
|
--transport android-netsim --target-addr F0:F1:F2:F3:F4:F5
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the corresponding `battery_server` Python example, and launch an emulator in
|
||||||
|
Android Studio (currently, Canary is required) to run netsim.
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
|
||||||
|
Explore the available subcommands:
|
||||||
|
|
||||||
|
```
|
||||||
|
PYTHONPATH=..:[virtualenv site-packages] \
|
||||||
|
cargo run --features bumble-tools --bin bumble -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
# Development
|
||||||
|
|
||||||
|
Run the tests:
|
||||||
|
|
||||||
|
```
|
||||||
|
PYTHONPATH=.. cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
Check lints:
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo clippy --all-targets
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code gen
|
||||||
|
|
||||||
|
To have the fastest startup while keeping the build simple, code gen for
|
||||||
|
assigned numbers is done with the `gen_assigned_numbers` tool. It should
|
||||||
|
be re-run whenever the Python assigned numbers are changed. To ensure that the
|
||||||
|
generated code is kept up to date, the Rust data is compared to the Python
|
||||||
|
in tests at `pytests/assigned_numbers.rs`.
|
||||||
|
|
||||||
|
To regenerate the assigned number tables based on the Python codebase:
|
||||||
|
|
||||||
|
```
|
||||||
|
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features bumble-codegen
|
||||||
|
```
|
||||||
112
rust/examples/battery_client.rs
Normal file
112
rust/examples/battery_client.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Counterpart to the Python example `battery_server.py`.
|
||||||
|
//!
|
||||||
|
//! Start an Android emulator from Android Studio, or otherwise have netsim running.
|
||||||
|
//!
|
||||||
|
//! Run the server from the project root:
|
||||||
|
//! ```
|
||||||
|
//! PYTHONPATH=. python examples/battery_server.py \
|
||||||
|
//! examples/device1.json android-netsim
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Then run this example from the `rust` directory:
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! PYTHONPATH=..:/path/to/virtualenv/site-packages/ \
|
||||||
|
//! cargo run --example battery_client -- \
|
||||||
|
//! --transport android-netsim \
|
||||||
|
//! --target-addr F0:F1:F2:F3:F4:F5
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use bumble::wrapper::{
|
||||||
|
device::{Device, Peer},
|
||||||
|
profile::BatteryServiceProxy,
|
||||||
|
transport::Transport,
|
||||||
|
PyObjectExt,
|
||||||
|
};
|
||||||
|
use clap::Parser as _;
|
||||||
|
use log::info;
|
||||||
|
use owo_colors::OwoColorize;
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
|
||||||
|
#[pyo3_asyncio::tokio::main]
|
||||||
|
async fn main() -> PyResult<()> {
|
||||||
|
env_logger::builder()
|
||||||
|
.filter_level(log::LevelFilter::Info)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let transport = Transport::open(cli.transport).await?;
|
||||||
|
|
||||||
|
let device = Device::with_hci(
|
||||||
|
"Bumble",
|
||||||
|
"F0:F1:F2:F3:F4:F5",
|
||||||
|
transport.source()?,
|
||||||
|
transport.sink()?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
device.power_on().await?;
|
||||||
|
|
||||||
|
let conn = device.connect(&cli.target_addr).await?;
|
||||||
|
let mut peer = Peer::new(conn)?;
|
||||||
|
for mut s in peer.discover_services().await? {
|
||||||
|
s.discover_characteristics().await?;
|
||||||
|
}
|
||||||
|
let battery_service = peer
|
||||||
|
.create_service_proxy::<BatteryServiceProxy>()?
|
||||||
|
.ok_or(anyhow::anyhow!("No battery service found"))?;
|
||||||
|
|
||||||
|
let mut battery_level_char = battery_service
|
||||||
|
.battery_level()?
|
||||||
|
.ok_or(anyhow::anyhow!("No battery level characteristic"))?;
|
||||||
|
info!(
|
||||||
|
"{} {}",
|
||||||
|
"Initial Battery Level:".green(),
|
||||||
|
battery_level_char
|
||||||
|
.read_value()
|
||||||
|
.await?
|
||||||
|
.extract_with_gil::<u32>()?
|
||||||
|
);
|
||||||
|
battery_level_char
|
||||||
|
.subscribe(|_py, args| {
|
||||||
|
info!(
|
||||||
|
"{} {:?}",
|
||||||
|
"Battery level update:".green(),
|
||||||
|
args.get_item(0)?.extract::<u32>()?,
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// wait until user kills the process
|
||||||
|
tokio::signal::ctrl_c().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
/// Bumble transport spec.
|
||||||
|
///
|
||||||
|
/// <https://google.github.io/bumble/transports/index.html>
|
||||||
|
#[arg(long)]
|
||||||
|
transport: String,
|
||||||
|
|
||||||
|
/// Address to connect to
|
||||||
|
#[arg(long)]
|
||||||
|
target_addr: String,
|
||||||
|
}
|
||||||
98
rust/examples/broadcast.rs
Normal file
98
rust/examples/broadcast.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use bumble::{
|
||||||
|
adv::{AdvertisementDataBuilder, CommonDataType},
|
||||||
|
wrapper::{
|
||||||
|
device::Device,
|
||||||
|
logging::{bumble_env_logging_level, py_logging_basic_config},
|
||||||
|
transport::Transport,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use clap::Parser as _;
|
||||||
|
use pyo3::PyResult;
|
||||||
|
use rand::Rng;
|
||||||
|
use std::path;
|
||||||
|
|
||||||
|
#[pyo3_asyncio::tokio::main]
|
||||||
|
async fn main() -> PyResult<()> {
|
||||||
|
env_logger::builder()
|
||||||
|
.filter_level(log::LevelFilter::Info)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
if cli.log_hci {
|
||||||
|
py_logging_basic_config(bumble_env_logging_level("DEBUG"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let transport = Transport::open(cli.transport).await?;
|
||||||
|
|
||||||
|
let mut device = Device::from_config_file_with_hci(
|
||||||
|
&cli.device_config,
|
||||||
|
transport.source()?,
|
||||||
|
transport.sink()?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut adv_data = AdvertisementDataBuilder::new();
|
||||||
|
|
||||||
|
adv_data
|
||||||
|
.append(
|
||||||
|
CommonDataType::CompleteLocalName,
|
||||||
|
"Bumble from Rust".as_bytes(),
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow!(e))?;
|
||||||
|
|
||||||
|
// Randomized TX power
|
||||||
|
adv_data
|
||||||
|
.append(
|
||||||
|
CommonDataType::TxPowerLevel,
|
||||||
|
&[rand::thread_rng().gen_range(-100_i8..=20) as u8],
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow!(e))?;
|
||||||
|
|
||||||
|
device.set_advertising_data(adv_data)?;
|
||||||
|
device.power_on().await?;
|
||||||
|
|
||||||
|
println!("Advertising...");
|
||||||
|
device.start_advertising(true).await?;
|
||||||
|
|
||||||
|
// wait until user kills the process
|
||||||
|
tokio::signal::ctrl_c().await?;
|
||||||
|
|
||||||
|
println!("Stopping...");
|
||||||
|
device.stop_advertising().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
/// Bumble device config.
|
||||||
|
///
|
||||||
|
/// See, for instance, `examples/device1.json` in the Python project.
|
||||||
|
#[arg(long)]
|
||||||
|
device_config: path::PathBuf,
|
||||||
|
/// Bumble transport spec.
|
||||||
|
///
|
||||||
|
/// <https://google.github.io/bumble/transports/index.html>
|
||||||
|
#[arg(long)]
|
||||||
|
transport: String,
|
||||||
|
|
||||||
|
/// Log HCI commands
|
||||||
|
#[arg(long)]
|
||||||
|
log_hci: bool,
|
||||||
|
}
|
||||||
185
rust/examples/scanner.rs
Normal file
185
rust/examples/scanner.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Counterpart to the Python example `run_scanner.py`.
|
||||||
|
//!
|
||||||
|
//! Device deduplication is done here rather than relying on the controller's filtering to provide
|
||||||
|
//! for additional features, like the ability to make deduplication time-bounded.
|
||||||
|
|
||||||
|
use bumble::{
|
||||||
|
adv::CommonDataType,
|
||||||
|
wrapper::{
|
||||||
|
core::AdvertisementDataUnit, device::Device, hci::AddressType, transport::Transport,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use clap::Parser as _;
|
||||||
|
use itertools::Itertools;
|
||||||
|
use owo_colors::{OwoColorize, Style};
|
||||||
|
use pyo3::PyResult;
|
||||||
|
use std::{
|
||||||
|
collections,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[pyo3_asyncio::tokio::main]
|
||||||
|
async fn main() -> PyResult<()> {
|
||||||
|
env_logger::builder()
|
||||||
|
.filter_level(log::LevelFilter::Info)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let transport = Transport::open(cli.transport).await?;
|
||||||
|
|
||||||
|
let mut device = Device::with_hci(
|
||||||
|
"Bumble",
|
||||||
|
"F0:F1:F2:F3:F4:F5",
|
||||||
|
transport.source()?,
|
||||||
|
transport.sink()?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// in practice, devices can send multiple advertisements from the same address, so we keep
|
||||||
|
// track of a timestamp for each set of data
|
||||||
|
let seen_advertisements = Arc::new(Mutex::new(collections::HashMap::<
|
||||||
|
Vec<u8>,
|
||||||
|
collections::HashMap<Vec<AdvertisementDataUnit>, time::Instant>,
|
||||||
|
>::new()));
|
||||||
|
|
||||||
|
let seen_adv_clone = seen_advertisements.clone();
|
||||||
|
device.on_advertisement(move |_py, adv| {
|
||||||
|
let rssi = adv.rssi()?;
|
||||||
|
let data_units = adv.data()?.data_units()?;
|
||||||
|
let addr = adv.address()?;
|
||||||
|
|
||||||
|
let show_adv = if cli.filter_duplicates {
|
||||||
|
let addr_bytes = addr.as_le_bytes()?;
|
||||||
|
|
||||||
|
let mut seen_adv_cache = seen_adv_clone.lock().unwrap();
|
||||||
|
let expiry_duration = time::Duration::from_secs(cli.dedup_expiry_secs);
|
||||||
|
|
||||||
|
let advs_from_addr = seen_adv_cache
|
||||||
|
.entry(addr_bytes)
|
||||||
|
.or_insert_with(collections::HashMap::new);
|
||||||
|
// we expect cache hits to be the norm, so we do a separate lookup to avoid cloning
|
||||||
|
// on every lookup with entry()
|
||||||
|
let show = if let Some(prev) = advs_from_addr.get_mut(&data_units) {
|
||||||
|
let expired = prev.elapsed() > expiry_duration;
|
||||||
|
*prev = time::Instant::now();
|
||||||
|
expired
|
||||||
|
} else {
|
||||||
|
advs_from_addr.insert(data_units.clone(), time::Instant::now());
|
||||||
|
true
|
||||||
|
};
|
||||||
|
|
||||||
|
// clean out anything we haven't seen in a while
|
||||||
|
advs_from_addr.retain(|_, instant| instant.elapsed() <= expiry_duration);
|
||||||
|
|
||||||
|
show
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
};
|
||||||
|
|
||||||
|
if !show_adv {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let addr_style = if adv.is_connectable()? {
|
||||||
|
Style::new().yellow()
|
||||||
|
} else {
|
||||||
|
Style::new().red()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (type_style, qualifier) = match adv.address()?.address_type()? {
|
||||||
|
AddressType::PublicIdentity | AddressType::PublicDevice => (Style::new().cyan(), ""),
|
||||||
|
_ => {
|
||||||
|
if addr.is_static()? {
|
||||||
|
(Style::new().green(), "(static)")
|
||||||
|
} else if addr.is_resolvable()? {
|
||||||
|
(Style::new().magenta(), "(resolvable)")
|
||||||
|
} else {
|
||||||
|
(Style::new().default_color(), "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
">>> {} [{:?}] {qualifier}:\n RSSI: {}",
|
||||||
|
addr.as_hex()?.style(addr_style),
|
||||||
|
addr.address_type()?.style(type_style),
|
||||||
|
rssi,
|
||||||
|
);
|
||||||
|
|
||||||
|
data_units.into_iter().for_each(|(code, data)| {
|
||||||
|
let matching = CommonDataType::for_type_code(code).collect::<Vec<_>>();
|
||||||
|
let code_str = if matching.is_empty() {
|
||||||
|
format!("0x{}", hex::encode_upper([code.into()]))
|
||||||
|
} else {
|
||||||
|
matching
|
||||||
|
.iter()
|
||||||
|
.map(|t| format!("{}", t))
|
||||||
|
.join(" / ")
|
||||||
|
.blue()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// use the first matching type's formatted data, if any
|
||||||
|
let data_str = matching
|
||||||
|
.iter()
|
||||||
|
.filter_map(|t| {
|
||||||
|
t.format_data(&data).map(|formatted| {
|
||||||
|
format!(
|
||||||
|
"{} {}",
|
||||||
|
formatted,
|
||||||
|
format!("(raw: 0x{})", hex::encode_upper(&data)).dimmed()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.next()
|
||||||
|
.unwrap_or_else(|| format!("0x{}", hex::encode_upper(&data)));
|
||||||
|
|
||||||
|
println!(" [{}]: {}", code_str, data_str)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
device.power_on().await?;
|
||||||
|
// do our own dedup
|
||||||
|
device.start_scanning(false).await?;
|
||||||
|
|
||||||
|
// wait until user kills the process
|
||||||
|
tokio::signal::ctrl_c().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
/// Bumble transport spec.
|
||||||
|
///
|
||||||
|
/// <https://google.github.io/bumble/transports/index.html>
|
||||||
|
#[arg(long)]
|
||||||
|
transport: String,
|
||||||
|
|
||||||
|
/// Filter duplicate advertisements
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
filter_duplicates: bool,
|
||||||
|
|
||||||
|
/// How long before a deduplicated advertisement that hasn't been seen in a while is considered
|
||||||
|
/// fresh again, in seconds
|
||||||
|
#[arg(long, default_value_t = 10, requires = "filter_duplicates")]
|
||||||
|
dedup_expiry_secs: u64,
|
||||||
|
}
|
||||||
30
rust/pytests/assigned_numbers.rs
Normal file
30
rust/pytests/assigned_numbers.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use bumble::wrapper::{self, core::Uuid16};
|
||||||
|
use pyo3::{intern, prelude::*, types::PyDict};
|
||||||
|
use std::collections;
|
||||||
|
|
||||||
|
#[pyo3_asyncio::tokio::test]
|
||||||
|
async fn company_ids_matches_python() -> PyResult<()> {
|
||||||
|
let ids_from_python = Python::with_gil(|py| {
|
||||||
|
PyModule::import(py, intern!(py, "bumble.company_ids"))?
|
||||||
|
.getattr(intern!(py, "COMPANY_IDENTIFIERS"))?
|
||||||
|
.downcast::<PyDict>()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| {
|
||||||
|
Ok((
|
||||||
|
Uuid16::from_be_bytes(k.extract::<u16>()?.to_be_bytes()),
|
||||||
|
v.str()?.to_str()?.to_string(),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect::<PyResult<collections::HashMap<_, _>>>()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
wrapper::assigned_numbers::COMPANY_IDS
|
||||||
|
.iter()
|
||||||
|
.map(|(id, name)| (*id, name.to_string()))
|
||||||
|
.collect::<collections::HashMap<_, _>>(),
|
||||||
|
ids_from_python,
|
||||||
|
"Company ids do not match -- re-run gen_assigned_numbers?"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
21
rust/pytests/pytests.rs
Normal file
21
rust/pytests/pytests.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
#[pyo3_asyncio::tokio::main]
|
||||||
|
async fn main() -> pyo3::PyResult<()> {
|
||||||
|
pyo3_asyncio::testing::main().await
|
||||||
|
}
|
||||||
|
|
||||||
|
mod assigned_numbers;
|
||||||
|
mod wrapper;
|
||||||
37
rust/pytests/wrapper.rs
Normal file
37
rust/pytests/wrapper.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use bumble::wrapper::{drivers::rtk::DriverInfo, transport::Transport};
|
||||||
|
use nix::sys::stat::Mode;
|
||||||
|
use pyo3::PyResult;
|
||||||
|
|
||||||
|
#[pyo3_asyncio::tokio::test]
|
||||||
|
async fn fifo_transport_can_open() -> PyResult<()> {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut fifo = dir.path().to_path_buf();
|
||||||
|
fifo.push("bumble-transport-fifo");
|
||||||
|
nix::unistd::mkfifo(&fifo, Mode::S_IRWXU).unwrap();
|
||||||
|
|
||||||
|
let mut t = Transport::open(format!("file:{}", fifo.to_str().unwrap())).await?;
|
||||||
|
|
||||||
|
t.close().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyo3_asyncio::tokio::test]
|
||||||
|
async fn realtek_driver_info_all_drivers() -> PyResult<()> {
|
||||||
|
assert_eq!(12, DriverInfo::all_drivers()?.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
4
rust/resources/test/firmware/realtek/README.md
Normal file
4
rust/resources/test/firmware/realtek/README.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
This dir contains samples firmware images in the format used for Realtek chips,
|
||||||
|
but with repetitions of the length of the section as a little-endian 32-bit int
|
||||||
|
for the patch data instead of actual firmware, since we only need the structure
|
||||||
|
to test parsing.
|
||||||
BIN
rust/resources/test/firmware/realtek/rtl8723b_fw_structure.bin
Normal file
BIN
rust/resources/test/firmware/realtek/rtl8723b_fw_structure.bin
Normal file
Binary file not shown.
BIN
rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin
Normal file
BIN
rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin
Normal file
Binary file not shown.
446
rust/src/adv.rs
Normal file
446
rust/src/adv.rs
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
//! BLE advertisements.
|
||||||
|
|
||||||
|
use crate::wrapper::assigned_numbers::{COMPANY_IDS, SERVICE_IDS};
|
||||||
|
use crate::wrapper::core::{Uuid128, Uuid16, Uuid32};
|
||||||
|
use itertools::Itertools;
|
||||||
|
use nom::{combinator, multi, number};
|
||||||
|
use std::fmt;
|
||||||
|
use strum::IntoEnumIterator;
|
||||||
|
|
||||||
|
/// The numeric code for a common data type.
|
||||||
|
///
|
||||||
|
/// For known types, see [CommonDataType], or use this type directly for non-assigned codes.
|
||||||
|
#[derive(PartialEq, Eq, Debug, Clone, Copy, Hash)]
|
||||||
|
pub struct CommonDataTypeCode(u8);
|
||||||
|
|
||||||
|
impl From<CommonDataType> for CommonDataTypeCode {
|
||||||
|
fn from(value: CommonDataType) -> Self {
|
||||||
|
let byte = match value {
|
||||||
|
CommonDataType::Flags => 0x01,
|
||||||
|
CommonDataType::IncompleteListOf16BitServiceClassUuids => 0x02,
|
||||||
|
CommonDataType::CompleteListOf16BitServiceClassUuids => 0x03,
|
||||||
|
CommonDataType::IncompleteListOf32BitServiceClassUuids => 0x04,
|
||||||
|
CommonDataType::CompleteListOf32BitServiceClassUuids => 0x05,
|
||||||
|
CommonDataType::IncompleteListOf128BitServiceClassUuids => 0x06,
|
||||||
|
CommonDataType::CompleteListOf128BitServiceClassUuids => 0x07,
|
||||||
|
CommonDataType::ShortenedLocalName => 0x08,
|
||||||
|
CommonDataType::CompleteLocalName => 0x09,
|
||||||
|
CommonDataType::TxPowerLevel => 0x0A,
|
||||||
|
CommonDataType::ClassOfDevice => 0x0D,
|
||||||
|
CommonDataType::SimplePairingHashC192 => 0x0E,
|
||||||
|
CommonDataType::SimplePairingRandomizerR192 => 0x0F,
|
||||||
|
// These two both really have type code 0x10! D:
|
||||||
|
CommonDataType::DeviceId => 0x10,
|
||||||
|
CommonDataType::SecurityManagerTkValue => 0x10,
|
||||||
|
CommonDataType::SecurityManagerOutOfBandFlags => 0x11,
|
||||||
|
CommonDataType::PeripheralConnectionIntervalRange => 0x12,
|
||||||
|
CommonDataType::ListOf16BitServiceSolicitationUuids => 0x14,
|
||||||
|
CommonDataType::ListOf128BitServiceSolicitationUuids => 0x15,
|
||||||
|
CommonDataType::ServiceData16BitUuid => 0x16,
|
||||||
|
CommonDataType::PublicTargetAddress => 0x17,
|
||||||
|
CommonDataType::RandomTargetAddress => 0x18,
|
||||||
|
CommonDataType::Appearance => 0x19,
|
||||||
|
CommonDataType::AdvertisingInterval => 0x1A,
|
||||||
|
CommonDataType::LeBluetoothDeviceAddress => 0x1B,
|
||||||
|
CommonDataType::LeRole => 0x1C,
|
||||||
|
CommonDataType::SimplePairingHashC256 => 0x1D,
|
||||||
|
CommonDataType::SimplePairingRandomizerR256 => 0x1E,
|
||||||
|
CommonDataType::ListOf32BitServiceSolicitationUuids => 0x1F,
|
||||||
|
CommonDataType::ServiceData32BitUuid => 0x20,
|
||||||
|
CommonDataType::ServiceData128BitUuid => 0x21,
|
||||||
|
CommonDataType::LeSecureConnectionsConfirmationValue => 0x22,
|
||||||
|
CommonDataType::LeSecureConnectionsRandomValue => 0x23,
|
||||||
|
CommonDataType::Uri => 0x24,
|
||||||
|
CommonDataType::IndoorPositioning => 0x25,
|
||||||
|
CommonDataType::TransportDiscoveryData => 0x26,
|
||||||
|
CommonDataType::LeSupportedFeatures => 0x27,
|
||||||
|
CommonDataType::ChannelMapUpdateIndication => 0x28,
|
||||||
|
CommonDataType::PbAdv => 0x29,
|
||||||
|
CommonDataType::MeshMessage => 0x2A,
|
||||||
|
CommonDataType::MeshBeacon => 0x2B,
|
||||||
|
CommonDataType::BigInfo => 0x2C,
|
||||||
|
CommonDataType::BroadcastCode => 0x2D,
|
||||||
|
CommonDataType::ResolvableSetIdentifier => 0x2E,
|
||||||
|
CommonDataType::AdvertisingIntervalLong => 0x2F,
|
||||||
|
CommonDataType::ThreeDInformationData => 0x3D,
|
||||||
|
CommonDataType::ManufacturerSpecificData => 0xFF,
|
||||||
|
};
|
||||||
|
|
||||||
|
Self(byte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u8> for CommonDataTypeCode {
|
||||||
|
fn from(value: u8) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CommonDataTypeCode> for u8 {
|
||||||
|
fn from(value: CommonDataTypeCode) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data types for assigned type codes.
|
||||||
|
///
|
||||||
|
/// See Bluetooth Assigned Numbers § 2.3
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum_macros::EnumIter)]
|
||||||
|
#[allow(missing_docs)]
|
||||||
|
pub enum CommonDataType {
|
||||||
|
Flags,
|
||||||
|
IncompleteListOf16BitServiceClassUuids,
|
||||||
|
CompleteListOf16BitServiceClassUuids,
|
||||||
|
IncompleteListOf32BitServiceClassUuids,
|
||||||
|
CompleteListOf32BitServiceClassUuids,
|
||||||
|
IncompleteListOf128BitServiceClassUuids,
|
||||||
|
CompleteListOf128BitServiceClassUuids,
|
||||||
|
ShortenedLocalName,
|
||||||
|
CompleteLocalName,
|
||||||
|
TxPowerLevel,
|
||||||
|
ClassOfDevice,
|
||||||
|
SimplePairingHashC192,
|
||||||
|
SimplePairingRandomizerR192,
|
||||||
|
DeviceId,
|
||||||
|
SecurityManagerTkValue,
|
||||||
|
SecurityManagerOutOfBandFlags,
|
||||||
|
PeripheralConnectionIntervalRange,
|
||||||
|
ListOf16BitServiceSolicitationUuids,
|
||||||
|
ListOf128BitServiceSolicitationUuids,
|
||||||
|
ServiceData16BitUuid,
|
||||||
|
PublicTargetAddress,
|
||||||
|
RandomTargetAddress,
|
||||||
|
Appearance,
|
||||||
|
AdvertisingInterval,
|
||||||
|
LeBluetoothDeviceAddress,
|
||||||
|
LeRole,
|
||||||
|
SimplePairingHashC256,
|
||||||
|
SimplePairingRandomizerR256,
|
||||||
|
ListOf32BitServiceSolicitationUuids,
|
||||||
|
ServiceData32BitUuid,
|
||||||
|
ServiceData128BitUuid,
|
||||||
|
LeSecureConnectionsConfirmationValue,
|
||||||
|
LeSecureConnectionsRandomValue,
|
||||||
|
Uri,
|
||||||
|
IndoorPositioning,
|
||||||
|
TransportDiscoveryData,
|
||||||
|
LeSupportedFeatures,
|
||||||
|
ChannelMapUpdateIndication,
|
||||||
|
PbAdv,
|
||||||
|
MeshMessage,
|
||||||
|
MeshBeacon,
|
||||||
|
BigInfo,
|
||||||
|
BroadcastCode,
|
||||||
|
ResolvableSetIdentifier,
|
||||||
|
AdvertisingIntervalLong,
|
||||||
|
ThreeDInformationData,
|
||||||
|
ManufacturerSpecificData,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonDataType {
|
||||||
|
/// Iterate over the zero, one, or more matching types for the provided code.
|
||||||
|
///
|
||||||
|
/// `0x10` maps to both Device Id and Security Manager TK Value, so multiple matching types
|
||||||
|
/// may exist for a single code.
|
||||||
|
pub fn for_type_code(code: CommonDataTypeCode) -> impl Iterator<Item = CommonDataType> {
|
||||||
|
Self::iter().filter(move |t| CommonDataTypeCode::from(*t) == code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply type-specific human-oriented formatting to data, if any is applicable
|
||||||
|
pub fn format_data(&self, data: &[u8]) -> Option<String> {
|
||||||
|
match self {
|
||||||
|
Self::Flags => Some(Flags::matching(data).map(|f| format!("{:?}", f)).join(",")),
|
||||||
|
Self::CompleteListOf16BitServiceClassUuids
|
||||||
|
| Self::IncompleteListOf16BitServiceClassUuids
|
||||||
|
| Self::ListOf16BitServiceSolicitationUuids => {
|
||||||
|
combinator::complete(multi::many0(Uuid16::parse_le))(data)
|
||||||
|
.map(|(_res, uuids)| {
|
||||||
|
uuids
|
||||||
|
.into_iter()
|
||||||
|
.map(|uuid| {
|
||||||
|
SERVICE_IDS
|
||||||
|
.get(&uuid)
|
||||||
|
.map(|name| format!("{:?} ({name})", uuid))
|
||||||
|
.unwrap_or_else(|| format!("{:?}", uuid))
|
||||||
|
})
|
||||||
|
.join(", ")
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
Self::CompleteListOf32BitServiceClassUuids
|
||||||
|
| Self::IncompleteListOf32BitServiceClassUuids
|
||||||
|
| Self::ListOf32BitServiceSolicitationUuids => {
|
||||||
|
combinator::complete(multi::many0(Uuid32::parse))(data)
|
||||||
|
.map(|(_res, uuids)| uuids.into_iter().map(|u| format!("{:?}", u)).join(", "))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
Self::CompleteListOf128BitServiceClassUuids
|
||||||
|
| Self::IncompleteListOf128BitServiceClassUuids
|
||||||
|
| Self::ListOf128BitServiceSolicitationUuids => {
|
||||||
|
combinator::complete(multi::many0(Uuid128::parse_le))(data)
|
||||||
|
.map(|(_res, uuids)| uuids.into_iter().map(|u| format!("{:?}", u)).join(", "))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
Self::ServiceData16BitUuid => Uuid16::parse_le(data)
|
||||||
|
.map(|(rem, uuid)| {
|
||||||
|
format!(
|
||||||
|
"service={:?}, data={}",
|
||||||
|
SERVICE_IDS
|
||||||
|
.get(&uuid)
|
||||||
|
.map(|name| format!("{:?} ({name})", uuid))
|
||||||
|
.unwrap_or_else(|| format!("{:?}", uuid)),
|
||||||
|
hex::encode_upper(rem)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.ok(),
|
||||||
|
Self::ServiceData32BitUuid => Uuid32::parse(data)
|
||||||
|
.map(|(rem, uuid)| format!("service={:?}, data={}", uuid, hex::encode_upper(rem)))
|
||||||
|
.ok(),
|
||||||
|
Self::ServiceData128BitUuid => Uuid128::parse_le(data)
|
||||||
|
.map(|(rem, uuid)| format!("service={:?}, data={}", uuid, hex::encode_upper(rem)))
|
||||||
|
.ok(),
|
||||||
|
Self::ShortenedLocalName | Self::CompleteLocalName => {
|
||||||
|
std::str::from_utf8(data).ok().map(|s| format!("\"{}\"", s))
|
||||||
|
}
|
||||||
|
Self::TxPowerLevel => {
|
||||||
|
let (_, tx) =
|
||||||
|
combinator::complete(number::complete::i8::<_, nom::error::Error<_>>)(data)
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
Some(tx.to_string())
|
||||||
|
}
|
||||||
|
Self::ManufacturerSpecificData => {
|
||||||
|
let (rem, id) = Uuid16::parse_le(data).ok()?;
|
||||||
|
Some(format!(
|
||||||
|
"company={}, data=0x{}",
|
||||||
|
COMPANY_IDS
|
||||||
|
.get(&id)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| format!("{:?}", id)),
|
||||||
|
hex::encode_upper(rem)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CommonDataType {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
CommonDataType::Flags => write!(f, "Flags"),
|
||||||
|
CommonDataType::IncompleteListOf16BitServiceClassUuids => {
|
||||||
|
write!(f, "Incomplete List of 16-bit Service Class UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::CompleteListOf16BitServiceClassUuids => {
|
||||||
|
write!(f, "Complete List of 16-bit Service Class UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::IncompleteListOf32BitServiceClassUuids => {
|
||||||
|
write!(f, "Incomplete List of 32-bit Service Class UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::CompleteListOf32BitServiceClassUuids => {
|
||||||
|
write!(f, "Complete List of 32-bit Service Class UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::ListOf16BitServiceSolicitationUuids => {
|
||||||
|
write!(f, "List of 16-bit Service Solicitation UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::ListOf32BitServiceSolicitationUuids => {
|
||||||
|
write!(f, "List of 32-bit Service Solicitation UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::ListOf128BitServiceSolicitationUuids => {
|
||||||
|
write!(f, "List of 128-bit Service Solicitation UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::IncompleteListOf128BitServiceClassUuids => {
|
||||||
|
write!(f, "Incomplete List of 128-bit Service Class UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::CompleteListOf128BitServiceClassUuids => {
|
||||||
|
write!(f, "Complete List of 128-bit Service Class UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::ShortenedLocalName => write!(f, "Shortened Local Name"),
|
||||||
|
CommonDataType::CompleteLocalName => write!(f, "Complete Local Name"),
|
||||||
|
CommonDataType::TxPowerLevel => write!(f, "TX Power Level"),
|
||||||
|
CommonDataType::ClassOfDevice => write!(f, "Class of Device"),
|
||||||
|
CommonDataType::SimplePairingHashC192 => {
|
||||||
|
write!(f, "Simple Pairing Hash C-192")
|
||||||
|
}
|
||||||
|
CommonDataType::SimplePairingHashC256 => {
|
||||||
|
write!(f, "Simple Pairing Hash C 256")
|
||||||
|
}
|
||||||
|
CommonDataType::SimplePairingRandomizerR192 => {
|
||||||
|
write!(f, "Simple Pairing Randomizer R-192")
|
||||||
|
}
|
||||||
|
CommonDataType::SimplePairingRandomizerR256 => {
|
||||||
|
write!(f, "Simple Pairing Randomizer R 256")
|
||||||
|
}
|
||||||
|
CommonDataType::DeviceId => write!(f, "Device Id"),
|
||||||
|
CommonDataType::SecurityManagerTkValue => {
|
||||||
|
write!(f, "Security Manager TK Value")
|
||||||
|
}
|
||||||
|
CommonDataType::SecurityManagerOutOfBandFlags => {
|
||||||
|
write!(f, "Security Manager Out of Band Flags")
|
||||||
|
}
|
||||||
|
CommonDataType::PeripheralConnectionIntervalRange => {
|
||||||
|
write!(f, "Peripheral Connection Interval Range")
|
||||||
|
}
|
||||||
|
CommonDataType::ServiceData16BitUuid => {
|
||||||
|
write!(f, "Service Data 16-bit UUID")
|
||||||
|
}
|
||||||
|
CommonDataType::ServiceData32BitUuid => {
|
||||||
|
write!(f, "Service Data 32-bit UUID")
|
||||||
|
}
|
||||||
|
CommonDataType::ServiceData128BitUuid => {
|
||||||
|
write!(f, "Service Data 128-bit UUID")
|
||||||
|
}
|
||||||
|
CommonDataType::PublicTargetAddress => write!(f, "Public Target Address"),
|
||||||
|
CommonDataType::RandomTargetAddress => write!(f, "Random Target Address"),
|
||||||
|
CommonDataType::Appearance => write!(f, "Appearance"),
|
||||||
|
CommonDataType::AdvertisingInterval => write!(f, "Advertising Interval"),
|
||||||
|
CommonDataType::LeBluetoothDeviceAddress => {
|
||||||
|
write!(f, "LE Bluetooth Device Address")
|
||||||
|
}
|
||||||
|
CommonDataType::LeRole => write!(f, "LE Role"),
|
||||||
|
CommonDataType::LeSecureConnectionsConfirmationValue => {
|
||||||
|
write!(f, "LE Secure Connections Confirmation Value")
|
||||||
|
}
|
||||||
|
CommonDataType::LeSecureConnectionsRandomValue => {
|
||||||
|
write!(f, "LE Secure Connections Random Value")
|
||||||
|
}
|
||||||
|
CommonDataType::LeSupportedFeatures => write!(f, "LE Supported Features"),
|
||||||
|
CommonDataType::Uri => write!(f, "URI"),
|
||||||
|
CommonDataType::IndoorPositioning => write!(f, "Indoor Positioning"),
|
||||||
|
CommonDataType::TransportDiscoveryData => {
|
||||||
|
write!(f, "Transport Discovery Data")
|
||||||
|
}
|
||||||
|
CommonDataType::ChannelMapUpdateIndication => {
|
||||||
|
write!(f, "Channel Map Update Indication")
|
||||||
|
}
|
||||||
|
CommonDataType::PbAdv => write!(f, "PB-ADV"),
|
||||||
|
CommonDataType::MeshMessage => write!(f, "Mesh Message"),
|
||||||
|
CommonDataType::MeshBeacon => write!(f, "Mesh Beacon"),
|
||||||
|
CommonDataType::BigInfo => write!(f, "BIGIInfo"),
|
||||||
|
CommonDataType::BroadcastCode => write!(f, "Broadcast Code"),
|
||||||
|
CommonDataType::ResolvableSetIdentifier => {
|
||||||
|
write!(f, "Resolvable Set Identifier")
|
||||||
|
}
|
||||||
|
CommonDataType::AdvertisingIntervalLong => {
|
||||||
|
write!(f, "Advertising Interval Long")
|
||||||
|
}
|
||||||
|
CommonDataType::ThreeDInformationData => write!(f, "3D Information Data"),
|
||||||
|
CommonDataType::ManufacturerSpecificData => {
|
||||||
|
write!(f, "Manufacturer Specific Data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulates advertisement data to broadcast on a [crate::wrapper::device::Device].
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AdvertisementDataBuilder {
|
||||||
|
encoded_data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdvertisementDataBuilder {
|
||||||
|
/// Returns a new, empty instance.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
encoded_data: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append advertising data to the builder.
|
||||||
|
///
|
||||||
|
/// Returns an error if the data cannot be appended.
|
||||||
|
pub fn append(
|
||||||
|
&mut self,
|
||||||
|
type_code: impl Into<CommonDataTypeCode>,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<(), AdvertisementDataBuilderError> {
|
||||||
|
self.encoded_data.push(
|
||||||
|
data.len()
|
||||||
|
.try_into()
|
||||||
|
.ok()
|
||||||
|
.and_then(|len: u8| len.checked_add(1))
|
||||||
|
.ok_or(AdvertisementDataBuilderError::DataTooLong)?,
|
||||||
|
);
|
||||||
|
self.encoded_data.push(type_code.into().0);
|
||||||
|
self.encoded_data.extend_from_slice(data);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn into_bytes(self) -> Vec<u8> {
|
||||||
|
self.encoded_data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors that can occur when building advertisement data with [AdvertisementDataBuilder].
|
||||||
|
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum AdvertisementDataBuilderError {
|
||||||
|
/// The provided adv data is too long to be encoded
|
||||||
|
#[error("Data too long")]
|
||||||
|
DataTooLong,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, strum_macros::EnumIter)]
|
||||||
|
#[allow(missing_docs)]
|
||||||
|
/// Features in the Flags AD
|
||||||
|
pub enum Flags {
|
||||||
|
LeLimited,
|
||||||
|
LeDiscoverable,
|
||||||
|
NoBrEdr,
|
||||||
|
BrEdrController,
|
||||||
|
BrEdrHost,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Flags {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.short_name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Flags {
|
||||||
|
/// Iterates over the flags that are present in the provided `flags` bytes.
|
||||||
|
pub fn matching(flags: &[u8]) -> impl Iterator<Item = Self> + '_ {
|
||||||
|
// The encoding is not clear from the spec: do we look at the first byte? or the last?
|
||||||
|
// In practice it's only one byte.
|
||||||
|
let first_byte = flags.first().unwrap_or(&0_u8);
|
||||||
|
|
||||||
|
Self::iter().filter(move |f| {
|
||||||
|
let mask = match f {
|
||||||
|
Flags::LeLimited => 0x01_u8,
|
||||||
|
Flags::LeDiscoverable => 0x02,
|
||||||
|
Flags::NoBrEdr => 0x04,
|
||||||
|
Flags::BrEdrController => 0x08,
|
||||||
|
Flags::BrEdrHost => 0x10,
|
||||||
|
};
|
||||||
|
|
||||||
|
mask & first_byte > 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An abbreviated form of the flag name.
|
||||||
|
///
|
||||||
|
/// See [Flags::name] for the full name.
|
||||||
|
pub fn short_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Flags::LeLimited => "LE Limited",
|
||||||
|
Flags::LeDiscoverable => "LE General",
|
||||||
|
Flags::NoBrEdr => "No BR/EDR",
|
||||||
|
Flags::BrEdrController => "BR/EDR C",
|
||||||
|
Flags::BrEdrHost => "BR/EDR H",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The human-readable name of the flag.
|
||||||
|
///
|
||||||
|
/// See [Flags::short_name] for a shorter string for use if compactness is important.
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Flags::LeLimited => "LE Limited Discoverable Mode",
|
||||||
|
Flags::LeDiscoverable => "LE General Discoverable Mode",
|
||||||
|
Flags::NoBrEdr => "BR/EDR Not Supported",
|
||||||
|
Flags::BrEdrController => "Simultaneous LE and BR/EDR (Controller)",
|
||||||
|
Flags::BrEdrHost => "Simultaneous LE and BR/EDR (Host)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
rust/src/cli/firmware/mod.rs
Normal file
15
rust/src/cli/firmware/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
pub(crate) mod rtk;
|
||||||
265
rust/src/cli/firmware/rtk.rs
Normal file
265
rust/src/cli/firmware/rtk.rs
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Realtek firmware tools
|
||||||
|
|
||||||
|
use crate::{Download, Source};
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use bumble::wrapper::{
|
||||||
|
drivers::rtk::{Driver, DriverInfo, Firmware},
|
||||||
|
host::{DriverFactory, Host},
|
||||||
|
transport::Transport,
|
||||||
|
};
|
||||||
|
use owo_colors::{colors::css, OwoColorize};
|
||||||
|
use pyo3::PyResult;
|
||||||
|
use std::{fs, path};
|
||||||
|
|
||||||
|
pub(crate) async fn download(dl: Download) -> PyResult<()> {
|
||||||
|
let data_dir = dl
|
||||||
|
.output_dir
|
||||||
|
.or_else(|| {
|
||||||
|
directories::ProjectDirs::from("com", "google", "bumble")
|
||||||
|
.map(|pd| pd.data_local_dir().join("firmware").join("realtek"))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
eprintln!("Could not determine standard data directory");
|
||||||
|
path::PathBuf::from(".")
|
||||||
|
});
|
||||||
|
fs::create_dir_all(&data_dir)?;
|
||||||
|
|
||||||
|
let (base_url, uses_bin_suffix) = match dl.source {
|
||||||
|
Source::LinuxKernel => ("https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git/plain/rtl_bt", true),
|
||||||
|
Source::RealtekOpensource => ("https://github.com/Realtek-OpenSource/android_hardware_realtek/raw/rtk1395/bt/rtkbt/Firmware/BT", false),
|
||||||
|
Source::LinuxFromScratch => ("https://anduin.linuxfromscratch.org/sources/linux-firmware/rtl_bt", true),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Downloading");
|
||||||
|
println!("{} {}", "FROM:".green(), base_url);
|
||||||
|
println!("{} {}", "TO:".green(), data_dir.to_string_lossy());
|
||||||
|
|
||||||
|
let url_for_file = |file_name: &str| {
|
||||||
|
let url_suffix = if uses_bin_suffix {
|
||||||
|
file_name
|
||||||
|
} else {
|
||||||
|
file_name.trim_end_matches(".bin")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut url = base_url.to_string();
|
||||||
|
url.push('/');
|
||||||
|
url.push_str(url_suffix);
|
||||||
|
url
|
||||||
|
};
|
||||||
|
|
||||||
|
let to_download = if let Some(single) = dl.single {
|
||||||
|
vec![(
|
||||||
|
format!("{single}_fw.bin"),
|
||||||
|
Some(format!("{single}_config.bin")),
|
||||||
|
false,
|
||||||
|
)]
|
||||||
|
} else {
|
||||||
|
DriverInfo::all_drivers()?
|
||||||
|
.iter()
|
||||||
|
.map(|di| Ok((di.firmware_name()?, di.config_name()?, di.config_needed()?)))
|
||||||
|
.collect::<PyResult<Vec<_>>>()?
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = SimpleClient::new();
|
||||||
|
|
||||||
|
for (fw_filename, config_filename, config_needed) in to_download {
|
||||||
|
println!("{}", "---".yellow());
|
||||||
|
let fw_path = data_dir.join(&fw_filename);
|
||||||
|
let config_path = config_filename.as_ref().map(|f| data_dir.join(f));
|
||||||
|
|
||||||
|
if fw_path.exists() && !dl.overwrite {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!("{} already exists, skipping", fw_path.to_string_lossy())
|
||||||
|
.fg::<css::Orange>()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(cp) = config_path.as_ref() {
|
||||||
|
if cp.exists() && !dl.overwrite {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!("{} already exists, skipping", cp.to_string_lossy())
|
||||||
|
.fg::<css::Orange>()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fw_contents = match client.get(&url_for_file(&fw_filename)).await {
|
||||||
|
Ok(data) => {
|
||||||
|
println!("Downloaded {}: {} bytes", fw_filename, data.len());
|
||||||
|
data
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"{} {} {:?}",
|
||||||
|
"Failed to download".red(),
|
||||||
|
fw_filename.red(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let config_contents = if let Some(cn) = &config_filename {
|
||||||
|
match client.get(&url_for_file(cn)).await {
|
||||||
|
Ok(data) => {
|
||||||
|
println!("Downloaded {}: {} bytes", cn, data.len());
|
||||||
|
Some(data)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if config_needed {
|
||||||
|
eprintln!("{} {} {:?}", "Failed to download".red(), cn.red(), e);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
format!("No config available as {cn}").fg::<css::Orange>()
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
fs::write(&fw_path, &fw_contents)?;
|
||||||
|
if !dl.no_parse && config_filename.is_some() {
|
||||||
|
println!("{} {}", "Parsing:".cyan(), &fw_filename);
|
||||||
|
match Firmware::parse(&fw_contents).map_err(|e| anyhow!("Parse error: {:?}", e)) {
|
||||||
|
Ok(fw) => dump_firmware_desc(&fw),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"{} {:?}",
|
||||||
|
"Could not parse firmware:".fg::<css::Orange>(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((cp, cd)) = config_path
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| config_contents.map(|c| (p, c)))
|
||||||
|
{
|
||||||
|
fs::write(cp, &cd)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse(firmware_path: &path::Path) -> PyResult<()> {
|
||||||
|
let contents = fs::read(firmware_path)?;
|
||||||
|
let fw = Firmware::parse(&contents)
|
||||||
|
// squish the error into a string to avoid the error type requiring that the input be
|
||||||
|
// 'static
|
||||||
|
.map_err(|e| anyhow!("Parse error: {:?}", e))?;
|
||||||
|
|
||||||
|
dump_firmware_desc(&fw);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn info(transport: &str, force: bool) -> PyResult<()> {
|
||||||
|
let transport = Transport::open(transport).await?;
|
||||||
|
|
||||||
|
let mut host = Host::new(transport.source()?, transport.sink()?)?;
|
||||||
|
host.reset(DriverFactory::None).await?;
|
||||||
|
|
||||||
|
if !force && !Driver::check(&host).await? {
|
||||||
|
println!("USB device not supported by this RTK driver");
|
||||||
|
} else if let Some(driver_info) = Driver::driver_info_for_host(&host).await? {
|
||||||
|
println!("Driver:");
|
||||||
|
println!(" {:10} {:04X}", "ROM:", driver_info.rom()?);
|
||||||
|
println!(" {:10} {}", "Firmware:", driver_info.firmware_name()?);
|
||||||
|
println!(
|
||||||
|
" {:10} {}",
|
||||||
|
"Config:",
|
||||||
|
driver_info.config_name()?.unwrap_or_default()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("Firmware already loaded or no supported driver for this device.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn load(transport: &str, force: bool) -> PyResult<()> {
|
||||||
|
let transport = Transport::open(transport).await?;
|
||||||
|
|
||||||
|
let mut host = Host::new(transport.source()?, transport.sink()?)?;
|
||||||
|
host.reset(DriverFactory::None).await?;
|
||||||
|
|
||||||
|
match Driver::for_host(&host, force).await? {
|
||||||
|
None => {
|
||||||
|
eprintln!("Firmware already loaded or no supported driver for this device.");
|
||||||
|
}
|
||||||
|
Some(mut d) => d.download_firmware().await?,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn drop(transport: &str) -> PyResult<()> {
|
||||||
|
let transport = Transport::open(transport).await?;
|
||||||
|
|
||||||
|
let mut host = Host::new(transport.source()?, transport.sink()?)?;
|
||||||
|
host.reset(DriverFactory::None).await?;
|
||||||
|
|
||||||
|
Driver::drop_firmware(&mut host).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dump_firmware_desc(fw: &Firmware) {
|
||||||
|
println!(
|
||||||
|
"Firmware: version=0x{:08X} project_id=0x{:04X}",
|
||||||
|
fw.version(),
|
||||||
|
fw.project_id()
|
||||||
|
);
|
||||||
|
for p in fw.patches() {
|
||||||
|
println!(
|
||||||
|
" Patch: chip_id=0x{:04X}, {} bytes, SVN Version={:08X}",
|
||||||
|
p.chip_id(),
|
||||||
|
p.contents().len(),
|
||||||
|
p.svn_version()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SimpleClient {
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SimpleClient {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, url: &str) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let resp = self.client.get(url).send().await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(anyhow!("Bad status: {}", resp.status()));
|
||||||
|
}
|
||||||
|
let bytes = resp.bytes().await?;
|
||||||
|
Ok(bytes.as_ref().to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
191
rust/src/cli/l2cap/client_bridge.rs
Normal file
191
rust/src/cli/l2cap/client_bridge.rs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
/// L2CAP CoC client bridge: connects to a BLE device, then waits for an inbound
|
||||||
|
/// TCP connection on a specified port number. When a TCP client connects, an
|
||||||
|
/// L2CAP CoC channel connection to the BLE device is established, and the data
|
||||||
|
/// is bridged in both directions, with flow control.
|
||||||
|
/// When the TCP connection is closed by the client, the L2CAP CoC channel is
|
||||||
|
/// disconnected, but the connection to the BLE device remains, ready for a new
|
||||||
|
/// TCP client to connect.
|
||||||
|
/// When the L2CAP CoC channel is closed, the TCP connection is closed as well.
|
||||||
|
use crate::cli::l2cap::{
|
||||||
|
proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
|
||||||
|
BridgeData,
|
||||||
|
};
|
||||||
|
use bumble::wrapper::{
|
||||||
|
device::{Connection, Device},
|
||||||
|
hci::HciConstant,
|
||||||
|
};
|
||||||
|
use futures::executor::block_on;
|
||||||
|
use owo_colors::OwoColorize;
|
||||||
|
use pyo3::{PyResult, Python};
|
||||||
|
use std::{net::SocketAddr, sync::Arc};
|
||||||
|
use tokio::{
|
||||||
|
join,
|
||||||
|
net::{TcpListener, TcpStream},
|
||||||
|
sync::{mpsc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Args {
|
||||||
|
pub psm: u16,
|
||||||
|
pub max_credits: Option<u16>,
|
||||||
|
pub mtu: Option<u16>,
|
||||||
|
pub mps: Option<u16>,
|
||||||
|
pub bluetooth_address: String,
|
||||||
|
pub tcp_host: String,
|
||||||
|
pub tcp_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!("### Connecting to {}...", args.bluetooth_address).yellow()
|
||||||
|
);
|
||||||
|
let mut ble_connection = device.connect(&args.bluetooth_address).await?;
|
||||||
|
ble_connection.on_disconnection(|_py, reason| {
|
||||||
|
let disconnection_info = match HciConstant::error_name(reason) {
|
||||||
|
Ok(info_string) => info_string,
|
||||||
|
Err(py_err) => format!("failed to get disconnection error name ({})", py_err),
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
"@@@ Bluetooth disconnection: ".red(),
|
||||||
|
disconnection_info,
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Start the TCP server.
|
||||||
|
let listener = TcpListener::bind(format!("{}:{}", args.tcp_host, args.tcp_port))
|
||||||
|
.await
|
||||||
|
.expect("failed to bind tcp to address");
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!(
|
||||||
|
"### Listening for TCP connections on port {}",
|
||||||
|
args.tcp_port
|
||||||
|
)
|
||||||
|
.magenta()
|
||||||
|
);
|
||||||
|
|
||||||
|
let psm = args.psm;
|
||||||
|
let max_credits = args.max_credits;
|
||||||
|
let mtu = args.mtu;
|
||||||
|
let mps = args.mps;
|
||||||
|
let ble_connection = Arc::new(Mutex::new(ble_connection));
|
||||||
|
// Ensure Python event loop is available to l2cap `disconnect`
|
||||||
|
let _ = run_future_with_current_task_locals(async move {
|
||||||
|
while let Ok((tcp_stream, addr)) = listener.accept().await {
|
||||||
|
let ble_connection = ble_connection.clone();
|
||||||
|
let _ = run_future_with_current_task_locals(proxy_data_between_tcp_and_l2cap(
|
||||||
|
ble_connection,
|
||||||
|
tcp_stream,
|
||||||
|
addr,
|
||||||
|
psm,
|
||||||
|
max_credits,
|
||||||
|
mtu,
|
||||||
|
mps,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_data_between_tcp_and_l2cap(
|
||||||
|
ble_connection: Arc<Mutex<Connection>>,
|
||||||
|
tcp_stream: TcpStream,
|
||||||
|
addr: SocketAddr,
|
||||||
|
psm: u16,
|
||||||
|
max_credits: Option<u16>,
|
||||||
|
mtu: Option<u16>,
|
||||||
|
mps: Option<u16>,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
println!("{}", format!("<<< TCP connection from {}", addr).magenta());
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!(">>> Opening L2CAP channel on PSM = {}", psm).yellow()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut l2cap_channel = match ble_connection
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.open_l2cap_channel(psm, max_credits, mtu, mps)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(channel) => channel,
|
||||||
|
Err(e) => {
|
||||||
|
println!("{}", format!("!!! Connection failed: {e}").red());
|
||||||
|
// TCP stream will get dropped after returning, automatically shutting it down.
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let channel_info = l2cap_channel
|
||||||
|
.debug_string()
|
||||||
|
.unwrap_or_else(|e| format!("failed to get l2cap channel info ({e})"));
|
||||||
|
|
||||||
|
println!("{}{}", "*** L2CAP channel: ".cyan(), channel_info);
|
||||||
|
|
||||||
|
let (l2cap_to_tcp_tx, l2cap_to_tcp_rx) = mpsc::channel::<BridgeData>(10);
|
||||||
|
|
||||||
|
// Set l2cap callback (`set_sink`) for when data is received.
|
||||||
|
let l2cap_to_tcp_tx_clone = l2cap_to_tcp_tx.clone();
|
||||||
|
l2cap_channel
|
||||||
|
.set_sink(move |_py, sdu| {
|
||||||
|
block_on(l2cap_to_tcp_tx_clone.send(BridgeData::Data(sdu.into())))
|
||||||
|
.expect("failed to channel data to tcp");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("failed to set sink for l2cap connection");
|
||||||
|
|
||||||
|
// Set l2cap callback for when the channel is closed.
|
||||||
|
l2cap_channel
|
||||||
|
.on_close(move |_py| {
|
||||||
|
println!("{}", "*** L2CAP channel closed".red());
|
||||||
|
block_on(l2cap_to_tcp_tx.send(BridgeData::CloseSignal))
|
||||||
|
.expect("failed to channel close signal to tcp");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("failed to set on_close callback for l2cap channel");
|
||||||
|
|
||||||
|
let l2cap_channel = Arc::new(Mutex::new(Some(l2cap_channel)));
|
||||||
|
let (tcp_reader, tcp_writer) = tcp_stream.into_split();
|
||||||
|
|
||||||
|
// Do tcp stuff when something happens on the l2cap channel.
|
||||||
|
let handle_l2cap_data_future =
|
||||||
|
proxy_l2cap_rx_to_tcp_tx(l2cap_to_tcp_rx, tcp_writer, l2cap_channel.clone());
|
||||||
|
|
||||||
|
// Do l2cap stuff when something happens on tcp.
|
||||||
|
let handle_tcp_data_future = proxy_tcp_rx_to_l2cap_tx(tcp_reader, l2cap_channel.clone(), true);
|
||||||
|
|
||||||
|
let (handle_l2cap_result, handle_tcp_result) =
|
||||||
|
join!(handle_l2cap_data_future, handle_tcp_data_future);
|
||||||
|
|
||||||
|
if let Err(e) = handle_l2cap_result {
|
||||||
|
println!("!!! Error: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = handle_tcp_result {
|
||||||
|
println!("!!! Error: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Python::with_gil(|_| {
|
||||||
|
// Must hold GIL at least once while/after dropping for Python heap object to ensure
|
||||||
|
// de-allocation.
|
||||||
|
drop(l2cap_channel);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
190
rust/src/cli/l2cap/mod.rs
Normal file
190
rust/src/cli/l2cap/mod.rs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Rust version of the Python `l2cap_bridge.py` found under the `apps` folder.
|
||||||
|
|
||||||
|
use crate::L2cap;
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use bumble::wrapper::{device::Device, l2cap::LeConnectionOrientedChannel, transport::Transport};
|
||||||
|
use owo_colors::{colors::css::Orange, OwoColorize};
|
||||||
|
use pyo3::{PyObject, PyResult, Python};
|
||||||
|
use std::{future::Future, path::PathBuf, sync::Arc};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
|
net::tcp::{OwnedReadHalf, OwnedWriteHalf},
|
||||||
|
sync::{mpsc::Receiver, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
mod client_bridge;
|
||||||
|
mod server_bridge;
|
||||||
|
|
||||||
|
pub(crate) async fn run(
|
||||||
|
command: L2cap,
|
||||||
|
device_config: PathBuf,
|
||||||
|
transport: String,
|
||||||
|
psm: u16,
|
||||||
|
max_credits: Option<u16>,
|
||||||
|
mtu: Option<u16>,
|
||||||
|
mps: Option<u16>,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
println!("<<< connecting to HCI...");
|
||||||
|
let transport = Transport::open(transport).await?;
|
||||||
|
println!("<<< connected");
|
||||||
|
|
||||||
|
let mut device =
|
||||||
|
Device::from_config_file_with_hci(&device_config, transport.source()?, transport.sink()?)?;
|
||||||
|
|
||||||
|
device.power_on().await?;
|
||||||
|
|
||||||
|
match command {
|
||||||
|
L2cap::Server { tcp_host, tcp_port } => {
|
||||||
|
let args = server_bridge::Args {
|
||||||
|
psm,
|
||||||
|
max_credits,
|
||||||
|
mtu,
|
||||||
|
mps,
|
||||||
|
tcp_host,
|
||||||
|
tcp_port,
|
||||||
|
};
|
||||||
|
|
||||||
|
server_bridge::start(&args, &mut device).await?
|
||||||
|
}
|
||||||
|
L2cap::Client {
|
||||||
|
bluetooth_address,
|
||||||
|
tcp_host,
|
||||||
|
tcp_port,
|
||||||
|
} => {
|
||||||
|
let args = client_bridge::Args {
|
||||||
|
psm,
|
||||||
|
max_credits,
|
||||||
|
mtu,
|
||||||
|
mps,
|
||||||
|
bluetooth_address,
|
||||||
|
tcp_host,
|
||||||
|
tcp_port,
|
||||||
|
};
|
||||||
|
|
||||||
|
client_bridge::start(&args, &mut device).await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// wait until user kills the process
|
||||||
|
tokio::signal::ctrl_c().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used for channeling data from Python callbacks to a Rust consumer.
|
||||||
|
enum BridgeData {
|
||||||
|
Data(Vec<u8>),
|
||||||
|
CloseSignal,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_l2cap_rx_to_tcp_tx(
|
||||||
|
mut l2cap_data_receiver: Receiver<BridgeData>,
|
||||||
|
mut tcp_writer: OwnedWriteHalf,
|
||||||
|
l2cap_channel: Arc<Mutex<Option<LeConnectionOrientedChannel>>>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
while let Some(bridge_data) = l2cap_data_receiver.recv().await {
|
||||||
|
match bridge_data {
|
||||||
|
BridgeData::Data(sdu) => {
|
||||||
|
println!("{}", format!("<<< [L2CAP SDU]: {} bytes", sdu.len()).cyan());
|
||||||
|
tcp_writer
|
||||||
|
.write_all(sdu.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("Failed to write to tcp stream"))?;
|
||||||
|
tcp_writer
|
||||||
|
.flush()
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("Failed to flush tcp stream"))?;
|
||||||
|
}
|
||||||
|
BridgeData::CloseSignal => {
|
||||||
|
l2cap_channel.lock().await.take();
|
||||||
|
tcp_writer
|
||||||
|
.shutdown()
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("Failed to shut down write half of tcp stream"))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_tcp_rx_to_l2cap_tx(
|
||||||
|
mut tcp_reader: OwnedReadHalf,
|
||||||
|
l2cap_channel: Arc<Mutex<Option<LeConnectionOrientedChannel>>>,
|
||||||
|
drain_l2cap_after_write: bool,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
let mut buf = [0; 4096];
|
||||||
|
loop {
|
||||||
|
match tcp_reader.read(&mut buf).await {
|
||||||
|
Ok(len) => {
|
||||||
|
if len == 0 {
|
||||||
|
println!("{}", "!!! End of stream".fg::<Orange>());
|
||||||
|
|
||||||
|
if let Some(mut channel) = l2cap_channel.lock().await.take() {
|
||||||
|
channel.disconnect().await.map_err(|e| {
|
||||||
|
eprintln!("Failed to call disconnect on l2cap channel: {e}");
|
||||||
|
e
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{}", format!("<<< [TCP DATA]: {len} bytes").blue());
|
||||||
|
match l2cap_channel.lock().await.as_mut() {
|
||||||
|
None => {
|
||||||
|
println!("{}", "!!! L2CAP channel not connected, dropping".red());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Some(channel) => {
|
||||||
|
channel.write(&buf[..len])?;
|
||||||
|
if drain_l2cap_after_write {
|
||||||
|
channel.drain().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("{}", format!("!!! TCP connection lost: {}", e).red());
|
||||||
|
if let Some(mut channel) = l2cap_channel.lock().await.take() {
|
||||||
|
let _ = channel.disconnect().await.map_err(|e| {
|
||||||
|
eprintln!("Failed to call disconnect on l2cap channel: {e}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies the current thread's TaskLocals into a Python "awaitable" and encapsulates it in a Rust
|
||||||
|
/// future, running it as a Python Task.
|
||||||
|
/// `TaskLocals` stores the current event loop, and allows the user to copy the current Python
|
||||||
|
/// context if necessary. In this case, the python event loop is used when calling `disconnect` on
|
||||||
|
/// an l2cap connection, or else the call will fail.
|
||||||
|
pub fn run_future_with_current_task_locals<F>(
|
||||||
|
fut: F,
|
||||||
|
) -> PyResult<impl Future<Output = PyResult<PyObject>> + Send>
|
||||||
|
where
|
||||||
|
F: Future<Output = PyResult<()>> + Send + 'static,
|
||||||
|
{
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
let locals = pyo3_asyncio::tokio::get_current_locals(py)?;
|
||||||
|
let future = pyo3_asyncio::tokio::scope(locals.clone(), fut);
|
||||||
|
pyo3_asyncio::tokio::future_into_py_with_locals(py, locals, future)
|
||||||
|
.and_then(pyo3_asyncio::tokio::into_future)
|
||||||
|
})
|
||||||
|
}
|
||||||
205
rust/src/cli/l2cap/server_bridge.rs
Normal file
205
rust/src/cli/l2cap/server_bridge.rs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
/// L2CAP CoC server bridge: waits for a peer to connect an L2CAP CoC channel
|
||||||
|
/// on a specified PSM. 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 L2CAP CoC channel is closed, the bridge disconnects the TCP socket
|
||||||
|
/// and waits for a new L2CAP CoC channel to be connected.
|
||||||
|
/// When the TCP connection is closed by the TCP server, the L2CAP connection is closed as well.
|
||||||
|
use crate::cli::l2cap::{
|
||||||
|
proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
|
||||||
|
BridgeData,
|
||||||
|
};
|
||||||
|
use bumble::wrapper::{device::Device, hci::HciConstant, l2cap::LeConnectionOrientedChannel};
|
||||||
|
use futures::executor::block_on;
|
||||||
|
use owo_colors::OwoColorize;
|
||||||
|
use pyo3::{PyResult, Python};
|
||||||
|
use std::{sync::Arc, time::Duration};
|
||||||
|
use tokio::{
|
||||||
|
join,
|
||||||
|
net::TcpStream,
|
||||||
|
select,
|
||||||
|
sync::{mpsc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Args {
|
||||||
|
pub psm: u16,
|
||||||
|
pub max_credits: Option<u16>,
|
||||||
|
pub mtu: Option<u16>,
|
||||||
|
pub mps: Option<u16>,
|
||||||
|
pub tcp_host: String,
|
||||||
|
pub tcp_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
|
||||||
|
let host = args.tcp_host.clone();
|
||||||
|
let port = args.tcp_port;
|
||||||
|
device.register_l2cap_channel_server(
|
||||||
|
args.psm,
|
||||||
|
move |_py, l2cap_channel| {
|
||||||
|
let channel_info = l2cap_channel
|
||||||
|
.debug_string()
|
||||||
|
.unwrap_or_else(|e| format!("failed to get l2cap channel info ({e})"));
|
||||||
|
println!("{} {channel_info}", "*** L2CAP channel:".cyan());
|
||||||
|
|
||||||
|
let host = host.clone();
|
||||||
|
// Ensure Python event loop is available to l2cap `disconnect`
|
||||||
|
let _ = run_future_with_current_task_locals(proxy_data_between_l2cap_and_tcp(
|
||||||
|
l2cap_channel,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
args.max_credits,
|
||||||
|
args.mtu,
|
||||||
|
args.mps,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!("### Listening for CoC connection on PSM {}", args.psm).yellow()
|
||||||
|
);
|
||||||
|
|
||||||
|
device.on_connection(|_py, mut connection| {
|
||||||
|
let connection_info = connection
|
||||||
|
.debug_string()
|
||||||
|
.unwrap_or_else(|e| format!("failed to get connection info ({e})"));
|
||||||
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
"@@@ Bluetooth connection: ".green(),
|
||||||
|
connection_info,
|
||||||
|
);
|
||||||
|
connection.on_disconnection(|_py, reason| {
|
||||||
|
let disconnection_info = match HciConstant::error_name(reason) {
|
||||||
|
Ok(info_string) => info_string,
|
||||||
|
Err(py_err) => format!("failed to get disconnection error name ({})", py_err),
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
"@@@ Bluetooth disconnection: ".red(),
|
||||||
|
disconnection_info,
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
device.start_advertising(false).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_data_between_l2cap_and_tcp(
|
||||||
|
mut l2cap_channel: LeConnectionOrientedChannel,
|
||||||
|
tcp_host: String,
|
||||||
|
tcp_port: u16,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
let (l2cap_to_tcp_tx, mut l2cap_to_tcp_rx) = mpsc::channel::<BridgeData>(10);
|
||||||
|
|
||||||
|
// Set callback (`set_sink`) for when l2cap data is received.
|
||||||
|
let l2cap_to_tcp_tx_clone = l2cap_to_tcp_tx.clone();
|
||||||
|
l2cap_channel
|
||||||
|
.set_sink(move |_py, sdu| {
|
||||||
|
block_on(l2cap_to_tcp_tx_clone.send(BridgeData::Data(sdu.into())))
|
||||||
|
.expect("failed to channel data to tcp");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("failed to set sink for l2cap connection");
|
||||||
|
|
||||||
|
// Set l2cap callback for when the channel is closed.
|
||||||
|
l2cap_channel
|
||||||
|
.on_close(move |_py| {
|
||||||
|
println!("{}", "*** L2CAP channel closed".red());
|
||||||
|
block_on(l2cap_to_tcp_tx.send(BridgeData::CloseSignal))
|
||||||
|
.expect("failed to channel close signal to tcp");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("failed to set on_close callback for l2cap channel");
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!("### Connecting to TCP {tcp_host}:{tcp_port}...").yellow()
|
||||||
|
);
|
||||||
|
|
||||||
|
let l2cap_channel = Arc::new(Mutex::new(Some(l2cap_channel)));
|
||||||
|
let tcp_stream = match TcpStream::connect(format!("{tcp_host}:{tcp_port}")).await {
|
||||||
|
Ok(stream) => {
|
||||||
|
println!("{}", "### Connected".green());
|
||||||
|
Some(stream)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
println!("{}", format!("!!! Connection failed: {err}").red());
|
||||||
|
if let Some(mut channel) = l2cap_channel.lock().await.take() {
|
||||||
|
// Bumble might enter an invalid state if disconnection request is received from
|
||||||
|
// l2cap client before receiving a disconnection response from the same client,
|
||||||
|
// blocking this async call from returning.
|
||||||
|
// See: https://github.com/google/bumble/issues/257
|
||||||
|
select! {
|
||||||
|
res = channel.disconnect() => {
|
||||||
|
let _ = res.map_err(|e| eprintln!("Failed to call disconnect on l2cap channel: {e}"));
|
||||||
|
},
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(1)) => eprintln!("Timed out while calling disconnect on l2cap channel."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match tcp_stream {
|
||||||
|
None => {
|
||||||
|
while let Some(bridge_data) = l2cap_to_tcp_rx.recv().await {
|
||||||
|
match bridge_data {
|
||||||
|
BridgeData::Data(sdu) => {
|
||||||
|
println!("{}", format!("<<< [L2CAP SDU]: {} bytes", sdu.len()).cyan());
|
||||||
|
println!("{}", "!!! TCP socket not open, dropping".red())
|
||||||
|
}
|
||||||
|
BridgeData::CloseSignal => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(tcp_stream) => {
|
||||||
|
let (tcp_reader, tcp_writer) = tcp_stream.into_split();
|
||||||
|
|
||||||
|
// Do tcp stuff when something happens on the l2cap channel.
|
||||||
|
let handle_l2cap_data_future =
|
||||||
|
proxy_l2cap_rx_to_tcp_tx(l2cap_to_tcp_rx, tcp_writer, l2cap_channel.clone());
|
||||||
|
|
||||||
|
// Do l2cap stuff when something happens on tcp.
|
||||||
|
let handle_tcp_data_future =
|
||||||
|
proxy_tcp_rx_to_l2cap_tx(tcp_reader, l2cap_channel.clone(), false);
|
||||||
|
|
||||||
|
let (handle_l2cap_result, handle_tcp_result) =
|
||||||
|
join!(handle_l2cap_data_future, handle_tcp_data_future);
|
||||||
|
|
||||||
|
if let Err(e) = handle_l2cap_result {
|
||||||
|
println!("!!! Error: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = handle_tcp_result {
|
||||||
|
println!("!!! Error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Python::with_gil(|_| {
|
||||||
|
// Must hold GIL at least once while/after dropping for Python heap object to ensure
|
||||||
|
// de-allocation.
|
||||||
|
drop(l2cap_channel);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
19
rust/src/cli/mod.rs
Normal file
19
rust/src/cli/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
pub(crate) mod firmware;
|
||||||
|
|
||||||
|
pub(crate) mod usb;
|
||||||
|
|
||||||
|
pub(crate) mod l2cap;
|
||||||
330
rust/src/cli/usb/mod.rs
Normal file
330
rust/src/cli/usb/mod.rs
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Rust version of the Python `usb_probe.py`.
|
||||||
|
//!
|
||||||
|
//! This tool lists all the USB devices, with details about each device.
|
||||||
|
//! For each device, the different possible Bumble transport strings that can
|
||||||
|
//! refer to it are listed. If the device is known to be a Bluetooth HCI device,
|
||||||
|
//! its identifier is printed in reverse colors, and the transport names in cyan color.
|
||||||
|
//! For other devices, regardless of their type, the transport names are printed
|
||||||
|
//! in red. Whether that device is actually a Bluetooth device or not depends on
|
||||||
|
//! whether it is a Bluetooth device that uses a non-standard Class, or some other
|
||||||
|
//! type of device (there's no way to tell).
|
||||||
|
|
||||||
|
use itertools::Itertools as _;
|
||||||
|
use owo_colors::{OwoColorize, Style};
|
||||||
|
use rusb::{Device, DeviceDescriptor, Direction, TransferType, UsbContext};
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
const USB_DEVICE_CLASS_DEVICE: u8 = 0x00;
|
||||||
|
const USB_DEVICE_CLASS_WIRELESS_CONTROLLER: u8 = 0xE0;
|
||||||
|
const USB_DEVICE_SUBCLASS_RF_CONTROLLER: u8 = 0x01;
|
||||||
|
const USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER: u8 = 0x01;
|
||||||
|
|
||||||
|
pub(crate) fn probe(verbose: bool) -> anyhow::Result<()> {
|
||||||
|
let mut bt_dev_count = 0;
|
||||||
|
let mut device_serials_by_id: HashMap<(u16, u16), HashSet<String>> = HashMap::new();
|
||||||
|
for device in rusb::devices()?.iter() {
|
||||||
|
let device_desc = device.device_descriptor().unwrap();
|
||||||
|
|
||||||
|
let class_info = ClassInfo::from(&device_desc);
|
||||||
|
let handle = device.open()?;
|
||||||
|
let timeout = Duration::from_secs(1);
|
||||||
|
// some devices don't have languages
|
||||||
|
let lang = handle
|
||||||
|
.read_languages(timeout)
|
||||||
|
.ok()
|
||||||
|
.and_then(|langs| langs.into_iter().next());
|
||||||
|
let serial = lang.and_then(|l| {
|
||||||
|
handle
|
||||||
|
.read_serial_number_string(l, &device_desc, timeout)
|
||||||
|
.ok()
|
||||||
|
});
|
||||||
|
let mfg = lang.and_then(|l| {
|
||||||
|
handle
|
||||||
|
.read_manufacturer_string(l, &device_desc, timeout)
|
||||||
|
.ok()
|
||||||
|
});
|
||||||
|
let product = lang.and_then(|l| handle.read_product_string(l, &device_desc, timeout).ok());
|
||||||
|
|
||||||
|
let is_hci = is_bluetooth_hci(&device, &device_desc)?;
|
||||||
|
let addr_style = if is_hci {
|
||||||
|
bt_dev_count += 1;
|
||||||
|
Style::new().black().on_yellow()
|
||||||
|
} else {
|
||||||
|
Style::new().yellow().on_black()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut transport_names = Vec::new();
|
||||||
|
let basic_transport_name = format!(
|
||||||
|
"usb:{:04X}:{:04X}",
|
||||||
|
device_desc.vendor_id(),
|
||||||
|
device_desc.product_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
if is_hci {
|
||||||
|
transport_names.push(format!("usb:{}", bt_dev_count - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
let device_id = (device_desc.vendor_id(), device_desc.product_id());
|
||||||
|
if !device_serials_by_id.contains_key(&device_id) {
|
||||||
|
transport_names.push(basic_transport_name.clone());
|
||||||
|
} else {
|
||||||
|
transport_names.push(format!(
|
||||||
|
"{}#{}",
|
||||||
|
basic_transport_name,
|
||||||
|
device_serials_by_id
|
||||||
|
.get(&device_id)
|
||||||
|
.map(|serials| serials.len())
|
||||||
|
.unwrap_or(0)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(s) = &serial {
|
||||||
|
if !device_serials_by_id
|
||||||
|
.get(&device_id)
|
||||||
|
.map(|serials| serials.contains(s))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
transport_names.push(format!("{}/{}", basic_transport_name, s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!(
|
||||||
|
"ID {:04X}:{:04X}",
|
||||||
|
device_desc.vendor_id(),
|
||||||
|
device_desc.product_id()
|
||||||
|
)
|
||||||
|
.style(addr_style)
|
||||||
|
);
|
||||||
|
if !transport_names.is_empty() {
|
||||||
|
let style = if is_hci {
|
||||||
|
Style::new().cyan()
|
||||||
|
} else {
|
||||||
|
Style::new().red()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{:26}{}",
|
||||||
|
" Bumble Transport Names:".blue(),
|
||||||
|
transport_names.iter().map(|n| n.style(style)).join(" or ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{:26}{:03}/{:03}",
|
||||||
|
" Bus/Device:".green(),
|
||||||
|
device.bus_number(),
|
||||||
|
device.address()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{:26}{}",
|
||||||
|
" Class:".green(),
|
||||||
|
class_info.formatted_class_name()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{:26}{}",
|
||||||
|
" Subclass/Protocol:".green(),
|
||||||
|
class_info.formatted_subclass_protocol()
|
||||||
|
);
|
||||||
|
if let Some(s) = serial {
|
||||||
|
println!("{:26}{}", " Serial:".green(), s);
|
||||||
|
device_serials_by_id
|
||||||
|
.entry(device_id)
|
||||||
|
.or_insert(HashSet::new())
|
||||||
|
.insert(s);
|
||||||
|
}
|
||||||
|
if let Some(m) = mfg {
|
||||||
|
println!("{:26}{}", " Manufacturer:".green(), m);
|
||||||
|
}
|
||||||
|
if let Some(p) = product {
|
||||||
|
println!("{:26}{}", " Product:".green(), p);
|
||||||
|
}
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
print_device_details(&device, &device_desc)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_bluetooth_hci<T: UsbContext>(
|
||||||
|
device: &Device<T>,
|
||||||
|
device_desc: &DeviceDescriptor,
|
||||||
|
) -> rusb::Result<bool> {
|
||||||
|
if device_desc.class_code() == USB_DEVICE_CLASS_WIRELESS_CONTROLLER
|
||||||
|
&& device_desc.sub_class_code() == USB_DEVICE_SUBCLASS_RF_CONTROLLER
|
||||||
|
&& device_desc.protocol_code() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
|
||||||
|
{
|
||||||
|
Ok(true)
|
||||||
|
} else if device_desc.class_code() == USB_DEVICE_CLASS_DEVICE {
|
||||||
|
for i in 0..device_desc.num_configurations() {
|
||||||
|
for interface in device.config_descriptor(i)?.interfaces() {
|
||||||
|
for d in interface.descriptors() {
|
||||||
|
if d.class_code() == USB_DEVICE_CLASS_WIRELESS_CONTROLLER
|
||||||
|
&& d.sub_class_code() == USB_DEVICE_SUBCLASS_RF_CONTROLLER
|
||||||
|
&& d.protocol_code() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_device_details<T: UsbContext>(
|
||||||
|
device: &Device<T>,
|
||||||
|
device_desc: &DeviceDescriptor,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
for i in 0..device_desc.num_configurations() {
|
||||||
|
println!(" Configuration {}", i + 1);
|
||||||
|
for interface in device.config_descriptor(i)?.interfaces() {
|
||||||
|
let interface_descriptors: Vec<_> = interface.descriptors().collect();
|
||||||
|
for d in &interface_descriptors {
|
||||||
|
let class_info =
|
||||||
|
ClassInfo::new(d.class_code(), d.sub_class_code(), d.protocol_code());
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" Interface: {}{} ({}, {})",
|
||||||
|
interface.number(),
|
||||||
|
if interface_descriptors.len() > 1 {
|
||||||
|
format!("/{}", d.setting_number())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
},
|
||||||
|
class_info.formatted_class_name(),
|
||||||
|
class_info.formatted_subclass_protocol()
|
||||||
|
);
|
||||||
|
|
||||||
|
for e in d.endpoint_descriptors() {
|
||||||
|
println!(
|
||||||
|
" Endpoint {:#04X}: {} {}",
|
||||||
|
e.address(),
|
||||||
|
match e.transfer_type() {
|
||||||
|
TransferType::Control => "CONTROL",
|
||||||
|
TransferType::Isochronous => "ISOCHRONOUS",
|
||||||
|
TransferType::Bulk => "BULK",
|
||||||
|
TransferType::Interrupt => "INTERRUPT",
|
||||||
|
},
|
||||||
|
match e.direction() {
|
||||||
|
Direction::In => "IN",
|
||||||
|
Direction::Out => "OUT",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClassInfo {
|
||||||
|
class: u8,
|
||||||
|
sub_class: u8,
|
||||||
|
protocol: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClassInfo {
|
||||||
|
fn new(class: u8, sub_class: u8, protocol: u8) -> Self {
|
||||||
|
Self {
|
||||||
|
class,
|
||||||
|
sub_class,
|
||||||
|
protocol,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class_name(&self) -> Option<&str> {
|
||||||
|
match self.class {
|
||||||
|
0x00 => Some("Device"),
|
||||||
|
0x01 => Some("Audio"),
|
||||||
|
0x02 => Some("Communications and CDC Control"),
|
||||||
|
0x03 => Some("Human Interface Device"),
|
||||||
|
0x05 => Some("Physical"),
|
||||||
|
0x06 => Some("Still Imaging"),
|
||||||
|
0x07 => Some("Printer"),
|
||||||
|
0x08 => Some("Mass Storage"),
|
||||||
|
0x09 => Some("Hub"),
|
||||||
|
0x0A => Some("CDC Data"),
|
||||||
|
0x0B => Some("Smart Card"),
|
||||||
|
0x0D => Some("Content Security"),
|
||||||
|
0x0E => Some("Video"),
|
||||||
|
0x0F => Some("Personal Healthcare"),
|
||||||
|
0x10 => Some("Audio/Video"),
|
||||||
|
0x11 => Some("Billboard"),
|
||||||
|
0x12 => Some("USB Type-C Bridge"),
|
||||||
|
0x3C => Some("I3C"),
|
||||||
|
0xDC => Some("Diagnostic"),
|
||||||
|
USB_DEVICE_CLASS_WIRELESS_CONTROLLER => Some("Wireless Controller"),
|
||||||
|
0xEF => Some("Miscellaneous"),
|
||||||
|
0xFE => Some("Application Specific"),
|
||||||
|
0xFF => Some("Vendor Specific"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_name(&self) -> Option<&str> {
|
||||||
|
match self.class {
|
||||||
|
USB_DEVICE_CLASS_WIRELESS_CONTROLLER => match self.sub_class {
|
||||||
|
0x01 => match self.protocol {
|
||||||
|
0x01 => Some("Bluetooth"),
|
||||||
|
0x02 => Some("UWB"),
|
||||||
|
0x03 => Some("Remote NDIS"),
|
||||||
|
0x04 => Some("Bluetooth AMP"),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn formatted_class_name(&self) -> String {
|
||||||
|
self.class_name()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| format!("{:#04X}", self.class))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn formatted_subclass_protocol(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/{}{}",
|
||||||
|
self.sub_class,
|
||||||
|
self.protocol,
|
||||||
|
self.protocol_name()
|
||||||
|
.map(|s| format!(" [{}]", s))
|
||||||
|
.unwrap_or_else(String::new)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&DeviceDescriptor> for ClassInfo {
|
||||||
|
fn from(value: &DeviceDescriptor) -> Self {
|
||||||
|
Self::new(
|
||||||
|
value.class_code(),
|
||||||
|
value.sub_class_code(),
|
||||||
|
value.protocol_code(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user