forked from auracaster/bumble_mirror
Compare commits
178 Commits
gbg/speake
...
v0.0.176
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1b55b94e0 | ||
|
|
80db9e2e2f | ||
|
|
ce74690420 | ||
|
|
50de4dfb5d | ||
|
|
9bcdf860f4 | ||
|
|
511ab4b630 | ||
|
|
6f2b623e3c | ||
|
|
fa12165cd3 | ||
|
|
c0c6f3329d | ||
|
|
406a932467 | ||
|
|
cc96d4245f | ||
|
|
c6cdca8923 | ||
|
|
7e331c2944 | ||
|
|
10347765cb | ||
|
|
c12dee4e76 | ||
|
|
772c188674 | ||
|
|
7c1a3bb8f9 | ||
|
|
8c3c0b1e13 | ||
|
|
1ad84ad51c | ||
|
|
64937c3f77 | ||
|
|
50fd2218fa | ||
|
|
4c29a16271 | ||
|
|
762d3e92de | ||
|
|
2f97531d78 | ||
|
|
f6c7cae661 | ||
|
|
f1777a5bd2 | ||
|
|
78a06ae8cf | ||
|
|
d290df4aa9 | ||
|
|
e559744f32 | ||
|
|
67418e649a | ||
|
|
5adf9fab53 | ||
|
|
2491b686fa | ||
|
|
efd02b2f3e | ||
|
|
3b14078646 | ||
|
|
eb9d5632bc | ||
|
|
45f60edbb6 | ||
|
|
393ea6a7bb | ||
|
|
6ec6f1efe5 | ||
|
|
5d9598ea51 | ||
|
|
0d36d99a73 | ||
|
|
d8a9f5a724 | ||
|
|
2c66e1a042 | ||
|
|
d5eccdb00f | ||
|
|
32626573a6 | ||
|
|
caa82b8f7e | ||
|
|
5af347b499 | ||
|
|
4ed5bb5a9e | ||
|
|
2478d45673 | ||
|
|
1bc7d94111 | ||
|
|
6432414cd5 | ||
|
|
179064ba15 | ||
|
|
783b2d70a5 | ||
|
|
80824f3fc1 | ||
|
|
f39f5f531c | ||
|
|
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 | ||
|
|
4d2e821e50 | ||
|
|
7f987dc3cd | ||
|
|
689745040f | ||
|
|
809d4a18f5 | ||
|
|
54be8b328a | ||
|
|
57b469198a | ||
|
|
4d74339c04 | ||
|
|
39db278f2e | ||
|
|
27fbb58447 | ||
|
|
6826f68478 | ||
|
|
f80c83d0b3 | ||
|
|
3de35193bc | ||
|
|
740a2e0ca0 |
4
.github/workflows/code-check.yml
vendored
4
.github/workflows/code-check.yml
vendored
@@ -14,6 +14,10 @@ jobs:
|
||||
check:
|
||||
name: Check Code
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Check out from Git
|
||||
|
||||
43
.github/workflows/python-build-test.yml
vendored
43
.github/workflows/python-build-test.yml
vendored
@@ -12,11 +12,11 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
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
|
||||
|
||||
steps:
|
||||
@@ -41,3 +41,40 @@ jobs:
|
||||
run: |
|
||||
inv build
|
||||
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: Check License Headers
|
||||
run: cd rust && cargo run --features dev-tools --bin file-header check-all
|
||||
- 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
|
||||
bumble/_version.py
|
||||
.vscode/launch.json
|
||||
/.idea
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -39,10 +39,12 @@
|
||||
"libusb",
|
||||
"MITM",
|
||||
"NDIS",
|
||||
"netsim",
|
||||
"NONBLOCK",
|
||||
"NONCONN",
|
||||
"OXIMETER",
|
||||
"popleft",
|
||||
"protobuf",
|
||||
"psms",
|
||||
"pyee",
|
||||
"pyusb",
|
||||
|
||||
@@ -1172,7 +1172,7 @@ class ScanResult:
|
||||
name = ''
|
||||
|
||||
# Remove any '/P' qualifier suffix from the address string
|
||||
address_str = str(self.address).replace('/P', '')
|
||||
address_str = self.address.to_string(with_type_qualifier=False)
|
||||
|
||||
# RSSI bar
|
||||
bar_string = rssi_bar(self.rssi)
|
||||
|
||||
@@ -63,7 +63,8 @@ async def get_classic_info(host):
|
||||
if command_succeeded(response):
|
||||
print()
|
||||
print(
|
||||
color('Classic Address:', 'yellow'), response.return_parameters.bd_addr
|
||||
color('Classic Address:', 'yellow'),
|
||||
response.return_parameters.bd_addr.to_string(False),
|
||||
)
|
||||
|
||||
if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND):
|
||||
|
||||
@@ -105,7 +105,7 @@ class ServerBridge:
|
||||
asyncio.create_task(self.pipe.l2cap_channel.disconnect())
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
@@ -123,6 +123,7 @@ class ServerBridge:
|
||||
await self.l2cap_channel.disconnect()
|
||||
|
||||
def on_l2cap_close(self):
|
||||
print(color('*** L2CAP channel closed', 'red'))
|
||||
self.l2cap_channel = None
|
||||
if self.tcp_transport is not None:
|
||||
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('###-----------------------------------')
|
||||
|
||||
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):
|
||||
@@ -207,7 +227,7 @@ def on_connection(connection, request):
|
||||
|
||||
# Listen for pairing events
|
||||
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)
|
||||
|
||||
# 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('*** Paired!', 'cyan'))
|
||||
print(color(f'*** Paired! (peer identity={address})', 'cyan'))
|
||||
keys.print(prefix=color('*** ', 'cyan'))
|
||||
print(color('***-----------------------------------', 'cyan'))
|
||||
Waiter.instance.terminate()
|
||||
@@ -283,17 +303,6 @@ async def pair(
|
||||
# Create a device to manage the host
|
||||
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
|
||||
# responding with an authentication error when read
|
||||
if mode == 'le':
|
||||
@@ -323,6 +332,17 @@ async def pair(
|
||||
# Get things going
|
||||
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
|
||||
device.pairing_config_factory = lambda connection: PairingConfig(
|
||||
sc, mitm, bond, Delegate(mode, connection, io, prompt)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
import click
|
||||
import logging
|
||||
import json
|
||||
|
||||
from bumble.pandora import PandoraDevice, serve
|
||||
from bumble.pandora import PandoraDevice, Config, serve
|
||||
from typing import Dict, Any
|
||||
|
||||
BUMBLE_SERVER_GRPC_PORT = 7999
|
||||
ROOTCANAL_PORT_CUTTLEFISH = 7300
|
||||
@@ -18,12 +20,31 @@ ROOTCANAL_PORT_CUTTLEFISH = 7300
|
||||
help='HCI transport',
|
||||
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:
|
||||
transport = transport.replace('<rootcanal-port>', str(rootcanal_port))
|
||||
device = PandoraDevice({'transport': transport})
|
||||
|
||||
bumble_config = retrieve_config(config)
|
||||
bumble_config.setdefault('transport', transport)
|
||||
device = PandoraDevice(bumble_config)
|
||||
|
||||
server_config = Config()
|
||||
server_config.load_from_dict(bumble_config.get('server', {}))
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
asyncio.run(serve(device, port=grpc_port))
|
||||
asyncio.run(serve(device, config=server_config, 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__':
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
await device.power_on()
|
||||
|
||||
if keystore_file:
|
||||
keystore = JsonKeyStore(namespace=None, filename=keystore_file)
|
||||
device.keystore = keystore
|
||||
else:
|
||||
resolver = None
|
||||
device.keystore = JsonKeyStore.from_device(device, filename=keystore_file)
|
||||
|
||||
if device.keystore:
|
||||
resolving_keys = await device.keystore.get_resolving_keys()
|
||||
resolver = AddressResolver(resolving_keys)
|
||||
else:
|
||||
resolver = None
|
||||
|
||||
printer = AdvertisementPrinter(min_rssi, resolver)
|
||||
if raw:
|
||||
@@ -149,8 +150,6 @@ async def scan(
|
||||
else:
|
||||
device.on('advertisement', printer.on_advertisement)
|
||||
|
||||
await device.power_on()
|
||||
|
||||
if phy is None:
|
||||
scanning_phys = [HCI_LE_1M_PHY, HCI_LE_CODED_PHY]
|
||||
else:
|
||||
|
||||
15
apps/show.py
15
apps/show.py
@@ -102,9 +102,21 @@ class SnoopPacketReader:
|
||||
default='h4',
|
||||
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')
|
||||
# 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')
|
||||
if format == 'h4':
|
||||
packet_reader = PacketReader(input)
|
||||
@@ -124,7 +136,6 @@ def main(format, filename):
|
||||
if packet is None:
|
||||
break
|
||||
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
|
||||
|
||||
except Exception as error:
|
||||
print(color(f'!!! {error}', 'red'))
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ body, h1, h2, h3, h4, h5, h6 {
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
margin: 6px;
|
||||
margin-left: 0px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
@@ -65,7 +65,7 @@ th, td {
|
||||
}
|
||||
|
||||
.properties td:nth-child(even) {
|
||||
background-color: #D6EEEE;
|
||||
background-color: #d6eeee;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Bumble Speaker</title>
|
||||
<script type="text/javascript" src="speaker.js"></script>
|
||||
<script src="speaker.js"></script>
|
||||
<link rel="stylesheet" href="speaker.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -195,7 +195,7 @@ class WebSocketOutput(QueuedOutput):
|
||||
except HCI_StatusError:
|
||||
pass
|
||||
peer_name = '' if connection.peer_name is None else connection.peer_name
|
||||
peer_address = str(connection.peer_address).replace('/P', '')
|
||||
peer_address = connection.peer_address.to_string(False)
|
||||
await self.send_message(
|
||||
'connection',
|
||||
peer_address=peer_address,
|
||||
@@ -228,10 +228,11 @@ class FfplayOutput(QueuedOutput):
|
||||
subprocess: Optional[asyncio.subprocess.Process]
|
||||
ffplay_task: Optional[asyncio.Task]
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(AacAudioExtractor())
|
||||
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:
|
||||
@@ -240,7 +241,7 @@ class FfplayOutput(QueuedOutput):
|
||||
await super().start()
|
||||
|
||||
self.subprocess = await asyncio.create_subprocess_shell(
|
||||
'ffplay -acodec aac pipe:0',
|
||||
f'ffplay -f {self.codec} pipe:0',
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
@@ -375,7 +376,7 @@ class UiServer:
|
||||
if connection := self.speaker().connection:
|
||||
await self.send_message(
|
||||
'connection',
|
||||
peer_address=str(connection.peer_address).replace('/P', ''),
|
||||
peer_address=connection.peer_address.to_string(False),
|
||||
peer_name=connection.peer_name,
|
||||
)
|
||||
|
||||
@@ -419,7 +420,7 @@ class Speaker:
|
||||
self.outputs = []
|
||||
for output in outputs:
|
||||
if output == '@ffplay':
|
||||
self.outputs.append(FfplayOutput())
|
||||
self.outputs.append(FfplayOutput(codec))
|
||||
continue
|
||||
|
||||
# Default to FileOutput
|
||||
@@ -708,17 +709,6 @@ def speaker(
|
||||
):
|
||||
"""Run the speaker."""
|
||||
|
||||
# ffplay only works with AAC for now
|
||||
if codec != 'aac' and '@ffplay' in output:
|
||||
print(
|
||||
color(
|
||||
f'{codec} not supported with @ffplay output, '
|
||||
'@ffplay output will be skipped',
|
||||
'yellow',
|
||||
)
|
||||
)
|
||||
output = list(filter(lambda x: x != '@ffplay', output))
|
||||
|
||||
if '@ffplay' in output:
|
||||
# Check if ffplay is installed
|
||||
try:
|
||||
|
||||
@@ -22,40 +22,58 @@ import click
|
||||
|
||||
from bumble.device import Device
|
||||
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):
|
||||
# Create a device to manage the host
|
||||
device = Device.from_config_file(device_config)
|
||||
|
||||
# Get all entries in the keystore
|
||||
async def unbond(keystore_file, device_config, hci_transport, address):
|
||||
# With a keystore file, we can instantiate the keystore directly
|
||||
if keystore_file:
|
||||
keystore = JsonKeyStore(None, keystore_file)
|
||||
else:
|
||||
keystore = device.keystore
|
||||
return await unbond_with_keystore(JsonKeyStore(None, keystore_file), address)
|
||||
|
||||
if keystore is None:
|
||||
print('no keystore')
|
||||
return
|
||||
# Without a keystore file, we need to obtain the keystore from the device
|
||||
async with await open_transport(hci_transport) as (hci_source, hci_sink):
|
||||
# Create a device to manage the host
|
||||
device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
|
||||
|
||||
if address is None:
|
||||
await keystore.print()
|
||||
else:
|
||||
try:
|
||||
await keystore.delete(address)
|
||||
except KeyError:
|
||||
print('!!! pairing not found')
|
||||
# Power-on the device to ensure we have a key store
|
||||
await device.power_on()
|
||||
|
||||
return await unbond_with_keystore(device.keystore, address)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@click.command()
|
||||
@click.option('--keystore-file', help='File in which to store the pairing keys')
|
||||
@click.argument('device-config')
|
||||
@click.option('--keystore-file', help='File in which the pairing keys are stored')
|
||||
@click.option('--hci-transport', help='HCI transport for the controller')
|
||||
@click.argument('device-config', 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())
|
||||
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))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
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]
|
||||
124
bumble/att.py
124
bumble/att.py
@@ -23,13 +23,14 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
import functools
|
||||
import struct
|
||||
from pyee import EventEmitter
|
||||
from typing import Dict, Type, TYPE_CHECKING
|
||||
from typing import Dict, Type, List, Protocol, Union, Optional, Any, TYPE_CHECKING
|
||||
|
||||
from bumble.core import UUID, name_or_number, get_dict_key_by_value, ProtocolError
|
||||
from bumble.hci import HCI_Object, key_with_value, HCI_Constant
|
||||
from bumble.core import UUID, name_or_number, ProtocolError
|
||||
from bumble.hci import HCI_Object, key_with_value
|
||||
from bumble.colors import color
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -182,6 +183,7 @@ UUID_2_FIELD_SPEC = lambda x, y: UUID.parse_uuid_2(x, y) # noqa: E731
|
||||
# pylint: enable=line-too-long
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Exceptions
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -209,7 +211,7 @@ class ATT_PDU:
|
||||
|
||||
pdu_classes: Dict[int, Type[ATT_PDU]] = {}
|
||||
op_code = 0
|
||||
name = None
|
||||
name: str
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(pdu):
|
||||
@@ -719,48 +721,68 @@ class ATT_Handle_Value_Confirmation(ATT_PDU):
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class ConnectionValue(Protocol):
|
||||
def read(self, connection) -> bytes:
|
||||
...
|
||||
|
||||
def write(self, connection, value: bytes) -> None:
|
||||
...
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Attribute(EventEmitter):
|
||||
# Permission flags
|
||||
READABLE = 0x01
|
||||
WRITEABLE = 0x02
|
||||
READ_REQUIRES_ENCRYPTION = 0x04
|
||||
WRITE_REQUIRES_ENCRYPTION = 0x08
|
||||
READ_REQUIRES_AUTHENTICATION = 0x10
|
||||
WRITE_REQUIRES_AUTHENTICATION = 0x20
|
||||
READ_REQUIRES_AUTHORIZATION = 0x40
|
||||
WRITE_REQUIRES_AUTHORIZATION = 0x80
|
||||
class Permissions(enum.IntFlag):
|
||||
READABLE = 0x01
|
||||
WRITEABLE = 0x02
|
||||
READ_REQUIRES_ENCRYPTION = 0x04
|
||||
WRITE_REQUIRES_ENCRYPTION = 0x08
|
||||
READ_REQUIRES_AUTHENTICATION = 0x10
|
||||
WRITE_REQUIRES_AUTHENTICATION = 0x20
|
||||
READ_REQUIRES_AUTHORIZATION = 0x40
|
||||
WRITE_REQUIRES_AUTHORIZATION = 0x80
|
||||
|
||||
PERMISSION_NAMES = {
|
||||
READABLE: 'READABLE',
|
||||
WRITEABLE: 'WRITEABLE',
|
||||
READ_REQUIRES_ENCRYPTION: 'READ_REQUIRES_ENCRYPTION',
|
||||
WRITE_REQUIRES_ENCRYPTION: 'WRITE_REQUIRES_ENCRYPTION',
|
||||
READ_REQUIRES_AUTHENTICATION: 'READ_REQUIRES_AUTHENTICATION',
|
||||
WRITE_REQUIRES_AUTHENTICATION: 'WRITE_REQUIRES_AUTHENTICATION',
|
||||
READ_REQUIRES_AUTHORIZATION: 'READ_REQUIRES_AUTHORIZATION',
|
||||
WRITE_REQUIRES_AUTHORIZATION: 'WRITE_REQUIRES_AUTHORIZATION',
|
||||
}
|
||||
@classmethod
|
||||
def from_string(cls, permissions_str: str) -> Attribute.Permissions:
|
||||
try:
|
||||
return functools.reduce(
|
||||
lambda x, y: x | Attribute.Permissions[y],
|
||||
permissions_str.replace('|', ',').split(","),
|
||||
Attribute.Permissions(0),
|
||||
)
|
||||
except TypeError as exc:
|
||||
# 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(
|
||||
f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {enum_list_str }\nGot: {permissions_str}"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def string_to_permissions(permissions_str: str):
|
||||
try:
|
||||
return functools.reduce(
|
||||
lambda x, y: x | get_dict_key_by_value(Attribute.PERMISSION_NAMES, y),
|
||||
permissions_str.split(","),
|
||||
0,
|
||||
)
|
||||
except TypeError as exc:
|
||||
raise TypeError(
|
||||
f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {','.join(Attribute.PERMISSION_NAMES.values())}\nGot: {permissions_str}"
|
||||
) from exc
|
||||
# Permission flags(legacy-use only)
|
||||
READABLE = Permissions.READABLE
|
||||
WRITEABLE = Permissions.WRITEABLE
|
||||
READ_REQUIRES_ENCRYPTION = Permissions.READ_REQUIRES_ENCRYPTION
|
||||
WRITE_REQUIRES_ENCRYPTION = Permissions.WRITE_REQUIRES_ENCRYPTION
|
||||
READ_REQUIRES_AUTHENTICATION = Permissions.READ_REQUIRES_AUTHENTICATION
|
||||
WRITE_REQUIRES_AUTHENTICATION = Permissions.WRITE_REQUIRES_AUTHENTICATION
|
||||
READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
|
||||
WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
|
||||
|
||||
def __init__(self, attribute_type, permissions, value=b''):
|
||||
value: Union[str, bytes, ConnectionValue]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
attribute_type: Union[str, bytes, UUID],
|
||||
permissions: Union[str, Attribute.Permissions],
|
||||
value: Union[str, bytes, ConnectionValue] = b'',
|
||||
) -> None:
|
||||
EventEmitter.__init__(self)
|
||||
self.handle = 0
|
||||
self.end_group_handle = 0
|
||||
if isinstance(permissions, str):
|
||||
self.permissions = self.string_to_permissions(permissions)
|
||||
self.permissions = Attribute.Permissions.from_string(permissions)
|
||||
else:
|
||||
self.permissions = permissions
|
||||
|
||||
@@ -778,22 +800,26 @@ class Attribute(EventEmitter):
|
||||
else:
|
||||
self.value = value
|
||||
|
||||
def encode_value(self, value):
|
||||
def encode_value(self, value: Any) -> bytes:
|
||||
return value
|
||||
|
||||
def decode_value(self, value_bytes):
|
||||
def decode_value(self, value_bytes: bytes) -> Any:
|
||||
return value_bytes
|
||||
|
||||
def read_value(self, connection: Connection):
|
||||
def read_value(self, connection: Optional[Connection]) -> bytes:
|
||||
if (
|
||||
self.permissions & self.READ_REQUIRES_ENCRYPTION
|
||||
) and not connection.encryption:
|
||||
(self.permissions & self.READ_REQUIRES_ENCRYPTION)
|
||||
and connection is not None
|
||||
and not connection.encryption
|
||||
):
|
||||
raise ATT_Error(
|
||||
error_code=ATT_INSUFFICIENT_ENCRYPTION_ERROR, att_handle=self.handle
|
||||
)
|
||||
if (
|
||||
self.permissions & self.READ_REQUIRES_AUTHENTICATION
|
||||
) and not connection.authenticated:
|
||||
(self.permissions & self.READ_REQUIRES_AUTHENTICATION)
|
||||
and connection is not None
|
||||
and not connection.authenticated
|
||||
):
|
||||
raise ATT_Error(
|
||||
error_code=ATT_INSUFFICIENT_AUTHENTICATION_ERROR, att_handle=self.handle
|
||||
)
|
||||
@@ -803,9 +829,9 @@ class Attribute(EventEmitter):
|
||||
error_code=ATT_INSUFFICIENT_AUTHORIZATION_ERROR, att_handle=self.handle
|
||||
)
|
||||
|
||||
if read := getattr(self.value, 'read', None):
|
||||
if hasattr(self.value, 'read'):
|
||||
try:
|
||||
value = read(connection) # pylint: disable=not-callable
|
||||
value = self.value.read(connection)
|
||||
except ATT_Error as error:
|
||||
raise ATT_Error(
|
||||
error_code=error.error_code, att_handle=self.handle
|
||||
@@ -815,7 +841,7 @@ class Attribute(EventEmitter):
|
||||
|
||||
return self.encode_value(value)
|
||||
|
||||
def write_value(self, connection: Connection, value_bytes):
|
||||
def write_value(self, connection: Connection, value_bytes: bytes) -> None:
|
||||
if (
|
||||
self.permissions & self.WRITE_REQUIRES_ENCRYPTION
|
||||
) and not connection.encryption:
|
||||
@@ -836,9 +862,9 @@ class Attribute(EventEmitter):
|
||||
|
||||
value = self.decode_value(value_bytes)
|
||||
|
||||
if write := getattr(self.value, 'write', None):
|
||||
if hasattr(self.value, 'write'):
|
||||
try:
|
||||
write(connection, value) # pylint: disable=not-callable
|
||||
self.value.write(connection, value) # pylint: disable=not-callable
|
||||
except ATT_Error as error:
|
||||
raise ATT_Error(
|
||||
error_code=error.error_code, att_handle=self.handle
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import itertools
|
||||
@@ -58,8 +60,10 @@ from bumble.hci import (
|
||||
HCI_Packet,
|
||||
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
|
||||
@@ -104,7 +108,7 @@ class Controller:
|
||||
self,
|
||||
name,
|
||||
host_source=None,
|
||||
host_sink=None,
|
||||
host_sink: Optional[TransportSink] = None,
|
||||
link=None,
|
||||
public_address: Optional[Union[bytes, str, Address]] = None,
|
||||
):
|
||||
@@ -188,6 +192,8 @@ class Controller:
|
||||
if link:
|
||||
link.add_controller(self)
|
||||
|
||||
self.terminated = asyncio.get_running_loop().create_future()
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
return self.hci_sink
|
||||
@@ -288,10 +294,9 @@ class Controller:
|
||||
if self.host:
|
||||
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):
|
||||
# For now, just wait forever
|
||||
await asyncio.get_running_loop().create_future()
|
||||
await self.terminated
|
||||
|
||||
############################################################
|
||||
# Link connections
|
||||
@@ -654,7 +659,7 @@ class Controller:
|
||||
|
||||
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:
|
||||
@@ -685,7 +690,7 @@ class Controller:
|
||||
|
||||
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
|
||||
self.send_hci_packet(
|
||||
@@ -719,7 +724,7 @@ class Controller:
|
||||
|
||||
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:
|
||||
@@ -735,7 +740,7 @@ class Controller:
|
||||
|
||||
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:
|
||||
@@ -751,21 +756,21 @@ class Controller:
|
||||
|
||||
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
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
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
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
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
|
||||
if len(local_name):
|
||||
@@ -780,7 +785,7 @@ class Controller:
|
||||
|
||||
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]
|
||||
if len(local_name) < 248:
|
||||
@@ -790,19 +795,19 @@ class Controller:
|
||||
|
||||
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])
|
||||
|
||||
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])
|
||||
|
||||
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
|
||||
'''
|
||||
if self.sync_flow_control:
|
||||
@@ -813,7 +818,7 @@ class Controller:
|
||||
|
||||
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
|
||||
'''
|
||||
ret = HCI_SUCCESS
|
||||
@@ -825,41 +830,59 @@ class Controller:
|
||||
ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR
|
||||
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):
|
||||
'''
|
||||
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])
|
||||
|
||||
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])
|
||||
|
||||
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
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
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])
|
||||
|
||||
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
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
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
|
||||
'''
|
||||
# TODO
|
||||
@@ -867,7 +890,7 @@ class Controller:
|
||||
|
||||
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(
|
||||
'<BBHBHH',
|
||||
@@ -881,19 +904,19 @@ class Controller:
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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 = (
|
||||
self._public_address.to_bytes()
|
||||
@@ -904,14 +927,14 @@ class Controller:
|
||||
|
||||
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
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
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(
|
||||
'<BHB',
|
||||
@@ -922,49 +945,49 @@ class Controller:
|
||||
|
||||
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
|
||||
'''
|
||||
return bytes([HCI_SUCCESS]) + self.le_features
|
||||
|
||||
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
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
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
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
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
|
||||
'''
|
||||
return bytes([HCI_SUCCESS, self.advertising_channel_tx_power])
|
||||
|
||||
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
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
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
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
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:
|
||||
self.start_advertising()
|
||||
@@ -975,7 +998,7 @@ class Controller:
|
||||
|
||||
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_interval = command.le_scan_interval
|
||||
@@ -986,7 +1009,7 @@ class Controller:
|
||||
|
||||
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.filter_duplicates = command.filter_duplicates
|
||||
@@ -994,7 +1017,7 @@ class Controller:
|
||||
|
||||
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:
|
||||
@@ -1027,40 +1050,40 @@ class Controller:
|
||||
|
||||
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])
|
||||
|
||||
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
|
||||
'''
|
||||
return bytes([HCI_SUCCESS, self.filter_accept_list_size])
|
||||
|
||||
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])
|
||||
|
||||
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
|
||||
'''
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
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
|
||||
'''
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
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
|
||||
@@ -1083,13 +1106,13 @@ class Controller:
|
||||
|
||||
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))
|
||||
|
||||
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
|
||||
@@ -1122,13 +1145,13 @@ class Controller:
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
'''
|
||||
return struct.pack(
|
||||
@@ -1140,7 +1163,7 @@ class Controller:
|
||||
|
||||
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
|
||||
'''
|
||||
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):
|
||||
'''
|
||||
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
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
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
|
||||
'''
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
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])
|
||||
|
||||
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])
|
||||
|
||||
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
|
||||
'''
|
||||
ret = HCI_SUCCESS
|
||||
@@ -1190,7 +1213,7 @@ class Controller:
|
||||
|
||||
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
|
||||
'''
|
||||
self.le_rpa_timeout = command.rpa_timeout
|
||||
@@ -1198,7 +1221,7 @@ class Controller:
|
||||
|
||||
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(
|
||||
'<BHHHH',
|
||||
@@ -1211,7 +1234,7 @@ class Controller:
|
||||
|
||||
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(
|
||||
'<BHBB',
|
||||
@@ -1223,7 +1246,7 @@ class Controller:
|
||||
|
||||
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 = {
|
||||
'all_phys': command.all_phys,
|
||||
@@ -1234,6 +1257,6 @@ class Controller:
|
||||
|
||||
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)
|
||||
|
||||
101
bumble/core.py
101
bumble/core.py
@@ -17,7 +17,7 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
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
|
||||
|
||||
@@ -53,7 +53,7 @@ def bit_flags_to_strings(bits, bit_flag_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)
|
||||
if name is not None:
|
||||
return name
|
||||
@@ -78,7 +78,13 @@ def get_dict_key_by_value(dictionary, value):
|
||||
class BaseError(Exception):
|
||||
"""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: Optional[int],
|
||||
error_namespace: str = '',
|
||||
error_name: str = '',
|
||||
details: str = '',
|
||||
):
|
||||
super().__init__()
|
||||
self.error_code = error_code
|
||||
self.error_namespace = error_namespace
|
||||
@@ -90,12 +96,14 @@ class BaseError(Exception):
|
||||
namespace = f'{self.error_namespace}/'
|
||||
else:
|
||||
namespace = ''
|
||||
if self.error_name:
|
||||
name = f'{self.error_name} [0x{self.error_code:X}]'
|
||||
else:
|
||||
name = f'0x{self.error_code:X}'
|
||||
error_text = {
|
||||
(True, True): f'{self.error_name} [0x{self.error_code:X}]',
|
||||
(True, False): self.error_name,
|
||||
(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):
|
||||
@@ -134,6 +142,10 @@ class ConnectionError(BaseError): # pylint: disable=redefined-builtin
|
||||
self.peer_address = peer_address
|
||||
|
||||
|
||||
class ConnectionParameterUpdateError(BaseError):
|
||||
"""Connection Parameter Update Error"""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# UUID
|
||||
#
|
||||
@@ -562,11 +574,82 @@ class DeviceClass:
|
||||
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 = {
|
||||
COMPUTER_MAJOR_DEVICE_CLASS: COMPUTER_MINOR_DEVICE_CLASS_NAMES,
|
||||
PHONE_MAJOR_DEVICE_CLASS: PHONE_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
|
||||
|
||||
@@ -23,22 +23,18 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
import operator
|
||||
import platform
|
||||
|
||||
if platform.system() != 'Emscripten':
|
||||
import secrets
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||
generate_private_key,
|
||||
ECDH,
|
||||
EllipticCurvePublicNumbers,
|
||||
EllipticCurvePrivateNumbers,
|
||||
SECP256R1,
|
||||
)
|
||||
from cryptography.hazmat.primitives import cmac
|
||||
else:
|
||||
# TODO: implement stubs
|
||||
pass
|
||||
import secrets
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||
generate_private_key,
|
||||
ECDH,
|
||||
EllipticCurvePublicNumbers,
|
||||
EllipticCurvePrivateNumbers,
|
||||
SECP256R1,
|
||||
)
|
||||
from cryptography.hazmat.primitives import cmac
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
|
||||
287
bumble/device.py
287
bumble/device.py
@@ -23,7 +23,18 @@ import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager, AsyncExitStack
|
||||
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 .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
|
||||
@@ -86,6 +97,7 @@ from .hci import (
|
||||
HCI_LE_Extended_Create_Connection_Command,
|
||||
HCI_LE_Rand_Command,
|
||||
HCI_LE_Read_PHY_Command,
|
||||
HCI_LE_Set_Address_Resolution_Enable_Command,
|
||||
HCI_LE_Set_Advertising_Data_Command,
|
||||
HCI_LE_Set_Advertising_Enable_Command,
|
||||
HCI_LE_Set_Advertising_Parameters_Command,
|
||||
@@ -129,6 +141,7 @@ from .core import (
|
||||
BT_LE_TRANSPORT,
|
||||
BT_PERIPHERAL_ROLE,
|
||||
AdvertisingData,
|
||||
ConnectionParameterUpdateError,
|
||||
CommandTimeoutError,
|
||||
ConnectionPHY,
|
||||
InvalidStateError,
|
||||
@@ -151,6 +164,9 @@ from . import sdp
|
||||
from . import l2cap
|
||||
from . import core
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .transport.common import TransportSource, TransportSink
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -651,7 +667,7 @@ class Connection(CompositeEventEmitter):
|
||||
def is_incomplete(self) -> bool:
|
||||
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)
|
||||
|
||||
def create_l2cap_connector(self, psm):
|
||||
@@ -708,6 +724,7 @@ class Connection(CompositeEventEmitter):
|
||||
connection_interval_max,
|
||||
max_latency,
|
||||
supervision_timeout,
|
||||
use_l2cap=False,
|
||||
):
|
||||
return await self.device.update_connection_parameters(
|
||||
self,
|
||||
@@ -715,6 +732,7 @@ class Connection(CompositeEventEmitter):
|
||||
connection_interval_max,
|
||||
max_latency,
|
||||
supervision_timeout,
|
||||
use_l2cap=use_l2cap,
|
||||
)
|
||||
|
||||
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.keystore = None
|
||||
self.gatt_services: List[Dict[str, Any]] = []
|
||||
self.address_resolution_offload = False
|
||||
|
||||
def load_from_dict(self, config: Dict[str, Any]) -> None:
|
||||
# Load simple properties
|
||||
@@ -940,7 +959,13 @@ class Device(CompositeEventEmitter):
|
||||
pass
|
||||
|
||||
@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
|
||||
through an HCI source/sink
|
||||
@@ -949,18 +974,25 @@ class Device(CompositeEventEmitter):
|
||||
return cls(name=name, address=address, host=host)
|
||||
|
||||
@classmethod
|
||||
def from_config_file(cls, filename):
|
||||
def from_config_file(cls, filename: str) -> Device:
|
||||
config = DeviceConfiguration()
|
||||
config.load_from_file(filename)
|
||||
return cls(config=config)
|
||||
|
||||
@classmethod
|
||||
def from_config_with_hci(cls, config, hci_source, hci_sink):
|
||||
def from_config_with_hci(
|
||||
cls,
|
||||
config: DeviceConfiguration,
|
||||
hci_source: TransportSource,
|
||||
hci_sink: TransportSink,
|
||||
) -> Device:
|
||||
host = Host(controller_source=hci_source, controller_sink=hci_sink)
|
||||
return cls(config=config, host=host)
|
||||
|
||||
@classmethod
|
||||
def from_config_file_with_hci(cls, filename, hci_source, hci_sink):
|
||||
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)
|
||||
@@ -1029,6 +1061,7 @@ class Device(CompositeEventEmitter):
|
||||
self.discoverable = config.discoverable
|
||||
self.connectable = config.connectable
|
||||
self.classic_accept_any = config.classic_accept_any
|
||||
self.address_resolution_offload = config.address_resolution_offload
|
||||
|
||||
for service in config.gatt_services:
|
||||
characteristics = []
|
||||
@@ -1093,7 +1126,7 @@ class Device(CompositeEventEmitter):
|
||||
return self._host
|
||||
|
||||
@host.setter
|
||||
def host(self, host):
|
||||
def host(self, host: Host) -> None:
|
||||
# Unsubscribe from events from the current host
|
||||
if self._host:
|
||||
for event_name in device_host_event_handlers:
|
||||
@@ -1153,8 +1186,8 @@ class Device(CompositeEventEmitter):
|
||||
def create_l2cap_registrar(self, psm):
|
||||
return lambda handler: self.register_l2cap_server(psm, handler)
|
||||
|
||||
def register_l2cap_server(self, psm, server):
|
||||
self.l2cap_channel_manager.register_server(psm, server)
|
||||
def register_l2cap_server(self, psm, server) -> int:
|
||||
return self.l2cap_channel_manager.register_server(psm, server)
|
||||
|
||||
def register_l2cap_channel_server(
|
||||
self,
|
||||
@@ -1180,7 +1213,7 @@ class Device(CompositeEventEmitter):
|
||||
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)
|
||||
|
||||
async def send_command(self, command, check_result=False):
|
||||
@@ -1256,31 +1289,16 @@ class Device(CompositeEventEmitter):
|
||||
)
|
||||
|
||||
# Load the address resolving list
|
||||
if self.keystore and self.host.supports_command(
|
||||
HCI_LE_CLEAR_RESOLVING_LIST_COMMAND
|
||||
):
|
||||
await self.send_command(HCI_LE_Clear_Resolving_List_Command()) # type: ignore[call-arg]
|
||||
if self.keystore:
|
||||
await self.refresh_resolving_list()
|
||||
|
||||
resolving_keys = await self.keystore.get_resolving_keys()
|
||||
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]
|
||||
)
|
||||
|
||||
# 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)
|
||||
# Enable address resolution
|
||||
if self.address_resolution_offload:
|
||||
await self.send_command(
|
||||
HCI_LE_Set_Address_Resolution_Enable_Command(
|
||||
address_resolution_enable=1
|
||||
) # type: ignore[call-arg]
|
||||
)
|
||||
|
||||
if self.classic_enabled:
|
||||
await self.send_command(
|
||||
@@ -1310,6 +1328,26 @@ class Device(CompositeEventEmitter):
|
||||
await self.host.flush()
|
||||
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):
|
||||
return self.host.supports_le_feature(feature)
|
||||
|
||||
@@ -1387,10 +1425,10 @@ class Device(CompositeEventEmitter):
|
||||
check_result=True,
|
||||
)
|
||||
|
||||
self.advertising_own_address_type = own_address_type
|
||||
self.auto_restart_advertising = auto_restart
|
||||
self.advertising_type = advertising_type
|
||||
self.advertising_own_address_type = own_address_type
|
||||
self.advertising = True
|
||||
self.auto_restart_advertising = auto_restart
|
||||
|
||||
async def stop_advertising(self) -> None:
|
||||
# Disable advertising
|
||||
@@ -1400,9 +1438,9 @@ class Device(CompositeEventEmitter):
|
||||
check_result=True,
|
||||
)
|
||||
|
||||
self.advertising_type = None
|
||||
self.advertising_own_address_type = None
|
||||
self.advertising = False
|
||||
self.advertising_type = None
|
||||
self.auto_restart_advertising = False
|
||||
|
||||
@property
|
||||
@@ -2075,11 +2113,30 @@ class Device(CompositeEventEmitter):
|
||||
supervision_timeout,
|
||||
min_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
|
||||
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(
|
||||
HCI_LE_Connection_Update_Command(
|
||||
connection_handle=connection.handle,
|
||||
@@ -2089,7 +2146,7 @@ class Device(CompositeEventEmitter):
|
||||
supervision_timeout=supervision_timeout,
|
||||
min_ce_length=min_ce_length,
|
||||
max_ce_length=max_ce_length,
|
||||
)
|
||||
) # type: ignore[call-arg]
|
||||
)
|
||||
if result.status != HCI_Command_Status_Event.PENDING:
|
||||
raise HCI_StatusError(result)
|
||||
@@ -2230,9 +2287,11 @@ class Device(CompositeEventEmitter):
|
||||
def request_pairing(self, 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:
|
||||
return
|
||||
return None
|
||||
|
||||
# Start by looking for the key in an SMP session
|
||||
ltk = self.smp_manager.get_long_term_key(connection, rand, ediv)
|
||||
@@ -2252,19 +2311,24 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
if connection.role == BT_PERIPHERAL_ROLE and keys.ltk_peripheral:
|
||||
return keys.ltk_peripheral.value
|
||||
return None
|
||||
|
||||
async def get_link_key(self, address: Address) -> Optional[bytes]:
|
||||
# Look for the key in the keystore
|
||||
if self.keystore is not 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
|
||||
if self.keystore is None:
|
||||
return None
|
||||
|
||||
return keys.link_key.value
|
||||
return None
|
||||
# Look for the key in the keystore
|
||||
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]
|
||||
async def authenticate(self, connection):
|
||||
@@ -2383,6 +2447,18 @@ class Device(CompositeEventEmitter):
|
||||
'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]
|
||||
async def switch_role(self, connection: Connection, role: int):
|
||||
pending_role_change = asyncio.get_running_loop().create_future()
|
||||
@@ -2477,13 +2553,7 @@ class Device(CompositeEventEmitter):
|
||||
value=link_key, authenticated=authenticated
|
||||
)
|
||||
|
||||
async def store_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())
|
||||
self.abort_on('flush', self.update_keys(str(bd_addr), pairing_keys))
|
||||
|
||||
if connection := self.find_connection_by_bd_addr(
|
||||
bd_addr, transport=BT_BR_EDR_TRANSPORT
|
||||
@@ -2560,7 +2630,6 @@ class Device(CompositeEventEmitter):
|
||||
own_address_type = self.advertising_own_address_type
|
||||
|
||||
# We are no longer advertising
|
||||
self.advertising_own_address_type = None
|
||||
self.advertising = False
|
||||
|
||||
if own_address_type in (
|
||||
@@ -2617,7 +2686,6 @@ class Device(CompositeEventEmitter):
|
||||
and self.advertising
|
||||
and self.advertising_type.is_directed
|
||||
):
|
||||
self.advertising_own_address_type = None
|
||||
self.advertising = False
|
||||
|
||||
# Notify listeners
|
||||
@@ -2688,7 +2756,9 @@ class Device(CompositeEventEmitter):
|
||||
self.abort_on(
|
||||
'flush',
|
||||
self.start_advertising(
|
||||
advertising_type=self.advertising_type, auto_restart=True
|
||||
advertising_type=self.advertising_type,
|
||||
own_address_type=self.advertising_own_address_type,
|
||||
auto_restart=True,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2735,20 +2805,6 @@ class Device(CompositeEventEmitter):
|
||||
)
|
||||
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]
|
||||
@host_event_handler
|
||||
@with_connection_from_address
|
||||
@@ -2851,18 +2907,22 @@ class Device(CompositeEventEmitter):
|
||||
method = methods[peer_io_capability][io_capability]
|
||||
|
||||
async def reply() -> None:
|
||||
if await connection.abort_on('disconnection', method()):
|
||||
await self.host.send_command(
|
||||
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
|
||||
try:
|
||||
if await connection.abort_on('disconnection', method()):
|
||||
await self.host.send_command(
|
||||
HCI_User_Confirmation_Request_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())
|
||||
|
||||
@@ -2874,21 +2934,25 @@ class Device(CompositeEventEmitter):
|
||||
pairing_config = self.pairing_config_factory(connection)
|
||||
|
||||
async def reply() -> None:
|
||||
number = await connection.abort_on(
|
||||
'disconnection', pairing_config.delegate.get_number()
|
||||
try:
|
||||
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())
|
||||
|
||||
@@ -3095,10 +3159,31 @@ class Device(CompositeEventEmitter):
|
||||
connection.emit('role_change_failure', 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:
|
||||
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.authenticated = True
|
||||
connection.emit('pairing', keys)
|
||||
@@ -3134,7 +3219,7 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
@host_event_handler
|
||||
@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)
|
||||
|
||||
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
|
||||
@@ -28,7 +28,7 @@ import enum
|
||||
import functools
|
||||
import logging
|
||||
import struct
|
||||
from typing import Optional, Sequence, List
|
||||
from typing import Optional, Sequence, Iterable, List, Union
|
||||
|
||||
from .colors import color
|
||||
from .core import UUID, get_dict_key_by_value
|
||||
@@ -187,7 +187,7 @@ GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bi
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def show_services(services):
|
||||
def show_services(services: Iterable[Service]) -> None:
|
||||
for service in services:
|
||||
print(color(str(service), 'cyan'))
|
||||
|
||||
@@ -210,11 +210,11 @@ class Service(Attribute):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
uuid,
|
||||
uuid: Union[str, UUID],
|
||||
characteristics: List[Characteristic],
|
||||
primary=True,
|
||||
included_services: List[Service] = [],
|
||||
):
|
||||
) -> None:
|
||||
# Convert the uuid to a UUID object if it isn't already
|
||||
if isinstance(uuid, str):
|
||||
uuid = UUID(uuid)
|
||||
@@ -239,7 +239,7 @@ class Service(Attribute):
|
||||
"""
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'Service(handle=0x{self.handle:04X}, '
|
||||
f'end=0x{self.end_group_handle:04X}, '
|
||||
@@ -255,9 +255,11 @@ class TemplateService(Service):
|
||||
to expose their UUID as a class property
|
||||
'''
|
||||
|
||||
UUID: Optional[UUID] = None
|
||||
UUID: UUID
|
||||
|
||||
def __init__(self, characteristics, primary=True):
|
||||
def __init__(
|
||||
self, characteristics: List[Characteristic], primary: bool = True
|
||||
) -> None:
|
||||
super().__init__(self.UUID, characteristics, primary)
|
||||
|
||||
|
||||
@@ -269,7 +271,7 @@ class IncludedServiceDeclaration(Attribute):
|
||||
|
||||
service: Service
|
||||
|
||||
def __init__(self, service):
|
||||
def __init__(self, service: Service) -> None:
|
||||
declaration_bytes = struct.pack(
|
||||
'<HH2s', service.handle, service.end_group_handle, service.uuid.to_bytes()
|
||||
)
|
||||
@@ -278,13 +280,12 @@ class IncludedServiceDeclaration(Attribute):
|
||||
)
|
||||
self.service = service
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'IncludedServiceDefinition(handle=0x{self.handle:04X}, '
|
||||
f'group_starting_handle=0x{self.service.handle:04X}, '
|
||||
f'group_ending_handle=0x{self.service.end_group_handle:04X}, '
|
||||
f'uuid={self.service.uuid}, '
|
||||
f'{self.service.properties!s})'
|
||||
f'uuid={self.service.uuid})'
|
||||
)
|
||||
|
||||
|
||||
@@ -309,31 +310,33 @@ class Characteristic(Attribute):
|
||||
AUTHENTICATED_SIGNED_WRITES = 0x40
|
||||
EXTENDED_PROPERTIES = 0x80
|
||||
|
||||
@staticmethod
|
||||
def from_string(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")
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, properties_str: str) -> Characteristic.Properties:
|
||||
try:
|
||||
return functools.reduce(
|
||||
lambda x, y: x | string_to_property(y),
|
||||
properties_str.split(","),
|
||||
lambda x, y: x | cls[y],
|
||||
properties_str.replace("|", ",").split(","),
|
||||
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(
|
||||
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) -> str:
|
||||
# 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 new code, please use Characteristic.Properties.X
|
||||
BROADCAST = Properties.BROADCAST
|
||||
@@ -347,10 +350,10 @@ class Characteristic(Attribute):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
uuid,
|
||||
uuid: Union[str, bytes, UUID],
|
||||
properties: Characteristic.Properties,
|
||||
permissions,
|
||||
value=b'',
|
||||
permissions: Union[str, Attribute.Permissions],
|
||||
value: Union[str, bytes, CharacteristicValue] = b'',
|
||||
descriptors: Sequence[Descriptor] = (),
|
||||
):
|
||||
super().__init__(uuid, permissions, value)
|
||||
@@ -368,12 +371,12 @@ class Characteristic(Attribute):
|
||||
def has_properties(self, properties: Characteristic.Properties) -> bool:
|
||||
return self.properties & properties == properties
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'Characteristic(handle=0x{self.handle:04X}, '
|
||||
f'end=0x{self.end_group_handle:04X}, '
|
||||
f'uuid={self.uuid}, '
|
||||
f'{self.properties!s})'
|
||||
f'{self.properties})'
|
||||
)
|
||||
|
||||
|
||||
@@ -385,7 +388,7 @@ class CharacteristicDeclaration(Attribute):
|
||||
|
||||
characteristic: Characteristic
|
||||
|
||||
def __init__(self, characteristic, value_handle):
|
||||
def __init__(self, characteristic: Characteristic, value_handle: int) -> None:
|
||||
declaration_bytes = (
|
||||
struct.pack('<BH', characteristic.properties, value_handle)
|
||||
+ characteristic.uuid.to_pdu_bytes()
|
||||
@@ -396,12 +399,12 @@ class CharacteristicDeclaration(Attribute):
|
||||
self.value_handle = value_handle
|
||||
self.characteristic = characteristic
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
|
||||
f'value_handle=0x{self.value_handle:04X}, '
|
||||
f'uuid={self.characteristic.uuid}, '
|
||||
f'{self.characteristic.properties!s})'
|
||||
f'{self.characteristic.properties})'
|
||||
)
|
||||
|
||||
|
||||
@@ -519,7 +522,7 @@ class CharacteristicAdapter:
|
||||
|
||||
return self.wrapped_characteristic.unsubscribe(subscriber)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
wrapped = str(self.wrapped_characteristic)
|
||||
return f'{self.__class__.__name__}({wrapped})'
|
||||
|
||||
@@ -599,10 +602,10 @@ class UTF8CharacteristicAdapter(CharacteristicAdapter):
|
||||
Adapter that converts strings to/from bytes using UTF-8 encoding
|
||||
'''
|
||||
|
||||
def encode_value(self, value):
|
||||
def encode_value(self, value: str) -> bytes:
|
||||
return value.encode('utf-8')
|
||||
|
||||
def decode_value(self, value):
|
||||
def decode_value(self, value: bytes) -> str:
|
||||
return value.decode('utf-8')
|
||||
|
||||
|
||||
@@ -612,7 +615,7 @@ class Descriptor(Attribute):
|
||||
See Vol 3, Part G - 3.3.3 Characteristic Descriptor Declarations
|
||||
'''
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'Descriptor(handle=0x{self.handle:04X}, '
|
||||
f'type={self.type}, '
|
||||
|
||||
@@ -28,7 +28,18 @@ import asyncio
|
||||
import logging
|
||||
import struct
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Tuple, Callable, Union, Any
|
||||
from typing import (
|
||||
List,
|
||||
Optional,
|
||||
Dict,
|
||||
Tuple,
|
||||
Callable,
|
||||
Union,
|
||||
Any,
|
||||
Iterable,
|
||||
Type,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
from pyee import EventEmitter
|
||||
|
||||
@@ -66,8 +77,12 @@ from .gatt import (
|
||||
GATT_INCLUDE_ATTRIBUTE_TYPE,
|
||||
Characteristic,
|
||||
ClientCharacteristicConfigurationBits,
|
||||
TemplateService,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble.device import Connection
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -78,16 +93,16 @@ logger = logging.getLogger(__name__)
|
||||
# Proxies
|
||||
# -----------------------------------------------------------------------------
|
||||
class AttributeProxy(EventEmitter):
|
||||
client: Client
|
||||
|
||||
def __init__(self, client, handle, end_group_handle, attribute_type):
|
||||
def __init__(
|
||||
self, client: Client, handle: int, end_group_handle: int, attribute_type: UUID
|
||||
) -> None:
|
||||
EventEmitter.__init__(self)
|
||||
self.client = client
|
||||
self.handle = handle
|
||||
self.end_group_handle = end_group_handle
|
||||
self.type = attribute_type
|
||||
|
||||
async def read_value(self, no_long_read=False):
|
||||
async def read_value(self, no_long_read: bool = False) -> bytes:
|
||||
return self.decode_value(
|
||||
await self.client.read_value(self.handle, no_long_read)
|
||||
)
|
||||
@@ -97,13 +112,13 @@ class AttributeProxy(EventEmitter):
|
||||
self.handle, self.encode_value(value), with_response
|
||||
)
|
||||
|
||||
def encode_value(self, value):
|
||||
def encode_value(self, value: Any) -> bytes:
|
||||
return value
|
||||
|
||||
def decode_value(self, value_bytes):
|
||||
def decode_value(self, value_bytes: bytes) -> Any:
|
||||
return value_bytes
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return f'Attribute(handle=0x{self.handle:04X}, type={self.type})'
|
||||
|
||||
|
||||
@@ -136,14 +151,14 @@ class ServiceProxy(AttributeProxy):
|
||||
def get_characteristics_by_uuid(self, uuid):
|
||||
return self.client.get_characteristics_by_uuid(uuid, self)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return f'Service(handle=0x{self.handle:04X}, uuid={self.uuid})'
|
||||
|
||||
|
||||
class CharacteristicProxy(AttributeProxy):
|
||||
properties: Characteristic.Properties
|
||||
descriptors: List[DescriptorProxy]
|
||||
subscribers: Dict[Any, Callable]
|
||||
subscribers: Dict[Any, Callable[[bytes], Any]]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -171,7 +186,9 @@ class CharacteristicProxy(AttributeProxy):
|
||||
return await self.client.discover_descriptors(self)
|
||||
|
||||
async def subscribe(
|
||||
self, subscriber: Optional[Callable] = None, prefer_notify=True
|
||||
self,
|
||||
subscriber: Optional[Callable[[bytes], Any]] = None,
|
||||
prefer_notify: bool = True,
|
||||
):
|
||||
if subscriber is not None:
|
||||
if subscriber in self.subscribers:
|
||||
@@ -195,7 +212,7 @@ class CharacteristicProxy(AttributeProxy):
|
||||
|
||||
return await self.client.unsubscribe(self, subscriber)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'Characteristic(handle=0x{self.handle:04X}, '
|
||||
f'uuid={self.uuid}, '
|
||||
@@ -207,7 +224,7 @@ class DescriptorProxy(AttributeProxy):
|
||||
def __init__(self, client, handle, descriptor_type):
|
||||
super().__init__(client, handle, 0, descriptor_type)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return f'Descriptor(handle=0x{self.handle:04X}, type={self.type})'
|
||||
|
||||
|
||||
@@ -216,8 +233,10 @@ class ProfileServiceProxy:
|
||||
Base class for profile-specific service proxies
|
||||
'''
|
||||
|
||||
SERVICE_CLASS: Type[TemplateService]
|
||||
|
||||
@classmethod
|
||||
def from_client(cls, client):
|
||||
def from_client(cls, client: Client) -> ProfileServiceProxy:
|
||||
return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID)
|
||||
|
||||
|
||||
@@ -227,8 +246,12 @@ class ProfileServiceProxy:
|
||||
class Client:
|
||||
services: List[ServiceProxy]
|
||||
cached_values: Dict[int, Tuple[datetime, bytes]]
|
||||
notification_subscribers: Dict[int, Callable[[bytes], Any]]
|
||||
indication_subscribers: Dict[int, Callable[[bytes], Any]]
|
||||
pending_response: Optional[asyncio.futures.Future[ATT_PDU]]
|
||||
pending_request: Optional[ATT_PDU]
|
||||
|
||||
def __init__(self, connection):
|
||||
def __init__(self, connection: Connection) -> None:
|
||||
self.connection = connection
|
||||
self.mtu_exchange_done = False
|
||||
self.request_semaphore = asyncio.Semaphore(1)
|
||||
@@ -241,16 +264,16 @@ class Client:
|
||||
self.services = []
|
||||
self.cached_values = {}
|
||||
|
||||
def send_gatt_pdu(self, pdu):
|
||||
def send_gatt_pdu(self, pdu: bytes) -> None:
|
||||
self.connection.send_l2cap_pdu(ATT_CID, pdu)
|
||||
|
||||
async def send_command(self, command):
|
||||
async def send_command(self, command: ATT_PDU) -> None:
|
||||
logger.debug(
|
||||
f'GATT Command from client: [0x{self.connection.handle:04X}] {command}'
|
||||
)
|
||||
self.send_gatt_pdu(command.to_bytes())
|
||||
|
||||
async def send_request(self, request):
|
||||
async def send_request(self, request: ATT_PDU):
|
||||
logger.debug(
|
||||
f'GATT Request from client: [0x{self.connection.handle:04X}] {request}'
|
||||
)
|
||||
@@ -279,14 +302,14 @@ class Client:
|
||||
|
||||
return response
|
||||
|
||||
def send_confirmation(self, confirmation):
|
||||
def send_confirmation(self, confirmation: ATT_Handle_Value_Confirmation) -> None:
|
||||
logger.debug(
|
||||
f'GATT Confirmation from client: [0x{self.connection.handle:04X}] '
|
||||
f'{confirmation}'
|
||||
)
|
||||
self.send_gatt_pdu(confirmation.to_bytes())
|
||||
|
||||
async def request_mtu(self, mtu):
|
||||
async def request_mtu(self, mtu: int) -> int:
|
||||
# Check the range
|
||||
if mtu < ATT_DEFAULT_MTU:
|
||||
raise ValueError(f'MTU must be >= {ATT_DEFAULT_MTU}')
|
||||
@@ -313,10 +336,12 @@ class Client:
|
||||
|
||||
return self.connection.att_mtu
|
||||
|
||||
def get_services_by_uuid(self, uuid):
|
||||
def get_services_by_uuid(self, uuid: UUID) -> List[ServiceProxy]:
|
||||
return [service for service in self.services if service.uuid == uuid]
|
||||
|
||||
def get_characteristics_by_uuid(self, uuid, service=None):
|
||||
def get_characteristics_by_uuid(
|
||||
self, uuid: UUID, service: Optional[ServiceProxy] = None
|
||||
) -> List[CharacteristicProxy]:
|
||||
services = [service] if service else self.services
|
||||
return [
|
||||
c
|
||||
@@ -363,7 +388,7 @@ class Client:
|
||||
if not already_known:
|
||||
self.services.append(service)
|
||||
|
||||
async def discover_services(self, uuids=None) -> List[ServiceProxy]:
|
||||
async def discover_services(self, uuids: Iterable[UUID] = []) -> List[ServiceProxy]:
|
||||
'''
|
||||
See Vol 3, Part G - 4.4.1 Discover All Primary Services
|
||||
'''
|
||||
@@ -435,7 +460,7 @@ class Client:
|
||||
|
||||
return services
|
||||
|
||||
async def discover_service(self, uuid):
|
||||
async def discover_service(self, uuid: Union[str, UUID]) -> List[ServiceProxy]:
|
||||
'''
|
||||
See Vol 3, Part G - 4.4.2 Discover Primary Service by Service UUID
|
||||
'''
|
||||
@@ -468,7 +493,7 @@ class Client:
|
||||
f'{HCI_Constant.error_name(response.error_code)}'
|
||||
)
|
||||
# TODO raise appropriate exception
|
||||
return
|
||||
return []
|
||||
break
|
||||
|
||||
for attribute_handle, end_group_handle in response.handles_information:
|
||||
@@ -480,7 +505,7 @@ class Client:
|
||||
logger.warning(
|
||||
f'bogus handle values: {attribute_handle} {end_group_handle}'
|
||||
)
|
||||
return
|
||||
return []
|
||||
|
||||
# Create a service proxy for this service
|
||||
service = ServiceProxy(
|
||||
@@ -721,7 +746,7 @@ class Client:
|
||||
|
||||
return descriptors
|
||||
|
||||
async def discover_attributes(self):
|
||||
async def discover_attributes(self) -> List[AttributeProxy]:
|
||||
'''
|
||||
Discover all attributes, regardless of type
|
||||
'''
|
||||
@@ -844,7 +869,9 @@ class Client:
|
||||
# No more subscribers left
|
||||
await self.write_value(cccd, b'\x00\x00', with_response=True)
|
||||
|
||||
async def read_value(self, attribute, no_long_read=False):
|
||||
async def read_value(
|
||||
self, attribute: Union[int, AttributeProxy], no_long_read: bool = False
|
||||
) -> Any:
|
||||
'''
|
||||
See Vol 3, Part G - 4.8.1 Read Characteristic Value
|
||||
|
||||
@@ -905,7 +932,9 @@ class Client:
|
||||
# Return the value as bytes
|
||||
return attribute_value
|
||||
|
||||
async def read_characteristics_by_uuid(self, uuid, service):
|
||||
async def read_characteristics_by_uuid(
|
||||
self, uuid: UUID, service: Optional[ServiceProxy]
|
||||
) -> List[bytes]:
|
||||
'''
|
||||
See Vol 3, Part G - 4.8.2 Read Using Characteristic UUID
|
||||
'''
|
||||
@@ -960,7 +989,12 @@ class Client:
|
||||
|
||||
return characteristics_values
|
||||
|
||||
async def write_value(self, attribute, value, with_response=False):
|
||||
async def write_value(
|
||||
self,
|
||||
attribute: Union[int, AttributeProxy],
|
||||
value: bytes,
|
||||
with_response: bool = False,
|
||||
) -> None:
|
||||
'''
|
||||
See Vol 3, Part G - 4.9.1 Write Without Response & 4.9.3 Write Characteristic
|
||||
Value
|
||||
@@ -990,7 +1024,7 @@ class Client:
|
||||
)
|
||||
)
|
||||
|
||||
def on_gatt_pdu(self, att_pdu):
|
||||
def on_gatt_pdu(self, att_pdu: ATT_PDU) -> None:
|
||||
logger.debug(
|
||||
f'GATT Response to client: [0x{self.connection.handle:04X}] {att_pdu}'
|
||||
)
|
||||
@@ -1013,6 +1047,7 @@ class Client:
|
||||
return
|
||||
|
||||
# Return the response to the coroutine that is waiting for it
|
||||
assert self.pending_response is not None
|
||||
self.pending_response.set_result(att_pdu)
|
||||
else:
|
||||
handler_name = f'on_{att_pdu.name.lower()}'
|
||||
@@ -1060,7 +1095,7 @@ class Client:
|
||||
# Confirm that we received the indication
|
||||
self.send_confirmation(ATT_Handle_Value_Confirmation())
|
||||
|
||||
def cache_value(self, attribute_handle: int, value: bytes):
|
||||
def cache_value(self, attribute_handle: int, value: bytes) -> None:
|
||||
self.cached_values[attribute_handle] = (
|
||||
datetime.now(),
|
||||
value,
|
||||
|
||||
@@ -23,11 +23,12 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
import struct
|
||||
from typing import List, Tuple, Optional, TypeVar, Type
|
||||
from typing import List, Tuple, Optional, TypeVar, Type, Dict, Iterable, TYPE_CHECKING
|
||||
from pyee import EventEmitter
|
||||
|
||||
from .colors import color
|
||||
@@ -42,6 +43,7 @@ from .att import (
|
||||
ATT_INVALID_OFFSET_ERROR,
|
||||
ATT_REQUEST_NOT_SUPPORTED_ERROR,
|
||||
ATT_REQUESTS,
|
||||
ATT_PDU,
|
||||
ATT_UNLIKELY_ERROR_ERROR,
|
||||
ATT_UNSUPPORTED_GROUP_TYPE_ERROR,
|
||||
ATT_Error,
|
||||
@@ -73,6 +75,8 @@ from .gatt import (
|
||||
Service,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble.device import Device, Connection
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -91,8 +95,13 @@ GATT_SERVER_DEFAULT_MAX_MTU = 517
|
||||
# -----------------------------------------------------------------------------
|
||||
class Server(EventEmitter):
|
||||
attributes: List[Attribute]
|
||||
services: List[Service]
|
||||
attributes_by_handle: Dict[int, Attribute]
|
||||
subscribers: Dict[int, Dict[int, bytes]]
|
||||
indication_semaphores: defaultdict[int, asyncio.Semaphore]
|
||||
pending_confirmations: defaultdict[int, Optional[asyncio.futures.Future]]
|
||||
|
||||
def __init__(self, device):
|
||||
def __init__(self, device: Device) -> None:
|
||||
super().__init__()
|
||||
self.device = device
|
||||
self.services = []
|
||||
@@ -107,16 +116,16 @@ class Server(EventEmitter):
|
||||
self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1))
|
||||
self.pending_confirmations = defaultdict(lambda: None)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return "\n".join(map(str, self.attributes))
|
||||
|
||||
def send_gatt_pdu(self, connection_handle, pdu):
|
||||
def send_gatt_pdu(self, connection_handle: int, pdu: bytes) -> None:
|
||||
self.device.send_l2cap_pdu(connection_handle, ATT_CID, pdu)
|
||||
|
||||
def next_handle(self):
|
||||
def next_handle(self) -> int:
|
||||
return 1 + len(self.attributes)
|
||||
|
||||
def get_advertising_service_data(self):
|
||||
def get_advertising_service_data(self) -> Dict[Attribute, bytes]:
|
||||
return {
|
||||
attribute: data
|
||||
for attribute in self.attributes
|
||||
@@ -124,7 +133,7 @@ class Server(EventEmitter):
|
||||
and (data := attribute.get_advertising_data())
|
||||
}
|
||||
|
||||
def get_attribute(self, handle):
|
||||
def get_attribute(self, handle: int) -> Optional[Attribute]:
|
||||
attribute = self.attributes_by_handle.get(handle)
|
||||
if attribute:
|
||||
return attribute
|
||||
@@ -173,12 +182,17 @@ class Server(EventEmitter):
|
||||
|
||||
return next(
|
||||
(
|
||||
(attribute, self.get_attribute(attribute.characteristic.handle))
|
||||
(
|
||||
attribute,
|
||||
self.get_attribute(attribute.characteristic.handle),
|
||||
) # type: ignore
|
||||
for attribute in map(
|
||||
self.get_attribute,
|
||||
range(service_handle.handle, service_handle.end_group_handle + 1),
|
||||
)
|
||||
if attribute.type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
|
||||
if attribute is not None
|
||||
and attribute.type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
|
||||
and isinstance(attribute, CharacteristicDeclaration)
|
||||
and attribute.characteristic.uuid == characteristic_uuid
|
||||
),
|
||||
None,
|
||||
@@ -197,7 +211,7 @@ class Server(EventEmitter):
|
||||
|
||||
return next(
|
||||
(
|
||||
attribute
|
||||
attribute # type: ignore
|
||||
for attribute in map(
|
||||
self.get_attribute,
|
||||
range(
|
||||
@@ -205,12 +219,12 @@ class Server(EventEmitter):
|
||||
characteristic_value.end_group_handle + 1,
|
||||
),
|
||||
)
|
||||
if attribute.type == descriptor_uuid
|
||||
if attribute is not None and attribute.type == descriptor_uuid
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def add_attribute(self, attribute):
|
||||
def add_attribute(self, attribute: Attribute) -> None:
|
||||
# Assign a handle to this attribute
|
||||
attribute.handle = self.next_handle()
|
||||
attribute.end_group_handle = (
|
||||
@@ -220,7 +234,7 @@ class Server(EventEmitter):
|
||||
# Add this attribute to the list
|
||||
self.attributes.append(attribute)
|
||||
|
||||
def add_service(self, service: Service):
|
||||
def add_service(self, service: Service) -> None:
|
||||
# Add the service attribute to the DB
|
||||
self.add_attribute(service)
|
||||
|
||||
@@ -285,11 +299,13 @@ class Server(EventEmitter):
|
||||
service.end_group_handle = self.attributes[-1].handle
|
||||
self.services.append(service)
|
||||
|
||||
def add_services(self, services):
|
||||
def add_services(self, services: Iterable[Service]) -> None:
|
||||
for service in services:
|
||||
self.add_service(service)
|
||||
|
||||
def read_cccd(self, connection, characteristic):
|
||||
def read_cccd(
|
||||
self, connection: Optional[Connection], characteristic: Characteristic
|
||||
) -> bytes:
|
||||
if connection is None:
|
||||
return bytes([0, 0])
|
||||
|
||||
@@ -300,7 +316,12 @@ class Server(EventEmitter):
|
||||
|
||||
return cccd or bytes([0, 0])
|
||||
|
||||
def write_cccd(self, connection, characteristic, value):
|
||||
def write_cccd(
|
||||
self,
|
||||
connection: Connection,
|
||||
characteristic: Characteristic,
|
||||
value: bytes,
|
||||
) -> None:
|
||||
logger.debug(
|
||||
f'Subscription update for connection=0x{connection.handle:04X}, '
|
||||
f'handle=0x{characteristic.handle:04X}: {value.hex()}'
|
||||
@@ -327,13 +348,19 @@ class Server(EventEmitter):
|
||||
indicate_enabled,
|
||||
)
|
||||
|
||||
def send_response(self, connection, response):
|
||||
def send_response(self, connection: Connection, response: ATT_PDU) -> None:
|
||||
logger.debug(
|
||||
f'GATT Response from server: [0x{connection.handle:04X}] {response}'
|
||||
)
|
||||
self.send_gatt_pdu(connection.handle, response.to_bytes())
|
||||
|
||||
async def notify_subscriber(self, connection, attribute, value=None, force=False):
|
||||
async def notify_subscriber(
|
||||
self,
|
||||
connection: Connection,
|
||||
attribute: Attribute,
|
||||
value: Optional[bytes] = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
# Check if there's a subscriber
|
||||
if not force:
|
||||
subscribers = self.subscribers.get(connection.handle)
|
||||
@@ -370,7 +397,13 @@ class Server(EventEmitter):
|
||||
)
|
||||
self.send_gatt_pdu(connection.handle, bytes(notification))
|
||||
|
||||
async def indicate_subscriber(self, connection, attribute, value=None, force=False):
|
||||
async def indicate_subscriber(
|
||||
self,
|
||||
connection: Connection,
|
||||
attribute: Attribute,
|
||||
value: Optional[bytes] = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
# Check if there's a subscriber
|
||||
if not force:
|
||||
subscribers = self.subscribers.get(connection.handle)
|
||||
@@ -411,15 +444,13 @@ class Server(EventEmitter):
|
||||
assert self.pending_confirmations[connection.handle] is None
|
||||
|
||||
# Create a future value to hold the eventual response
|
||||
self.pending_confirmations[
|
||||
pending_confirmation = self.pending_confirmations[
|
||||
connection.handle
|
||||
] = asyncio.get_running_loop().create_future()
|
||||
|
||||
try:
|
||||
self.send_gatt_pdu(connection.handle, indication.to_bytes())
|
||||
await asyncio.wait_for(
|
||||
self.pending_confirmations[connection.handle], GATT_REQUEST_TIMEOUT
|
||||
)
|
||||
await asyncio.wait_for(pending_confirmation, GATT_REQUEST_TIMEOUT)
|
||||
except asyncio.TimeoutError as error:
|
||||
logger.warning(color('!!! GATT Indicate timeout', 'red'))
|
||||
raise TimeoutError(f'GATT timeout for {indication.name}') from error
|
||||
@@ -427,8 +458,12 @@ class Server(EventEmitter):
|
||||
self.pending_confirmations[connection.handle] = None
|
||||
|
||||
async def notify_or_indicate_subscribers(
|
||||
self, indicate, attribute, value=None, force=False
|
||||
):
|
||||
self,
|
||||
indicate: bool,
|
||||
attribute: Attribute,
|
||||
value: Optional[bytes] = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
# Get all the connections for which there's at least one subscription
|
||||
connections = [
|
||||
connection
|
||||
@@ -450,13 +485,23 @@ class Server(EventEmitter):
|
||||
]
|
||||
)
|
||||
|
||||
async def notify_subscribers(self, attribute, value=None, force=False):
|
||||
async def notify_subscribers(
|
||||
self,
|
||||
attribute: Attribute,
|
||||
value: Optional[bytes] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
return await self.notify_or_indicate_subscribers(False, attribute, value, force)
|
||||
|
||||
async def indicate_subscribers(self, attribute, value=None, force=False):
|
||||
async def indicate_subscribers(
|
||||
self,
|
||||
attribute: Attribute,
|
||||
value: Optional[bytes] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
return await self.notify_or_indicate_subscribers(True, attribute, value, force)
|
||||
|
||||
def on_disconnection(self, connection):
|
||||
def on_disconnection(self, connection: Connection) -> None:
|
||||
if connection.handle in self.subscribers:
|
||||
del self.subscribers[connection.handle]
|
||||
if connection.handle in self.indication_semaphores:
|
||||
@@ -464,7 +509,7 @@ class Server(EventEmitter):
|
||||
if connection.handle in self.pending_confirmations:
|
||||
del self.pending_confirmations[connection.handle]
|
||||
|
||||
def on_gatt_pdu(self, connection, att_pdu):
|
||||
def on_gatt_pdu(self, connection: Connection, att_pdu: ATT_PDU) -> None:
|
||||
logger.debug(f'GATT Request to server: [0x{connection.handle:04X}] {att_pdu}')
|
||||
handler_name = f'on_{att_pdu.name.lower()}'
|
||||
handler = getattr(self, handler_name, None)
|
||||
@@ -506,7 +551,7 @@ class Server(EventEmitter):
|
||||
#######################################################
|
||||
# ATT handlers
|
||||
#######################################################
|
||||
def on_att_request(self, connection, pdu):
|
||||
def on_att_request(self, connection: Connection, pdu: ATT_PDU) -> None:
|
||||
'''
|
||||
Handler for requests without a more specific handler
|
||||
'''
|
||||
@@ -679,7 +724,6 @@ class Server(EventEmitter):
|
||||
and attribute.handle <= request.ending_handle
|
||||
and pdu_space_available
|
||||
):
|
||||
|
||||
try:
|
||||
attribute_value = attribute.read_value(connection)
|
||||
except ATT_Error as error:
|
||||
|
||||
933
bumble/hci.py
933
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");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -15,11 +15,35 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import collections.abc
|
||||
import logging
|
||||
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__)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Error
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HfpProtocolError(ProtocolError):
|
||||
def __init__(self, error_name: str = '', details: str = ''):
|
||||
super().__init__(None, 'hfp', error_name, details)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Protocol Support
|
||||
@@ -34,7 +67,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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.buffer = ''
|
||||
self.lines = collections.deque()
|
||||
@@ -42,7 +81,7 @@ class HfpProtocol:
|
||||
|
||||
dlc.sink = self.feed
|
||||
|
||||
def feed(self, data):
|
||||
def feed(self, data: Union[bytes, str]) -> None:
|
||||
# Convert the data to a string if needed
|
||||
if isinstance(data, bytes):
|
||||
data = data.decode('utf-8')
|
||||
@@ -57,19 +96,19 @@ class HfpProtocol:
|
||||
if len(line) > 0:
|
||||
self.on_line(line)
|
||||
|
||||
def on_line(self, line):
|
||||
def on_line(self, line: str) -> None:
|
||||
self.lines.append(line)
|
||||
self.lines_available.set()
|
||||
|
||||
def send_command_line(self, line):
|
||||
def send_command_line(self, line: str) -> None:
|
||||
logger.debug(color(f'>>> {line}', 'yellow'))
|
||||
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'))
|
||||
self.dlc.write('\r\n' + line + '\r\n')
|
||||
|
||||
async def next_line(self):
|
||||
async def next_line(self) -> str:
|
||||
await self.lines_available.wait()
|
||||
line = self.lines.popleft()
|
||||
if not self.lines:
|
||||
@@ -77,19 +116,706 @@ class HfpProtocol:
|
||||
logger.debug(color(f'<<< {line}', 'green'))
|
||||
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())
|
||||
await (self.next_line())
|
||||
# -----------------------------------------------------------------------------
|
||||
# Normative protocol definitions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
self.send_command_line('AT+CIND?')
|
||||
await (self.next_line())
|
||||
await (self.next_line())
|
||||
|
||||
self.send_command_line('AT+CMER=3,0,0,1')
|
||||
await (self.next_line())
|
||||
# HF supported features (AT+BRSF=) (normative).
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
|
||||
109
bumble/host.py
109
bumble/host.py
@@ -15,22 +15,24 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import collections
|
||||
import logging
|
||||
import struct
|
||||
|
||||
from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.l2cap import L2CAP_PDU
|
||||
from bumble.snoop import Snooper
|
||||
|
||||
from typing import Optional
|
||||
from bumble import drivers
|
||||
|
||||
from .hci import (
|
||||
Address,
|
||||
HCI_ACL_DATA_PACKET,
|
||||
HCI_COMMAND_COMPLETE_EVENT,
|
||||
HCI_COMMAND_PACKET,
|
||||
HCI_COMMAND_COMPLETE_EVENT,
|
||||
HCI_EVENT_PACKET,
|
||||
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
||||
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
|
||||
@@ -44,8 +46,11 @@ from .hci import (
|
||||
HCI_VERSION_BLUETOOTH_CORE_4_0,
|
||||
HCI_AclDataPacket,
|
||||
HCI_AclDataPacketAssembler,
|
||||
HCI_Command,
|
||||
HCI_Command_Complete_Event,
|
||||
HCI_Constant,
|
||||
HCI_Error,
|
||||
HCI_Event,
|
||||
HCI_LE_Long_Term_Key_Request_Negative_Reply_Command,
|
||||
HCI_LE_Long_Term_Key_Request_Reply_Command,
|
||||
HCI_LE_Read_Buffer_Size_Command,
|
||||
@@ -62,16 +67,19 @@ from .hci import (
|
||||
HCI_Read_Local_Version_Information_Command,
|
||||
HCI_Reset_Command,
|
||||
HCI_Set_Event_Mask_Command,
|
||||
map_null_terminated_utf8_string,
|
||||
)
|
||||
from .core import (
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
BT_CENTRAL_ROLE,
|
||||
BT_LE_TRANSPORT,
|
||||
ConnectionPHY,
|
||||
ConnectionParameters,
|
||||
InvalidStateError,
|
||||
)
|
||||
from .utils import AbortableEventEmitter
|
||||
from .transport.common import TransportLostError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .transport.common import TransportSink, TransportSource
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -95,27 +103,39 @@ HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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.handle = handle
|
||||
self.peer_address = peer_address
|
||||
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||
self.transport = transport
|
||||
|
||||
def on_hci_acl_data_packet(self, packet):
|
||||
def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
|
||||
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)
|
||||
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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__()
|
||||
|
||||
self.hci_sink = None
|
||||
self.hci_metadata = None
|
||||
self.ready = False # True when we can accept incoming packets
|
||||
self.reset_done = False
|
||||
self.connections = {} # Connections, by connection handle
|
||||
@@ -141,6 +161,9 @@ class Host(AbortableEventEmitter):
|
||||
# Connect to the source and sink if specified
|
||||
if controller_source:
|
||||
controller_source.set_packet_sink(self)
|
||||
self.hci_metadata = getattr(
|
||||
controller_source, 'metadata', self.hci_metadata
|
||||
)
|
||||
if controller_sink:
|
||||
self.set_packet_sink(controller_sink)
|
||||
|
||||
@@ -170,7 +193,7 @@ class Host(AbortableEventEmitter):
|
||||
self.emit('flush')
|
||||
self.command_semaphore.release()
|
||||
|
||||
async def reset(self):
|
||||
async def reset(self, driver_factory=drivers.get_driver_for_host):
|
||||
if self.ready:
|
||||
self.ready = False
|
||||
await self.flush()
|
||||
@@ -178,6 +201,15 @@ class Host(AbortableEventEmitter):
|
||||
await self.send_command(HCI_Reset_Command(), check_result=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(
|
||||
HCI_Read_Local_Supported_Commands_Command(), check_result=True
|
||||
)
|
||||
@@ -282,7 +314,7 @@ class Host(AbortableEventEmitter):
|
||||
self.reset_done = True
|
||||
|
||||
@property
|
||||
def controller(self):
|
||||
def controller(self) -> TransportSink:
|
||||
return self.hci_sink
|
||||
|
||||
@controller.setter
|
||||
@@ -291,14 +323,13 @@ class Host(AbortableEventEmitter):
|
||||
if controller:
|
||||
controller.set_packet_sink(self)
|
||||
|
||||
def set_packet_sink(self, sink):
|
||||
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||
self.hci_sink = sink
|
||||
|
||||
def send_hci_packet(self, packet):
|
||||
def send_hci_packet(self, packet: HCI_Packet) -> None:
|
||||
if self.snooper:
|
||||
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
|
||||
|
||||
self.hci_sink.on_packet(packet.to_bytes())
|
||||
self.hci_sink.on_packet(bytes(packet))
|
||||
|
||||
async def send_command(self, command, check_result=False):
|
||||
logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: {command}')
|
||||
@@ -335,7 +366,7 @@ class Host(AbortableEventEmitter):
|
||||
return response
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f'{color("!!! Exception while sending HCI packet:", "red")} {error}'
|
||||
f'{color("!!! Exception while sending command:", "red")} {error}'
|
||||
)
|
||||
raise error
|
||||
finally:
|
||||
@@ -343,14 +374,14 @@ class Host(AbortableEventEmitter):
|
||||
self.pending_response = None
|
||||
|
||||
# Use this method to send a command from a task
|
||||
def send_command_sync(self, command):
|
||||
async def send_command(command):
|
||||
def send_command_sync(self, command: HCI_Command) -> None:
|
||||
async def send_command(command: HCI_Command) -> None:
|
||||
await self.send_command(command)
|
||||
|
||||
asyncio.create_task(send_command(command))
|
||||
|
||||
def send_l2cap_pdu(self, connection_handle, cid, pdu):
|
||||
l2cap_pdu = L2CAP_PDU(cid, pdu).to_bytes()
|
||||
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
||||
l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
|
||||
|
||||
# Send the data to the controller via ACL packets
|
||||
bytes_remaining = len(l2cap_pdu)
|
||||
@@ -374,7 +405,7 @@ class Host(AbortableEventEmitter):
|
||||
offset += 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.check_acl_packet_queue()
|
||||
|
||||
@@ -384,7 +415,7 @@ class Host(AbortableEventEmitter):
|
||||
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)
|
||||
while (
|
||||
len(self.acl_packet_queue) > 0
|
||||
@@ -430,47 +461,53 @@ class Host(AbortableEventEmitter):
|
||||
]
|
||||
|
||||
# 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)
|
||||
if self.ready or (
|
||||
hci_packet.hci_packet_type == HCI_EVENT_PACKET
|
||||
and hci_packet.event_code == HCI_COMMAND_COMPLETE_EVENT
|
||||
isinstance(hci_packet, HCI_Command_Complete_Event)
|
||||
and hci_packet.command_opcode == HCI_RESET_COMMAND
|
||||
):
|
||||
self.on_hci_packet(hci_packet)
|
||||
else:
|
||||
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}')
|
||||
|
||||
if self.snooper:
|
||||
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
|
||||
|
||||
# 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)
|
||||
elif packet.hci_packet_type == HCI_EVENT_PACKET:
|
||||
elif isinstance(packet, HCI_Event):
|
||||
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)
|
||||
else:
|
||||
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}')
|
||||
|
||||
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 = getattr(self, handler_name, self.on_hci_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
|
||||
if connection := self.connections.get(packet.connection_handle):
|
||||
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)
|
||||
|
||||
def on_command_processed(self, event):
|
||||
@@ -808,6 +845,10 @@ class Host(AbortableEventEmitter):
|
||||
f'simple pairing complete for {event.bd_addr}: '
|
||||
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):
|
||||
self.emit('pin_code_request', event.bd_addr)
|
||||
|
||||
118
bumble/keys.py
118
bumble/keys.py
@@ -190,10 +190,44 @@ class 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_AUTHOR = 'Google'
|
||||
KEYS_DIR = 'Pairing'
|
||||
DEFAULT_NAMESPACE = '__DEFAULT__'
|
||||
DEFAULT_BASE_NAME = "keys"
|
||||
|
||||
def __init__(self, namespace, filename=None):
|
||||
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(
|
||||
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 = (
|
||||
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)
|
||||
else:
|
||||
@@ -219,11 +254,13 @@ class JsonKeyStore(KeyStore):
|
||||
logger.debug(f'JSON keystore: {self.filename}')
|
||||
|
||||
@staticmethod
|
||||
def from_device(device: Device) -> Optional[JsonKeyStore]:
|
||||
if not device.config.keystore:
|
||||
return None
|
||||
|
||||
params = device.config.keystore.split(':', 1)[1:]
|
||||
def from_device(device: Device, filename=None) -> Optional[JsonKeyStore]:
|
||||
if not filename:
|
||||
# Extract the filename from the config if there is one
|
||||
if device.config.keystore is not None:
|
||||
params = device.config.keystore.split(':', 1)[1:]
|
||||
if params:
|
||||
filename = params[0]
|
||||
|
||||
# Use a namespace based on the device address
|
||||
if device.public_address not in (Address.ANY, Address.ANY_RANDOM):
|
||||
@@ -232,19 +269,31 @@ class JsonKeyStore(KeyStore):
|
||||
namespace = str(device.random_address)
|
||||
else:
|
||||
namespace = JsonKeyStore.DEFAULT_NAMESPACE
|
||||
if params:
|
||||
filename = params[0]
|
||||
else:
|
||||
filename = None
|
||||
|
||||
return JsonKeyStore(namespace, filename)
|
||||
|
||||
async def load(self):
|
||||
# Try to open the file, without failing. If the file does not exist, it
|
||||
# will be created upon saving.
|
||||
try:
|
||||
with open(self.filename, 'r', encoding='utf-8') as json_file:
|
||||
return json.load(json_file)
|
||||
db = json.load(json_file)
|
||||
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):
|
||||
# Create the directory if it doesn't exist
|
||||
@@ -260,53 +309,30 @@ class JsonKeyStore(KeyStore):
|
||||
os.replace(temp_filename, self.filename)
|
||||
|
||||
async def delete(self, name: str) -> None:
|
||||
db = await self.load()
|
||||
|
||||
namespace = db.get(self.namespace)
|
||||
if namespace is None:
|
||||
raise KeyError(name)
|
||||
|
||||
del namespace[name]
|
||||
db, key_map = await self.load()
|
||||
del key_map[name]
|
||||
await self.save(db)
|
||||
|
||||
async def update(self, name, keys):
|
||||
db = await self.load()
|
||||
|
||||
namespace = db.setdefault(self.namespace, {})
|
||||
namespace.setdefault(name, {}).update(keys.to_dict())
|
||||
|
||||
db, key_map = await self.load()
|
||||
key_map.setdefault(name, {}).update(keys.to_dict())
|
||||
await self.save(db)
|
||||
|
||||
async def get_all(self):
|
||||
db = await self.load()
|
||||
|
||||
namespace = db.get(self.namespace)
|
||||
if namespace is None:
|
||||
return []
|
||||
|
||||
return [
|
||||
(name, PairingKeys.from_dict(keys)) for (name, keys) in namespace.items()
|
||||
]
|
||||
_, key_map = await self.load()
|
||||
return [(name, PairingKeys.from_dict(keys)) for (name, keys) in key_map.items()]
|
||||
|
||||
async def delete_all(self):
|
||||
db = await self.load()
|
||||
|
||||
db.pop(self.namespace, None)
|
||||
|
||||
db, key_map = await self.load()
|
||||
key_map.clear()
|
||||
await self.save(db)
|
||||
|
||||
async def get(self, name: str) -> Optional[PairingKeys]:
|
||||
db = await self.load()
|
||||
|
||||
namespace = db.get(self.namespace)
|
||||
if namespace is None:
|
||||
_, key_map = await self.load()
|
||||
if name not in key_map:
|
||||
return None
|
||||
|
||||
keys = namespace.get(name)
|
||||
if keys is None:
|
||||
return None
|
||||
|
||||
return PairingKeys.from_dict(keys)
|
||||
return PairingKeys.from_dict(key_map[name])
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
586
bumble/l2cap.py
586
bumble/l2cap.py
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ import enum
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .hci import (
|
||||
Address,
|
||||
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
||||
HCI_DISPLAY_ONLY_IO_CAPABILITY,
|
||||
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
|
||||
@@ -168,21 +169,28 @@ class PairingDelegate:
|
||||
class PairingConfig:
|
||||
"""Configuration for the Pairing protocol."""
|
||||
|
||||
class AddressType(enum.IntEnum):
|
||||
PUBLIC = Address.PUBLIC_DEVICE_ADDRESS
|
||||
RANDOM = Address.RANDOM_DEVICE_ADDRESS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sc: bool = True,
|
||||
mitm: bool = True,
|
||||
bonding: bool = True,
|
||||
delegate: Optional[PairingDelegate] = None,
|
||||
identity_address_type: Optional[AddressType] = None,
|
||||
) -> None:
|
||||
self.sc = sc
|
||||
self.mitm = mitm
|
||||
self.bonding = bonding
|
||||
self.delegate = delegate or PairingDelegate()
|
||||
self.identity_address_type = identity_address_type
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'PairingConfig(sc={self.sc}, '
|
||||
f'mitm={self.mitm}, bonding={self.bonding}, '
|
||||
f'identity_address_type={self.identity_address_type}, '
|
||||
f'delegate[{self.delegate.io_capability}])'
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from bumble.pairing import PairingDelegate
|
||||
from bumble.pairing import PairingConfig, PairingDelegate
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import Any, Dict
|
||||
@dataclass
|
||||
class Config:
|
||||
io_capability: PairingDelegate.IoCapability = PairingDelegate.NO_OUTPUT_NO_INPUT
|
||||
identity_address_type: PairingConfig.AddressType = PairingConfig.AddressType.RANDOM
|
||||
pairing_sc_enable: bool = True
|
||||
pairing_mitm_enable: bool = True
|
||||
pairing_bonding_enable: bool = True
|
||||
@@ -35,6 +36,12 @@ class Config:
|
||||
'io_capability', 'no_output_no_input'
|
||||
).upper()
|
||||
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_mitm_enable = config.get('pairing_mitm_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
|
||||
|
||||
|
||||
# Default rootcanal HCI TCP address
|
||||
ROOTCANAL_HCI_ADDRESS = "localhost:6402"
|
||||
|
||||
|
||||
class PandoraDevice:
|
||||
"""
|
||||
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:
|
||||
self.config = 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
|
||||
|
||||
@property
|
||||
|
||||
@@ -43,7 +43,8 @@ from bumble.hci import (
|
||||
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||
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_pb2 import (
|
||||
NOT_CONNECTABLE,
|
||||
@@ -111,7 +112,7 @@ class HostService(HostServicer):
|
||||
async def FactoryReset(
|
||||
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
||||
) -> empty_pb2.Empty:
|
||||
self.log.info('FactoryReset')
|
||||
self.log.debug('FactoryReset')
|
||||
|
||||
# delete all bonds
|
||||
if self.device.keystore is not None:
|
||||
@@ -125,7 +126,7 @@ class HostService(HostServicer):
|
||||
async def Reset(
|
||||
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
||||
) -> empty_pb2.Empty:
|
||||
self.log.info('Reset')
|
||||
self.log.debug('Reset')
|
||||
|
||||
# clear service.
|
||||
self.waited_connections.clear()
|
||||
@@ -138,7 +139,7 @@ class HostService(HostServicer):
|
||||
async def ReadLocalAddress(
|
||||
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
||||
) -> ReadLocalAddressResponse:
|
||||
self.log.info('ReadLocalAddress')
|
||||
self.log.debug('ReadLocalAddress')
|
||||
return ReadLocalAddressResponse(
|
||||
address=bytes(reversed(bytes(self.device.public_address)))
|
||||
)
|
||||
@@ -151,7 +152,7 @@ class HostService(HostServicer):
|
||||
address = 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:
|
||||
connection = await self.device.connect(
|
||||
@@ -166,7 +167,7 @@ class HostService(HostServicer):
|
||||
return ConnectResponse(connection_already_exists=empty_pb2.Empty())
|
||||
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'))
|
||||
return ConnectResponse(connection=Connection(cookie=cookie))
|
||||
@@ -185,7 +186,7 @@ class HostService(HostServicer):
|
||||
if address in (Address.NIL, Address.ANY):
|
||||
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(
|
||||
address, transport=BT_BR_EDR_TRANSPORT
|
||||
@@ -200,7 +201,7 @@ class HostService(HostServicer):
|
||||
# save connection has waited and respond.
|
||||
self.waited_connections.add(id(connection))
|
||||
|
||||
self.log.info(
|
||||
self.log.debug(
|
||||
f"WaitConnection from {address} done (handle={connection.handle})"
|
||||
)
|
||||
|
||||
@@ -215,7 +216,7 @@ class HostService(HostServicer):
|
||||
if address in (Address.NIL, Address.ANY):
|
||||
raise ValueError('Invalid address')
|
||||
|
||||
self.log.info(f"ConnectLE to {address}...")
|
||||
self.log.debug(f"ConnectLE to {address}...")
|
||||
|
||||
try:
|
||||
connection = await self.device.connect(
|
||||
@@ -232,7 +233,7 @@ class HostService(HostServicer):
|
||||
return ConnectLEResponse(connection_already_exists=empty_pb2.Empty())
|
||||
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'))
|
||||
return ConnectLEResponse(connection=Connection(cookie=cookie))
|
||||
@@ -242,12 +243,12 @@ class HostService(HostServicer):
|
||||
self, request: DisconnectRequest, context: grpc.ServicerContext
|
||||
) -> empty_pb2.Empty:
|
||||
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):
|
||||
await connection.disconnect(HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR)
|
||||
self.log.info("Disconnected")
|
||||
self.log.debug("Disconnected")
|
||||
|
||||
return empty_pb2.Empty()
|
||||
|
||||
@@ -256,7 +257,7 @@ class HostService(HostServicer):
|
||||
self, request: WaitDisconnectionRequest, context: grpc.ServicerContext
|
||||
) -> empty_pb2.Empty:
|
||||
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):
|
||||
disconnection_future: asyncio.Future[
|
||||
@@ -269,7 +270,7 @@ class HostService(HostServicer):
|
||||
connection.on('disconnection', on_disconnection)
|
||||
try:
|
||||
await disconnection_future
|
||||
self.log.info("Disconnected")
|
||||
self.log.debug("Disconnected")
|
||||
finally:
|
||||
connection.remove_listener('disconnection', on_disconnection) # type: ignore
|
||||
|
||||
@@ -377,7 +378,7 @@ class HostService(HostServicer):
|
||||
try:
|
||||
while True:
|
||||
if not self.device.is_advertising:
|
||||
self.log.info('Advertise')
|
||||
self.log.debug('Advertise')
|
||||
await self.device.start_advertising(
|
||||
target=target,
|
||||
advertising_type=advertising_type,
|
||||
@@ -392,10 +393,10 @@ class HostService(HostServicer):
|
||||
bumble.device.Connection
|
||||
] = asyncio.get_running_loop().create_future()
|
||||
|
||||
self.log.info('Wait for LE connection...')
|
||||
self.log.debug('Wait for LE connection...')
|
||||
connection = await pending_connection
|
||||
|
||||
self.log.info(
|
||||
self.log.debug(
|
||||
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
|
||||
|
||||
try:
|
||||
self.log.info('Stop advertising')
|
||||
self.log.debug('Stop advertising')
|
||||
await self.device.abort_on('flush', self.device.stop_advertising())
|
||||
except:
|
||||
pass
|
||||
@@ -422,7 +423,7 @@ class HostService(HostServicer):
|
||||
if 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()
|
||||
handler = self.device.on('advertisement', scan_queue.put_nowait)
|
||||
@@ -469,7 +470,7 @@ class HostService(HostServicer):
|
||||
finally:
|
||||
self.device.remove_listener('advertisement', handler) # type: ignore
|
||||
try:
|
||||
self.log.info('Stop scanning')
|
||||
self.log.debug('Stop scanning')
|
||||
await self.device.abort_on('flush', self.device.stop_scanning())
|
||||
except:
|
||||
pass
|
||||
@@ -478,7 +479,7 @@ class HostService(HostServicer):
|
||||
async def Inquiry(
|
||||
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
||||
) -> AsyncGenerator[InquiryResponse, None]:
|
||||
self.log.info('Inquiry')
|
||||
self.log.debug('Inquiry')
|
||||
|
||||
inquiry_queue: asyncio.Queue[
|
||||
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_result', result_handler) # type: ignore
|
||||
try:
|
||||
self.log.info('Stop inquiry')
|
||||
self.log.debug('Stop inquiry')
|
||||
await self.device.abort_on('flush', self.device.stop_discovery())
|
||||
except:
|
||||
pass
|
||||
@@ -518,7 +519,7 @@ class HostService(HostServicer):
|
||||
async def SetDiscoverabilityMode(
|
||||
self, request: SetDiscoverabilityModeRequest, context: grpc.ServicerContext
|
||||
) -> empty_pb2.Empty:
|
||||
self.log.info("SetDiscoverabilityMode")
|
||||
self.log.debug("SetDiscoverabilityMode")
|
||||
await self.device.set_discoverable(request.mode != NOT_DISCOVERABLE)
|
||||
return empty_pb2.Empty()
|
||||
|
||||
@@ -526,7 +527,7 @@ class HostService(HostServicer):
|
||||
async def SetConnectabilityMode(
|
||||
self, request: SetConnectabilityModeRequest, context: grpc.ServicerContext
|
||||
) -> empty_pb2.Empty:
|
||||
self.log.info("SetConnectabilityMode")
|
||||
self.log.debug("SetConnectabilityMode")
|
||||
await self.device.set_connectable(request.mode != NOT_CONNECTABLE)
|
||||
return empty_pb2.Empty()
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
# limitations under the License.
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import grpc
|
||||
import logging
|
||||
|
||||
@@ -27,14 +28,11 @@ from bumble.core import (
|
||||
)
|
||||
from bumble.device import Connection as BumbleConnection, Device
|
||||
from bumble.hci import HCI_Error
|
||||
from bumble.utils import EventWatcher
|
||||
from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate
|
||||
from contextlib import suppress
|
||||
from google.protobuf import (
|
||||
any_pb2,
|
||||
empty_pb2,
|
||||
wrappers_pb2,
|
||||
) # pytype: disable=pyi-error
|
||||
from google.protobuf.wrappers_pb2 import BoolValue # pytype: disable=pyi-error
|
||||
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
||||
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
||||
from google.protobuf import wrappers_pb2 # pytype: disable=pyi-error
|
||||
from pandora.host_pb2 import Connection
|
||||
from pandora.security_grpc_aio import SecurityServicer, SecurityStorageServicer
|
||||
from pandora.security_pb2 import (
|
||||
@@ -102,7 +100,7 @@ class PairingDelegate(BasePairingDelegate):
|
||||
return ev
|
||||
|
||||
async def confirm(self, auto: bool = False) -> bool:
|
||||
self.log.info(
|
||||
self.log.debug(
|
||||
f"Pairing event: `just_works` (io_capability: {self.io_capability})"
|
||||
)
|
||||
|
||||
@@ -117,7 +115,7 @@ class PairingDelegate(BasePairingDelegate):
|
||||
return answer.confirm
|
||||
|
||||
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})"
|
||||
)
|
||||
|
||||
@@ -132,7 +130,7 @@ class PairingDelegate(BasePairingDelegate):
|
||||
return answer.confirm
|
||||
|
||||
async def get_number(self) -> Optional[int]:
|
||||
self.log.info(
|
||||
self.log.debug(
|
||||
f"Pairing event: `passkey_entry_request` (io_capability: {self.io_capability})"
|
||||
)
|
||||
|
||||
@@ -149,7 +147,7 @@ class PairingDelegate(BasePairingDelegate):
|
||||
return answer.passkey
|
||||
|
||||
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})"
|
||||
)
|
||||
|
||||
@@ -180,7 +178,7 @@ class PairingDelegate(BasePairingDelegate):
|
||||
):
|
||||
return
|
||||
|
||||
self.log.info(
|
||||
self.log.debug(
|
||||
f"Pairing event: `passkey_entry_notification` (io_capability: {self.io_capability})"
|
||||
)
|
||||
|
||||
@@ -235,6 +233,11 @@ class SecurityService(SecurityServicer):
|
||||
sc=config.pairing_sc_enable,
|
||||
mitm=config.pairing_mitm_enable,
|
||||
bonding=config.pairing_bonding_enable,
|
||||
identity_address_type=(
|
||||
PairingConfig.AddressType.PUBLIC
|
||||
if connection.self_address.is_public
|
||||
else config.identity_address_type
|
||||
),
|
||||
delegate=PairingDelegate(
|
||||
connection,
|
||||
self,
|
||||
@@ -250,7 +253,7 @@ class SecurityService(SecurityServicer):
|
||||
async def OnPairing(
|
||||
self, request: AsyncIterator[PairingEventAnswer], context: grpc.ServicerContext
|
||||
) -> AsyncGenerator[PairingEvent, None]:
|
||||
self.log.info('OnPairing')
|
||||
self.log.debug('OnPairing')
|
||||
|
||||
if self.event_queue is not None:
|
||||
raise RuntimeError('already streaming pairing events')
|
||||
@@ -276,7 +279,7 @@ class SecurityService(SecurityServicer):
|
||||
self, request: SecureRequest, context: grpc.ServicerContext
|
||||
) -> SecureResponse:
|
||||
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)
|
||||
assert connection
|
||||
@@ -294,25 +297,37 @@ class SecurityService(SecurityServicer):
|
||||
# trigger pairing if needed
|
||||
if self.need_pairing(connection, level):
|
||||
try:
|
||||
self.log.info('Pair...')
|
||||
self.log.debug('Pair...')
|
||||
|
||||
if (
|
||||
connection.transport == BT_LE_TRANSPORT
|
||||
and connection.role == BT_PERIPHERAL_ROLE
|
||||
):
|
||||
wait_for_security: asyncio.Future[
|
||||
bool
|
||||
] = asyncio.get_running_loop().create_future()
|
||||
connection.on("pairing", lambda *_: wait_for_security.set_result(True)) # type: ignore
|
||||
connection.on("pairing_failure", wait_for_security.set_exception)
|
||||
security_result = asyncio.get_running_loop().create_future()
|
||||
|
||||
connection.request_pairing()
|
||||
with contextlib.closing(EventWatcher()) as watcher:
|
||||
|
||||
await wait_for_security
|
||||
else:
|
||||
await connection.pair()
|
||||
@watcher.on(connection, 'pairing')
|
||||
def on_pairing(*_: Any) -> None:
|
||||
security_result.set_result('success')
|
||||
|
||||
self.log.info('Paired')
|
||||
@watcher.on(connection, 'pairing_failure')
|
||||
def on_pairing_failure(*_: Any) -> None:
|
||||
security_result.set_result('pairing_failure')
|
||||
|
||||
@watcher.on(connection, 'disconnection')
|
||||
def on_disconnection(*_: Any) -> None:
|
||||
security_result.set_result('connection_died')
|
||||
|
||||
if (
|
||||
connection.transport == BT_LE_TRANSPORT
|
||||
and connection.role == BT_PERIPHERAL_ROLE
|
||||
):
|
||||
connection.request_pairing()
|
||||
else:
|
||||
await connection.pair()
|
||||
|
||||
result = await security_result
|
||||
|
||||
self.log.debug(f'Pairing session complete, status={result}')
|
||||
if result != 'success':
|
||||
return SecureResponse(**{result: empty_pb2.Empty()})
|
||||
except asyncio.CancelledError:
|
||||
self.log.warning("Connection died during encryption")
|
||||
return SecureResponse(connection_died=empty_pb2.Empty())
|
||||
@@ -323,9 +338,9 @@ class SecurityService(SecurityServicer):
|
||||
# trigger authentication if needed
|
||||
if self.need_authentication(connection, level):
|
||||
try:
|
||||
self.log.info('Authenticate...')
|
||||
self.log.debug('Authenticate...')
|
||||
await connection.authenticate()
|
||||
self.log.info('Authenticated')
|
||||
self.log.debug('Authenticated')
|
||||
except asyncio.CancelledError:
|
||||
self.log.warning("Connection died during authentication")
|
||||
return SecureResponse(connection_died=empty_pb2.Empty())
|
||||
@@ -336,9 +351,9 @@ class SecurityService(SecurityServicer):
|
||||
# trigger encryption if needed
|
||||
if self.need_encryption(connection, level):
|
||||
try:
|
||||
self.log.info('Encrypt...')
|
||||
self.log.debug('Encrypt...')
|
||||
await connection.encrypt()
|
||||
self.log.info('Encrypted')
|
||||
self.log.debug('Encrypted')
|
||||
except asyncio.CancelledError:
|
||||
self.log.warning("Connection died during encryption")
|
||||
return SecureResponse(connection_died=empty_pb2.Empty())
|
||||
@@ -356,7 +371,7 @@ class SecurityService(SecurityServicer):
|
||||
self, request: WaitSecurityRequest, context: grpc.ServicerContext
|
||||
) -> WaitSecurityResponse:
|
||||
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)
|
||||
assert connection
|
||||
@@ -371,6 +386,7 @@ class SecurityService(SecurityServicer):
|
||||
str
|
||||
] = asyncio.get_running_loop().create_future()
|
||||
authenticate_task: Optional[asyncio.Future[None]] = None
|
||||
pair_task: Optional[asyncio.Future[None]] = None
|
||||
|
||||
async def authenticate() -> None:
|
||||
assert connection
|
||||
@@ -393,7 +409,7 @@ class SecurityService(SecurityServicer):
|
||||
|
||||
def set_failure(name: str) -> Callable[..., 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)
|
||||
|
||||
return wrapper
|
||||
@@ -401,13 +417,13 @@ class SecurityService(SecurityServicer):
|
||||
def try_set_success(*_: Any) -> None:
|
||||
assert connection
|
||||
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')
|
||||
|
||||
def on_encryption_change(*_: Any) -> None:
|
||||
assert connection
|
||||
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')
|
||||
elif (
|
||||
connection.transport == BT_BR_EDR_TRANSPORT
|
||||
@@ -417,6 +433,10 @@ class SecurityService(SecurityServicer):
|
||||
if authenticate_task is None:
|
||||
authenticate_task = asyncio.create_task(authenticate())
|
||||
|
||||
def pair(*_: Any) -> None:
|
||||
if self.need_pairing(connection, level):
|
||||
pair_task = asyncio.create_task(connection.pair())
|
||||
|
||||
listeners: Dict[str, Callable[..., None]] = {
|
||||
'disconnection': set_failure('connection_died'),
|
||||
'pairing_failure': set_failure('pairing_failure'),
|
||||
@@ -425,6 +445,9 @@ class SecurityService(SecurityServicer):
|
||||
'pairing': try_set_success,
|
||||
'connection_authentication': try_set_success,
|
||||
'connection_encryption_change': on_encryption_change,
|
||||
'classic_pairing': try_set_success,
|
||||
'classic_pairing_failure': set_failure('pairing_failure'),
|
||||
'security_request': pair,
|
||||
}
|
||||
|
||||
# register event handlers
|
||||
@@ -435,7 +458,7 @@ class SecurityService(SecurityServicer):
|
||||
if self.reached_security_level(connection, level):
|
||||
return WaitSecurityResponse(success=empty_pb2.Empty())
|
||||
|
||||
self.log.info('Wait for security...')
|
||||
self.log.debug('Wait for security...')
|
||||
kwargs = {}
|
||||
kwargs[await wait_for_security] = empty_pb2.Empty()
|
||||
|
||||
@@ -445,12 +468,21 @@ class SecurityService(SecurityServicer):
|
||||
|
||||
# wait for `authenticate` to finish if any
|
||||
if authenticate_task is not None:
|
||||
self.log.info('Wait for authentication...')
|
||||
self.log.debug('Wait for authentication...')
|
||||
try:
|
||||
await authenticate_task # type: ignore
|
||||
except:
|
||||
pass
|
||||
self.log.info('Authenticated')
|
||||
self.log.debug('Authenticated')
|
||||
|
||||
# wait for `pair` to finish if any
|
||||
if pair_task is not None:
|
||||
self.log.debug('Wait for authentication...')
|
||||
try:
|
||||
await pair_task # type: ignore
|
||||
except:
|
||||
pass
|
||||
self.log.debug('paired')
|
||||
|
||||
return WaitSecurityResponse(**kwargs)
|
||||
|
||||
@@ -506,24 +538,24 @@ class SecurityStorageService(SecurityStorageServicer):
|
||||
self, request: IsBondedRequest, context: grpc.ServicerContext
|
||||
) -> wrappers_pb2.BoolValue:
|
||||
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:
|
||||
is_bonded = await self.device.keystore.get(str(address)) is not None
|
||||
else:
|
||||
is_bonded = False
|
||||
|
||||
return BoolValue(value=is_bonded)
|
||||
return wrappers_pb2.BoolValue(value=is_bonded)
|
||||
|
||||
@utils.rpc
|
||||
async def DeleteBond(
|
||||
self, request: DeleteBondRequest, context: grpc.ServicerContext
|
||||
) -> empty_pb2.Empty:
|
||||
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:
|
||||
with suppress(KeyError):
|
||||
with contextlib.suppress(KeyError):
|
||||
await self.device.keystore.delete(str(address))
|
||||
|
||||
return empty_pb2.Empty()
|
||||
|
||||
410
bumble/rfcomm.py
410
bumble/rfcomm.py
@@ -15,14 +15,37 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import enum
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
||||
|
||||
from pyee import EventEmitter
|
||||
|
||||
from . import core
|
||||
from . import core, l2cap
|
||||
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
|
||||
@@ -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
|
||||
for byte in buffer:
|
||||
result = CRC_TABLE[result ^ byte]
|
||||
@@ -114,7 +181,15 @@ def compute_fcs(buffer):
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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.c_r = c_r
|
||||
self.dlci = dlci
|
||||
@@ -136,13 +211,13 @@ class RFCOMM_Frame:
|
||||
else:
|
||||
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]
|
||||
|
||||
@staticmethod
|
||||
def parse_mcc(data):
|
||||
def parse_mcc(data) -> Tuple[int, bool, bytes]:
|
||||
mcc_type = data[0] >> 2
|
||||
c_r = (data[0] >> 1) & 1
|
||||
c_r = bool((data[0] >> 1) & 1)
|
||||
length = data[1]
|
||||
if data[1] & 1:
|
||||
length >>= 1
|
||||
@@ -154,36 +229,36 @@ class RFCOMM_Frame:
|
||||
return (mcc_type, c_r, value)
|
||||
|
||||
@staticmethod
|
||||
def make_mcc(mcc_type, c_r, data):
|
||||
def make_mcc(mcc_type: int, c_r: int, data: bytes) -> bytes:
|
||||
return (
|
||||
bytes([(mcc_type << 2 | c_r << 1 | 1) & 0xFF, (len(data) & 0x7F) << 1 | 1])
|
||||
+ data
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def sabm(c_r, dlci):
|
||||
def sabm(c_r: int, dlci: int):
|
||||
return RFCOMM_Frame(RFCOMM_SABM_FRAME, c_r, dlci, 1)
|
||||
|
||||
@staticmethod
|
||||
def ua(c_r, dlci):
|
||||
def ua(c_r: int, dlci: int):
|
||||
return RFCOMM_Frame(RFCOMM_UA_FRAME, c_r, dlci, 1)
|
||||
|
||||
@staticmethod
|
||||
def dm(c_r, dlci):
|
||||
def dm(c_r: int, dlci: int):
|
||||
return RFCOMM_Frame(RFCOMM_DM_FRAME, c_r, dlci, 1)
|
||||
|
||||
@staticmethod
|
||||
def disc(c_r, dlci):
|
||||
def disc(c_r: int, dlci: int):
|
||||
return RFCOMM_Frame(RFCOMM_DISC_FRAME, c_r, dlci, 1)
|
||||
|
||||
@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(
|
||||
RFCOMM_UIH_FRAME, c_r, dlci, p_f, information, with_credits=(p_f == 1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(data):
|
||||
def from_bytes(data: bytes) -> RFCOMM_Frame:
|
||||
# Extract fields
|
||||
dlci = (data[0] >> 2) & 0x3F
|
||||
c_r = (data[0] >> 1) & 0x01
|
||||
@@ -206,7 +281,7 @@ class RFCOMM_Frame:
|
||||
|
||||
return frame
|
||||
|
||||
def __bytes__(self):
|
||||
def __bytes__(self) -> bytes:
|
||||
return (
|
||||
bytes([self.address, self.control])
|
||||
+ self.length
|
||||
@@ -214,7 +289,7 @@ class RFCOMM_Frame:
|
||||
+ bytes([self.fcs])
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'{color(self.type_name(), "yellow")}'
|
||||
f'(c/r={self.c_r},'
|
||||
@@ -227,16 +302,24 @@ class RFCOMM_Frame:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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__(
|
||||
self,
|
||||
dlci,
|
||||
cl,
|
||||
priority,
|
||||
ack_timer,
|
||||
max_frame_size,
|
||||
max_retransmissions,
|
||||
window_size,
|
||||
):
|
||||
dlci: int,
|
||||
cl: int,
|
||||
priority: int,
|
||||
ack_timer: int,
|
||||
max_frame_size: int,
|
||||
max_retransmissions: int,
|
||||
window_size: int,
|
||||
) -> None:
|
||||
self.dlci = dlci
|
||||
self.cl = cl
|
||||
self.priority = priority
|
||||
@@ -246,7 +329,7 @@ class RFCOMM_MCC_PN:
|
||||
self.window_size = window_size
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(data):
|
||||
def from_bytes(data: bytes) -> RFCOMM_MCC_PN:
|
||||
return RFCOMM_MCC_PN(
|
||||
dlci=data[0],
|
||||
cl=data[1],
|
||||
@@ -257,7 +340,7 @@ class RFCOMM_MCC_PN:
|
||||
window_size=data[7],
|
||||
)
|
||||
|
||||
def __bytes__(self):
|
||||
def __bytes__(self) -> bytes:
|
||||
return bytes(
|
||||
[
|
||||
self.dlci & 0xFF,
|
||||
@@ -271,7 +354,7 @@ class RFCOMM_MCC_PN:
|
||||
]
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'PN(dlci={self.dlci},'
|
||||
f'cl={self.cl},'
|
||||
@@ -285,7 +368,16 @@ class RFCOMM_MCC_PN:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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.fc = fc
|
||||
self.rtc = rtc
|
||||
@@ -294,7 +386,7 @@ class RFCOMM_MCC_MSC:
|
||||
self.dv = dv
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(data):
|
||||
def from_bytes(data: bytes) -> RFCOMM_MCC_MSC:
|
||||
return RFCOMM_MCC_MSC(
|
||||
dlci=data[0] >> 2,
|
||||
fc=data[1] >> 1 & 1,
|
||||
@@ -304,7 +396,7 @@ class RFCOMM_MCC_MSC:
|
||||
dv=data[1] >> 7 & 1,
|
||||
)
|
||||
|
||||
def __bytes__(self):
|
||||
def __bytes__(self) -> bytes:
|
||||
return bytes(
|
||||
[
|
||||
(self.dlci << 2) | 3,
|
||||
@@ -317,7 +409,7 @@ class RFCOMM_MCC_MSC:
|
||||
]
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'MSC(dlci={self.dlci},'
|
||||
f'fc={self.fc},'
|
||||
@@ -330,24 +422,24 @@ class RFCOMM_MCC_MSC:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class DLC(EventEmitter):
|
||||
# States
|
||||
INIT = 0x00
|
||||
CONNECTING = 0x01
|
||||
CONNECTED = 0x02
|
||||
DISCONNECTING = 0x03
|
||||
DISCONNECTED = 0x04
|
||||
RESET = 0x05
|
||||
class State(enum.IntEnum):
|
||||
INIT = 0x00
|
||||
CONNECTING = 0x01
|
||||
CONNECTED = 0x02
|
||||
DISCONNECTING = 0x03
|
||||
DISCONNECTED = 0x04
|
||||
RESET = 0x05
|
||||
|
||||
STATE_NAMES = {
|
||||
INIT: 'INIT',
|
||||
CONNECTING: 'CONNECTING',
|
||||
CONNECTED: 'CONNECTED',
|
||||
DISCONNECTING: 'DISCONNECTING',
|
||||
DISCONNECTED: 'DISCONNECTED',
|
||||
RESET: 'RESET',
|
||||
}
|
||||
connection_result: Optional[asyncio.Future]
|
||||
sink: Optional[Callable[[bytes], None]]
|
||||
|
||||
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__()
|
||||
self.multiplexer = multiplexer
|
||||
self.dlci = dlci
|
||||
@@ -355,9 +447,9 @@ class DLC(EventEmitter):
|
||||
self.rx_threshold = self.rx_credits // 2
|
||||
self.tx_credits = initial_tx_credits
|
||||
self.tx_buffer = b''
|
||||
self.state = DLC.INIT
|
||||
self.state = DLC.State.INIT
|
||||
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.connection_result = None
|
||||
|
||||
@@ -367,25 +459,19 @@ class DLC(EventEmitter):
|
||||
max_frame_size, self.multiplexer.l2cap_channel.mtu - max_overhead
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def state_name(state):
|
||||
return DLC.STATE_NAMES[state]
|
||||
|
||||
def change_state(self, new_state):
|
||||
logger.debug(
|
||||
f'{self} state change -> {color(self.state_name(new_state), "magenta")}'
|
||||
)
|
||||
def change_state(self, new_state: State) -> None:
|
||||
logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}')
|
||||
self.state = new_state
|
||||
|
||||
def send_frame(self, frame):
|
||||
def send_frame(self, frame: RFCOMM_Frame) -> None:
|
||||
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(frame)
|
||||
|
||||
def on_sabm_frame(self, _frame):
|
||||
if self.state != DLC.CONNECTING:
|
||||
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
if self.state != DLC.State.CONNECTING:
|
||||
logger.warning(
|
||||
color('!!! received SABM when not in CONNECTING state', 'red')
|
||||
)
|
||||
@@ -401,11 +487,11 @@ class DLC(EventEmitter):
|
||||
logger.debug(f'>>> MCC MSC Command: {msc}')
|
||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||
|
||||
self.change_state(DLC.CONNECTED)
|
||||
self.change_state(DLC.State.CONNECTED)
|
||||
self.emit('open')
|
||||
|
||||
def on_ua_frame(self, _frame):
|
||||
if self.state != DLC.CONNECTING:
|
||||
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
if self.state != DLC.State.CONNECTING:
|
||||
logger.warning(
|
||||
color('!!! received SABM when not in CONNECTING state', 'red')
|
||||
)
|
||||
@@ -419,18 +505,18 @@ class DLC(EventEmitter):
|
||||
logger.debug(f'>>> MCC MSC Command: {msc}')
|
||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||
|
||||
self.change_state(DLC.CONNECTED)
|
||||
self.change_state(DLC.State.CONNECTED)
|
||||
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
|
||||
pass
|
||||
|
||||
def on_disc_frame(self, _frame):
|
||||
def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
# TODO: handle all states
|
||||
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
|
||||
if frame.p_f == 1:
|
||||
# With credits
|
||||
@@ -460,10 +546,10 @@ class DLC(EventEmitter):
|
||||
# Check if there's anything to send (including credits)
|
||||
self.process_tx()
|
||||
|
||||
def on_ui_frame(self, frame):
|
||||
def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
|
||||
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:
|
||||
# Command
|
||||
logger.debug(f'<<< MCC MSC Command: {msc}')
|
||||
@@ -477,16 +563,16 @@ class DLC(EventEmitter):
|
||||
# Response
|
||||
logger.debug(f'<<< MCC MSC Response: {msc}')
|
||||
|
||||
def connect(self):
|
||||
if self.state != DLC.INIT:
|
||||
def connect(self) -> None:
|
||||
if self.state != DLC.State.INIT:
|
||||
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.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci))
|
||||
|
||||
def accept(self):
|
||||
if self.state != DLC.INIT:
|
||||
def accept(self) -> None:
|
||||
if self.state != DLC.State.INIT:
|
||||
raise InvalidStateError('invalid state')
|
||||
|
||||
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))
|
||||
logger.debug(f'>>> PN Response: {pn}')
|
||||
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:
|
||||
return RFCOMM_DEFAULT_INITIAL_RX_CREDITS - self.rx_credits
|
||||
|
||||
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)
|
||||
rx_credits_needed = self.rx_credits_needed()
|
||||
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
|
||||
|
||||
# Stream protocol
|
||||
def write(self, data):
|
||||
def write(self, data: Union[bytes, str]) -> None:
|
||||
# We can only send bytes
|
||||
if not isinstance(data, bytes):
|
||||
if isinstance(data, str):
|
||||
@@ -559,44 +645,40 @@ class DLC(EventEmitter):
|
||||
self.tx_buffer += data
|
||||
self.process_tx()
|
||||
|
||||
def drain(self):
|
||||
def drain(self) -> None:
|
||||
# TODO
|
||||
pass
|
||||
|
||||
def __str__(self):
|
||||
return f'DLC(dlci={self.dlci},state={self.state_name(self.state)})'
|
||||
def __str__(self) -> str:
|
||||
return f'DLC(dlci={self.dlci},state={self.state.name})'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Multiplexer(EventEmitter):
|
||||
# Roles
|
||||
INITIATOR = 0x00
|
||||
RESPONDER = 0x01
|
||||
class Role(enum.IntEnum):
|
||||
INITIATOR = 0x00
|
||||
RESPONDER = 0x01
|
||||
|
||||
# States
|
||||
INIT = 0x00
|
||||
CONNECTING = 0x01
|
||||
CONNECTED = 0x02
|
||||
OPENING = 0x03
|
||||
DISCONNECTING = 0x04
|
||||
DISCONNECTED = 0x05
|
||||
RESET = 0x06
|
||||
class State(enum.IntEnum):
|
||||
INIT = 0x00
|
||||
CONNECTING = 0x01
|
||||
CONNECTED = 0x02
|
||||
OPENING = 0x03
|
||||
DISCONNECTING = 0x04
|
||||
DISCONNECTED = 0x05
|
||||
RESET = 0x06
|
||||
|
||||
STATE_NAMES = {
|
||||
INIT: 'INIT',
|
||||
CONNECTING: 'CONNECTING',
|
||||
CONNECTED: 'CONNECTED',
|
||||
OPENING: 'OPENING',
|
||||
DISCONNECTING: 'DISCONNECTING',
|
||||
DISCONNECTED: 'DISCONNECTED',
|
||||
RESET: 'RESET',
|
||||
}
|
||||
connection_result: Optional[asyncio.Future]
|
||||
disconnection_result: Optional[asyncio.Future]
|
||||
open_result: Optional[asyncio.Future]
|
||||
acceptor: Optional[Callable[[int], bool]]
|
||||
dlcs: Dict[int, DLC]
|
||||
|
||||
def __init__(self, l2cap_channel, role):
|
||||
def __init__(self, l2cap_channel: l2cap.Channel, role: Role) -> None:
|
||||
super().__init__()
|
||||
self.role = role
|
||||
self.l2cap_channel = l2cap_channel
|
||||
self.state = Multiplexer.INIT
|
||||
self.state = Multiplexer.State.INIT
|
||||
self.dlcs = {} # DLCs, by DLCI
|
||||
self.connection_result = None
|
||||
self.disconnection_result = None
|
||||
@@ -606,21 +688,15 @@ class Multiplexer(EventEmitter):
|
||||
# Become a sink for the L2CAP channel
|
||||
l2cap_channel.sink = self.on_pdu
|
||||
|
||||
@staticmethod
|
||||
def state_name(state):
|
||||
return Multiplexer.STATE_NAMES[state]
|
||||
|
||||
def change_state(self, new_state):
|
||||
logger.debug(
|
||||
f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
|
||||
)
|
||||
def change_state(self, new_state: State) -> None:
|
||||
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
|
||||
self.state = new_state
|
||||
|
||||
def send_frame(self, frame):
|
||||
def send_frame(self, frame: RFCOMM_Frame) -> None:
|
||||
logger.debug(f'>>> Multiplexer sending {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)
|
||||
logger.debug(f'<<< Multiplexer received {frame}')
|
||||
|
||||
@@ -640,32 +716,32 @@ class Multiplexer(EventEmitter):
|
||||
return
|
||||
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(frame)
|
||||
|
||||
def on_sabm_frame(self, _frame):
|
||||
if self.state != Multiplexer.INIT:
|
||||
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
if self.state != Multiplexer.State.INIT:
|
||||
logger.debug('not in INIT state, ignoring SABM')
|
||||
return
|
||||
self.change_state(Multiplexer.CONNECTED)
|
||||
self.change_state(Multiplexer.State.CONNECTED)
|
||||
self.send_frame(RFCOMM_Frame.ua(c_r=1, dlci=0))
|
||||
|
||||
def on_ua_frame(self, _frame):
|
||||
if self.state == Multiplexer.CONNECTING:
|
||||
self.change_state(Multiplexer.CONNECTED)
|
||||
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
if self.state == Multiplexer.State.CONNECTING:
|
||||
self.change_state(Multiplexer.State.CONNECTED)
|
||||
if self.connection_result:
|
||||
self.connection_result.set_result(0)
|
||||
self.connection_result = None
|
||||
elif self.state == Multiplexer.DISCONNECTING:
|
||||
self.change_state(Multiplexer.DISCONNECTED)
|
||||
elif self.state == Multiplexer.State.DISCONNECTING:
|
||||
self.change_state(Multiplexer.State.DISCONNECTED)
|
||||
if self.disconnection_result:
|
||||
self.disconnection_result.set_result(None)
|
||||
self.disconnection_result = None
|
||||
|
||||
def on_dm_frame(self, _frame):
|
||||
if self.state == Multiplexer.OPENING:
|
||||
self.change_state(Multiplexer.CONNECTED)
|
||||
def on_dm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
if self.state == Multiplexer.State.OPENING:
|
||||
self.change_state(Multiplexer.State.CONNECTED)
|
||||
if self.open_result:
|
||||
self.open_result.set_exception(
|
||||
core.ConnectionError(
|
||||
@@ -678,13 +754,15 @@ class Multiplexer(EventEmitter):
|
||||
else:
|
||||
logger.warning(f'unexpected state for DM: {self}')
|
||||
|
||||
def on_disc_frame(self, _frame):
|
||||
self.change_state(Multiplexer.DISCONNECTED)
|
||||
def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
self.change_state(Multiplexer.State.DISCONNECTED)
|
||||
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)
|
||||
|
||||
if mcc_type == RFCOMM_MCC_PN_TYPE:
|
||||
@@ -694,11 +772,11 @@ class Multiplexer(EventEmitter):
|
||||
mcs = RFCOMM_MCC_MSC.from_bytes(value)
|
||||
self.on_mcc_msc(c_r, mcs)
|
||||
|
||||
def on_ui_frame(self, frame):
|
||||
def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
|
||||
pass
|
||||
|
||||
def on_mcc_pn(self, c_r, pn):
|
||||
if c_r == 1:
|
||||
def on_mcc_pn(self, c_r: bool, pn: RFCOMM_MCC_PN) -> None:
|
||||
if c_r:
|
||||
# Command
|
||||
logger.debug(f'<<< PN Command: {pn}')
|
||||
|
||||
@@ -729,45 +807,45 @@ class Multiplexer(EventEmitter):
|
||||
else:
|
||||
# Response
|
||||
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)
|
||||
self.dlcs[pn.dlci] = dlc
|
||||
dlc.connect()
|
||||
else:
|
||||
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)
|
||||
if dlc is None:
|
||||
logger.warning(f'no dlc for DLCI {msc.dlci}')
|
||||
return
|
||||
dlc.on_mcc_msc(c_r, msc)
|
||||
|
||||
async def connect(self):
|
||||
if self.state != Multiplexer.INIT:
|
||||
async def connect(self) -> None:
|
||||
if self.state != Multiplexer.State.INIT:
|
||||
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.send_frame(RFCOMM_Frame.sabm(c_r=1, dlci=0))
|
||||
return await self.connection_result
|
||||
|
||||
async def disconnect(self):
|
||||
if self.state != Multiplexer.CONNECTED:
|
||||
async def disconnect(self) -> None:
|
||||
if self.state != Multiplexer.State.CONNECTED:
|
||||
return
|
||||
|
||||
self.disconnection_result = asyncio.get_running_loop().create_future()
|
||||
self.change_state(Multiplexer.DISCONNECTING)
|
||||
self.change_state(Multiplexer.State.DISCONNECTING)
|
||||
self.send_frame(
|
||||
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
|
||||
|
||||
async def open_dlc(self, channel):
|
||||
if self.state != Multiplexer.CONNECTED:
|
||||
if self.state == Multiplexer.OPENING:
|
||||
async def open_dlc(self, channel: int) -> DLC:
|
||||
if self.state != Multiplexer.State.CONNECTED:
|
||||
if self.state == Multiplexer.State.OPENING:
|
||||
raise InvalidStateError('open already in progress')
|
||||
|
||||
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))
|
||||
logger.debug(f'>>> Sending MCC: {pn}')
|
||||
self.open_result = asyncio.get_running_loop().create_future()
|
||||
self.change_state(Multiplexer.OPENING)
|
||||
self.change_state(Multiplexer.State.OPENING)
|
||||
self.send_frame(
|
||||
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,
|
||||
information=mcc,
|
||||
)
|
||||
@@ -796,25 +874,28 @@ class Multiplexer(EventEmitter):
|
||||
self.open_result = None
|
||||
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')
|
||||
self.change_state(Multiplexer.CONNECTED)
|
||||
self.change_state(Multiplexer.State.CONNECTED)
|
||||
if self.open_result:
|
||||
self.open_result.set_result(dlc)
|
||||
|
||||
def __str__(self):
|
||||
return f'Multiplexer(state={self.state_name(self.state)})'
|
||||
def __str__(self) -> str:
|
||||
return f'Multiplexer(state={self.state.name})'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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.connection = connection
|
||||
self.l2cap_channel = None
|
||||
self.multiplexer = None
|
||||
|
||||
async def start(self):
|
||||
async def start(self) -> Multiplexer:
|
||||
# Create a new L2CAP connection
|
||||
try:
|
||||
self.l2cap_channel = await self.device.l2cap_channel_manager.connect(
|
||||
@@ -824,15 +905,18 @@ class Client:
|
||||
logger.warning(f'L2CAP connection failed: {error}')
|
||||
raise
|
||||
|
||||
assert self.l2cap_channel is not None
|
||||
# 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
|
||||
await self.multiplexer.connect()
|
||||
|
||||
return self.multiplexer
|
||||
|
||||
async def shutdown(self):
|
||||
async def shutdown(self) -> None:
|
||||
if self.multiplexer is None:
|
||||
return
|
||||
# Disconnect the multiplexer
|
||||
await self.multiplexer.disconnect()
|
||||
self.multiplexer = None
|
||||
@@ -843,7 +927,9 @@ class Client:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Server(EventEmitter):
|
||||
def __init__(self, device):
|
||||
acceptors: Dict[int, Callable[[DLC], None]]
|
||||
|
||||
def __init__(self, device: Device) -> None:
|
||||
super().__init__()
|
||||
self.device = device
|
||||
self.multiplexer = None
|
||||
@@ -852,7 +938,7 @@ class Server(EventEmitter):
|
||||
# Register ourselves with the L2CAP channel manager
|
||||
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 in self.acceptors:
|
||||
# Busy
|
||||
@@ -874,25 +960,25 @@ class Server(EventEmitter):
|
||||
self.acceptors[channel] = acceptor
|
||||
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}')
|
||||
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}')
|
||||
|
||||
# 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.on('dlc', self.on_dlc)
|
||||
|
||||
# Notify
|
||||
self.emit('start', multiplexer)
|
||||
|
||||
def accept_dlc(self, channel_number):
|
||||
def accept_dlc(self, channel_number: int) -> bool:
|
||||
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}')
|
||||
|
||||
# Let the acceptor know
|
||||
|
||||
115
bumble/sdp.py
115
bumble/sdp.py
@@ -18,13 +18,16 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
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 .core import InvalidStateError
|
||||
from .hci import HCI_Object, name_or_number, key_with_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .device import Device, Connection
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -94,6 +97,10 @@ SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID = 0X000B
|
||||
SDP_ICON_URL_ATTRIBUTE_ID = 0X000C
|
||||
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_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',
|
||||
@@ -462,7 +469,7 @@ class ServiceAttribute:
|
||||
self.value = value
|
||||
|
||||
@staticmethod
|
||||
def list_from_data_elements(elements):
|
||||
def list_from_data_elements(elements: List[DataElement]) -> List[ServiceAttribute]:
|
||||
attribute_list = []
|
||||
for i in range(0, len(elements) // 2):
|
||||
attribute_id, attribute_value = elements[2 * i : 2 * (i + 1)]
|
||||
@@ -474,7 +481,9 @@ class ServiceAttribute:
|
||||
return attribute_list
|
||||
|
||||
@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(
|
||||
(
|
||||
attribute.value
|
||||
@@ -489,7 +498,7 @@ class ServiceAttribute:
|
||||
return name_or_number(SDP_ATTRIBUTE_ID_NAMES, id_code)
|
||||
|
||||
@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
|
||||
if value.type == DataElement.UUID:
|
||||
return value.value == uuid
|
||||
@@ -543,7 +552,9 @@ class SDP_PDU:
|
||||
return self
|
||||
|
||||
@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]
|
||||
handle_list = [
|
||||
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
|
||||
'''
|
||||
|
||||
service_search_pattern: DataElement
|
||||
maximum_service_record_count: int
|
||||
continuation_state: bytes
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@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
|
||||
'''
|
||||
|
||||
service_record_handle_list: List[int]
|
||||
total_service_record_count: int
|
||||
current_service_record_count: int
|
||||
continuation_state: bytes
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@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
|
||||
'''
|
||||
|
||||
service_record_handle: int
|
||||
maximum_attribute_byte_count: int
|
||||
attribute_id_list: DataElement
|
||||
continuation_state: bytes
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@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
|
||||
'''
|
||||
|
||||
attribute_list_byte_count: int
|
||||
attribute_list: bytes
|
||||
continuation_state: bytes
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@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
|
||||
'''
|
||||
|
||||
service_search_pattern: DataElement
|
||||
maximum_attribute_byte_count: int
|
||||
attribute_id_list: DataElement
|
||||
continuation_state: bytes
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@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
|
||||
'''
|
||||
|
||||
attribute_list_byte_count: int
|
||||
attribute_list: bytes
|
||||
continuation_state: bytes
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Client:
|
||||
def __init__(self, device):
|
||||
channel: Optional[l2cap.Channel]
|
||||
|
||||
def __init__(self, device: Device) -> None:
|
||||
self.device = device
|
||||
self.pending_request = 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)
|
||||
self.channel = result
|
||||
|
||||
async def disconnect(self):
|
||||
async def disconnect(self) -> None:
|
||||
if self.channel:
|
||||
await self.channel.disconnect()
|
||||
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:
|
||||
raise InvalidStateError('request already pending')
|
||||
if self.channel is None:
|
||||
raise InvalidStateError('L2CAP not connected')
|
||||
|
||||
service_search_pattern = DataElement.sequence(
|
||||
[DataElement.uuid(uuid) for uuid in uuids]
|
||||
@@ -766,9 +808,13 @@ class Client:
|
||||
|
||||
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:
|
||||
raise InvalidStateError('request already pending')
|
||||
if self.channel is None:
|
||||
raise InvalidStateError('L2CAP not connected')
|
||||
|
||||
service_search_pattern = DataElement.sequence(
|
||||
[DataElement.uuid(uuid) for uuid in uuids]
|
||||
@@ -819,9 +865,15 @@ class Client:
|
||||
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:
|
||||
raise InvalidStateError('request already pending')
|
||||
if self.channel is None:
|
||||
raise InvalidStateError('L2CAP not connected')
|
||||
|
||||
attribute_id_list = DataElement.sequence(
|
||||
[
|
||||
@@ -869,21 +921,25 @@ class Client:
|
||||
# -----------------------------------------------------------------------------
|
||||
class Server:
|
||||
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.service_records = {} # Service records maps, by record handle
|
||||
self.channel = 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)
|
||||
|
||||
def send_response(self, response):
|
||||
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {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
|
||||
# service's attribute values (NOTE: the value search recurses into sequences)
|
||||
matching_services = {}
|
||||
@@ -953,7 +1009,9 @@ class Server:
|
||||
return (payload, continuation_state)
|
||||
|
||||
@staticmethod
|
||||
def get_service_attributes(service, attribute_ids):
|
||||
def get_service_attributes(
|
||||
service: Service, attribute_ids: List[DataElement]
|
||||
) -> DataElement:
|
||||
attributes = []
|
||||
for attribute_id in attribute_ids:
|
||||
if attribute_id.value_size == 4:
|
||||
@@ -978,10 +1036,10 @@ class Server:
|
||||
|
||||
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
|
||||
if len(request.continuation_state) > 1:
|
||||
if not self.current_response:
|
||||
if self.current_response is None:
|
||||
self.send_response(
|
||||
SDP_ErrorResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
@@ -1010,6 +1068,7 @@ class Server:
|
||||
)
|
||||
|
||||
# Respond, keeping any unsent handles for later
|
||||
assert isinstance(self.current_response, tuple)
|
||||
service_record_handles = self.current_response[1][
|
||||
: 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
|
||||
if len(request.continuation_state) > 1:
|
||||
if not self.current_response:
|
||||
if self.current_response is None:
|
||||
self.send_response(
|
||||
SDP_ErrorResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
@@ -1069,22 +1130,24 @@ class Server:
|
||||
self.current_response = bytes(attribute_list)
|
||||
|
||||
# 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
|
||||
)
|
||||
self.send_response(
|
||||
SDP_ServiceAttributeResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
attribute_list_byte_count=len(attribute_list),
|
||||
attribute_list_byte_count=len(attribute_list_response),
|
||||
attribute_list=attribute_list,
|
||||
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
|
||||
if len(request.continuation_state) > 1:
|
||||
if not self.current_response:
|
||||
if self.current_response is None:
|
||||
self.send_response(
|
||||
SDP_ErrorResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
@@ -1114,13 +1177,13 @@ class Server:
|
||||
self.current_response = bytes(attribute_lists)
|
||||
|
||||
# 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
|
||||
)
|
||||
self.send_response(
|
||||
SDP_ServiceSearchAttributeResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
attribute_lists_byte_count=len(attribute_lists),
|
||||
attribute_lists_byte_count=len(attribute_lists_response),
|
||||
attribute_lists=attribute_lists,
|
||||
continuation_state=continuation_state,
|
||||
)
|
||||
|
||||
249
bumble/smp.py
249
bumble/smp.py
@@ -25,6 +25,7 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import asyncio
|
||||
import enum
|
||||
import secrets
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
@@ -36,6 +37,7 @@ from typing import (
|
||||
Optional,
|
||||
Tuple,
|
||||
Type,
|
||||
cast,
|
||||
)
|
||||
|
||||
from pyee import EventEmitter
|
||||
@@ -553,20 +555,16 @@ class AddressResolver:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Session:
|
||||
# Pairing methods
|
||||
class PairingMethod(enum.IntEnum):
|
||||
JUST_WORKS = 0
|
||||
NUMERIC_COMPARISON = 1
|
||||
PASSKEY = 2
|
||||
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
|
||||
#
|
||||
# See Bluetooth spec @ Vol 3, part H - Table 2.8: Mapping of IO Capabilities to Key
|
||||
@@ -581,47 +579,50 @@ class Session:
|
||||
# (False).
|
||||
PAIRING_METHODS = {
|
||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: {
|
||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
|
||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: JUST_WORKS,
|
||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
|
||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
|
||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PASSKEY, True, False),
|
||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
|
||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
|
||||
},
|
||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: {
|
||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
|
||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (JUST_WORKS, NUMERIC_COMPARISON),
|
||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
|
||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
|
||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (
|
||||
PairingMethod.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: (
|
||||
(PASSKEY, True, False),
|
||||
NUMERIC_COMPARISON,
|
||||
(PairingMethod.PASSKEY, True, False),
|
||||
PairingMethod.NUMERIC_COMPARISON,
|
||||
),
|
||||
},
|
||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: {
|
||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: (PASSKEY, False, True),
|
||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (PASSKEY, False, True),
|
||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, False, False),
|
||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
|
||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PASSKEY, False, True),
|
||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
|
||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
|
||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, False),
|
||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
|
||||
},
|
||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: {
|
||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
|
||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: JUST_WORKS,
|
||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: JUST_WORKS,
|
||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
|
||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: JUST_WORKS,
|
||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||
},
|
||||
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: (
|
||||
(PASSKEY, False, True),
|
||||
NUMERIC_COMPARISON,
|
||||
(PairingMethod.PASSKEY, False, True),
|
||||
PairingMethod.NUMERIC_COMPARISON,
|
||||
),
|
||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
|
||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
|
||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
|
||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (
|
||||
(PASSKEY, True, False),
|
||||
NUMERIC_COMPARISON,
|
||||
(PairingMethod.PASSKEY, True, False),
|
||||
PairingMethod.NUMERIC_COMPARISON,
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -664,7 +665,7 @@ class Session:
|
||||
self.passkey_ready = asyncio.Event()
|
||||
self.passkey_step = 0
|
||||
self.passkey_display = False
|
||||
self.pairing_method = 0
|
||||
self.pairing_method: PairingMethod = PairingMethod.JUST_WORKS
|
||||
self.pairing_config = pairing_config
|
||||
self.wait_before_continuing: Optional[asyncio.Future[None]] = None
|
||||
self.completed = False
|
||||
@@ -769,19 +770,23 @@ class Session:
|
||||
def decide_pairing_method(
|
||||
self, auth_req: int, initiator_io_capability: int, responder_io_capability: int
|
||||
) -> 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):
|
||||
self.pairing_method = self.JUST_WORKS
|
||||
self.pairing_method = PairingMethod.JUST_WORKS
|
||||
return
|
||||
|
||||
details = self.PAIRING_METHODS[initiator_io_capability][responder_io_capability] # type: ignore[index]
|
||||
if isinstance(details, tuple) and len(details) == 2:
|
||||
# One entry for legacy pairing and one for secure connections
|
||||
details = details[1 if self.sc else 0]
|
||||
if isinstance(details, int):
|
||||
if isinstance(details, PairingMethod):
|
||||
# Just a method ID
|
||||
self.pairing_method = details
|
||||
else:
|
||||
# PASSKEY method, with a method ID and display/input flags
|
||||
assert isinstance(details[0], PairingMethod)
|
||||
self.pairing_method = details[0]
|
||||
self.passkey_display = details[1 if self.is_initiator else 2]
|
||||
|
||||
@@ -858,10 +863,13 @@ class Session:
|
||||
self.tk = self.passkey.to_bytes(16, byteorder='little')
|
||||
logger.debug(f'TK from passkey = {self.tk.hex()}')
|
||||
|
||||
self.connection.abort_on(
|
||||
'disconnection',
|
||||
self.pairing_config.delegate.display_number(self.passkey, digits=6),
|
||||
)
|
||||
try:
|
||||
self.connection.abort_on(
|
||||
'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:
|
||||
# Prompt the user for the passkey displayed on the peer
|
||||
@@ -929,9 +937,12 @@ class Session:
|
||||
if self.sc:
|
||||
|
||||
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
|
||||
elif self.pairing_method == self.PASSKEY:
|
||||
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||
# We need a passkey
|
||||
await self.passkey_ready.wait()
|
||||
assert self.passkey
|
||||
@@ -983,6 +994,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:
|
||||
# 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
|
||||
@@ -1006,6 +1030,7 @@ class Session:
|
||||
self.ltk = crypto.h6(ilk, b'brle')
|
||||
|
||||
def distribute_keys(self) -> None:
|
||||
|
||||
# Distribute the keys as required
|
||||
if self.is_initiator:
|
||||
# CTKD: Derive LTK from LinkKey
|
||||
@@ -1035,12 +1060,7 @@ class Session:
|
||||
identity_resolving_key=self.manager.device.irk
|
||||
)
|
||||
)
|
||||
self.send_command(
|
||||
SMP_Identity_Address_Information_Command(
|
||||
addr_type=self.connection.self_address.address_type,
|
||||
bd_addr=self.connection.self_address,
|
||||
)
|
||||
)
|
||||
self.send_identity_address_command()
|
||||
|
||||
# Distribute CSRK
|
||||
csrk = bytes(16) # FIXME: testing
|
||||
@@ -1084,12 +1104,7 @@ class Session:
|
||||
identity_resolving_key=self.manager.device.irk
|
||||
)
|
||||
)
|
||||
self.send_command(
|
||||
SMP_Identity_Address_Information_Command(
|
||||
addr_type=self.connection.self_address.address_type,
|
||||
bd_addr=self.connection.self_address,
|
||||
)
|
||||
)
|
||||
self.send_identity_address_command()
|
||||
|
||||
# Distribute CSRK
|
||||
csrk = bytes(16) # FIXME: testing
|
||||
@@ -1224,7 +1239,7 @@ class Session:
|
||||
# Create an object to hold the keys
|
||||
keys = PairingKeys()
|
||||
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:
|
||||
keys.ltk = PairingKeys.Key(value=self.ltk, authenticated=authenticated)
|
||||
else:
|
||||
@@ -1258,7 +1273,7 @@ class Session:
|
||||
keys.link_key = PairingKeys.Key(
|
||||
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:
|
||||
logger.warning(f'pairing failure ({error_name(reason)})')
|
||||
@@ -1300,7 +1315,11 @@ class Session:
|
||||
self, command: SMP_Pairing_Request_Command
|
||||
) -> None:
|
||||
# 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:
|
||||
logger.debug('pairing rejected by delegate')
|
||||
self.send_pairing_failed(SMP_PAIRING_NOT_SUPPORTED_ERROR)
|
||||
@@ -1323,9 +1342,7 @@ class Session:
|
||||
self.decide_pairing_method(
|
||||
command.auth_req, command.io_capability, self.io_capability
|
||||
)
|
||||
logger.debug(
|
||||
f'pairing method: {self.PAIRING_METHOD_NAMES[self.pairing_method]}'
|
||||
)
|
||||
logger.debug(f'pairing method: {self.pairing_method.name}')
|
||||
|
||||
# Key distribution
|
||||
(
|
||||
@@ -1341,7 +1358,7 @@ class Session:
|
||||
|
||||
# Display a passkey if we need to
|
||||
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()
|
||||
|
||||
# Respond
|
||||
@@ -1382,9 +1399,7 @@ class Session:
|
||||
self.decide_pairing_method(
|
||||
command.auth_req, self.io_capability, command.io_capability
|
||||
)
|
||||
logger.debug(
|
||||
f'pairing method: {self.PAIRING_METHOD_NAMES[self.pairing_method]}'
|
||||
)
|
||||
logger.debug(f'pairing method: {self.pairing_method.name}')
|
||||
|
||||
# Key distribution
|
||||
if (
|
||||
@@ -1400,13 +1415,16 @@ class Session:
|
||||
self.compute_peer_expected_distributions(self.responder_key_distribution)
|
||||
|
||||
# Start phase 2
|
||||
if self.sc:
|
||||
if self.pairing_method == self.PASSKEY:
|
||||
if self.pairing_method == PairingMethod.CTKD_OVER_CLASSIC:
|
||||
# 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.send_public_key_command()
|
||||
else:
|
||||
if self.pairing_method == self.PASSKEY:
|
||||
if self.pairing_method == PairingMethod.PASSKEY:
|
||||
self.display_or_input_passkey(self.send_pairing_confirm_command)
|
||||
else:
|
||||
self.send_pairing_confirm_command()
|
||||
@@ -1418,7 +1436,10 @@ class Session:
|
||||
self.send_pairing_random_command()
|
||||
else:
|
||||
# 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)
|
||||
else:
|
||||
self.send_pairing_confirm_command()
|
||||
@@ -1426,11 +1447,14 @@ class Session:
|
||||
def on_smp_pairing_confirm_command_secure_connections(
|
||||
self, _: SMP_Pairing_Confirm_Command
|
||||
) -> 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:
|
||||
self.r = crypto.r()
|
||||
self.send_pairing_random_command()
|
||||
elif self.pairing_method == self.PASSKEY:
|
||||
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||
if self.is_initiator:
|
||||
self.send_pairing_random_command()
|
||||
else:
|
||||
@@ -1486,13 +1510,16 @@ class Session:
|
||||
def on_smp_pairing_random_command_secure_connections(
|
||||
self, command: SMP_Pairing_Random_Command
|
||||
) -> 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')
|
||||
return
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
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
|
||||
# Check that the random value matches what was committed to earlier
|
||||
confirm_verifier = crypto.f4(
|
||||
@@ -1502,7 +1529,7 @@ class Session:
|
||||
self.confirm_value, confirm_verifier, SMP_CONFIRM_VALUE_FAILED_ERROR
|
||||
):
|
||||
return
|
||||
elif self.pairing_method == self.PASSKEY:
|
||||
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||
assert self.passkey and self.confirm_value
|
||||
# Check that the random value matches what was committed to earlier
|
||||
confirm_verifier = crypto.f4(
|
||||
@@ -1525,9 +1552,12 @@ class Session:
|
||||
else:
|
||||
return
|
||||
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()
|
||||
elif self.pairing_method == self.PASSKEY:
|
||||
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||
assert self.passkey and self.confirm_value
|
||||
# Check that the random value matches what was committed to earlier
|
||||
confirm_verifier = crypto.f4(
|
||||
@@ -1558,10 +1588,13 @@ class Session:
|
||||
(mac_key, self.ltk) = crypto.f5(self.dh_key, self.na, self.nb, a, b)
|
||||
|
||||
# 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)
|
||||
rb = ra
|
||||
elif self.pairing_method == self.PASSKEY:
|
||||
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||
assert self.passkey
|
||||
ra = self.passkey.to_bytes(16, byteorder='little')
|
||||
rb = ra
|
||||
@@ -1585,13 +1618,16 @@ class Session:
|
||||
self.wait_before_continuing.set_result(None)
|
||||
|
||||
# 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
|
||||
code = crypto.g2(self.pka, self.pkb, self.na, self.nb) % 1000000
|
||||
|
||||
# Ask for user confirmation
|
||||
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)
|
||||
else:
|
||||
self.prompt_user_for_numeric_comparison(code, next_steps)
|
||||
@@ -1628,13 +1664,16 @@ class Session:
|
||||
if self.is_initiator:
|
||||
self.send_pairing_confirm_command()
|
||||
else:
|
||||
if self.pairing_method == self.PASSKEY:
|
||||
if self.pairing_method == PairingMethod.PASSKEY:
|
||||
self.display_or_input_passkey()
|
||||
|
||||
# Send our public key back to the initiator
|
||||
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
|
||||
self.send_pairing_confirm_command()
|
||||
|
||||
@@ -1733,7 +1772,26 @@ class Manager(EventEmitter):
|
||||
cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID
|
||||
connection.send_l2cap_pdu(cid, command.to_bytes())
|
||||
|
||||
def on_smp_security_request_command(
|
||||
self, connection: Connection, request: SMP_Security_Request_Command
|
||||
) -> None:
|
||||
connection.emit('security_request', request.auth_req)
|
||||
|
||||
def on_smp_pdu(self, connection: Connection, pdu: bytes) -> None:
|
||||
# Parse the L2CAP payload into an SMP Command object
|
||||
command = SMP_Command.from_bytes(pdu)
|
||||
logger.debug(
|
||||
f'<<< Received SMP Command on connection [0x{connection.handle:04X}] '
|
||||
f'{connection.peer_address}: {command}'
|
||||
)
|
||||
|
||||
# Security request is more than just pairing, so let applications handle them
|
||||
if command.code == SMP_SECURITY_REQUEST_COMMAND:
|
||||
self.on_smp_security_request_command(
|
||||
connection, cast(SMP_Security_Request_Command, command)
|
||||
)
|
||||
return
|
||||
|
||||
# Look for a session with this connection, and create one if none exists
|
||||
if not (session := self.sessions.get(connection.handle)):
|
||||
if connection.role == BT_CENTRAL_ROLE:
|
||||
@@ -1744,13 +1802,6 @@ class Manager(EventEmitter):
|
||||
)
|
||||
self.sessions[connection.handle] = session
|
||||
|
||||
# Parse the L2CAP payload into an SMP Command object
|
||||
command = SMP_Command.from_bytes(pdu)
|
||||
logger.debug(
|
||||
f'<<< Received SMP Command on connection [0x{connection.handle:04X}] '
|
||||
f'{connection.peer_address}: {command}'
|
||||
)
|
||||
|
||||
# Delegate the handling of the command to the session
|
||||
session.on_smp_command(command)
|
||||
|
||||
@@ -1789,23 +1840,17 @@ class Manager(EventEmitter):
|
||||
def on_session_start(self, session: Session) -> None:
|
||||
self.device.on_pairing_start(session.connection)
|
||||
|
||||
def on_pairing(
|
||||
async def on_pairing(
|
||||
self, session: Session, identity_address: Optional[Address], keys: PairingKeys
|
||||
) -> None:
|
||||
# Store the keys in the key store
|
||||
if self.device.keystore and identity_address is not None:
|
||||
|
||||
async def store_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())
|
||||
self.device.abort_on(
|
||||
'flush', self.device.update_keys(str(identity_address), keys)
|
||||
)
|
||||
|
||||
# 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:
|
||||
self.device.on_pairing_failure(session.connection, reason)
|
||||
|
||||
@@ -20,7 +20,6 @@ import logging
|
||||
import os
|
||||
|
||||
from .common import Transport, AsyncPipeSink, SnoopingTransport
|
||||
from ..controller import Controller
|
||||
from ..snoop import create_snooper
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -69,6 +68,7 @@ async def open_transport(name: str) -> Transport:
|
||||
* usb
|
||||
* pyusb
|
||||
* android-emulator
|
||||
* android-netsim
|
||||
"""
|
||||
|
||||
return _wrap_transport(await _open_transport(name))
|
||||
@@ -118,7 +118,8 @@ async def _open_transport(name: str) -> Transport:
|
||||
if scheme == 'file':
|
||||
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':
|
||||
from .vhci import open_vhci_transport
|
||||
@@ -133,12 +134,14 @@ async def _open_transport(name: str) -> Transport:
|
||||
if scheme == 'usb':
|
||||
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':
|
||||
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':
|
||||
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:'):
|
||||
from ..controller import Controller
|
||||
from ..link import RemoteLink # lazy import
|
||||
|
||||
link = RemoteLink(name[11:])
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
import logging
|
||||
import grpc.aio
|
||||
|
||||
from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink
|
||||
from typing import Optional, Union
|
||||
|
||||
from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink, Transport
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from .grpc_protobuf.emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
|
||||
@@ -33,7 +35,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_android_emulator_transport(spec):
|
||||
async def open_android_emulator_transport(spec: Optional[str]) -> Transport:
|
||||
'''
|
||||
Open a transport connection to an Android emulator via its gRPC interface.
|
||||
The parameter string has this syntax:
|
||||
@@ -66,7 +68,7 @@ async def open_android_emulator_transport(spec):
|
||||
# Parse the parameters
|
||||
mode = 'host'
|
||||
server_host = 'localhost'
|
||||
server_port = 8554
|
||||
server_port = '8554'
|
||||
if spec is not None:
|
||||
params = spec.split(',')
|
||||
for param in params:
|
||||
@@ -82,6 +84,7 @@ async def open_android_emulator_transport(spec):
|
||||
logger.debug(f'connecting to gRPC server at {server_address}')
|
||||
channel = grpc.aio.insecure_channel(server_address)
|
||||
|
||||
service: Union[EmulatedBluetoothServiceStub, VhciForwardingServiceStub]
|
||||
if mode == 'host':
|
||||
# Connect as a host
|
||||
service = EmulatedBluetoothServiceStub(channel)
|
||||
@@ -94,10 +97,13 @@ async def open_android_emulator_transport(spec):
|
||||
raise ValueError('invalid mode')
|
||||
|
||||
# Create the transport object
|
||||
transport = PumpedTransport(
|
||||
PumpedPacketSource(hci_device.read),
|
||||
PumpedPacketSink(hci_device.write),
|
||||
channel.close,
|
||||
class EmulatorTransport(PumpedTransport):
|
||||
async def close(self):
|
||||
await super().close()
|
||||
await channel.close()
|
||||
|
||||
transport = EmulatorTransport(
|
||||
PumpedPacketSource(hci_device.read), PumpedPacketSink(hci_device.write)
|
||||
)
|
||||
transport.start()
|
||||
|
||||
|
||||
@@ -18,11 +18,12 @@
|
||||
import asyncio
|
||||
import atexit
|
||||
import logging
|
||||
import grpc.aio
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
from typing import Optional
|
||||
from typing import Dict, Optional
|
||||
|
||||
import grpc.aio
|
||||
|
||||
from .common import (
|
||||
ParserSource,
|
||||
@@ -33,8 +34,8 @@ from .common import (
|
||||
)
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from .grpc_protobuf.packet_streamer_pb2_grpc import PacketStreamerStub
|
||||
from .grpc_protobuf.packet_streamer_pb2_grpc import (
|
||||
PacketStreamerStub,
|
||||
PacketStreamerServicer,
|
||||
add_PacketStreamerServicer_to_server,
|
||||
)
|
||||
@@ -43,6 +44,7 @@ from .grpc_protobuf.hci_packet_pb2 import HCIPacket
|
||||
from .grpc_protobuf.startup_pb2 import Chip, ChipInfo
|
||||
from .grpc_protobuf.common_pb2 import ChipKind
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -74,14 +76,20 @@ def get_ini_dir() -> Optional[pathlib.Path]:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def find_grpc_port() -> int:
|
||||
def ini_file_name(instance_number: int) -> str:
|
||||
suffix = f'_{instance_number}' if instance_number > 0 else ''
|
||||
return f'netsim{suffix}.ini'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def find_grpc_port(instance_number: int) -> int:
|
||||
if not (ini_dir := get_ini_dir()):
|
||||
logger.debug('no known directory for .ini file')
|
||||
return 0
|
||||
|
||||
ini_file = ini_dir / 'netsim.ini'
|
||||
ini_file = ini_dir / ini_file_name(instance_number)
|
||||
logger.debug(f'Looking for .ini file at {ini_file}')
|
||||
if ini_file.is_file():
|
||||
logger.debug(f'Found .ini file at {ini_file}')
|
||||
with open(ini_file, 'r') as ini_file_data:
|
||||
for line in ini_file_data.readlines():
|
||||
if '=' in line:
|
||||
@@ -90,12 +98,14 @@ def find_grpc_port() -> int:
|
||||
logger.debug(f'gRPC port = {value}')
|
||||
return int(value)
|
||||
|
||||
logger.debug('no grpc.port property found in .ini file')
|
||||
|
||||
# Not found
|
||||
return 0
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def publish_grpc_port(grpc_port) -> bool:
|
||||
def publish_grpc_port(grpc_port: int, instance_number: int) -> bool:
|
||||
if not (ini_dir := get_ini_dir()):
|
||||
logger.debug('no known directory for .ini file')
|
||||
return False
|
||||
@@ -104,7 +114,7 @@ def publish_grpc_port(grpc_port) -> bool:
|
||||
logger.debug('ini directory does not exist')
|
||||
return False
|
||||
|
||||
ini_file = ini_dir / 'netsim.ini'
|
||||
ini_file = ini_dir / ini_file_name(instance_number)
|
||||
try:
|
||||
ini_file.write_text(f'grpc.port={grpc_port}\n')
|
||||
logger.debug(f"published gRPC port at {ini_file}")
|
||||
@@ -121,13 +131,16 @@ 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: Optional[str], server_port: int, options: Dict[str, str]
|
||||
) -> Transport:
|
||||
if not server_port:
|
||||
raise ValueError('invalid port')
|
||||
if server_host == '_' or not server_host:
|
||||
server_host = 'localhost'
|
||||
|
||||
if not publish_grpc_port(server_port):
|
||||
instance_number = int(options.get('instance', "0"))
|
||||
if not publish_grpc_port(server_port, instance_number):
|
||||
logger.warning("unable to publish gRPC port")
|
||||
|
||||
class HciDevice:
|
||||
@@ -184,15 +197,12 @@ async def open_android_netsim_controller_transport(server_host, server_port):
|
||||
logger.debug(f'<<< PACKET: {data.hex()}')
|
||||
self.on_data_received(data)
|
||||
|
||||
def send_packet(self, data):
|
||||
async def send():
|
||||
await self.context.write(
|
||||
PacketResponse(
|
||||
hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
|
||||
)
|
||||
async def send_packet(self, data):
|
||||
return await self.context.write(
|
||||
PacketResponse(
|
||||
hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
|
||||
)
|
||||
|
||||
self.loop.create_task(send())
|
||||
)
|
||||
|
||||
def terminate(self):
|
||||
self.task.cancel()
|
||||
@@ -226,17 +236,17 @@ async def open_android_netsim_controller_transport(server_host, server_port):
|
||||
logger.debug('gRPC server cancelled')
|
||||
await self.grpc_server.stop(None)
|
||||
|
||||
def on_packet(self, packet):
|
||||
async def send_packet(self, packet):
|
||||
if not self.device:
|
||||
logger.debug('no device, dropping packet')
|
||||
return
|
||||
|
||||
self.device.send_packet(packet)
|
||||
return await self.device.send_packet(packet)
|
||||
|
||||
async def StreamPackets(self, _request_iterator, context):
|
||||
logger.debug('StreamPackets request')
|
||||
|
||||
# Check that we won't already have a device
|
||||
# Check that we don't already have a device
|
||||
if self.device:
|
||||
logger.debug('busy, already serving a device')
|
||||
return PacketResponse(error='Busy')
|
||||
@@ -259,15 +269,42 @@ async def open_android_netsim_controller_transport(server_host, server_port):
|
||||
await server.start()
|
||||
asyncio.get_running_loop().create_task(server.serve())
|
||||
|
||||
class GrpcServerTransport(Transport):
|
||||
async def close(self):
|
||||
await super().close()
|
||||
|
||||
return GrpcServerTransport(server, server)
|
||||
sink = PumpedPacketSink(server.send_packet)
|
||||
sink.start()
|
||||
return Transport(server, sink)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_android_netsim_host_transport(server_host, server_port, options):
|
||||
async def open_android_netsim_host_transport_with_address(
|
||||
server_host: Optional[str],
|
||||
server_port: int,
|
||||
options: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
if server_host == '_' or not server_host:
|
||||
server_host = 'localhost'
|
||||
|
||||
if not server_port:
|
||||
# Look for the gRPC config in a .ini file
|
||||
instance_number = 0 if options is None else int(options.get('instance', '0'))
|
||||
server_port = find_grpc_port(instance_number)
|
||||
if not server_port:
|
||||
raise RuntimeError('gRPC server port not found')
|
||||
|
||||
# Connect to the gRPC server
|
||||
server_address = f'{server_host}:{server_port}'
|
||||
logger.debug(f'Connecting to gRPC server at {server_address}')
|
||||
channel = grpc.aio.insecure_channel(server_address)
|
||||
|
||||
return await open_android_netsim_host_transport_with_channel(
|
||||
channel,
|
||||
options,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_android_netsim_host_transport_with_channel(
|
||||
channel, options: Optional[Dict[str, str]] = None
|
||||
):
|
||||
# Wrapper for I/O operations
|
||||
class HciDevice:
|
||||
def __init__(self, name, manufacturer, hci_device):
|
||||
@@ -286,10 +323,12 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
|
||||
async def read(self):
|
||||
response = await self.hci_device.read()
|
||||
response_type = response.WhichOneof('response_type')
|
||||
|
||||
if response_type == 'error':
|
||||
logger.warning(f'received error: {response.error}')
|
||||
raise RuntimeError(response.error)
|
||||
elif response_type == 'hci_packet':
|
||||
|
||||
if response_type == 'hci_packet':
|
||||
return (
|
||||
bytes([response.hci_packet.packet_type])
|
||||
+ response.hci_packet.packet
|
||||
@@ -304,24 +343,9 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
|
||||
)
|
||||
)
|
||||
|
||||
name = options.get('name', DEFAULT_NAME)
|
||||
name = DEFAULT_NAME if options is None else options.get('name', DEFAULT_NAME)
|
||||
manufacturer = DEFAULT_MANUFACTURER
|
||||
|
||||
if server_host == '_' or not server_host:
|
||||
server_host = 'localhost'
|
||||
|
||||
if not server_port:
|
||||
# Look for the gRPC config in a .ini file
|
||||
server_host = 'localhost'
|
||||
server_port = find_grpc_port()
|
||||
if not server_port:
|
||||
raise RuntimeError('gRPC server port not found')
|
||||
|
||||
# Connect to the gRPC server
|
||||
server_address = f'{server_host}:{server_port}'
|
||||
logger.debug(f'Connecting to gRPC server at {server_address}')
|
||||
channel = grpc.aio.insecure_channel(server_address)
|
||||
|
||||
# Connect as a host
|
||||
service = PacketStreamerStub(channel)
|
||||
hci_device = HciDevice(
|
||||
@@ -332,10 +356,14 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
|
||||
await hci_device.start()
|
||||
|
||||
# Create the transport object
|
||||
transport = PumpedTransport(
|
||||
class GrpcTransport(PumpedTransport):
|
||||
async def close(self):
|
||||
await super().close()
|
||||
await channel.close()
|
||||
|
||||
transport = GrpcTransport(
|
||||
PumpedPacketSource(hci_device.read),
|
||||
PumpedPacketSink(hci_device.write),
|
||||
channel.close,
|
||||
)
|
||||
transport.start()
|
||||
|
||||
@@ -343,7 +371,7 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_android_netsim_transport(spec):
|
||||
async def open_android_netsim_transport(spec: Optional[str]) -> Transport:
|
||||
'''
|
||||
Open a transport connection as a client or server, implementing Android's `netsim`
|
||||
simulator protocol over gRPC.
|
||||
@@ -357,6 +385,11 @@ async def open_android_netsim_transport(spec):
|
||||
to connect *to* a netsim server (netsim is the controller), or accept
|
||||
connections *as* a netsim-compatible server.
|
||||
|
||||
instance=<n>
|
||||
Specifies an instance number, with <n> > 0. This is used to determine which
|
||||
.init file to use. In `host` mode, it is ignored when the <host>:<port>
|
||||
specifier is present, since in that case no .ini file is used.
|
||||
|
||||
In `host` mode:
|
||||
The <host>:<port> part is optional. When not specified, the transport
|
||||
looks for a netsim .ini file, from which it will read the `grpc.backend.port`
|
||||
@@ -385,14 +418,15 @@ async def open_android_netsim_transport(spec):
|
||||
params = spec.split(',') if spec else []
|
||||
if params and ':' in params[0]:
|
||||
# Explicit <host>:<port>
|
||||
host, port = params[0].split(':')
|
||||
host, port_str = params[0].split(':')
|
||||
port = int(port_str)
|
||||
params_offset = 1
|
||||
else:
|
||||
host = None
|
||||
port = 0
|
||||
params_offset = 0
|
||||
|
||||
options = {}
|
||||
options: Dict[str, str] = {}
|
||||
for param in params[params_offset:]:
|
||||
if '=' not in param:
|
||||
raise ValueError('invalid parameter, expected <name>=<value>')
|
||||
@@ -401,10 +435,12 @@ async def open_android_netsim_transport(spec):
|
||||
|
||||
mode = options.get('mode', 'host')
|
||||
if mode == 'host':
|
||||
return await open_android_netsim_host_transport(host, port, options)
|
||||
return await open_android_netsim_host_transport_with_address(
|
||||
host, port, options
|
||||
)
|
||||
if mode == 'controller':
|
||||
if host is None:
|
||||
raise ValueError('<host>:<port> missing')
|
||||
return await open_android_netsim_controller_transport(host, port)
|
||||
return await open_android_netsim_controller_transport(host, port, options)
|
||||
|
||||
raise ValueError('invalid mode option')
|
||||
|
||||
@@ -20,11 +20,12 @@ import contextlib
|
||||
import struct
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import ContextManager
|
||||
import io
|
||||
from typing import ContextManager, Tuple, Optional, Protocol, Dict
|
||||
|
||||
from .. import hci
|
||||
from ..colors import color
|
||||
from ..snoop import Snooper
|
||||
from bumble import hci
|
||||
from bumble.colors import color
|
||||
from bumble.snoop import Snooper
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -36,7 +37,7 @@ logger = logging.getLogger(__name__)
|
||||
# Information needed to parse HCI packets with a generic parser:
|
||||
# For each packet type, the info represents:
|
||||
# (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_ACL_DATA_PACKET: (2, 2, 'H'),
|
||||
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
|
||||
@@ -45,33 +46,54 @@ HCI_PACKET_INFO = {
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class PacketPump:
|
||||
'''
|
||||
Pump HCI packets from a reader to a sink
|
||||
'''
|
||||
# Errors
|
||||
# -----------------------------------------------------------------------------
|
||||
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.sink = sink
|
||||
|
||||
async def run(self):
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
# Get a packet from the source
|
||||
packet = hci.HCI_Packet.from_bytes(await self.reader.next_packet())
|
||||
|
||||
# Deliver the packet to the sink
|
||||
self.sink.on_packet(packet)
|
||||
self.sink.on_packet(await self.reader.next_packet())
|
||||
except Exception as error:
|
||||
logger.warning(f'!!! {error}')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class PacketParser:
|
||||
'''
|
||||
"""
|
||||
In-line parser that accepts data and emits 'on_packet' when a full packet has been
|
||||
parsed
|
||||
'''
|
||||
parsed.
|
||||
"""
|
||||
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
|
||||
@@ -79,18 +101,22 @@ class PacketParser:
|
||||
NEED_LENGTH = 1
|
||||
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.extended_packet_info = {}
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
def reset(self) -> None:
|
||||
self.state = PacketParser.NEED_TYPE
|
||||
self.bytes_needed = 1
|
||||
self.packet = bytearray()
|
||||
self.packet_info = None
|
||||
|
||||
def feed_data(self, data):
|
||||
def feed_data(self, data: bytes) -> None:
|
||||
data_offset = 0
|
||||
data_left = len(data)
|
||||
while data_left and self.bytes_needed:
|
||||
@@ -111,6 +137,7 @@ class PacketParser:
|
||||
self.state = PacketParser.NEED_LENGTH
|
||||
self.bytes_needed = self.packet_info[0] + self.packet_info[1]
|
||||
elif self.state == PacketParser.NEED_LENGTH:
|
||||
assert self.packet_info is not None
|
||||
body_length = struct.unpack_from(
|
||||
self.packet_info[2], self.packet, 1 + self.packet_info[1]
|
||||
)[0]
|
||||
@@ -128,20 +155,20 @@ class PacketParser:
|
||||
)
|
||||
self.reset()
|
||||
|
||||
def set_packet_sink(self, sink):
|
||||
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||
self.sink = sink
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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
|
||||
|
||||
def next_packet(self):
|
||||
def next_packet(self) -> Optional[bytes]:
|
||||
# Get the packet type
|
||||
packet_type = self.source.read(1)
|
||||
if len(packet_type) != 1:
|
||||
@@ -150,7 +177,7 @@ class PacketReader:
|
||||
# Get the packet info based on its type
|
||||
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
||||
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)
|
||||
header_size = packet_info[0] + packet_info[1]
|
||||
@@ -169,21 +196,21 @@ class PacketReader:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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
|
||||
|
||||
async def next_packet(self):
|
||||
async def next_packet(self) -> bytes:
|
||||
# Get the packet type
|
||||
packet_type = await self.source.readexactly(1)
|
||||
|
||||
# Get the packet info based on its type
|
||||
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
||||
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)
|
||||
header_size = packet_info[0] + packet_info[1]
|
||||
@@ -198,15 +225,15 @@ class AsyncPacketReader:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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.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)
|
||||
|
||||
|
||||
@@ -216,35 +243,48 @@ class ParserSource:
|
||||
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.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)
|
||||
|
||||
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
|
||||
|
||||
def close(self):
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class StreamPacketSource(asyncio.Protocol, ParserSource):
|
||||
def data_received(self, data):
|
||||
def data_received(self, data: bytes) -> None:
|
||||
self.parser.feed_data(data)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class StreamPacketSink:
|
||||
def __init__(self, transport):
|
||||
def __init__(self, transport: asyncio.WriteTransport) -> None:
|
||||
self.transport = transport
|
||||
|
||||
def on_packet(self, packet):
|
||||
def on_packet(self, packet: bytes) -> None:
|
||||
self.transport.write(packet)
|
||||
|
||||
def close(self):
|
||||
def close(self) -> None:
|
||||
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.sink = sink
|
||||
|
||||
@@ -278,34 +318,39 @@ class Transport:
|
||||
return iter((self.source, self.sink))
|
||||
|
||||
async def close(self) -> None:
|
||||
self.source.close()
|
||||
self.sink.close()
|
||||
if hasattr(self.source, 'close'):
|
||||
self.source.close()
|
||||
if hasattr(self.sink, 'close'):
|
||||
self.sink.close()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class PumpedPacketSource(ParserSource):
|
||||
def __init__(self, receive):
|
||||
pump_task: Optional[asyncio.Task[None]]
|
||||
|
||||
def __init__(self, receive) -> None:
|
||||
super().__init__()
|
||||
self.receive_function = receive
|
||||
self.pump_task = None
|
||||
|
||||
def start(self):
|
||||
async def pump_packets():
|
||||
def start(self) -> None:
|
||||
async def pump_packets() -> None:
|
||||
while True:
|
||||
try:
|
||||
packet = await self.receive_function()
|
||||
self.parser.feed_data(packet)
|
||||
except asyncio.exceptions.CancelledError:
|
||||
except asyncio.CancelledError:
|
||||
logger.debug('source pump task done')
|
||||
self.terminated.set_result(None)
|
||||
break
|
||||
except Exception as error:
|
||||
logger.warning(f'exception while waiting for packet: {error}')
|
||||
self.terminated.set_result(error)
|
||||
self.terminated.set_exception(error)
|
||||
break
|
||||
|
||||
self.pump_task = asyncio.create_task(pump_packets())
|
||||
|
||||
def close(self):
|
||||
def close(self) -> None:
|
||||
if self.pump_task:
|
||||
self.pump_task.cancel()
|
||||
|
||||
@@ -317,7 +362,7 @@ class PumpedPacketSink:
|
||||
self.packet_queue = asyncio.Queue()
|
||||
self.pump_task = None
|
||||
|
||||
def on_packet(self, packet):
|
||||
def on_packet(self, packet: bytes) -> None:
|
||||
self.packet_queue.put_nowait(packet)
|
||||
|
||||
def start(self):
|
||||
@@ -326,7 +371,7 @@ class PumpedPacketSink:
|
||||
try:
|
||||
packet = await self.packet_queue.get()
|
||||
await self.send_function(packet)
|
||||
except asyncio.exceptions.CancelledError:
|
||||
except asyncio.CancelledError:
|
||||
logger.debug('sink pump task done')
|
||||
break
|
||||
except Exception as error:
|
||||
@@ -342,18 +387,20 @@ class PumpedPacketSink:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class PumpedTransport(Transport):
|
||||
def __init__(self, source, sink, close_function):
|
||||
super().__init__(source, sink)
|
||||
self.close_function = close_function
|
||||
source: PumpedPacketSource
|
||||
sink: PumpedPacketSink
|
||||
|
||||
def start(self):
|
||||
def __init__(
|
||||
self,
|
||||
source: PumpedPacketSource,
|
||||
sink: PumpedPacketSink,
|
||||
) -> None:
|
||||
super().__init__(source, sink)
|
||||
|
||||
def start(self) -> None:
|
||||
self.source.start()
|
||||
self.sink.start()
|
||||
|
||||
async def close(self):
|
||||
await super().close()
|
||||
await self.close_function()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class SnoopingTransport(Transport):
|
||||
@@ -375,31 +422,38 @@ class SnoopingTransport(Transport):
|
||||
raise RuntimeError('unexpected code path') # Satisfy the type checker
|
||||
|
||||
class Source:
|
||||
def __init__(self, source, snooper):
|
||||
sink: TransportSink
|
||||
|
||||
def __init__(self, source: TransportSource, snooper: Snooper):
|
||||
self.source = source
|
||||
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.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)
|
||||
if self.sink:
|
||||
self.sink.on_packet(packet)
|
||||
|
||||
class Sink:
|
||||
def __init__(self, sink, snooper):
|
||||
def __init__(self, sink: TransportSink, snooper: Snooper) -> None:
|
||||
self.sink = sink
|
||||
self.snooper = snooper
|
||||
|
||||
def on_packet(self, packet):
|
||||
def on_packet(self, packet: bytes) -> None:
|
||||
self.snooper.snoop(packet, Snooper.Direction.HOST_TO_CONTROLLER)
|
||||
if self.sink:
|
||||
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__(
|
||||
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
|
||||
virtual files).
|
||||
|
||||
@@ -23,6 +23,8 @@ import socket
|
||||
import ctypes
|
||||
import collections
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .common import Transport, ParserSource
|
||||
|
||||
|
||||
@@ -33,7 +35,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_hci_socket_transport(spec):
|
||||
async def open_hci_socket_transport(spec: Optional[str]) -> Transport:
|
||||
'''
|
||||
Open an HCI Socket (only available on some platforms).
|
||||
The parameter string is either empty (to use the first/default Bluetooth adapter)
|
||||
@@ -45,9 +47,9 @@ async def open_hci_socket_transport(spec):
|
||||
# Create a raw HCI socket
|
||||
try:
|
||||
hci_socket = socket.socket(
|
||||
socket.AF_BLUETOOTH,
|
||||
socket.SOCK_RAW | socket.SOCK_NONBLOCK,
|
||||
socket.BTPROTO_HCI,
|
||||
socket.AF_BLUETOOTH, # type: ignore[attr-defined]
|
||||
socket.SOCK_RAW | socket.SOCK_NONBLOCK, # type: ignore[attr-defined]
|
||||
socket.BTPROTO_HCI, # type: ignore[attr-defined]
|
||||
)
|
||||
except AttributeError as error:
|
||||
# Not supported on this platform
|
||||
@@ -78,7 +80,7 @@ async def open_hci_socket_transport(spec):
|
||||
bind_address = struct.pack(
|
||||
# pylint: disable=no-member
|
||||
'<HHH',
|
||||
socket.AF_BLUETOOTH,
|
||||
socket.AF_BLUETOOTH, # type: ignore[attr-defined]
|
||||
adapter_index,
|
||||
HCI_CHANNEL_USER,
|
||||
)
|
||||
|
||||
@@ -23,6 +23,8 @@ import atexit
|
||||
import os
|
||||
import logging
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .common import Transport, StreamPacketSource, StreamPacketSink
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -32,7 +34,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_pty_transport(spec):
|
||||
async def open_pty_transport(spec: Optional[str]) -> Transport:
|
||||
'''
|
||||
Open a PTY transport.
|
||||
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]
|
||||
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.
|
||||
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.
|
||||
The parameter string has this syntax:
|
||||
@@ -39,7 +39,7 @@ async def open_tcp_client_transport(spec):
|
||||
class TcpPacketSource(StreamPacketSource):
|
||||
def connection_lost(self, exc):
|
||||
logger.debug(f'connection lost: {exc}')
|
||||
self.terminated.set_result(exc)
|
||||
self.on_transport_lost()
|
||||
|
||||
remote_host, remote_port = spec.split(':')
|
||||
tcp_transport, packet_source = await asyncio.get_running_loop().create_connection(
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
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.
|
||||
The parameter string has this syntax:
|
||||
@@ -42,7 +43,7 @@ async def open_tcp_server_transport(spec):
|
||||
async def close(self):
|
||||
await super().close()
|
||||
|
||||
class TcpServerProtocol:
|
||||
class TcpServerProtocol(asyncio.BaseProtocol):
|
||||
def __init__(self, packet_source, packet_sink):
|
||||
self.packet_source = packet_source
|
||||
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.
|
||||
The parameter string has this syntax:
|
||||
|
||||
@@ -60,7 +60,7 @@ def load_libusb():
|
||||
usb1.loadLibrary(libusb_dll)
|
||||
|
||||
|
||||
async def open_usb_transport(spec):
|
||||
async def open_usb_transport(spec: str) -> Transport:
|
||||
'''
|
||||
Open a USB transport.
|
||||
The moniker string has this syntax:
|
||||
@@ -206,10 +206,11 @@ async def open_usb_transport(spec):
|
||||
logger.debug('OUT transfer likely already completed')
|
||||
|
||||
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__()
|
||||
self.context = context
|
||||
self.device = device
|
||||
self.metadata = metadata
|
||||
self.acl_in = acl_in
|
||||
self.events_in = events_in
|
||||
self.loop = asyncio.get_running_loop()
|
||||
@@ -510,6 +511,10 @@ async def open_usb_transport(spec):
|
||||
f'events_in=0x{events_in:02X}, '
|
||||
)
|
||||
|
||||
device_metadata = {
|
||||
'vendor_id': found.getVendorID(),
|
||||
'product_id': found.getProductID(),
|
||||
}
|
||||
device = found.open()
|
||||
|
||||
# Auto-detach the kernel driver if supported
|
||||
@@ -535,7 +540,7 @@ async def open_usb_transport(spec):
|
||||
except usb1.USBError:
|
||||
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)
|
||||
return UsbTransport(context, device, interface, setting, source, sink)
|
||||
except usb1.USBError as error:
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .common import Transport
|
||||
from .file import open_file_transport
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -26,7 +29,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_vhci_transport(spec):
|
||||
async def open_vhci_transport(spec: Optional[str]) -> Transport:
|
||||
'''
|
||||
Open a VHCI transport (only available on some platforms).
|
||||
The parameter string is either empty (to use the default VHCI device
|
||||
@@ -42,15 +45,15 @@ async def open_vhci_transport(spec):
|
||||
# Override the source's `data_received` method so that we can
|
||||
# filter out the vendor packet that is received just after the
|
||||
# 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) == 4:
|
||||
hci_index = data[2] << 8 | data[3]
|
||||
logger.info(f'HCI index {hci_index}')
|
||||
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
|
||||
transport.sink.on_packet(bytes([HCI_VENDOR_PKT, HCI_BREDR]))
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
import websockets
|
||||
import websockets.client
|
||||
|
||||
from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport
|
||||
from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport, Transport
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -27,23 +27,25 @@ 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.
|
||||
The parameter string has this syntax:
|
||||
<remote-host>:<remote-port>
|
||||
<websocket-url>
|
||||
|
||||
Example: 127.0.0.1:9001
|
||||
Example: ws://localhost:7681/v1/websocket/bt
|
||||
'''
|
||||
|
||||
remote_host, remote_port = spec.split(':')
|
||||
uri = f'ws://{remote_host}:{remote_port}'
|
||||
websocket = await websockets.connect(uri)
|
||||
websocket = await websockets.client.connect(spec)
|
||||
|
||||
transport = PumpedTransport(
|
||||
class WsTransport(PumpedTransport):
|
||||
async def close(self):
|
||||
await super().close()
|
||||
await websocket.close()
|
||||
|
||||
transport = WsTransport(
|
||||
PumpedPacketSource(websocket.recv),
|
||||
PumpedPacketSink(websocket.send),
|
||||
websocket.close,
|
||||
)
|
||||
transport.start()
|
||||
return transport
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import logging
|
||||
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.
|
||||
The parameter string has this syntax:
|
||||
@@ -43,7 +42,7 @@ async def open_ws_server_transport(spec):
|
||||
def __init__(self):
|
||||
source = ParserSource()
|
||||
sink = PumpedPacketSink(self.send_packet)
|
||||
self.connection = asyncio.get_running_loop().create_future()
|
||||
self.connection = None
|
||||
self.server = None
|
||||
|
||||
super().__init__(source, sink)
|
||||
@@ -63,7 +62,7 @@ async def open_ws_server_transport(spec):
|
||||
f'new connection on {connection.local_address} '
|
||||
f'from {connection.remote_address}'
|
||||
)
|
||||
self.connection.set_result(connection)
|
||||
self.connection = connection
|
||||
# pylint: disable=no-member
|
||||
try:
|
||||
async for packet in connection:
|
||||
@@ -74,12 +73,14 @@ async def open_ws_server_transport(spec):
|
||||
except websockets.WebSocketException as error:
|
||||
logger.debug(f'exception while receiving packet: {error}')
|
||||
|
||||
# Wait for a new connection
|
||||
self.connection = asyncio.get_running_loop().create_future()
|
||||
# We're now disconnected
|
||||
self.connection = None
|
||||
|
||||
async def send_packet(self, packet):
|
||||
connection = await self.connection
|
||||
return await connection.send(packet)
|
||||
if self.connection is None:
|
||||
logger.debug('no connection, dropping packet')
|
||||
return
|
||||
return await self.connection.send(packet)
|
||||
|
||||
local_host, local_port = spec.split(':')
|
||||
transport = WsServerTransport()
|
||||
|
||||
129
bumble/utils.py
129
bumble/utils.py
@@ -15,13 +15,25 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
import collections
|
||||
import sys
|
||||
from typing import Awaitable, Set, TypeVar
|
||||
from functools import wraps
|
||||
from typing import (
|
||||
Awaitable,
|
||||
Set,
|
||||
TypeVar,
|
||||
List,
|
||||
Tuple,
|
||||
Callable,
|
||||
Any,
|
||||
Optional,
|
||||
Union,
|
||||
overload,
|
||||
)
|
||||
from functools import wraps, partial
|
||||
from pyee import EventEmitter
|
||||
|
||||
from .colors import color
|
||||
@@ -64,6 +76,102 @@ def composite_listener(cls):
|
||||
return cls
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
_Handler = TypeVar('_Handler', bound=Callable)
|
||||
|
||||
|
||||
class EventWatcher:
|
||||
'''A wrapper class to control the lifecycle of event handlers better.
|
||||
|
||||
Usage:
|
||||
```
|
||||
watcher = EventWatcher()
|
||||
|
||||
def on_foo():
|
||||
...
|
||||
watcher.on(emitter, 'foo', on_foo)
|
||||
|
||||
@watcher.on(emitter, 'bar')
|
||||
def on_bar():
|
||||
...
|
||||
|
||||
# Close all event handlers watching through this watcher
|
||||
watcher.close()
|
||||
```
|
||||
|
||||
As context:
|
||||
```
|
||||
with contextlib.closing(EventWatcher()) as context:
|
||||
@context.on(emitter, 'foo')
|
||||
def on_foo():
|
||||
...
|
||||
# on_foo() has been removed here!
|
||||
```
|
||||
'''
|
||||
|
||||
handlers: List[Tuple[EventEmitter, str, Callable[..., Any]]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.handlers = []
|
||||
|
||||
@overload
|
||||
def on(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]:
|
||||
...
|
||||
|
||||
@overload
|
||||
def on(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler:
|
||||
...
|
||||
|
||||
def on(
|
||||
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
|
||||
) -> Union[_Handler, Callable[[_Handler], _Handler]]:
|
||||
'''Watch an event until the context is closed.
|
||||
|
||||
Args:
|
||||
emitter: EventEmitter to watch
|
||||
event: Event name
|
||||
handler: (Optional) Event handler. When nothing is passed, this method works as a decorator.
|
||||
'''
|
||||
|
||||
def wrapper(f: _Handler) -> _Handler:
|
||||
self.handlers.append((emitter, event, f))
|
||||
emitter.on(event, f)
|
||||
return f
|
||||
|
||||
return wrapper if handler is None else wrapper(handler)
|
||||
|
||||
@overload
|
||||
def once(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]:
|
||||
...
|
||||
|
||||
@overload
|
||||
def once(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler:
|
||||
...
|
||||
|
||||
def once(
|
||||
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
|
||||
) -> Union[_Handler, Callable[[_Handler], _Handler]]:
|
||||
'''Watch an event for once.
|
||||
|
||||
Args:
|
||||
emitter: EventEmitter to watch
|
||||
event: Event name
|
||||
handler: (Optional) Event handler. When nothing passed, this method works as a decorator.
|
||||
'''
|
||||
|
||||
def wrapper(f: _Handler) -> _Handler:
|
||||
self.handlers.append((emitter, event, f))
|
||||
emitter.once(event, f)
|
||||
return f
|
||||
|
||||
return wrapper if handler is None else wrapper(handler)
|
||||
|
||||
def close(self) -> None:
|
||||
for emitter, event, handler in self.handlers:
|
||||
if handler in emitter.listeners(event):
|
||||
emitter.remove_listener(event, handler)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
_T = TypeVar('_T')
|
||||
|
||||
@@ -302,3 +410,20 @@ class FlowControlAsyncPipe:
|
||||
self.resume_source()
|
||||
|
||||
self.check_pump()
|
||||
|
||||
|
||||
async def async_call(function, *args, **kwargs):
|
||||
"""
|
||||
Immediately calls the function with provided args and kwargs, wrapping it in an async function.
|
||||
Rust's `pyo3_asyncio` library needs functions to be marked async to properly inject a running loop.
|
||||
|
||||
result = await async_call(some_function, ...)
|
||||
"""
|
||||
return function(*args, **kwargs)
|
||||
|
||||
|
||||
def wrap_async(function):
|
||||
"""
|
||||
Wraps the provided function in an async function.
|
||||
"""
|
||||
return partial(async_call, function)
|
||||
|
||||
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
|
||||
- Android Emulator: transports/android_emulator.md
|
||||
- File: transports/file.md
|
||||
- Drivers:
|
||||
- Overview: drivers/index.md
|
||||
- Realtek: drivers/realtek.md
|
||||
- API:
|
||||
- Guide: api/guide.md
|
||||
- Examples: api/examples.md
|
||||
@@ -61,6 +64,7 @@ nav:
|
||||
- Linux: platforms/linux.md
|
||||
- Windows: platforms/windows.md
|
||||
- Android: platforms/android.md
|
||||
- Zephyr: platforms/zephyr.md
|
||||
- Examples:
|
||||
- Overview: examples/index.md
|
||||
|
||||
|
||||
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.
|
||||
```
|
||||
@@ -9,3 +9,4 @@ For platform-specific information, see the following pages:
|
||||
* :material-linux: Linux - see the [Linux platform page](linux.md)
|
||||
* :material-microsoft-windows: Windows - see the [Windows platform page](windows.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
|
||||
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
|
||||
`udp:0.0.0.0:9000,127.0.0.1:9001`
|
||||
UDP transport where packets are received on port `9000` and sent to `127.0.0.1` on port `9001`
|
||||
`ws-client:ws://localhost:1234/some/path`
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`udp:0.0.0.0:9000,127.0.0.1:9001`
|
||||
UDP transport where packets are received on port `9000` and sent to `127.0.0.1` on port `9001`
|
||||
`ws-server:_:9001`
|
||||
|
||||
@@ -3,7 +3,7 @@ channels:
|
||||
- defaults
|
||||
- conda-forge
|
||||
dependencies:
|
||||
- pip=20
|
||||
- pip=23
|
||||
- python=3.8
|
||||
- pip:
|
||||
- --editable .[development,documentation,test]
|
||||
|
||||
@@ -30,7 +30,7 @@ from bumble.core import (
|
||||
BT_RFCOMM_PROTOCOL_ID,
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
)
|
||||
from bumble.rfcomm import Client
|
||||
from bumble import rfcomm, hfp
|
||||
from bumble.sdp import (
|
||||
Client as SDP_Client,
|
||||
DataElement,
|
||||
@@ -39,7 +39,9 @@ from bumble.sdp import (
|
||||
SDP_SERVICE_CLASS_ID_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
|
||||
print('@@@ Starting to RFCOMM client...')
|
||||
rfcomm_client = Client(device, connection)
|
||||
rfcomm_client = rfcomm.Client(device, connection)
|
||||
rfcomm_mux = await rfcomm_client.start()
|
||||
print('@@@ Started')
|
||||
|
||||
@@ -196,7 +198,7 @@ async def main():
|
||||
return
|
||||
|
||||
# Protocol loop (just for testing at this point)
|
||||
protocol = HfpProtocol(session)
|
||||
protocol = hfp.HfpProtocol(session)
|
||||
while True:
|
||||
line = await protocol.next_line()
|
||||
|
||||
|
||||
@@ -21,82 +21,22 @@ import os
|
||||
import logging
|
||||
import json
|
||||
import websockets
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.rfcomm import Server as RfcommServer
|
||||
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,
|
||||
)
|
||||
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),
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
from bumble import hfp
|
||||
from bumble.hfp import HfProtocol
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class UiServer:
|
||||
protocol = None
|
||||
protocol: Optional[HfProtocol] = None
|
||||
|
||||
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):
|
||||
while True:
|
||||
try:
|
||||
@@ -107,7 +47,7 @@ class UiServer:
|
||||
message_type = parsed['type']
|
||||
if message_type == 'at_command':
|
||||
if self.protocol is not None:
|
||||
self.protocol.send_command_line(parsed['command'])
|
||||
await self.protocol.execute_command(parsed['command'])
|
||||
|
||||
except websockets.exceptions.ConnectionClosedOK:
|
||||
pass
|
||||
@@ -117,19 +57,11 @@ class UiServer:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def protocol_loop(protocol):
|
||||
await protocol.initialize_service()
|
||||
|
||||
while True:
|
||||
await (protocol.next_line())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_dlc(dlc):
|
||||
def on_dlc(dlc, configuration: hfp.Configuration):
|
||||
print('*** DLC connected', dlc)
|
||||
protocol = HfpProtocol(dlc)
|
||||
protocol = HfProtocol(dlc, configuration)
|
||||
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):
|
||||
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
|
||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
||||
device.classic_enabled = True
|
||||
@@ -151,11 +104,13 @@ async def main():
|
||||
rfcomm_server = RfcommServer(device)
|
||||
|
||||
# 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}')
|
||||
|
||||
# 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!
|
||||
await device.power_on()
|
||||
|
||||
@@ -20,83 +20,109 @@ import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
from bumble.core import UUID
|
||||
from bumble.device import Device
|
||||
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.sdp import (
|
||||
DataElement,
|
||||
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,
|
||||
)
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.rfcomm import make_service_sdp_records
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def sdp_records(channel):
|
||||
def sdp_records(channel, uuid):
|
||||
service_record_handle = 0x00010001
|
||||
return {
|
||||
0x00010001: [
|
||||
ServiceAttribute(
|
||||
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),
|
||||
]
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
service_record_handle: make_service_sdp_records(
|
||||
service_record_handle, channel, UUID(uuid)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_dlc(dlc):
|
||||
print('*** DLC connected', dlc)
|
||||
dlc.sink = lambda data: on_rfcomm_data_received(dlc, data)
|
||||
def on_rfcomm_session(rfcomm_session, tcp_server):
|
||||
print('*** RFComm session connected', rfcomm_session)
|
||||
tcp_server.attach_session(rfcomm_session)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_rfcomm_data_received(dlc, data):
|
||||
print(f'<<< Data received: {data.hex()}')
|
||||
try:
|
||||
message = data.decode('utf-8')
|
||||
print(f'<<< Message = {message}')
|
||||
except Exception:
|
||||
pass
|
||||
class TcpServerProtocol(asyncio.Protocol):
|
||||
def __init__(self, server):
|
||||
self.server = server
|
||||
|
||||
# Echo everything back
|
||||
dlc.write(data)
|
||||
def connection_made(self, transport):
|
||||
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():
|
||||
if len(sys.argv) < 3:
|
||||
print('Usage: run_rfcomm_server.py <device-config> <transport-spec>')
|
||||
print('example: run_rfcomm_server.py classic2.json usb:04b4:f901')
|
||||
if len(sys.argv) < 4:
|
||||
print(
|
||||
'Usage: run_rfcomm_server.py <device-config> <transport-spec> '
|
||||
'<tcp-port> [<uuid>]'
|
||||
)
|
||||
print('example: run_rfcomm_server.py classic2.json usb:0 8888')
|
||||
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...')
|
||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
||||
print('<<< connected')
|
||||
@@ -105,15 +131,20 @@ async def main():
|
||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
||||
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)
|
||||
|
||||
# Listen for incoming DLC connections
|
||||
channel_number = rfcomm_server.listen(on_dlc)
|
||||
print(f'### Listening for connection on channel {channel_number}')
|
||||
channel_number = rfcomm_server.listen(
|
||||
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
|
||||
device.sdp_service_records = sdp_records(channel_number)
|
||||
device.sdp_service_records = sdp_records(channel_number, uuid)
|
||||
|
||||
# Start the controller
|
||||
await device.power_on()
|
||||
|
||||
@@ -62,7 +62,7 @@ async def main():
|
||||
print(
|
||||
f'>>> {color(advertisement.address, address_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'{advertisement.data.to_string(separator)}'
|
||||
)
|
||||
|
||||
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
|
||||
2201
rust/Cargo.lock
generated
Normal file
2201
rust/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
89
rust/Cargo.toml
Normal file
89
rust/Cargo.toml
Normal file
@@ -0,0 +1,89 @@
|
||||
[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"
|
||||
bytes = "1.5.0"
|
||||
pdl-derive = "0.2.0"
|
||||
pdl-runtime = "0.2.0"
|
||||
|
||||
# Dev tools
|
||||
file-header = { version = "0.1.2", optional = true }
|
||||
globset = { version = "0.4.13", optional = true }
|
||||
|
||||
# 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 = "file-header"
|
||||
path = "tools/file_header.rs"
|
||||
required-features = ["dev-tools"]
|
||||
|
||||
[[bin]]
|
||||
name = "gen-assigned-numbers"
|
||||
path = "tools/gen_assigned_numbers.rs"
|
||||
required-features = ["dev-tools"]
|
||||
|
||||
[[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"]
|
||||
dev-tools = ["dep:anyhow", "dep:clap", "dep:file-header", "dep:globset"]
|
||||
# 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 dev-tools
|
||||
```
|
||||
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,
|
||||
}
|
||||
188
rust/examples/scanner.rs
Normal file
188
rust/examples/scanner.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
// 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::packets::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::PublicIdentityAddress | AddressType::PublicDeviceAddress => {
|
||||
(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,
|
||||
}
|
||||
44
rust/pytests/assigned_numbers.rs
Normal file
44
rust/pytests/assigned_numbers.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
// 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::{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;
|
||||
77
rust/pytests/wrapper.rs
Normal file
77
rust/pytests/wrapper.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
// 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::{
|
||||
controller::Controller,
|
||||
device::Device,
|
||||
drivers::rtk::DriverInfo,
|
||||
hci::{
|
||||
packets::{
|
||||
AddressType, ErrorCode, ReadLocalVersionInformationBuilder,
|
||||
ReadLocalVersionInformationComplete,
|
||||
},
|
||||
Address, Error,
|
||||
},
|
||||
host::Host,
|
||||
link::Link,
|
||||
transport::Transport,
|
||||
};
|
||||
use nix::sys::stat::Mode;
|
||||
use pyo3::{
|
||||
exceptions::PyException,
|
||||
{PyErr, 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(())
|
||||
}
|
||||
|
||||
#[pyo3_asyncio::tokio::test]
|
||||
async fn hci_command_wrapper_has_correct_methods() -> PyResult<()> {
|
||||
let address = Address::new("F0:F1:F2:F3:F4:F5", &AddressType::RandomDeviceAddress)?;
|
||||
let link = Link::new_local_link()?;
|
||||
let controller = Controller::new("C1", None, None, Some(link), Some(address.clone())).await?;
|
||||
let host = Host::new(controller.clone().into(), controller.into()).await?;
|
||||
let device = Device::new(None, Some(address), None, Some(host), None)?;
|
||||
|
||||
device.power_on().await?;
|
||||
|
||||
// Send some simple command. A successful response means [HciCommandWrapper] has the minimum
|
||||
// required interface for the Python code to think its an [HCI_Command] object.
|
||||
let command = ReadLocalVersionInformationBuilder {};
|
||||
let event: ReadLocalVersionInformationComplete = device
|
||||
.send_command(&command.into(), true)
|
||||
.await?
|
||||
.try_into()
|
||||
.map_err(|e: Error| PyErr::new::<PyException, _>(e.to_string()))?;
|
||||
|
||||
assert_eq!(ErrorCode::Success, event.get_status());
|
||||
Ok(())
|
||||
}
|
||||
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.
460
rust/src/adv.rs
Normal file
460
rust/src/adv.rs
Normal file
@@ -0,0 +1,460 @@
|
||||
// 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.
|
||||
|
||||
//! 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()?).await?;
|
||||
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()?).await?;
|
||||
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()?).await?;
|
||||
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())
|
||||
}
|
||||
}
|
||||
192
rust/src/cli/l2cap/client_bridge.rs
Normal file
192
rust/src/cli/l2cap/client_bridge.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
// 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::{
|
||||
inject_py_event_loop, proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, 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));
|
||||
// spawn thread to handle incoming tcp connections
|
||||
tokio::spawn(inject_py_event_loop(async move {
|
||||
while let Ok((tcp_stream, addr)) = listener.accept().await {
|
||||
let ble_connection = ble_connection.clone();
|
||||
// spawn thread to handle this specific tcp connection
|
||||
if let Ok(future) = inject_py_event_loop(proxy_data_between_tcp_and_l2cap(
|
||||
ble_connection,
|
||||
tcp_stream,
|
||||
addr,
|
||||
psm,
|
||||
max_credits,
|
||||
mtu,
|
||||
mps,
|
||||
)) {
|
||||
tokio::spawn(future);
|
||||
}
|
||||
}
|
||||
})?);
|
||||
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(())
|
||||
}
|
||||
181
rust/src/cli/l2cap/mod.rs
Normal file
181
rust/src/cli/l2cap/mod.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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::{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 Python even loop (contained in `TaskLocals`) into the given future.
|
||||
/// Useful when sending work to another thread that calls Python code which calls `get_running_loop()`.
|
||||
pub fn inject_py_event_loop<F, R>(fut: F) -> PyResult<impl Future<Output = R>>
|
||||
where
|
||||
F: Future<Output = R> + Send + 'static,
|
||||
{
|
||||
let locals = Python::with_gil(pyo3_asyncio::tokio::get_current_locals)?;
|
||||
Ok(pyo3_asyncio::tokio::scope(locals, fut))
|
||||
}
|
||||
202
rust/src/cli/l2cap/server_bridge.rs
Normal file
202
rust/src/cli/l2cap/server_bridge.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
// 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, 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();
|
||||
// Handles setting up a tokio runtime that runs this future to completion while also
|
||||
// containing the necessary context vars.
|
||||
pyo3_asyncio::tokio::future_into_py(
|
||||
py,
|
||||
proxy_data_between_l2cap_and_tcp(l2cap_channel, host, port),
|
||||
)?;
|
||||
Ok(())
|
||||
},
|
||||
args.max_credits,
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
17
rust/src/internal/drivers/mod.rs
Normal file
17
rust/src/internal/drivers/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
// 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.
|
||||
|
||||
//! Device drivers
|
||||
|
||||
pub(crate) mod rtk;
|
||||
253
rust/src/internal/drivers/rtk.rs
Normal file
253
rust/src/internal/drivers/rtk.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
// 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.
|
||||
|
||||
//! Drivers for Realtek controllers
|
||||
|
||||
use nom::{bytes, combinator, error, multi, number, sequence};
|
||||
|
||||
/// Realtek firmware file contents
|
||||
pub struct Firmware {
|
||||
version: u32,
|
||||
project_id: u8,
|
||||
patches: Vec<Patch>,
|
||||
}
|
||||
|
||||
impl Firmware {
|
||||
/// Parse a `*_fw.bin` file
|
||||
pub fn parse(input: &[u8]) -> Result<Self, nom::Err<error::Error<&[u8]>>> {
|
||||
let extension_sig = [0x51, 0x04, 0xFD, 0x77];
|
||||
|
||||
let (_rem, (_tag, fw_version, patch_count, payload)) =
|
||||
combinator::all_consuming(combinator::map_parser(
|
||||
// ignore the sig suffix
|
||||
sequence::terminated(
|
||||
bytes::complete::take(
|
||||
// underflow will show up as parse failure
|
||||
input.len().saturating_sub(extension_sig.len()),
|
||||
),
|
||||
bytes::complete::tag(extension_sig.as_slice()),
|
||||
),
|
||||
sequence::tuple((
|
||||
bytes::complete::tag(b"Realtech"),
|
||||
// version
|
||||
number::complete::le_u32,
|
||||
// patch count
|
||||
combinator::map(number::complete::le_u16, |c| c as usize),
|
||||
// everything else except suffix
|
||||
combinator::rest,
|
||||
)),
|
||||
))(input)?;
|
||||
|
||||
// ignore remaining input, since patch offsets are relative to the complete input
|
||||
let (_rem, (chip_ids, patch_lengths, patch_offsets)) = sequence::tuple((
|
||||
// chip id
|
||||
multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
|
||||
// patch length
|
||||
multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
|
||||
// patch offset
|
||||
multi::many_m_n(patch_count, patch_count, number::complete::le_u32),
|
||||
))(payload)?;
|
||||
|
||||
let patches = chip_ids
|
||||
.into_iter()
|
||||
.zip(patch_lengths.into_iter())
|
||||
.zip(patch_offsets.into_iter())
|
||||
.map(|((chip_id, patch_length), patch_offset)| {
|
||||
combinator::map(
|
||||
sequence::preceded(
|
||||
bytes::complete::take(patch_offset),
|
||||
// ignore trailing 4-byte suffix
|
||||
sequence::terminated(
|
||||
// patch including svn version, but not suffix
|
||||
combinator::consumed(sequence::preceded(
|
||||
// patch before svn version or version suffix
|
||||
// prefix length underflow will show up as parse failure
|
||||
bytes::complete::take(patch_length.saturating_sub(8)),
|
||||
// svn version
|
||||
number::complete::le_u32,
|
||||
)),
|
||||
// dummy suffix, overwritten with firmware version
|
||||
bytes::complete::take(4_usize),
|
||||
),
|
||||
),
|
||||
|(patch_contents_before_version, svn_version): (&[u8], u32)| {
|
||||
let mut contents = patch_contents_before_version.to_vec();
|
||||
// replace what would have been the trailing dummy suffix with fw version
|
||||
contents.extend_from_slice(&fw_version.to_le_bytes());
|
||||
|
||||
Patch {
|
||||
contents,
|
||||
svn_version,
|
||||
chip_id,
|
||||
}
|
||||
},
|
||||
)(input)
|
||||
.map(|(_rem, output)| output)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
// look for project id from the end
|
||||
let mut offset = payload.len();
|
||||
let mut project_id: Option<u8> = None;
|
||||
while offset >= 2 {
|
||||
// Won't panic, since offset >= 2
|
||||
let chunk = &payload[offset - 2..offset];
|
||||
let length: usize = chunk[0].into();
|
||||
let opcode = chunk[1];
|
||||
offset -= 2;
|
||||
|
||||
if opcode == 0xFF {
|
||||
break;
|
||||
}
|
||||
if length == 0 {
|
||||
// report what nom likely would have done, if nom was good at parsing backwards
|
||||
return Err(nom::Err::Error(error::Error::new(
|
||||
chunk,
|
||||
error::ErrorKind::Verify,
|
||||
)));
|
||||
}
|
||||
if opcode == 0 && length == 1 {
|
||||
project_id = offset
|
||||
.checked_sub(1)
|
||||
.and_then(|index| payload.get(index))
|
||||
.copied();
|
||||
break;
|
||||
}
|
||||
|
||||
offset -= length;
|
||||
}
|
||||
|
||||
match project_id {
|
||||
Some(project_id) => Ok(Firmware {
|
||||
project_id,
|
||||
version: fw_version,
|
||||
patches,
|
||||
}),
|
||||
None => {
|
||||
// we ran out of file without finding a project id
|
||||
Err(nom::Err::Error(error::Error::new(
|
||||
payload,
|
||||
error::ErrorKind::Eof,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch version
|
||||
pub fn version(&self) -> u32 {
|
||||
self.version
|
||||
}
|
||||
|
||||
/// Project id
|
||||
pub fn project_id(&self) -> u8 {
|
||||
self.project_id
|
||||
}
|
||||
|
||||
/// Patches
|
||||
pub fn patches(&self) -> &[Patch] {
|
||||
&self.patches
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch in a [Firmware}
|
||||
pub struct Patch {
|
||||
chip_id: u16,
|
||||
contents: Vec<u8>,
|
||||
svn_version: u32,
|
||||
}
|
||||
|
||||
impl Patch {
|
||||
/// Chip id
|
||||
pub fn chip_id(&self) -> u16 {
|
||||
self.chip_id
|
||||
}
|
||||
/// Contents of the patch, including the 4-byte firmware version suffix
|
||||
pub fn contents(&self) -> &[u8] {
|
||||
&self.contents
|
||||
}
|
||||
/// SVN version
|
||||
pub fn svn_version(&self) -> u32 {
|
||||
self.svn_version
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::anyhow;
|
||||
use std::{fs, io, path};
|
||||
|
||||
#[test]
|
||||
fn parse_firmware_rtl8723b() -> anyhow::Result<()> {
|
||||
let fw = Firmware::parse(&firmware_contents("rtl8723b_fw_structure.bin")?)
|
||||
.map_err(|e| anyhow!("{:?}", e))?;
|
||||
|
||||
let fw_version = 0x0E2F9F73;
|
||||
assert_eq!(fw_version, fw.version());
|
||||
assert_eq!(0x0001, fw.project_id());
|
||||
assert_eq!(
|
||||
vec![(0x0001, 0x00002BBF, 22368,), (0x0002, 0x00002BBF, 22496,),],
|
||||
patch_summaries(fw, fw_version)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_firmware_rtl8761bu() -> anyhow::Result<()> {
|
||||
let fw = Firmware::parse(&firmware_contents("rtl8761bu_fw_structure.bin")?)
|
||||
.map_err(|e| anyhow!("{:?}", e))?;
|
||||
|
||||
let fw_version = 0xDFC6D922;
|
||||
assert_eq!(fw_version, fw.version());
|
||||
assert_eq!(0x000E, fw.project_id());
|
||||
assert_eq!(
|
||||
vec![(0x0001, 0x00005060, 14048,), (0x0002, 0xD6D525A4, 30204,),],
|
||||
patch_summaries(fw, fw_version)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn firmware_contents(filename: &str) -> io::Result<Vec<u8>> {
|
||||
fs::read(
|
||||
path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources/test/firmware/realtek")
|
||||
.join(filename),
|
||||
)
|
||||
}
|
||||
|
||||
/// Return a tuple of (chip id, svn version, contents len, contents sha256)
|
||||
fn patch_summaries(fw: Firmware, fw_version: u32) -> Vec<(u16, u32, usize)> {
|
||||
fw.patches()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let contents = p.contents();
|
||||
let mut dummy_contents = dummy_contents(contents.len());
|
||||
dummy_contents.extend_from_slice(&p.svn_version().to_le_bytes());
|
||||
dummy_contents.extend_from_slice(&fw_version.to_le_bytes());
|
||||
assert_eq!(&dummy_contents, contents);
|
||||
(p.chip_id(), p.svn_version(), contents.len())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn dummy_contents(len: usize) -> Vec<u8> {
|
||||
let mut vec = (len as u32).to_le_bytes().as_slice().repeat(len / 4 + 1);
|
||||
assert!(vec.len() >= len);
|
||||
// leave room for svn version and firmware version
|
||||
vec.truncate(len - 8);
|
||||
vec
|
||||
}
|
||||
}
|
||||
161
rust/src/internal/hci/mod.rs
Normal file
161
rust/src/internal/hci/mod.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
// Copyright 2023 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
pub use pdl_runtime::{Error, Packet};
|
||||
|
||||
use crate::internal::hci::packets::{Acl, Command, Event, Sco};
|
||||
use pdl_derive::pdl;
|
||||
|
||||
#[allow(missing_docs, warnings, clippy::all)]
|
||||
#[pdl("src/internal/hci/packets.pdl")]
|
||||
pub mod packets {}
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// HCI Packet type, prepended to the packet.
|
||||
/// Rootcanal's PDL declaration excludes this from ser/deser and instead is implemented in code.
|
||||
/// To maintain the ability to easily use future versions of their packet PDL, packet type is
|
||||
/// implemented here.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) enum PacketType {
|
||||
Command = 0x01,
|
||||
Acl = 0x02,
|
||||
Sco = 0x03,
|
||||
Event = 0x04,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for PacketType {
|
||||
type Error = PacketTypeParseError;
|
||||
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0x01 => Ok(PacketType::Command),
|
||||
0x02 => Ok(PacketType::Acl),
|
||||
0x03 => Ok(PacketType::Sco),
|
||||
0x04 => Ok(PacketType::Event),
|
||||
_ => Err(PacketTypeParseError::InvalidPacketType { value }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PacketType> for u8 {
|
||||
fn from(packet_type: PacketType) -> Self {
|
||||
match packet_type {
|
||||
PacketType::Command => 0x01,
|
||||
PacketType::Acl => 0x02,
|
||||
PacketType::Sco => 0x03,
|
||||
PacketType::Event => 0x04,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows for smoother interoperability between a [Packet] and a bytes representation of it that
|
||||
/// includes its type as a header
|
||||
pub(crate) trait WithPacketType<T: Packet> {
|
||||
/// Converts the [Packet] into bytes, prefixed with its type
|
||||
fn to_vec_with_packet_type(self) -> Vec<u8>;
|
||||
|
||||
/// Parses a [Packet] out of bytes that are prefixed with the packet's type
|
||||
fn parse_with_packet_type(bytes: &[u8]) -> Result<T, PacketTypeParseError>;
|
||||
}
|
||||
|
||||
/// Errors that may arise when parsing a packet that is prefixed with its type
|
||||
#[derive(Debug, PartialEq, thiserror::Error)]
|
||||
pub(crate) enum PacketTypeParseError {
|
||||
#[error("The slice being parsed was empty")]
|
||||
EmptySlice,
|
||||
#[error("Packet type ({value:#X}) is invalid")]
|
||||
InvalidPacketType { value: u8 },
|
||||
#[error("Expected packet type: {expected:?}, but got: {actual:?}")]
|
||||
PacketTypeMismatch {
|
||||
expected: PacketType,
|
||||
actual: PacketType,
|
||||
},
|
||||
#[error("Failed to parse packet after header: {error}")]
|
||||
PacketParse { error: Error },
|
||||
}
|
||||
|
||||
impl From<Error> for PacketTypeParseError {
|
||||
fn from(error: Error) -> Self {
|
||||
Self::PacketParse { error }
|
||||
}
|
||||
}
|
||||
|
||||
impl WithPacketType<Self> for Command {
|
||||
fn to_vec_with_packet_type(self) -> Vec<u8> {
|
||||
prepend_packet_type(PacketType::Command, self.to_vec())
|
||||
}
|
||||
|
||||
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
|
||||
parse_with_expected_packet_type(Command::parse, PacketType::Command, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl WithPacketType<Self> for Acl {
|
||||
fn to_vec_with_packet_type(self) -> Vec<u8> {
|
||||
prepend_packet_type(PacketType::Acl, self.to_vec())
|
||||
}
|
||||
|
||||
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
|
||||
parse_with_expected_packet_type(Acl::parse, PacketType::Acl, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl WithPacketType<Self> for Sco {
|
||||
fn to_vec_with_packet_type(self) -> Vec<u8> {
|
||||
prepend_packet_type(PacketType::Sco, self.to_vec())
|
||||
}
|
||||
|
||||
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
|
||||
parse_with_expected_packet_type(Sco::parse, PacketType::Sco, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl WithPacketType<Self> for Event {
|
||||
fn to_vec_with_packet_type(self) -> Vec<u8> {
|
||||
prepend_packet_type(PacketType::Event, self.to_vec())
|
||||
}
|
||||
|
||||
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
|
||||
parse_with_expected_packet_type(Event::parse, PacketType::Event, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn prepend_packet_type(packet_type: PacketType, mut packet_bytes: Vec<u8>) -> Vec<u8> {
|
||||
packet_bytes.insert(0, packet_type.into());
|
||||
packet_bytes
|
||||
}
|
||||
|
||||
fn parse_with_expected_packet_type<T: Packet, F, E>(
|
||||
parser: F,
|
||||
expected_packet_type: PacketType,
|
||||
bytes: &[u8],
|
||||
) -> Result<T, PacketTypeParseError>
|
||||
where
|
||||
F: Fn(&[u8]) -> Result<T, E>,
|
||||
PacketTypeParseError: From<E>,
|
||||
{
|
||||
let (first_byte, packet_bytes) = bytes
|
||||
.split_first()
|
||||
.ok_or(PacketTypeParseError::EmptySlice)?;
|
||||
let actual_packet_type = PacketType::try_from(*first_byte)?;
|
||||
if actual_packet_type == expected_packet_type {
|
||||
Ok(parser(packet_bytes)?)
|
||||
} else {
|
||||
Err(PacketTypeParseError::PacketTypeMismatch {
|
||||
expected: expected_packet_type,
|
||||
actual: actual_packet_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
6253
rust/src/internal/hci/packets.pdl
Normal file
6253
rust/src/internal/hci/packets.pdl
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user