forked from auracaster/bumble_mirror
Compare commits
239 Commits
gbg/hci-ob
...
gbg/androi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8400ff0802 | ||
|
|
0ed6aa230b | ||
|
|
72d5360af9 | ||
|
|
ac3961e763 | ||
|
|
8385035400 | ||
|
|
247cb89332 | ||
|
|
3fc71a0266 | ||
|
|
392dcc3a05 | ||
|
|
f27015d1b7 | ||
|
|
86a19b41aa | ||
|
|
320164d476 | ||
|
|
40ae661ee5 | ||
|
|
c5def93bb8 | ||
|
|
a9c4c5833d | ||
|
|
58c9c4f590 | ||
|
|
24524d88cb | ||
|
|
b8849ab311 | ||
|
|
f3cd8f8ed0 | ||
|
|
2b26de3f3a | ||
|
|
0149c4c212 | ||
|
|
f2ed898784 | ||
|
|
464a476f9f | ||
|
|
e85d067fb5 | ||
|
|
04d5bf3afc | ||
|
|
a13e193d3b | ||
|
|
28a1a5ebc2 | ||
|
|
6310dc777f | ||
|
|
863de18877 | ||
|
|
f0e5cdee1a | ||
|
|
7bc7d0f5af | ||
|
|
a65a215fd7 | ||
|
|
80d34a226d | ||
|
|
a9628f73e3 | ||
|
|
9bf2e03354 | ||
|
|
2900b93bb3 | ||
|
|
284cc8a321 | ||
|
|
3dc2e4036c | ||
|
|
268f6b0d51 | ||
|
|
46239b321b | ||
|
|
8a536cd522 | ||
|
|
f9f5d7ccbd | ||
|
|
e08c84dd20 | ||
|
|
8b46136703 | ||
|
|
9c7089c8ff | ||
|
|
aac8d89cd0 | ||
|
|
24e75bfeab | ||
|
|
42868b08d3 | ||
|
|
19b61d9ac0 | ||
|
|
db2a2e2bb9 | ||
|
|
e1fdb12647 | ||
|
|
a8ec1b0949 | ||
|
|
2e30b2de77 | ||
|
|
7e407ccae1 | ||
|
|
0667e83919 | ||
|
|
1a6c9a4d04 | ||
|
|
14f5b912ad | ||
|
|
46d6242171 | ||
|
|
753b966148 | ||
|
|
5a307c19b8 | ||
|
|
2cd4f84800 | ||
|
|
4ae612090b | ||
|
|
c67ca4a09e | ||
|
|
94506220d3 | ||
|
|
dbd865a484 | ||
|
|
9d2f3e932a | ||
|
|
49d32f5b5b | ||
|
|
f7b74c0bcb | ||
|
|
c75cb0c7b7 | ||
|
|
a63b335149 | ||
|
|
d8517ce407 | ||
|
|
ad13b11464 | ||
|
|
99bc92d53d | ||
|
|
72199f5615 | ||
|
|
78b8b50082 | ||
|
|
3ab64ce00d | ||
|
|
651e44e0b6 | ||
|
|
963fa41a49 | ||
|
|
493f4f8b95 | ||
|
|
fc1bf36ace | ||
|
|
5ddee17411 | ||
|
|
5ce353bcde | ||
|
|
16d33199eb | ||
|
|
e02303a448 | ||
|
|
36fc966ad6 | ||
|
|
644f74400d | ||
|
|
b7cd451ddb | ||
|
|
59d7717963 | ||
|
|
88392efca4 | ||
|
|
907f2acc7e | ||
|
|
6616477bcf | ||
|
|
5b173cb879 | ||
|
|
dc6b466a42 | ||
|
|
8b04161da3 | ||
|
|
5a85765360 | ||
|
|
333940919b | ||
|
|
b9476be9ad | ||
|
|
704c60491c | ||
|
|
4a8e612c6e | ||
|
|
5e5c9c2580 | ||
|
|
4e71ec5738 | ||
|
|
1004f10384 | ||
|
|
1051648ffb | ||
|
|
7255a09705 | ||
|
|
c2bf6b5f13 | ||
|
|
d8e699b588 | ||
|
|
3e4d4705f5 | ||
|
|
c8b2804446 | ||
|
|
e732f2589f | ||
|
|
aec5543081 | ||
|
|
e03d90ca57 | ||
|
|
495ce62d9c | ||
|
|
fbc3959a5a | ||
|
|
246b11925c | ||
|
|
dfa9131192 | ||
|
|
88c801b4c2 | ||
|
|
a1b55b94e0 | ||
|
|
80db9e2e2f | ||
|
|
ce74690420 | ||
|
|
50de4dfb5d | ||
|
|
9bcdf860f4 | ||
|
|
511ab4b630 | ||
|
|
6f2b623e3c | ||
|
|
fa12165cd3 | ||
|
|
c0c6f3329d | ||
|
|
406a932467 | ||
|
|
cc96d4245f | ||
|
|
c6cdca8923 | ||
|
|
45edcafb06 | ||
|
|
9f0bcc131f | ||
|
|
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 |
4
.github/workflows/code-check.yml
vendored
4
.github/workflows/code-check.yml
vendored
@@ -14,6 +14,10 @@ jobs:
|
|||||||
check:
|
check:
|
||||||
name: Check Code
|
name: Check Code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||||
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out from Git
|
- name: Check out from Git
|
||||||
|
|||||||
43
.github/workflows/python-avatar.yml
vendored
Normal file
43
.github/workflows/python-avatar.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: Python Avatar
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Avatar [${{ matrix.shard }}]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
shard: [
|
||||||
|
1/24, 2/24, 3/24, 4/24,
|
||||||
|
5/24, 6/24, 7/24, 8/24,
|
||||||
|
9/24, 10/24, 11/24, 12/24,
|
||||||
|
13/24, 14/24, 15/24, 16/24,
|
||||||
|
17/24, 18/24, 19/24, 20/24,
|
||||||
|
21/24, 22/24, 23/24, 24/24,
|
||||||
|
]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Set Up Python 3.11
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: 3.11
|
||||||
|
- name: Install
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install .[avatar]
|
||||||
|
- name: Rootcanal
|
||||||
|
run: nohup python -m rootcanal > rootcanal.log &
|
||||||
|
- name: Test
|
||||||
|
run: |
|
||||||
|
avatar --list | grep -Ev '^=' > test-names.txt
|
||||||
|
timeout 5m avatar --test-beds bumble.bumbles --tests $(split test-names.txt -n l/${{ matrix.shard }})
|
||||||
|
- name: Rootcanal Logs
|
||||||
|
run: cat rootcanal.log
|
||||||
28
.github/workflows/python-build-test.yml
vendored
28
.github/workflows/python-build-test.yml
vendored
@@ -12,10 +12,10 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
||||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
||||||
@@ -41,11 +41,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
inv build
|
inv build
|
||||||
inv build.mkdocs
|
inv build.mkdocs
|
||||||
|
|
||||||
build-rust:
|
build-rust:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [ "3.8", "3.9", "3.10" ]
|
python-version: [ "3.8", "3.9", "3.10", "3.11" ]
|
||||||
|
rust-version: [ "1.70.0", "stable" ]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- name: Check out from Git
|
- name: Check out from Git
|
||||||
@@ -54,7 +56,7 @@ jobs:
|
|||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install dependencies
|
- name: Install Python dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
python -m pip install ".[build,test,development,documentation]"
|
python -m pip install ".[build,test,development,documentation]"
|
||||||
@@ -62,9 +64,19 @@ jobs:
|
|||||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
with:
|
with:
|
||||||
components: clippy,rustfmt
|
components: clippy,rustfmt
|
||||||
- name: Rust Lints
|
toolchain: ${{ matrix.rust-version }}
|
||||||
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings
|
- name: Install Rust dependencies
|
||||||
|
run: cargo install cargo-all-features # allows building/testing combinations of features
|
||||||
|
- name: Check License Headers
|
||||||
|
run: cd rust && cargo run --features dev-tools --bin file-header check-all
|
||||||
- name: Rust Build
|
- name: Rust Build
|
||||||
run: cd rust && cargo build --all-targets
|
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
|
- name: Rust Tests
|
||||||
run: cd rust && cargo test
|
run: cd rust && cargo test-all-features
|
||||||
|
# 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
|
||||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@@ -21,7 +21,9 @@
|
|||||||
"cccds",
|
"cccds",
|
||||||
"cmac",
|
"cmac",
|
||||||
"CONNECTIONLESS",
|
"CONNECTIONLESS",
|
||||||
|
"csip",
|
||||||
"csrcs",
|
"csrcs",
|
||||||
|
"CVSD",
|
||||||
"datagram",
|
"datagram",
|
||||||
"DATALINK",
|
"DATALINK",
|
||||||
"delayreport",
|
"delayreport",
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
"deregistration",
|
"deregistration",
|
||||||
"dhkey",
|
"dhkey",
|
||||||
"diversifier",
|
"diversifier",
|
||||||
|
"endianness",
|
||||||
"Fitbit",
|
"Fitbit",
|
||||||
"GATTLINK",
|
"GATTLINK",
|
||||||
"HANDSFREE",
|
"HANDSFREE",
|
||||||
@@ -38,13 +41,18 @@
|
|||||||
"libc",
|
"libc",
|
||||||
"libusb",
|
"libusb",
|
||||||
"MITM",
|
"MITM",
|
||||||
|
"MSBC",
|
||||||
"NDIS",
|
"NDIS",
|
||||||
|
"netsim",
|
||||||
"NONBLOCK",
|
"NONBLOCK",
|
||||||
"NONCONN",
|
"NONCONN",
|
||||||
"OXIMETER",
|
"OXIMETER",
|
||||||
"popleft",
|
"popleft",
|
||||||
|
"PRAND",
|
||||||
|
"protobuf",
|
||||||
"psms",
|
"psms",
|
||||||
"pyee",
|
"pyee",
|
||||||
|
"Pyodide",
|
||||||
"pyusb",
|
"pyusb",
|
||||||
"rfcomm",
|
"rfcomm",
|
||||||
"ROHC",
|
"ROHC",
|
||||||
@@ -52,6 +60,7 @@
|
|||||||
"SEID",
|
"SEID",
|
||||||
"seids",
|
"seids",
|
||||||
"SERV",
|
"SERV",
|
||||||
|
"SIRK",
|
||||||
"ssrc",
|
"ssrc",
|
||||||
"strerror",
|
"strerror",
|
||||||
"subband",
|
"subband",
|
||||||
|
|||||||
234
apps/bench.py
234
apps/bench.py
@@ -24,6 +24,7 @@ import time
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from bumble import l2cap
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
BT_BR_EDR_TRANSPORT,
|
BT_BR_EDR_TRANSPORT,
|
||||||
BT_LE_TRANSPORT,
|
BT_LE_TRANSPORT,
|
||||||
@@ -49,8 +50,10 @@ from bumble.sdp import (
|
|||||||
SDP_PUBLIC_BROWSE_ROOT,
|
SDP_PUBLIC_BROWSE_ROOT,
|
||||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||||
|
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
DataElement,
|
DataElement,
|
||||||
ServiceAttribute,
|
ServiceAttribute,
|
||||||
|
Client as SdpClient,
|
||||||
)
|
)
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
import bumble.rfcomm
|
import bumble.rfcomm
|
||||||
@@ -76,6 +79,7 @@ SPEED_SERVICE_UUID = '50DB505C-8AC4-4738-8448-3B1D9CC09CC5'
|
|||||||
SPEED_TX_UUID = 'E789C754-41A1-45F4-A948-A0A1A90DBA53'
|
SPEED_TX_UUID = 'E789C754-41A1-45F4-A948-A0A1A90DBA53'
|
||||||
SPEED_RX_UUID = '016A2CC7-E14B-4819-935F-1F56EAE4098D'
|
SPEED_RX_UUID = '016A2CC7-E14B-4819-935F-1F56EAE4098D'
|
||||||
|
|
||||||
|
DEFAULT_RFCOMM_UUID = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
|
||||||
DEFAULT_L2CAP_PSM = 1234
|
DEFAULT_L2CAP_PSM = 1234
|
||||||
DEFAULT_L2CAP_MAX_CREDITS = 128
|
DEFAULT_L2CAP_MAX_CREDITS = 128
|
||||||
DEFAULT_L2CAP_MTU = 1022
|
DEFAULT_L2CAP_MTU = 1022
|
||||||
@@ -85,6 +89,7 @@ DEFAULT_LINGER_TIME = 1.0
|
|||||||
|
|
||||||
DEFAULT_RFCOMM_CHANNEL = 8
|
DEFAULT_RFCOMM_CHANNEL = 8
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Utils
|
# Utils
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -126,11 +131,16 @@ def print_connection(connection):
|
|||||||
if connection.transport == BT_LE_TRANSPORT:
|
if connection.transport == BT_LE_TRANSPORT:
|
||||||
phy_state = (
|
phy_state = (
|
||||||
'PHY='
|
'PHY='
|
||||||
f'RX:{le_phy_name(connection.phy.rx_phy)}/'
|
f'TX:{le_phy_name(connection.phy.tx_phy)}/'
|
||||||
f'TX:{le_phy_name(connection.phy.tx_phy)}'
|
f'RX:{le_phy_name(connection.phy.rx_phy)}'
|
||||||
)
|
)
|
||||||
|
|
||||||
data_length = f'DL={connection.data_length}'
|
data_length = (
|
||||||
|
'DL=('
|
||||||
|
f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
|
||||||
|
f'RX:{connection.data_length[2]}/{connection.data_length[3]}'
|
||||||
|
')'
|
||||||
|
)
|
||||||
connection_parameters = (
|
connection_parameters = (
|
||||||
'Parameters='
|
'Parameters='
|
||||||
f'{connection.parameters.connection_interval * 1.25:.2f}/'
|
f'{connection.parameters.connection_interval * 1.25:.2f}/'
|
||||||
@@ -167,9 +177,7 @@ def make_sdp_records(channel):
|
|||||||
),
|
),
|
||||||
ServiceAttribute(
|
ServiceAttribute(
|
||||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
DataElement.sequence(
|
DataElement.sequence([DataElement.uuid(UUID(DEFAULT_RFCOMM_UUID))]),
|
||||||
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
ServiceAttribute(
|
ServiceAttribute(
|
||||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
@@ -189,6 +197,48 @@ def make_sdp_records(channel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def find_rfcomm_channel_with_uuid(connection: Connection, uuid: str) -> int:
|
||||||
|
# Connect to the SDP Server
|
||||||
|
sdp_client = SdpClient(connection)
|
||||||
|
await sdp_client.connect()
|
||||||
|
|
||||||
|
# Search for services with an L2CAP service attribute
|
||||||
|
search_result = await sdp_client.search_attributes(
|
||||||
|
[BT_L2CAP_PROTOCOL_ID],
|
||||||
|
[
|
||||||
|
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for attribute_list in search_result:
|
||||||
|
service_uuid = None
|
||||||
|
service_class_id_list = ServiceAttribute.find_attribute_in_list(
|
||||||
|
attribute_list, SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID
|
||||||
|
)
|
||||||
|
if service_class_id_list:
|
||||||
|
if service_class_id_list.value:
|
||||||
|
for service_class_id in service_class_id_list.value:
|
||||||
|
service_uuid = service_class_id.value
|
||||||
|
if str(service_uuid) != uuid:
|
||||||
|
# This service doesn't have a UUID or isn't the right one.
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Look for the RFCOMM Channel number
|
||||||
|
protocol_descriptor_list = ServiceAttribute.find_attribute_in_list(
|
||||||
|
attribute_list, SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID
|
||||||
|
)
|
||||||
|
if protocol_descriptor_list:
|
||||||
|
for protocol_descriptor in protocol_descriptor_list.value:
|
||||||
|
if len(protocol_descriptor.value) >= 2:
|
||||||
|
if protocol_descriptor.value[0].value == BT_RFCOMM_PROTOCOL_ID:
|
||||||
|
await sdp_client.disconnect()
|
||||||
|
return protocol_descriptor.value[1].value
|
||||||
|
|
||||||
|
await sdp_client.disconnect()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
class PacketType(enum.IntEnum):
|
class PacketType(enum.IntEnum):
|
||||||
RESET = 0
|
RESET = 0
|
||||||
SEQUENCE = 1
|
SEQUENCE = 1
|
||||||
@@ -197,6 +247,7 @@ class PacketType(enum.IntEnum):
|
|||||||
|
|
||||||
PACKET_FLAG_LAST = 1
|
PACKET_FLAG_LAST = 1
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Sender
|
# Sender
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -221,7 +272,7 @@ class Sender:
|
|||||||
|
|
||||||
if self.tx_start_delay:
|
if self.tx_start_delay:
|
||||||
print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
|
print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
|
||||||
await asyncio.sleep(self.tx_start_delay) # FIXME
|
await asyncio.sleep(self.tx_start_delay)
|
||||||
|
|
||||||
print(color('=== Sending RESET', 'magenta'))
|
print(color('=== Sending RESET', 'magenta'))
|
||||||
await self.packet_io.send_packet(bytes([PacketType.RESET]))
|
await self.packet_io.send_packet(bytes([PacketType.RESET]))
|
||||||
@@ -361,7 +412,7 @@ class Ping:
|
|||||||
|
|
||||||
if self.tx_start_delay:
|
if self.tx_start_delay:
|
||||||
print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
|
print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
|
||||||
await asyncio.sleep(self.tx_start_delay) # FIXME
|
await asyncio.sleep(self.tx_start_delay)
|
||||||
|
|
||||||
print(color('=== Sending RESET', 'magenta'))
|
print(color('=== Sending RESET', 'magenta'))
|
||||||
await self.packet_io.send_packet(bytes([PacketType.RESET]))
|
await self.packet_io.send_packet(bytes([PacketType.RESET]))
|
||||||
@@ -659,17 +710,19 @@ class L2capClient(StreamedPacketIO):
|
|||||||
self.mps = mps
|
self.mps = mps
|
||||||
self.ready = asyncio.Event()
|
self.ready = asyncio.Event()
|
||||||
|
|
||||||
async def on_connection(self, connection):
|
async def on_connection(self, connection: Connection) -> None:
|
||||||
connection.on('disconnection', self.on_disconnection)
|
connection.on('disconnection', self.on_disconnection)
|
||||||
|
|
||||||
# Connect a new L2CAP channel
|
# Connect a new L2CAP channel
|
||||||
print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow'))
|
print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow'))
|
||||||
try:
|
try:
|
||||||
l2cap_channel = await connection.open_l2cap_channel(
|
l2cap_channel = await connection.create_l2cap_channel(
|
||||||
psm=self.psm,
|
spec=l2cap.LeCreditBasedChannelSpec(
|
||||||
max_credits=self.max_credits,
|
psm=self.psm,
|
||||||
mtu=self.mtu,
|
max_credits=self.max_credits,
|
||||||
mps=self.mps,
|
mtu=self.mtu,
|
||||||
|
mps=self.mps,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
|
print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
@@ -695,7 +748,7 @@ class L2capClient(StreamedPacketIO):
|
|||||||
class L2capServer(StreamedPacketIO):
|
class L2capServer(StreamedPacketIO):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
device,
|
device: Device,
|
||||||
psm=DEFAULT_L2CAP_PSM,
|
psm=DEFAULT_L2CAP_PSM,
|
||||||
max_credits=DEFAULT_L2CAP_MAX_CREDITS,
|
max_credits=DEFAULT_L2CAP_MAX_CREDITS,
|
||||||
mtu=DEFAULT_L2CAP_MTU,
|
mtu=DEFAULT_L2CAP_MTU,
|
||||||
@@ -705,15 +758,14 @@ class L2capServer(StreamedPacketIO):
|
|||||||
self.l2cap_channel = None
|
self.l2cap_channel = None
|
||||||
self.ready = asyncio.Event()
|
self.ready = asyncio.Event()
|
||||||
|
|
||||||
# Listen for incoming L2CAP CoC connections
|
# Listen for incoming L2CAP connections
|
||||||
device.register_l2cap_channel_server(
|
device.create_l2cap_server(
|
||||||
psm=psm,
|
spec=l2cap.LeCreditBasedChannelSpec(
|
||||||
server=self.on_l2cap_channel,
|
psm=psm, mtu=mtu, mps=mps, max_credits=max_credits
|
||||||
max_credits=max_credits,
|
),
|
||||||
mtu=mtu,
|
handler=self.on_l2cap_channel,
|
||||||
mps=mps,
|
|
||||||
)
|
)
|
||||||
print(color(f'### Listening for CoC connection on PSM {psm}', 'yellow'))
|
print(color(f'### Listening for L2CAP connection on PSM {psm}', 'yellow'))
|
||||||
|
|
||||||
async def on_connection(self, connection):
|
async def on_connection(self, connection):
|
||||||
connection.on('disconnection', self.on_disconnection)
|
connection.on('disconnection', self.on_disconnection)
|
||||||
@@ -739,21 +791,35 @@ class L2capServer(StreamedPacketIO):
|
|||||||
# RfcommClient
|
# RfcommClient
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class RfcommClient(StreamedPacketIO):
|
class RfcommClient(StreamedPacketIO):
|
||||||
def __init__(self, device):
|
def __init__(self, device, channel, uuid):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.device = device
|
self.device = device
|
||||||
|
self.channel = channel
|
||||||
|
self.uuid = uuid
|
||||||
self.ready = asyncio.Event()
|
self.ready = asyncio.Event()
|
||||||
|
|
||||||
async def on_connection(self, connection):
|
async def on_connection(self, connection):
|
||||||
connection.on('disconnection', self.on_disconnection)
|
connection.on('disconnection', self.on_disconnection)
|
||||||
|
|
||||||
|
# Find the channel number if not specified
|
||||||
|
channel = self.channel
|
||||||
|
if channel == 0:
|
||||||
|
print(
|
||||||
|
color(f'@@@ Discovering channel number from UUID {self.uuid}', 'cyan')
|
||||||
|
)
|
||||||
|
channel = await find_rfcomm_channel_with_uuid(connection, self.uuid)
|
||||||
|
print(color(f'@@@ Channel number = {channel}', 'cyan'))
|
||||||
|
if channel == 0:
|
||||||
|
print(color('!!! No RFComm service with this UUID found', 'red'))
|
||||||
|
await connection.disconnect()
|
||||||
|
return
|
||||||
|
|
||||||
# Create a client and start it
|
# Create a client and start it
|
||||||
print(color('*** Starting RFCOMM client...', 'blue'))
|
print(color('*** Starting RFCOMM client...', 'blue'))
|
||||||
rfcomm_client = bumble.rfcomm.Client(self.device, connection)
|
rfcomm_client = bumble.rfcomm.Client(connection)
|
||||||
rfcomm_mux = await rfcomm_client.start()
|
rfcomm_mux = await rfcomm_client.start()
|
||||||
print(color('*** Started', 'blue'))
|
print(color('*** Started', 'blue'))
|
||||||
|
|
||||||
channel = DEFAULT_RFCOMM_CHANNEL
|
|
||||||
print(color(f'### Opening session for channel {channel}...', 'yellow'))
|
print(color(f'### Opening session for channel {channel}...', 'yellow'))
|
||||||
try:
|
try:
|
||||||
rfcomm_session = await rfcomm_mux.open_dlc(channel)
|
rfcomm_session = await rfcomm_mux.open_dlc(channel)
|
||||||
@@ -776,7 +842,7 @@ class RfcommClient(StreamedPacketIO):
|
|||||||
# RfcommServer
|
# RfcommServer
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class RfcommServer(StreamedPacketIO):
|
class RfcommServer(StreamedPacketIO):
|
||||||
def __init__(self, device):
|
def __init__(self, device, channel):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.ready = asyncio.Event()
|
self.ready = asyncio.Event()
|
||||||
|
|
||||||
@@ -784,7 +850,7 @@ class RfcommServer(StreamedPacketIO):
|
|||||||
rfcomm_server = bumble.rfcomm.Server(device)
|
rfcomm_server = bumble.rfcomm.Server(device)
|
||||||
|
|
||||||
# Listen for incoming DLC connections
|
# Listen for incoming DLC connections
|
||||||
channel_number = rfcomm_server.listen(self.on_dlc, DEFAULT_RFCOMM_CHANNEL)
|
channel_number = rfcomm_server.listen(self.on_dlc, channel)
|
||||||
|
|
||||||
# Setup the SDP to advertise this channel
|
# Setup the SDP to advertise this channel
|
||||||
device.sdp_service_records = make_sdp_records(channel_number)
|
device.sdp_service_records = make_sdp_records(channel_number)
|
||||||
@@ -821,6 +887,9 @@ class Central(Connection.Listener):
|
|||||||
mode_factory,
|
mode_factory,
|
||||||
connection_interval,
|
connection_interval,
|
||||||
phy,
|
phy,
|
||||||
|
authenticate,
|
||||||
|
encrypt,
|
||||||
|
extended_data_length,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
@@ -828,6 +897,9 @@ class Central(Connection.Listener):
|
|||||||
self.classic = classic
|
self.classic = classic
|
||||||
self.role_factory = role_factory
|
self.role_factory = role_factory
|
||||||
self.mode_factory = mode_factory
|
self.mode_factory = mode_factory
|
||||||
|
self.authenticate = authenticate
|
||||||
|
self.encrypt = encrypt or authenticate
|
||||||
|
self.extended_data_length = extended_data_length
|
||||||
self.device = None
|
self.device = None
|
||||||
self.connection = None
|
self.connection = None
|
||||||
|
|
||||||
@@ -900,7 +972,26 @@ class Central(Connection.Listener):
|
|||||||
self.connection.listener = self
|
self.connection.listener = self
|
||||||
print_connection(self.connection)
|
print_connection(self.connection)
|
||||||
|
|
||||||
await mode.on_connection(self.connection)
|
# Request a new data length if requested
|
||||||
|
if self.extended_data_length:
|
||||||
|
print(color('+++ Requesting extended data length', 'cyan'))
|
||||||
|
await self.connection.set_data_length(
|
||||||
|
self.extended_data_length[0], self.extended_data_length[1]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Authenticate if requested
|
||||||
|
if self.authenticate:
|
||||||
|
# Request authentication
|
||||||
|
print(color('*** Authenticating...', 'cyan'))
|
||||||
|
await self.connection.authenticate()
|
||||||
|
print(color('*** Authenticated', 'cyan'))
|
||||||
|
|
||||||
|
# Encrypt if requested
|
||||||
|
if self.encrypt:
|
||||||
|
# Enable encryption
|
||||||
|
print(color('*** Enabling encryption...', 'cyan'))
|
||||||
|
await self.connection.encrypt()
|
||||||
|
print(color('*** Encryption on', 'cyan'))
|
||||||
|
|
||||||
# Set the PHY if requested
|
# Set the PHY if requested
|
||||||
if self.phy is not None:
|
if self.phy is not None:
|
||||||
@@ -915,6 +1006,8 @@ class Central(Connection.Listener):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await mode.on_connection(self.connection)
|
||||||
|
|
||||||
await role.run()
|
await role.run()
|
||||||
await asyncio.sleep(DEFAULT_LINGER_TIME)
|
await asyncio.sleep(DEFAULT_LINGER_TIME)
|
||||||
|
|
||||||
@@ -939,9 +1032,12 @@ class Central(Connection.Listener):
|
|||||||
# Peripheral
|
# Peripheral
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Peripheral(Device.Listener, Connection.Listener):
|
class Peripheral(Device.Listener, Connection.Listener):
|
||||||
def __init__(self, transport, classic, role_factory, mode_factory):
|
def __init__(
|
||||||
|
self, transport, classic, extended_data_length, role_factory, mode_factory
|
||||||
|
):
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
self.classic = classic
|
self.classic = classic
|
||||||
|
self.extended_data_length = extended_data_length
|
||||||
self.role_factory = role_factory
|
self.role_factory = role_factory
|
||||||
self.role = None
|
self.role = None
|
||||||
self.mode_factory = mode_factory
|
self.mode_factory = mode_factory
|
||||||
@@ -1002,6 +1098,15 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.connected.set()
|
self.connected.set()
|
||||||
|
|
||||||
|
# Request a new data length if needed
|
||||||
|
if self.extended_data_length:
|
||||||
|
print("+++ Requesting extended data length")
|
||||||
|
AsyncRunner.spawn(
|
||||||
|
connection.set_data_length(
|
||||||
|
self.extended_data_length[0], self.extended_data_length[1]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def on_disconnection(self, reason):
|
def on_disconnection(self, reason):
|
||||||
print(color(f'!!! Disconnection: reason={reason}', 'red'))
|
print(color(f'!!! Disconnection: reason={reason}', 'red'))
|
||||||
self.connection = None
|
self.connection = None
|
||||||
@@ -1034,16 +1139,18 @@ def create_mode_factory(ctx, default_mode):
|
|||||||
return GattServer(device)
|
return GattServer(device)
|
||||||
|
|
||||||
if mode == 'l2cap-client':
|
if mode == 'l2cap-client':
|
||||||
return L2capClient(device)
|
return L2capClient(device, psm=ctx.obj['l2cap_psm'])
|
||||||
|
|
||||||
if mode == 'l2cap-server':
|
if mode == 'l2cap-server':
|
||||||
return L2capServer(device)
|
return L2capServer(device, psm=ctx.obj['l2cap_psm'])
|
||||||
|
|
||||||
if mode == 'rfcomm-client':
|
if mode == 'rfcomm-client':
|
||||||
return RfcommClient(device)
|
return RfcommClient(
|
||||||
|
device, channel=ctx.obj['rfcomm_channel'], uuid=ctx.obj['rfcomm_uuid']
|
||||||
|
)
|
||||||
|
|
||||||
if mode == 'rfcomm-server':
|
if mode == 'rfcomm-server':
|
||||||
return RfcommServer(device)
|
return RfcommServer(device, channel=ctx.obj['rfcomm_channel'])
|
||||||
|
|
||||||
raise ValueError('invalid mode')
|
raise ValueError('invalid mode')
|
||||||
|
|
||||||
@@ -1109,6 +1216,27 @@ def create_role_factory(ctx, default_role):
|
|||||||
type=click.IntRange(23, 517),
|
type=click.IntRange(23, 517),
|
||||||
help='GATT MTU (gatt-client mode)',
|
help='GATT MTU (gatt-client mode)',
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
'--extended-data-length',
|
||||||
|
help='Request a data length upon connection, specified as tx_octets/tx_time',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--rfcomm-channel',
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_RFCOMM_CHANNEL,
|
||||||
|
help='RFComm channel to use',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--rfcomm-uuid',
|
||||||
|
default=DEFAULT_RFCOMM_UUID,
|
||||||
|
help='RFComm service UUID to use (ignored is --rfcomm-channel is not 0)',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--l2cap-psm',
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_L2CAP_PSM,
|
||||||
|
help='L2CAP PSM to use',
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--packet-size',
|
'--packet-size',
|
||||||
'-s',
|
'-s',
|
||||||
@@ -1135,17 +1263,36 @@ def create_role_factory(ctx, default_role):
|
|||||||
)
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def bench(
|
def bench(
|
||||||
ctx, device_config, role, mode, att_mtu, packet_size, packet_count, start_delay
|
ctx,
|
||||||
|
device_config,
|
||||||
|
role,
|
||||||
|
mode,
|
||||||
|
att_mtu,
|
||||||
|
extended_data_length,
|
||||||
|
packet_size,
|
||||||
|
packet_count,
|
||||||
|
start_delay,
|
||||||
|
rfcomm_channel,
|
||||||
|
rfcomm_uuid,
|
||||||
|
l2cap_psm,
|
||||||
):
|
):
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
ctx.obj['device_config'] = device_config
|
ctx.obj['device_config'] = device_config
|
||||||
ctx.obj['role'] = role
|
ctx.obj['role'] = role
|
||||||
ctx.obj['mode'] = mode
|
ctx.obj['mode'] = mode
|
||||||
ctx.obj['att_mtu'] = att_mtu
|
ctx.obj['att_mtu'] = att_mtu
|
||||||
|
ctx.obj['rfcomm_channel'] = rfcomm_channel
|
||||||
|
ctx.obj['rfcomm_uuid'] = rfcomm_uuid
|
||||||
|
ctx.obj['l2cap_psm'] = l2cap_psm
|
||||||
ctx.obj['packet_size'] = packet_size
|
ctx.obj['packet_size'] = packet_size
|
||||||
ctx.obj['packet_count'] = packet_count
|
ctx.obj['packet_count'] = packet_count
|
||||||
ctx.obj['start_delay'] = start_delay
|
ctx.obj['start_delay'] = start_delay
|
||||||
|
|
||||||
|
ctx.obj['extended_data_length'] = (
|
||||||
|
[int(x) for x in extended_data_length.split('/')]
|
||||||
|
if extended_data_length
|
||||||
|
else None
|
||||||
|
)
|
||||||
ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server')
|
ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server')
|
||||||
|
|
||||||
|
|
||||||
@@ -1166,8 +1313,12 @@ def bench(
|
|||||||
help='Connection interval (in ms)',
|
help='Connection interval (in ms)',
|
||||||
)
|
)
|
||||||
@click.option('--phy', type=click.Choice(['1m', '2m', 'coded']), help='PHY to use')
|
@click.option('--phy', type=click.Choice(['1m', '2m', 'coded']), help='PHY to use')
|
||||||
|
@click.option('--authenticate', is_flag=True, help='Authenticate (RFComm only)')
|
||||||
|
@click.option('--encrypt', is_flag=True, help='Encrypt the connection (RFComm only)')
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def central(ctx, transport, peripheral_address, connection_interval, phy):
|
def central(
|
||||||
|
ctx, transport, peripheral_address, connection_interval, phy, authenticate, encrypt
|
||||||
|
):
|
||||||
"""Run as a central (initiates the connection)"""
|
"""Run as a central (initiates the connection)"""
|
||||||
role_factory = create_role_factory(ctx, 'sender')
|
role_factory = create_role_factory(ctx, 'sender')
|
||||||
mode_factory = create_mode_factory(ctx, 'gatt-client')
|
mode_factory = create_mode_factory(ctx, 'gatt-client')
|
||||||
@@ -1182,6 +1333,9 @@ def central(ctx, transport, peripheral_address, connection_interval, phy):
|
|||||||
mode_factory,
|
mode_factory,
|
||||||
connection_interval,
|
connection_interval,
|
||||||
phy,
|
phy,
|
||||||
|
authenticate,
|
||||||
|
encrypt or authenticate,
|
||||||
|
ctx.obj['extended_data_length'],
|
||||||
).run()
|
).run()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1195,7 +1349,13 @@ def peripheral(ctx, transport):
|
|||||||
mode_factory = create_mode_factory(ctx, 'gatt-server')
|
mode_factory = create_mode_factory(ctx, 'gatt-server')
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
Peripheral(transport, ctx.obj['classic'], role_factory, mode_factory).run()
|
Peripheral(
|
||||||
|
transport,
|
||||||
|
ctx.obj['classic'],
|
||||||
|
ctx.obj['extended_data_length'],
|
||||||
|
role_factory,
|
||||||
|
mode_factory,
|
||||||
|
).run()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1172,7 +1172,7 @@ class ScanResult:
|
|||||||
name = ''
|
name = ''
|
||||||
|
|
||||||
# Remove any '/P' qualifier suffix from the address string
|
# 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
|
# RSSI bar
|
||||||
bar_string = rssi_bar(self.rssi)
|
bar_string = rssi_bar(self.rssi)
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ from bumble.hci import (
|
|||||||
HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command,
|
HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command,
|
||||||
HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND,
|
HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND,
|
||||||
HCI_LE_Read_Maximum_Advertising_Data_Length_Command,
|
HCI_LE_Read_Maximum_Advertising_Data_Length_Command,
|
||||||
|
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
||||||
|
HCI_LE_Read_Suggested_Default_Data_Length_Command,
|
||||||
)
|
)
|
||||||
from bumble.host import Host
|
from bumble.host import Host
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
@@ -63,7 +65,8 @@ async def get_classic_info(host):
|
|||||||
if command_succeeded(response):
|
if command_succeeded(response):
|
||||||
print()
|
print()
|
||||||
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):
|
if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND):
|
||||||
@@ -116,6 +119,18 @@ async def get_le_info(host):
|
|||||||
'\n',
|
'\n',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if host.supports_command(HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND):
|
||||||
|
response = await host.send_command(
|
||||||
|
HCI_LE_Read_Suggested_Default_Data_Length_Command()
|
||||||
|
)
|
||||||
|
if command_succeeded(response):
|
||||||
|
print(
|
||||||
|
color('Suggested Default Data Length:', 'yellow'),
|
||||||
|
f'{response.return_parameters.suggested_max_tx_octets}/'
|
||||||
|
f'{response.return_parameters.suggested_max_tx_time}',
|
||||||
|
'\n',
|
||||||
|
)
|
||||||
|
|
||||||
print(color('LE Features:', 'yellow'))
|
print(color('LE Features:', 'yellow'))
|
||||||
for feature in host.supported_le_features:
|
for feature in host.supported_le_features:
|
||||||
print(' ', name_or_number(HCI_LE_SUPPORTED_FEATURES_NAMES, feature))
|
print(' ', name_or_number(HCI_LE_SUPPORTED_FEATURES_NAMES, feature))
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import struct
|
|||||||
import logging
|
import logging
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from bumble import l2cap
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.device import Device, Peer
|
from bumble.device import Device, Peer
|
||||||
from bumble.core import AdvertisingData
|
from bumble.core import AdvertisingData
|
||||||
@@ -204,7 +205,7 @@ class GattlinkHubBridge(GattlinkL2capEndpoint, Device.Listener):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener):
|
class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener):
|
||||||
def __init__(self, device):
|
def __init__(self, device: Device):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.device = device
|
self.device = device
|
||||||
self.peer = None
|
self.peer = None
|
||||||
@@ -218,7 +219,12 @@ class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener):
|
|||||||
|
|
||||||
# Listen for incoming L2CAP CoC connections
|
# Listen for incoming L2CAP CoC connections
|
||||||
psm = 0xFB
|
psm = 0xFB
|
||||||
device.register_l2cap_channel_server(0xFB, self.on_coc)
|
device.create_l2cap_server(
|
||||||
|
spec=l2cap.LeCreditBasedChannelSpec(
|
||||||
|
psm=0xFB,
|
||||||
|
),
|
||||||
|
handler=self.on_coc,
|
||||||
|
)
|
||||||
print(f'### Listening for CoC connection on PSM {psm}')
|
print(f'### Listening for CoC connection on PSM {psm}')
|
||||||
|
|
||||||
# Setup the Gattlink service
|
# Setup the Gattlink service
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from bumble import l2cap
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
@@ -47,14 +48,13 @@ class ServerBridge:
|
|||||||
self.tcp_host = tcp_host
|
self.tcp_host = tcp_host
|
||||||
self.tcp_port = tcp_port
|
self.tcp_port = tcp_port
|
||||||
|
|
||||||
async def start(self, device):
|
async def start(self, device: Device) -> None:
|
||||||
# Listen for incoming L2CAP CoC connections
|
# Listen for incoming L2CAP CoC connections
|
||||||
device.register_l2cap_channel_server(
|
device.create_l2cap_server(
|
||||||
psm=self.psm,
|
spec=l2cap.LeCreditBasedChannelSpec(
|
||||||
server=self.on_coc,
|
psm=self.psm, mtu=self.mtu, mps=self.mps, max_credits=self.max_credits
|
||||||
max_credits=self.max_credits,
|
),
|
||||||
mtu=self.mtu,
|
handler=self.on_coc,
|
||||||
mps=self.mps,
|
|
||||||
)
|
)
|
||||||
print(color(f'### Listening for CoC connection on PSM {self.psm}', 'yellow'))
|
print(color(f'### Listening for CoC connection on PSM {self.psm}', 'yellow'))
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ class ServerBridge:
|
|||||||
asyncio.create_task(self.pipe.l2cap_channel.disconnect())
|
asyncio.create_task(self.pipe.l2cap_channel.disconnect())
|
||||||
|
|
||||||
def data_received(self, data):
|
def data_received(self, data):
|
||||||
print(f'<<< Received on TCP: {len(data)}')
|
print(color(f'<<< [TCP DATA]: {len(data)} bytes', 'blue'))
|
||||||
self.pipe.l2cap_channel.write(data)
|
self.pipe.l2cap_channel.write(data)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -123,6 +123,7 @@ class ServerBridge:
|
|||||||
await self.l2cap_channel.disconnect()
|
await self.l2cap_channel.disconnect()
|
||||||
|
|
||||||
def on_l2cap_close(self):
|
def on_l2cap_close(self):
|
||||||
|
print(color('*** L2CAP channel closed', 'red'))
|
||||||
self.l2cap_channel = None
|
self.l2cap_channel = None
|
||||||
if self.tcp_transport is not None:
|
if self.tcp_transport is not None:
|
||||||
self.tcp_transport.close()
|
self.tcp_transport.close()
|
||||||
@@ -194,11 +195,13 @@ class ClientBridge:
|
|||||||
# Connect a new L2CAP channel
|
# Connect a new L2CAP channel
|
||||||
print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow'))
|
print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow'))
|
||||||
try:
|
try:
|
||||||
l2cap_channel = await connection.open_l2cap_channel(
|
l2cap_channel = await connection.create_l2cap_channel(
|
||||||
psm=self.psm,
|
spec=l2cap.LeCreditBasedChannelSpec(
|
||||||
max_credits=self.max_credits,
|
psm=self.psm,
|
||||||
mtu=self.mtu,
|
max_credits=self.max_credits,
|
||||||
mps=self.mps,
|
mtu=self.mtu,
|
||||||
|
mps=self.mps,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
|
print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
|||||||
75
apps/pair.py
75
apps/pair.py
@@ -24,10 +24,16 @@ from prompt_toolkit.shortcuts import PromptSession
|
|||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.device import Device, Peer
|
from bumble.device import Device, Peer
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
from bumble.pairing import PairingDelegate, PairingConfig
|
from bumble.pairing import OobData, PairingDelegate, PairingConfig
|
||||||
|
from bumble.smp import OobContext, OobLegacyContext
|
||||||
from bumble.smp import error_name as smp_error_name
|
from bumble.smp import error_name as smp_error_name
|
||||||
from bumble.keys import JsonKeyStore
|
from bumble.keys import JsonKeyStore
|
||||||
from bumble.core import ProtocolError
|
from bumble.core import (
|
||||||
|
AdvertisingData,
|
||||||
|
ProtocolError,
|
||||||
|
BT_LE_TRANSPORT,
|
||||||
|
BT_BR_EDR_TRANSPORT,
|
||||||
|
)
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
GATT_DEVICE_NAME_CHARACTERISTIC,
|
GATT_DEVICE_NAME_CHARACTERISTIC,
|
||||||
GATT_GENERIC_ACCESS_SERVICE,
|
GATT_GENERIC_ACCESS_SERVICE,
|
||||||
@@ -60,7 +66,7 @@ class Waiter:
|
|||||||
class Delegate(PairingDelegate):
|
class Delegate(PairingDelegate):
|
||||||
def __init__(self, mode, connection, capability_string, do_prompt):
|
def __init__(self, mode, connection, capability_string, do_prompt):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
{
|
io_capability={
|
||||||
'keyboard': PairingDelegate.KEYBOARD_INPUT_ONLY,
|
'keyboard': PairingDelegate.KEYBOARD_INPUT_ONLY,
|
||||||
'display': PairingDelegate.DISPLAY_OUTPUT_ONLY,
|
'display': PairingDelegate.DISPLAY_OUTPUT_ONLY,
|
||||||
'display+keyboard': PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT,
|
'display+keyboard': PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT,
|
||||||
@@ -285,7 +291,9 @@ async def pair(
|
|||||||
mitm,
|
mitm,
|
||||||
bond,
|
bond,
|
||||||
ctkd,
|
ctkd,
|
||||||
|
linger,
|
||||||
io,
|
io,
|
||||||
|
oob,
|
||||||
prompt,
|
prompt,
|
||||||
request,
|
request,
|
||||||
print_keys,
|
print_keys,
|
||||||
@@ -306,6 +314,7 @@ async def pair(
|
|||||||
# Expose a GATT characteristic that can be used to trigger pairing by
|
# Expose a GATT characteristic that can be used to trigger pairing by
|
||||||
# responding with an authentication error when read
|
# responding with an authentication error when read
|
||||||
if mode == 'le':
|
if mode == 'le':
|
||||||
|
device.le_enabled = True
|
||||||
device.add_service(
|
device.add_service(
|
||||||
Service(
|
Service(
|
||||||
'50DB505C-8AC4-4738-8448-3B1D9CC09CC5',
|
'50DB505C-8AC4-4738-8448-3B1D9CC09CC5',
|
||||||
@@ -326,7 +335,6 @@ async def pair(
|
|||||||
# Select LE or Classic
|
# Select LE or Classic
|
||||||
if mode == 'classic':
|
if mode == 'classic':
|
||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
device.le_enabled = False
|
|
||||||
device.classic_smp_enabled = ctkd
|
device.classic_smp_enabled = ctkd
|
||||||
|
|
||||||
# Get things going
|
# Get things going
|
||||||
@@ -343,16 +351,52 @@ async def pair(
|
|||||||
await device.keystore.print(prefix=color('@@@ ', 'blue'))
|
await device.keystore.print(prefix=color('@@@ ', 'blue'))
|
||||||
print(color('@@@-----------------------------------', 'blue'))
|
print(color('@@@-----------------------------------', 'blue'))
|
||||||
|
|
||||||
|
# Create an OOB context if needed
|
||||||
|
if oob:
|
||||||
|
our_oob_context = OobContext()
|
||||||
|
shared_data = (
|
||||||
|
None
|
||||||
|
if oob == '-'
|
||||||
|
else OobData.from_ad(AdvertisingData.from_bytes(bytes.fromhex(oob)))
|
||||||
|
)
|
||||||
|
legacy_context = OobLegacyContext()
|
||||||
|
oob_contexts = PairingConfig.OobConfig(
|
||||||
|
our_context=our_oob_context,
|
||||||
|
peer_data=shared_data,
|
||||||
|
legacy_context=legacy_context,
|
||||||
|
)
|
||||||
|
oob_data = OobData(
|
||||||
|
address=device.random_address,
|
||||||
|
shared_data=shared_data,
|
||||||
|
legacy_context=legacy_context,
|
||||||
|
)
|
||||||
|
print(color('@@@-----------------------------------', 'yellow'))
|
||||||
|
print(color('@@@ OOB Data:', 'yellow'))
|
||||||
|
print(color(f'@@@ {our_oob_context.share()}', 'yellow'))
|
||||||
|
print(color(f'@@@ TK={legacy_context.tk.hex()}', 'yellow'))
|
||||||
|
print(color(f'@@@ HEX: ({bytes(oob_data.to_ad()).hex()})', 'yellow'))
|
||||||
|
print(color('@@@-----------------------------------', 'yellow'))
|
||||||
|
else:
|
||||||
|
oob_contexts = None
|
||||||
|
|
||||||
# Set up a pairing config factory
|
# Set up a pairing config factory
|
||||||
device.pairing_config_factory = lambda connection: PairingConfig(
|
device.pairing_config_factory = lambda connection: PairingConfig(
|
||||||
sc, mitm, bond, Delegate(mode, connection, io, prompt)
|
sc=sc,
|
||||||
|
mitm=mitm,
|
||||||
|
bonding=bond,
|
||||||
|
oob=oob_contexts,
|
||||||
|
delegate=Delegate(mode, connection, io, prompt),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Connect to a peer or wait for a connection
|
# Connect to a peer or wait for a connection
|
||||||
device.on('connection', lambda connection: on_connection(connection, request))
|
device.on('connection', lambda connection: on_connection(connection, request))
|
||||||
if address_or_name is not None:
|
if address_or_name is not None:
|
||||||
print(color(f'=== Connecting to {address_or_name}...', 'green'))
|
print(color(f'=== Connecting to {address_or_name}...', 'green'))
|
||||||
connection = await device.connect(address_or_name)
|
connection = await device.connect(
|
||||||
|
address_or_name,
|
||||||
|
transport=BT_LE_TRANSPORT if mode == 'le' else BT_BR_EDR_TRANSPORT,
|
||||||
|
)
|
||||||
|
pairing_failure = False
|
||||||
|
|
||||||
if not request:
|
if not request:
|
||||||
try:
|
try:
|
||||||
@@ -360,10 +404,12 @@ async def pair(
|
|||||||
await connection.pair()
|
await connection.pair()
|
||||||
else:
|
else:
|
||||||
await connection.authenticate()
|
await connection.authenticate()
|
||||||
return
|
|
||||||
except ProtocolError as error:
|
except ProtocolError as error:
|
||||||
|
pairing_failure = True
|
||||||
print(color(f'Pairing failed: {error}', 'red'))
|
print(color(f'Pairing failed: {error}', 'red'))
|
||||||
return
|
|
||||||
|
if not linger or pairing_failure:
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
if mode == 'le':
|
if mode == 'le':
|
||||||
# Advertise so that peers can find us and connect
|
# Advertise so that peers can find us and connect
|
||||||
@@ -413,6 +459,7 @@ class LogHandler(logging.Handler):
|
|||||||
help='Enable CTKD',
|
help='Enable CTKD',
|
||||||
show_default=True,
|
show_default=True,
|
||||||
)
|
)
|
||||||
|
@click.option('--linger', default=True, is_flag=True, help='Linger after pairing')
|
||||||
@click.option(
|
@click.option(
|
||||||
'--io',
|
'--io',
|
||||||
type=click.Choice(
|
type=click.Choice(
|
||||||
@@ -421,6 +468,14 @@ class LogHandler(logging.Handler):
|
|||||||
default='display+keyboard',
|
default='display+keyboard',
|
||||||
show_default=True,
|
show_default=True,
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
'--oob',
|
||||||
|
metavar='<oob-data-hex>',
|
||||||
|
help=(
|
||||||
|
'Use OOB pairing with this data from the peer '
|
||||||
|
'(use "-" to enable OOB without peer data)'
|
||||||
|
),
|
||||||
|
)
|
||||||
@click.option('--prompt', is_flag=True, help='Prompt to accept/reject pairing request')
|
@click.option('--prompt', is_flag=True, help='Prompt to accept/reject pairing request')
|
||||||
@click.option(
|
@click.option(
|
||||||
'--request', is_flag=True, help='Request that the connecting peer initiate pairing'
|
'--request', is_flag=True, help='Request that the connecting peer initiate pairing'
|
||||||
@@ -440,7 +495,9 @@ def main(
|
|||||||
mitm,
|
mitm,
|
||||||
bond,
|
bond,
|
||||||
ctkd,
|
ctkd,
|
||||||
|
linger,
|
||||||
io,
|
io,
|
||||||
|
oob,
|
||||||
prompt,
|
prompt,
|
||||||
request,
|
request,
|
||||||
print_keys,
|
print_keys,
|
||||||
@@ -463,7 +520,9 @@ def main(
|
|||||||
mitm,
|
mitm,
|
||||||
bond,
|
bond,
|
||||||
ctkd,
|
ctkd,
|
||||||
|
linger,
|
||||||
io,
|
io,
|
||||||
|
oob,
|
||||||
prompt,
|
prompt,
|
||||||
request,
|
request,
|
||||||
print_keys,
|
print_keys,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import click
|
import click
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
from bumble.pandora import PandoraDevice, serve
|
from bumble.pandora import PandoraDevice, Config, serve
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
BUMBLE_SERVER_GRPC_PORT = 7999
|
BUMBLE_SERVER_GRPC_PORT = 7999
|
||||||
ROOTCANAL_PORT_CUTTLEFISH = 7300
|
ROOTCANAL_PORT_CUTTLEFISH = 7300
|
||||||
@@ -18,12 +20,31 @@ ROOTCANAL_PORT_CUTTLEFISH = 7300
|
|||||||
help='HCI transport',
|
help='HCI transport',
|
||||||
default=f'tcp-client:127.0.0.1:<rootcanal-port>',
|
default=f'tcp-client:127.0.0.1:<rootcanal-port>',
|
||||||
)
|
)
|
||||||
def main(grpc_port: int, rootcanal_port: int, transport: str) -> None:
|
@click.option(
|
||||||
|
'--config',
|
||||||
|
help='Bumble json configuration file',
|
||||||
|
)
|
||||||
|
def main(grpc_port: int, rootcanal_port: int, transport: str, config: str) -> None:
|
||||||
if '<rootcanal-port>' in transport:
|
if '<rootcanal-port>' in transport:
|
||||||
transport = transport.replace('<rootcanal-port>', str(rootcanal_port))
|
transport = transport.replace('<rootcanal-port>', str(rootcanal_port))
|
||||||
device = PandoraDevice({'transport': transport})
|
|
||||||
|
bumble_config = retrieve_config(config)
|
||||||
|
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)
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
15
apps/show.py
15
apps/show.py
@@ -102,9 +102,21 @@ class SnoopPacketReader:
|
|||||||
default='h4',
|
default='h4',
|
||||||
help='Format of the input file',
|
help='Format of the input file',
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
'--vendors',
|
||||||
|
type=click.Choice(['android', 'zephyr']),
|
||||||
|
multiple=True,
|
||||||
|
help='Support vendor-specific commands (list one or more)',
|
||||||
|
)
|
||||||
@click.argument('filename')
|
@click.argument('filename')
|
||||||
# pylint: disable=redefined-builtin
|
# pylint: disable=redefined-builtin
|
||||||
def main(format, filename):
|
def main(format, vendors, filename):
|
||||||
|
for vendor in vendors:
|
||||||
|
if vendor == 'android':
|
||||||
|
import bumble.vendor.android.hci
|
||||||
|
elif vendor == 'zephyr':
|
||||||
|
import bumble.vendor.zephyr.hci
|
||||||
|
|
||||||
input = open(filename, 'rb')
|
input = open(filename, 'rb')
|
||||||
if format == 'h4':
|
if format == 'h4':
|
||||||
packet_reader = PacketReader(input)
|
packet_reader = PacketReader(input)
|
||||||
@@ -124,7 +136,6 @@ def main(format, filename):
|
|||||||
if packet is None:
|
if packet is None:
|
||||||
break
|
break
|
||||||
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
|
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
|
||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
print(color(f'!!! {error}', 'red'))
|
print(color(f'!!! {error}', 'red'))
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ body, h1, h2, h3, h4, h5, h6 {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
margin: 6px;
|
margin: 6px;
|
||||||
margin-left: 0px;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
th, td {
|
||||||
@@ -65,7 +65,7 @@ th, td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.properties td:nth-child(even) {
|
.properties td:nth-child(even) {
|
||||||
background-color: #D6EEEE;
|
background-color: #d6eeee;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Bumble Speaker</title>
|
<title>Bumble Speaker</title>
|
||||||
<script type="text/javascript" src="speaker.js"></script>
|
<script src="speaker.js"></script>
|
||||||
<link rel="stylesheet" href="speaker.css">
|
<link rel="stylesheet" href="speaker.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ class WebSocketOutput(QueuedOutput):
|
|||||||
except HCI_StatusError:
|
except HCI_StatusError:
|
||||||
pass
|
pass
|
||||||
peer_name = '' if connection.peer_name is None else connection.peer_name
|
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(
|
await self.send_message(
|
||||||
'connection',
|
'connection',
|
||||||
peer_address=peer_address,
|
peer_address=peer_address,
|
||||||
@@ -228,10 +228,11 @@ class FfplayOutput(QueuedOutput):
|
|||||||
subprocess: Optional[asyncio.subprocess.Process]
|
subprocess: Optional[asyncio.subprocess.Process]
|
||||||
ffplay_task: Optional[asyncio.Task]
|
ffplay_task: Optional[asyncio.Task]
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, codec: str) -> None:
|
||||||
super().__init__(AacAudioExtractor())
|
super().__init__(AudioExtractor.create(codec))
|
||||||
self.subprocess = None
|
self.subprocess = None
|
||||||
self.ffplay_task = None
|
self.ffplay_task = None
|
||||||
|
self.codec = codec
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
if self.started:
|
if self.started:
|
||||||
@@ -240,7 +241,7 @@ class FfplayOutput(QueuedOutput):
|
|||||||
await super().start()
|
await super().start()
|
||||||
|
|
||||||
self.subprocess = await asyncio.create_subprocess_shell(
|
self.subprocess = await asyncio.create_subprocess_shell(
|
||||||
'ffplay -acodec aac pipe:0',
|
f'ffplay -f {self.codec} pipe:0',
|
||||||
stdin=asyncio.subprocess.PIPE,
|
stdin=asyncio.subprocess.PIPE,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
@@ -375,7 +376,7 @@ class UiServer:
|
|||||||
if connection := self.speaker().connection:
|
if connection := self.speaker().connection:
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
'connection',
|
'connection',
|
||||||
peer_address=str(connection.peer_address).replace('/P', ''),
|
peer_address=connection.peer_address.to_string(False),
|
||||||
peer_name=connection.peer_name,
|
peer_name=connection.peer_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -419,7 +420,7 @@ class Speaker:
|
|||||||
self.outputs = []
|
self.outputs = []
|
||||||
for output in outputs:
|
for output in outputs:
|
||||||
if output == '@ffplay':
|
if output == '@ffplay':
|
||||||
self.outputs.append(FfplayOutput())
|
self.outputs.append(FfplayOutput(codec))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Default to FileOutput
|
# Default to FileOutput
|
||||||
@@ -640,7 +641,7 @@ class Speaker:
|
|||||||
self.device.on('connection', self.on_bluetooth_connection)
|
self.device.on('connection', self.on_bluetooth_connection)
|
||||||
|
|
||||||
# Create a listener to wait for AVDTP connections
|
# Create a listener to wait for AVDTP connections
|
||||||
self.listener = Listener(Listener.create_registrar(self.device))
|
self.listener = Listener.for_device(self.device)
|
||||||
self.listener.on('connection', self.on_avdtp_connection)
|
self.listener.on('connection', self.on_avdtp_connection)
|
||||||
|
|
||||||
print(f'Speaker ready to play, codec={color(self.codec, "cyan")}')
|
print(f'Speaker ready to play, codec={color(self.codec, "cyan")}')
|
||||||
@@ -708,17 +709,6 @@ def speaker(
|
|||||||
):
|
):
|
||||||
"""Run the 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:
|
if '@ffplay' in output:
|
||||||
# Check if ffplay is installed
|
# Check if ffplay is installed
|
||||||
try:
|
try:
|
||||||
|
|||||||
151
bumble/a2dp.py
151
bumble/a2dp.py
@@ -15,9 +15,13 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
import struct
|
import struct
|
||||||
import logging
|
import logging
|
||||||
from collections import namedtuple
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import List, Callable, Awaitable
|
||||||
|
|
||||||
from .company_ids import COMPANY_IDENTIFIERS
|
from .company_ids import COMPANY_IDENTIFIERS
|
||||||
from .sdp import (
|
from .sdp import (
|
||||||
@@ -239,24 +243,20 @@ def make_audio_sink_service_sdp_records(service_record_handle, version=(1, 3)):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class SbcMediaCodecInformation(
|
@dataclasses.dataclass
|
||||||
namedtuple(
|
class SbcMediaCodecInformation:
|
||||||
'SbcMediaCodecInformation',
|
|
||||||
[
|
|
||||||
'sampling_frequency',
|
|
||||||
'channel_mode',
|
|
||||||
'block_length',
|
|
||||||
'subbands',
|
|
||||||
'allocation_method',
|
|
||||||
'minimum_bitpool_value',
|
|
||||||
'maximum_bitpool_value',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
):
|
|
||||||
'''
|
'''
|
||||||
A2DP spec - 4.3.2 Codec Specific Information Elements
|
A2DP spec - 4.3.2 Codec Specific Information Elements
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
sampling_frequency: int
|
||||||
|
channel_mode: int
|
||||||
|
block_length: int
|
||||||
|
subbands: int
|
||||||
|
allocation_method: int
|
||||||
|
minimum_bitpool_value: int
|
||||||
|
maximum_bitpool_value: int
|
||||||
|
|
||||||
SAMPLING_FREQUENCY_BITS = {16000: 1 << 3, 32000: 1 << 2, 44100: 1 << 1, 48000: 1}
|
SAMPLING_FREQUENCY_BITS = {16000: 1 << 3, 32000: 1 << 2, 44100: 1 << 1, 48000: 1}
|
||||||
CHANNEL_MODE_BITS = {
|
CHANNEL_MODE_BITS = {
|
||||||
SBC_MONO_CHANNEL_MODE: 1 << 3,
|
SBC_MONO_CHANNEL_MODE: 1 << 3,
|
||||||
@@ -272,7 +272,7 @@ class SbcMediaCodecInformation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(data: bytes) -> 'SbcMediaCodecInformation':
|
def from_bytes(data: bytes) -> SbcMediaCodecInformation:
|
||||||
sampling_frequency = (data[0] >> 4) & 0x0F
|
sampling_frequency = (data[0] >> 4) & 0x0F
|
||||||
channel_mode = (data[0] >> 0) & 0x0F
|
channel_mode = (data[0] >> 0) & 0x0F
|
||||||
block_length = (data[1] >> 4) & 0x0F
|
block_length = (data[1] >> 4) & 0x0F
|
||||||
@@ -293,14 +293,14 @@ class SbcMediaCodecInformation(
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_discrete_values(
|
def from_discrete_values(
|
||||||
cls,
|
cls,
|
||||||
sampling_frequency,
|
sampling_frequency: int,
|
||||||
channel_mode,
|
channel_mode: int,
|
||||||
block_length,
|
block_length: int,
|
||||||
subbands,
|
subbands: int,
|
||||||
allocation_method,
|
allocation_method: int,
|
||||||
minimum_bitpool_value,
|
minimum_bitpool_value: int,
|
||||||
maximum_bitpool_value,
|
maximum_bitpool_value: int,
|
||||||
):
|
) -> SbcMediaCodecInformation:
|
||||||
return SbcMediaCodecInformation(
|
return SbcMediaCodecInformation(
|
||||||
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
|
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
|
||||||
channel_mode=cls.CHANNEL_MODE_BITS[channel_mode],
|
channel_mode=cls.CHANNEL_MODE_BITS[channel_mode],
|
||||||
@@ -314,14 +314,14 @@ class SbcMediaCodecInformation(
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_lists(
|
def from_lists(
|
||||||
cls,
|
cls,
|
||||||
sampling_frequencies,
|
sampling_frequencies: List[int],
|
||||||
channel_modes,
|
channel_modes: List[int],
|
||||||
block_lengths,
|
block_lengths: List[int],
|
||||||
subbands,
|
subbands: List[int],
|
||||||
allocation_methods,
|
allocation_methods: List[int],
|
||||||
minimum_bitpool_value,
|
minimum_bitpool_value: int,
|
||||||
maximum_bitpool_value,
|
maximum_bitpool_value: int,
|
||||||
):
|
) -> SbcMediaCodecInformation:
|
||||||
return SbcMediaCodecInformation(
|
return SbcMediaCodecInformation(
|
||||||
sampling_frequency=sum(
|
sampling_frequency=sum(
|
||||||
cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies
|
cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies
|
||||||
@@ -348,7 +348,7 @@ class SbcMediaCodecInformation(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
channel_modes = ['MONO', 'DUAL_CHANNEL', 'STEREO', 'JOINT_STEREO']
|
channel_modes = ['MONO', 'DUAL_CHANNEL', 'STEREO', 'JOINT_STEREO']
|
||||||
allocation_methods = ['SNR', 'Loudness']
|
allocation_methods = ['SNR', 'Loudness']
|
||||||
return '\n'.join(
|
return '\n'.join(
|
||||||
@@ -367,16 +367,19 @@ class SbcMediaCodecInformation(
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class AacMediaCodecInformation(
|
@dataclasses.dataclass
|
||||||
namedtuple(
|
class AacMediaCodecInformation:
|
||||||
'AacMediaCodecInformation',
|
|
||||||
['object_type', 'sampling_frequency', 'channels', 'rfa', 'vbr', 'bitrate'],
|
|
||||||
)
|
|
||||||
):
|
|
||||||
'''
|
'''
|
||||||
A2DP spec - 4.5.2 Codec Specific Information Elements
|
A2DP spec - 4.5.2 Codec Specific Information Elements
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
object_type: int
|
||||||
|
sampling_frequency: int
|
||||||
|
channels: int
|
||||||
|
rfa: int
|
||||||
|
vbr: int
|
||||||
|
bitrate: int
|
||||||
|
|
||||||
OBJECT_TYPE_BITS = {
|
OBJECT_TYPE_BITS = {
|
||||||
MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7,
|
MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7,
|
||||||
MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6,
|
MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6,
|
||||||
@@ -400,7 +403,7 @@ class AacMediaCodecInformation(
|
|||||||
CHANNELS_BITS = {1: 1 << 1, 2: 1}
|
CHANNELS_BITS = {1: 1 << 1, 2: 1}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(data: bytes) -> 'AacMediaCodecInformation':
|
def from_bytes(data: bytes) -> AacMediaCodecInformation:
|
||||||
object_type = data[0]
|
object_type = data[0]
|
||||||
sampling_frequency = (data[1] << 4) | ((data[2] >> 4) & 0x0F)
|
sampling_frequency = (data[1] << 4) | ((data[2] >> 4) & 0x0F)
|
||||||
channels = (data[2] >> 2) & 0x03
|
channels = (data[2] >> 2) & 0x03
|
||||||
@@ -413,8 +416,13 @@ class AacMediaCodecInformation(
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_discrete_values(
|
def from_discrete_values(
|
||||||
cls, object_type, sampling_frequency, channels, vbr, bitrate
|
cls,
|
||||||
):
|
object_type: int,
|
||||||
|
sampling_frequency: int,
|
||||||
|
channels: int,
|
||||||
|
vbr: int,
|
||||||
|
bitrate: int,
|
||||||
|
) -> AacMediaCodecInformation:
|
||||||
return AacMediaCodecInformation(
|
return AacMediaCodecInformation(
|
||||||
object_type=cls.OBJECT_TYPE_BITS[object_type],
|
object_type=cls.OBJECT_TYPE_BITS[object_type],
|
||||||
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
|
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
|
||||||
@@ -425,7 +433,14 @@ class AacMediaCodecInformation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_lists(cls, object_types, sampling_frequencies, channels, vbr, bitrate):
|
def from_lists(
|
||||||
|
cls,
|
||||||
|
object_types: List[int],
|
||||||
|
sampling_frequencies: List[int],
|
||||||
|
channels: List[int],
|
||||||
|
vbr: int,
|
||||||
|
bitrate: int,
|
||||||
|
) -> AacMediaCodecInformation:
|
||||||
return AacMediaCodecInformation(
|
return AacMediaCodecInformation(
|
||||||
object_type=sum(cls.OBJECT_TYPE_BITS[x] for x in object_types),
|
object_type=sum(cls.OBJECT_TYPE_BITS[x] for x in object_types),
|
||||||
sampling_frequency=sum(
|
sampling_frequency=sum(
|
||||||
@@ -449,7 +464,7 @@ class AacMediaCodecInformation(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
object_types = [
|
object_types = [
|
||||||
'MPEG_2_AAC_LC',
|
'MPEG_2_AAC_LC',
|
||||||
'MPEG_4_AAC_LC',
|
'MPEG_4_AAC_LC',
|
||||||
@@ -474,26 +489,26 @@ class AacMediaCodecInformation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class VendorSpecificMediaCodecInformation:
|
class VendorSpecificMediaCodecInformation:
|
||||||
'''
|
'''
|
||||||
A2DP spec - 4.7.2 Codec Specific Information Elements
|
A2DP spec - 4.7.2 Codec Specific Information Elements
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
vendor_id: int
|
||||||
|
codec_id: int
|
||||||
|
value: bytes
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(data):
|
def from_bytes(data: bytes) -> VendorSpecificMediaCodecInformation:
|
||||||
(vendor_id, codec_id) = struct.unpack_from('<IH', data, 0)
|
(vendor_id, codec_id) = struct.unpack_from('<IH', data, 0)
|
||||||
return VendorSpecificMediaCodecInformation(vendor_id, codec_id, data[6:])
|
return VendorSpecificMediaCodecInformation(vendor_id, codec_id, data[6:])
|
||||||
|
|
||||||
def __init__(self, vendor_id, codec_id, value):
|
def __bytes__(self) -> bytes:
|
||||||
self.vendor_id = vendor_id
|
|
||||||
self.codec_id = codec_id
|
|
||||||
self.value = value
|
|
||||||
|
|
||||||
def __bytes__(self):
|
|
||||||
return struct.pack('<IH', self.vendor_id, self.codec_id, self.value)
|
return struct.pack('<IH', self.vendor_id, self.codec_id, self.value)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
return '\n'.join(
|
return '\n'.join(
|
||||||
[
|
[
|
||||||
@@ -506,29 +521,27 @@ class VendorSpecificMediaCodecInformation:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclasses.dataclass
|
||||||
class SbcFrame:
|
class SbcFrame:
|
||||||
def __init__(
|
sampling_frequency: int
|
||||||
self, sampling_frequency, block_count, channel_mode, subband_count, payload
|
block_count: int
|
||||||
):
|
channel_mode: int
|
||||||
self.sampling_frequency = sampling_frequency
|
subband_count: int
|
||||||
self.block_count = block_count
|
payload: bytes
|
||||||
self.channel_mode = channel_mode
|
|
||||||
self.subband_count = subband_count
|
|
||||||
self.payload = payload
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sample_count(self):
|
def sample_count(self) -> int:
|
||||||
return self.subband_count * self.block_count
|
return self.subband_count * self.block_count
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bitrate(self):
|
def bitrate(self) -> int:
|
||||||
return 8 * ((len(self.payload) * self.sampling_frequency) // self.sample_count)
|
return 8 * ((len(self.payload) * self.sampling_frequency) // self.sample_count)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def duration(self):
|
def duration(self) -> float:
|
||||||
return self.sample_count / self.sampling_frequency
|
return self.sample_count / self.sampling_frequency
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'SBC(sf={self.sampling_frequency},'
|
f'SBC(sf={self.sampling_frequency},'
|
||||||
f'cm={self.channel_mode},'
|
f'cm={self.channel_mode},'
|
||||||
@@ -540,12 +553,12 @@ class SbcFrame:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class SbcParser:
|
class SbcParser:
|
||||||
def __init__(self, read):
|
def __init__(self, read: Callable[[int], Awaitable[bytes]]) -> None:
|
||||||
self.read = read
|
self.read = read
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def frames(self):
|
def frames(self) -> AsyncGenerator[SbcFrame, None]:
|
||||||
async def generate_frames():
|
async def generate_frames() -> AsyncGenerator[SbcFrame, None]:
|
||||||
while True:
|
while True:
|
||||||
# Read 4 bytes of header
|
# Read 4 bytes of header
|
||||||
header = await self.read(4)
|
header = await self.read(4)
|
||||||
@@ -589,7 +602,9 @@ class SbcParser:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class SbcPacketSource:
|
class SbcPacketSource:
|
||||||
def __init__(self, read, mtu, codec_capabilities):
|
def __init__(
|
||||||
|
self, read: Callable[[int], Awaitable[bytes]], mtu: int, codec_capabilities
|
||||||
|
) -> None:
|
||||||
self.read = read
|
self.read = read
|
||||||
self.mtu = mtu
|
self.mtu = mtu
|
||||||
self.codec_capabilities = codec_capabilities
|
self.codec_capabilities = codec_capabilities
|
||||||
|
|||||||
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
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import enum
|
||||||
import functools
|
import functools
|
||||||
import struct
|
import struct
|
||||||
from pyee import EventEmitter
|
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.core import UUID, name_or_number, ProtocolError
|
||||||
from bumble.hci import HCI_Object, key_with_value, HCI_Constant
|
from bumble.hci import HCI_Object, key_with_value
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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: enable=line-too-long
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Exceptions
|
# Exceptions
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -209,7 +211,7 @@ class ATT_PDU:
|
|||||||
|
|
||||||
pdu_classes: Dict[int, Type[ATT_PDU]] = {}
|
pdu_classes: Dict[int, Type[ATT_PDU]] = {}
|
||||||
op_code = 0
|
op_code = 0
|
||||||
name = None
|
name: str
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(pdu):
|
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):
|
class Attribute(EventEmitter):
|
||||||
# Permission flags
|
class Permissions(enum.IntFlag):
|
||||||
READABLE = 0x01
|
READABLE = 0x01
|
||||||
WRITEABLE = 0x02
|
WRITEABLE = 0x02
|
||||||
READ_REQUIRES_ENCRYPTION = 0x04
|
READ_REQUIRES_ENCRYPTION = 0x04
|
||||||
WRITE_REQUIRES_ENCRYPTION = 0x08
|
WRITE_REQUIRES_ENCRYPTION = 0x08
|
||||||
READ_REQUIRES_AUTHENTICATION = 0x10
|
READ_REQUIRES_AUTHENTICATION = 0x10
|
||||||
WRITE_REQUIRES_AUTHENTICATION = 0x20
|
WRITE_REQUIRES_AUTHENTICATION = 0x20
|
||||||
READ_REQUIRES_AUTHORIZATION = 0x40
|
READ_REQUIRES_AUTHORIZATION = 0x40
|
||||||
WRITE_REQUIRES_AUTHORIZATION = 0x80
|
WRITE_REQUIRES_AUTHORIZATION = 0x80
|
||||||
|
|
||||||
PERMISSION_NAMES = {
|
@classmethod
|
||||||
READABLE: 'READABLE',
|
def from_string(cls, permissions_str: str) -> Attribute.Permissions:
|
||||||
WRITEABLE: 'WRITEABLE',
|
try:
|
||||||
READ_REQUIRES_ENCRYPTION: 'READ_REQUIRES_ENCRYPTION',
|
return functools.reduce(
|
||||||
WRITE_REQUIRES_ENCRYPTION: 'WRITE_REQUIRES_ENCRYPTION',
|
lambda x, y: x | Attribute.Permissions[y],
|
||||||
READ_REQUIRES_AUTHENTICATION: 'READ_REQUIRES_AUTHENTICATION',
|
permissions_str.replace('|', ',').split(","),
|
||||||
WRITE_REQUIRES_AUTHENTICATION: 'WRITE_REQUIRES_AUTHENTICATION',
|
Attribute.Permissions(0),
|
||||||
READ_REQUIRES_AUTHORIZATION: 'READ_REQUIRES_AUTHORIZATION',
|
)
|
||||||
WRITE_REQUIRES_AUTHORIZATION: 'WRITE_REQUIRES_AUTHORIZATION',
|
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
|
# Permission flags(legacy-use only)
|
||||||
def string_to_permissions(permissions_str: str):
|
READABLE = Permissions.READABLE
|
||||||
try:
|
WRITEABLE = Permissions.WRITEABLE
|
||||||
return functools.reduce(
|
READ_REQUIRES_ENCRYPTION = Permissions.READ_REQUIRES_ENCRYPTION
|
||||||
lambda x, y: x | get_dict_key_by_value(Attribute.PERMISSION_NAMES, y),
|
WRITE_REQUIRES_ENCRYPTION = Permissions.WRITE_REQUIRES_ENCRYPTION
|
||||||
permissions_str.split(","),
|
READ_REQUIRES_AUTHENTICATION = Permissions.READ_REQUIRES_AUTHENTICATION
|
||||||
0,
|
WRITE_REQUIRES_AUTHENTICATION = Permissions.WRITE_REQUIRES_AUTHENTICATION
|
||||||
)
|
READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
|
||||||
except TypeError as exc:
|
WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
|
||||||
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
|
|
||||||
|
|
||||||
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)
|
EventEmitter.__init__(self)
|
||||||
self.handle = 0
|
self.handle = 0
|
||||||
self.end_group_handle = 0
|
self.end_group_handle = 0
|
||||||
if isinstance(permissions, str):
|
if isinstance(permissions, str):
|
||||||
self.permissions = self.string_to_permissions(permissions)
|
self.permissions = Attribute.Permissions.from_string(permissions)
|
||||||
else:
|
else:
|
||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
|
|
||||||
@@ -778,22 +800,26 @@ class Attribute(EventEmitter):
|
|||||||
else:
|
else:
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
def encode_value(self, value):
|
def encode_value(self, value: Any) -> bytes:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def decode_value(self, value_bytes):
|
def decode_value(self, value_bytes: bytes) -> Any:
|
||||||
return value_bytes
|
return value_bytes
|
||||||
|
|
||||||
def read_value(self, connection: Connection):
|
def read_value(self, connection: Optional[Connection]) -> bytes:
|
||||||
if (
|
if (
|
||||||
self.permissions & self.READ_REQUIRES_ENCRYPTION
|
(self.permissions & self.READ_REQUIRES_ENCRYPTION)
|
||||||
) and not connection.encryption:
|
and connection is not None
|
||||||
|
and not connection.encryption
|
||||||
|
):
|
||||||
raise ATT_Error(
|
raise ATT_Error(
|
||||||
error_code=ATT_INSUFFICIENT_ENCRYPTION_ERROR, att_handle=self.handle
|
error_code=ATT_INSUFFICIENT_ENCRYPTION_ERROR, att_handle=self.handle
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
self.permissions & self.READ_REQUIRES_AUTHENTICATION
|
(self.permissions & self.READ_REQUIRES_AUTHENTICATION)
|
||||||
) and not connection.authenticated:
|
and connection is not None
|
||||||
|
and not connection.authenticated
|
||||||
|
):
|
||||||
raise ATT_Error(
|
raise ATT_Error(
|
||||||
error_code=ATT_INSUFFICIENT_AUTHENTICATION_ERROR, att_handle=self.handle
|
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
|
error_code=ATT_INSUFFICIENT_AUTHORIZATION_ERROR, att_handle=self.handle
|
||||||
)
|
)
|
||||||
|
|
||||||
if read := getattr(self.value, 'read', None):
|
if hasattr(self.value, 'read'):
|
||||||
try:
|
try:
|
||||||
value = read(connection) # pylint: disable=not-callable
|
value = self.value.read(connection)
|
||||||
except ATT_Error as error:
|
except ATT_Error as error:
|
||||||
raise ATT_Error(
|
raise ATT_Error(
|
||||||
error_code=error.error_code, att_handle=self.handle
|
error_code=error.error_code, att_handle=self.handle
|
||||||
@@ -815,7 +841,7 @@ class Attribute(EventEmitter):
|
|||||||
|
|
||||||
return self.encode_value(value)
|
return self.encode_value(value)
|
||||||
|
|
||||||
def write_value(self, connection: Connection, value_bytes):
|
def write_value(self, connection: Connection, value_bytes: bytes) -> None:
|
||||||
if (
|
if (
|
||||||
self.permissions & self.WRITE_REQUIRES_ENCRYPTION
|
self.permissions & self.WRITE_REQUIRES_ENCRYPTION
|
||||||
) and not connection.encryption:
|
) and not connection.encryption:
|
||||||
@@ -836,9 +862,9 @@ class Attribute(EventEmitter):
|
|||||||
|
|
||||||
value = self.decode_value(value_bytes)
|
value = self.decode_value(value_bytes)
|
||||||
|
|
||||||
if write := getattr(self.value, 'write', None):
|
if hasattr(self.value, 'write'):
|
||||||
try:
|
try:
|
||||||
write(connection, value) # pylint: disable=not-callable
|
self.value.write(connection, value) # pylint: disable=not-callable
|
||||||
except ATT_Error as error:
|
except ATT_Error as error:
|
||||||
raise ATT_Error(
|
raise ATT_Error(
|
||||||
error_code=error.error_code, att_handle=self.handle
|
error_code=error.error_code, att_handle=self.handle
|
||||||
|
|||||||
484
bumble/avdtp.py
484
bumble/avdtp.py
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import itertools
|
import itertools
|
||||||
@@ -58,8 +60,10 @@ from bumble.hci import (
|
|||||||
HCI_Packet,
|
HCI_Packet,
|
||||||
HCI_Role_Change_Event,
|
HCI_Role_Change_Event,
|
||||||
)
|
)
|
||||||
from typing import Optional, Union, Dict
|
from typing import Optional, Union, Dict, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble.transport.common import TransportSink, TransportSource
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -104,7 +108,7 @@ class Controller:
|
|||||||
self,
|
self,
|
||||||
name,
|
name,
|
||||||
host_source=None,
|
host_source=None,
|
||||||
host_sink=None,
|
host_sink: Optional[TransportSink] = None,
|
||||||
link=None,
|
link=None,
|
||||||
public_address: Optional[Union[bytes, str, Address]] = None,
|
public_address: Optional[Union[bytes, str, Address]] = None,
|
||||||
):
|
):
|
||||||
@@ -188,6 +192,8 @@ class Controller:
|
|||||||
if link:
|
if link:
|
||||||
link.add_controller(self)
|
link.add_controller(self)
|
||||||
|
|
||||||
|
self.terminated = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def host(self):
|
def host(self):
|
||||||
return self.hci_sink
|
return self.hci_sink
|
||||||
@@ -288,10 +294,9 @@ class Controller:
|
|||||||
if self.host:
|
if self.host:
|
||||||
self.host.on_packet(packet.to_bytes())
|
self.host.on_packet(packet.to_bytes())
|
||||||
|
|
||||||
# This method allow the controller to emulate the same API as a transport source
|
# This method allows the controller to emulate the same API as a transport source
|
||||||
async def wait_for_termination(self):
|
async def wait_for_termination(self):
|
||||||
# For now, just wait forever
|
await self.terminated
|
||||||
await asyncio.get_running_loop().create_future()
|
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# Link connections
|
# Link connections
|
||||||
@@ -995,6 +1000,9 @@ class Controller:
|
|||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 4, 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
|
||||||
'''
|
'''
|
||||||
|
if self.le_scan_enable:
|
||||||
|
return bytes([HCI_COMMAND_DISALLOWED_ERROR])
|
||||||
|
|
||||||
self.le_scan_type = command.le_scan_type
|
self.le_scan_type = command.le_scan_type
|
||||||
self.le_scan_interval = command.le_scan_interval
|
self.le_scan_interval = command.le_scan_interval
|
||||||
self.le_scan_window = command.le_scan_window
|
self.le_scan_window = command.le_scan_window
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import enum
|
||||||
import struct
|
import struct
|
||||||
from typing import List, Optional, Tuple, Union, cast, Dict
|
from typing import List, Optional, Tuple, Union, cast, Dict
|
||||||
|
|
||||||
@@ -78,7 +79,13 @@ def get_dict_key_by_value(dictionary, value):
|
|||||||
class BaseError(Exception):
|
class BaseError(Exception):
|
||||||
"""Base class for errors with an error code, error name and namespace"""
|
"""Base class for errors with an error code, error name and namespace"""
|
||||||
|
|
||||||
def __init__(self, error_code, error_namespace='', error_name='', details=''):
|
def __init__(
|
||||||
|
self,
|
||||||
|
error_code: Optional[int],
|
||||||
|
error_namespace: str = '',
|
||||||
|
error_name: str = '',
|
||||||
|
details: str = '',
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.error_code = error_code
|
self.error_code = error_code
|
||||||
self.error_namespace = error_namespace
|
self.error_namespace = error_namespace
|
||||||
@@ -90,12 +97,14 @@ class BaseError(Exception):
|
|||||||
namespace = f'{self.error_namespace}/'
|
namespace = f'{self.error_namespace}/'
|
||||||
else:
|
else:
|
||||||
namespace = ''
|
namespace = ''
|
||||||
if self.error_name:
|
error_text = {
|
||||||
name = f'{self.error_name} [0x{self.error_code:X}]'
|
(True, True): f'{self.error_name} [0x{self.error_code:X}]',
|
||||||
else:
|
(True, False): self.error_name,
|
||||||
name = f'0x{self.error_code:X}'
|
(False, True): f'0x{self.error_code:X}',
|
||||||
|
(False, False): '',
|
||||||
|
}[(self.error_name != '', self.error_code is not None)]
|
||||||
|
|
||||||
return f'{type(self).__name__}({namespace}{name})'
|
return f'{type(self).__name__}({namespace}{error_text})'
|
||||||
|
|
||||||
|
|
||||||
class ProtocolError(BaseError):
|
class ProtocolError(BaseError):
|
||||||
@@ -134,6 +143,10 @@ class ConnectionError(BaseError): # pylint: disable=redefined-builtin
|
|||||||
self.peer_address = peer_address
|
self.peer_address = peer_address
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionParameterUpdateError(BaseError):
|
||||||
|
"""Connection Parameter Update Error"""
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# UUID
|
# UUID
|
||||||
#
|
#
|
||||||
@@ -1039,3 +1052,13 @@ class ConnectionPHY:
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'ConnectionPHY(tx_phy={self.tx_phy}, rx_phy={self.rx_phy})'
|
return f'ConnectionPHY(tx_phy={self.tx_phy}, rx_phy={self.rx_phy})'
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# LE Role
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class LeRole(enum.IntEnum):
|
||||||
|
PERIPHERAL_ONLY = 0x00
|
||||||
|
CENTRAL_ONLY = 0x01
|
||||||
|
BOTH_PERIPHERAL_PREFERRED = 0x02
|
||||||
|
BOTH_CENTRAL_PREFERRED = 0x03
|
||||||
|
|||||||
174
bumble/crypto.py
174
bumble/crypto.py
@@ -21,24 +21,24 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import operator
|
import operator
|
||||||
import platform
|
|
||||||
|
|
||||||
if platform.system() != 'Emscripten':
|
import secrets
|
||||||
import secrets
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
generate_private_key,
|
||||||
generate_private_key,
|
ECDH,
|
||||||
ECDH,
|
EllipticCurvePrivateKey,
|
||||||
EllipticCurvePublicNumbers,
|
EllipticCurvePublicNumbers,
|
||||||
EllipticCurvePrivateNumbers,
|
EllipticCurvePrivateNumbers,
|
||||||
SECP256R1,
|
SECP256R1,
|
||||||
)
|
)
|
||||||
from cryptography.hazmat.primitives import cmac
|
from cryptography.hazmat.primitives import cmac
|
||||||
else:
|
from typing import Tuple
|
||||||
# TODO: implement stubs
|
|
||||||
pass
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -50,16 +50,18 @@ logger = logging.getLogger(__name__)
|
|||||||
# Classes
|
# Classes
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class EccKey:
|
class EccKey:
|
||||||
def __init__(self, private_key):
|
def __init__(self, private_key: EllipticCurvePrivateKey) -> None:
|
||||||
self.private_key = private_key
|
self.private_key = private_key
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def generate(cls):
|
def generate(cls) -> EccKey:
|
||||||
private_key = generate_private_key(SECP256R1())
|
private_key = generate_private_key(SECP256R1())
|
||||||
return cls(private_key)
|
return cls(private_key)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_private_key_bytes(cls, d_bytes, x_bytes, y_bytes):
|
def from_private_key_bytes(
|
||||||
|
cls, d_bytes: bytes, x_bytes: bytes, y_bytes: bytes
|
||||||
|
) -> EccKey:
|
||||||
d = int.from_bytes(d_bytes, byteorder='big', signed=False)
|
d = int.from_bytes(d_bytes, byteorder='big', signed=False)
|
||||||
x = int.from_bytes(x_bytes, byteorder='big', signed=False)
|
x = int.from_bytes(x_bytes, byteorder='big', signed=False)
|
||||||
y = int.from_bytes(y_bytes, byteorder='big', signed=False)
|
y = int.from_bytes(y_bytes, byteorder='big', signed=False)
|
||||||
@@ -69,7 +71,7 @@ class EccKey:
|
|||||||
return cls(private_key)
|
return cls(private_key)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def x(self):
|
def x(self) -> bytes:
|
||||||
return (
|
return (
|
||||||
self.private_key.public_key()
|
self.private_key.public_key()
|
||||||
.public_numbers()
|
.public_numbers()
|
||||||
@@ -77,14 +79,14 @@ class EccKey:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def y(self):
|
def y(self) -> bytes:
|
||||||
return (
|
return (
|
||||||
self.private_key.public_key()
|
self.private_key.public_key()
|
||||||
.public_numbers()
|
.public_numbers()
|
||||||
.y.to_bytes(32, byteorder='big')
|
.y.to_bytes(32, byteorder='big')
|
||||||
)
|
)
|
||||||
|
|
||||||
def dh(self, public_key_x, public_key_y):
|
def dh(self, public_key_x: bytes, public_key_y: bytes) -> bytes:
|
||||||
x = int.from_bytes(public_key_x, byteorder='big', signed=False)
|
x = int.from_bytes(public_key_x, byteorder='big', signed=False)
|
||||||
y = int.from_bytes(public_key_y, byteorder='big', signed=False)
|
y = int.from_bytes(public_key_y, byteorder='big', signed=False)
|
||||||
public_key = EllipticCurvePublicNumbers(x, y, SECP256R1()).public_key()
|
public_key = EllipticCurvePublicNumbers(x, y, SECP256R1()).public_key()
|
||||||
@@ -97,14 +99,23 @@ class EccKey:
|
|||||||
# Functions
|
# Functions
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def xor(x, y):
|
def xor(x: bytes, y: bytes) -> bytes:
|
||||||
assert len(x) == len(y)
|
assert len(x) == len(y)
|
||||||
return bytes(map(operator.xor, x, y))
|
return bytes(map(operator.xor, x, y))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def r():
|
def reverse(input: bytes) -> bytes:
|
||||||
|
'''
|
||||||
|
Returns bytes of input in reversed endianness.
|
||||||
|
'''
|
||||||
|
return input[::-1]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def r() -> bytes:
|
||||||
'''
|
'''
|
||||||
Generate 16 bytes of random data
|
Generate 16 bytes of random data
|
||||||
'''
|
'''
|
||||||
@@ -112,20 +123,20 @@ def r():
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def e(key, data):
|
def e(key: bytes, data: bytes) -> bytes:
|
||||||
'''
|
'''
|
||||||
AES-128 ECB, expecting byte-swapped inputs and producing a byte-swapped output.
|
AES-128 ECB, expecting byte-swapped inputs and producing a byte-swapped output.
|
||||||
|
|
||||||
See Bluetooth spec Vol 3, Part H - 2.2.1 Security function e
|
See Bluetooth spec Vol 3, Part H - 2.2.1 Security function e
|
||||||
'''
|
'''
|
||||||
|
|
||||||
cipher = Cipher(algorithms.AES(bytes(reversed(key))), modes.ECB())
|
cipher = Cipher(algorithms.AES(reverse(key)), modes.ECB())
|
||||||
encryptor = cipher.encryptor()
|
encryptor = cipher.encryptor()
|
||||||
return bytes(reversed(encryptor.update(bytes(reversed(data)))))
|
return reverse(encryptor.update(reverse(data)))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def ah(k, r): # pylint: disable=redefined-outer-name
|
def ah(k: bytes, r: bytes) -> bytes: # pylint: disable=redefined-outer-name
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 3, Part H - 2.2.2 Random Address Hash function ah
|
See Bluetooth spec Vol 3, Part H - 2.2.2 Random Address Hash function ah
|
||||||
'''
|
'''
|
||||||
@@ -136,7 +147,16 @@ def ah(k, r): # pylint: disable=redefined-outer-name
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def c1(k, r, preq, pres, iat, rat, ia, ra): # pylint: disable=redefined-outer-name
|
def c1(
|
||||||
|
k: bytes,
|
||||||
|
r: bytes,
|
||||||
|
preq: bytes,
|
||||||
|
pres: bytes,
|
||||||
|
iat: int,
|
||||||
|
rat: int,
|
||||||
|
ia: bytes,
|
||||||
|
ra: bytes,
|
||||||
|
) -> bytes: # pylint: disable=redefined-outer-name
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec, Vol 3, Part H - 2.2.3 Confirm value generation function c1 for
|
See Bluetooth spec, Vol 3, Part H - 2.2.3 Confirm value generation function c1 for
|
||||||
LE Legacy Pairing
|
LE Legacy Pairing
|
||||||
@@ -148,7 +168,7 @@ def c1(k, r, preq, pres, iat, rat, ia, ra): # pylint: disable=redefined-outer-n
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def s1(k, r1, r2):
|
def s1(k: bytes, r1: bytes, r2: bytes) -> bytes:
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec, Vol 3, Part H - 2.2.4 Key generation function s1 for LE Legacy
|
See Bluetooth spec, Vol 3, Part H - 2.2.4 Key generation function s1 for LE Legacy
|
||||||
Pairing
|
Pairing
|
||||||
@@ -158,7 +178,7 @@ def s1(k, r1, r2):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def aes_cmac(m, k):
|
def aes_cmac(m: bytes, k: bytes) -> bytes:
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec, Vol 3, Part H - 2.2.5 FunctionAES-CMAC
|
See Bluetooth spec, Vol 3, Part H - 2.2.5 FunctionAES-CMAC
|
||||||
|
|
||||||
@@ -170,20 +190,16 @@ def aes_cmac(m, k):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def f4(u, v, x, z):
|
def f4(u: bytes, v: bytes, x: bytes, z: bytes) -> bytes:
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec, Vol 3, Part H - 2.2.6 LE Secure Connections Confirm Value
|
See Bluetooth spec, Vol 3, Part H - 2.2.6 LE Secure Connections Confirm Value
|
||||||
Generation Function f4
|
Generation Function f4
|
||||||
'''
|
'''
|
||||||
return bytes(
|
return reverse(aes_cmac(reverse(u) + reverse(v) + z, reverse(x)))
|
||||||
reversed(
|
|
||||||
aes_cmac(bytes(reversed(u)) + bytes(reversed(v)) + z, bytes(reversed(x)))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def f5(w, n1, n2, a1, a2):
|
def f5(w: bytes, n1: bytes, n2: bytes, a1: bytes, a2: bytes) -> Tuple[bytes, bytes]:
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec, Vol 3, Part H - 2.2.7 LE Secure Connections Key Generation
|
See Bluetooth spec, Vol 3, Part H - 2.2.7 LE Secure Connections Key Generation
|
||||||
Function f5
|
Function f5
|
||||||
@@ -191,87 +207,83 @@ def f5(w, n1, n2, a1, a2):
|
|||||||
NOTE: this returns a tuple: (MacKey, LTK) in little-endian byte order
|
NOTE: this returns a tuple: (MacKey, LTK) in little-endian byte order
|
||||||
'''
|
'''
|
||||||
salt = bytes.fromhex('6C888391AAF5A53860370BDB5A6083BE')
|
salt = bytes.fromhex('6C888391AAF5A53860370BDB5A6083BE')
|
||||||
t = aes_cmac(bytes(reversed(w)), salt)
|
t = aes_cmac(reverse(w), salt)
|
||||||
key_id = bytes([0x62, 0x74, 0x6C, 0x65])
|
key_id = bytes([0x62, 0x74, 0x6C, 0x65])
|
||||||
return (
|
return (
|
||||||
bytes(
|
reverse(
|
||||||
reversed(
|
aes_cmac(
|
||||||
aes_cmac(
|
bytes([0])
|
||||||
bytes([0])
|
+ key_id
|
||||||
+ key_id
|
+ reverse(n1)
|
||||||
+ bytes(reversed(n1))
|
+ reverse(n2)
|
||||||
+ bytes(reversed(n2))
|
+ reverse(a1)
|
||||||
+ bytes(reversed(a1))
|
+ reverse(a2)
|
||||||
+ bytes(reversed(a2))
|
+ bytes([1, 0]),
|
||||||
+ bytes([1, 0]),
|
t,
|
||||||
t,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
bytes(
|
reverse(
|
||||||
reversed(
|
aes_cmac(
|
||||||
aes_cmac(
|
bytes([1])
|
||||||
bytes([1])
|
+ key_id
|
||||||
+ key_id
|
+ reverse(n1)
|
||||||
+ bytes(reversed(n1))
|
+ reverse(n2)
|
||||||
+ bytes(reversed(n2))
|
+ reverse(a1)
|
||||||
+ bytes(reversed(a1))
|
+ reverse(a2)
|
||||||
+ bytes(reversed(a2))
|
+ bytes([1, 0]),
|
||||||
+ bytes([1, 0]),
|
t,
|
||||||
t,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def f6(w, n1, n2, r, io_cap, a1, a2): # pylint: disable=redefined-outer-name
|
def f6(
|
||||||
|
w: bytes, n1: bytes, n2: bytes, r: bytes, io_cap: bytes, a1: bytes, a2: bytes
|
||||||
|
) -> bytes: # pylint: disable=redefined-outer-name
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec, Vol 3, Part H - 2.2.8 LE Secure Connections Check Value
|
See Bluetooth spec, Vol 3, Part H - 2.2.8 LE Secure Connections Check Value
|
||||||
Generation Function f6
|
Generation Function f6
|
||||||
'''
|
'''
|
||||||
return bytes(
|
return reverse(
|
||||||
reversed(
|
aes_cmac(
|
||||||
aes_cmac(
|
reverse(n1)
|
||||||
bytes(reversed(n1))
|
+ reverse(n2)
|
||||||
+ bytes(reversed(n2))
|
+ reverse(r)
|
||||||
+ bytes(reversed(r))
|
+ reverse(io_cap)
|
||||||
+ bytes(reversed(io_cap))
|
+ reverse(a1)
|
||||||
+ bytes(reversed(a1))
|
+ reverse(a2),
|
||||||
+ bytes(reversed(a2)),
|
reverse(w),
|
||||||
bytes(reversed(w)),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def g2(u, v, x, y):
|
def g2(u: bytes, v: bytes, x: bytes, y: bytes) -> int:
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec, Vol 3, Part H - 2.2.9 LE Secure Connections Numeric Comparison
|
See Bluetooth spec, Vol 3, Part H - 2.2.9 LE Secure Connections Numeric Comparison
|
||||||
Value Generation Function g2
|
Value Generation Function g2
|
||||||
'''
|
'''
|
||||||
return int.from_bytes(
|
return int.from_bytes(
|
||||||
aes_cmac(
|
aes_cmac(
|
||||||
bytes(reversed(u)) + bytes(reversed(v)) + bytes(reversed(y)),
|
reverse(u) + reverse(v) + reverse(y),
|
||||||
bytes(reversed(x)),
|
reverse(x),
|
||||||
)[-4:],
|
)[-4:],
|
||||||
byteorder='big',
|
byteorder='big',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def h6(w, key_id):
|
def h6(w: bytes, key_id: bytes) -> bytes:
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec, Vol 3, Part H - 2.2.10 Link key conversion function h6
|
See Bluetooth spec, Vol 3, Part H - 2.2.10 Link key conversion function h6
|
||||||
'''
|
'''
|
||||||
return aes_cmac(key_id, w)
|
return reverse(aes_cmac(key_id, reverse(w)))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def h7(salt, w):
|
def h7(salt: bytes, w: bytes) -> bytes:
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec, Vol 3, Part H - 2.2.11 Link key conversion function h7
|
See Bluetooth spec, Vol 3, Part H - 2.2.11 Link key conversion function h7
|
||||||
'''
|
'''
|
||||||
return aes_cmac(w, salt)
|
return reverse(aes_cmac(reverse(w), salt))
|
||||||
|
|||||||
966
bumble/device.py
966
bumble/device.py
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,8 @@ like loading firmware after a cold start.
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import abc
|
import abc
|
||||||
import logging
|
import logging
|
||||||
|
import pathlib
|
||||||
|
import platform
|
||||||
from . import rtk
|
from . import rtk
|
||||||
|
|
||||||
|
|
||||||
@@ -66,3 +68,24 @@ async def get_driver_for_host(host):
|
|||||||
return driver
|
return driver
|
||||||
|
|
||||||
return None
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -34,10 +34,9 @@ import weakref
|
|||||||
|
|
||||||
|
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
hci_command_op_code,
|
hci_vendor_command_op_code,
|
||||||
STATUS_SPEC,
|
STATUS_SPEC,
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
HCI_COMMAND_NAMES,
|
|
||||||
HCI_Command,
|
HCI_Command,
|
||||||
HCI_Reset_Command,
|
HCI_Reset_Command,
|
||||||
HCI_Read_Local_Version_Information_Command,
|
HCI_Read_Local_Version_Information_Command,
|
||||||
@@ -125,6 +124,7 @@ RTK_USB_PRODUCTS = {
|
|||||||
(0x2550, 0x8761),
|
(0x2550, 0x8761),
|
||||||
(0x2B89, 0x8761),
|
(0x2B89, 0x8761),
|
||||||
(0x7392, 0xC611),
|
(0x7392, 0xC611),
|
||||||
|
(0x0BDA, 0x877B),
|
||||||
# Realtek 8821AE
|
# Realtek 8821AE
|
||||||
(0x0B05, 0x17DC),
|
(0x0B05, 0x17DC),
|
||||||
(0x13D3, 0x3414),
|
(0x13D3, 0x3414),
|
||||||
@@ -178,8 +178,10 @@ RTK_USB_PRODUCTS = {
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# HCI Commands
|
# HCI Commands
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
HCI_RTK_READ_ROM_VERSION_COMMAND = hci_command_op_code(0x3F, 0x6D)
|
HCI_RTK_READ_ROM_VERSION_COMMAND = hci_vendor_command_op_code(0x6D)
|
||||||
HCI_COMMAND_NAMES[HCI_RTK_READ_ROM_VERSION_COMMAND] = "HCI_RTK_READ_ROM_VERSION_COMMAND"
|
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)])
|
@HCI_Command.command(return_parameters_fields=[("status", STATUS_SPEC), ("version", 1)])
|
||||||
@@ -187,10 +189,6 @@ class HCI_RTK_Read_ROM_Version_Command(HCI_Command):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
HCI_RTK_DOWNLOAD_COMMAND = hci_command_op_code(0x3F, 0x20)
|
|
||||||
HCI_COMMAND_NAMES[HCI_RTK_DOWNLOAD_COMMAND] = "HCI_RTK_DOWNLOAD_COMMAND"
|
|
||||||
|
|
||||||
|
|
||||||
@HCI_Command.command(
|
@HCI_Command.command(
|
||||||
fields=[("index", 1), ("payload", RTK_FRAGMENT_LENGTH)],
|
fields=[("index", 1), ("payload", RTK_FRAGMENT_LENGTH)],
|
||||||
return_parameters_fields=[("status", STATUS_SPEC), ("index", 1)],
|
return_parameters_fields=[("status", STATUS_SPEC), ("index", 1)],
|
||||||
@@ -199,10 +197,6 @@ class HCI_RTK_Download_Command(HCI_Command):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
HCI_RTK_DROP_FIRMWARE_COMMAND = hci_command_op_code(0x3F, 0x66)
|
|
||||||
HCI_COMMAND_NAMES[HCI_RTK_DROP_FIRMWARE_COMMAND] = "HCI_RTK_DROP_FIRMWARE_COMMAND"
|
|
||||||
|
|
||||||
|
|
||||||
@HCI_Command.command()
|
@HCI_Command.command()
|
||||||
class HCI_RTK_Drop_Firmware_Command(HCI_Command):
|
class HCI_RTK_Drop_Firmware_Command(HCI_Command):
|
||||||
pass
|
pass
|
||||||
@@ -445,6 +439,11 @@ class Driver:
|
|||||||
# When the environment variable is set, don't look elsewhere
|
# When the environment variable is set, don't look elsewhere
|
||||||
return None
|
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
|
# Then, look in the package's driver directory
|
||||||
if (path := pathlib.Path(__file__).parent / "rtk_fw" / file_name).is_file():
|
if (path := pathlib.Path(__file__).parent / "rtk_fw" / file_name).is_file():
|
||||||
logger.debug(f"{file_name} found in package dir")
|
logger.debug(f"{file_name} found in package dir")
|
||||||
@@ -645,3 +644,16 @@ class Driver:
|
|||||||
await self.download_firmware()
|
await self.download_firmware()
|
||||||
await self.host.send_command(HCI_Reset_Command(), check_result=True)
|
await self.host.send_command(HCI_Reset_Command(), check_result=True)
|
||||||
logger.info(f"loaded FW image {self.driver_info.fw_name}")
|
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
|
||||||
|
|||||||
164
bumble/gatt.py
164
bumble/gatt.py
@@ -28,7 +28,7 @@ import enum
|
|||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional, Sequence, List
|
from typing import Optional, Sequence, Iterable, List, Union
|
||||||
|
|
||||||
from .colors import color
|
from .colors import color
|
||||||
from .core import UUID, get_dict_key_by_value
|
from .core import UUID, get_dict_key_by_value
|
||||||
@@ -93,20 +93,35 @@ GATT_RECONNECTION_CONFIGURATION_SERVICE = UUID.from_16_bits(0x1829, 'Reconne
|
|||||||
GATT_INSULIN_DELIVERY_SERVICE = UUID.from_16_bits(0x183A, 'Insulin Delivery')
|
GATT_INSULIN_DELIVERY_SERVICE = UUID.from_16_bits(0x183A, 'Insulin Delivery')
|
||||||
GATT_BINARY_SENSOR_SERVICE = UUID.from_16_bits(0x183B, 'Binary Sensor')
|
GATT_BINARY_SENSOR_SERVICE = UUID.from_16_bits(0x183B, 'Binary Sensor')
|
||||||
GATT_EMERGENCY_CONFIGURATION_SERVICE = UUID.from_16_bits(0x183C, 'Emergency Configuration')
|
GATT_EMERGENCY_CONFIGURATION_SERVICE = UUID.from_16_bits(0x183C, 'Emergency Configuration')
|
||||||
|
GATT_AUTHORIZATION_CONTROL_SERVICE = UUID.from_16_bits(0x183D, 'Authorization Control')
|
||||||
GATT_PHYSICAL_ACTIVITY_MONITOR_SERVICE = UUID.from_16_bits(0x183E, 'Physical Activity Monitor')
|
GATT_PHYSICAL_ACTIVITY_MONITOR_SERVICE = UUID.from_16_bits(0x183E, 'Physical Activity Monitor')
|
||||||
|
GATT_ELAPSED_TIME_SERVICE = UUID.from_16_bits(0x183F, 'Elapsed Time')
|
||||||
|
GATT_GENERIC_HEALTH_SENSOR_SERVICE = UUID.from_16_bits(0x1840, 'Generic Health Sensor')
|
||||||
GATT_AUDIO_INPUT_CONTROL_SERVICE = UUID.from_16_bits(0x1843, 'Audio Input Control')
|
GATT_AUDIO_INPUT_CONTROL_SERVICE = UUID.from_16_bits(0x1843, 'Audio Input Control')
|
||||||
GATT_VOLUME_CONTROL_SERVICE = UUID.from_16_bits(0x1844, 'Volume Control')
|
GATT_VOLUME_CONTROL_SERVICE = UUID.from_16_bits(0x1844, 'Volume Control')
|
||||||
GATT_VOLUME_OFFSET_CONTROL_SERVICE = UUID.from_16_bits(0x1845, 'Volume Offset Control')
|
GATT_VOLUME_OFFSET_CONTROL_SERVICE = UUID.from_16_bits(0x1845, 'Volume Offset Control')
|
||||||
GATT_COORDINATED_SET_IDENTIFICATION_SERVICE = UUID.from_16_bits(0x1846, 'Coordinated Set Identification Service')
|
GATT_COORDINATED_SET_IDENTIFICATION_SERVICE = UUID.from_16_bits(0x1846, 'Coordinated Set Identification')
|
||||||
GATT_DEVICE_TIME_SERVICE = UUID.from_16_bits(0x1847, 'Device Time')
|
GATT_DEVICE_TIME_SERVICE = UUID.from_16_bits(0x1847, 'Device Time')
|
||||||
GATT_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1848, 'Media Control Service')
|
GATT_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1848, 'Media Control')
|
||||||
GATT_GENERIC_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1849, 'Generic Media Control Service')
|
GATT_GENERIC_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1849, 'Generic Media Control')
|
||||||
GATT_CONSTANT_TONE_EXTENSION_SERVICE = UUID.from_16_bits(0x184A, 'Constant Tone Extension')
|
GATT_CONSTANT_TONE_EXTENSION_SERVICE = UUID.from_16_bits(0x184A, 'Constant Tone Extension')
|
||||||
GATT_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184B, 'Telephone Bearer Service')
|
GATT_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184B, 'Telephone Bearer')
|
||||||
GATT_GENERIC_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184C, 'Generic Telephone Bearer Service')
|
GATT_GENERIC_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184C, 'Generic Telephone Bearer')
|
||||||
GATT_MICROPHONE_CONTROL_SERVICE = UUID.from_16_bits(0x184D, 'Microphone Control')
|
GATT_MICROPHONE_CONTROL_SERVICE = UUID.from_16_bits(0x184D, 'Microphone Control')
|
||||||
|
GATT_AUDIO_STREAM_CONTROL_SERVICE = UUID.from_16_bits(0x184E, 'Audio Stream Control')
|
||||||
|
GATT_BROADCAST_AUDIO_SCAN_SERVICE = UUID.from_16_bits(0x184F, 'Broadcast Audio Scan')
|
||||||
|
GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE = UUID.from_16_bits(0x1850, 'Published Audio Capabilities')
|
||||||
|
GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1851, 'Basic Audio Announcement')
|
||||||
|
GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1852, 'Broadcast Audio Announcement')
|
||||||
|
GATT_COMMON_AUDIO_SERVICE = UUID.from_16_bits(0x1853, 'Common Audio')
|
||||||
|
GATT_HEARING_ACCESS_SERVICE = UUID.from_16_bits(0x1854, 'Hearing Access')
|
||||||
|
GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE = UUID.from_16_bits(0x1855, 'Telephony and Media Audio')
|
||||||
|
GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1856, 'Public Broadcast Announcement')
|
||||||
|
GATT_ELECTRONIC_SHELF_LABEL_SERVICE = UUID.from_16_bits(0X1857, 'Electronic Shelf Label')
|
||||||
|
GATT_GAMING_AUDIO_SERVICE = UUID.from_16_bits(0x1858, 'Gaming Audio')
|
||||||
|
GATT_MESH_PROXY_SOLICITATION_SERVICE = UUID.from_16_bits(0x1859, 'Mesh Audio Solicitation')
|
||||||
|
|
||||||
# Types
|
# Attribute Types
|
||||||
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2800, 'Primary Service')
|
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2800, 'Primary Service')
|
||||||
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2801, 'Secondary Service')
|
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2801, 'Secondary Service')
|
||||||
GATT_INCLUDE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2802, 'Include')
|
GATT_INCLUDE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2802, 'Include')
|
||||||
@@ -129,6 +144,8 @@ GATT_ENVIRONMENTAL_SENSING_MEASUREMENT_DESCRIPTOR = UUID.from_16_bits(0x290C,
|
|||||||
GATT_ENVIRONMENTAL_SENSING_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290D, 'Environmental Sensing Trigger Setting')
|
GATT_ENVIRONMENTAL_SENSING_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290D, 'Environmental Sensing Trigger Setting')
|
||||||
GATT_TIME_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290E, 'Time Trigger Setting')
|
GATT_TIME_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290E, 'Time Trigger Setting')
|
||||||
GATT_COMPLETE_BR_EDR_TRANSPORT_BLOCK_DATA_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Complete BR-EDR Transport Block Data')
|
GATT_COMPLETE_BR_EDR_TRANSPORT_BLOCK_DATA_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Complete BR-EDR Transport Block Data')
|
||||||
|
GATT_OBSERVATION_SCHEDULE_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Observation Schedule')
|
||||||
|
GATT_VALID_RANGE_AND_ACCURACY_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Valid Range And Accuracy')
|
||||||
|
|
||||||
# Device Information Service
|
# Device Information Service
|
||||||
GATT_SYSTEM_ID_CHARACTERISTIC = UUID.from_16_bits(0x2A23, 'System ID')
|
GATT_SYSTEM_ID_CHARACTERISTIC = UUID.from_16_bits(0x2A23, 'System ID')
|
||||||
@@ -156,6 +173,96 @@ GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2A39, 'Heart
|
|||||||
# Battery Service
|
# Battery Service
|
||||||
GATT_BATTERY_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A19, 'Battery Level')
|
GATT_BATTERY_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A19, 'Battery Level')
|
||||||
|
|
||||||
|
# Telephony And Media Audio Service (TMAS)
|
||||||
|
GATT_TMAP_ROLE_CHARACTERISTIC = UUID.from_16_bits(0x2B51, 'TMAP Role')
|
||||||
|
|
||||||
|
# Audio Input Control Service (AICS)
|
||||||
|
GATT_AUDIO_INPUT_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B77, 'Audio Input State')
|
||||||
|
GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC = UUID.from_16_bits(0x2B78, 'Gain Settings Attribute')
|
||||||
|
GATT_AUDIO_INPUT_TYPE_CHARACTERISTIC = UUID.from_16_bits(0x2B79, 'Audio Input Type')
|
||||||
|
GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC = UUID.from_16_bits(0x2B7A, 'Audio Input Status')
|
||||||
|
GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2B7B, 'Audio Input Control Point')
|
||||||
|
GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC = UUID.from_16_bits(0x2B7C, 'Audio Input Description')
|
||||||
|
|
||||||
|
# Volume Control Service (VCS)
|
||||||
|
GATT_VOLUME_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B7D, 'Volume State')
|
||||||
|
GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2B7E, 'Volume Control Point')
|
||||||
|
GATT_VOLUME_FLAGS_CHARACTERISTIC = UUID.from_16_bits(0x2B7F, 'Volume Flags')
|
||||||
|
|
||||||
|
# Volume Offset Control Service (VOCS)
|
||||||
|
GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B80, 'Volume Offset State')
|
||||||
|
GATT_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2B81, 'Audio Location')
|
||||||
|
GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2B82, 'Volume Offset Control Point')
|
||||||
|
GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC = UUID.from_16_bits(0x2B83, 'Audio Output Description')
|
||||||
|
|
||||||
|
# Coordinated Set Identification Service (CSIS)
|
||||||
|
GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC = UUID.from_16_bits(0x2B84, 'Set Identity Resolving Key')
|
||||||
|
GATT_COORDINATED_SET_SIZE_CHARACTERISTIC = UUID.from_16_bits(0x2B85, 'Coordinated Set Size')
|
||||||
|
GATT_SET_MEMBER_LOCK_CHARACTERISTIC = UUID.from_16_bits(0x2B86, 'Set Member Lock')
|
||||||
|
GATT_SET_MEMBER_RANK_CHARACTERISTIC = UUID.from_16_bits(0x2B87, 'Set Member Rank')
|
||||||
|
|
||||||
|
# Media Control Service (MCS)
|
||||||
|
GATT_MEDIA_PLAYER_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2B93, 'Media Player Name')
|
||||||
|
GATT_MEDIA_PLAYER_ICON_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2B94, 'Media Player Icon Object ID')
|
||||||
|
GATT_MEDIA_PLAYER_ICON_URL_CHARACTERISTIC = UUID.from_16_bits(0x2B95, 'Media Player Icon URL')
|
||||||
|
GATT_TRACK_CHANGED_CHARACTERISTIC = UUID.from_16_bits(0x2B96, 'Track Changed')
|
||||||
|
GATT_TRACK_TITLE_CHARACTERISTIC = UUID.from_16_bits(0x2B97, 'Track Title')
|
||||||
|
GATT_TRACK_DURATION_CHARACTERISTIC = UUID.from_16_bits(0x2B98, 'Track Duration')
|
||||||
|
GATT_TRACK_POSITION_CHARACTERISTIC = UUID.from_16_bits(0x2B99, 'Track Position')
|
||||||
|
GATT_PLAYBACK_SPEED_CHARACTERISTIC = UUID.from_16_bits(0x2B9A, 'Playback Speed')
|
||||||
|
GATT_SEEKING_SPEED_CHARACTERISTIC = UUID.from_16_bits(0x2B9B, 'Seeking Speed')
|
||||||
|
GATT_CURRENT_TRACK_SEGMENTS_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2B9C, 'Current Track Segments Object ID')
|
||||||
|
GATT_CURRENT_TRACK_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2B9D, 'Current Track Object ID')
|
||||||
|
GATT_NEXT_TRACK_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2B9E, 'Next Track Object ID')
|
||||||
|
GATT_PARENT_GROUP_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2B9F, 'Parent Group Object ID')
|
||||||
|
GATT_CURRENT_GROUP_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BA0, 'Current Group Object ID')
|
||||||
|
GATT_PLAYING_ORDER_CHARACTERISTIC = UUID.from_16_bits(0x2BA1, 'Playing Order')
|
||||||
|
GATT_PLAYING_ORDERS_SUPPORTED_CHARACTERISTIC = UUID.from_16_bits(0x2BA2, 'Playing Orders Supported')
|
||||||
|
GATT_MEDIA_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2BA3, 'Media State')
|
||||||
|
GATT_MEDIA_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BA4, 'Media Control Point')
|
||||||
|
GATT_MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_CHARACTERISTIC = UUID.from_16_bits(0x2BA5, 'Media Control Point Opcodes Supported')
|
||||||
|
GATT_SEARCH_RESULTS_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BA6, 'Search Results Object ID')
|
||||||
|
GATT_SEARCH_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BA7, 'Search Control Point')
|
||||||
|
GATT_CONTENT_CONTROL_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BBA, 'Content Control Id')
|
||||||
|
|
||||||
|
# Telephone Bearer Service (TBS)
|
||||||
|
GATT_BEARER_PROVIDER_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2BB4, 'Bearer Provider Name')
|
||||||
|
GATT_BEARER_UCI_CHARACTERISTIC = UUID.from_16_bits(0x2BB5, 'Bearer UCI')
|
||||||
|
GATT_BEARER_TECHNOLOGY_CHARACTERISTIC = UUID.from_16_bits(0x2BB6, 'Bearer Technology')
|
||||||
|
GATT_BEARER_URI_SCHEMES_SUPPORTED_LIST_CHARACTERISTIC = UUID.from_16_bits(0x2BB7, 'Bearer URI Schemes Supported List')
|
||||||
|
GATT_BEARER_SIGNAL_STRENGTH_CHARACTERISTIC = UUID.from_16_bits(0x2BB8, 'Bearer Signal Strength')
|
||||||
|
GATT_BEARER_SIGNAL_STRENGTH_REPORTING_INTERVAL_CHARACTERISTIC = UUID.from_16_bits(0x2BB9, 'Bearer Signal Strength Reporting Interval')
|
||||||
|
GATT_BEARER_LIST_CURRENT_CALLS_CHARACTERISTIC = UUID.from_16_bits(0x2BBA, 'Bearer List Current Calls')
|
||||||
|
GATT_CONTENT_CONTROL_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BBB, 'Content Control ID')
|
||||||
|
GATT_STATUS_FLAGS_CHARACTERISTIC = UUID.from_16_bits(0x2BBC, 'Status Flags')
|
||||||
|
GATT_INCOMING_CALL_TARGET_BEARER_URI_CHARACTERISTIC = UUID.from_16_bits(0x2BBD, 'Incoming Call Target Bearer URI')
|
||||||
|
GATT_CALL_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2BBE, 'Call State')
|
||||||
|
GATT_CALL_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BBF, 'Call Control Point')
|
||||||
|
GATT_CALL_CONTROL_POINT_OPTIONAL_OPCODES_CHARACTERISTIC = UUID.from_16_bits(0x2BC0, 'Call Control Point Optional Opcodes')
|
||||||
|
GATT_TERMINATION_REASON_CHARACTERISTIC = UUID.from_16_bits(0x2BC1, 'Termination Reason')
|
||||||
|
GATT_INCOMING_CALL_CHARACTERISTIC = UUID.from_16_bits(0x2BC2, 'Incoming Call')
|
||||||
|
GATT_CALL_FRIENDLY_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2BC3, 'Call Friendly Name')
|
||||||
|
|
||||||
|
# Microphone Control Service (MICS)
|
||||||
|
GATT_MUTE_CHARACTERISTIC = UUID.from_16_bits(0x2BC3, 'Mute')
|
||||||
|
|
||||||
|
# Audio Stream Control Service (ASCS)
|
||||||
|
GATT_SINK_ASE_CHARACTERISTIC = UUID.from_16_bits(0x2BC4, 'Sink ASE')
|
||||||
|
GATT_SOURCE_ASE_CHARACTERISTIC = UUID.from_16_bits(0x2BC5, 'Source ASE')
|
||||||
|
GATT_ASE_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BC6, 'ASE Control Point')
|
||||||
|
|
||||||
|
# Broadcast Audio Scan Service (BASS)
|
||||||
|
GATT_BROADCAST_AUDIO_SCAN_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BC7, 'Broadcast Audio Scan Control Point')
|
||||||
|
GATT_BROADCAST_RECEIVE_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2BC8, 'Broadcast Receive State')
|
||||||
|
|
||||||
|
# Published Audio Capabilities Service (PACS)
|
||||||
|
GATT_SINK_PAC_CHARACTERISTIC = UUID.from_16_bits(0x2BC9, 'Sink PAC')
|
||||||
|
GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2BCA, 'Sink Audio Location')
|
||||||
|
GATT_SOURCE_PAC_CHARACTERISTIC = UUID.from_16_bits(0x2BCB, 'Source PAC')
|
||||||
|
GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2BCC, 'Source Audio Location')
|
||||||
|
GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCD, 'Available Audio Contexts')
|
||||||
|
GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCE, 'Supported Audio Contexts')
|
||||||
|
|
||||||
# ASHA Service
|
# ASHA Service
|
||||||
GATT_ASHA_SERVICE = UUID.from_16_bits(0xFDF0, 'Audio Streaming for Hearing Aid')
|
GATT_ASHA_SERVICE = UUID.from_16_bits(0xFDF0, 'Audio Streaming for Hearing Aid')
|
||||||
GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC = UUID('6333651e-c481-4a3e-9169-7c902aad37bb', 'ReadOnlyProperties')
|
GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC = UUID('6333651e-c481-4a3e-9169-7c902aad37bb', 'ReadOnlyProperties')
|
||||||
@@ -177,6 +284,9 @@ GATT_BOOT_KEYBOARD_INPUT_REPORT_CHARACTERISTIC = UUID.from_16_bi
|
|||||||
GATT_CURRENT_TIME_CHARACTERISTIC = UUID.from_16_bits(0x2A2B, 'Current Time')
|
GATT_CURRENT_TIME_CHARACTERISTIC = UUID.from_16_bits(0x2A2B, 'Current Time')
|
||||||
GATT_BOOT_KEYBOARD_OUTPUT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A32, 'Boot Keyboard Output Report')
|
GATT_BOOT_KEYBOARD_OUTPUT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A32, 'Boot Keyboard Output Report')
|
||||||
GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bits(0x2AA6, 'Central Address Resolution')
|
GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bits(0x2AA6, 'Central Address Resolution')
|
||||||
|
GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2B29, 'Client Supported Features')
|
||||||
|
GATT_DATABASE_HASH_CHARACTERISTIC = UUID.from_16_bits(0x2B2A, 'Database Hash')
|
||||||
|
GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2B3A, 'Server Supported Features')
|
||||||
|
|
||||||
# fmt: on
|
# fmt: on
|
||||||
# pylint: enable=line-too-long
|
# pylint: enable=line-too-long
|
||||||
@@ -187,7 +297,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:
|
for service in services:
|
||||||
print(color(str(service), 'cyan'))
|
print(color(str(service), 'cyan'))
|
||||||
|
|
||||||
@@ -210,11 +320,11 @@ class Service(Attribute):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
uuid,
|
uuid: Union[str, UUID],
|
||||||
characteristics: List[Characteristic],
|
characteristics: List[Characteristic],
|
||||||
primary=True,
|
primary=True,
|
||||||
included_services: List[Service] = [],
|
included_services: List[Service] = [],
|
||||||
):
|
) -> None:
|
||||||
# Convert the uuid to a UUID object if it isn't already
|
# Convert the uuid to a UUID object if it isn't already
|
||||||
if isinstance(uuid, str):
|
if isinstance(uuid, str):
|
||||||
uuid = UUID(uuid)
|
uuid = UUID(uuid)
|
||||||
@@ -239,7 +349,7 @@ class Service(Attribute):
|
|||||||
"""
|
"""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'Service(handle=0x{self.handle:04X}, '
|
f'Service(handle=0x{self.handle:04X}, '
|
||||||
f'end=0x{self.end_group_handle:04X}, '
|
f'end=0x{self.end_group_handle:04X}, '
|
||||||
@@ -255,9 +365,11 @@ class TemplateService(Service):
|
|||||||
to expose their UUID as a class property
|
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)
|
super().__init__(self.UUID, characteristics, primary)
|
||||||
|
|
||||||
|
|
||||||
@@ -269,7 +381,7 @@ class IncludedServiceDeclaration(Attribute):
|
|||||||
|
|
||||||
service: Service
|
service: Service
|
||||||
|
|
||||||
def __init__(self, service):
|
def __init__(self, service: Service) -> None:
|
||||||
declaration_bytes = struct.pack(
|
declaration_bytes = struct.pack(
|
||||||
'<HH2s', service.handle, service.end_group_handle, service.uuid.to_bytes()
|
'<HH2s', service.handle, service.end_group_handle, service.uuid.to_bytes()
|
||||||
)
|
)
|
||||||
@@ -278,7 +390,7 @@ class IncludedServiceDeclaration(Attribute):
|
|||||||
)
|
)
|
||||||
self.service = service
|
self.service = service
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'IncludedServiceDefinition(handle=0x{self.handle:04X}, '
|
f'IncludedServiceDefinition(handle=0x{self.handle:04X}, '
|
||||||
f'group_starting_handle=0x{self.service.handle:04X}, '
|
f'group_starting_handle=0x{self.service.handle:04X}, '
|
||||||
@@ -326,7 +438,7 @@ class Characteristic(Attribute):
|
|||||||
f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by , or |: {enum_list_str}\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):
|
def __str__(self) -> str:
|
||||||
# NOTE: we override this method to offer a consistent result between python
|
# NOTE: we override this method to offer a consistent result between python
|
||||||
# versions: the value returned by IntFlag.__str__() changed in version 11.
|
# versions: the value returned by IntFlag.__str__() changed in version 11.
|
||||||
return '|'.join(
|
return '|'.join(
|
||||||
@@ -348,10 +460,10 @@ class Characteristic(Attribute):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
uuid,
|
uuid: Union[str, bytes, UUID],
|
||||||
properties: Characteristic.Properties,
|
properties: Characteristic.Properties,
|
||||||
permissions,
|
permissions: Union[str, Attribute.Permissions],
|
||||||
value=b'',
|
value: Union[str, bytes, CharacteristicValue] = b'',
|
||||||
descriptors: Sequence[Descriptor] = (),
|
descriptors: Sequence[Descriptor] = (),
|
||||||
):
|
):
|
||||||
super().__init__(uuid, permissions, value)
|
super().__init__(uuid, permissions, value)
|
||||||
@@ -369,7 +481,7 @@ class Characteristic(Attribute):
|
|||||||
def has_properties(self, properties: Characteristic.Properties) -> bool:
|
def has_properties(self, properties: Characteristic.Properties) -> bool:
|
||||||
return self.properties & properties == properties
|
return self.properties & properties == properties
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'Characteristic(handle=0x{self.handle:04X}, '
|
f'Characteristic(handle=0x{self.handle:04X}, '
|
||||||
f'end=0x{self.end_group_handle:04X}, '
|
f'end=0x{self.end_group_handle:04X}, '
|
||||||
@@ -386,7 +498,7 @@ class CharacteristicDeclaration(Attribute):
|
|||||||
|
|
||||||
characteristic: Characteristic
|
characteristic: Characteristic
|
||||||
|
|
||||||
def __init__(self, characteristic, value_handle):
|
def __init__(self, characteristic: Characteristic, value_handle: int) -> None:
|
||||||
declaration_bytes = (
|
declaration_bytes = (
|
||||||
struct.pack('<BH', characteristic.properties, value_handle)
|
struct.pack('<BH', characteristic.properties, value_handle)
|
||||||
+ characteristic.uuid.to_pdu_bytes()
|
+ characteristic.uuid.to_pdu_bytes()
|
||||||
@@ -397,7 +509,7 @@ class CharacteristicDeclaration(Attribute):
|
|||||||
self.value_handle = value_handle
|
self.value_handle = value_handle
|
||||||
self.characteristic = characteristic
|
self.characteristic = characteristic
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
|
f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
|
||||||
f'value_handle=0x{self.value_handle:04X}, '
|
f'value_handle=0x{self.value_handle:04X}, '
|
||||||
@@ -520,7 +632,7 @@ class CharacteristicAdapter:
|
|||||||
|
|
||||||
return self.wrapped_characteristic.unsubscribe(subscriber)
|
return self.wrapped_characteristic.unsubscribe(subscriber)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
wrapped = str(self.wrapped_characteristic)
|
wrapped = str(self.wrapped_characteristic)
|
||||||
return f'{self.__class__.__name__}({wrapped})'
|
return f'{self.__class__.__name__}({wrapped})'
|
||||||
|
|
||||||
@@ -600,10 +712,10 @@ class UTF8CharacteristicAdapter(CharacteristicAdapter):
|
|||||||
Adapter that converts strings to/from bytes using UTF-8 encoding
|
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')
|
return value.encode('utf-8')
|
||||||
|
|
||||||
def decode_value(self, value):
|
def decode_value(self, value: bytes) -> str:
|
||||||
return value.decode('utf-8')
|
return value.decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
@@ -613,7 +725,7 @@ class Descriptor(Attribute):
|
|||||||
See Vol 3, Part G - 3.3.3 Characteristic Descriptor Declarations
|
See Vol 3, Part G - 3.3.3 Characteristic Descriptor Declarations
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'Descriptor(handle=0x{self.handle:04X}, '
|
f'Descriptor(handle=0x{self.handle:04X}, '
|
||||||
f'type={self.type}, '
|
f'type={self.type}, '
|
||||||
|
|||||||
@@ -28,7 +28,19 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from datetime import datetime
|
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,
|
||||||
|
Set,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
|
|
||||||
@@ -66,8 +78,12 @@ from .gatt import (
|
|||||||
GATT_INCLUDE_ATTRIBUTE_TYPE,
|
GATT_INCLUDE_ATTRIBUTE_TYPE,
|
||||||
Characteristic,
|
Characteristic,
|
||||||
ClientCharacteristicConfigurationBits,
|
ClientCharacteristicConfigurationBits,
|
||||||
|
TemplateService,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble.device import Connection
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -78,16 +94,16 @@ logger = logging.getLogger(__name__)
|
|||||||
# Proxies
|
# Proxies
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class AttributeProxy(EventEmitter):
|
class AttributeProxy(EventEmitter):
|
||||||
client: Client
|
def __init__(
|
||||||
|
self, client: Client, handle: int, end_group_handle: int, attribute_type: UUID
|
||||||
def __init__(self, client, handle, end_group_handle, attribute_type):
|
) -> None:
|
||||||
EventEmitter.__init__(self)
|
EventEmitter.__init__(self)
|
||||||
self.client = client
|
self.client = client
|
||||||
self.handle = handle
|
self.handle = handle
|
||||||
self.end_group_handle = end_group_handle
|
self.end_group_handle = end_group_handle
|
||||||
self.type = attribute_type
|
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(
|
return self.decode_value(
|
||||||
await self.client.read_value(self.handle, no_long_read)
|
await self.client.read_value(self.handle, no_long_read)
|
||||||
)
|
)
|
||||||
@@ -97,13 +113,13 @@ class AttributeProxy(EventEmitter):
|
|||||||
self.handle, self.encode_value(value), with_response
|
self.handle, self.encode_value(value), with_response
|
||||||
)
|
)
|
||||||
|
|
||||||
def encode_value(self, value):
|
def encode_value(self, value: Any) -> bytes:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def decode_value(self, value_bytes):
|
def decode_value(self, value_bytes: bytes) -> Any:
|
||||||
return value_bytes
|
return value_bytes
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f'Attribute(handle=0x{self.handle:04X}, type={self.type})'
|
return f'Attribute(handle=0x{self.handle:04X}, type={self.type})'
|
||||||
|
|
||||||
|
|
||||||
@@ -113,7 +129,7 @@ class ServiceProxy(AttributeProxy):
|
|||||||
included_services: List[ServiceProxy]
|
included_services: List[ServiceProxy]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_client(service_class, client, service_uuid):
|
def from_client(service_class, client: Client, service_uuid: UUID):
|
||||||
# The service and its characteristics are considered to have already been
|
# The service and its characteristics are considered to have already been
|
||||||
# discovered
|
# discovered
|
||||||
services = client.get_services_by_uuid(service_uuid)
|
services = client.get_services_by_uuid(service_uuid)
|
||||||
@@ -136,14 +152,14 @@ class ServiceProxy(AttributeProxy):
|
|||||||
def get_characteristics_by_uuid(self, uuid):
|
def get_characteristics_by_uuid(self, uuid):
|
||||||
return self.client.get_characteristics_by_uuid(uuid, self)
|
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})'
|
return f'Service(handle=0x{self.handle:04X}, uuid={self.uuid})'
|
||||||
|
|
||||||
|
|
||||||
class CharacteristicProxy(AttributeProxy):
|
class CharacteristicProxy(AttributeProxy):
|
||||||
properties: Characteristic.Properties
|
properties: Characteristic.Properties
|
||||||
descriptors: List[DescriptorProxy]
|
descriptors: List[DescriptorProxy]
|
||||||
subscribers: Dict[Any, Callable]
|
subscribers: Dict[Any, Callable[[bytes], Any]]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -171,7 +187,9 @@ class CharacteristicProxy(AttributeProxy):
|
|||||||
return await self.client.discover_descriptors(self)
|
return await self.client.discover_descriptors(self)
|
||||||
|
|
||||||
async def subscribe(
|
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 is not None:
|
||||||
if subscriber in self.subscribers:
|
if subscriber in self.subscribers:
|
||||||
@@ -189,13 +207,13 @@ class CharacteristicProxy(AttributeProxy):
|
|||||||
|
|
||||||
return await self.client.subscribe(self, subscriber, prefer_notify)
|
return await self.client.subscribe(self, subscriber, prefer_notify)
|
||||||
|
|
||||||
async def unsubscribe(self, subscriber=None):
|
async def unsubscribe(self, subscriber=None, force=False):
|
||||||
if subscriber in self.subscribers:
|
if subscriber in self.subscribers:
|
||||||
subscriber = self.subscribers.pop(subscriber)
|
subscriber = self.subscribers.pop(subscriber)
|
||||||
|
|
||||||
return await self.client.unsubscribe(self, subscriber)
|
return await self.client.unsubscribe(self, subscriber, force)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'Characteristic(handle=0x{self.handle:04X}, '
|
f'Characteristic(handle=0x{self.handle:04X}, '
|
||||||
f'uuid={self.uuid}, '
|
f'uuid={self.uuid}, '
|
||||||
@@ -207,7 +225,7 @@ class DescriptorProxy(AttributeProxy):
|
|||||||
def __init__(self, client, handle, descriptor_type):
|
def __init__(self, client, handle, descriptor_type):
|
||||||
super().__init__(client, handle, 0, 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})'
|
return f'Descriptor(handle=0x{self.handle:04X}, type={self.type})'
|
||||||
|
|
||||||
|
|
||||||
@@ -216,8 +234,10 @@ class ProfileServiceProxy:
|
|||||||
Base class for profile-specific service proxies
|
Base class for profile-specific service proxies
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
SERVICE_CLASS: Type[TemplateService]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_client(cls, client):
|
def from_client(cls, client: Client) -> ProfileServiceProxy:
|
||||||
return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID)
|
return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID)
|
||||||
|
|
||||||
|
|
||||||
@@ -227,30 +247,36 @@ class ProfileServiceProxy:
|
|||||||
class Client:
|
class Client:
|
||||||
services: List[ServiceProxy]
|
services: List[ServiceProxy]
|
||||||
cached_values: Dict[int, Tuple[datetime, bytes]]
|
cached_values: Dict[int, Tuple[datetime, bytes]]
|
||||||
|
notification_subscribers: Dict[
|
||||||
|
int, Set[Union[CharacteristicProxy, Callable[[bytes], Any]]]
|
||||||
|
]
|
||||||
|
indication_subscribers: Dict[
|
||||||
|
int, Set[Union[CharacteristicProxy, 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.connection = connection
|
||||||
self.mtu_exchange_done = False
|
self.mtu_exchange_done = False
|
||||||
self.request_semaphore = asyncio.Semaphore(1)
|
self.request_semaphore = asyncio.Semaphore(1)
|
||||||
self.pending_request = None
|
self.pending_request = None
|
||||||
self.pending_response = None
|
self.pending_response = None
|
||||||
self.notification_subscribers = (
|
self.notification_subscribers = {} # Subscriber set, by attribute handle
|
||||||
{}
|
self.indication_subscribers = {} # Subscriber set, by attribute handle
|
||||||
) # Notification subscribers, by attribute handle
|
|
||||||
self.indication_subscribers = {} # Indication subscribers, by attribute handle
|
|
||||||
self.services = []
|
self.services = []
|
||||||
self.cached_values = {}
|
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)
|
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(
|
logger.debug(
|
||||||
f'GATT Command from client: [0x{self.connection.handle:04X}] {command}'
|
f'GATT Command from client: [0x{self.connection.handle:04X}] {command}'
|
||||||
)
|
)
|
||||||
self.send_gatt_pdu(command.to_bytes())
|
self.send_gatt_pdu(command.to_bytes())
|
||||||
|
|
||||||
async def send_request(self, request):
|
async def send_request(self, request: ATT_PDU):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'GATT Request from client: [0x{self.connection.handle:04X}] {request}'
|
f'GATT Request from client: [0x{self.connection.handle:04X}] {request}'
|
||||||
)
|
)
|
||||||
@@ -279,14 +305,14 @@ class Client:
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def send_confirmation(self, confirmation):
|
def send_confirmation(self, confirmation: ATT_Handle_Value_Confirmation) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'GATT Confirmation from client: [0x{self.connection.handle:04X}] '
|
f'GATT Confirmation from client: [0x{self.connection.handle:04X}] '
|
||||||
f'{confirmation}'
|
f'{confirmation}'
|
||||||
)
|
)
|
||||||
self.send_gatt_pdu(confirmation.to_bytes())
|
self.send_gatt_pdu(confirmation.to_bytes())
|
||||||
|
|
||||||
async def request_mtu(self, mtu):
|
async def request_mtu(self, mtu: int) -> int:
|
||||||
# Check the range
|
# Check the range
|
||||||
if mtu < ATT_DEFAULT_MTU:
|
if mtu < ATT_DEFAULT_MTU:
|
||||||
raise ValueError(f'MTU must be >= {ATT_DEFAULT_MTU}')
|
raise ValueError(f'MTU must be >= {ATT_DEFAULT_MTU}')
|
||||||
@@ -313,10 +339,12 @@ class Client:
|
|||||||
|
|
||||||
return self.connection.att_mtu
|
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]
|
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
|
services = [service] if service else self.services
|
||||||
return [
|
return [
|
||||||
c
|
c
|
||||||
@@ -363,7 +391,7 @@ class Client:
|
|||||||
if not already_known:
|
if not already_known:
|
||||||
self.services.append(service)
|
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
|
See Vol 3, Part G - 4.4.1 Discover All Primary Services
|
||||||
'''
|
'''
|
||||||
@@ -435,7 +463,7 @@ class Client:
|
|||||||
|
|
||||||
return services
|
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
|
See Vol 3, Part G - 4.4.2 Discover Primary Service by Service UUID
|
||||||
'''
|
'''
|
||||||
@@ -468,7 +496,7 @@ class Client:
|
|||||||
f'{HCI_Constant.error_name(response.error_code)}'
|
f'{HCI_Constant.error_name(response.error_code)}'
|
||||||
)
|
)
|
||||||
# TODO raise appropriate exception
|
# TODO raise appropriate exception
|
||||||
return
|
return []
|
||||||
break
|
break
|
||||||
|
|
||||||
for attribute_handle, end_group_handle in response.handles_information:
|
for attribute_handle, end_group_handle in response.handles_information:
|
||||||
@@ -480,7 +508,7 @@ class Client:
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f'bogus handle values: {attribute_handle} {end_group_handle}'
|
f'bogus handle values: {attribute_handle} {end_group_handle}'
|
||||||
)
|
)
|
||||||
return
|
return []
|
||||||
|
|
||||||
# Create a service proxy for this service
|
# Create a service proxy for this service
|
||||||
service = ServiceProxy(
|
service = ServiceProxy(
|
||||||
@@ -657,8 +685,8 @@ class Client:
|
|||||||
async def discover_descriptors(
|
async def discover_descriptors(
|
||||||
self,
|
self,
|
||||||
characteristic: Optional[CharacteristicProxy] = None,
|
characteristic: Optional[CharacteristicProxy] = None,
|
||||||
start_handle=None,
|
start_handle: Optional[int] = None,
|
||||||
end_handle=None,
|
end_handle: Optional[int] = None,
|
||||||
) -> List[DescriptorProxy]:
|
) -> List[DescriptorProxy]:
|
||||||
'''
|
'''
|
||||||
See Vol 3, Part G - 4.7.1 Discover All Characteristic Descriptors
|
See Vol 3, Part G - 4.7.1 Discover All Characteristic Descriptors
|
||||||
@@ -721,7 +749,7 @@ class Client:
|
|||||||
|
|
||||||
return descriptors
|
return descriptors
|
||||||
|
|
||||||
async def discover_attributes(self):
|
async def discover_attributes(self) -> List[AttributeProxy]:
|
||||||
'''
|
'''
|
||||||
Discover all attributes, regardless of type
|
Discover all attributes, regardless of type
|
||||||
'''
|
'''
|
||||||
@@ -764,7 +792,12 @@ class Client:
|
|||||||
|
|
||||||
return attributes
|
return attributes
|
||||||
|
|
||||||
async def subscribe(self, characteristic, subscriber=None, prefer_notify=True):
|
async def subscribe(
|
||||||
|
self,
|
||||||
|
characteristic: CharacteristicProxy,
|
||||||
|
subscriber: Optional[Callable[[bytes], Any]] = None,
|
||||||
|
prefer_notify: bool = True,
|
||||||
|
) -> None:
|
||||||
# If we haven't already discovered the descriptors for this characteristic,
|
# If we haven't already discovered the descriptors for this characteristic,
|
||||||
# do it now
|
# do it now
|
||||||
if not characteristic.descriptors_discovered:
|
if not characteristic.descriptors_discovered:
|
||||||
@@ -801,6 +834,7 @@ class Client:
|
|||||||
subscriber_set = subscribers.setdefault(characteristic.handle, set())
|
subscriber_set = subscribers.setdefault(characteristic.handle, set())
|
||||||
if subscriber is not None:
|
if subscriber is not None:
|
||||||
subscriber_set.add(subscriber)
|
subscriber_set.add(subscriber)
|
||||||
|
|
||||||
# Add the characteristic as a subscriber, which will result in the
|
# Add the characteristic as a subscriber, which will result in the
|
||||||
# characteristic emitting an 'update' event when a notification or indication
|
# characteristic emitting an 'update' event when a notification or indication
|
||||||
# is received
|
# is received
|
||||||
@@ -808,7 +842,18 @@ class Client:
|
|||||||
|
|
||||||
await self.write_value(cccd, struct.pack('<H', bits), with_response=True)
|
await self.write_value(cccd, struct.pack('<H', bits), with_response=True)
|
||||||
|
|
||||||
async def unsubscribe(self, characteristic, subscriber=None):
|
async def unsubscribe(
|
||||||
|
self,
|
||||||
|
characteristic: CharacteristicProxy,
|
||||||
|
subscriber: Optional[Callable[[bytes], Any]] = None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Unsubscribe from a characteristic.
|
||||||
|
|
||||||
|
If `force` is True, this will write zeros to the CCCD when there are no
|
||||||
|
subscribers left, even if there were already no registered subscribers.
|
||||||
|
'''
|
||||||
# If we haven't already discovered the descriptors for this characteristic,
|
# If we haven't already discovered the descriptors for this characteristic,
|
||||||
# do it now
|
# do it now
|
||||||
if not characteristic.descriptors_discovered:
|
if not characteristic.descriptors_discovered:
|
||||||
@@ -822,29 +867,45 @@ class Client:
|
|||||||
logger.warning('unsubscribing from characteristic with no CCCD descriptor')
|
logger.warning('unsubscribing from characteristic with no CCCD descriptor')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Check if the characteristic has subscribers
|
||||||
|
if not (
|
||||||
|
characteristic.handle in self.notification_subscribers
|
||||||
|
or characteristic.handle in self.indication_subscribers
|
||||||
|
):
|
||||||
|
if not force:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove the subscriber(s)
|
||||||
if subscriber is not None:
|
if subscriber is not None:
|
||||||
# Remove matching subscriber from subscriber sets
|
# Remove matching subscriber from subscriber sets
|
||||||
for subscriber_set in (
|
for subscriber_set in (
|
||||||
self.notification_subscribers,
|
self.notification_subscribers,
|
||||||
self.indication_subscribers,
|
self.indication_subscribers,
|
||||||
):
|
):
|
||||||
subscribers = subscriber_set.get(characteristic.handle, [])
|
if (
|
||||||
if subscriber in subscribers:
|
subscribers := subscriber_set.get(characteristic.handle)
|
||||||
|
) and subscriber in subscribers:
|
||||||
subscribers.remove(subscriber)
|
subscribers.remove(subscriber)
|
||||||
|
|
||||||
# Cleanup if we removed the last one
|
# Cleanup if we removed the last one
|
||||||
if not subscribers:
|
if not subscribers:
|
||||||
del subscriber_set[characteristic.handle]
|
del subscriber_set[characteristic.handle]
|
||||||
else:
|
else:
|
||||||
# Remove all subscribers for this attribute from the sets!
|
# Remove all subscribers for this attribute from the sets
|
||||||
self.notification_subscribers.pop(characteristic.handle, None)
|
self.notification_subscribers.pop(characteristic.handle, None)
|
||||||
self.indication_subscribers.pop(characteristic.handle, None)
|
self.indication_subscribers.pop(characteristic.handle, None)
|
||||||
|
|
||||||
if not self.notification_subscribers and not self.indication_subscribers:
|
# Update the CCCD
|
||||||
|
if not (
|
||||||
|
characteristic.handle in self.notification_subscribers
|
||||||
|
or characteristic.handle in self.indication_subscribers
|
||||||
|
):
|
||||||
# No more subscribers left
|
# No more subscribers left
|
||||||
await self.write_value(cccd, b'\x00\x00', with_response=True)
|
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
|
||||||
|
) -> bytes:
|
||||||
'''
|
'''
|
||||||
See Vol 3, Part G - 4.8.1 Read Characteristic Value
|
See Vol 3, Part G - 4.8.1 Read Characteristic Value
|
||||||
|
|
||||||
@@ -905,7 +966,9 @@ class Client:
|
|||||||
# Return the value as bytes
|
# Return the value as bytes
|
||||||
return attribute_value
|
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
|
See Vol 3, Part G - 4.8.2 Read Using Characteristic UUID
|
||||||
'''
|
'''
|
||||||
@@ -960,7 +1023,12 @@ class Client:
|
|||||||
|
|
||||||
return characteristics_values
|
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
|
See Vol 3, Part G - 4.9.1 Write Without Response & 4.9.3 Write Characteristic
|
||||||
Value
|
Value
|
||||||
@@ -990,7 +1058,7 @@ class Client:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_gatt_pdu(self, att_pdu):
|
def on_gatt_pdu(self, att_pdu: ATT_PDU) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'GATT Response to client: [0x{self.connection.handle:04X}] {att_pdu}'
|
f'GATT Response to client: [0x{self.connection.handle:04X}] {att_pdu}'
|
||||||
)
|
)
|
||||||
@@ -1013,6 +1081,7 @@ class Client:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Return the response to the coroutine that is waiting for it
|
# 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)
|
self.pending_response.set_result(att_pdu)
|
||||||
else:
|
else:
|
||||||
handler_name = f'on_{att_pdu.name.lower()}'
|
handler_name = f'on_{att_pdu.name.lower()}'
|
||||||
@@ -1032,7 +1101,7 @@ class Client:
|
|||||||
def on_att_handle_value_notification(self, notification):
|
def on_att_handle_value_notification(self, notification):
|
||||||
# Call all subscribers
|
# Call all subscribers
|
||||||
subscribers = self.notification_subscribers.get(
|
subscribers = self.notification_subscribers.get(
|
||||||
notification.attribute_handle, []
|
notification.attribute_handle, set()
|
||||||
)
|
)
|
||||||
if not subscribers:
|
if not subscribers:
|
||||||
logger.warning('!!! received notification with no subscriber')
|
logger.warning('!!! received notification with no subscriber')
|
||||||
@@ -1046,7 +1115,9 @@ class Client:
|
|||||||
|
|
||||||
def on_att_handle_value_indication(self, indication):
|
def on_att_handle_value_indication(self, indication):
|
||||||
# Call all subscribers
|
# Call all subscribers
|
||||||
subscribers = self.indication_subscribers.get(indication.attribute_handle, [])
|
subscribers = self.indication_subscribers.get(
|
||||||
|
indication.attribute_handle, set()
|
||||||
|
)
|
||||||
if not subscribers:
|
if not subscribers:
|
||||||
logger.warning('!!! received indication with no subscriber')
|
logger.warning('!!! received indication with no subscriber')
|
||||||
|
|
||||||
@@ -1060,7 +1131,7 @@ class Client:
|
|||||||
# Confirm that we received the indication
|
# Confirm that we received the indication
|
||||||
self.send_confirmation(ATT_Handle_Value_Confirmation())
|
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] = (
|
self.cached_values[attribute_handle] = (
|
||||||
datetime.now(),
|
datetime.now(),
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -23,11 +23,12 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import struct
|
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 pyee import EventEmitter
|
||||||
|
|
||||||
from .colors import color
|
from .colors import color
|
||||||
@@ -42,6 +43,7 @@ from .att import (
|
|||||||
ATT_INVALID_OFFSET_ERROR,
|
ATT_INVALID_OFFSET_ERROR,
|
||||||
ATT_REQUEST_NOT_SUPPORTED_ERROR,
|
ATT_REQUEST_NOT_SUPPORTED_ERROR,
|
||||||
ATT_REQUESTS,
|
ATT_REQUESTS,
|
||||||
|
ATT_PDU,
|
||||||
ATT_UNLIKELY_ERROR_ERROR,
|
ATT_UNLIKELY_ERROR_ERROR,
|
||||||
ATT_UNSUPPORTED_GROUP_TYPE_ERROR,
|
ATT_UNSUPPORTED_GROUP_TYPE_ERROR,
|
||||||
ATT_Error,
|
ATT_Error,
|
||||||
@@ -73,6 +75,8 @@ from .gatt import (
|
|||||||
Service,
|
Service,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble.device import Device, Connection
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -91,8 +95,13 @@ GATT_SERVER_DEFAULT_MAX_MTU = 517
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Server(EventEmitter):
|
class Server(EventEmitter):
|
||||||
attributes: List[Attribute]
|
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__()
|
super().__init__()
|
||||||
self.device = device
|
self.device = device
|
||||||
self.services = []
|
self.services = []
|
||||||
@@ -107,16 +116,16 @@ class Server(EventEmitter):
|
|||||||
self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1))
|
self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1))
|
||||||
self.pending_confirmations = defaultdict(lambda: None)
|
self.pending_confirmations = defaultdict(lambda: None)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return "\n".join(map(str, self.attributes))
|
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)
|
self.device.send_l2cap_pdu(connection_handle, ATT_CID, pdu)
|
||||||
|
|
||||||
def next_handle(self):
|
def next_handle(self) -> int:
|
||||||
return 1 + len(self.attributes)
|
return 1 + len(self.attributes)
|
||||||
|
|
||||||
def get_advertising_service_data(self):
|
def get_advertising_service_data(self) -> Dict[Attribute, bytes]:
|
||||||
return {
|
return {
|
||||||
attribute: data
|
attribute: data
|
||||||
for attribute in self.attributes
|
for attribute in self.attributes
|
||||||
@@ -124,7 +133,7 @@ class Server(EventEmitter):
|
|||||||
and (data := attribute.get_advertising_data())
|
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)
|
attribute = self.attributes_by_handle.get(handle)
|
||||||
if attribute:
|
if attribute:
|
||||||
return attribute
|
return attribute
|
||||||
@@ -173,12 +182,17 @@ class Server(EventEmitter):
|
|||||||
|
|
||||||
return next(
|
return next(
|
||||||
(
|
(
|
||||||
(attribute, self.get_attribute(attribute.characteristic.handle))
|
(
|
||||||
|
attribute,
|
||||||
|
self.get_attribute(attribute.characteristic.handle),
|
||||||
|
) # type: ignore
|
||||||
for attribute in map(
|
for attribute in map(
|
||||||
self.get_attribute,
|
self.get_attribute,
|
||||||
range(service_handle.handle, service_handle.end_group_handle + 1),
|
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
|
and attribute.characteristic.uuid == characteristic_uuid
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
@@ -197,7 +211,7 @@ class Server(EventEmitter):
|
|||||||
|
|
||||||
return next(
|
return next(
|
||||||
(
|
(
|
||||||
attribute
|
attribute # type: ignore
|
||||||
for attribute in map(
|
for attribute in map(
|
||||||
self.get_attribute,
|
self.get_attribute,
|
||||||
range(
|
range(
|
||||||
@@ -205,12 +219,12 @@ class Server(EventEmitter):
|
|||||||
characteristic_value.end_group_handle + 1,
|
characteristic_value.end_group_handle + 1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if attribute.type == descriptor_uuid
|
if attribute is not None and attribute.type == descriptor_uuid
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_attribute(self, attribute):
|
def add_attribute(self, attribute: Attribute) -> None:
|
||||||
# Assign a handle to this attribute
|
# Assign a handle to this attribute
|
||||||
attribute.handle = self.next_handle()
|
attribute.handle = self.next_handle()
|
||||||
attribute.end_group_handle = (
|
attribute.end_group_handle = (
|
||||||
@@ -220,7 +234,7 @@ class Server(EventEmitter):
|
|||||||
# Add this attribute to the list
|
# Add this attribute to the list
|
||||||
self.attributes.append(attribute)
|
self.attributes.append(attribute)
|
||||||
|
|
||||||
def add_service(self, service: Service):
|
def add_service(self, service: Service) -> None:
|
||||||
# Add the service attribute to the DB
|
# Add the service attribute to the DB
|
||||||
self.add_attribute(service)
|
self.add_attribute(service)
|
||||||
|
|
||||||
@@ -285,11 +299,13 @@ class Server(EventEmitter):
|
|||||||
service.end_group_handle = self.attributes[-1].handle
|
service.end_group_handle = self.attributes[-1].handle
|
||||||
self.services.append(service)
|
self.services.append(service)
|
||||||
|
|
||||||
def add_services(self, services):
|
def add_services(self, services: Iterable[Service]) -> None:
|
||||||
for service in services:
|
for service in services:
|
||||||
self.add_service(service)
|
self.add_service(service)
|
||||||
|
|
||||||
def read_cccd(self, connection, characteristic):
|
def read_cccd(
|
||||||
|
self, connection: Optional[Connection], characteristic: Characteristic
|
||||||
|
) -> bytes:
|
||||||
if connection is None:
|
if connection is None:
|
||||||
return bytes([0, 0])
|
return bytes([0, 0])
|
||||||
|
|
||||||
@@ -300,7 +316,12 @@ class Server(EventEmitter):
|
|||||||
|
|
||||||
return cccd or bytes([0, 0])
|
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(
|
logger.debug(
|
||||||
f'Subscription update for connection=0x{connection.handle:04X}, '
|
f'Subscription update for connection=0x{connection.handle:04X}, '
|
||||||
f'handle=0x{characteristic.handle:04X}: {value.hex()}'
|
f'handle=0x{characteristic.handle:04X}: {value.hex()}'
|
||||||
@@ -327,13 +348,19 @@ class Server(EventEmitter):
|
|||||||
indicate_enabled,
|
indicate_enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
def send_response(self, connection, response):
|
def send_response(self, connection: Connection, response: ATT_PDU) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'GATT Response from server: [0x{connection.handle:04X}] {response}'
|
f'GATT Response from server: [0x{connection.handle:04X}] {response}'
|
||||||
)
|
)
|
||||||
self.send_gatt_pdu(connection.handle, response.to_bytes())
|
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
|
# Check if there's a subscriber
|
||||||
if not force:
|
if not force:
|
||||||
subscribers = self.subscribers.get(connection.handle)
|
subscribers = self.subscribers.get(connection.handle)
|
||||||
@@ -370,7 +397,13 @@ class Server(EventEmitter):
|
|||||||
)
|
)
|
||||||
self.send_gatt_pdu(connection.handle, bytes(notification))
|
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
|
# Check if there's a subscriber
|
||||||
if not force:
|
if not force:
|
||||||
subscribers = self.subscribers.get(connection.handle)
|
subscribers = self.subscribers.get(connection.handle)
|
||||||
@@ -411,15 +444,13 @@ class Server(EventEmitter):
|
|||||||
assert self.pending_confirmations[connection.handle] is None
|
assert self.pending_confirmations[connection.handle] is None
|
||||||
|
|
||||||
# Create a future value to hold the eventual response
|
# Create a future value to hold the eventual response
|
||||||
self.pending_confirmations[
|
pending_confirmation = self.pending_confirmations[
|
||||||
connection.handle
|
connection.handle
|
||||||
] = asyncio.get_running_loop().create_future()
|
] = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.send_gatt_pdu(connection.handle, indication.to_bytes())
|
self.send_gatt_pdu(connection.handle, indication.to_bytes())
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(pending_confirmation, GATT_REQUEST_TIMEOUT)
|
||||||
self.pending_confirmations[connection.handle], GATT_REQUEST_TIMEOUT
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError as error:
|
except asyncio.TimeoutError as error:
|
||||||
logger.warning(color('!!! GATT Indicate timeout', 'red'))
|
logger.warning(color('!!! GATT Indicate timeout', 'red'))
|
||||||
raise TimeoutError(f'GATT timeout for {indication.name}') from error
|
raise TimeoutError(f'GATT timeout for {indication.name}') from error
|
||||||
@@ -427,8 +458,12 @@ class Server(EventEmitter):
|
|||||||
self.pending_confirmations[connection.handle] = None
|
self.pending_confirmations[connection.handle] = None
|
||||||
|
|
||||||
async def notify_or_indicate_subscribers(
|
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
|
# Get all the connections for which there's at least one subscription
|
||||||
connections = [
|
connections = [
|
||||||
connection
|
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)
|
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)
|
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:
|
if connection.handle in self.subscribers:
|
||||||
del self.subscribers[connection.handle]
|
del self.subscribers[connection.handle]
|
||||||
if connection.handle in self.indication_semaphores:
|
if connection.handle in self.indication_semaphores:
|
||||||
@@ -464,7 +509,7 @@ class Server(EventEmitter):
|
|||||||
if connection.handle in self.pending_confirmations:
|
if connection.handle in self.pending_confirmations:
|
||||||
del self.pending_confirmations[connection.handle]
|
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}')
|
logger.debug(f'GATT Request to server: [0x{connection.handle:04X}] {att_pdu}')
|
||||||
handler_name = f'on_{att_pdu.name.lower()}'
|
handler_name = f'on_{att_pdu.name.lower()}'
|
||||||
handler = getattr(self, handler_name, None)
|
handler = getattr(self, handler_name, None)
|
||||||
@@ -506,7 +551,7 @@ class Server(EventEmitter):
|
|||||||
#######################################################
|
#######################################################
|
||||||
# ATT handlers
|
# 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
|
Handler for requests without a more specific handler
|
||||||
'''
|
'''
|
||||||
@@ -679,7 +724,6 @@ class Server(EventEmitter):
|
|||||||
and attribute.handle <= request.ending_handle
|
and attribute.handle <= request.ending_handle
|
||||||
and pdu_space_available
|
and pdu_space_available
|
||||||
):
|
):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
attribute_value = attribute.read_value(connection)
|
attribute_value = attribute.read_value(connection)
|
||||||
except ATT_Error as error:
|
except ATT_Error as error:
|
||||||
|
|||||||
818
bumble/hci.py
818
bumble/hci.py
File diff suppressed because it is too large
Load Diff
@@ -15,30 +15,39 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, MutableMapping
|
||||||
|
from typing import cast, Any
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from .colors import color
|
from bumble import avdtp
|
||||||
from .att import ATT_CID, ATT_PDU
|
from bumble.colors import color
|
||||||
from .smp import SMP_CID, SMP_Command
|
from bumble.att import ATT_CID, ATT_PDU
|
||||||
from .core import name_or_number
|
from bumble.smp import SMP_CID, SMP_Command
|
||||||
from .l2cap import (
|
from bumble.core import name_or_number
|
||||||
|
from bumble.l2cap import (
|
||||||
L2CAP_PDU,
|
L2CAP_PDU,
|
||||||
L2CAP_CONNECTION_REQUEST,
|
L2CAP_CONNECTION_REQUEST,
|
||||||
L2CAP_CONNECTION_RESPONSE,
|
L2CAP_CONNECTION_RESPONSE,
|
||||||
L2CAP_SIGNALING_CID,
|
L2CAP_SIGNALING_CID,
|
||||||
L2CAP_LE_SIGNALING_CID,
|
L2CAP_LE_SIGNALING_CID,
|
||||||
L2CAP_Control_Frame,
|
L2CAP_Control_Frame,
|
||||||
|
L2CAP_Connection_Request,
|
||||||
L2CAP_Connection_Response,
|
L2CAP_Connection_Response,
|
||||||
)
|
)
|
||||||
from .hci import (
|
from bumble.hci import (
|
||||||
HCI_EVENT_PACKET,
|
HCI_EVENT_PACKET,
|
||||||
HCI_ACL_DATA_PACKET,
|
HCI_ACL_DATA_PACKET,
|
||||||
HCI_DISCONNECTION_COMPLETE_EVENT,
|
HCI_DISCONNECTION_COMPLETE_EVENT,
|
||||||
HCI_AclDataPacketAssembler,
|
HCI_AclDataPacketAssembler,
|
||||||
|
HCI_Packet,
|
||||||
|
HCI_Event,
|
||||||
|
HCI_AclDataPacket,
|
||||||
|
HCI_Disconnection_Complete_Event,
|
||||||
)
|
)
|
||||||
from .rfcomm import RFCOMM_Frame, RFCOMM_PSM
|
from bumble.rfcomm import RFCOMM_Frame, RFCOMM_PSM
|
||||||
from .sdp import SDP_PDU, SDP_PSM
|
from bumble.sdp import SDP_PDU, SDP_PSM
|
||||||
from .avdtp import MessageAssembler as AVDTP_MessageAssembler, AVDTP_PSM
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -50,23 +59,25 @@ logger = logging.getLogger(__name__)
|
|||||||
PSM_NAMES = {
|
PSM_NAMES = {
|
||||||
RFCOMM_PSM: 'RFCOMM',
|
RFCOMM_PSM: 'RFCOMM',
|
||||||
SDP_PSM: 'SDP',
|
SDP_PSM: 'SDP',
|
||||||
AVDTP_PSM: 'AVDTP'
|
avdtp.AVDTP_PSM: 'AVDTP',
|
||||||
# TODO: add more PSM values
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PacketTracer:
|
class PacketTracer:
|
||||||
class AclStream:
|
class AclStream:
|
||||||
def __init__(self, analyzer):
|
psms: MutableMapping[int, int]
|
||||||
|
peer: PacketTracer.AclStream
|
||||||
|
avdtp_assemblers: MutableMapping[int, avdtp.MessageAssembler]
|
||||||
|
|
||||||
|
def __init__(self, analyzer: PacketTracer.Analyzer) -> None:
|
||||||
self.analyzer = analyzer
|
self.analyzer = analyzer
|
||||||
self.packet_assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
self.packet_assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||||
self.avdtp_assemblers = {} # AVDTP assemblers, by source_cid
|
self.avdtp_assemblers = {} # AVDTP assemblers, by source_cid
|
||||||
self.psms = {} # PSM, by source_cid
|
self.psms = {} # PSM, by source_cid
|
||||||
self.peer = None # ACL stream in the other direction
|
|
||||||
|
|
||||||
# pylint: disable=too-many-nested-blocks
|
# pylint: disable=too-many-nested-blocks
|
||||||
def on_acl_pdu(self, pdu):
|
def on_acl_pdu(self, pdu: bytes) -> None:
|
||||||
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
||||||
|
|
||||||
if l2cap_pdu.cid == ATT_CID:
|
if l2cap_pdu.cid == ATT_CID:
|
||||||
@@ -81,26 +92,30 @@ class PacketTracer:
|
|||||||
|
|
||||||
# Check if this signals a new channel
|
# Check if this signals a new channel
|
||||||
if control_frame.code == L2CAP_CONNECTION_REQUEST:
|
if control_frame.code == L2CAP_CONNECTION_REQUEST:
|
||||||
self.psms[control_frame.source_cid] = control_frame.psm
|
connection_request = cast(L2CAP_Connection_Request, control_frame)
|
||||||
|
self.psms[connection_request.source_cid] = connection_request.psm
|
||||||
elif control_frame.code == L2CAP_CONNECTION_RESPONSE:
|
elif control_frame.code == L2CAP_CONNECTION_RESPONSE:
|
||||||
|
connection_response = cast(L2CAP_Connection_Response, control_frame)
|
||||||
if (
|
if (
|
||||||
control_frame.result
|
connection_response.result
|
||||||
== L2CAP_Connection_Response.CONNECTION_SUCCESSFUL
|
== L2CAP_Connection_Response.CONNECTION_SUCCESSFUL
|
||||||
):
|
):
|
||||||
if self.peer:
|
if self.peer:
|
||||||
if psm := self.peer.psms.get(control_frame.source_cid):
|
if psm := self.peer.psms.get(
|
||||||
|
connection_response.source_cid
|
||||||
|
):
|
||||||
# Found a pending connection
|
# Found a pending connection
|
||||||
self.psms[control_frame.destination_cid] = psm
|
self.psms[connection_response.destination_cid] = psm
|
||||||
|
|
||||||
# For AVDTP connections, create a packet assembler for
|
# For AVDTP connections, create a packet assembler for
|
||||||
# each direction
|
# each direction
|
||||||
if psm == AVDTP_PSM:
|
if psm == avdtp.AVDTP_PSM:
|
||||||
self.avdtp_assemblers[
|
self.avdtp_assemblers[
|
||||||
control_frame.source_cid
|
connection_response.source_cid
|
||||||
] = AVDTP_MessageAssembler(self.on_avdtp_message)
|
] = avdtp.MessageAssembler(self.on_avdtp_message)
|
||||||
self.peer.avdtp_assemblers[
|
self.peer.avdtp_assemblers[
|
||||||
control_frame.destination_cid
|
connection_response.destination_cid
|
||||||
] = AVDTP_MessageAssembler(
|
] = avdtp.MessageAssembler(
|
||||||
self.peer.on_avdtp_message
|
self.peer.on_avdtp_message
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -113,7 +128,7 @@ class PacketTracer:
|
|||||||
elif psm == RFCOMM_PSM:
|
elif psm == RFCOMM_PSM:
|
||||||
rfcomm_frame = RFCOMM_Frame.from_bytes(l2cap_pdu.payload)
|
rfcomm_frame = RFCOMM_Frame.from_bytes(l2cap_pdu.payload)
|
||||||
self.analyzer.emit(rfcomm_frame)
|
self.analyzer.emit(rfcomm_frame)
|
||||||
elif psm == AVDTP_PSM:
|
elif psm == avdtp.AVDTP_PSM:
|
||||||
self.analyzer.emit(
|
self.analyzer.emit(
|
||||||
f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
|
f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
|
||||||
f'PSM=AVDTP]: {l2cap_pdu.payload.hex()}'
|
f'PSM=AVDTP]: {l2cap_pdu.payload.hex()}'
|
||||||
@@ -130,22 +145,26 @@ class PacketTracer:
|
|||||||
else:
|
else:
|
||||||
self.analyzer.emit(l2cap_pdu)
|
self.analyzer.emit(l2cap_pdu)
|
||||||
|
|
||||||
def on_avdtp_message(self, transaction_label, message):
|
def on_avdtp_message(
|
||||||
|
self, transaction_label: int, message: avdtp.Message
|
||||||
|
) -> None:
|
||||||
self.analyzer.emit(
|
self.analyzer.emit(
|
||||||
f'{color("AVDTP", "green")} [{transaction_label}] {message}'
|
f'{color("AVDTP", "green")} [{transaction_label}] {message}'
|
||||||
)
|
)
|
||||||
|
|
||||||
def feed_packet(self, packet):
|
def feed_packet(self, packet: HCI_AclDataPacket) -> None:
|
||||||
self.packet_assembler.feed_packet(packet)
|
self.packet_assembler.feed_packet(packet)
|
||||||
|
|
||||||
class Analyzer:
|
class Analyzer:
|
||||||
def __init__(self, label, emit_message):
|
acl_streams: MutableMapping[int, PacketTracer.AclStream]
|
||||||
|
peer: PacketTracer.Analyzer
|
||||||
|
|
||||||
|
def __init__(self, label: str, emit_message: Callable[..., None]) -> None:
|
||||||
self.label = label
|
self.label = label
|
||||||
self.emit_message = emit_message
|
self.emit_message = emit_message
|
||||||
self.acl_streams = {} # ACL streams, by connection handle
|
self.acl_streams = {} # ACL streams, by connection handle
|
||||||
self.peer = None # Analyzer in the other direction
|
|
||||||
|
|
||||||
def start_acl_stream(self, connection_handle):
|
def start_acl_stream(self, connection_handle: int) -> PacketTracer.AclStream:
|
||||||
logger.info(
|
logger.info(
|
||||||
f'[{self.label}] +++ Creating ACL stream for connection '
|
f'[{self.label}] +++ Creating ACL stream for connection '
|
||||||
f'0x{connection_handle:04X}'
|
f'0x{connection_handle:04X}'
|
||||||
@@ -160,7 +179,7 @@ class PacketTracer:
|
|||||||
|
|
||||||
return stream
|
return stream
|
||||||
|
|
||||||
def end_acl_stream(self, connection_handle):
|
def end_acl_stream(self, connection_handle: int) -> None:
|
||||||
if connection_handle in self.acl_streams:
|
if connection_handle in self.acl_streams:
|
||||||
logger.info(
|
logger.info(
|
||||||
f'[{self.label}] --- Removing ACL stream for connection '
|
f'[{self.label}] --- Removing ACL stream for connection '
|
||||||
@@ -171,23 +190,29 @@ class PacketTracer:
|
|||||||
# Let the other forwarder know so it can cleanup its stream as well
|
# Let the other forwarder know so it can cleanup its stream as well
|
||||||
self.peer.end_acl_stream(connection_handle)
|
self.peer.end_acl_stream(connection_handle)
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: HCI_Packet) -> None:
|
||||||
self.emit(packet)
|
self.emit(packet)
|
||||||
|
|
||||||
if packet.hci_packet_type == HCI_ACL_DATA_PACKET:
|
if packet.hci_packet_type == HCI_ACL_DATA_PACKET:
|
||||||
|
acl_packet = cast(HCI_AclDataPacket, packet)
|
||||||
# Look for an existing stream for this handle, create one if it is the
|
# Look for an existing stream for this handle, create one if it is the
|
||||||
# first ACL packet for that connection handle
|
# first ACL packet for that connection handle
|
||||||
if (stream := self.acl_streams.get(packet.connection_handle)) is None:
|
if (
|
||||||
stream = self.start_acl_stream(packet.connection_handle)
|
stream := self.acl_streams.get(acl_packet.connection_handle)
|
||||||
stream.feed_packet(packet)
|
) is None:
|
||||||
|
stream = self.start_acl_stream(acl_packet.connection_handle)
|
||||||
|
stream.feed_packet(acl_packet)
|
||||||
elif packet.hci_packet_type == HCI_EVENT_PACKET:
|
elif packet.hci_packet_type == HCI_EVENT_PACKET:
|
||||||
if packet.event_code == HCI_DISCONNECTION_COMPLETE_EVENT:
|
event_packet = cast(HCI_Event, packet)
|
||||||
self.end_acl_stream(packet.connection_handle)
|
if event_packet.event_code == HCI_DISCONNECTION_COMPLETE_EVENT:
|
||||||
|
self.end_acl_stream(
|
||||||
|
cast(HCI_Disconnection_Complete_Event, packet).connection_handle
|
||||||
|
)
|
||||||
|
|
||||||
def emit(self, message):
|
def emit(self, message: Any) -> None:
|
||||||
self.emit_message(f'[{self.label}] {message}')
|
self.emit_message(f'[{self.label}] {message}')
|
||||||
|
|
||||||
def trace(self, packet, direction=0):
|
def trace(self, packet: HCI_Packet, direction: int = 0) -> None:
|
||||||
if direction == 0:
|
if direction == 0:
|
||||||
self.host_to_controller_analyzer.on_packet(packet)
|
self.host_to_controller_analyzer.on_packet(packet)
|
||||||
else:
|
else:
|
||||||
@@ -195,10 +220,10 @@ class PacketTracer:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
host_to_controller_label=color('HOST->CONTROLLER', 'blue'),
|
host_to_controller_label: str = color('HOST->CONTROLLER', 'blue'),
|
||||||
controller_to_host_label=color('CONTROLLER->HOST', 'cyan'),
|
controller_to_host_label: str = color('CONTROLLER->HOST', 'cyan'),
|
||||||
emit_message=logger.info,
|
emit_message: Callable[..., None] = logger.info,
|
||||||
):
|
) -> None:
|
||||||
self.host_to_controller_analyzer = PacketTracer.Analyzer(
|
self.host_to_controller_analyzer = PacketTracer.Analyzer(
|
||||||
host_to_controller_label, emit_message
|
host_to_controller_label, emit_message
|
||||||
)
|
)
|
||||||
|
|||||||
932
bumble/hfp.py
932
bumble/hfp.py
@@ -1,4 +1,4 @@
|
|||||||
# Copyright 2021-2022 Google LLC
|
# Copyright 2023 Google LLC
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -15,24 +15,62 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
import collections.abc
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import collections
|
import dataclasses
|
||||||
from typing import Union
|
import enum
|
||||||
|
import traceback
|
||||||
|
import warnings
|
||||||
|
from typing import Dict, List, Union, Set, Any, TYPE_CHECKING
|
||||||
|
|
||||||
|
from . import at
|
||||||
from . import rfcomm
|
from . import rfcomm
|
||||||
from .colors import color
|
|
||||||
|
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.hci import (
|
||||||
|
HCI_Enhanced_Setup_Synchronous_Connection_Command,
|
||||||
|
CodingFormat,
|
||||||
|
CodecID,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Error
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class HfpProtocolError(ProtocolError):
|
||||||
|
def __init__(self, error_name: str = '', details: str = ''):
|
||||||
|
super().__init__(None, 'hfp', error_name, details)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Protocol Support
|
# Protocol Support
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class HfpProtocol:
|
class HfpProtocol:
|
||||||
dlc: rfcomm.DLC
|
dlc: rfcomm.DLC
|
||||||
@@ -41,6 +79,7 @@ class HfpProtocol:
|
|||||||
lines_available: asyncio.Event
|
lines_available: asyncio.Event
|
||||||
|
|
||||||
def __init__(self, dlc: rfcomm.DLC) -> None:
|
def __init__(self, dlc: rfcomm.DLC) -> None:
|
||||||
|
warnings.warn("See HfProtocol", DeprecationWarning)
|
||||||
self.dlc = dlc
|
self.dlc = dlc
|
||||||
self.buffer = ''
|
self.buffer = ''
|
||||||
self.lines = collections.deque()
|
self.lines = collections.deque()
|
||||||
@@ -83,19 +122,878 @@ class HfpProtocol:
|
|||||||
logger.debug(color(f'<<< {line}', 'green'))
|
logger.debug(color(f'<<< {line}', 'green'))
|
||||||
return line
|
return line
|
||||||
|
|
||||||
async def initialize_service(self) -> None:
|
|
||||||
# Perform Service Level Connection Initialization
|
|
||||||
self.send_command_line('AT+BRSF=2072') # Retrieve Supported Features
|
|
||||||
await (self.next_line())
|
|
||||||
await (self.next_line())
|
|
||||||
|
|
||||||
self.send_command_line('AT+CIND=?')
|
# -----------------------------------------------------------------------------
|
||||||
await (self.next_line())
|
# Normative protocol definitions
|
||||||
await (self.next_line())
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
self.send_command_line('AT+CIND?')
|
|
||||||
await (self.next_line())
|
|
||||||
await (self.next_line())
|
|
||||||
|
|
||||||
self.send_command_line('AT+CMER=3,0,0,1')
|
# HF supported features (AT+BRSF=) (normative).
|
||||||
await (self.next_line())
|
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||||
|
# and 3GPP 27.007
|
||||||
|
class HfFeature(enum.IntFlag):
|
||||||
|
EC_NR = 0x001 # Echo Cancel & Noise reduction
|
||||||
|
THREE_WAY_CALLING = 0x002
|
||||||
|
CLI_PRESENTATION_CAPABILITY = 0x004
|
||||||
|
VOICE_RECOGNITION_ACTIVATION = 0x008
|
||||||
|
REMOTE_VOLUME_CONTROL = 0x010
|
||||||
|
ENHANCED_CALL_STATUS = 0x020
|
||||||
|
ENHANCED_CALL_CONTROL = 0x040
|
||||||
|
CODEC_NEGOTIATION = 0x080
|
||||||
|
HF_INDICATORS = 0x100
|
||||||
|
ESCO_S4_SETTINGS_SUPPORTED = 0x200
|
||||||
|
ENHANCED_VOICE_RECOGNITION_STATUS = 0x400
|
||||||
|
VOICE_RECOGNITION_TEST = 0x800
|
||||||
|
|
||||||
|
|
||||||
|
# AG supported features (+BRSF:) (normative).
|
||||||
|
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||||
|
# and 3GPP 27.007
|
||||||
|
class AgFeature(enum.IntFlag):
|
||||||
|
THREE_WAY_CALLING = 0x001
|
||||||
|
EC_NR = 0x002 # Echo Cancel & Noise reduction
|
||||||
|
VOICE_RECOGNITION_FUNCTION = 0x004
|
||||||
|
IN_BAND_RING_TONE_CAPABILITY = 0x008
|
||||||
|
VOICE_TAG = 0x010 # Attach a number to voice tag
|
||||||
|
REJECT_CALL = 0x020 # Ability to reject a call
|
||||||
|
ENHANCED_CALL_STATUS = 0x040
|
||||||
|
ENHANCED_CALL_CONTROL = 0x080
|
||||||
|
EXTENDED_ERROR_RESULT_CODES = 0x100
|
||||||
|
CODEC_NEGOTIATION = 0x200
|
||||||
|
HF_INDICATORS = 0x400
|
||||||
|
ESCO_S4_SETTINGS_SUPPORTED = 0x800
|
||||||
|
ENHANCED_VOICE_RECOGNITION_STATUS = 0x1000
|
||||||
|
VOICE_RECOGNITION_TEST = 0x2000
|
||||||
|
|
||||||
|
|
||||||
|
# Audio Codec IDs (normative).
|
||||||
|
# Hands-Free Profile v1.8, 10 Appendix B
|
||||||
|
class AudioCodec(enum.IntEnum):
|
||||||
|
CVSD = 0x01 # Support for CVSD audio codec
|
||||||
|
MSBC = 0x02 # Support for mSBC audio codec
|
||||||
|
|
||||||
|
|
||||||
|
# HF Indicators (normative).
|
||||||
|
# Bluetooth Assigned Numbers, 6.10.1 HF Indicators
|
||||||
|
class HfIndicator(enum.IntEnum):
|
||||||
|
ENHANCED_SAFETY = 0x01 # Enhanced safety feature
|
||||||
|
BATTERY_LEVEL = 0x02 # Battery level feature
|
||||||
|
|
||||||
|
|
||||||
|
# Call Hold supported operations (normative).
|
||||||
|
# AT Commands Reference Guide, 3.5.2.3.12 +CHLD - Call Holding Services
|
||||||
|
class CallHoldOperation(enum.IntEnum):
|
||||||
|
RELEASE_ALL_HELD_CALLS = 0 # Release all held calls
|
||||||
|
RELEASE_ALL_ACTIVE_CALLS = 1 # Release all active calls, accept other
|
||||||
|
HOLD_ALL_ACTIVE_CALLS = 2 # Place all active calls on hold, accept other
|
||||||
|
ADD_HELD_CALL = 3 # Adds a held call to conversation
|
||||||
|
|
||||||
|
|
||||||
|
# Response Hold status (normative).
|
||||||
|
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||||
|
# and 3GPP 27.007
|
||||||
|
class ResponseHoldStatus(enum.IntEnum):
|
||||||
|
INC_CALL_HELD = 0 # Put incoming call on hold
|
||||||
|
HELD_CALL_ACC = 1 # Accept a held incoming call
|
||||||
|
HELD_CALL_REJ = 2 # Reject a held incoming call
|
||||||
|
|
||||||
|
|
||||||
|
# Values for the Call Setup AG indicator (normative).
|
||||||
|
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||||
|
# and 3GPP 27.007
|
||||||
|
class CallSetupAgIndicator(enum.IntEnum):
|
||||||
|
NOT_IN_CALL_SETUP = 0
|
||||||
|
INCOMING_CALL_PROCESS = 1
|
||||||
|
OUTGOING_CALL_SETUP = 2
|
||||||
|
REMOTE_ALERTED = 3 # Remote party alerted in an outgoing call
|
||||||
|
|
||||||
|
|
||||||
|
# Values for the Call Held AG indicator (normative).
|
||||||
|
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||||
|
# and 3GPP 27.007
|
||||||
|
class CallHeldAgIndicator(enum.IntEnum):
|
||||||
|
NO_CALLS_HELD = 0
|
||||||
|
# Call is placed on hold or active/held calls swapped
|
||||||
|
# (The AG has both an active AND a held call)
|
||||||
|
CALL_ON_HOLD_AND_ACTIVE_CALL = 1
|
||||||
|
CALL_ON_HOLD_NO_ACTIVE_CALL = 2 # Call on hold, no active call
|
||||||
|
|
||||||
|
|
||||||
|
# Call Info direction (normative).
|
||||||
|
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
|
||||||
|
class CallInfoDirection(enum.IntEnum):
|
||||||
|
MOBILE_ORIGINATED_CALL = 0
|
||||||
|
MOBILE_TERMINATED_CALL = 1
|
||||||
|
|
||||||
|
|
||||||
|
# Call Info status (normative).
|
||||||
|
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
|
||||||
|
class CallInfoStatus(enum.IntEnum):
|
||||||
|
ACTIVE = 0
|
||||||
|
HELD = 1
|
||||||
|
DIALING = 2
|
||||||
|
ALERTING = 3
|
||||||
|
INCOMING = 4
|
||||||
|
WAITING = 5
|
||||||
|
|
||||||
|
|
||||||
|
# Call Info mode (normative).
|
||||||
|
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
|
||||||
|
class CallInfoMode(enum.IntEnum):
|
||||||
|
VOICE = 0
|
||||||
|
DATA = 1
|
||||||
|
FAX = 2
|
||||||
|
UNKNOWN = 9
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Hands-Free Control Interoperability Requirements
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Response codes.
|
||||||
|
RESPONSE_CODES = [
|
||||||
|
"+APLSIRI",
|
||||||
|
"+BAC",
|
||||||
|
"+BCC",
|
||||||
|
"+BCS",
|
||||||
|
"+BIA",
|
||||||
|
"+BIEV",
|
||||||
|
"+BIND",
|
||||||
|
"+BINP",
|
||||||
|
"+BLDN",
|
||||||
|
"+BRSF",
|
||||||
|
"+BTRH",
|
||||||
|
"+BVRA",
|
||||||
|
"+CCWA",
|
||||||
|
"+CHLD",
|
||||||
|
"+CHUP",
|
||||||
|
"+CIND",
|
||||||
|
"+CLCC",
|
||||||
|
"+CLIP",
|
||||||
|
"+CMEE",
|
||||||
|
"+CMER",
|
||||||
|
"+CNUM",
|
||||||
|
"+COPS",
|
||||||
|
"+IPHONEACCEV",
|
||||||
|
"+NREC",
|
||||||
|
"+VGM",
|
||||||
|
"+VGS",
|
||||||
|
"+VTS",
|
||||||
|
"+XAPL",
|
||||||
|
"A",
|
||||||
|
"D",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Unsolicited responses and statuses.
|
||||||
|
UNSOLICITED_CODES = [
|
||||||
|
"+APLSIRI",
|
||||||
|
"+BCS",
|
||||||
|
"+BIND",
|
||||||
|
"+BSIR",
|
||||||
|
"+BTRH",
|
||||||
|
"+BVRA",
|
||||||
|
"+CCWA",
|
||||||
|
"+CIEV",
|
||||||
|
"+CLIP",
|
||||||
|
"+VGM",
|
||||||
|
"+VGS",
|
||||||
|
"BLACKLISTED",
|
||||||
|
"BUSY",
|
||||||
|
"DELAYED",
|
||||||
|
"NO ANSWER",
|
||||||
|
"NO CARRIER",
|
||||||
|
"RING",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Status codes
|
||||||
|
STATUS_CODES = [
|
||||||
|
"+CME ERROR",
|
||||||
|
"BLACKLISTED",
|
||||||
|
"BUSY",
|
||||||
|
"DELAYED",
|
||||||
|
"ERROR",
|
||||||
|
"NO ANSWER",
|
||||||
|
"NO CARRIER",
|
||||||
|
"OK",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class Configuration:
|
||||||
|
supported_hf_features: List[HfFeature]
|
||||||
|
supported_hf_indicators: List[HfIndicator]
|
||||||
|
supported_audio_codecs: List[AudioCodec]
|
||||||
|
|
||||||
|
|
||||||
|
class AtResponseType(enum.Enum):
|
||||||
|
"""Indicate if a response is expected from an AT command, and if multiple
|
||||||
|
responses are accepted."""
|
||||||
|
|
||||||
|
NONE = 0
|
||||||
|
SINGLE = 1
|
||||||
|
MULTIPLE = 2
|
||||||
|
|
||||||
|
|
||||||
|
class AtResponse:
|
||||||
|
code: str
|
||||||
|
parameters: list
|
||||||
|
|
||||||
|
def __init__(self, response: bytearray):
|
||||||
|
code_and_parameters = response.split(b':')
|
||||||
|
parameters = (
|
||||||
|
code_and_parameters[1] if len(code_and_parameters) > 1 else bytearray()
|
||||||
|
)
|
||||||
|
self.code = code_and_parameters[0].decode()
|
||||||
|
self.parameters = at.parse_parameters(parameters)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class AgIndicatorState:
|
||||||
|
description: str
|
||||||
|
index: int
|
||||||
|
supported_values: Set[int]
|
||||||
|
current_status: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HfIndicatorState:
|
||||||
|
supported: bool = False
|
||||||
|
enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class HfProtocol:
|
||||||
|
"""Implementation for the Hands-Free side of the Hands-Free profile.
|
||||||
|
Reference specification Hands-Free Profile v1.8"""
|
||||||
|
|
||||||
|
supported_hf_features: int
|
||||||
|
supported_audio_codecs: List[AudioCodec]
|
||||||
|
|
||||||
|
supported_ag_features: int
|
||||||
|
supported_ag_call_hold_operations: List[CallHoldOperation]
|
||||||
|
|
||||||
|
ag_indicators: List[AgIndicatorState]
|
||||||
|
hf_indicators: Dict[HfIndicator, HfIndicatorState]
|
||||||
|
|
||||||
|
dlc: rfcomm.DLC
|
||||||
|
command_lock: asyncio.Lock
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
response_queue: asyncio.Queue[AtResponse]
|
||||||
|
unsolicited_queue: asyncio.Queue[AtResponse]
|
||||||
|
else:
|
||||||
|
response_queue: asyncio.Queue
|
||||||
|
unsolicited_queue: asyncio.Queue
|
||||||
|
read_buffer: bytearray
|
||||||
|
|
||||||
|
def __init__(self, dlc: rfcomm.DLC, configuration: Configuration):
|
||||||
|
# Configure internal state.
|
||||||
|
self.dlc = dlc
|
||||||
|
self.command_lock = asyncio.Lock()
|
||||||
|
self.response_queue = asyncio.Queue()
|
||||||
|
self.unsolicited_queue = asyncio.Queue()
|
||||||
|
self.read_buffer = bytearray()
|
||||||
|
|
||||||
|
# Build local features.
|
||||||
|
self.supported_hf_features = sum(configuration.supported_hf_features)
|
||||||
|
self.supported_audio_codecs = configuration.supported_audio_codecs
|
||||||
|
|
||||||
|
self.hf_indicators = {
|
||||||
|
indicator: HfIndicatorState()
|
||||||
|
for indicator in configuration.supported_hf_indicators
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clear remote features.
|
||||||
|
self.supported_ag_features = 0
|
||||||
|
self.supported_ag_call_hold_operations = []
|
||||||
|
self.ag_indicators = []
|
||||||
|
|
||||||
|
# Bind the AT reader to the RFCOMM channel.
|
||||||
|
self.dlc.sink = self._read_at
|
||||||
|
|
||||||
|
def supports_hf_feature(self, feature: HfFeature) -> bool:
|
||||||
|
return (self.supported_hf_features & feature) != 0
|
||||||
|
|
||||||
|
def supports_ag_feature(self, feature: AgFeature) -> bool:
|
||||||
|
return (self.supported_ag_features & feature) != 0
|
||||||
|
|
||||||
|
# Read AT messages from the RFCOMM channel.
|
||||||
|
# Enqueue AT commands, responses, unsolicited responses to their
|
||||||
|
# respective queues, and set the corresponding event.
|
||||||
|
def _read_at(self, data: bytes):
|
||||||
|
# Append to the read buffer.
|
||||||
|
self.read_buffer.extend(data)
|
||||||
|
|
||||||
|
# Locate header and trailer.
|
||||||
|
header = self.read_buffer.find(b'\r\n')
|
||||||
|
trailer = self.read_buffer.find(b'\r\n', header + 2)
|
||||||
|
if header == -1 or trailer == -1:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Isolate the AT response code and parameters.
|
||||||
|
raw_response = self.read_buffer[header + 2 : trailer]
|
||||||
|
response = AtResponse(raw_response)
|
||||||
|
logger.debug(f"<<< {raw_response.decode()}")
|
||||||
|
|
||||||
|
# Consume the response bytes.
|
||||||
|
self.read_buffer = self.read_buffer[trailer + 2 :]
|
||||||
|
|
||||||
|
# Forward the received code to the correct queue.
|
||||||
|
if self.command_lock.locked() and (
|
||||||
|
response.code in STATUS_CODES or response.code in RESPONSE_CODES
|
||||||
|
):
|
||||||
|
self.response_queue.put_nowait(response)
|
||||||
|
elif response.code in UNSOLICITED_CODES:
|
||||||
|
self.unsolicited_queue.put_nowait(response)
|
||||||
|
else:
|
||||||
|
logger.warning(f"dropping unexpected response with code '{response.code}'")
|
||||||
|
|
||||||
|
# Send an AT command and wait for the peer response.
|
||||||
|
# Wait for the AT responses sent by the peer, to the status code.
|
||||||
|
# Raises asyncio.TimeoutError if the status is not received
|
||||||
|
# after a timeout (default 1 second).
|
||||||
|
# Raises ProtocolError if the status is not OK.
|
||||||
|
async def execute_command(
|
||||||
|
self,
|
||||||
|
cmd: str,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
response_type: AtResponseType = AtResponseType.NONE,
|
||||||
|
) -> Union[None, AtResponse, List[AtResponse]]:
|
||||||
|
async with self.command_lock:
|
||||||
|
logger.debug(f">>> {cmd}")
|
||||||
|
self.dlc.write(cmd + '\r')
|
||||||
|
responses: List[AtResponse] = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
self.response_queue.get(), timeout=timeout
|
||||||
|
)
|
||||||
|
if result.code == 'OK':
|
||||||
|
if response_type == AtResponseType.SINGLE and len(responses) != 1:
|
||||||
|
raise HfpProtocolError("NO ANSWER")
|
||||||
|
|
||||||
|
if response_type == AtResponseType.MULTIPLE:
|
||||||
|
return responses
|
||||||
|
if response_type == AtResponseType.SINGLE:
|
||||||
|
return responses[0]
|
||||||
|
return None
|
||||||
|
if result.code in STATUS_CODES:
|
||||||
|
raise HfpProtocolError(result.code)
|
||||||
|
responses.append(result)
|
||||||
|
|
||||||
|
# 4.2.1 Service Level Connection Initialization.
|
||||||
|
async def initiate_slc(self):
|
||||||
|
# 4.2.1.1 Supported features exchange
|
||||||
|
# First, in the initialization procedure, the HF shall send the
|
||||||
|
# AT+BRSF=<HF supported features> command to the AG to both notify
|
||||||
|
# the AG of the supported features in the HF, as well as to retrieve the
|
||||||
|
# supported features in the AG using the +BRSF result code.
|
||||||
|
response = await self.execute_command(
|
||||||
|
f"AT+BRSF={self.supported_hf_features}", response_type=AtResponseType.SINGLE
|
||||||
|
)
|
||||||
|
|
||||||
|
self.supported_ag_features = int(response.parameters[0])
|
||||||
|
logger.info(f"supported AG features: {self.supported_ag_features}")
|
||||||
|
for feature in AgFeature:
|
||||||
|
if self.supports_ag_feature(feature):
|
||||||
|
logger.info(f" - {feature.name}")
|
||||||
|
|
||||||
|
# 4.2.1.2 Codec Negotiation
|
||||||
|
# Secondly, in the initialization procedure, if the HF supports the
|
||||||
|
# Codec Negotiation feature, it shall check if the AT+BRSF command
|
||||||
|
# response from the AG has indicated that it supports the Codec
|
||||||
|
# Negotiation feature.
|
||||||
|
if self.supports_hf_feature(
|
||||||
|
HfFeature.CODEC_NEGOTIATION
|
||||||
|
) and self.supports_ag_feature(AgFeature.CODEC_NEGOTIATION):
|
||||||
|
# If both the HF and AG do support the Codec Negotiation feature
|
||||||
|
# then the HF shall send the AT+BAC=<HF available codecs> command to
|
||||||
|
# the AG to notify the AG of the available codecs in the HF.
|
||||||
|
codecs = [str(c) for c in self.supported_audio_codecs]
|
||||||
|
await self.execute_command(f"AT+BAC={','.join(codecs)}")
|
||||||
|
|
||||||
|
# 4.2.1.3 AG Indicators
|
||||||
|
# After having retrieved the supported features in the AG, the HF shall
|
||||||
|
# determine which indicators are supported by the AG, as well as the
|
||||||
|
# ordering of the supported indicators. This is because, according to
|
||||||
|
# the 3GPP 27.007 specification [2], the AG may support additional
|
||||||
|
# indicators not provided for by the Hands-Free Profile, and because the
|
||||||
|
# ordering of the indicators is implementation specific. The HF uses
|
||||||
|
# the AT+CIND=? Test command to retrieve information about the supported
|
||||||
|
# indicators and their ordering.
|
||||||
|
response = await self.execute_command(
|
||||||
|
"AT+CIND=?", response_type=AtResponseType.SINGLE
|
||||||
|
)
|
||||||
|
|
||||||
|
self.ag_indicators = []
|
||||||
|
for index, indicator in enumerate(response.parameters):
|
||||||
|
description = indicator[0].decode()
|
||||||
|
supported_values = []
|
||||||
|
for value in indicator[1]:
|
||||||
|
value = value.split(b'-')
|
||||||
|
value = [int(v) for v in value]
|
||||||
|
value_min = value[0]
|
||||||
|
value_max = value[1] if len(value) > 1 else value[0]
|
||||||
|
supported_values.extend([v for v in range(value_min, value_max + 1)])
|
||||||
|
|
||||||
|
self.ag_indicators.append(
|
||||||
|
AgIndicatorState(description, index, set(supported_values), 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Once the HF has the necessary supported indicator and ordering
|
||||||
|
# information, it shall retrieve the current status of the indicators
|
||||||
|
# in the AG using the AT+CIND? Read command.
|
||||||
|
response = await self.execute_command(
|
||||||
|
"AT+CIND?", response_type=AtResponseType.SINGLE
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, indicator in enumerate(response.parameters):
|
||||||
|
self.ag_indicators[index].current_status = int(indicator)
|
||||||
|
|
||||||
|
# After having retrieved the status of the indicators in the AG, the HF
|
||||||
|
# shall then enable the "Indicators status update" function in the AG by
|
||||||
|
# issuing the AT+CMER command, to which the AG shall respond with OK.
|
||||||
|
await self.execute_command("AT+CMER=3,,,1")
|
||||||
|
|
||||||
|
if self.supports_hf_feature(
|
||||||
|
HfFeature.THREE_WAY_CALLING
|
||||||
|
) and self.supports_ag_feature(HfFeature.THREE_WAY_CALLING):
|
||||||
|
# After the HF has enabled the “Indicators status update” function in
|
||||||
|
# the AG, and if the “Call waiting and 3-way calling” bit was set in the
|
||||||
|
# supported features bitmap by both the HF and the AG, the HF shall
|
||||||
|
# issue the AT+CHLD=? test command to retrieve the information about how
|
||||||
|
# the call hold and multiparty services are supported in the AG. The HF
|
||||||
|
# shall not issue the AT+CHLD=? test command in case either the HF or
|
||||||
|
# the AG does not support the "Three-way calling" feature.
|
||||||
|
response = await self.execute_command(
|
||||||
|
"AT+CHLD=?", response_type=AtResponseType.SINGLE
|
||||||
|
)
|
||||||
|
|
||||||
|
self.supported_ag_call_hold_operations = [
|
||||||
|
CallHoldOperation(int(operation))
|
||||||
|
for operation in response.parameters[0]
|
||||||
|
if not b'x' in operation
|
||||||
|
]
|
||||||
|
|
||||||
|
# 4.2.1.4 HF Indicators
|
||||||
|
# If the HF supports the HF indicator feature, it shall check the +BRSF
|
||||||
|
# response to see if the AG also supports the HF Indicator feature.
|
||||||
|
if self.supports_hf_feature(
|
||||||
|
HfFeature.HF_INDICATORS
|
||||||
|
) and self.supports_ag_feature(AgFeature.HF_INDICATORS):
|
||||||
|
# If both the HF and AG support the HF Indicator feature, then the HF
|
||||||
|
# shall send the AT+BIND=<HF supported HF indicators> command to the AG
|
||||||
|
# to notify the AG of the supported indicators’ assigned numbers in the
|
||||||
|
# HF. The AG shall respond with OK
|
||||||
|
indicators = [str(i) for i in self.hf_indicators.keys()]
|
||||||
|
await self.execute_command(f"AT+BIND={','.join(indicators)}")
|
||||||
|
|
||||||
|
# After having provided the AG with the HF indicators it supports,
|
||||||
|
# the HF shall send the AT+BIND=? to request HF indicators supported
|
||||||
|
# by the AG. The AG shall reply with the +BIND response listing all
|
||||||
|
# HF indicators that it supports followed by an OK.
|
||||||
|
response = await self.execute_command(
|
||||||
|
"AT+BIND=?", response_type=AtResponseType.SINGLE
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("supported HF indicators:")
|
||||||
|
for indicator in response.parameters[0]:
|
||||||
|
indicator = HfIndicator(int(indicator))
|
||||||
|
logger.info(f" - {indicator.name}")
|
||||||
|
if indicator in self.hf_indicators:
|
||||||
|
self.hf_indicators[indicator].supported = True
|
||||||
|
|
||||||
|
# Once the HF receives the supported HF indicators list from the AG,
|
||||||
|
# the HF shall send the AT+BIND? command to determine which HF
|
||||||
|
# indicators are enabled. The AG shall respond with one or more
|
||||||
|
# +BIND responses. The AG shall terminate the list with OK.
|
||||||
|
# (See Section 4.36.1.3).
|
||||||
|
responses = await self.execute_command(
|
||||||
|
"AT+BIND?", response_type=AtResponseType.MULTIPLE
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("enabled HF indicators:")
|
||||||
|
for response in responses:
|
||||||
|
indicator = HfIndicator(int(response.parameters[0]))
|
||||||
|
enabled = int(response.parameters[1]) != 0
|
||||||
|
logger.info(f" - {indicator.name}: {enabled}")
|
||||||
|
if indicator in self.hf_indicators:
|
||||||
|
self.hf_indicators[indicator].enabled = True
|
||||||
|
|
||||||
|
logger.info("SLC setup completed")
|
||||||
|
|
||||||
|
# 4.11.2 Audio Connection Setup by HF
|
||||||
|
async def setup_audio_connection(self):
|
||||||
|
# When the HF triggers the establishment of the Codec Connection it
|
||||||
|
# shall send the AT command AT+BCC to the AG. The AG shall respond with
|
||||||
|
# OK if it will start the Codec Connection procedure, and with ERROR
|
||||||
|
# if it cannot start the Codec Connection procedure.
|
||||||
|
await self.execute_command("AT+BCC")
|
||||||
|
|
||||||
|
# 4.11.3 Codec Connection Setup
|
||||||
|
async def setup_codec_connection(self, codec_id: int):
|
||||||
|
# The AG shall send a +BCS=<Codec ID> unsolicited response to the HF.
|
||||||
|
# The HF shall then respond to the incoming unsolicited response with
|
||||||
|
# the AT command AT+BCS=<Codec ID>. The ID shall be the same as in the
|
||||||
|
# unsolicited response code as long as the ID is supported.
|
||||||
|
# If the received ID is not available, the HF shall respond with
|
||||||
|
# AT+BAC with its available codecs.
|
||||||
|
if codec_id not in self.supported_audio_codecs:
|
||||||
|
codecs = [str(c) for c in self.supported_audio_codecs]
|
||||||
|
await self.execute_command(f"AT+BAC={','.join(codecs)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.execute_command(f"AT+BCS={codec_id}")
|
||||||
|
|
||||||
|
# After sending the OK response, the AG shall open the
|
||||||
|
# Synchronous Connection with the settings that are determined by the
|
||||||
|
# ID. The HF shall be ready to accept the synchronous connection
|
||||||
|
# establishment as soon as it has sent the AT commands AT+BCS=<Codec ID>.
|
||||||
|
|
||||||
|
logger.info("codec connection setup completed")
|
||||||
|
|
||||||
|
# 4.13.1 Answer Incoming Call from the HF – In-Band Ringing
|
||||||
|
async def answer_incoming_call(self):
|
||||||
|
# The user accepts the incoming voice call by using the proper means
|
||||||
|
# provided by the HF. The HF shall then send the ATA command
|
||||||
|
# (see Section 4.34) to the AG. The AG shall then begin the procedure for
|
||||||
|
# accepting the incoming call.
|
||||||
|
await self.execute_command("ATA")
|
||||||
|
|
||||||
|
# 4.14.1 Reject an Incoming Call from the HF
|
||||||
|
async def reject_incoming_call(self):
|
||||||
|
# The user rejects the incoming call by using the User Interface on the
|
||||||
|
# Hands-Free unit. The HF shall then send the AT+CHUP command
|
||||||
|
# (see Section 4.34) to the AG. This may happen at any time during the
|
||||||
|
# procedures described in Sections 4.13.1 and 4.13.2.
|
||||||
|
await self.execute_command("AT+CHUP")
|
||||||
|
|
||||||
|
# 4.15.1 Terminate a Call Process from the HF
|
||||||
|
async def terminate_call(self):
|
||||||
|
# The user may abort the ongoing call process using whatever means
|
||||||
|
# provided by the Hands-Free unit. The HF shall send AT+CHUP command
|
||||||
|
# (see Section 4.34) to the AG, and the AG shall then start the
|
||||||
|
# procedure to terminate or interrupt the current call procedure.
|
||||||
|
# The AG shall then send the OK indication followed by the +CIEV result
|
||||||
|
# code, with the value indicating (call=0).
|
||||||
|
await self.execute_command("AT+CHUP")
|
||||||
|
|
||||||
|
async def update_ag_indicator(self, index: int, value: int):
|
||||||
|
self.ag_indicators[index].current_status = value
|
||||||
|
logger.info(
|
||||||
|
f"AG indicator updated: {self.ag_indicators[index].description}, {value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_unsolicited(self):
|
||||||
|
"""Handle unsolicited result codes sent by the audio gateway."""
|
||||||
|
result = await self.unsolicited_queue.get()
|
||||||
|
if result.code == "+BCS":
|
||||||
|
await self.setup_codec_connection(int(result.parameters[0]))
|
||||||
|
elif result.code == "+CIEV":
|
||||||
|
await self.update_ag_indicator(
|
||||||
|
int(result.parameters[0]), int(result.parameters[1])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.info(f"unhandled unsolicited response {result.code}")
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""Main rountine for the Hands-Free side of the HFP protocol.
|
||||||
|
Initiates the service level connection then loops handling
|
||||||
|
unsolicited AG responses."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.initiate_slc()
|
||||||
|
while True:
|
||||||
|
await self.handle_unsolicited()
|
||||||
|
except Exception:
|
||||||
|
logger.error("HFP-HF protocol failed with the following error:")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Normative SDP definitions
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# Profile version (normative).
|
||||||
|
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
|
||||||
|
class ProfileVersion(enum.IntEnum):
|
||||||
|
V1_5 = 0x0105
|
||||||
|
V1_6 = 0x0106
|
||||||
|
V1_7 = 0x0107
|
||||||
|
V1_8 = 0x0108
|
||||||
|
V1_9 = 0x0109
|
||||||
|
|
||||||
|
|
||||||
|
# HF supported features (normative).
|
||||||
|
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
|
||||||
|
class HfSdpFeature(enum.IntFlag):
|
||||||
|
EC_NR = 0x01 # Echo Cancel & Noise reduction
|
||||||
|
THREE_WAY_CALLING = 0x02
|
||||||
|
CLI_PRESENTATION_CAPABILITY = 0x04
|
||||||
|
VOICE_RECOGNITION_ACTIVATION = 0x08
|
||||||
|
REMOTE_VOLUME_CONTROL = 0x10
|
||||||
|
WIDE_BAND = 0x20 # Wide band speech
|
||||||
|
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
|
||||||
|
VOICE_RECOGNITION_TEST = 0x80
|
||||||
|
|
||||||
|
|
||||||
|
# AG supported features (normative).
|
||||||
|
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
|
||||||
|
class AgSdpFeature(enum.IntFlag):
|
||||||
|
THREE_WAY_CALLING = 0x01
|
||||||
|
EC_NR = 0x02 # Echo Cancel & Noise reduction
|
||||||
|
VOICE_RECOGNITION_FUNCTION = 0x04
|
||||||
|
IN_BAND_RING_TONE_CAPABILITY = 0x08
|
||||||
|
VOICE_TAG = 0x10 # Attach a number to voice tag
|
||||||
|
WIDE_BAND = 0x20 # Wide band speech
|
||||||
|
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
|
||||||
|
VOICE_RECOGNITION_TEST = 0x80
|
||||||
|
|
||||||
|
|
||||||
|
def sdp_records(
|
||||||
|
service_record_handle: int, rfcomm_channel: int, configuration: Configuration
|
||||||
|
) -> List[ServiceAttribute]:
|
||||||
|
"""Generate the SDP record for HFP Hands-Free support.
|
||||||
|
The record exposes the features supported in the input configuration,
|
||||||
|
and the allocated RFCOMM channel."""
|
||||||
|
|
||||||
|
hf_supported_features = 0
|
||||||
|
|
||||||
|
if HfFeature.EC_NR in configuration.supported_hf_features:
|
||||||
|
hf_supported_features |= HfSdpFeature.EC_NR
|
||||||
|
if HfFeature.THREE_WAY_CALLING in configuration.supported_hf_features:
|
||||||
|
hf_supported_features |= HfSdpFeature.THREE_WAY_CALLING
|
||||||
|
if HfFeature.CLI_PRESENTATION_CAPABILITY in configuration.supported_hf_features:
|
||||||
|
hf_supported_features |= HfSdpFeature.CLI_PRESENTATION_CAPABILITY
|
||||||
|
if HfFeature.VOICE_RECOGNITION_ACTIVATION in configuration.supported_hf_features:
|
||||||
|
hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_ACTIVATION
|
||||||
|
if HfFeature.REMOTE_VOLUME_CONTROL in configuration.supported_hf_features:
|
||||||
|
hf_supported_features |= HfSdpFeature.REMOTE_VOLUME_CONTROL
|
||||||
|
if (
|
||||||
|
HfFeature.ENHANCED_VOICE_RECOGNITION_STATUS
|
||||||
|
in configuration.supported_hf_features
|
||||||
|
):
|
||||||
|
hf_supported_features |= HfSdpFeature.ENHANCED_VOICE_RECOGNITION_STATUS
|
||||||
|
if HfFeature.VOICE_RECOGNITION_TEST in configuration.supported_hf_features:
|
||||||
|
hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_TEST
|
||||||
|
|
||||||
|
if AudioCodec.MSBC in configuration.supported_audio_codecs:
|
||||||
|
hf_supported_features |= HfSdpFeature.WIDE_BAND
|
||||||
|
|
||||||
|
return [
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||||
|
DataElement.unsigned_integer_32(service_record_handle),
|
||||||
|
),
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
|
DataElement.uuid(BT_HANDSFREE_SERVICE),
|
||||||
|
DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
|
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
|
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
|
||||||
|
DataElement.unsigned_integer_8(rfcomm_channel),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
|
DataElement.uuid(BT_HANDSFREE_SERVICE),
|
||||||
|
DataElement.unsigned_integer_16(ProfileVersion.V1_8),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
|
||||||
|
DataElement.unsigned_integer_16(hf_supported_features),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# ESCO Codec Default Parameters
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# Hands-Free Profile v1.8, 5.7 Codec Interoperability Requirements
|
||||||
|
class DefaultCodecParameters(enum.IntEnum):
|
||||||
|
SCO_CVSD_D0 = enum.auto()
|
||||||
|
SCO_CVSD_D1 = enum.auto()
|
||||||
|
ESCO_CVSD_S1 = enum.auto()
|
||||||
|
ESCO_CVSD_S2 = enum.auto()
|
||||||
|
ESCO_CVSD_S3 = enum.auto()
|
||||||
|
ESCO_CVSD_S4 = enum.auto()
|
||||||
|
ESCO_MSBC_T1 = enum.auto()
|
||||||
|
ESCO_MSBC_T2 = enum.auto()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class EscoParameters:
|
||||||
|
# Codec specific
|
||||||
|
transmit_coding_format: CodingFormat
|
||||||
|
receive_coding_format: CodingFormat
|
||||||
|
packet_type: HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType
|
||||||
|
retransmission_effort: HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort
|
||||||
|
max_latency: int
|
||||||
|
|
||||||
|
# Common
|
||||||
|
input_coding_format: CodingFormat = CodingFormat(CodecID.LINEAR_PCM)
|
||||||
|
output_coding_format: CodingFormat = CodingFormat(CodecID.LINEAR_PCM)
|
||||||
|
input_coded_data_size: int = 16
|
||||||
|
output_coded_data_size: int = 16
|
||||||
|
input_pcm_data_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat = (
|
||||||
|
HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT
|
||||||
|
)
|
||||||
|
output_pcm_data_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat = (
|
||||||
|
HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT
|
||||||
|
)
|
||||||
|
input_pcm_sample_payload_msb_position: int = 0
|
||||||
|
output_pcm_sample_payload_msb_position: int = 0
|
||||||
|
input_data_path: HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath = (
|
||||||
|
HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath.HCI
|
||||||
|
)
|
||||||
|
output_data_path: HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath = (
|
||||||
|
HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath.HCI
|
||||||
|
)
|
||||||
|
input_transport_unit_size: int = 0
|
||||||
|
output_transport_unit_size: int = 0
|
||||||
|
input_bandwidth: int = 16000
|
||||||
|
output_bandwidth: int = 16000
|
||||||
|
transmit_bandwidth: int = 8000
|
||||||
|
receive_bandwidth: int = 8000
|
||||||
|
transmit_codec_frame_size: int = 60
|
||||||
|
receive_codec_frame_size: int = 60
|
||||||
|
|
||||||
|
def asdict(self) -> Dict[str, Any]:
|
||||||
|
# dataclasses.asdict() will recursively deep-copy the entire object,
|
||||||
|
# which is expensive and breaks CodingFormat object, so let it simply copy here.
|
||||||
|
return self.__dict__
|
||||||
|
|
||||||
|
|
||||||
|
_ESCO_PARAMETERS_CVSD_D0 = EscoParameters(
|
||||||
|
transmit_coding_format=CodingFormat(CodecID.CVSD),
|
||||||
|
receive_coding_format=CodingFormat(CodecID.CVSD),
|
||||||
|
max_latency=0xFFFF,
|
||||||
|
packet_type=HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.HV1,
|
||||||
|
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.NO_RETRANSMISSION,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ESCO_PARAMETERS_CVSD_D1 = EscoParameters(
|
||||||
|
transmit_coding_format=CodingFormat(CodecID.CVSD),
|
||||||
|
receive_coding_format=CodingFormat(CodecID.CVSD),
|
||||||
|
max_latency=0xFFFF,
|
||||||
|
packet_type=HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.HV3,
|
||||||
|
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.NO_RETRANSMISSION,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ESCO_PARAMETERS_CVSD_S1 = EscoParameters(
|
||||||
|
transmit_coding_format=CodingFormat(CodecID.CVSD),
|
||||||
|
receive_coding_format=CodingFormat(CodecID.CVSD),
|
||||||
|
max_latency=0x0007,
|
||||||
|
packet_type=(
|
||||||
|
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV3
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
|
||||||
|
),
|
||||||
|
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_POWER,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ESCO_PARAMETERS_CVSD_S2 = EscoParameters(
|
||||||
|
transmit_coding_format=CodingFormat(CodecID.CVSD),
|
||||||
|
receive_coding_format=CodingFormat(CodecID.CVSD),
|
||||||
|
max_latency=0x0007,
|
||||||
|
packet_type=(
|
||||||
|
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
|
||||||
|
),
|
||||||
|
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_POWER,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ESCO_PARAMETERS_CVSD_S3 = EscoParameters(
|
||||||
|
transmit_coding_format=CodingFormat(CodecID.CVSD),
|
||||||
|
receive_coding_format=CodingFormat(CodecID.CVSD),
|
||||||
|
max_latency=0x000A,
|
||||||
|
packet_type=(
|
||||||
|
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
|
||||||
|
),
|
||||||
|
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_POWER,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ESCO_PARAMETERS_CVSD_S4 = EscoParameters(
|
||||||
|
transmit_coding_format=CodingFormat(CodecID.CVSD),
|
||||||
|
receive_coding_format=CodingFormat(CodecID.CVSD),
|
||||||
|
max_latency=0x000C,
|
||||||
|
packet_type=(
|
||||||
|
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
|
||||||
|
),
|
||||||
|
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ESCO_PARAMETERS_MSBC_T1 = EscoParameters(
|
||||||
|
transmit_coding_format=CodingFormat(CodecID.MSBC),
|
||||||
|
receive_coding_format=CodingFormat(CodecID.MSBC),
|
||||||
|
max_latency=0x0008,
|
||||||
|
packet_type=(
|
||||||
|
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
|
||||||
|
),
|
||||||
|
input_bandwidth=32000,
|
||||||
|
output_bandwidth=32000,
|
||||||
|
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ESCO_PARAMETERS_MSBC_T2 = EscoParameters(
|
||||||
|
transmit_coding_format=CodingFormat(CodecID.MSBC),
|
||||||
|
receive_coding_format=CodingFormat(CodecID.MSBC),
|
||||||
|
max_latency=0x000D,
|
||||||
|
packet_type=(
|
||||||
|
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV3
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
|
||||||
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
|
||||||
|
),
|
||||||
|
input_bandwidth=32000,
|
||||||
|
output_bandwidth=32000,
|
||||||
|
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
ESCO_PARAMETERS = {
|
||||||
|
DefaultCodecParameters.SCO_CVSD_D0: _ESCO_PARAMETERS_CVSD_D0,
|
||||||
|
DefaultCodecParameters.SCO_CVSD_D1: _ESCO_PARAMETERS_CVSD_D1,
|
||||||
|
DefaultCodecParameters.ESCO_CVSD_S1: _ESCO_PARAMETERS_CVSD_S1,
|
||||||
|
DefaultCodecParameters.ESCO_CVSD_S2: _ESCO_PARAMETERS_CVSD_S2,
|
||||||
|
DefaultCodecParameters.ESCO_CVSD_S3: _ESCO_PARAMETERS_CVSD_S3,
|
||||||
|
DefaultCodecParameters.ESCO_CVSD_S4: _ESCO_PARAMETERS_CVSD_S4,
|
||||||
|
DefaultCodecParameters.ESCO_MSBC_T1: _ESCO_PARAMETERS_MSBC_T1,
|
||||||
|
DefaultCodecParameters.ESCO_MSBC_T2: _ESCO_PARAMETERS_MSBC_T2,
|
||||||
|
}
|
||||||
|
|||||||
333
bumble/hid.py
Normal file
333
bumble/hid.py
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import logging
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from pyee import EventEmitter
|
||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
from bumble import l2cap
|
||||||
|
from bumble.colors import color
|
||||||
|
from bumble.core import InvalidStateError, ProtocolError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble.device import Device, Connection
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# fmt: on
|
||||||
|
HID_CONTROL_PSM = 0x0011
|
||||||
|
HID_INTERRUPT_PSM = 0x0013
|
||||||
|
|
||||||
|
|
||||||
|
class Message:
|
||||||
|
message_type: MessageType
|
||||||
|
# Report types
|
||||||
|
class ReportType(enum.IntEnum):
|
||||||
|
OTHER_REPORT = 0x00
|
||||||
|
INPUT_REPORT = 0x01
|
||||||
|
OUTPUT_REPORT = 0x02
|
||||||
|
FEATURE_REPORT = 0x03
|
||||||
|
|
||||||
|
# Handshake parameters
|
||||||
|
class Handshake(enum.IntEnum):
|
||||||
|
SUCCESSFUL = 0x00
|
||||||
|
NOT_READY = 0x01
|
||||||
|
ERR_INVALID_REPORT_ID = 0x02
|
||||||
|
ERR_UNSUPPORTED_REQUEST = 0x03
|
||||||
|
ERR_UNKNOWN = 0x0E
|
||||||
|
ERR_FATAL = 0x0F
|
||||||
|
|
||||||
|
# Message Type
|
||||||
|
class MessageType(enum.IntEnum):
|
||||||
|
HANDSHAKE = 0x00
|
||||||
|
CONTROL = 0x01
|
||||||
|
GET_REPORT = 0x04
|
||||||
|
SET_REPORT = 0x05
|
||||||
|
GET_PROTOCOL = 0x06
|
||||||
|
SET_PROTOCOL = 0x07
|
||||||
|
DATA = 0x0A
|
||||||
|
|
||||||
|
# Protocol modes
|
||||||
|
class ProtocolMode(enum.IntEnum):
|
||||||
|
BOOT_PROTOCOL = 0x00
|
||||||
|
REPORT_PROTOCOL = 0x01
|
||||||
|
|
||||||
|
# Control Operations
|
||||||
|
class ControlCommand(enum.IntEnum):
|
||||||
|
SUSPEND = 0x03
|
||||||
|
EXIT_SUSPEND = 0x04
|
||||||
|
VIRTUAL_CABLE_UNPLUG = 0x05
|
||||||
|
|
||||||
|
# Class Method to derive header
|
||||||
|
@classmethod
|
||||||
|
def header(cls, lower_bits: int = 0x00) -> bytes:
|
||||||
|
return bytes([(cls.message_type << 4) | lower_bits])
|
||||||
|
|
||||||
|
|
||||||
|
# HIDP messages
|
||||||
|
@dataclass
|
||||||
|
class GetReportMessage(Message):
|
||||||
|
report_type: int
|
||||||
|
report_id: int
|
||||||
|
buffer_size: int
|
||||||
|
message_type = Message.MessageType.GET_REPORT
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
packet_bytes = bytearray()
|
||||||
|
packet_bytes.append(self.report_id)
|
||||||
|
packet_bytes.extend(
|
||||||
|
[(self.buffer_size & 0xFF), ((self.buffer_size >> 8) & 0xFF)]
|
||||||
|
)
|
||||||
|
if self.report_type == Message.ReportType.OTHER_REPORT:
|
||||||
|
return self.header(self.report_type) + packet_bytes
|
||||||
|
else:
|
||||||
|
return self.header(0x08 | self.report_type) + packet_bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SetReportMessage(Message):
|
||||||
|
report_type: int
|
||||||
|
data: bytes
|
||||||
|
message_type = Message.MessageType.SET_REPORT
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return self.header(self.report_type) + self.data
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GetProtocolMessage(Message):
|
||||||
|
message_type = Message.MessageType.GET_PROTOCOL
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return self.header()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SetProtocolMessage(Message):
|
||||||
|
protocol_mode: int
|
||||||
|
message_type = Message.MessageType.SET_PROTOCOL
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return self.header(self.protocol_mode)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Suspend(Message):
|
||||||
|
message_type = Message.MessageType.CONTROL
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return self.header(Message.ControlCommand.SUSPEND)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExitSuspend(Message):
|
||||||
|
message_type = Message.MessageType.CONTROL
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return self.header(Message.ControlCommand.EXIT_SUSPEND)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VirtualCableUnplug(Message):
|
||||||
|
message_type = Message.MessageType.CONTROL
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return self.header(Message.ControlCommand.VIRTUAL_CABLE_UNPLUG)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SendData(Message):
|
||||||
|
data: bytes
|
||||||
|
message_type = Message.MessageType.DATA
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return self.header(Message.ReportType.OUTPUT_REPORT) + self.data
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Host(EventEmitter):
|
||||||
|
l2cap_ctrl_channel: Optional[l2cap.ClassicChannel]
|
||||||
|
l2cap_intr_channel: Optional[l2cap.ClassicChannel]
|
||||||
|
|
||||||
|
def __init__(self, device: Device, connection: Connection) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.device = device
|
||||||
|
self.connection = connection
|
||||||
|
|
||||||
|
self.l2cap_ctrl_channel = None
|
||||||
|
self.l2cap_intr_channel = None
|
||||||
|
|
||||||
|
# Register ourselves with the L2CAP channel manager
|
||||||
|
device.register_l2cap_server(HID_CONTROL_PSM, self.on_connection)
|
||||||
|
device.register_l2cap_server(HID_INTERRUPT_PSM, self.on_connection)
|
||||||
|
|
||||||
|
async def connect_control_channel(self) -> None:
|
||||||
|
# Create a new L2CAP connection - control channel
|
||||||
|
try:
|
||||||
|
self.l2cap_ctrl_channel = await self.device.l2cap_channel_manager.connect(
|
||||||
|
self.connection, HID_CONTROL_PSM
|
||||||
|
)
|
||||||
|
except ProtocolError:
|
||||||
|
logging.exception(f'L2CAP connection failed.')
|
||||||
|
raise
|
||||||
|
|
||||||
|
assert self.l2cap_ctrl_channel is not None
|
||||||
|
# Become a sink for the L2CAP channel
|
||||||
|
self.l2cap_ctrl_channel.sink = self.on_ctrl_pdu
|
||||||
|
|
||||||
|
async def connect_interrupt_channel(self) -> None:
|
||||||
|
# Create a new L2CAP connection - interrupt channel
|
||||||
|
try:
|
||||||
|
self.l2cap_intr_channel = await self.device.l2cap_channel_manager.connect(
|
||||||
|
self.connection, HID_INTERRUPT_PSM
|
||||||
|
)
|
||||||
|
except ProtocolError:
|
||||||
|
logging.exception(f'L2CAP connection failed.')
|
||||||
|
raise
|
||||||
|
|
||||||
|
assert self.l2cap_intr_channel is not None
|
||||||
|
# Become a sink for the L2CAP channel
|
||||||
|
self.l2cap_intr_channel.sink = self.on_intr_pdu
|
||||||
|
|
||||||
|
async def disconnect_interrupt_channel(self) -> None:
|
||||||
|
if self.l2cap_intr_channel is None:
|
||||||
|
raise InvalidStateError('invalid state')
|
||||||
|
channel = self.l2cap_intr_channel
|
||||||
|
self.l2cap_intr_channel = None
|
||||||
|
await channel.disconnect()
|
||||||
|
|
||||||
|
async def disconnect_control_channel(self) -> None:
|
||||||
|
if self.l2cap_ctrl_channel is None:
|
||||||
|
raise InvalidStateError('invalid state')
|
||||||
|
channel = self.l2cap_ctrl_channel
|
||||||
|
self.l2cap_ctrl_channel = None
|
||||||
|
await channel.disconnect()
|
||||||
|
|
||||||
|
def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> 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: l2cap.ClassicChannel) -> None:
|
||||||
|
if l2cap_channel.psm == HID_CONTROL_PSM:
|
||||||
|
self.l2cap_ctrl_channel = l2cap_channel
|
||||||
|
self.l2cap_ctrl_channel.sink = self.on_ctrl_pdu
|
||||||
|
else:
|
||||||
|
self.l2cap_intr_channel = l2cap_channel
|
||||||
|
self.l2cap_intr_channel.sink = self.on_intr_pdu
|
||||||
|
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
|
||||||
|
|
||||||
|
def on_ctrl_pdu(self, pdu: bytes) -> None:
|
||||||
|
logger.debug(f'<<< HID CONTROL PDU: {pdu.hex()}')
|
||||||
|
# Here we will receive all kinds of packets, parse and then call respective callbacks
|
||||||
|
message_type = pdu[0] >> 4
|
||||||
|
param = pdu[0] & 0x0F
|
||||||
|
|
||||||
|
if message_type == Message.MessageType.HANDSHAKE:
|
||||||
|
logger.debug(f'<<< HID HANDSHAKE: {Message.Handshake(param).name}')
|
||||||
|
self.emit('handshake', Message.Handshake(param))
|
||||||
|
elif message_type == Message.MessageType.DATA:
|
||||||
|
logger.debug('<<< HID CONTROL DATA')
|
||||||
|
self.emit('data', pdu)
|
||||||
|
elif message_type == Message.MessageType.CONTROL:
|
||||||
|
if param == Message.ControlCommand.SUSPEND:
|
||||||
|
logger.debug('<<< HID SUSPEND')
|
||||||
|
self.emit('suspend', pdu)
|
||||||
|
elif param == Message.ControlCommand.EXIT_SUSPEND:
|
||||||
|
logger.debug('<<< HID EXIT SUSPEND')
|
||||||
|
self.emit('exit_suspend', pdu)
|
||||||
|
elif param == Message.ControlCommand.VIRTUAL_CABLE_UNPLUG:
|
||||||
|
logger.debug('<<< HID VIRTUAL CABLE UNPLUG')
|
||||||
|
self.emit('virtual_cable_unplug')
|
||||||
|
else:
|
||||||
|
logger.debug('<<< HID CONTROL OPERATION UNSUPPORTED')
|
||||||
|
else:
|
||||||
|
logger.debug('<<< HID CONTROL DATA')
|
||||||
|
self.emit('data', pdu)
|
||||||
|
|
||||||
|
def on_intr_pdu(self, pdu: bytes) -> None:
|
||||||
|
logger.debug(f'<<< HID INTERRUPT PDU: {pdu.hex()}')
|
||||||
|
self.emit("data", pdu)
|
||||||
|
|
||||||
|
def get_report(self, report_type: int, report_id: int, buffer_size: int) -> None:
|
||||||
|
msg = GetReportMessage(
|
||||||
|
report_type=report_type, report_id=report_id, buffer_size=buffer_size
|
||||||
|
)
|
||||||
|
hid_message = bytes(msg)
|
||||||
|
logger.debug(f'>>> HID CONTROL GET REPORT, PDU: {hid_message.hex()}')
|
||||||
|
self.send_pdu_on_ctrl(hid_message)
|
||||||
|
|
||||||
|
def set_report(self, report_type: int, data: bytes):
|
||||||
|
msg = SetReportMessage(report_type=report_type, data=data)
|
||||||
|
hid_message = bytes(msg)
|
||||||
|
logger.debug(f'>>> HID CONTROL SET REPORT, PDU:{hid_message.hex()}')
|
||||||
|
self.send_pdu_on_ctrl(hid_message)
|
||||||
|
|
||||||
|
def get_protocol(self):
|
||||||
|
msg = GetProtocolMessage()
|
||||||
|
hid_message = bytes(msg)
|
||||||
|
logger.debug(f'>>> HID CONTROL GET PROTOCOL, PDU: {hid_message.hex()}')
|
||||||
|
self.send_pdu_on_ctrl(hid_message)
|
||||||
|
|
||||||
|
def set_protocol(self, protocol_mode: int):
|
||||||
|
msg = SetProtocolMessage(protocol_mode=protocol_mode)
|
||||||
|
hid_message = bytes(msg)
|
||||||
|
logger.debug(f'>>> HID CONTROL SET PROTOCOL, PDU: {hid_message.hex()}')
|
||||||
|
self.send_pdu_on_ctrl(hid_message)
|
||||||
|
|
||||||
|
def send_pdu_on_ctrl(self, msg: bytes) -> None:
|
||||||
|
assert self.l2cap_ctrl_channel
|
||||||
|
self.l2cap_ctrl_channel.send_pdu(msg)
|
||||||
|
|
||||||
|
def send_pdu_on_intr(self, msg: bytes) -> None:
|
||||||
|
assert self.l2cap_intr_channel
|
||||||
|
self.l2cap_intr_channel.send_pdu(msg)
|
||||||
|
|
||||||
|
def send_data(self, data):
|
||||||
|
msg = SendData(data)
|
||||||
|
hid_message = bytes(msg)
|
||||||
|
logger.debug(f'>>> HID INTERRUPT SEND DATA, PDU: {hid_message.hex()}')
|
||||||
|
self.send_pdu_on_intr(hid_message)
|
||||||
|
|
||||||
|
def suspend(self):
|
||||||
|
msg = Suspend()
|
||||||
|
hid_message = bytes(msg)
|
||||||
|
logger.debug(f'>>> HID CONTROL SUSPEND, PDU:{hid_message.hex()}')
|
||||||
|
self.send_pdu_on_ctrl(msg)
|
||||||
|
|
||||||
|
def exit_suspend(self):
|
||||||
|
msg = ExitSuspend()
|
||||||
|
hid_message = bytes(msg)
|
||||||
|
logger.debug(f'>>> HID CONTROL EXIT SUSPEND, PDU:{hid_message.hex()}')
|
||||||
|
self.send_pdu_on_ctrl(msg)
|
||||||
|
|
||||||
|
def virtual_cable_unplug(self):
|
||||||
|
msg = VirtualCableUnplug()
|
||||||
|
hid_message = bytes(msg)
|
||||||
|
logger.debug(f'>>> HID CONTROL VIRTUAL CABLE UNPLUG, PDU: {hid_message.hex()}')
|
||||||
|
self.send_pdu_on_ctrl(msg)
|
||||||
143
bumble/host.py
143
bumble/host.py
@@ -15,24 +15,25 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import collections
|
import collections
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
|
from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable, cast
|
||||||
|
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.l2cap import L2CAP_PDU
|
from bumble.l2cap import L2CAP_PDU
|
||||||
from bumble.snoop import Snooper
|
from bumble.snoop import Snooper
|
||||||
from bumble import drivers
|
from bumble import drivers
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from .hci import (
|
from .hci import (
|
||||||
Address,
|
Address,
|
||||||
HCI_ACL_DATA_PACKET,
|
HCI_ACL_DATA_PACKET,
|
||||||
HCI_COMMAND_COMPLETE_EVENT,
|
|
||||||
HCI_COMMAND_PACKET,
|
HCI_COMMAND_PACKET,
|
||||||
HCI_EVENT_PACKET,
|
HCI_EVENT_PACKET,
|
||||||
|
HCI_ISO_DATA_PACKET,
|
||||||
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
||||||
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
|
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
|
||||||
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
||||||
@@ -42,11 +43,16 @@ from .hci import (
|
|||||||
HCI_RESET_COMMAND,
|
HCI_RESET_COMMAND,
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
HCI_SUPPORTED_COMMANDS_FLAGS,
|
HCI_SUPPORTED_COMMANDS_FLAGS,
|
||||||
|
HCI_SYNCHRONOUS_DATA_PACKET,
|
||||||
HCI_VERSION_BLUETOOTH_CORE_4_0,
|
HCI_VERSION_BLUETOOTH_CORE_4_0,
|
||||||
HCI_AclDataPacket,
|
HCI_AclDataPacket,
|
||||||
HCI_AclDataPacketAssembler,
|
HCI_AclDataPacketAssembler,
|
||||||
|
HCI_Command,
|
||||||
|
HCI_Command_Complete_Event,
|
||||||
HCI_Constant,
|
HCI_Constant,
|
||||||
HCI_Error,
|
HCI_Error,
|
||||||
|
HCI_Event,
|
||||||
|
HCI_IsoDataPacket,
|
||||||
HCI_LE_Long_Term_Key_Request_Negative_Reply_Command,
|
HCI_LE_Long_Term_Key_Request_Negative_Reply_Command,
|
||||||
HCI_LE_Long_Term_Key_Request_Reply_Command,
|
HCI_LE_Long_Term_Key_Request_Reply_Command,
|
||||||
HCI_LE_Read_Buffer_Size_Command,
|
HCI_LE_Read_Buffer_Size_Command,
|
||||||
@@ -63,16 +69,19 @@ from .hci import (
|
|||||||
HCI_Read_Local_Version_Information_Command,
|
HCI_Read_Local_Version_Information_Command,
|
||||||
HCI_Reset_Command,
|
HCI_Reset_Command,
|
||||||
HCI_Set_Event_Mask_Command,
|
HCI_Set_Event_Mask_Command,
|
||||||
map_null_terminated_utf8_string,
|
HCI_SynchronousDataPacket,
|
||||||
)
|
)
|
||||||
from .core import (
|
from .core import (
|
||||||
BT_BR_EDR_TRANSPORT,
|
BT_BR_EDR_TRANSPORT,
|
||||||
BT_CENTRAL_ROLE,
|
|
||||||
BT_LE_TRANSPORT,
|
BT_LE_TRANSPORT,
|
||||||
ConnectionPHY,
|
ConnectionPHY,
|
||||||
ConnectionParameters,
|
ConnectionParameters,
|
||||||
)
|
)
|
||||||
from .utils import AbortableEventEmitter
|
from .utils import AbortableEventEmitter
|
||||||
|
from .transport.common import TransportLostError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .transport.common import TransportSink, TransportSource
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -96,27 +105,38 @@ HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Connection:
|
class Connection:
|
||||||
def __init__(self, host, handle, peer_address, transport):
|
def __init__(self, host: Host, handle: int, peer_address: Address, transport: int):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.handle = handle
|
self.handle = handle
|
||||||
self.peer_address = peer_address
|
self.peer_address = peer_address
|
||||||
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
|
|
||||||
def on_hci_acl_data_packet(self, packet):
|
def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
|
||||||
self.assembler.feed_packet(packet)
|
self.assembler.feed_packet(packet)
|
||||||
|
|
||||||
def on_acl_pdu(self, pdu):
|
def on_acl_pdu(self, pdu: bytes) -> None:
|
||||||
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
||||||
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
|
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Host(AbortableEventEmitter):
|
class Host(AbortableEventEmitter):
|
||||||
def __init__(self, controller_source=None, controller_sink=None):
|
connections: Dict[int, Connection]
|
||||||
|
acl_packet_queue: collections.deque[HCI_AclDataPacket]
|
||||||
|
hci_sink: TransportSink
|
||||||
|
long_term_key_provider: Optional[
|
||||||
|
Callable[[int, bytes, int], Awaitable[Optional[bytes]]]
|
||||||
|
]
|
||||||
|
link_key_provider: Optional[Callable[[Address], Awaitable[Optional[bytes]]]]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
controller_source: Optional[TransportSource] = None,
|
||||||
|
controller_sink: Optional[TransportSink] = None,
|
||||||
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.hci_sink = None
|
|
||||||
self.hci_metadata = None
|
self.hci_metadata = None
|
||||||
self.ready = False # True when we can accept incoming packets
|
self.ready = False # True when we can accept incoming packets
|
||||||
self.reset_done = False
|
self.reset_done = False
|
||||||
@@ -223,7 +243,7 @@ class Host(AbortableEventEmitter):
|
|||||||
# understand
|
# understand
|
||||||
le_event_mask = bytes.fromhex('1F00000000000000')
|
le_event_mask = bytes.fromhex('1F00000000000000')
|
||||||
else:
|
else:
|
||||||
le_event_mask = bytes.fromhex('FFFFF00000000000')
|
le_event_mask = bytes.fromhex('FFFFFFFF00000000')
|
||||||
|
|
||||||
await self.send_command(
|
await self.send_command(
|
||||||
HCI_LE_Set_Event_Mask_Command(le_event_mask=le_event_mask)
|
HCI_LE_Set_Event_Mask_Command(le_event_mask=le_event_mask)
|
||||||
@@ -296,7 +316,7 @@ class Host(AbortableEventEmitter):
|
|||||||
self.reset_done = True
|
self.reset_done = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def controller(self):
|
def controller(self) -> TransportSink:
|
||||||
return self.hci_sink
|
return self.hci_sink
|
||||||
|
|
||||||
@controller.setter
|
@controller.setter
|
||||||
@@ -305,13 +325,12 @@ class Host(AbortableEventEmitter):
|
|||||||
if controller:
|
if controller:
|
||||||
controller.set_packet_sink(self)
|
controller.set_packet_sink(self)
|
||||||
|
|
||||||
def set_packet_sink(self, sink):
|
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||||
self.hci_sink = sink
|
self.hci_sink = sink
|
||||||
|
|
||||||
def send_hci_packet(self, packet):
|
def send_hci_packet(self, packet: HCI_Packet) -> None:
|
||||||
if self.snooper:
|
if self.snooper:
|
||||||
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
|
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
|
||||||
|
|
||||||
self.hci_sink.on_packet(bytes(packet))
|
self.hci_sink.on_packet(bytes(packet))
|
||||||
|
|
||||||
async def send_command(self, command, check_result=False):
|
async def send_command(self, command, check_result=False):
|
||||||
@@ -349,7 +368,7 @@ class Host(AbortableEventEmitter):
|
|||||||
return response
|
return response
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f'{color("!!! Exception while sending HCI packet:", "red")} {error}'
|
f'{color("!!! Exception while sending command:", "red")} {error}'
|
||||||
)
|
)
|
||||||
raise error
|
raise error
|
||||||
finally:
|
finally:
|
||||||
@@ -357,13 +376,13 @@ class Host(AbortableEventEmitter):
|
|||||||
self.pending_response = None
|
self.pending_response = None
|
||||||
|
|
||||||
# Use this method to send a command from a task
|
# Use this method to send a command from a task
|
||||||
def send_command_sync(self, command):
|
def send_command_sync(self, command: HCI_Command) -> None:
|
||||||
async def send_command(command):
|
async def send_command(command: HCI_Command) -> None:
|
||||||
await self.send_command(command)
|
await self.send_command(command)
|
||||||
|
|
||||||
asyncio.create_task(send_command(command))
|
asyncio.create_task(send_command(command))
|
||||||
|
|
||||||
def send_l2cap_pdu(self, connection_handle, cid, pdu):
|
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
||||||
l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
|
l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
|
||||||
|
|
||||||
# Send the data to the controller via ACL packets
|
# Send the data to the controller via ACL packets
|
||||||
@@ -388,7 +407,7 @@ class Host(AbortableEventEmitter):
|
|||||||
offset += data_total_length
|
offset += data_total_length
|
||||||
bytes_remaining -= data_total_length
|
bytes_remaining -= data_total_length
|
||||||
|
|
||||||
def queue_acl_packet(self, acl_packet):
|
def queue_acl_packet(self, acl_packet: HCI_AclDataPacket) -> None:
|
||||||
self.acl_packet_queue.appendleft(acl_packet)
|
self.acl_packet_queue.appendleft(acl_packet)
|
||||||
self.check_acl_packet_queue()
|
self.check_acl_packet_queue()
|
||||||
|
|
||||||
@@ -398,7 +417,7 @@ class Host(AbortableEventEmitter):
|
|||||||
f'{len(self.acl_packet_queue)} in queue'
|
f'{len(self.acl_packet_queue)} in queue'
|
||||||
)
|
)
|
||||||
|
|
||||||
def check_acl_packet_queue(self):
|
def check_acl_packet_queue(self) -> None:
|
||||||
# Send all we can (TODO: support different LE/Classic limits)
|
# Send all we can (TODO: support different LE/Classic limits)
|
||||||
while (
|
while (
|
||||||
len(self.acl_packet_queue) > 0
|
len(self.acl_packet_queue) > 0
|
||||||
@@ -444,18 +463,24 @@ class Host(AbortableEventEmitter):
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Packet Sink protocol (packets coming from the controller via HCI)
|
# Packet Sink protocol (packets coming from the controller via HCI)
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
hci_packet = HCI_Packet.from_bytes(packet)
|
hci_packet = HCI_Packet.from_bytes(packet)
|
||||||
if self.ready or (
|
if self.ready or (
|
||||||
hci_packet.hci_packet_type == HCI_EVENT_PACKET
|
isinstance(hci_packet, HCI_Command_Complete_Event)
|
||||||
and hci_packet.event_code == HCI_COMMAND_COMPLETE_EVENT
|
|
||||||
and hci_packet.command_opcode == HCI_RESET_COMMAND
|
and hci_packet.command_opcode == HCI_RESET_COMMAND
|
||||||
):
|
):
|
||||||
self.on_hci_packet(hci_packet)
|
self.on_hci_packet(hci_packet)
|
||||||
else:
|
else:
|
||||||
logger.debug('reset not done, ignoring packet from controller')
|
logger.debug('reset not done, ignoring packet from controller')
|
||||||
|
|
||||||
def on_hci_packet(self, packet):
|
def on_transport_lost(self):
|
||||||
|
# Called by the source when the transport has been lost.
|
||||||
|
if self.pending_response:
|
||||||
|
self.pending_response.set_exception(TransportLostError('transport lost'))
|
||||||
|
|
||||||
|
self.emit('flush')
|
||||||
|
|
||||||
|
def on_hci_packet(self, packet: HCI_Packet) -> None:
|
||||||
logger.debug(f'{color("### CONTROLLER -> HOST", "green")}: {packet}')
|
logger.debug(f'{color("### CONTROLLER -> HOST", "green")}: {packet}')
|
||||||
|
|
||||||
if self.snooper:
|
if self.snooper:
|
||||||
@@ -463,28 +488,40 @@ class Host(AbortableEventEmitter):
|
|||||||
|
|
||||||
# If the packet is a command, invoke the handler for this packet
|
# If the packet is a command, invoke the handler for this packet
|
||||||
if packet.hci_packet_type == HCI_COMMAND_PACKET:
|
if packet.hci_packet_type == HCI_COMMAND_PACKET:
|
||||||
self.on_hci_command_packet(packet)
|
self.on_hci_command_packet(cast(HCI_Command, packet))
|
||||||
elif packet.hci_packet_type == HCI_EVENT_PACKET:
|
elif packet.hci_packet_type == HCI_EVENT_PACKET:
|
||||||
self.on_hci_event_packet(packet)
|
self.on_hci_event_packet(cast(HCI_Event, packet))
|
||||||
elif packet.hci_packet_type == HCI_ACL_DATA_PACKET:
|
elif packet.hci_packet_type == HCI_ACL_DATA_PACKET:
|
||||||
self.on_hci_acl_data_packet(packet)
|
self.on_hci_acl_data_packet(cast(HCI_AclDataPacket, packet))
|
||||||
|
elif packet.hci_packet_type == HCI_SYNCHRONOUS_DATA_PACKET:
|
||||||
|
self.on_hci_sco_data_packet(cast(HCI_SynchronousDataPacket, packet))
|
||||||
|
elif packet.hci_packet_type == HCI_ISO_DATA_PACKET:
|
||||||
|
self.on_hci_iso_data_packet(cast(HCI_IsoDataPacket, packet))
|
||||||
else:
|
else:
|
||||||
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
|
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
|
||||||
|
|
||||||
def on_hci_command_packet(self, command):
|
def on_hci_command_packet(self, command: HCI_Command) -> None:
|
||||||
logger.warning(f'!!! unexpected command packet: {command}')
|
logger.warning(f'!!! unexpected command packet: {command}')
|
||||||
|
|
||||||
def on_hci_event_packet(self, event):
|
def on_hci_event_packet(self, event: HCI_Event) -> None:
|
||||||
handler_name = f'on_{event.name.lower()}'
|
handler_name = f'on_{event.name.lower()}'
|
||||||
handler = getattr(self, handler_name, self.on_hci_event)
|
handler = getattr(self, handler_name, self.on_hci_event)
|
||||||
handler(event)
|
handler(event)
|
||||||
|
|
||||||
def on_hci_acl_data_packet(self, packet):
|
def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
|
||||||
# Look for the connection to which this data belongs
|
# Look for the connection to which this data belongs
|
||||||
if connection := self.connections.get(packet.connection_handle):
|
if connection := self.connections.get(packet.connection_handle):
|
||||||
connection.on_hci_acl_data_packet(packet)
|
connection.on_hci_acl_data_packet(packet)
|
||||||
|
|
||||||
def on_l2cap_pdu(self, connection, cid, pdu):
|
def on_hci_sco_data_packet(self, packet: HCI_SynchronousDataPacket) -> None:
|
||||||
|
# Experimental
|
||||||
|
self.emit('sco_packet', packet.connection_handle, packet)
|
||||||
|
|
||||||
|
def on_hci_iso_data_packet(self, packet: HCI_IsoDataPacket) -> None:
|
||||||
|
# Experimental
|
||||||
|
self.emit('iso_packet', packet.connection_handle, packet)
|
||||||
|
|
||||||
|
def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
|
||||||
self.emit('l2cap_pdu', connection.handle, cid, pdu)
|
self.emit('l2cap_pdu', connection.handle, cid, pdu)
|
||||||
|
|
||||||
def on_command_processed(self, event):
|
def on_command_processed(self, event):
|
||||||
@@ -684,6 +721,24 @@ class Host(AbortableEventEmitter):
|
|||||||
def on_hci_le_extended_advertising_report_event(self, event):
|
def on_hci_le_extended_advertising_report_event(self, event):
|
||||||
self.on_hci_le_advertising_report_event(event)
|
self.on_hci_le_advertising_report_event(event)
|
||||||
|
|
||||||
|
def on_hci_le_cis_request_event(self, event):
|
||||||
|
self.emit(
|
||||||
|
'cis_request',
|
||||||
|
event.acl_connection_handle,
|
||||||
|
event.cis_connection_handle,
|
||||||
|
event.cig_id,
|
||||||
|
event.cis_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_hci_le_cis_established_event(self, event):
|
||||||
|
# The remaining parameters are unused for now.
|
||||||
|
if event.status == HCI_SUCCESS:
|
||||||
|
self.emit('cis_establishment', event.connection_handle)
|
||||||
|
else:
|
||||||
|
self.emit(
|
||||||
|
'cis_establishment_failure', event.connection_handle, event.status
|
||||||
|
)
|
||||||
|
|
||||||
def on_hci_le_remote_connection_parameter_request_event(self, event):
|
def on_hci_le_remote_connection_parameter_request_event(self, event):
|
||||||
if event.connection_handle not in self.connections:
|
if event.connection_handle not in self.connections:
|
||||||
logger.warning('!!! REMOTE CONNECTION PARAMETER REQUEST: unknown handle')
|
logger.warning('!!! REMOTE CONNECTION PARAMETER REQUEST: unknown handle')
|
||||||
@@ -737,7 +792,25 @@ class Host(AbortableEventEmitter):
|
|||||||
asyncio.create_task(send_long_term_key())
|
asyncio.create_task(send_long_term_key())
|
||||||
|
|
||||||
def on_hci_synchronous_connection_complete_event(self, event):
|
def on_hci_synchronous_connection_complete_event(self, event):
|
||||||
pass
|
if event.status == HCI_SUCCESS:
|
||||||
|
# Create/update the connection
|
||||||
|
logger.debug(
|
||||||
|
f'### SCO CONNECTION: [0x{event.connection_handle:04X}] '
|
||||||
|
f'{event.bd_addr}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify the client
|
||||||
|
self.emit(
|
||||||
|
'sco_connection',
|
||||||
|
event.bd_addr,
|
||||||
|
event.connection_handle,
|
||||||
|
event.link_type,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(f'### SCO CONNECTION FAILED: {event.status}')
|
||||||
|
|
||||||
|
# Notify the client
|
||||||
|
self.emit('sco_connection_failure', event.bd_addr, event.status)
|
||||||
|
|
||||||
def on_hci_synchronous_connection_changed_event(self, event):
|
def on_hci_synchronous_connection_changed_event(self, event):
|
||||||
pass
|
pass
|
||||||
@@ -822,6 +895,10 @@ class Host(AbortableEventEmitter):
|
|||||||
f'simple pairing complete for {event.bd_addr}: '
|
f'simple pairing complete for {event.bd_addr}: '
|
||||||
f'status={HCI_Constant.status_name(event.status)}'
|
f'status={HCI_Constant.status_name(event.status)}'
|
||||||
)
|
)
|
||||||
|
if event.status == HCI_SUCCESS:
|
||||||
|
self.emit('classic_pairing', event.bd_addr)
|
||||||
|
else:
|
||||||
|
self.emit('classic_pairing_failure', event.bd_addr, event.status)
|
||||||
|
|
||||||
def on_hci_pin_code_request_event(self, event):
|
def on_hci_pin_code_request_event(self, event):
|
||||||
self.emit('pin_code_request', event.bd_addr)
|
self.emit('pin_code_request', event.bd_addr)
|
||||||
|
|||||||
599
bumble/l2cap.py
599
bumble/l2cap.py
File diff suppressed because it is too large
Load Diff
@@ -15,10 +15,13 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
import enum
|
import enum
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from .hci import (
|
from .hci import (
|
||||||
|
Address,
|
||||||
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
||||||
HCI_DISPLAY_ONLY_IO_CAPABILITY,
|
HCI_DISPLAY_ONLY_IO_CAPABILITY,
|
||||||
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
|
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
|
||||||
@@ -34,7 +37,60 @@ from .smp import (
|
|||||||
SMP_ID_KEY_DISTRIBUTION_FLAG,
|
SMP_ID_KEY_DISTRIBUTION_FLAG,
|
||||||
SMP_SIGN_KEY_DISTRIBUTION_FLAG,
|
SMP_SIGN_KEY_DISTRIBUTION_FLAG,
|
||||||
SMP_LINK_KEY_DISTRIBUTION_FLAG,
|
SMP_LINK_KEY_DISTRIBUTION_FLAG,
|
||||||
|
OobContext,
|
||||||
|
OobLegacyContext,
|
||||||
|
OobSharedData,
|
||||||
)
|
)
|
||||||
|
from .core import AdvertisingData, LeRole
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
|
class OobData:
|
||||||
|
"""OOB data that can be sent from one device to another."""
|
||||||
|
|
||||||
|
address: Optional[Address] = None
|
||||||
|
role: Optional[LeRole] = None
|
||||||
|
shared_data: Optional[OobSharedData] = None
|
||||||
|
legacy_context: Optional[OobLegacyContext] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_ad(cls, ad: AdvertisingData) -> OobData:
|
||||||
|
instance = cls()
|
||||||
|
shared_data_c: Optional[bytes] = None
|
||||||
|
shared_data_r: Optional[bytes] = None
|
||||||
|
for ad_type, ad_data in ad.ad_structures:
|
||||||
|
if ad_type == AdvertisingData.LE_BLUETOOTH_DEVICE_ADDRESS:
|
||||||
|
instance.address = Address(ad_data)
|
||||||
|
elif ad_type == AdvertisingData.LE_ROLE:
|
||||||
|
instance.role = LeRole(ad_data[0])
|
||||||
|
elif ad_type == AdvertisingData.LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE:
|
||||||
|
shared_data_c = ad_data
|
||||||
|
elif ad_type == AdvertisingData.LE_SECURE_CONNECTIONS_RANDOM_VALUE:
|
||||||
|
shared_data_r = ad_data
|
||||||
|
elif ad_type == AdvertisingData.SECURITY_MANAGER_TK_VALUE:
|
||||||
|
instance.legacy_context = OobLegacyContext(tk=ad_data)
|
||||||
|
if shared_data_c and shared_data_r:
|
||||||
|
instance.shared_data = OobSharedData(c=shared_data_c, r=shared_data_r)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def to_ad(self) -> AdvertisingData:
|
||||||
|
ad_structures = []
|
||||||
|
if self.address is not None:
|
||||||
|
ad_structures.append(
|
||||||
|
(AdvertisingData.LE_BLUETOOTH_DEVICE_ADDRESS, bytes(self.address))
|
||||||
|
)
|
||||||
|
if self.role is not None:
|
||||||
|
ad_structures.append((AdvertisingData.LE_ROLE, bytes([self.role])))
|
||||||
|
if self.shared_data is not None:
|
||||||
|
ad_structures.extend(self.shared_data.to_ad().ad_structures)
|
||||||
|
if self.legacy_context is not None:
|
||||||
|
ad_structures.append(
|
||||||
|
(AdvertisingData.SECURITY_MANAGER_TK_VALUE, self.legacy_context.tk)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdvertisingData(ad_structures)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -168,21 +224,39 @@ class PairingDelegate:
|
|||||||
class PairingConfig:
|
class PairingConfig:
|
||||||
"""Configuration for the Pairing protocol."""
|
"""Configuration for the Pairing protocol."""
|
||||||
|
|
||||||
|
class AddressType(enum.IntEnum):
|
||||||
|
PUBLIC = Address.PUBLIC_DEVICE_ADDRESS
|
||||||
|
RANDOM = Address.RANDOM_DEVICE_ADDRESS
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OobConfig:
|
||||||
|
"""Config for OOB pairing."""
|
||||||
|
|
||||||
|
our_context: Optional[OobContext]
|
||||||
|
peer_data: Optional[OobSharedData]
|
||||||
|
legacy_context: Optional[OobLegacyContext]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
sc: bool = True,
|
sc: bool = True,
|
||||||
mitm: bool = True,
|
mitm: bool = True,
|
||||||
bonding: bool = True,
|
bonding: bool = True,
|
||||||
delegate: Optional[PairingDelegate] = None,
|
delegate: Optional[PairingDelegate] = None,
|
||||||
|
identity_address_type: Optional[AddressType] = None,
|
||||||
|
oob: Optional[OobConfig] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.sc = sc
|
self.sc = sc
|
||||||
self.mitm = mitm
|
self.mitm = mitm
|
||||||
self.bonding = bonding
|
self.bonding = bonding
|
||||||
self.delegate = delegate or PairingDelegate()
|
self.delegate = delegate or PairingDelegate()
|
||||||
|
self.identity_address_type = identity_address_type
|
||||||
|
self.oob = oob
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'PairingConfig(sc={self.sc}, '
|
f'PairingConfig(sc={self.sc}, '
|
||||||
f'mitm={self.mitm}, bonding={self.bonding}, '
|
f'mitm={self.mitm}, bonding={self.bonding}, '
|
||||||
f'delegate[{self.delegate.io_capability}])'
|
f'identity_address_type={self.identity_address_type}, '
|
||||||
|
f'delegate[{self.delegate.io_capability}]), '
|
||||||
|
f'oob[{self.oob}])'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from bumble.pairing import PairingDelegate
|
from __future__ import annotations
|
||||||
|
from bumble.pairing import PairingConfig, PairingDelegate
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ from typing import Any, Dict
|
|||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
io_capability: PairingDelegate.IoCapability = PairingDelegate.NO_OUTPUT_NO_INPUT
|
io_capability: PairingDelegate.IoCapability = PairingDelegate.NO_OUTPUT_NO_INPUT
|
||||||
|
identity_address_type: PairingConfig.AddressType = PairingConfig.AddressType.RANDOM
|
||||||
pairing_sc_enable: bool = True
|
pairing_sc_enable: bool = True
|
||||||
pairing_mitm_enable: bool = True
|
pairing_mitm_enable: bool = True
|
||||||
pairing_bonding_enable: bool = True
|
pairing_bonding_enable: bool = True
|
||||||
@@ -35,6 +37,12 @@ class Config:
|
|||||||
'io_capability', 'no_output_no_input'
|
'io_capability', 'no_output_no_input'
|
||||||
).upper()
|
).upper()
|
||||||
self.io_capability = getattr(PairingDelegate, io_capability_name)
|
self.io_capability = getattr(PairingDelegate, io_capability_name)
|
||||||
|
identity_address_type_name: str = config.get(
|
||||||
|
'identity_address_type', 'random'
|
||||||
|
).upper()
|
||||||
|
self.identity_address_type = getattr(
|
||||||
|
PairingConfig.AddressType, identity_address_type_name
|
||||||
|
)
|
||||||
self.pairing_sc_enable = config.get('pairing_sc_enable', True)
|
self.pairing_sc_enable = config.get('pairing_sc_enable', True)
|
||||||
self.pairing_mitm_enable = config.get('pairing_mitm_enable', True)
|
self.pairing_mitm_enable = config.get('pairing_mitm_enable', True)
|
||||||
self.pairing_bonding_enable = config.get('pairing_bonding_enable', True)
|
self.pairing_bonding_enable = config.get('pairing_bonding_enable', True)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
"""Generic & dependency free Bumble (reference) device."""
|
"""Generic & dependency free Bumble (reference) device."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
from bumble import transport
|
from bumble import transport
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
BT_GENERIC_AUDIO_SERVICE,
|
BT_GENERIC_AUDIO_SERVICE,
|
||||||
@@ -34,6 +35,10 @@ from bumble.sdp import (
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# Default rootcanal HCI TCP address
|
||||||
|
ROOTCANAL_HCI_ADDRESS = "localhost:6402"
|
||||||
|
|
||||||
|
|
||||||
class PandoraDevice:
|
class PandoraDevice:
|
||||||
"""
|
"""
|
||||||
Small wrapper around a Bumble device and it's HCI transport.
|
Small wrapper around a Bumble device and it's HCI transport.
|
||||||
@@ -53,7 +58,9 @@ class PandoraDevice:
|
|||||||
def __init__(self, config: Dict[str, Any]) -> None:
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.device = _make_device(config)
|
self.device = _make_device(config)
|
||||||
self._hci_name = config.get('transport', '')
|
self._hci_name = config.get(
|
||||||
|
'transport', f"tcp-client:{config.get('tcp', ROOTCANAL_HCI_ADDRESS)}"
|
||||||
|
)
|
||||||
self._hci = None
|
self._hci = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import bumble.device
|
import bumble.device
|
||||||
import grpc
|
import grpc
|
||||||
@@ -112,7 +113,7 @@ class HostService(HostServicer):
|
|||||||
async def FactoryReset(
|
async def FactoryReset(
|
||||||
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
||||||
) -> empty_pb2.Empty:
|
) -> empty_pb2.Empty:
|
||||||
self.log.info('FactoryReset')
|
self.log.debug('FactoryReset')
|
||||||
|
|
||||||
# delete all bonds
|
# delete all bonds
|
||||||
if self.device.keystore is not None:
|
if self.device.keystore is not None:
|
||||||
@@ -126,7 +127,7 @@ class HostService(HostServicer):
|
|||||||
async def Reset(
|
async def Reset(
|
||||||
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
||||||
) -> empty_pb2.Empty:
|
) -> empty_pb2.Empty:
|
||||||
self.log.info('Reset')
|
self.log.debug('Reset')
|
||||||
|
|
||||||
# clear service.
|
# clear service.
|
||||||
self.waited_connections.clear()
|
self.waited_connections.clear()
|
||||||
@@ -139,7 +140,7 @@ class HostService(HostServicer):
|
|||||||
async def ReadLocalAddress(
|
async def ReadLocalAddress(
|
||||||
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
||||||
) -> ReadLocalAddressResponse:
|
) -> ReadLocalAddressResponse:
|
||||||
self.log.info('ReadLocalAddress')
|
self.log.debug('ReadLocalAddress')
|
||||||
return ReadLocalAddressResponse(
|
return ReadLocalAddressResponse(
|
||||||
address=bytes(reversed(bytes(self.device.public_address)))
|
address=bytes(reversed(bytes(self.device.public_address)))
|
||||||
)
|
)
|
||||||
@@ -152,7 +153,7 @@ class HostService(HostServicer):
|
|||||||
address = Address(
|
address = Address(
|
||||||
bytes(reversed(request.address)), address_type=Address.PUBLIC_DEVICE_ADDRESS
|
bytes(reversed(request.address)), address_type=Address.PUBLIC_DEVICE_ADDRESS
|
||||||
)
|
)
|
||||||
self.log.info(f"Connect to {address}")
|
self.log.debug(f"Connect to {address}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
connection = await self.device.connect(
|
connection = await self.device.connect(
|
||||||
@@ -167,7 +168,7 @@ class HostService(HostServicer):
|
|||||||
return ConnectResponse(connection_already_exists=empty_pb2.Empty())
|
return ConnectResponse(connection_already_exists=empty_pb2.Empty())
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
self.log.info(f"Connect to {address} done (handle={connection.handle})")
|
self.log.debug(f"Connect to {address} done (handle={connection.handle})")
|
||||||
|
|
||||||
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
|
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
|
||||||
return ConnectResponse(connection=Connection(cookie=cookie))
|
return ConnectResponse(connection=Connection(cookie=cookie))
|
||||||
@@ -186,7 +187,7 @@ class HostService(HostServicer):
|
|||||||
if address in (Address.NIL, Address.ANY):
|
if address in (Address.NIL, Address.ANY):
|
||||||
raise ValueError('Invalid address')
|
raise ValueError('Invalid address')
|
||||||
|
|
||||||
self.log.info(f"WaitConnection from {address}...")
|
self.log.debug(f"WaitConnection from {address}...")
|
||||||
|
|
||||||
connection = self.device.find_connection_by_bd_addr(
|
connection = self.device.find_connection_by_bd_addr(
|
||||||
address, transport=BT_BR_EDR_TRANSPORT
|
address, transport=BT_BR_EDR_TRANSPORT
|
||||||
@@ -201,7 +202,7 @@ class HostService(HostServicer):
|
|||||||
# save connection has waited and respond.
|
# save connection has waited and respond.
|
||||||
self.waited_connections.add(id(connection))
|
self.waited_connections.add(id(connection))
|
||||||
|
|
||||||
self.log.info(
|
self.log.debug(
|
||||||
f"WaitConnection from {address} done (handle={connection.handle})"
|
f"WaitConnection from {address} done (handle={connection.handle})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -216,7 +217,7 @@ class HostService(HostServicer):
|
|||||||
if address in (Address.NIL, Address.ANY):
|
if address in (Address.NIL, Address.ANY):
|
||||||
raise ValueError('Invalid address')
|
raise ValueError('Invalid address')
|
||||||
|
|
||||||
self.log.info(f"ConnectLE to {address}...")
|
self.log.debug(f"ConnectLE to {address}...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
connection = await self.device.connect(
|
connection = await self.device.connect(
|
||||||
@@ -233,7 +234,7 @@ class HostService(HostServicer):
|
|||||||
return ConnectLEResponse(connection_already_exists=empty_pb2.Empty())
|
return ConnectLEResponse(connection_already_exists=empty_pb2.Empty())
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
self.log.info(f"ConnectLE to {address} done (handle={connection.handle})")
|
self.log.debug(f"ConnectLE to {address} done (handle={connection.handle})")
|
||||||
|
|
||||||
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
|
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
|
||||||
return ConnectLEResponse(connection=Connection(cookie=cookie))
|
return ConnectLEResponse(connection=Connection(cookie=cookie))
|
||||||
@@ -243,12 +244,12 @@ class HostService(HostServicer):
|
|||||||
self, request: DisconnectRequest, context: grpc.ServicerContext
|
self, request: DisconnectRequest, context: grpc.ServicerContext
|
||||||
) -> empty_pb2.Empty:
|
) -> empty_pb2.Empty:
|
||||||
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
||||||
self.log.info(f"Disconnect: {connection_handle}")
|
self.log.debug(f"Disconnect: {connection_handle}")
|
||||||
|
|
||||||
self.log.info("Disconnecting...")
|
self.log.debug("Disconnecting...")
|
||||||
if connection := self.device.lookup_connection(connection_handle):
|
if connection := self.device.lookup_connection(connection_handle):
|
||||||
await connection.disconnect(HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR)
|
await connection.disconnect(HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR)
|
||||||
self.log.info("Disconnected")
|
self.log.debug("Disconnected")
|
||||||
|
|
||||||
return empty_pb2.Empty()
|
return empty_pb2.Empty()
|
||||||
|
|
||||||
@@ -257,7 +258,7 @@ class HostService(HostServicer):
|
|||||||
self, request: WaitDisconnectionRequest, context: grpc.ServicerContext
|
self, request: WaitDisconnectionRequest, context: grpc.ServicerContext
|
||||||
) -> empty_pb2.Empty:
|
) -> empty_pb2.Empty:
|
||||||
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
||||||
self.log.info(f"WaitDisconnection: {connection_handle}")
|
self.log.debug(f"WaitDisconnection: {connection_handle}")
|
||||||
|
|
||||||
if connection := self.device.lookup_connection(connection_handle):
|
if connection := self.device.lookup_connection(connection_handle):
|
||||||
disconnection_future: asyncio.Future[
|
disconnection_future: asyncio.Future[
|
||||||
@@ -270,7 +271,7 @@ class HostService(HostServicer):
|
|||||||
connection.on('disconnection', on_disconnection)
|
connection.on('disconnection', on_disconnection)
|
||||||
try:
|
try:
|
||||||
await disconnection_future
|
await disconnection_future
|
||||||
self.log.info("Disconnected")
|
self.log.debug("Disconnected")
|
||||||
finally:
|
finally:
|
||||||
connection.remove_listener('disconnection', on_disconnection) # type: ignore
|
connection.remove_listener('disconnection', on_disconnection) # type: ignore
|
||||||
|
|
||||||
@@ -378,7 +379,7 @@ class HostService(HostServicer):
|
|||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
if not self.device.is_advertising:
|
if not self.device.is_advertising:
|
||||||
self.log.info('Advertise')
|
self.log.debug('Advertise')
|
||||||
await self.device.start_advertising(
|
await self.device.start_advertising(
|
||||||
target=target,
|
target=target,
|
||||||
advertising_type=advertising_type,
|
advertising_type=advertising_type,
|
||||||
@@ -393,10 +394,10 @@ class HostService(HostServicer):
|
|||||||
bumble.device.Connection
|
bumble.device.Connection
|
||||||
] = asyncio.get_running_loop().create_future()
|
] = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
self.log.info('Wait for LE connection...')
|
self.log.debug('Wait for LE connection...')
|
||||||
connection = await pending_connection
|
connection = await pending_connection
|
||||||
|
|
||||||
self.log.info(
|
self.log.debug(
|
||||||
f"Advertise: Connected to {connection.peer_address} (handle={connection.handle})"
|
f"Advertise: Connected to {connection.peer_address} (handle={connection.handle})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -410,7 +411,7 @@ class HostService(HostServicer):
|
|||||||
self.device.remove_listener('connection', on_connection) # type: ignore
|
self.device.remove_listener('connection', on_connection) # type: ignore
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.log.info('Stop advertising')
|
self.log.debug('Stop advertising')
|
||||||
await self.device.abort_on('flush', self.device.stop_advertising())
|
await self.device.abort_on('flush', self.device.stop_advertising())
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
@@ -423,7 +424,7 @@ class HostService(HostServicer):
|
|||||||
if request.phys:
|
if request.phys:
|
||||||
raise NotImplementedError("TODO: add support for `request.phys`")
|
raise NotImplementedError("TODO: add support for `request.phys`")
|
||||||
|
|
||||||
self.log.info('Scan')
|
self.log.debug('Scan')
|
||||||
|
|
||||||
scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue()
|
scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue()
|
||||||
handler = self.device.on('advertisement', scan_queue.put_nowait)
|
handler = self.device.on('advertisement', scan_queue.put_nowait)
|
||||||
@@ -470,7 +471,7 @@ class HostService(HostServicer):
|
|||||||
finally:
|
finally:
|
||||||
self.device.remove_listener('advertisement', handler) # type: ignore
|
self.device.remove_listener('advertisement', handler) # type: ignore
|
||||||
try:
|
try:
|
||||||
self.log.info('Stop scanning')
|
self.log.debug('Stop scanning')
|
||||||
await self.device.abort_on('flush', self.device.stop_scanning())
|
await self.device.abort_on('flush', self.device.stop_scanning())
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
@@ -479,7 +480,7 @@ class HostService(HostServicer):
|
|||||||
async def Inquiry(
|
async def Inquiry(
|
||||||
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
self, request: empty_pb2.Empty, context: grpc.ServicerContext
|
||||||
) -> AsyncGenerator[InquiryResponse, None]:
|
) -> AsyncGenerator[InquiryResponse, None]:
|
||||||
self.log.info('Inquiry')
|
self.log.debug('Inquiry')
|
||||||
|
|
||||||
inquiry_queue: asyncio.Queue[
|
inquiry_queue: asyncio.Queue[
|
||||||
Optional[Tuple[Address, int, AdvertisingData, int]]
|
Optional[Tuple[Address, int, AdvertisingData, int]]
|
||||||
@@ -510,7 +511,7 @@ class HostService(HostServicer):
|
|||||||
self.device.remove_listener('inquiry_complete', complete_handler) # type: ignore
|
self.device.remove_listener('inquiry_complete', complete_handler) # type: ignore
|
||||||
self.device.remove_listener('inquiry_result', result_handler) # type: ignore
|
self.device.remove_listener('inquiry_result', result_handler) # type: ignore
|
||||||
try:
|
try:
|
||||||
self.log.info('Stop inquiry')
|
self.log.debug('Stop inquiry')
|
||||||
await self.device.abort_on('flush', self.device.stop_discovery())
|
await self.device.abort_on('flush', self.device.stop_discovery())
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
@@ -519,7 +520,7 @@ class HostService(HostServicer):
|
|||||||
async def SetDiscoverabilityMode(
|
async def SetDiscoverabilityMode(
|
||||||
self, request: SetDiscoverabilityModeRequest, context: grpc.ServicerContext
|
self, request: SetDiscoverabilityModeRequest, context: grpc.ServicerContext
|
||||||
) -> empty_pb2.Empty:
|
) -> empty_pb2.Empty:
|
||||||
self.log.info("SetDiscoverabilityMode")
|
self.log.debug("SetDiscoverabilityMode")
|
||||||
await self.device.set_discoverable(request.mode != NOT_DISCOVERABLE)
|
await self.device.set_discoverable(request.mode != NOT_DISCOVERABLE)
|
||||||
return empty_pb2.Empty()
|
return empty_pb2.Empty()
|
||||||
|
|
||||||
@@ -527,7 +528,7 @@ class HostService(HostServicer):
|
|||||||
async def SetConnectabilityMode(
|
async def SetConnectabilityMode(
|
||||||
self, request: SetConnectabilityModeRequest, context: grpc.ServicerContext
|
self, request: SetConnectabilityModeRequest, context: grpc.ServicerContext
|
||||||
) -> empty_pb2.Empty:
|
) -> empty_pb2.Empty:
|
||||||
self.log.info("SetConnectabilityMode")
|
self.log.debug("SetConnectabilityMode")
|
||||||
await self.device.set_connectable(request.mode != NOT_CONNECTABLE)
|
await self.device.set_connectable(request.mode != NOT_CONNECTABLE)
|
||||||
return empty_pb2.Empty()
|
return empty_pb2.Empty()
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import grpc
|
import grpc
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -27,8 +29,8 @@ from bumble.core import (
|
|||||||
)
|
)
|
||||||
from bumble.device import Connection as BumbleConnection, Device
|
from bumble.device import Connection as BumbleConnection, Device
|
||||||
from bumble.hci import HCI_Error
|
from bumble.hci import HCI_Error
|
||||||
|
from bumble.utils import EventWatcher
|
||||||
from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate
|
from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate
|
||||||
from contextlib import suppress
|
|
||||||
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
||||||
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
||||||
from google.protobuf import wrappers_pb2 # pytype: disable=pyi-error
|
from google.protobuf import wrappers_pb2 # pytype: disable=pyi-error
|
||||||
@@ -99,7 +101,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
return ev
|
return ev
|
||||||
|
|
||||||
async def confirm(self, auto: bool = False) -> bool:
|
async def confirm(self, auto: bool = False) -> bool:
|
||||||
self.log.info(
|
self.log.debug(
|
||||||
f"Pairing event: `just_works` (io_capability: {self.io_capability})"
|
f"Pairing event: `just_works` (io_capability: {self.io_capability})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,7 +116,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
return answer.confirm
|
return answer.confirm
|
||||||
|
|
||||||
async def compare_numbers(self, number: int, digits: int = 6) -> bool:
|
async def compare_numbers(self, number: int, digits: int = 6) -> bool:
|
||||||
self.log.info(
|
self.log.debug(
|
||||||
f"Pairing event: `numeric_comparison` (io_capability: {self.io_capability})"
|
f"Pairing event: `numeric_comparison` (io_capability: {self.io_capability})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,7 +131,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
return answer.confirm
|
return answer.confirm
|
||||||
|
|
||||||
async def get_number(self) -> Optional[int]:
|
async def get_number(self) -> Optional[int]:
|
||||||
self.log.info(
|
self.log.debug(
|
||||||
f"Pairing event: `passkey_entry_request` (io_capability: {self.io_capability})"
|
f"Pairing event: `passkey_entry_request` (io_capability: {self.io_capability})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -146,7 +148,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
return answer.passkey
|
return answer.passkey
|
||||||
|
|
||||||
async def get_string(self, max_length: int) -> Optional[str]:
|
async def get_string(self, max_length: int) -> Optional[str]:
|
||||||
self.log.info(
|
self.log.debug(
|
||||||
f"Pairing event: `pin_code_request` (io_capability: {self.io_capability})"
|
f"Pairing event: `pin_code_request` (io_capability: {self.io_capability})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,7 +179,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
self.log.info(
|
self.log.debug(
|
||||||
f"Pairing event: `passkey_entry_notification` (io_capability: {self.io_capability})"
|
f"Pairing event: `passkey_entry_notification` (io_capability: {self.io_capability})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -232,6 +234,11 @@ class SecurityService(SecurityServicer):
|
|||||||
sc=config.pairing_sc_enable,
|
sc=config.pairing_sc_enable,
|
||||||
mitm=config.pairing_mitm_enable,
|
mitm=config.pairing_mitm_enable,
|
||||||
bonding=config.pairing_bonding_enable,
|
bonding=config.pairing_bonding_enable,
|
||||||
|
identity_address_type=(
|
||||||
|
PairingConfig.AddressType.PUBLIC
|
||||||
|
if connection.self_address.is_public
|
||||||
|
else config.identity_address_type
|
||||||
|
),
|
||||||
delegate=PairingDelegate(
|
delegate=PairingDelegate(
|
||||||
connection,
|
connection,
|
||||||
self,
|
self,
|
||||||
@@ -247,7 +254,7 @@ class SecurityService(SecurityServicer):
|
|||||||
async def OnPairing(
|
async def OnPairing(
|
||||||
self, request: AsyncIterator[PairingEventAnswer], context: grpc.ServicerContext
|
self, request: AsyncIterator[PairingEventAnswer], context: grpc.ServicerContext
|
||||||
) -> AsyncGenerator[PairingEvent, None]:
|
) -> AsyncGenerator[PairingEvent, None]:
|
||||||
self.log.info('OnPairing')
|
self.log.debug('OnPairing')
|
||||||
|
|
||||||
if self.event_queue is not None:
|
if self.event_queue is not None:
|
||||||
raise RuntimeError('already streaming pairing events')
|
raise RuntimeError('already streaming pairing events')
|
||||||
@@ -273,7 +280,7 @@ class SecurityService(SecurityServicer):
|
|||||||
self, request: SecureRequest, context: grpc.ServicerContext
|
self, request: SecureRequest, context: grpc.ServicerContext
|
||||||
) -> SecureResponse:
|
) -> SecureResponse:
|
||||||
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
||||||
self.log.info(f"Secure: {connection_handle}")
|
self.log.debug(f"Secure: {connection_handle}")
|
||||||
|
|
||||||
connection = self.device.lookup_connection(connection_handle)
|
connection = self.device.lookup_connection(connection_handle)
|
||||||
assert connection
|
assert connection
|
||||||
@@ -291,25 +298,37 @@ class SecurityService(SecurityServicer):
|
|||||||
# trigger pairing if needed
|
# trigger pairing if needed
|
||||||
if self.need_pairing(connection, level):
|
if self.need_pairing(connection, level):
|
||||||
try:
|
try:
|
||||||
self.log.info('Pair...')
|
self.log.debug('Pair...')
|
||||||
|
|
||||||
if (
|
security_result = asyncio.get_running_loop().create_future()
|
||||||
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)
|
|
||||||
|
|
||||||
connection.request_pairing()
|
with contextlib.closing(EventWatcher()) as watcher:
|
||||||
|
|
||||||
await wait_for_security
|
@watcher.on(connection, 'pairing')
|
||||||
else:
|
def on_pairing(*_: Any) -> None:
|
||||||
await connection.pair()
|
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:
|
except asyncio.CancelledError:
|
||||||
self.log.warning("Connection died during encryption")
|
self.log.warning("Connection died during encryption")
|
||||||
return SecureResponse(connection_died=empty_pb2.Empty())
|
return SecureResponse(connection_died=empty_pb2.Empty())
|
||||||
@@ -320,9 +339,9 @@ class SecurityService(SecurityServicer):
|
|||||||
# trigger authentication if needed
|
# trigger authentication if needed
|
||||||
if self.need_authentication(connection, level):
|
if self.need_authentication(connection, level):
|
||||||
try:
|
try:
|
||||||
self.log.info('Authenticate...')
|
self.log.debug('Authenticate...')
|
||||||
await connection.authenticate()
|
await connection.authenticate()
|
||||||
self.log.info('Authenticated')
|
self.log.debug('Authenticated')
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
self.log.warning("Connection died during authentication")
|
self.log.warning("Connection died during authentication")
|
||||||
return SecureResponse(connection_died=empty_pb2.Empty())
|
return SecureResponse(connection_died=empty_pb2.Empty())
|
||||||
@@ -333,9 +352,9 @@ class SecurityService(SecurityServicer):
|
|||||||
# trigger encryption if needed
|
# trigger encryption if needed
|
||||||
if self.need_encryption(connection, level):
|
if self.need_encryption(connection, level):
|
||||||
try:
|
try:
|
||||||
self.log.info('Encrypt...')
|
self.log.debug('Encrypt...')
|
||||||
await connection.encrypt()
|
await connection.encrypt()
|
||||||
self.log.info('Encrypted')
|
self.log.debug('Encrypted')
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
self.log.warning("Connection died during encryption")
|
self.log.warning("Connection died during encryption")
|
||||||
return SecureResponse(connection_died=empty_pb2.Empty())
|
return SecureResponse(connection_died=empty_pb2.Empty())
|
||||||
@@ -353,7 +372,7 @@ class SecurityService(SecurityServicer):
|
|||||||
self, request: WaitSecurityRequest, context: grpc.ServicerContext
|
self, request: WaitSecurityRequest, context: grpc.ServicerContext
|
||||||
) -> WaitSecurityResponse:
|
) -> WaitSecurityResponse:
|
||||||
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
||||||
self.log.info(f"WaitSecurity: {connection_handle}")
|
self.log.debug(f"WaitSecurity: {connection_handle}")
|
||||||
|
|
||||||
connection = self.device.lookup_connection(connection_handle)
|
connection = self.device.lookup_connection(connection_handle)
|
||||||
assert connection
|
assert connection
|
||||||
@@ -368,6 +387,7 @@ class SecurityService(SecurityServicer):
|
|||||||
str
|
str
|
||||||
] = asyncio.get_running_loop().create_future()
|
] = asyncio.get_running_loop().create_future()
|
||||||
authenticate_task: Optional[asyncio.Future[None]] = None
|
authenticate_task: Optional[asyncio.Future[None]] = None
|
||||||
|
pair_task: Optional[asyncio.Future[None]] = None
|
||||||
|
|
||||||
async def authenticate() -> None:
|
async def authenticate() -> None:
|
||||||
assert connection
|
assert connection
|
||||||
@@ -390,7 +410,7 @@ class SecurityService(SecurityServicer):
|
|||||||
|
|
||||||
def set_failure(name: str) -> Callable[..., None]:
|
def set_failure(name: str) -> Callable[..., None]:
|
||||||
def wrapper(*args: Any) -> None:
|
def wrapper(*args: Any) -> None:
|
||||||
self.log.info(f'Wait for security: error `{name}`: {args}')
|
self.log.debug(f'Wait for security: error `{name}`: {args}')
|
||||||
wait_for_security.set_result(name)
|
wait_for_security.set_result(name)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -398,13 +418,13 @@ class SecurityService(SecurityServicer):
|
|||||||
def try_set_success(*_: Any) -> None:
|
def try_set_success(*_: Any) -> None:
|
||||||
assert connection
|
assert connection
|
||||||
if self.reached_security_level(connection, level):
|
if self.reached_security_level(connection, level):
|
||||||
self.log.info('Wait for security: done')
|
self.log.debug('Wait for security: done')
|
||||||
wait_for_security.set_result('success')
|
wait_for_security.set_result('success')
|
||||||
|
|
||||||
def on_encryption_change(*_: Any) -> None:
|
def on_encryption_change(*_: Any) -> None:
|
||||||
assert connection
|
assert connection
|
||||||
if self.reached_security_level(connection, level):
|
if self.reached_security_level(connection, level):
|
||||||
self.log.info('Wait for security: done')
|
self.log.debug('Wait for security: done')
|
||||||
wait_for_security.set_result('success')
|
wait_for_security.set_result('success')
|
||||||
elif (
|
elif (
|
||||||
connection.transport == BT_BR_EDR_TRANSPORT
|
connection.transport == BT_BR_EDR_TRANSPORT
|
||||||
@@ -414,6 +434,10 @@ class SecurityService(SecurityServicer):
|
|||||||
if authenticate_task is None:
|
if authenticate_task is None:
|
||||||
authenticate_task = asyncio.create_task(authenticate())
|
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]] = {
|
listeners: Dict[str, Callable[..., None]] = {
|
||||||
'disconnection': set_failure('connection_died'),
|
'disconnection': set_failure('connection_died'),
|
||||||
'pairing_failure': set_failure('pairing_failure'),
|
'pairing_failure': set_failure('pairing_failure'),
|
||||||
@@ -422,32 +446,41 @@ class SecurityService(SecurityServicer):
|
|||||||
'pairing': try_set_success,
|
'pairing': try_set_success,
|
||||||
'connection_authentication': try_set_success,
|
'connection_authentication': try_set_success,
|
||||||
'connection_encryption_change': on_encryption_change,
|
'connection_encryption_change': on_encryption_change,
|
||||||
|
'classic_pairing': try_set_success,
|
||||||
|
'classic_pairing_failure': set_failure('pairing_failure'),
|
||||||
|
'security_request': pair,
|
||||||
}
|
}
|
||||||
|
|
||||||
# register event handlers
|
with contextlib.closing(EventWatcher()) as watcher:
|
||||||
for event, listener in listeners.items():
|
# register event handlers
|
||||||
connection.on(event, listener)
|
for event, listener in listeners.items():
|
||||||
|
watcher.on(connection, event, listener)
|
||||||
|
|
||||||
# security level already reached
|
# security level already reached
|
||||||
if self.reached_security_level(connection, level):
|
if self.reached_security_level(connection, level):
|
||||||
return WaitSecurityResponse(success=empty_pb2.Empty())
|
return WaitSecurityResponse(success=empty_pb2.Empty())
|
||||||
|
|
||||||
self.log.info('Wait for security...')
|
self.log.debug('Wait for security...')
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
kwargs[await wait_for_security] = empty_pb2.Empty()
|
kwargs[await wait_for_security] = empty_pb2.Empty()
|
||||||
|
|
||||||
# remove event handlers
|
|
||||||
for event, listener in listeners.items():
|
|
||||||
connection.remove_listener(event, listener) # type: ignore
|
|
||||||
|
|
||||||
# wait for `authenticate` to finish if any
|
# wait for `authenticate` to finish if any
|
||||||
if authenticate_task is not None:
|
if authenticate_task is not None:
|
||||||
self.log.info('Wait for authentication...')
|
self.log.debug('Wait for authentication...')
|
||||||
try:
|
try:
|
||||||
await authenticate_task # type: ignore
|
await authenticate_task # type: ignore
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
self.log.info('Authenticated')
|
self.log.debug('Authenticated')
|
||||||
|
|
||||||
|
# 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)
|
return WaitSecurityResponse(**kwargs)
|
||||||
|
|
||||||
@@ -503,7 +536,7 @@ class SecurityStorageService(SecurityStorageServicer):
|
|||||||
self, request: IsBondedRequest, context: grpc.ServicerContext
|
self, request: IsBondedRequest, context: grpc.ServicerContext
|
||||||
) -> wrappers_pb2.BoolValue:
|
) -> wrappers_pb2.BoolValue:
|
||||||
address = utils.address_from_request(request, request.WhichOneof("address"))
|
address = utils.address_from_request(request, request.WhichOneof("address"))
|
||||||
self.log.info(f"IsBonded: {address}")
|
self.log.debug(f"IsBonded: {address}")
|
||||||
|
|
||||||
if self.device.keystore is not None:
|
if self.device.keystore is not None:
|
||||||
is_bonded = await self.device.keystore.get(str(address)) is not None
|
is_bonded = await self.device.keystore.get(str(address)) is not None
|
||||||
@@ -517,10 +550,10 @@ class SecurityStorageService(SecurityStorageServicer):
|
|||||||
self, request: DeleteBondRequest, context: grpc.ServicerContext
|
self, request: DeleteBondRequest, context: grpc.ServicerContext
|
||||||
) -> empty_pb2.Empty:
|
) -> empty_pb2.Empty:
|
||||||
address = utils.address_from_request(request, request.WhichOneof("address"))
|
address = utils.address_from_request(request, request.WhichOneof("address"))
|
||||||
self.log.info(f"DeleteBond: {address}")
|
self.log.debug(f"DeleteBond: {address}")
|
||||||
|
|
||||||
if self.device.keystore is not None:
|
if self.device.keystore is not None:
|
||||||
with suppress(KeyError):
|
with contextlib.suppress(KeyError):
|
||||||
await self.device.keystore.delete(str(address))
|
await self.device.keystore.delete(str(address))
|
||||||
|
|
||||||
return empty_pb2.Empty()
|
return empty_pb2.Empty()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
import contextlib
|
import contextlib
|
||||||
import functools
|
import functools
|
||||||
import grpc
|
import grpc
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
import struct
|
import struct
|
||||||
import logging
|
import logging
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
from bumble import l2cap
|
||||||
from ..core import AdvertisingData
|
from ..core import AdvertisingData
|
||||||
from ..device import Device, Connection
|
from ..device import Device, Connection
|
||||||
from ..gatt import (
|
from ..gatt import (
|
||||||
@@ -149,7 +151,10 @@ class AshaService(TemplateService):
|
|||||||
channel.sink = on_data
|
channel.sink = on_data
|
||||||
|
|
||||||
# let the server find a free PSM
|
# let the server find a free PSM
|
||||||
self.psm = self.device.register_l2cap_channel_server(self.psm, on_coc, 8)
|
self.psm = device.create_l2cap_server(
|
||||||
|
spec=l2cap.LeCreditBasedChannelSpec(psm=self.psm, max_credits=8),
|
||||||
|
handler=on_coc,
|
||||||
|
).psm
|
||||||
self.le_psm_out_characteristic = Characteristic(
|
self.le_psm_out_characteristic = Characteristic(
|
||||||
GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC,
|
GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC,
|
||||||
Characteristic.Properties.READ,
|
Characteristic.Properties.READ,
|
||||||
|
|||||||
147
bumble/profiles/csip.py
Normal file
147
bumble/profiles/csip.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Copyright 2021-2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
import enum
|
||||||
|
import struct
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from bumble import gatt
|
||||||
|
from bumble import gatt_client
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class SirkType(enum.IntEnum):
|
||||||
|
'''Coordinated Set Identification Service - 5.1 Set Identity Resolving Key.'''
|
||||||
|
|
||||||
|
ENCRYPTED = 0x00
|
||||||
|
PLAINTEXT = 0x01
|
||||||
|
|
||||||
|
|
||||||
|
class MemberLock(enum.IntEnum):
|
||||||
|
'''Coordinated Set Identification Service - 5.3 Set Member Lock.'''
|
||||||
|
|
||||||
|
UNLOCKED = 0x01
|
||||||
|
LOCKED = 0x02
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Utils
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# TODO: Implement RSI Generator
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Server
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class CoordinatedSetIdentificationService(gatt.TemplateService):
|
||||||
|
UUID = gatt.GATT_COORDINATED_SET_IDENTIFICATION_SERVICE
|
||||||
|
|
||||||
|
set_identity_resolving_key_characteristic: gatt.Characteristic
|
||||||
|
coordinated_set_size_characteristic: Optional[gatt.Characteristic] = None
|
||||||
|
set_member_lock_characteristic: Optional[gatt.Characteristic] = None
|
||||||
|
set_member_rank_characteristic: Optional[gatt.Characteristic] = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
set_identity_resolving_key: bytes,
|
||||||
|
coordinated_set_size: Optional[int] = None,
|
||||||
|
set_member_lock: Optional[MemberLock] = None,
|
||||||
|
set_member_rank: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
characteristics = []
|
||||||
|
|
||||||
|
self.set_identity_resolving_key_characteristic = gatt.Characteristic(
|
||||||
|
uuid=gatt.GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC,
|
||||||
|
properties=gatt.Characteristic.Properties.READ
|
||||||
|
| gatt.Characteristic.Properties.NOTIFY,
|
||||||
|
permissions=gatt.Characteristic.Permissions.READABLE,
|
||||||
|
# TODO: Implement encrypted SIRK reader.
|
||||||
|
value=struct.pack('B', SirkType.PLAINTEXT) + set_identity_resolving_key,
|
||||||
|
)
|
||||||
|
characteristics.append(self.set_identity_resolving_key_characteristic)
|
||||||
|
|
||||||
|
if coordinated_set_size is not None:
|
||||||
|
self.coordinated_set_size_characteristic = gatt.Characteristic(
|
||||||
|
uuid=gatt.GATT_COORDINATED_SET_SIZE_CHARACTERISTIC,
|
||||||
|
properties=gatt.Characteristic.Properties.READ
|
||||||
|
| gatt.Characteristic.Properties.NOTIFY,
|
||||||
|
permissions=gatt.Characteristic.Permissions.READABLE,
|
||||||
|
value=struct.pack('B', coordinated_set_size),
|
||||||
|
)
|
||||||
|
characteristics.append(self.coordinated_set_size_characteristic)
|
||||||
|
|
||||||
|
if set_member_lock is not None:
|
||||||
|
self.set_member_lock_characteristic = gatt.Characteristic(
|
||||||
|
uuid=gatt.GATT_SET_MEMBER_LOCK_CHARACTERISTIC,
|
||||||
|
properties=gatt.Characteristic.Properties.READ
|
||||||
|
| gatt.Characteristic.Properties.NOTIFY
|
||||||
|
| gatt.Characteristic.Properties.WRITE,
|
||||||
|
permissions=gatt.Characteristic.Permissions.READABLE
|
||||||
|
| gatt.Characteristic.Permissions.WRITEABLE,
|
||||||
|
value=struct.pack('B', set_member_lock),
|
||||||
|
)
|
||||||
|
characteristics.append(self.set_member_lock_characteristic)
|
||||||
|
|
||||||
|
if set_member_rank is not None:
|
||||||
|
self.set_member_rank_characteristic = gatt.Characteristic(
|
||||||
|
uuid=gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC,
|
||||||
|
properties=gatt.Characteristic.Properties.READ
|
||||||
|
| gatt.Characteristic.Properties.NOTIFY,
|
||||||
|
permissions=gatt.Characteristic.Permissions.READABLE,
|
||||||
|
value=struct.pack('B', set_member_rank),
|
||||||
|
)
|
||||||
|
characteristics.append(self.set_member_rank_characteristic)
|
||||||
|
|
||||||
|
super().__init__(characteristics)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Client
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
|
||||||
|
SERVICE_CLASS = CoordinatedSetIdentificationService
|
||||||
|
|
||||||
|
set_identity_resolving_key: gatt_client.CharacteristicProxy
|
||||||
|
coordinated_set_size: Optional[gatt_client.CharacteristicProxy] = None
|
||||||
|
set_member_lock: Optional[gatt_client.CharacteristicProxy] = None
|
||||||
|
set_member_rank: Optional[gatt_client.CharacteristicProxy] = None
|
||||||
|
|
||||||
|
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
||||||
|
self.service_proxy = service_proxy
|
||||||
|
|
||||||
|
self.set_identity_resolving_key = service_proxy.get_characteristics_by_uuid(
|
||||||
|
gatt.GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
gatt.GATT_COORDINATED_SET_SIZE_CHARACTERISTIC
|
||||||
|
):
|
||||||
|
self.coordinated_set_size = characteristics[0]
|
||||||
|
|
||||||
|
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
gatt.GATT_SET_MEMBER_LOCK_CHARACTERISTIC
|
||||||
|
):
|
||||||
|
self.set_member_lock = characteristics[0]
|
||||||
|
|
||||||
|
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC
|
||||||
|
):
|
||||||
|
self.set_member_rank = characteristics[0]
|
||||||
@@ -42,12 +42,12 @@ class HeartRateService(TemplateService):
|
|||||||
RESET_ENERGY_EXPENDED = 0x01
|
RESET_ENERGY_EXPENDED = 0x01
|
||||||
|
|
||||||
class BodySensorLocation(IntEnum):
|
class BodySensorLocation(IntEnum):
|
||||||
OTHER = (0,)
|
OTHER = 0
|
||||||
CHEST = (1,)
|
CHEST = 1
|
||||||
WRIST = (2,)
|
WRIST = 2
|
||||||
FINGER = (3,)
|
FINGER = 3
|
||||||
HAND = (4,)
|
HAND = 4
|
||||||
EAR_LOBE = (5,)
|
EAR_LOBE = 5
|
||||||
FOOT = 6
|
FOOT = 6
|
||||||
|
|
||||||
class HeartRateMeasurement:
|
class HeartRateMeasurement:
|
||||||
|
|||||||
312
bumble/rfcomm.py
312
bumble/rfcomm.py
@@ -15,15 +15,37 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import enum
|
||||||
|
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
||||||
|
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
from typing import Optional, Tuple, Callable, Dict, Union
|
|
||||||
|
|
||||||
from . import core, l2cap
|
from . import core, l2cap
|
||||||
from .colors import color
|
from .colors import color
|
||||||
from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError
|
from .core import (
|
||||||
|
UUID,
|
||||||
|
BT_RFCOMM_PROTOCOL_ID,
|
||||||
|
BT_BR_EDR_TRANSPORT,
|
||||||
|
BT_L2CAP_PROTOCOL_ID,
|
||||||
|
InvalidStateError,
|
||||||
|
ProtocolError,
|
||||||
|
)
|
||||||
|
from .sdp import (
|
||||||
|
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||||
|
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_PUBLIC_BROWSE_ROOT,
|
||||||
|
DataElement,
|
||||||
|
ServiceAttribute,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble.device import Device, Connection
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -105,6 +127,50 @@ RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
|||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
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:
|
def compute_fcs(buffer: bytes) -> int:
|
||||||
result = 0xFF
|
result = 0xFF
|
||||||
@@ -149,9 +215,9 @@ class RFCOMM_Frame:
|
|||||||
return RFCOMM_FRAME_TYPE_NAMES[self.type]
|
return RFCOMM_FRAME_TYPE_NAMES[self.type]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_mcc(data) -> Tuple[int, int, bytes]:
|
def parse_mcc(data) -> Tuple[int, bool, bytes]:
|
||||||
mcc_type = data[0] >> 2
|
mcc_type = data[0] >> 2
|
||||||
c_r = (data[0] >> 1) & 1
|
c_r = bool((data[0] >> 1) & 1)
|
||||||
length = data[1]
|
length = data[1]
|
||||||
if data[1] & 1:
|
if data[1] & 1:
|
||||||
length >>= 1
|
length >>= 1
|
||||||
@@ -192,7 +258,7 @@ class RFCOMM_Frame:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(data: bytes):
|
def from_bytes(data: bytes) -> RFCOMM_Frame:
|
||||||
# Extract fields
|
# Extract fields
|
||||||
dlci = (data[0] >> 2) & 0x3F
|
dlci = (data[0] >> 2) & 0x3F
|
||||||
c_r = (data[0] >> 1) & 0x01
|
c_r = (data[0] >> 1) & 0x01
|
||||||
@@ -215,7 +281,7 @@ class RFCOMM_Frame:
|
|||||||
|
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self) -> bytes:
|
||||||
return (
|
return (
|
||||||
bytes([self.address, self.control])
|
bytes([self.address, self.control])
|
||||||
+ self.length
|
+ self.length
|
||||||
@@ -223,7 +289,7 @@ class RFCOMM_Frame:
|
|||||||
+ bytes([self.fcs])
|
+ bytes([self.fcs])
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'{color(self.type_name(), "yellow")}'
|
f'{color(self.type_name(), "yellow")}'
|
||||||
f'(c/r={self.c_r},'
|
f'(c/r={self.c_r},'
|
||||||
@@ -253,7 +319,7 @@ class RFCOMM_MCC_PN:
|
|||||||
max_frame_size: int,
|
max_frame_size: int,
|
||||||
max_retransmissions: int,
|
max_retransmissions: int,
|
||||||
window_size: int,
|
window_size: int,
|
||||||
):
|
) -> None:
|
||||||
self.dlci = dlci
|
self.dlci = dlci
|
||||||
self.cl = cl
|
self.cl = cl
|
||||||
self.priority = priority
|
self.priority = priority
|
||||||
@@ -263,7 +329,7 @@ class RFCOMM_MCC_PN:
|
|||||||
self.window_size = window_size
|
self.window_size = window_size
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(data: bytes):
|
def from_bytes(data: bytes) -> RFCOMM_MCC_PN:
|
||||||
return RFCOMM_MCC_PN(
|
return RFCOMM_MCC_PN(
|
||||||
dlci=data[0],
|
dlci=data[0],
|
||||||
cl=data[1],
|
cl=data[1],
|
||||||
@@ -274,7 +340,7 @@ class RFCOMM_MCC_PN:
|
|||||||
window_size=data[7],
|
window_size=data[7],
|
||||||
)
|
)
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self) -> bytes:
|
||||||
return bytes(
|
return bytes(
|
||||||
[
|
[
|
||||||
self.dlci & 0xFF,
|
self.dlci & 0xFF,
|
||||||
@@ -288,7 +354,7 @@ class RFCOMM_MCC_PN:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'PN(dlci={self.dlci},'
|
f'PN(dlci={self.dlci},'
|
||||||
f'cl={self.cl},'
|
f'cl={self.cl},'
|
||||||
@@ -309,7 +375,9 @@ class RFCOMM_MCC_MSC:
|
|||||||
ic: int
|
ic: int
|
||||||
dv: int
|
dv: int
|
||||||
|
|
||||||
def __init__(self, dlci: int, fc: int, rtc: int, rtr: int, ic: int, dv: int):
|
def __init__(
|
||||||
|
self, dlci: int, fc: int, rtc: int, rtr: int, ic: int, dv: int
|
||||||
|
) -> None:
|
||||||
self.dlci = dlci
|
self.dlci = dlci
|
||||||
self.fc = fc
|
self.fc = fc
|
||||||
self.rtc = rtc
|
self.rtc = rtc
|
||||||
@@ -318,7 +386,7 @@ class RFCOMM_MCC_MSC:
|
|||||||
self.dv = dv
|
self.dv = dv
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(data: bytes):
|
def from_bytes(data: bytes) -> RFCOMM_MCC_MSC:
|
||||||
return RFCOMM_MCC_MSC(
|
return RFCOMM_MCC_MSC(
|
||||||
dlci=data[0] >> 2,
|
dlci=data[0] >> 2,
|
||||||
fc=data[1] >> 1 & 1,
|
fc=data[1] >> 1 & 1,
|
||||||
@@ -328,7 +396,7 @@ class RFCOMM_MCC_MSC:
|
|||||||
dv=data[1] >> 7 & 1,
|
dv=data[1] >> 7 & 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self) -> bytes:
|
||||||
return bytes(
|
return bytes(
|
||||||
[
|
[
|
||||||
(self.dlci << 2) | 3,
|
(self.dlci << 2) | 3,
|
||||||
@@ -341,7 +409,7 @@ class RFCOMM_MCC_MSC:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'MSC(dlci={self.dlci},'
|
f'MSC(dlci={self.dlci},'
|
||||||
f'fc={self.fc},'
|
f'fc={self.fc},'
|
||||||
@@ -354,29 +422,24 @@ class RFCOMM_MCC_MSC:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class DLC(EventEmitter):
|
class DLC(EventEmitter):
|
||||||
# States
|
class State(enum.IntEnum):
|
||||||
INIT = 0x00
|
INIT = 0x00
|
||||||
CONNECTING = 0x01
|
CONNECTING = 0x01
|
||||||
CONNECTED = 0x02
|
CONNECTED = 0x02
|
||||||
DISCONNECTING = 0x03
|
DISCONNECTING = 0x03
|
||||||
DISCONNECTED = 0x04
|
DISCONNECTED = 0x04
|
||||||
RESET = 0x05
|
RESET = 0x05
|
||||||
|
|
||||||
STATE_NAMES = {
|
|
||||||
INIT: 'INIT',
|
|
||||||
CONNECTING: 'CONNECTING',
|
|
||||||
CONNECTED: 'CONNECTED',
|
|
||||||
DISCONNECTING: 'DISCONNECTING',
|
|
||||||
DISCONNECTED: 'DISCONNECTED',
|
|
||||||
RESET: 'RESET',
|
|
||||||
}
|
|
||||||
|
|
||||||
connection_result: Optional[asyncio.Future]
|
connection_result: Optional[asyncio.Future]
|
||||||
sink: Optional[Callable[[bytes], None]]
|
sink: Optional[Callable[[bytes], None]]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, multiplexer, dlci: int, max_frame_size: int, initial_tx_credits: int
|
self,
|
||||||
):
|
multiplexer: Multiplexer,
|
||||||
|
dlci: int,
|
||||||
|
max_frame_size: int,
|
||||||
|
initial_tx_credits: int,
|
||||||
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.multiplexer = multiplexer
|
self.multiplexer = multiplexer
|
||||||
self.dlci = dlci
|
self.dlci = dlci
|
||||||
@@ -384,9 +447,9 @@ class DLC(EventEmitter):
|
|||||||
self.rx_threshold = self.rx_credits // 2
|
self.rx_threshold = self.rx_credits // 2
|
||||||
self.tx_credits = initial_tx_credits
|
self.tx_credits = initial_tx_credits
|
||||||
self.tx_buffer = b''
|
self.tx_buffer = b''
|
||||||
self.state = DLC.INIT
|
self.state = DLC.State.INIT
|
||||||
self.role = multiplexer.role
|
self.role = multiplexer.role
|
||||||
self.c_r = 1 if self.role == Multiplexer.INITIATOR else 0
|
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
|
||||||
self.sink = None
|
self.sink = None
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
|
|
||||||
@@ -396,14 +459,8 @@ class DLC(EventEmitter):
|
|||||||
max_frame_size, self.multiplexer.l2cap_channel.mtu - max_overhead
|
max_frame_size, self.multiplexer.l2cap_channel.mtu - max_overhead
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
def change_state(self, new_state: State) -> None:
|
||||||
def state_name(state: int) -> str:
|
logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}')
|
||||||
return DLC.STATE_NAMES[state]
|
|
||||||
|
|
||||||
def change_state(self, new_state: int) -> None:
|
|
||||||
logger.debug(
|
|
||||||
f'{self} state change -> {color(self.state_name(new_state), "magenta")}'
|
|
||||||
)
|
|
||||||
self.state = new_state
|
self.state = new_state
|
||||||
|
|
||||||
def send_frame(self, frame: RFCOMM_Frame) -> None:
|
def send_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
@@ -413,8 +470,8 @@ class DLC(EventEmitter):
|
|||||||
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
|
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
|
||||||
handler(frame)
|
handler(frame)
|
||||||
|
|
||||||
def on_sabm_frame(self, _frame) -> None:
|
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
if self.state != DLC.CONNECTING:
|
if self.state != DLC.State.CONNECTING:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
color('!!! received SABM when not in CONNECTING state', 'red')
|
color('!!! received SABM when not in CONNECTING state', 'red')
|
||||||
)
|
)
|
||||||
@@ -430,11 +487,11 @@ class DLC(EventEmitter):
|
|||||||
logger.debug(f'>>> MCC MSC Command: {msc}')
|
logger.debug(f'>>> MCC MSC Command: {msc}')
|
||||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||||
|
|
||||||
self.change_state(DLC.CONNECTED)
|
self.change_state(DLC.State.CONNECTED)
|
||||||
self.emit('open')
|
self.emit('open')
|
||||||
|
|
||||||
def on_ua_frame(self, _frame) -> None:
|
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
if self.state != DLC.CONNECTING:
|
if self.state != DLC.State.CONNECTING:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
color('!!! received SABM when not in CONNECTING state', 'red')
|
color('!!! received SABM when not in CONNECTING state', 'red')
|
||||||
)
|
)
|
||||||
@@ -448,14 +505,14 @@ class DLC(EventEmitter):
|
|||||||
logger.debug(f'>>> MCC MSC Command: {msc}')
|
logger.debug(f'>>> MCC MSC Command: {msc}')
|
||||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||||
|
|
||||||
self.change_state(DLC.CONNECTED)
|
self.change_state(DLC.State.CONNECTED)
|
||||||
self.multiplexer.on_dlc_open_complete(self)
|
self.multiplexer.on_dlc_open_complete(self)
|
||||||
|
|
||||||
def on_dm_frame(self, frame) -> None:
|
def on_dm_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
# TODO: handle all states
|
# TODO: handle all states
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_disc_frame(self, _frame) -> None:
|
def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
# TODO: handle all states
|
# TODO: handle all states
|
||||||
self.send_frame(RFCOMM_Frame.ua(c_r=1 - self.c_r, dlci=self.dlci))
|
self.send_frame(RFCOMM_Frame.ua(c_r=1 - self.c_r, dlci=self.dlci))
|
||||||
|
|
||||||
@@ -489,10 +546,10 @@ class DLC(EventEmitter):
|
|||||||
# Check if there's anything to send (including credits)
|
# Check if there's anything to send (including credits)
|
||||||
self.process_tx()
|
self.process_tx()
|
||||||
|
|
||||||
def on_ui_frame(self, frame) -> None:
|
def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_mcc_msc(self, c_r, msc) -> None:
|
def on_mcc_msc(self, c_r: bool, msc: RFCOMM_MCC_MSC) -> None:
|
||||||
if c_r:
|
if c_r:
|
||||||
# Command
|
# Command
|
||||||
logger.debug(f'<<< MCC MSC Command: {msc}')
|
logger.debug(f'<<< MCC MSC Command: {msc}')
|
||||||
@@ -507,15 +564,15 @@ class DLC(EventEmitter):
|
|||||||
logger.debug(f'<<< MCC MSC Response: {msc}')
|
logger.debug(f'<<< MCC MSC Response: {msc}')
|
||||||
|
|
||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
if self.state != DLC.INIT:
|
if self.state != DLC.State.INIT:
|
||||||
raise InvalidStateError('invalid state')
|
raise InvalidStateError('invalid state')
|
||||||
|
|
||||||
self.change_state(DLC.CONNECTING)
|
self.change_state(DLC.State.CONNECTING)
|
||||||
self.connection_result = asyncio.get_running_loop().create_future()
|
self.connection_result = asyncio.get_running_loop().create_future()
|
||||||
self.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci))
|
self.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci))
|
||||||
|
|
||||||
def accept(self) -> None:
|
def accept(self) -> None:
|
||||||
if self.state != DLC.INIT:
|
if self.state != DLC.State.INIT:
|
||||||
raise InvalidStateError('invalid state')
|
raise InvalidStateError('invalid state')
|
||||||
|
|
||||||
pn = RFCOMM_MCC_PN(
|
pn = RFCOMM_MCC_PN(
|
||||||
@@ -530,7 +587,7 @@ class DLC(EventEmitter):
|
|||||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=0, data=bytes(pn))
|
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=0, data=bytes(pn))
|
||||||
logger.debug(f'>>> PN Response: {pn}')
|
logger.debug(f'>>> PN Response: {pn}')
|
||||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||||
self.change_state(DLC.CONNECTING)
|
self.change_state(DLC.State.CONNECTING)
|
||||||
|
|
||||||
def rx_credits_needed(self) -> int:
|
def rx_credits_needed(self) -> int:
|
||||||
if self.rx_credits <= self.rx_threshold:
|
if self.rx_credits <= self.rx_threshold:
|
||||||
@@ -592,34 +649,24 @@ class DLC(EventEmitter):
|
|||||||
# TODO
|
# TODO
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f'DLC(dlci={self.dlci},state={self.state_name(self.state)})'
|
return f'DLC(dlci={self.dlci},state={self.state.name})'
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Multiplexer(EventEmitter):
|
class Multiplexer(EventEmitter):
|
||||||
# Roles
|
class Role(enum.IntEnum):
|
||||||
INITIATOR = 0x00
|
INITIATOR = 0x00
|
||||||
RESPONDER = 0x01
|
RESPONDER = 0x01
|
||||||
|
|
||||||
# States
|
class State(enum.IntEnum):
|
||||||
INIT = 0x00
|
INIT = 0x00
|
||||||
CONNECTING = 0x01
|
CONNECTING = 0x01
|
||||||
CONNECTED = 0x02
|
CONNECTED = 0x02
|
||||||
OPENING = 0x03
|
OPENING = 0x03
|
||||||
DISCONNECTING = 0x04
|
DISCONNECTING = 0x04
|
||||||
DISCONNECTED = 0x05
|
DISCONNECTED = 0x05
|
||||||
RESET = 0x06
|
RESET = 0x06
|
||||||
|
|
||||||
STATE_NAMES = {
|
|
||||||
INIT: 'INIT',
|
|
||||||
CONNECTING: 'CONNECTING',
|
|
||||||
CONNECTED: 'CONNECTED',
|
|
||||||
OPENING: 'OPENING',
|
|
||||||
DISCONNECTING: 'DISCONNECTING',
|
|
||||||
DISCONNECTED: 'DISCONNECTED',
|
|
||||||
RESET: 'RESET',
|
|
||||||
}
|
|
||||||
|
|
||||||
connection_result: Optional[asyncio.Future]
|
connection_result: Optional[asyncio.Future]
|
||||||
disconnection_result: Optional[asyncio.Future]
|
disconnection_result: Optional[asyncio.Future]
|
||||||
@@ -627,11 +674,11 @@ class Multiplexer(EventEmitter):
|
|||||||
acceptor: Optional[Callable[[int], bool]]
|
acceptor: Optional[Callable[[int], bool]]
|
||||||
dlcs: Dict[int, DLC]
|
dlcs: Dict[int, DLC]
|
||||||
|
|
||||||
def __init__(self, l2cap_channel: l2cap.Channel, role: int) -> None:
|
def __init__(self, l2cap_channel: l2cap.ClassicChannel, role: Role) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.role = role
|
self.role = role
|
||||||
self.l2cap_channel = l2cap_channel
|
self.l2cap_channel = l2cap_channel
|
||||||
self.state = Multiplexer.INIT
|
self.state = Multiplexer.State.INIT
|
||||||
self.dlcs = {} # DLCs, by DLCI
|
self.dlcs = {} # DLCs, by DLCI
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
self.disconnection_result = None
|
self.disconnection_result = None
|
||||||
@@ -641,14 +688,8 @@ class Multiplexer(EventEmitter):
|
|||||||
# Become a sink for the L2CAP channel
|
# Become a sink for the L2CAP channel
|
||||||
l2cap_channel.sink = self.on_pdu
|
l2cap_channel.sink = self.on_pdu
|
||||||
|
|
||||||
@staticmethod
|
def change_state(self, new_state: State) -> None:
|
||||||
def state_name(state: int):
|
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
|
||||||
return Multiplexer.STATE_NAMES[state]
|
|
||||||
|
|
||||||
def change_state(self, new_state: int) -> None:
|
|
||||||
logger.debug(
|
|
||||||
f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
|
|
||||||
)
|
|
||||||
self.state = new_state
|
self.state = new_state
|
||||||
|
|
||||||
def send_frame(self, frame: RFCOMM_Frame) -> None:
|
def send_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
@@ -679,28 +720,28 @@ class Multiplexer(EventEmitter):
|
|||||||
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
|
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
|
||||||
handler(frame)
|
handler(frame)
|
||||||
|
|
||||||
def on_sabm_frame(self, _frame) -> None:
|
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
if self.state != Multiplexer.INIT:
|
if self.state != Multiplexer.State.INIT:
|
||||||
logger.debug('not in INIT state, ignoring SABM')
|
logger.debug('not in INIT state, ignoring SABM')
|
||||||
return
|
return
|
||||||
self.change_state(Multiplexer.CONNECTED)
|
self.change_state(Multiplexer.State.CONNECTED)
|
||||||
self.send_frame(RFCOMM_Frame.ua(c_r=1, dlci=0))
|
self.send_frame(RFCOMM_Frame.ua(c_r=1, dlci=0))
|
||||||
|
|
||||||
def on_ua_frame(self, _frame) -> None:
|
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
if self.state == Multiplexer.CONNECTING:
|
if self.state == Multiplexer.State.CONNECTING:
|
||||||
self.change_state(Multiplexer.CONNECTED)
|
self.change_state(Multiplexer.State.CONNECTED)
|
||||||
if self.connection_result:
|
if self.connection_result:
|
||||||
self.connection_result.set_result(0)
|
self.connection_result.set_result(0)
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
elif self.state == Multiplexer.DISCONNECTING:
|
elif self.state == Multiplexer.State.DISCONNECTING:
|
||||||
self.change_state(Multiplexer.DISCONNECTED)
|
self.change_state(Multiplexer.State.DISCONNECTED)
|
||||||
if self.disconnection_result:
|
if self.disconnection_result:
|
||||||
self.disconnection_result.set_result(None)
|
self.disconnection_result.set_result(None)
|
||||||
self.disconnection_result = None
|
self.disconnection_result = None
|
||||||
|
|
||||||
def on_dm_frame(self, _frame) -> None:
|
def on_dm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
if self.state == Multiplexer.OPENING:
|
if self.state == Multiplexer.State.OPENING:
|
||||||
self.change_state(Multiplexer.CONNECTED)
|
self.change_state(Multiplexer.State.CONNECTED)
|
||||||
if self.open_result:
|
if self.open_result:
|
||||||
self.open_result.set_exception(
|
self.open_result.set_exception(
|
||||||
core.ConnectionError(
|
core.ConnectionError(
|
||||||
@@ -713,10 +754,12 @@ class Multiplexer(EventEmitter):
|
|||||||
else:
|
else:
|
||||||
logger.warning(f'unexpected state for DM: {self}')
|
logger.warning(f'unexpected state for DM: {self}')
|
||||||
|
|
||||||
def on_disc_frame(self, _frame) -> None:
|
def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
self.change_state(Multiplexer.DISCONNECTED)
|
self.change_state(Multiplexer.State.DISCONNECTED)
|
||||||
self.send_frame(
|
self.send_frame(
|
||||||
RFCOMM_Frame.ua(c_r=0 if self.role == Multiplexer.INITIATOR else 1, dlci=0)
|
RFCOMM_Frame.ua(
|
||||||
|
c_r=0 if self.role == Multiplexer.Role.INITIATOR else 1, dlci=0
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
|
def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
@@ -729,11 +772,11 @@ class Multiplexer(EventEmitter):
|
|||||||
mcs = RFCOMM_MCC_MSC.from_bytes(value)
|
mcs = RFCOMM_MCC_MSC.from_bytes(value)
|
||||||
self.on_mcc_msc(c_r, mcs)
|
self.on_mcc_msc(c_r, mcs)
|
||||||
|
|
||||||
def on_ui_frame(self, frame) -> None:
|
def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_mcc_pn(self, c_r, pn) -> None:
|
def on_mcc_pn(self, c_r: bool, pn: RFCOMM_MCC_PN) -> None:
|
||||||
if c_r == 1:
|
if c_r:
|
||||||
# Command
|
# Command
|
||||||
logger.debug(f'<<< PN Command: {pn}')
|
logger.debug(f'<<< PN Command: {pn}')
|
||||||
|
|
||||||
@@ -764,14 +807,14 @@ class Multiplexer(EventEmitter):
|
|||||||
else:
|
else:
|
||||||
# Response
|
# Response
|
||||||
logger.debug(f'>>> PN Response: {pn}')
|
logger.debug(f'>>> PN Response: {pn}')
|
||||||
if self.state == Multiplexer.OPENING:
|
if self.state == Multiplexer.State.OPENING:
|
||||||
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size)
|
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size)
|
||||||
self.dlcs[pn.dlci] = dlc
|
self.dlcs[pn.dlci] = dlc
|
||||||
dlc.connect()
|
dlc.connect()
|
||||||
else:
|
else:
|
||||||
logger.warning('ignoring PN response')
|
logger.warning('ignoring PN response')
|
||||||
|
|
||||||
def on_mcc_msc(self, c_r, msc) -> None:
|
def on_mcc_msc(self, c_r: bool, msc: RFCOMM_MCC_MSC) -> None:
|
||||||
dlc = self.dlcs.get(msc.dlci)
|
dlc = self.dlcs.get(msc.dlci)
|
||||||
if dlc is None:
|
if dlc is None:
|
||||||
logger.warning(f'no dlc for DLCI {msc.dlci}')
|
logger.warning(f'no dlc for DLCI {msc.dlci}')
|
||||||
@@ -779,30 +822,30 @@ class Multiplexer(EventEmitter):
|
|||||||
dlc.on_mcc_msc(c_r, msc)
|
dlc.on_mcc_msc(c_r, msc)
|
||||||
|
|
||||||
async def connect(self) -> None:
|
async def connect(self) -> None:
|
||||||
if self.state != Multiplexer.INIT:
|
if self.state != Multiplexer.State.INIT:
|
||||||
raise InvalidStateError('invalid state')
|
raise InvalidStateError('invalid state')
|
||||||
|
|
||||||
self.change_state(Multiplexer.CONNECTING)
|
self.change_state(Multiplexer.State.CONNECTING)
|
||||||
self.connection_result = asyncio.get_running_loop().create_future()
|
self.connection_result = asyncio.get_running_loop().create_future()
|
||||||
self.send_frame(RFCOMM_Frame.sabm(c_r=1, dlci=0))
|
self.send_frame(RFCOMM_Frame.sabm(c_r=1, dlci=0))
|
||||||
return await self.connection_result
|
return await self.connection_result
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
if self.state != Multiplexer.CONNECTED:
|
if self.state != Multiplexer.State.CONNECTED:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.disconnection_result = asyncio.get_running_loop().create_future()
|
self.disconnection_result = asyncio.get_running_loop().create_future()
|
||||||
self.change_state(Multiplexer.DISCONNECTING)
|
self.change_state(Multiplexer.State.DISCONNECTING)
|
||||||
self.send_frame(
|
self.send_frame(
|
||||||
RFCOMM_Frame.disc(
|
RFCOMM_Frame.disc(
|
||||||
c_r=1 if self.role == Multiplexer.INITIATOR else 0, dlci=0
|
c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0, dlci=0
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await self.disconnection_result
|
await self.disconnection_result
|
||||||
|
|
||||||
async def open_dlc(self, channel: int) -> DLC:
|
async def open_dlc(self, channel: int) -> DLC:
|
||||||
if self.state != Multiplexer.CONNECTED:
|
if self.state != Multiplexer.State.CONNECTED:
|
||||||
if self.state == Multiplexer.OPENING:
|
if self.state == Multiplexer.State.OPENING:
|
||||||
raise InvalidStateError('open already in progress')
|
raise InvalidStateError('open already in progress')
|
||||||
|
|
||||||
raise InvalidStateError('not connected')
|
raise InvalidStateError('not connected')
|
||||||
@@ -819,10 +862,10 @@ class Multiplexer(EventEmitter):
|
|||||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=1, data=bytes(pn))
|
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=1, data=bytes(pn))
|
||||||
logger.debug(f'>>> Sending MCC: {pn}')
|
logger.debug(f'>>> Sending MCC: {pn}')
|
||||||
self.open_result = asyncio.get_running_loop().create_future()
|
self.open_result = asyncio.get_running_loop().create_future()
|
||||||
self.change_state(Multiplexer.OPENING)
|
self.change_state(Multiplexer.State.OPENING)
|
||||||
self.send_frame(
|
self.send_frame(
|
||||||
RFCOMM_Frame.uih(
|
RFCOMM_Frame.uih(
|
||||||
c_r=1 if self.role == Multiplexer.INITIATOR else 0,
|
c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0,
|
||||||
dlci=0,
|
dlci=0,
|
||||||
information=mcc,
|
information=mcc,
|
||||||
)
|
)
|
||||||
@@ -831,23 +874,22 @@ class Multiplexer(EventEmitter):
|
|||||||
self.open_result = None
|
self.open_result = None
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def on_dlc_open_complete(self, dlc: DLC):
|
def on_dlc_open_complete(self, dlc: DLC) -> None:
|
||||||
logger.debug(f'DLC [{dlc.dlci}] open complete')
|
logger.debug(f'DLC [{dlc.dlci}] open complete')
|
||||||
self.change_state(Multiplexer.CONNECTED)
|
self.change_state(Multiplexer.State.CONNECTED)
|
||||||
if self.open_result:
|
if self.open_result:
|
||||||
self.open_result.set_result(dlc)
|
self.open_result.set_result(dlc)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f'Multiplexer(state={self.state_name(self.state)})'
|
return f'Multiplexer(state={self.state.name})'
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Client:
|
class Client:
|
||||||
multiplexer: Optional[Multiplexer]
|
multiplexer: Optional[Multiplexer]
|
||||||
l2cap_channel: Optional[l2cap.Channel]
|
l2cap_channel: Optional[l2cap.ClassicChannel]
|
||||||
|
|
||||||
def __init__(self, device, connection) -> None:
|
def __init__(self, connection: Connection) -> None:
|
||||||
self.device = device
|
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.l2cap_channel = None
|
self.l2cap_channel = None
|
||||||
self.multiplexer = None
|
self.multiplexer = None
|
||||||
@@ -855,16 +897,16 @@ class Client:
|
|||||||
async def start(self) -> Multiplexer:
|
async def start(self) -> Multiplexer:
|
||||||
# Create a new L2CAP connection
|
# Create a new L2CAP connection
|
||||||
try:
|
try:
|
||||||
self.l2cap_channel = await self.device.l2cap_channel_manager.connect(
|
self.l2cap_channel = await self.connection.create_l2cap_channel(
|
||||||
self.connection, RFCOMM_PSM
|
spec=l2cap.ClassicChannelSpec(RFCOMM_PSM)
|
||||||
)
|
)
|
||||||
except ProtocolError as error:
|
except ProtocolError as error:
|
||||||
logger.warning(f'L2CAP connection failed: {error}')
|
logger.warning(f'L2CAP connection failed: {error}')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
assert self.l2cap_channel is not None
|
assert self.l2cap_channel is not None
|
||||||
# Create a mutliplexer to manage DLCs with the server
|
# Create a multiplexer to manage DLCs with the server
|
||||||
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.INITIATOR)
|
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.Role.INITIATOR)
|
||||||
|
|
||||||
# Connect the multiplexer
|
# Connect the multiplexer
|
||||||
await self.multiplexer.connect()
|
await self.multiplexer.connect()
|
||||||
@@ -886,14 +928,16 @@ class Client:
|
|||||||
class Server(EventEmitter):
|
class Server(EventEmitter):
|
||||||
acceptors: Dict[int, Callable[[DLC], None]]
|
acceptors: Dict[int, Callable[[DLC], None]]
|
||||||
|
|
||||||
def __init__(self, device) -> None:
|
def __init__(self, device: Device) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.device = device
|
self.device = device
|
||||||
self.multiplexer = None
|
self.multiplexer = None
|
||||||
self.acceptors = {}
|
self.acceptors = {}
|
||||||
|
|
||||||
# Register ourselves with the L2CAP channel manager
|
# Register ourselves with the L2CAP channel manager
|
||||||
device.register_l2cap_server(RFCOMM_PSM, self.on_connection)
|
device.create_l2cap_server(
|
||||||
|
spec=l2cap.ClassicChannelSpec(psm=RFCOMM_PSM), handler=self.on_connection
|
||||||
|
)
|
||||||
|
|
||||||
def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int:
|
def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int:
|
||||||
if channel:
|
if channel:
|
||||||
@@ -917,15 +961,15 @@ class Server(EventEmitter):
|
|||||||
self.acceptors[channel] = acceptor
|
self.acceptors[channel] = acceptor
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
def on_connection(self, l2cap_channel: l2cap.Channel) -> None:
|
def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
|
||||||
logger.debug(f'+++ new L2CAP connection: {l2cap_channel}')
|
logger.debug(f'+++ new L2CAP connection: {l2cap_channel}')
|
||||||
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel))
|
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel))
|
||||||
|
|
||||||
def on_l2cap_channel_open(self, l2cap_channel: l2cap.Channel) -> None:
|
def on_l2cap_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
|
||||||
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
|
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
|
||||||
|
|
||||||
# Create a new multiplexer for the channel
|
# Create a new multiplexer for the channel
|
||||||
multiplexer = Multiplexer(l2cap_channel, Multiplexer.RESPONDER)
|
multiplexer = Multiplexer(l2cap_channel, Multiplexer.Role.RESPONDER)
|
||||||
multiplexer.acceptor = self.accept_dlc
|
multiplexer.acceptor = self.accept_dlc
|
||||||
multiplexer.on('dlc', self.on_dlc)
|
multiplexer.on('dlc', self.on_dlc)
|
||||||
|
|
||||||
|
|||||||
132
bumble/sdp.py
132
bumble/sdp.py
@@ -18,13 +18,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Dict, List, Type
|
from typing import Dict, List, Type, Optional, Tuple, Union, NewType, TYPE_CHECKING
|
||||||
|
|
||||||
from . import core
|
from . import core, l2cap
|
||||||
from .colors import color
|
from .colors import color
|
||||||
from .core import InvalidStateError
|
from .core import InvalidStateError
|
||||||
from .hci import HCI_Object, name_or_number, key_with_value
|
from .hci import HCI_Object, name_or_number, key_with_value
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .device import Device, Connection
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -94,6 +97,10 @@ SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID = 0X000B
|
|||||||
SDP_ICON_URL_ATTRIBUTE_ID = 0X000C
|
SDP_ICON_URL_ATTRIBUTE_ID = 0X000C
|
||||||
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
|
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
|
||||||
|
|
||||||
|
# Attribute Identifier (cf. Assigned Numbers for Service Discovery)
|
||||||
|
# used by AVRCP, HFP and A2DP
|
||||||
|
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID = 0x0311
|
||||||
|
|
||||||
SDP_ATTRIBUTE_ID_NAMES = {
|
SDP_ATTRIBUTE_ID_NAMES = {
|
||||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID: 'SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID',
|
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID: 'SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID',
|
||||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID: 'SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID',
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID: 'SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID',
|
||||||
@@ -160,7 +167,7 @@ class DataElement:
|
|||||||
UUID: lambda x: DataElement(
|
UUID: lambda x: DataElement(
|
||||||
DataElement.UUID, core.UUID.from_bytes(bytes(reversed(x)))
|
DataElement.UUID, core.UUID.from_bytes(bytes(reversed(x)))
|
||||||
),
|
),
|
||||||
TEXT_STRING: lambda x: DataElement(DataElement.TEXT_STRING, x.decode('utf8')),
|
TEXT_STRING: lambda x: DataElement(DataElement.TEXT_STRING, x),
|
||||||
BOOLEAN: lambda x: DataElement(DataElement.BOOLEAN, x[0] == 1),
|
BOOLEAN: lambda x: DataElement(DataElement.BOOLEAN, x[0] == 1),
|
||||||
SEQUENCE: lambda x: DataElement(
|
SEQUENCE: lambda x: DataElement(
|
||||||
DataElement.SEQUENCE, DataElement.list_from_bytes(x)
|
DataElement.SEQUENCE, DataElement.list_from_bytes(x)
|
||||||
@@ -222,7 +229,7 @@ class DataElement:
|
|||||||
return DataElement(DataElement.UUID, value)
|
return DataElement(DataElement.UUID, value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def text_string(value: str) -> DataElement:
|
def text_string(value: bytes) -> DataElement:
|
||||||
return DataElement(DataElement.TEXT_STRING, value)
|
return DataElement(DataElement.TEXT_STRING, value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -369,7 +376,7 @@ class DataElement:
|
|||||||
raise ValueError('invalid value_size')
|
raise ValueError('invalid value_size')
|
||||||
elif self.type == DataElement.UUID:
|
elif self.type == DataElement.UUID:
|
||||||
data = bytes(reversed(bytes(self.value)))
|
data = bytes(reversed(bytes(self.value)))
|
||||||
elif self.type in (DataElement.TEXT_STRING, DataElement.URL):
|
elif self.type == DataElement.URL:
|
||||||
data = self.value.encode('utf8')
|
data = self.value.encode('utf8')
|
||||||
elif self.type == DataElement.BOOLEAN:
|
elif self.type == DataElement.BOOLEAN:
|
||||||
data = bytes([1 if self.value else 0])
|
data = bytes([1 if self.value else 0])
|
||||||
@@ -462,7 +469,7 @@ class ServiceAttribute:
|
|||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list_from_data_elements(elements):
|
def list_from_data_elements(elements: List[DataElement]) -> List[ServiceAttribute]:
|
||||||
attribute_list = []
|
attribute_list = []
|
||||||
for i in range(0, len(elements) // 2):
|
for i in range(0, len(elements) // 2):
|
||||||
attribute_id, attribute_value = elements[2 * i : 2 * (i + 1)]
|
attribute_id, attribute_value = elements[2 * i : 2 * (i + 1)]
|
||||||
@@ -474,7 +481,9 @@ class ServiceAttribute:
|
|||||||
return attribute_list
|
return attribute_list
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_attribute_in_list(attribute_list, attribute_id):
|
def find_attribute_in_list(
|
||||||
|
attribute_list: List[ServiceAttribute], attribute_id: int
|
||||||
|
) -> Optional[DataElement]:
|
||||||
return next(
|
return next(
|
||||||
(
|
(
|
||||||
attribute.value
|
attribute.value
|
||||||
@@ -489,7 +498,7 @@ class ServiceAttribute:
|
|||||||
return name_or_number(SDP_ATTRIBUTE_ID_NAMES, id_code)
|
return name_or_number(SDP_ATTRIBUTE_ID_NAMES, id_code)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_uuid_in_value(uuid, value):
|
def is_uuid_in_value(uuid: core.UUID, value: DataElement) -> bool:
|
||||||
# Find if a uuid matches a value, either directly or recursing into sequences
|
# Find if a uuid matches a value, either directly or recursing into sequences
|
||||||
if value.type == DataElement.UUID:
|
if value.type == DataElement.UUID:
|
||||||
return value.value == uuid
|
return value.value == uuid
|
||||||
@@ -543,7 +552,9 @@ class SDP_PDU:
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_service_record_handle_list_preceded_by_count(data, offset):
|
def parse_service_record_handle_list_preceded_by_count(
|
||||||
|
data: bytes, offset: int
|
||||||
|
) -> Tuple[int, List[int]]:
|
||||||
count = struct.unpack_from('>H', data, offset - 2)[0]
|
count = struct.unpack_from('>H', data, offset - 2)[0]
|
||||||
handle_list = [
|
handle_list = [
|
||||||
struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
|
struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
|
||||||
@@ -641,6 +652,10 @@ class SDP_ServiceSearchRequest(SDP_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.5.1 SDP_ServiceSearchRequest PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.5.1 SDP_ServiceSearchRequest PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
service_search_pattern: DataElement
|
||||||
|
maximum_service_record_count: int
|
||||||
|
continuation_state: bytes
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass(
|
||||||
@@ -659,6 +674,11 @@ class SDP_ServiceSearchResponse(SDP_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
service_record_handle_list: List[int]
|
||||||
|
total_service_record_count: int
|
||||||
|
current_service_record_count: int
|
||||||
|
continuation_state: bytes
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass(
|
||||||
@@ -674,6 +694,11 @@ class SDP_ServiceAttributeRequest(SDP_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.6.1 SDP_ServiceAttributeRequest PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.6.1 SDP_ServiceAttributeRequest PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
service_record_handle: int
|
||||||
|
maximum_attribute_byte_count: int
|
||||||
|
attribute_id_list: DataElement
|
||||||
|
continuation_state: bytes
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass(
|
||||||
@@ -688,6 +713,10 @@ class SDP_ServiceAttributeResponse(SDP_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.6.2 SDP_ServiceAttributeResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.6.2 SDP_ServiceAttributeResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
attribute_list_byte_count: int
|
||||||
|
attribute_list: bytes
|
||||||
|
continuation_state: bytes
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass(
|
||||||
@@ -703,6 +732,11 @@ class SDP_ServiceSearchAttributeRequest(SDP_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.7.1 SDP_ServiceSearchAttributeRequest PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.7.1 SDP_ServiceSearchAttributeRequest PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
service_search_pattern: DataElement
|
||||||
|
maximum_attribute_byte_count: int
|
||||||
|
attribute_id_list: DataElement
|
||||||
|
continuation_state: bytes
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass(
|
||||||
@@ -717,26 +751,35 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
attribute_list_byte_count: int
|
||||||
|
attribute_list: bytes
|
||||||
|
continuation_state: bytes
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Client:
|
class Client:
|
||||||
def __init__(self, device):
|
channel: Optional[l2cap.ClassicChannel]
|
||||||
self.device = device
|
|
||||||
|
def __init__(self, connection: Connection) -> None:
|
||||||
|
self.connection = connection
|
||||||
self.pending_request = None
|
self.pending_request = None
|
||||||
self.channel = None
|
self.channel = None
|
||||||
|
|
||||||
async def connect(self, connection):
|
async def connect(self) -> None:
|
||||||
result = await self.device.l2cap_channel_manager.connect(connection, SDP_PSM)
|
self.channel = await self.connection.create_l2cap_channel(
|
||||||
self.channel = result
|
spec=l2cap.ClassicChannelSpec(SDP_PSM)
|
||||||
|
)
|
||||||
|
|
||||||
async def disconnect(self):
|
async def disconnect(self) -> None:
|
||||||
if self.channel:
|
if self.channel:
|
||||||
await self.channel.disconnect()
|
await self.channel.disconnect()
|
||||||
self.channel = None
|
self.channel = None
|
||||||
|
|
||||||
async def search_services(self, uuids):
|
async def search_services(self, uuids: List[core.UUID]) -> List[int]:
|
||||||
if self.pending_request is not None:
|
if self.pending_request is not None:
|
||||||
raise InvalidStateError('request already pending')
|
raise InvalidStateError('request already pending')
|
||||||
|
if self.channel is None:
|
||||||
|
raise InvalidStateError('L2CAP not connected')
|
||||||
|
|
||||||
service_search_pattern = DataElement.sequence(
|
service_search_pattern = DataElement.sequence(
|
||||||
[DataElement.uuid(uuid) for uuid in uuids]
|
[DataElement.uuid(uuid) for uuid in uuids]
|
||||||
@@ -766,9 +809,13 @@ class Client:
|
|||||||
|
|
||||||
return service_record_handle_list
|
return service_record_handle_list
|
||||||
|
|
||||||
async def search_attributes(self, uuids, attribute_ids):
|
async def search_attributes(
|
||||||
|
self, uuids: List[core.UUID], attribute_ids: List[Union[int, Tuple[int, int]]]
|
||||||
|
) -> List[List[ServiceAttribute]]:
|
||||||
if self.pending_request is not None:
|
if self.pending_request is not None:
|
||||||
raise InvalidStateError('request already pending')
|
raise InvalidStateError('request already pending')
|
||||||
|
if self.channel is None:
|
||||||
|
raise InvalidStateError('L2CAP not connected')
|
||||||
|
|
||||||
service_search_pattern = DataElement.sequence(
|
service_search_pattern = DataElement.sequence(
|
||||||
[DataElement.uuid(uuid) for uuid in uuids]
|
[DataElement.uuid(uuid) for uuid in uuids]
|
||||||
@@ -819,9 +866,15 @@ class Client:
|
|||||||
if sequence.type == DataElement.SEQUENCE
|
if sequence.type == DataElement.SEQUENCE
|
||||||
]
|
]
|
||||||
|
|
||||||
async def get_attributes(self, service_record_handle, attribute_ids):
|
async def get_attributes(
|
||||||
|
self,
|
||||||
|
service_record_handle: int,
|
||||||
|
attribute_ids: List[Union[int, Tuple[int, int]]],
|
||||||
|
) -> List[ServiceAttribute]:
|
||||||
if self.pending_request is not None:
|
if self.pending_request is not None:
|
||||||
raise InvalidStateError('request already pending')
|
raise InvalidStateError('request already pending')
|
||||||
|
if self.channel is None:
|
||||||
|
raise InvalidStateError('L2CAP not connected')
|
||||||
|
|
||||||
attribute_id_list = DataElement.sequence(
|
attribute_id_list = DataElement.sequence(
|
||||||
[
|
[
|
||||||
@@ -869,21 +922,27 @@ class Client:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Server:
|
class Server:
|
||||||
CONTINUATION_STATE = bytes([0x01, 0x43])
|
CONTINUATION_STATE = bytes([0x01, 0x43])
|
||||||
|
channel: Optional[l2cap.ClassicChannel]
|
||||||
|
Service = NewType('Service', List[ServiceAttribute])
|
||||||
|
service_records: Dict[int, Service]
|
||||||
|
current_response: Union[None, bytes, Tuple[int, List[int]]]
|
||||||
|
|
||||||
def __init__(self, device):
|
def __init__(self, device: Device) -> None:
|
||||||
self.device = device
|
self.device = device
|
||||||
self.service_records = {} # Service records maps, by record handle
|
self.service_records = {} # Service records maps, by record handle
|
||||||
self.channel = None
|
self.channel = None
|
||||||
self.current_response = None
|
self.current_response = None
|
||||||
|
|
||||||
def register(self, l2cap_channel_manager):
|
def register(self, l2cap_channel_manager: l2cap.ChannelManager) -> None:
|
||||||
l2cap_channel_manager.register_server(SDP_PSM, self.on_connection)
|
l2cap_channel_manager.create_classic_server(
|
||||||
|
spec=l2cap.ClassicChannelSpec(psm=SDP_PSM), handler=self.on_connection
|
||||||
|
)
|
||||||
|
|
||||||
def send_response(self, response):
|
def send_response(self, response):
|
||||||
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
|
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
|
||||||
self.channel.send_pdu(response)
|
self.channel.send_pdu(response)
|
||||||
|
|
||||||
def match_services(self, search_pattern):
|
def match_services(self, search_pattern: DataElement) -> Dict[int, Service]:
|
||||||
# Find the services for which the attributes in the pattern is a subset of the
|
# Find the services for which the attributes in the pattern is a subset of the
|
||||||
# service's attribute values (NOTE: the value search recurses into sequences)
|
# service's attribute values (NOTE: the value search recurses into sequences)
|
||||||
matching_services = {}
|
matching_services = {}
|
||||||
@@ -953,7 +1012,9 @@ class Server:
|
|||||||
return (payload, continuation_state)
|
return (payload, continuation_state)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_service_attributes(service, attribute_ids):
|
def get_service_attributes(
|
||||||
|
service: Service, attribute_ids: List[DataElement]
|
||||||
|
) -> DataElement:
|
||||||
attributes = []
|
attributes = []
|
||||||
for attribute_id in attribute_ids:
|
for attribute_id in attribute_ids:
|
||||||
if attribute_id.value_size == 4:
|
if attribute_id.value_size == 4:
|
||||||
@@ -978,10 +1039,10 @@ class Server:
|
|||||||
|
|
||||||
return attribute_list
|
return attribute_list
|
||||||
|
|
||||||
def on_sdp_service_search_request(self, request):
|
def on_sdp_service_search_request(self, request: SDP_ServiceSearchRequest) -> None:
|
||||||
# Check if this is a continuation
|
# Check if this is a continuation
|
||||||
if len(request.continuation_state) > 1:
|
if len(request.continuation_state) > 1:
|
||||||
if not self.current_response:
|
if self.current_response is None:
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
@@ -1010,6 +1071,7 @@ class Server:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Respond, keeping any unsent handles for later
|
# Respond, keeping any unsent handles for later
|
||||||
|
assert isinstance(self.current_response, tuple)
|
||||||
service_record_handles = self.current_response[1][
|
service_record_handles = self.current_response[1][
|
||||||
: request.maximum_service_record_count
|
: request.maximum_service_record_count
|
||||||
]
|
]
|
||||||
@@ -1033,10 +1095,12 @@ class Server:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_sdp_service_attribute_request(self, request):
|
def on_sdp_service_attribute_request(
|
||||||
|
self, request: SDP_ServiceAttributeRequest
|
||||||
|
) -> None:
|
||||||
# Check if this is a continuation
|
# Check if this is a continuation
|
||||||
if len(request.continuation_state) > 1:
|
if len(request.continuation_state) > 1:
|
||||||
if not self.current_response:
|
if self.current_response is None:
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
@@ -1069,22 +1133,24 @@ class Server:
|
|||||||
self.current_response = bytes(attribute_list)
|
self.current_response = bytes(attribute_list)
|
||||||
|
|
||||||
# Respond, keeping any pending chunks for later
|
# Respond, keeping any pending chunks for later
|
||||||
attribute_list, continuation_state = self.get_next_response_payload(
|
attribute_list_response, continuation_state = self.get_next_response_payload(
|
||||||
request.maximum_attribute_byte_count
|
request.maximum_attribute_byte_count
|
||||||
)
|
)
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ServiceAttributeResponse(
|
SDP_ServiceAttributeResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
attribute_list_byte_count=len(attribute_list),
|
attribute_list_byte_count=len(attribute_list_response),
|
||||||
attribute_list=attribute_list,
|
attribute_list=attribute_list,
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_sdp_service_search_attribute_request(self, request):
|
def on_sdp_service_search_attribute_request(
|
||||||
|
self, request: SDP_ServiceSearchAttributeRequest
|
||||||
|
) -> None:
|
||||||
# Check if this is a continuation
|
# Check if this is a continuation
|
||||||
if len(request.continuation_state) > 1:
|
if len(request.continuation_state) > 1:
|
||||||
if not self.current_response:
|
if self.current_response is None:
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
@@ -1114,13 +1180,13 @@ class Server:
|
|||||||
self.current_response = bytes(attribute_lists)
|
self.current_response = bytes(attribute_lists)
|
||||||
|
|
||||||
# Respond, keeping any pending chunks for later
|
# Respond, keeping any pending chunks for later
|
||||||
attribute_lists, continuation_state = self.get_next_response_payload(
|
attribute_lists_response, continuation_state = self.get_next_response_payload(
|
||||||
request.maximum_attribute_byte_count
|
request.maximum_attribute_byte_count
|
||||||
)
|
)
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ServiceSearchAttributeResponse(
|
SDP_ServiceSearchAttributeResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
attribute_lists_byte_count=len(attribute_lists),
|
attribute_lists_byte_count=len(attribute_lists_response),
|
||||||
attribute_lists=attribute_lists,
|
attribute_lists=attribute_lists,
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
)
|
)
|
||||||
|
|||||||
351
bumble/smp.py
351
bumble/smp.py
@@ -27,6 +27,7 @@ import logging
|
|||||||
import asyncio
|
import asyncio
|
||||||
import enum
|
import enum
|
||||||
import secrets
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Any,
|
Any,
|
||||||
@@ -37,6 +38,7 @@ from typing import (
|
|||||||
Optional,
|
Optional,
|
||||||
Tuple,
|
Tuple,
|
||||||
Type,
|
Type,
|
||||||
|
cast,
|
||||||
)
|
)
|
||||||
|
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
@@ -52,6 +54,7 @@ from .core import (
|
|||||||
BT_BR_EDR_TRANSPORT,
|
BT_BR_EDR_TRANSPORT,
|
||||||
BT_CENTRAL_ROLE,
|
BT_CENTRAL_ROLE,
|
||||||
BT_LE_TRANSPORT,
|
BT_LE_TRANSPORT,
|
||||||
|
AdvertisingData,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
name_or_number,
|
name_or_number,
|
||||||
)
|
)
|
||||||
@@ -184,8 +187,8 @@ SMP_KEYPRESS_AUTHREQ = 0b00010000
|
|||||||
SMP_CT2_AUTHREQ = 0b00100000
|
SMP_CT2_AUTHREQ = 0b00100000
|
||||||
|
|
||||||
# Crypto salt
|
# Crypto salt
|
||||||
SMP_CTKD_H7_LEBR_SALT = bytes.fromhex('00000000000000000000000000000000746D7031')
|
SMP_CTKD_H7_LEBR_SALT = bytes.fromhex('000000000000000000000000746D7031')
|
||||||
SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('00000000000000000000000000000000746D7032')
|
SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('000000000000000000000000746D7032')
|
||||||
|
|
||||||
# fmt: on
|
# fmt: on
|
||||||
# pylint: enable=line-too-long
|
# pylint: enable=line-too-long
|
||||||
@@ -562,6 +565,54 @@ class PairingMethod(enum.IntEnum):
|
|||||||
CTKD_OVER_CLASSIC = 4
|
CTKD_OVER_CLASSIC = 4
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class OobContext:
|
||||||
|
"""Cryptographic context for LE SC OOB pairing."""
|
||||||
|
|
||||||
|
ecc_key: crypto.EccKey
|
||||||
|
r: bytes
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, ecc_key: Optional[crypto.EccKey] = None, r: Optional[bytes] = None
|
||||||
|
) -> None:
|
||||||
|
self.ecc_key = crypto.EccKey.generate() if ecc_key is None else ecc_key
|
||||||
|
self.r = crypto.r() if r is None else r
|
||||||
|
|
||||||
|
def share(self) -> OobSharedData:
|
||||||
|
pkx = self.ecc_key.x[::-1]
|
||||||
|
return OobSharedData(c=crypto.f4(pkx, pkx, self.r, bytes(1)), r=self.r)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class OobLegacyContext:
|
||||||
|
"""Cryptographic context for LE Legacy OOB pairing."""
|
||||||
|
|
||||||
|
tk: bytes
|
||||||
|
|
||||||
|
def __init__(self, tk: Optional[bytes] = None) -> None:
|
||||||
|
self.tk = crypto.r() if tk is None else tk
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
|
class OobSharedData:
|
||||||
|
"""Shareable data for LE SC OOB pairing."""
|
||||||
|
|
||||||
|
c: bytes
|
||||||
|
r: bytes
|
||||||
|
|
||||||
|
def to_ad(self) -> AdvertisingData:
|
||||||
|
return AdvertisingData(
|
||||||
|
[
|
||||||
|
(AdvertisingData.LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE, self.c),
|
||||||
|
(AdvertisingData.LE_SECURE_CONNECTIONS_RANDOM_VALUE, self.r),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f'OOB(C={self.c.hex()}, R={self.r.hex()})'
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Session:
|
class Session:
|
||||||
# I/O Capability to pairing method decision matrix
|
# I/O Capability to pairing method decision matrix
|
||||||
@@ -626,6 +677,13 @@ class Session:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ea: bytes
|
||||||
|
eb: bytes
|
||||||
|
ltk: bytes
|
||||||
|
preq: bytes
|
||||||
|
pres: bytes
|
||||||
|
tk: bytes
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
manager: Manager,
|
manager: Manager,
|
||||||
@@ -635,17 +693,10 @@ class Session:
|
|||||||
) -> None:
|
) -> None:
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.preq: Optional[bytes] = None
|
|
||||||
self.pres: Optional[bytes] = None
|
|
||||||
self.ea = None
|
|
||||||
self.eb = None
|
|
||||||
self.tk = bytes(16)
|
|
||||||
self.r = bytes(16)
|
|
||||||
self.stk = None
|
self.stk = None
|
||||||
self.ltk = None
|
|
||||||
self.ltk_ediv = 0
|
self.ltk_ediv = 0
|
||||||
self.ltk_rand = bytes(8)
|
self.ltk_rand = bytes(8)
|
||||||
self.link_key = None
|
self.link_key: Optional[bytes] = None
|
||||||
self.initiator_key_distribution: int = 0
|
self.initiator_key_distribution: int = 0
|
||||||
self.responder_key_distribution: int = 0
|
self.responder_key_distribution: int = 0
|
||||||
self.peer_random_value: Optional[bytes] = None
|
self.peer_random_value: Optional[bytes] = None
|
||||||
@@ -658,7 +709,7 @@ class Session:
|
|||||||
self.peer_bd_addr: Optional[Address] = None
|
self.peer_bd_addr: Optional[Address] = None
|
||||||
self.peer_signature_key = None
|
self.peer_signature_key = None
|
||||||
self.peer_expected_distributions: List[Type[SMP_Command]] = []
|
self.peer_expected_distributions: List[Type[SMP_Command]] = []
|
||||||
self.dh_key = None
|
self.dh_key = b''
|
||||||
self.confirm_value = None
|
self.confirm_value = None
|
||||||
self.passkey: Optional[int] = None
|
self.passkey: Optional[int] = None
|
||||||
self.passkey_ready = asyncio.Event()
|
self.passkey_ready = asyncio.Event()
|
||||||
@@ -711,8 +762,8 @@ class Session:
|
|||||||
self.io_capability = pairing_config.delegate.io_capability
|
self.io_capability = pairing_config.delegate.io_capability
|
||||||
self.peer_io_capability = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
|
self.peer_io_capability = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
|
||||||
|
|
||||||
# OOB (not supported yet)
|
# OOB
|
||||||
self.oob = False
|
self.oob_data_flag = 0 if pairing_config.oob is None else 1
|
||||||
|
|
||||||
# Set up addresses
|
# Set up addresses
|
||||||
self_address = connection.self_address
|
self_address = connection.self_address
|
||||||
@@ -728,9 +779,35 @@ class Session:
|
|||||||
self.ia = bytes(peer_address)
|
self.ia = bytes(peer_address)
|
||||||
self.iat = 1 if peer_address.is_random else 0
|
self.iat = 1 if peer_address.is_random else 0
|
||||||
|
|
||||||
|
# Select the ECC key, TK and r initial value
|
||||||
|
if pairing_config.oob:
|
||||||
|
self.peer_oob_data = pairing_config.oob.peer_data
|
||||||
|
if pairing_config.sc:
|
||||||
|
if pairing_config.oob.our_context is None:
|
||||||
|
raise ValueError(
|
||||||
|
"oob pairing config requires a context when sc is True"
|
||||||
|
)
|
||||||
|
self.r = pairing_config.oob.our_context.r
|
||||||
|
self.ecc_key = pairing_config.oob.our_context.ecc_key
|
||||||
|
if pairing_config.oob.legacy_context is not None:
|
||||||
|
self.tk = pairing_config.oob.legacy_context.tk
|
||||||
|
else:
|
||||||
|
if pairing_config.oob.legacy_context is None:
|
||||||
|
raise ValueError(
|
||||||
|
"oob pairing config requires a legacy context when sc is False"
|
||||||
|
)
|
||||||
|
self.r = bytes(16)
|
||||||
|
self.ecc_key = manager.ecc_key
|
||||||
|
self.tk = pairing_config.oob.legacy_context.tk
|
||||||
|
else:
|
||||||
|
self.peer_oob_data = None
|
||||||
|
self.r = bytes(16)
|
||||||
|
self.ecc_key = manager.ecc_key
|
||||||
|
self.tk = bytes(16)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pkx(self) -> Tuple[bytes, bytes]:
|
def pkx(self) -> Tuple[bytes, bytes]:
|
||||||
return (bytes(reversed(self.manager.ecc_key.x)), self.peer_public_key_x)
|
return (self.ecc_key.x[::-1], self.peer_public_key_x)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pka(self) -> bytes:
|
def pka(self) -> bytes:
|
||||||
@@ -767,7 +844,10 @@ class Session:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def decide_pairing_method(
|
def decide_pairing_method(
|
||||||
self, auth_req: int, initiator_io_capability: int, responder_io_capability: int
|
self,
|
||||||
|
auth_req: int,
|
||||||
|
initiator_io_capability: int,
|
||||||
|
responder_io_capability: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.connection.transport == BT_BR_EDR_TRANSPORT:
|
if self.connection.transport == BT_BR_EDR_TRANSPORT:
|
||||||
self.pairing_method = PairingMethod.CTKD_OVER_CLASSIC
|
self.pairing_method = PairingMethod.CTKD_OVER_CLASSIC
|
||||||
@@ -908,7 +988,7 @@ class Session:
|
|||||||
|
|
||||||
command = SMP_Pairing_Request_Command(
|
command = SMP_Pairing_Request_Command(
|
||||||
io_capability=self.io_capability,
|
io_capability=self.io_capability,
|
||||||
oob_data_flag=0,
|
oob_data_flag=self.oob_data_flag,
|
||||||
auth_req=self.auth_req,
|
auth_req=self.auth_req,
|
||||||
maximum_encryption_key_size=16,
|
maximum_encryption_key_size=16,
|
||||||
initiator_key_distribution=self.initiator_key_distribution,
|
initiator_key_distribution=self.initiator_key_distribution,
|
||||||
@@ -920,7 +1000,7 @@ class Session:
|
|||||||
def send_pairing_response_command(self) -> None:
|
def send_pairing_response_command(self) -> None:
|
||||||
response = SMP_Pairing_Response_Command(
|
response = SMP_Pairing_Response_Command(
|
||||||
io_capability=self.io_capability,
|
io_capability=self.io_capability,
|
||||||
oob_data_flag=0,
|
oob_data_flag=self.oob_data_flag,
|
||||||
auth_req=self.auth_req,
|
auth_req=self.auth_req,
|
||||||
maximum_encryption_key_size=16,
|
maximum_encryption_key_size=16,
|
||||||
initiator_key_distribution=self.initiator_key_distribution,
|
initiator_key_distribution=self.initiator_key_distribution,
|
||||||
@@ -981,8 +1061,8 @@ class Session:
|
|||||||
def send_public_key_command(self) -> None:
|
def send_public_key_command(self) -> None:
|
||||||
self.send_command(
|
self.send_command(
|
||||||
SMP_Pairing_Public_Key_Command(
|
SMP_Pairing_Public_Key_Command(
|
||||||
public_key_x=bytes(reversed(self.manager.ecc_key.x)),
|
public_key_x=self.ecc_key.x[::-1],
|
||||||
public_key_y=bytes(reversed(self.manager.ecc_key.y)),
|
public_key_y=self.ecc_key.y[::-1],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -993,6 +1073,19 @@ class Session:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def send_identity_address_command(self) -> None:
|
||||||
|
identity_address = {
|
||||||
|
None: self.connection.self_address,
|
||||||
|
Address.PUBLIC_DEVICE_ADDRESS: self.manager.device.public_address,
|
||||||
|
Address.RANDOM_DEVICE_ADDRESS: self.manager.device.random_address,
|
||||||
|
}[self.pairing_config.identity_address_type]
|
||||||
|
self.send_command(
|
||||||
|
SMP_Identity_Address_Information_Command(
|
||||||
|
addr_type=identity_address.address_type,
|
||||||
|
bd_addr=identity_address,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def start_encryption(self, key: bytes) -> None:
|
def start_encryption(self, key: bytes) -> None:
|
||||||
# We can now encrypt the connection with the short term key, so that we can
|
# We can now encrypt the connection with the short term key, so that we can
|
||||||
# distribute the long term and/or other keys over an encrypted connection
|
# distribute the long term and/or other keys over an encrypted connection
|
||||||
@@ -1005,15 +1098,52 @@ class Session:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def derive_ltk(self) -> None:
|
@classmethod
|
||||||
link_key = await self.manager.device.get_link_key(self.connection.peer_address)
|
def derive_ltk(cls, link_key: bytes, ct2: bool) -> bytes:
|
||||||
assert link_key is not None
|
'''Derives Long Term Key from Link Key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
link_key: BR/EDR Link Key bytes in little-endian.
|
||||||
|
ct2: whether ct2 is supported on both devices.
|
||||||
|
Returns:
|
||||||
|
LE Long Tern Key bytes in little-endian.
|
||||||
|
'''
|
||||||
ilk = (
|
ilk = (
|
||||||
crypto.h7(salt=SMP_CTKD_H7_BRLE_SALT, w=link_key)
|
crypto.h7(salt=SMP_CTKD_H7_BRLE_SALT, w=link_key)
|
||||||
if self.ct2
|
if ct2
|
||||||
else crypto.h6(link_key, b'tmp2')
|
else crypto.h6(link_key, b'tmp2')
|
||||||
)
|
)
|
||||||
self.ltk = crypto.h6(ilk, b'brle')
|
return crypto.h6(ilk, b'brle')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def derive_link_key(cls, ltk: bytes, ct2: bool) -> bytes:
|
||||||
|
'''Derives Link Key from Long Term Key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ltk: LE Long Term Key bytes in little-endian.
|
||||||
|
ct2: whether ct2 is supported on both devices.
|
||||||
|
Returns:
|
||||||
|
BR/EDR Link Key bytes in little-endian.
|
||||||
|
'''
|
||||||
|
ilk = (
|
||||||
|
crypto.h7(salt=SMP_CTKD_H7_LEBR_SALT, w=ltk)
|
||||||
|
if ct2
|
||||||
|
else crypto.h6(ltk, b'tmp1')
|
||||||
|
)
|
||||||
|
return crypto.h6(ilk, b'lebr')
|
||||||
|
|
||||||
|
async def get_link_key_and_derive_ltk(self) -> None:
|
||||||
|
'''Retrieves BR/EDR Link Key from storage and derive it to LE LTK.'''
|
||||||
|
link_key = await self.manager.device.get_link_key(self.connection.peer_address)
|
||||||
|
if link_key is None:
|
||||||
|
logging.warning(
|
||||||
|
'Try to derive LTK but host does not have the LK. Send a SMP_PAIRING_FAILED but the procedure will not be paused!'
|
||||||
|
)
|
||||||
|
self.send_pairing_failed(
|
||||||
|
SMP_CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED_ERROR
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.ltk = self.derive_ltk(link_key, self.ct2)
|
||||||
|
|
||||||
def distribute_keys(self) -> None:
|
def distribute_keys(self) -> None:
|
||||||
# Distribute the keys as required
|
# Distribute the keys as required
|
||||||
@@ -1024,7 +1154,7 @@ class Session:
|
|||||||
and self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
|
and self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
|
||||||
):
|
):
|
||||||
self.ctkd_task = self.connection.abort_on(
|
self.ctkd_task = self.connection.abort_on(
|
||||||
'disconnection', self.derive_ltk()
|
'disconnection', self.get_link_key_and_derive_ltk()
|
||||||
)
|
)
|
||||||
elif not self.sc:
|
elif not self.sc:
|
||||||
# Distribute the LTK, EDIV and RAND
|
# Distribute the LTK, EDIV and RAND
|
||||||
@@ -1045,12 +1175,7 @@ class Session:
|
|||||||
identity_resolving_key=self.manager.device.irk
|
identity_resolving_key=self.manager.device.irk
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.send_command(
|
self.send_identity_address_command()
|
||||||
SMP_Identity_Address_Information_Command(
|
|
||||||
addr_type=self.connection.self_address.address_type,
|
|
||||||
bd_addr=self.connection.self_address,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Distribute CSRK
|
# Distribute CSRK
|
||||||
csrk = bytes(16) # FIXME: testing
|
csrk = bytes(16) # FIXME: testing
|
||||||
@@ -1059,12 +1184,7 @@ class Session:
|
|||||||
|
|
||||||
# CTKD, calculate BR/EDR link key
|
# CTKD, calculate BR/EDR link key
|
||||||
if self.initiator_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
|
if self.initiator_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
|
||||||
ilk = (
|
self.link_key = self.derive_link_key(self.ltk, self.ct2)
|
||||||
crypto.h7(salt=SMP_CTKD_H7_LEBR_SALT, w=self.ltk)
|
|
||||||
if self.ct2
|
|
||||||
else crypto.h6(self.ltk, b'tmp1')
|
|
||||||
)
|
|
||||||
self.link_key = crypto.h6(ilk, b'lebr')
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# CTKD: Derive LTK from LinkKey
|
# CTKD: Derive LTK from LinkKey
|
||||||
@@ -1073,7 +1193,7 @@ class Session:
|
|||||||
and self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
|
and self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
|
||||||
):
|
):
|
||||||
self.ctkd_task = self.connection.abort_on(
|
self.ctkd_task = self.connection.abort_on(
|
||||||
'disconnection', self.derive_ltk()
|
'disconnection', self.get_link_key_and_derive_ltk()
|
||||||
)
|
)
|
||||||
# Distribute the LTK, EDIV and RAND
|
# Distribute the LTK, EDIV and RAND
|
||||||
elif not self.sc:
|
elif not self.sc:
|
||||||
@@ -1094,12 +1214,7 @@ class Session:
|
|||||||
identity_resolving_key=self.manager.device.irk
|
identity_resolving_key=self.manager.device.irk
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.send_command(
|
self.send_identity_address_command()
|
||||||
SMP_Identity_Address_Information_Command(
|
|
||||||
addr_type=self.connection.self_address.address_type,
|
|
||||||
bd_addr=self.connection.self_address,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Distribute CSRK
|
# Distribute CSRK
|
||||||
csrk = bytes(16) # FIXME: testing
|
csrk = bytes(16) # FIXME: testing
|
||||||
@@ -1108,12 +1223,7 @@ class Session:
|
|||||||
|
|
||||||
# CTKD, calculate BR/EDR link key
|
# CTKD, calculate BR/EDR link key
|
||||||
if self.responder_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
|
if self.responder_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
|
||||||
ilk = (
|
self.link_key = self.derive_link_key(self.ltk, self.ct2)
|
||||||
crypto.h7(salt=SMP_CTKD_H7_LEBR_SALT, w=self.ltk)
|
|
||||||
if self.ct2
|
|
||||||
else crypto.h6(self.ltk, b'tmp1')
|
|
||||||
)
|
|
||||||
self.link_key = crypto.h6(ilk, b'lebr')
|
|
||||||
|
|
||||||
def compute_peer_expected_distributions(self, key_distribution_flags: int) -> None:
|
def compute_peer_expected_distributions(self, key_distribution_flags: int) -> None:
|
||||||
# Set our expectations for what to wait for in the key distribution phase
|
# Set our expectations for what to wait for in the key distribution phase
|
||||||
@@ -1268,7 +1378,7 @@ class Session:
|
|||||||
keys.link_key = PairingKeys.Key(
|
keys.link_key = PairingKeys.Key(
|
||||||
value=self.link_key, authenticated=authenticated
|
value=self.link_key, authenticated=authenticated
|
||||||
)
|
)
|
||||||
self.manager.on_pairing(self, peer_address, keys)
|
await self.manager.on_pairing(self, peer_address, keys)
|
||||||
|
|
||||||
def on_pairing_failure(self, reason: int) -> None:
|
def on_pairing_failure(self, reason: int) -> None:
|
||||||
logger.warning(f'pairing failure ({error_name(reason)})')
|
logger.warning(f'pairing failure ({error_name(reason)})')
|
||||||
@@ -1291,7 +1401,7 @@ class Session:
|
|||||||
try:
|
try:
|
||||||
handler(command)
|
handler(command)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(f'{color("!!! Exception in handler:", "red")} {error}')
|
logger.exception(f'{color("!!! Exception in handler:", "red")} {error}')
|
||||||
response = SMP_Pairing_Failed_Command(
|
response = SMP_Pairing_Failed_Command(
|
||||||
reason=SMP_UNSPECIFIED_REASON_ERROR
|
reason=SMP_UNSPECIFIED_REASON_ERROR
|
||||||
)
|
)
|
||||||
@@ -1328,15 +1438,28 @@ class Session:
|
|||||||
self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0)
|
self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0)
|
||||||
self.ct2 = self.ct2 and (command.auth_req & SMP_CT2_AUTHREQ != 0)
|
self.ct2 = self.ct2 and (command.auth_req & SMP_CT2_AUTHREQ != 0)
|
||||||
|
|
||||||
# Check for OOB
|
# Infer the pairing method
|
||||||
if command.oob_data_flag != 0:
|
if (self.sc and (self.oob_data_flag != 0 or command.oob_data_flag != 0)) or (
|
||||||
self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
|
not self.sc and (self.oob_data_flag != 0 and command.oob_data_flag != 0)
|
||||||
return
|
):
|
||||||
|
# Use OOB
|
||||||
|
self.pairing_method = PairingMethod.OOB
|
||||||
|
if not self.sc and self.tk is None:
|
||||||
|
# For legacy OOB, TK is required.
|
||||||
|
logger.warning("legacy OOB without TK")
|
||||||
|
self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
|
||||||
|
return
|
||||||
|
if command.oob_data_flag == 0:
|
||||||
|
# The peer doesn't have OOB data, use r=0
|
||||||
|
self.r = bytes(16)
|
||||||
|
else:
|
||||||
|
# Decide which pairing method to use from the IO capability
|
||||||
|
self.decide_pairing_method(
|
||||||
|
command.auth_req,
|
||||||
|
command.io_capability,
|
||||||
|
self.io_capability,
|
||||||
|
)
|
||||||
|
|
||||||
# Decide which pairing method to use
|
|
||||||
self.decide_pairing_method(
|
|
||||||
command.auth_req, command.io_capability, self.io_capability
|
|
||||||
)
|
|
||||||
logger.debug(f'pairing method: {self.pairing_method.name}')
|
logger.debug(f'pairing method: {self.pairing_method.name}')
|
||||||
|
|
||||||
# Key distribution
|
# Key distribution
|
||||||
@@ -1385,15 +1508,26 @@ class Session:
|
|||||||
self.bonding = self.bonding and (command.auth_req & SMP_BONDING_AUTHREQ != 0)
|
self.bonding = self.bonding and (command.auth_req & SMP_BONDING_AUTHREQ != 0)
|
||||||
self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0)
|
self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0)
|
||||||
|
|
||||||
# Check for OOB
|
# Infer the pairing method
|
||||||
if self.sc and command.oob_data_flag:
|
if (self.sc and (self.oob_data_flag != 0 or command.oob_data_flag != 0)) or (
|
||||||
self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
|
not self.sc and (self.oob_data_flag != 0 and command.oob_data_flag != 0)
|
||||||
return
|
):
|
||||||
|
# Use OOB
|
||||||
|
self.pairing_method = PairingMethod.OOB
|
||||||
|
if not self.sc and self.tk is None:
|
||||||
|
# For legacy OOB, TK is required.
|
||||||
|
logger.warning("legacy OOB without TK")
|
||||||
|
self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
|
||||||
|
return
|
||||||
|
if command.oob_data_flag == 0:
|
||||||
|
# The peer doesn't have OOB data, use r=0
|
||||||
|
self.r = bytes(16)
|
||||||
|
else:
|
||||||
|
# Decide which pairing method to use from the IO capability
|
||||||
|
self.decide_pairing_method(
|
||||||
|
command.auth_req, self.io_capability, command.io_capability
|
||||||
|
)
|
||||||
|
|
||||||
# Decide which pairing method to use
|
|
||||||
self.decide_pairing_method(
|
|
||||||
command.auth_req, self.io_capability, command.io_capability
|
|
||||||
)
|
|
||||||
logger.debug(f'pairing method: {self.pairing_method.name}')
|
logger.debug(f'pairing method: {self.pairing_method.name}')
|
||||||
|
|
||||||
# Key distribution
|
# Key distribution
|
||||||
@@ -1544,12 +1678,13 @@ class Session:
|
|||||||
if self.passkey_step < 20:
|
if self.passkey_step < 20:
|
||||||
self.send_pairing_confirm_command()
|
self.send_pairing_confirm_command()
|
||||||
return
|
return
|
||||||
else:
|
elif self.pairing_method != PairingMethod.OOB:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
if self.pairing_method in (
|
if self.pairing_method in (
|
||||||
PairingMethod.JUST_WORKS,
|
PairingMethod.JUST_WORKS,
|
||||||
PairingMethod.NUMERIC_COMPARISON,
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
PairingMethod.OOB,
|
||||||
):
|
):
|
||||||
self.send_pairing_random_command()
|
self.send_pairing_random_command()
|
||||||
elif self.pairing_method == PairingMethod.PASSKEY:
|
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||||
@@ -1586,6 +1721,7 @@ class Session:
|
|||||||
if self.pairing_method in (
|
if self.pairing_method in (
|
||||||
PairingMethod.JUST_WORKS,
|
PairingMethod.JUST_WORKS,
|
||||||
PairingMethod.NUMERIC_COMPARISON,
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
PairingMethod.OOB,
|
||||||
):
|
):
|
||||||
ra = bytes(16)
|
ra = bytes(16)
|
||||||
rb = ra
|
rb = ra
|
||||||
@@ -1594,7 +1730,6 @@ class Session:
|
|||||||
ra = self.passkey.to_bytes(16, byteorder='little')
|
ra = self.passkey.to_bytes(16, byteorder='little')
|
||||||
rb = ra
|
rb = ra
|
||||||
else:
|
else:
|
||||||
# OOB not implemented yet
|
|
||||||
return
|
return
|
||||||
|
|
||||||
assert self.preq and self.pres
|
assert self.preq and self.pres
|
||||||
@@ -1646,18 +1781,33 @@ class Session:
|
|||||||
self.peer_public_key_y = command.public_key_y
|
self.peer_public_key_y = command.public_key_y
|
||||||
|
|
||||||
# Compute the DH key
|
# Compute the DH key
|
||||||
self.dh_key = bytes(
|
self.dh_key = self.ecc_key.dh(
|
||||||
reversed(
|
command.public_key_x[::-1],
|
||||||
self.manager.ecc_key.dh(
|
command.public_key_y[::-1],
|
||||||
bytes(reversed(command.public_key_x)),
|
)[::-1]
|
||||||
bytes(reversed(command.public_key_y)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.debug(f'DH key: {self.dh_key.hex()}')
|
logger.debug(f'DH key: {self.dh_key.hex()}')
|
||||||
|
|
||||||
|
if self.pairing_method == PairingMethod.OOB:
|
||||||
|
# Check against shared OOB data
|
||||||
|
if self.peer_oob_data:
|
||||||
|
confirm_verifier = crypto.f4(
|
||||||
|
self.peer_public_key_x,
|
||||||
|
self.peer_public_key_x,
|
||||||
|
self.peer_oob_data.r,
|
||||||
|
bytes(1),
|
||||||
|
)
|
||||||
|
if not self.check_expected_value(
|
||||||
|
self.peer_oob_data.c,
|
||||||
|
confirm_verifier,
|
||||||
|
SMP_CONFIRM_VALUE_FAILED_ERROR,
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
if self.is_initiator:
|
if self.is_initiator:
|
||||||
self.send_pairing_confirm_command()
|
if self.pairing_method == PairingMethod.OOB:
|
||||||
|
self.send_pairing_random_command()
|
||||||
|
else:
|
||||||
|
self.send_pairing_confirm_command()
|
||||||
else:
|
else:
|
||||||
if self.pairing_method == PairingMethod.PASSKEY:
|
if self.pairing_method == PairingMethod.PASSKEY:
|
||||||
self.display_or_input_passkey()
|
self.display_or_input_passkey()
|
||||||
@@ -1668,6 +1818,7 @@ class Session:
|
|||||||
if self.pairing_method in (
|
if self.pairing_method in (
|
||||||
PairingMethod.JUST_WORKS,
|
PairingMethod.JUST_WORKS,
|
||||||
PairingMethod.NUMERIC_COMPARISON,
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
PairingMethod.OOB,
|
||||||
):
|
):
|
||||||
# We can now send the confirmation value
|
# We can now send the confirmation value
|
||||||
self.send_pairing_confirm_command()
|
self.send_pairing_confirm_command()
|
||||||
@@ -1696,7 +1847,6 @@ class Session:
|
|||||||
else:
|
else:
|
||||||
self.send_pairing_dhkey_check_command()
|
self.send_pairing_dhkey_check_command()
|
||||||
else:
|
else:
|
||||||
assert self.ltk
|
|
||||||
self.start_encryption(self.ltk)
|
self.start_encryption(self.ltk)
|
||||||
|
|
||||||
def on_smp_pairing_failed_command(
|
def on_smp_pairing_failed_command(
|
||||||
@@ -1746,6 +1896,7 @@ class Manager(EventEmitter):
|
|||||||
sessions: Dict[int, Session]
|
sessions: Dict[int, Session]
|
||||||
pairing_config_factory: Callable[[Connection], PairingConfig]
|
pairing_config_factory: Callable[[Connection], PairingConfig]
|
||||||
session_proxy: Type[Session]
|
session_proxy: Type[Session]
|
||||||
|
_ecc_key: Optional[crypto.EccKey]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -1767,7 +1918,26 @@ class Manager(EventEmitter):
|
|||||||
cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID
|
cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID
|
||||||
connection.send_l2cap_pdu(cid, command.to_bytes())
|
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:
|
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
|
# Look for a session with this connection, and create one if none exists
|
||||||
if not (session := self.sessions.get(connection.handle)):
|
if not (session := self.sessions.get(connection.handle)):
|
||||||
if connection.role == BT_CENTRAL_ROLE:
|
if connection.role == BT_CENTRAL_ROLE:
|
||||||
@@ -1778,13 +1948,6 @@ class Manager(EventEmitter):
|
|||||||
)
|
)
|
||||||
self.sessions[connection.handle] = session
|
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
|
# Delegate the handling of the command to the session
|
||||||
session.on_smp_command(command)
|
session.on_smp_command(command)
|
||||||
|
|
||||||
@@ -1823,20 +1986,14 @@ class Manager(EventEmitter):
|
|||||||
def on_session_start(self, session: Session) -> None:
|
def on_session_start(self, session: Session) -> None:
|
||||||
self.device.on_pairing_start(session.connection)
|
self.device.on_pairing_start(session.connection)
|
||||||
|
|
||||||
def on_pairing(
|
async def on_pairing(
|
||||||
self, session: Session, identity_address: Optional[Address], keys: PairingKeys
|
self, session: Session, identity_address: Optional[Address], keys: PairingKeys
|
||||||
) -> None:
|
) -> None:
|
||||||
# Store the keys in the key store
|
# Store the keys in the key store
|
||||||
if self.device.keystore and identity_address is not None:
|
if self.device.keystore and identity_address is not None:
|
||||||
|
self.device.abort_on(
|
||||||
async def store_keys():
|
'flush', self.device.update_keys(str(identity_address), keys)
|
||||||
try:
|
)
|
||||||
assert self.device.keystore
|
|
||||||
await self.device.keystore.update(str(identity_address), keys)
|
|
||||||
except Exception as error:
|
|
||||||
logger.warning(f'!!! error while storing keys: {error}')
|
|
||||||
|
|
||||||
self.device.abort_on('flush', store_keys())
|
|
||||||
|
|
||||||
# Notify the device
|
# Notify the device
|
||||||
self.device.on_pairing(session.connection, identity_address, keys, session.sc)
|
self.device.on_pairing(session.connection, identity_address, keys, session.sc)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from .common import Transport, AsyncPipeSink, SnoopingTransport
|
from .common import Transport, AsyncPipeSink, SnoopingTransport
|
||||||
from ..controller import Controller
|
|
||||||
from ..snoop import create_snooper
|
from ..snoop import create_snooper
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -69,6 +68,7 @@ async def open_transport(name: str) -> Transport:
|
|||||||
* usb
|
* usb
|
||||||
* pyusb
|
* pyusb
|
||||||
* android-emulator
|
* android-emulator
|
||||||
|
* android-netsim
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return _wrap_transport(await _open_transport(name))
|
return _wrap_transport(await _open_transport(name))
|
||||||
@@ -118,7 +118,8 @@ async def _open_transport(name: str) -> Transport:
|
|||||||
if scheme == 'file':
|
if scheme == 'file':
|
||||||
from .file import open_file_transport
|
from .file import open_file_transport
|
||||||
|
|
||||||
return await open_file_transport(spec[0] if spec else None)
|
assert spec is not None
|
||||||
|
return await open_file_transport(spec[0])
|
||||||
|
|
||||||
if scheme == 'vhci':
|
if scheme == 'vhci':
|
||||||
from .vhci import open_vhci_transport
|
from .vhci import open_vhci_transport
|
||||||
@@ -133,12 +134,14 @@ async def _open_transport(name: str) -> Transport:
|
|||||||
if scheme == 'usb':
|
if scheme == 'usb':
|
||||||
from .usb import open_usb_transport
|
from .usb import open_usb_transport
|
||||||
|
|
||||||
return await open_usb_transport(spec[0] if spec else None)
|
assert spec is not None
|
||||||
|
return await open_usb_transport(spec[0])
|
||||||
|
|
||||||
if scheme == 'pyusb':
|
if scheme == 'pyusb':
|
||||||
from .pyusb import open_pyusb_transport
|
from .pyusb import open_pyusb_transport
|
||||||
|
|
||||||
return await open_pyusb_transport(spec[0] if spec else None)
|
assert spec is not None
|
||||||
|
return await open_pyusb_transport(spec[0])
|
||||||
|
|
||||||
if scheme == 'android-emulator':
|
if scheme == 'android-emulator':
|
||||||
from .android_emulator import open_android_emulator_transport
|
from .android_emulator import open_android_emulator_transport
|
||||||
@@ -167,6 +170,7 @@ async def open_transport_or_link(name: str) -> Transport:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
if name.startswith('link-relay:'):
|
if name.startswith('link-relay:'):
|
||||||
|
from ..controller import Controller
|
||||||
from ..link import RemoteLink # lazy import
|
from ..link import RemoteLink # lazy import
|
||||||
|
|
||||||
link = RemoteLink(name[11:])
|
link = RemoteLink(name[11:])
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
import logging
|
import logging
|
||||||
import grpc.aio
|
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
|
# pylint: disable=no-name-in-module
|
||||||
from .grpc_protobuf.emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
|
from .grpc_protobuf.emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
|
||||||
@@ -33,7 +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.
|
Open a transport connection to an Android emulator via its gRPC interface.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
@@ -66,7 +68,7 @@ async def open_android_emulator_transport(spec):
|
|||||||
# Parse the parameters
|
# Parse the parameters
|
||||||
mode = 'host'
|
mode = 'host'
|
||||||
server_host = 'localhost'
|
server_host = 'localhost'
|
||||||
server_port = 8554
|
server_port = '8554'
|
||||||
if spec is not None:
|
if spec is not None:
|
||||||
params = spec.split(',')
|
params = spec.split(',')
|
||||||
for param in params:
|
for param in params:
|
||||||
@@ -82,6 +84,7 @@ async def open_android_emulator_transport(spec):
|
|||||||
logger.debug(f'connecting to gRPC server at {server_address}')
|
logger.debug(f'connecting to gRPC server at {server_address}')
|
||||||
channel = grpc.aio.insecure_channel(server_address)
|
channel = grpc.aio.insecure_channel(server_address)
|
||||||
|
|
||||||
|
service: Union[EmulatedBluetoothServiceStub, VhciForwardingServiceStub]
|
||||||
if mode == 'host':
|
if mode == 'host':
|
||||||
# Connect as a host
|
# Connect as a host
|
||||||
service = EmulatedBluetoothServiceStub(channel)
|
service = EmulatedBluetoothServiceStub(channel)
|
||||||
@@ -94,10 +97,13 @@ async def open_android_emulator_transport(spec):
|
|||||||
raise ValueError('invalid mode')
|
raise ValueError('invalid mode')
|
||||||
|
|
||||||
# Create the transport object
|
# Create the transport object
|
||||||
transport = PumpedTransport(
|
class EmulatorTransport(PumpedTransport):
|
||||||
PumpedPacketSource(hci_device.read),
|
async def close(self):
|
||||||
PumpedPacketSink(hci_device.write),
|
await super().close()
|
||||||
channel.close,
|
await channel.close()
|
||||||
|
|
||||||
|
transport = EmulatorTransport(
|
||||||
|
PumpedPacketSource(hci_device.read), PumpedPacketSink(hci_device.write)
|
||||||
)
|
)
|
||||||
transport.start()
|
transport.start()
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import atexit
|
import atexit
|
||||||
import logging
|
import logging
|
||||||
import grpc.aio
|
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
from typing import Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import grpc.aio
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
ParserSource,
|
ParserSource,
|
||||||
@@ -33,8 +34,8 @@ from .common import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=no-name-in-module
|
# pylint: disable=no-name-in-module
|
||||||
from .grpc_protobuf.packet_streamer_pb2_grpc import PacketStreamerStub
|
|
||||||
from .grpc_protobuf.packet_streamer_pb2_grpc import (
|
from .grpc_protobuf.packet_streamer_pb2_grpc import (
|
||||||
|
PacketStreamerStub,
|
||||||
PacketStreamerServicer,
|
PacketStreamerServicer,
|
||||||
add_PacketStreamerServicer_to_server,
|
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.startup_pb2 import Chip, ChipInfo
|
||||||
from .grpc_protobuf.common_pb2 import ChipKind
|
from .grpc_protobuf.common_pb2 import ChipKind
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# 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()):
|
if not (ini_dir := get_ini_dir()):
|
||||||
logger.debug('no known directory for .ini file')
|
logger.debug('no known directory for .ini file')
|
||||||
return 0
|
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():
|
if ini_file.is_file():
|
||||||
logger.debug(f'Found .ini file at {ini_file}')
|
|
||||||
with open(ini_file, 'r') as ini_file_data:
|
with open(ini_file, 'r') as ini_file_data:
|
||||||
for line in ini_file_data.readlines():
|
for line in ini_file_data.readlines():
|
||||||
if '=' in line:
|
if '=' in line:
|
||||||
@@ -90,12 +98,14 @@ def find_grpc_port() -> int:
|
|||||||
logger.debug(f'gRPC port = {value}')
|
logger.debug(f'gRPC port = {value}')
|
||||||
return int(value)
|
return int(value)
|
||||||
|
|
||||||
|
logger.debug('no grpc.port property found in .ini file')
|
||||||
|
|
||||||
# Not found
|
# Not found
|
||||||
return 0
|
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()):
|
if not (ini_dir := get_ini_dir()):
|
||||||
logger.debug('no known directory for .ini file')
|
logger.debug('no known directory for .ini file')
|
||||||
return False
|
return False
|
||||||
@@ -104,7 +114,7 @@ def publish_grpc_port(grpc_port) -> bool:
|
|||||||
logger.debug('ini directory does not exist')
|
logger.debug('ini directory does not exist')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
ini_file = ini_dir / 'netsim.ini'
|
ini_file = ini_dir / ini_file_name(instance_number)
|
||||||
try:
|
try:
|
||||||
ini_file.write_text(f'grpc.port={grpc_port}\n')
|
ini_file.write_text(f'grpc.port={grpc_port}\n')
|
||||||
logger.debug(f"published gRPC port at {ini_file}")
|
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:
|
if not server_port:
|
||||||
raise ValueError('invalid port')
|
raise ValueError('invalid port')
|
||||||
if server_host == '_' or not server_host:
|
if server_host == '_' or not server_host:
|
||||||
server_host = 'localhost'
|
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")
|
logger.warning("unable to publish gRPC port")
|
||||||
|
|
||||||
class HciDevice:
|
class HciDevice:
|
||||||
@@ -184,15 +197,12 @@ async def open_android_netsim_controller_transport(server_host, server_port):
|
|||||||
logger.debug(f'<<< PACKET: {data.hex()}')
|
logger.debug(f'<<< PACKET: {data.hex()}')
|
||||||
self.on_data_received(data)
|
self.on_data_received(data)
|
||||||
|
|
||||||
def send_packet(self, data):
|
async def send_packet(self, data):
|
||||||
async def send():
|
return await self.context.write(
|
||||||
await self.context.write(
|
PacketResponse(
|
||||||
PacketResponse(
|
hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
|
||||||
hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
self.loop.create_task(send())
|
|
||||||
|
|
||||||
def terminate(self):
|
def terminate(self):
|
||||||
self.task.cancel()
|
self.task.cancel()
|
||||||
@@ -226,17 +236,17 @@ async def open_android_netsim_controller_transport(server_host, server_port):
|
|||||||
logger.debug('gRPC server cancelled')
|
logger.debug('gRPC server cancelled')
|
||||||
await self.grpc_server.stop(None)
|
await self.grpc_server.stop(None)
|
||||||
|
|
||||||
def on_packet(self, packet):
|
async def send_packet(self, packet):
|
||||||
if not self.device:
|
if not self.device:
|
||||||
logger.debug('no device, dropping packet')
|
logger.debug('no device, dropping packet')
|
||||||
return
|
return
|
||||||
|
|
||||||
self.device.send_packet(packet)
|
return await self.device.send_packet(packet)
|
||||||
|
|
||||||
async def StreamPackets(self, _request_iterator, context):
|
async def StreamPackets(self, _request_iterator, context):
|
||||||
logger.debug('StreamPackets request')
|
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:
|
if self.device:
|
||||||
logger.debug('busy, already serving a device')
|
logger.debug('busy, already serving a device')
|
||||||
return PacketResponse(error='Busy')
|
return PacketResponse(error='Busy')
|
||||||
@@ -259,15 +269,42 @@ async def open_android_netsim_controller_transport(server_host, server_port):
|
|||||||
await server.start()
|
await server.start()
|
||||||
asyncio.get_running_loop().create_task(server.serve())
|
asyncio.get_running_loop().create_task(server.serve())
|
||||||
|
|
||||||
class GrpcServerTransport(Transport):
|
sink = PumpedPacketSink(server.send_packet)
|
||||||
async def close(self):
|
sink.start()
|
||||||
await super().close()
|
return Transport(server, sink)
|
||||||
|
|
||||||
return GrpcServerTransport(server, server)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
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
|
# Wrapper for I/O operations
|
||||||
class HciDevice:
|
class HciDevice:
|
||||||
def __init__(self, name, manufacturer, hci_device):
|
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):
|
async def read(self):
|
||||||
response = await self.hci_device.read()
|
response = await self.hci_device.read()
|
||||||
response_type = response.WhichOneof('response_type')
|
response_type = response.WhichOneof('response_type')
|
||||||
|
|
||||||
if response_type == 'error':
|
if response_type == 'error':
|
||||||
logger.warning(f'received error: {response.error}')
|
logger.warning(f'received error: {response.error}')
|
||||||
raise RuntimeError(response.error)
|
raise RuntimeError(response.error)
|
||||||
elif response_type == 'hci_packet':
|
|
||||||
|
if response_type == 'hci_packet':
|
||||||
return (
|
return (
|
||||||
bytes([response.hci_packet.packet_type])
|
bytes([response.hci_packet.packet_type])
|
||||||
+ response.hci_packet.packet
|
+ 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
|
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
|
# Connect as a host
|
||||||
service = PacketStreamerStub(channel)
|
service = PacketStreamerStub(channel)
|
||||||
hci_device = HciDevice(
|
hci_device = HciDevice(
|
||||||
@@ -332,10 +356,14 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
|
|||||||
await hci_device.start()
|
await hci_device.start()
|
||||||
|
|
||||||
# Create the transport object
|
# 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),
|
PumpedPacketSource(hci_device.read),
|
||||||
PumpedPacketSink(hci_device.write),
|
PumpedPacketSink(hci_device.write),
|
||||||
channel.close,
|
|
||||||
)
|
)
|
||||||
transport.start()
|
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`
|
Open a transport connection as a client or server, implementing Android's `netsim`
|
||||||
simulator protocol over gRPC.
|
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
|
to connect *to* a netsim server (netsim is the controller), or accept
|
||||||
connections *as* a netsim-compatible server.
|
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:
|
In `host` mode:
|
||||||
The <host>:<port> part is optional. When not specified, the transport
|
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`
|
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 []
|
params = spec.split(',') if spec else []
|
||||||
if params and ':' in params[0]:
|
if params and ':' in params[0]:
|
||||||
# Explicit <host>:<port>
|
# Explicit <host>:<port>
|
||||||
host, port = params[0].split(':')
|
host, port_str = params[0].split(':')
|
||||||
|
port = int(port_str)
|
||||||
params_offset = 1
|
params_offset = 1
|
||||||
else:
|
else:
|
||||||
host = None
|
host = None
|
||||||
port = 0
|
port = 0
|
||||||
params_offset = 0
|
params_offset = 0
|
||||||
|
|
||||||
options = {}
|
options: Dict[str, str] = {}
|
||||||
for param in params[params_offset:]:
|
for param in params[params_offset:]:
|
||||||
if '=' not in param:
|
if '=' not in param:
|
||||||
raise ValueError('invalid parameter, expected <name>=<value>')
|
raise ValueError('invalid parameter, expected <name>=<value>')
|
||||||
@@ -401,10 +435,12 @@ async def open_android_netsim_transport(spec):
|
|||||||
|
|
||||||
mode = options.get('mode', 'host')
|
mode = options.get('mode', 'host')
|
||||||
if 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 mode == 'controller':
|
||||||
if host is None:
|
if host is None:
|
||||||
raise ValueError('<host>:<port> missing')
|
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')
|
raise ValueError('invalid mode option')
|
||||||
|
|||||||
@@ -20,11 +20,12 @@ import contextlib
|
|||||||
import struct
|
import struct
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import ContextManager
|
import io
|
||||||
|
from typing import ContextManager, Tuple, Optional, Protocol, Dict
|
||||||
|
|
||||||
from .. import hci
|
from bumble import hci
|
||||||
from ..colors import color
|
from bumble.colors import color
|
||||||
from ..snoop import Snooper
|
from bumble.snoop import Snooper
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -36,7 +37,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Information needed to parse HCI packets with a generic parser:
|
# Information needed to parse HCI packets with a generic parser:
|
||||||
# For each packet type, the info represents:
|
# For each packet type, the info represents:
|
||||||
# (length-size, length-offset, unpack-type)
|
# (length-size, length-offset, unpack-type)
|
||||||
HCI_PACKET_INFO = {
|
HCI_PACKET_INFO: Dict[int, Tuple[int, int, str]] = {
|
||||||
hci.HCI_COMMAND_PACKET: (1, 2, 'B'),
|
hci.HCI_COMMAND_PACKET: (1, 2, 'B'),
|
||||||
hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'),
|
hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'),
|
||||||
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
|
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
|
||||||
@@ -45,33 +46,54 @@ HCI_PACKET_INFO = {
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PacketPump:
|
# Errors
|
||||||
'''
|
# -----------------------------------------------------------------------------
|
||||||
Pump HCI packets from a reader to a sink
|
class TransportLostError(Exception):
|
||||||
'''
|
"""
|
||||||
|
The Transport has been lost/disconnected.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, reader, sink):
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Typing Protocols
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class TransportSink(Protocol):
|
||||||
|
def on_packet(self, packet: bytes) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class TransportSource(Protocol):
|
||||||
|
terminated: asyncio.Future[None]
|
||||||
|
|
||||||
|
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class PacketPump:
|
||||||
|
"""
|
||||||
|
Pump HCI packets from a reader to a sink.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, reader: AsyncPacketReader, sink: TransportSink) -> None:
|
||||||
self.reader = reader
|
self.reader = reader
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
|
|
||||||
async def run(self):
|
async def run(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Get a packet from the source
|
|
||||||
packet = hci.HCI_Packet.from_bytes(await self.reader.next_packet())
|
|
||||||
|
|
||||||
# Deliver the packet to the sink
|
# Deliver the packet to the sink
|
||||||
self.sink.on_packet(packet)
|
self.sink.on_packet(await self.reader.next_packet())
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(f'!!! {error}')
|
logger.warning(f'!!! {error}')
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PacketParser:
|
class PacketParser:
|
||||||
'''
|
"""
|
||||||
In-line parser that accepts data and emits 'on_packet' when a full packet has been
|
In-line parser that accepts data and emits 'on_packet' when a full packet has been
|
||||||
parsed
|
parsed.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
# pylint: disable=attribute-defined-outside-init
|
# pylint: disable=attribute-defined-outside-init
|
||||||
|
|
||||||
@@ -79,18 +101,22 @@ class PacketParser:
|
|||||||
NEED_LENGTH = 1
|
NEED_LENGTH = 1
|
||||||
NEED_BODY = 2
|
NEED_BODY = 2
|
||||||
|
|
||||||
def __init__(self, sink=None):
|
sink: Optional[TransportSink]
|
||||||
|
extended_packet_info: Dict[int, Tuple[int, int, str]]
|
||||||
|
packet_info: Optional[Tuple[int, int, str]] = None
|
||||||
|
|
||||||
|
def __init__(self, sink: Optional[TransportSink] = None) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
self.extended_packet_info = {}
|
self.extended_packet_info = {}
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
def reset(self):
|
def reset(self) -> None:
|
||||||
self.state = PacketParser.NEED_TYPE
|
self.state = PacketParser.NEED_TYPE
|
||||||
self.bytes_needed = 1
|
self.bytes_needed = 1
|
||||||
self.packet = bytearray()
|
self.packet = bytearray()
|
||||||
self.packet_info = None
|
self.packet_info = None
|
||||||
|
|
||||||
def feed_data(self, data):
|
def feed_data(self, data: bytes) -> None:
|
||||||
data_offset = 0
|
data_offset = 0
|
||||||
data_left = len(data)
|
data_left = len(data)
|
||||||
while data_left and self.bytes_needed:
|
while data_left and self.bytes_needed:
|
||||||
@@ -111,6 +137,7 @@ class PacketParser:
|
|||||||
self.state = PacketParser.NEED_LENGTH
|
self.state = PacketParser.NEED_LENGTH
|
||||||
self.bytes_needed = self.packet_info[0] + self.packet_info[1]
|
self.bytes_needed = self.packet_info[0] + self.packet_info[1]
|
||||||
elif self.state == PacketParser.NEED_LENGTH:
|
elif self.state == PacketParser.NEED_LENGTH:
|
||||||
|
assert self.packet_info is not None
|
||||||
body_length = struct.unpack_from(
|
body_length = struct.unpack_from(
|
||||||
self.packet_info[2], self.packet, 1 + self.packet_info[1]
|
self.packet_info[2], self.packet, 1 + self.packet_info[1]
|
||||||
)[0]
|
)[0]
|
||||||
@@ -123,25 +150,25 @@ class PacketParser:
|
|||||||
try:
|
try:
|
||||||
self.sink.on_packet(bytes(self.packet))
|
self.sink.on_packet(bytes(self.packet))
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(
|
logger.exception(
|
||||||
color(f'!!! Exception in on_packet: {error}', 'red')
|
color(f'!!! Exception in on_packet: {error}', 'red')
|
||||||
)
|
)
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
def set_packet_sink(self, sink):
|
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PacketReader:
|
class PacketReader:
|
||||||
'''
|
"""
|
||||||
Reader that reads HCI packets from a sync source
|
Reader that reads HCI packets from a sync source.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
def __init__(self, source):
|
def __init__(self, source: io.BufferedReader) -> None:
|
||||||
self.source = source
|
self.source = source
|
||||||
|
|
||||||
def next_packet(self):
|
def next_packet(self) -> Optional[bytes]:
|
||||||
# Get the packet type
|
# Get the packet type
|
||||||
packet_type = self.source.read(1)
|
packet_type = self.source.read(1)
|
||||||
if len(packet_type) != 1:
|
if len(packet_type) != 1:
|
||||||
@@ -150,7 +177,7 @@ class PacketReader:
|
|||||||
# Get the packet info based on its type
|
# Get the packet info based on its type
|
||||||
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
||||||
if packet_info is None:
|
if packet_info is None:
|
||||||
raise ValueError(f'invalid packet type {packet_type} found')
|
raise ValueError(f'invalid packet type {packet_type[0]} found')
|
||||||
|
|
||||||
# Read the header (that includes the length)
|
# Read the header (that includes the length)
|
||||||
header_size = packet_info[0] + packet_info[1]
|
header_size = packet_info[0] + packet_info[1]
|
||||||
@@ -169,21 +196,21 @@ class PacketReader:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class AsyncPacketReader:
|
class AsyncPacketReader:
|
||||||
'''
|
"""
|
||||||
Reader that reads HCI packets from an async source
|
Reader that reads HCI packets from an async source.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
def __init__(self, source):
|
def __init__(self, source: asyncio.StreamReader) -> None:
|
||||||
self.source = source
|
self.source = source
|
||||||
|
|
||||||
async def next_packet(self):
|
async def next_packet(self) -> bytes:
|
||||||
# Get the packet type
|
# Get the packet type
|
||||||
packet_type = await self.source.readexactly(1)
|
packet_type = await self.source.readexactly(1)
|
||||||
|
|
||||||
# Get the packet info based on its type
|
# Get the packet info based on its type
|
||||||
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
||||||
if packet_info is None:
|
if packet_info is None:
|
||||||
raise ValueError(f'invalid packet type {packet_type} found')
|
raise ValueError(f'invalid packet type {packet_type[0]} found')
|
||||||
|
|
||||||
# Read the header (that includes the length)
|
# Read the header (that includes the length)
|
||||||
header_size = packet_info[0] + packet_info[1]
|
header_size = packet_info[0] + packet_info[1]
|
||||||
@@ -198,15 +225,15 @@ class AsyncPacketReader:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class AsyncPipeSink:
|
class AsyncPipeSink:
|
||||||
'''
|
"""
|
||||||
Sink that forwards packets asynchronously to another sink
|
Sink that forwards packets asynchronously to another sink.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
def __init__(self, sink):
|
def __init__(self, sink: TransportSink) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
self.loop = asyncio.get_running_loop()
|
self.loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.loop.call_soon(self.sink.on_packet, packet)
|
self.loop.call_soon(self.sink.on_packet, packet)
|
||||||
|
|
||||||
|
|
||||||
@@ -216,35 +243,48 @@ class ParserSource:
|
|||||||
Base class designed to be subclassed by transport-specific source classes
|
Base class designed to be subclassed by transport-specific source classes
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
terminated: asyncio.Future[None]
|
||||||
|
parser: PacketParser
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
self.parser = PacketParser()
|
self.parser = PacketParser()
|
||||||
self.terminated = asyncio.get_running_loop().create_future()
|
self.terminated = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
def set_packet_sink(self, sink):
|
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||||
self.parser.set_packet_sink(sink)
|
self.parser.set_packet_sink(sink)
|
||||||
|
|
||||||
async def wait_for_termination(self):
|
def on_transport_lost(self) -> None:
|
||||||
|
self.terminated.set_result(None)
|
||||||
|
if self.parser.sink:
|
||||||
|
if hasattr(self.parser.sink, 'on_transport_lost'):
|
||||||
|
self.parser.sink.on_transport_lost()
|
||||||
|
|
||||||
|
async def wait_for_termination(self) -> None:
|
||||||
|
"""
|
||||||
|
Convenience method for backward compatibility. Prefer using the `terminated`
|
||||||
|
attribute instead.
|
||||||
|
"""
|
||||||
return await self.terminated
|
return await self.terminated
|
||||||
|
|
||||||
def close(self):
|
def close(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class StreamPacketSource(asyncio.Protocol, ParserSource):
|
class StreamPacketSource(asyncio.Protocol, ParserSource):
|
||||||
def data_received(self, data):
|
def data_received(self, data: bytes) -> None:
|
||||||
self.parser.feed_data(data)
|
self.parser.feed_data(data)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class StreamPacketSink:
|
class StreamPacketSink:
|
||||||
def __init__(self, transport):
|
def __init__(self, transport: asyncio.WriteTransport) -> None:
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.transport.write(packet)
|
self.transport.write(packet)
|
||||||
|
|
||||||
def close(self):
|
def close(self) -> None:
|
||||||
self.transport.close()
|
self.transport.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -264,7 +304,7 @@ class Transport:
|
|||||||
...
|
...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, source, sink):
|
def __init__(self, source: TransportSource, sink: TransportSink) -> None:
|
||||||
self.source = source
|
self.source = source
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
|
|
||||||
@@ -278,34 +318,39 @@ class Transport:
|
|||||||
return iter((self.source, self.sink))
|
return iter((self.source, self.sink))
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
self.source.close()
|
if hasattr(self.source, 'close'):
|
||||||
self.sink.close()
|
self.source.close()
|
||||||
|
if hasattr(self.sink, 'close'):
|
||||||
|
self.sink.close()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PumpedPacketSource(ParserSource):
|
class PumpedPacketSource(ParserSource):
|
||||||
def __init__(self, receive):
|
pump_task: Optional[asyncio.Task[None]]
|
||||||
|
|
||||||
|
def __init__(self, receive) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.receive_function = receive
|
self.receive_function = receive
|
||||||
self.pump_task = None
|
self.pump_task = None
|
||||||
|
|
||||||
def start(self):
|
def start(self) -> None:
|
||||||
async def pump_packets():
|
async def pump_packets() -> None:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
packet = await self.receive_function()
|
packet = await self.receive_function()
|
||||||
self.parser.feed_data(packet)
|
self.parser.feed_data(packet)
|
||||||
except asyncio.exceptions.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.debug('source pump task done')
|
logger.debug('source pump task done')
|
||||||
|
self.terminated.set_result(None)
|
||||||
break
|
break
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(f'exception while waiting for packet: {error}')
|
logger.warning(f'exception while waiting for packet: {error}')
|
||||||
self.terminated.set_result(error)
|
self.terminated.set_exception(error)
|
||||||
break
|
break
|
||||||
|
|
||||||
self.pump_task = asyncio.create_task(pump_packets())
|
self.pump_task = asyncio.create_task(pump_packets())
|
||||||
|
|
||||||
def close(self):
|
def close(self) -> None:
|
||||||
if self.pump_task:
|
if self.pump_task:
|
||||||
self.pump_task.cancel()
|
self.pump_task.cancel()
|
||||||
|
|
||||||
@@ -317,7 +362,7 @@ class PumpedPacketSink:
|
|||||||
self.packet_queue = asyncio.Queue()
|
self.packet_queue = asyncio.Queue()
|
||||||
self.pump_task = None
|
self.pump_task = None
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.packet_queue.put_nowait(packet)
|
self.packet_queue.put_nowait(packet)
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
@@ -326,7 +371,7 @@ class PumpedPacketSink:
|
|||||||
try:
|
try:
|
||||||
packet = await self.packet_queue.get()
|
packet = await self.packet_queue.get()
|
||||||
await self.send_function(packet)
|
await self.send_function(packet)
|
||||||
except asyncio.exceptions.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.debug('sink pump task done')
|
logger.debug('sink pump task done')
|
||||||
break
|
break
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
@@ -342,18 +387,20 @@ class PumpedPacketSink:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PumpedTransport(Transport):
|
class PumpedTransport(Transport):
|
||||||
def __init__(self, source, sink, close_function):
|
source: PumpedPacketSource
|
||||||
super().__init__(source, sink)
|
sink: PumpedPacketSink
|
||||||
self.close_function = close_function
|
|
||||||
|
|
||||||
def start(self):
|
def __init__(
|
||||||
|
self,
|
||||||
|
source: PumpedPacketSource,
|
||||||
|
sink: PumpedPacketSink,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(source, sink)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
self.source.start()
|
self.source.start()
|
||||||
self.sink.start()
|
self.sink.start()
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
await super().close()
|
|
||||||
await self.close_function()
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class SnoopingTransport(Transport):
|
class SnoopingTransport(Transport):
|
||||||
@@ -375,31 +422,38 @@ class SnoopingTransport(Transport):
|
|||||||
raise RuntimeError('unexpected code path') # Satisfy the type checker
|
raise RuntimeError('unexpected code path') # Satisfy the type checker
|
||||||
|
|
||||||
class Source:
|
class Source:
|
||||||
def __init__(self, source, snooper):
|
sink: TransportSink
|
||||||
|
|
||||||
|
def __init__(self, source: TransportSource, snooper: Snooper):
|
||||||
self.source = source
|
self.source = source
|
||||||
self.snooper = snooper
|
self.snooper = snooper
|
||||||
self.sink = None
|
self.terminated = source.terminated
|
||||||
|
|
||||||
def set_packet_sink(self, sink):
|
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
self.source.set_packet_sink(self)
|
self.source.set_packet_sink(self)
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.snooper.snoop(packet, Snooper.Direction.CONTROLLER_TO_HOST)
|
self.snooper.snoop(packet, Snooper.Direction.CONTROLLER_TO_HOST)
|
||||||
if self.sink:
|
if self.sink:
|
||||||
self.sink.on_packet(packet)
|
self.sink.on_packet(packet)
|
||||||
|
|
||||||
class Sink:
|
class Sink:
|
||||||
def __init__(self, sink, snooper):
|
def __init__(self, sink: TransportSink, snooper: Snooper) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
self.snooper = snooper
|
self.snooper = snooper
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.snooper.snoop(packet, Snooper.Direction.HOST_TO_CONTROLLER)
|
self.snooper.snoop(packet, Snooper.Direction.HOST_TO_CONTROLLER)
|
||||||
if self.sink:
|
if self.sink:
|
||||||
self.sink.on_packet(packet)
|
self.sink.on_packet(packet)
|
||||||
|
|
||||||
def __init__(self, transport, snooper, close_snooper=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
transport: Transport,
|
||||||
|
snooper: Snooper,
|
||||||
|
close_snooper=None,
|
||||||
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
self.Source(transport.source, snooper), self.Sink(transport.sink, snooper)
|
self.Source(transport.source, snooper), self.Sink(transport.sink, snooper)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_file_transport(spec):
|
async def open_file_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a File transport (typically not for a real file, but for a PTY or other unix
|
Open a File transport (typically not for a real file, but for a PTY or other unix
|
||||||
virtual files).
|
virtual files).
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import socket
|
|||||||
import ctypes
|
import ctypes
|
||||||
import collections
|
import collections
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from .common import Transport, ParserSource
|
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).
|
Open an HCI Socket (only available on some platforms).
|
||||||
The parameter string is either empty (to use the first/default Bluetooth adapter)
|
The parameter string is either empty (to use the first/default Bluetooth adapter)
|
||||||
@@ -45,9 +47,9 @@ async def open_hci_socket_transport(spec):
|
|||||||
# Create a raw HCI socket
|
# Create a raw HCI socket
|
||||||
try:
|
try:
|
||||||
hci_socket = socket.socket(
|
hci_socket = socket.socket(
|
||||||
socket.AF_BLUETOOTH,
|
socket.AF_BLUETOOTH, # type: ignore[attr-defined]
|
||||||
socket.SOCK_RAW | socket.SOCK_NONBLOCK,
|
socket.SOCK_RAW | socket.SOCK_NONBLOCK, # type: ignore[attr-defined]
|
||||||
socket.BTPROTO_HCI,
|
socket.BTPROTO_HCI, # type: ignore[attr-defined]
|
||||||
)
|
)
|
||||||
except AttributeError as error:
|
except AttributeError as error:
|
||||||
# Not supported on this platform
|
# Not supported on this platform
|
||||||
@@ -78,7 +80,7 @@ async def open_hci_socket_transport(spec):
|
|||||||
bind_address = struct.pack(
|
bind_address = struct.pack(
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
'<HHH',
|
'<HHH',
|
||||||
socket.AF_BLUETOOTH,
|
socket.AF_BLUETOOTH, # type: ignore[attr-defined]
|
||||||
adapter_index,
|
adapter_index,
|
||||||
HCI_CHANNEL_USER,
|
HCI_CHANNEL_USER,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import atexit
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from .common import Transport, StreamPacketSource, StreamPacketSink
|
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.
|
Open a PTY transport.
|
||||||
The parameter string may be empty, or a path name where a symbolic link
|
The parameter string may be empty, or a path name where a symbolic link
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_pyusb_transport(spec):
|
async def open_pyusb_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a USB transport. [Implementation based on PyUSB]
|
Open a USB transport. [Implementation based on PyUSB]
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_serial_transport(spec):
|
async def open_serial_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a serial port transport.
|
Open a serial port transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_tcp_client_transport(spec):
|
async def open_tcp_client_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a TCP client transport.
|
Open a TCP client transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
@@ -39,7 +39,7 @@ async def open_tcp_client_transport(spec):
|
|||||||
class TcpPacketSource(StreamPacketSource):
|
class TcpPacketSource(StreamPacketSource):
|
||||||
def connection_lost(self, exc):
|
def connection_lost(self, exc):
|
||||||
logger.debug(f'connection lost: {exc}')
|
logger.debug(f'connection lost: {exc}')
|
||||||
self.terminated.set_result(exc)
|
self.on_transport_lost()
|
||||||
|
|
||||||
remote_host, remote_port = spec.split(':')
|
remote_host, remote_port = spec.split(':')
|
||||||
tcp_transport, packet_source = await asyncio.get_running_loop().create_connection(
|
tcp_transport, packet_source = await asyncio.get_running_loop().create_connection(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_tcp_server_transport(spec):
|
async def open_tcp_server_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a TCP server transport.
|
Open a TCP server transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
@@ -42,7 +43,7 @@ async def open_tcp_server_transport(spec):
|
|||||||
async def close(self):
|
async def close(self):
|
||||||
await super().close()
|
await super().close()
|
||||||
|
|
||||||
class TcpServerProtocol:
|
class TcpServerProtocol(asyncio.BaseProtocol):
|
||||||
def __init__(self, packet_source, packet_sink):
|
def __init__(self, packet_source, packet_sink):
|
||||||
self.packet_source = packet_source
|
self.packet_source = packet_source
|
||||||
self.packet_sink = packet_sink
|
self.packet_sink = packet_sink
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_udp_transport(spec):
|
async def open_udp_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a UDP transport.
|
Open a UDP transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
|
|||||||
@@ -24,9 +24,10 @@ import platform
|
|||||||
|
|
||||||
import usb1
|
import usb1
|
||||||
|
|
||||||
from .common import Transport, ParserSource
|
from bumble.transport.common import Transport, ParserSource
|
||||||
from .. import hci
|
from bumble import hci
|
||||||
from ..colors import color
|
from bumble.colors import color
|
||||||
|
from bumble.utils import AsyncRunner
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -60,7 +61,7 @@ def load_libusb():
|
|||||||
usb1.loadLibrary(libusb_dll)
|
usb1.loadLibrary(libusb_dll)
|
||||||
|
|
||||||
|
|
||||||
async def open_usb_transport(spec):
|
async def open_usb_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a USB transport.
|
Open a USB transport.
|
||||||
The moniker string has this syntax:
|
The moniker string has this syntax:
|
||||||
@@ -113,7 +114,7 @@ async def open_usb_transport(spec):
|
|||||||
def __init__(self, device, acl_out):
|
def __init__(self, device, acl_out):
|
||||||
self.device = device
|
self.device = device
|
||||||
self.acl_out = acl_out
|
self.acl_out = acl_out
|
||||||
self.transfer = device.getTransfer()
|
self.acl_out_transfer = device.getTransfer()
|
||||||
self.packets = collections.deque() # Queue of packets waiting to be sent
|
self.packets = collections.deque() # Queue of packets waiting to be sent
|
||||||
self.loop = asyncio.get_running_loop()
|
self.loop = asyncio.get_running_loop()
|
||||||
self.cancel_done = self.loop.create_future()
|
self.cancel_done = self.loop.create_future()
|
||||||
@@ -137,21 +138,20 @@ async def open_usb_transport(spec):
|
|||||||
# The queue was previously empty, re-prime the pump
|
# The queue was previously empty, re-prime the pump
|
||||||
self.process_queue()
|
self.process_queue()
|
||||||
|
|
||||||
def on_packet_sent(self, transfer):
|
def transfer_callback(self, transfer):
|
||||||
status = transfer.getStatus()
|
status = transfer.getStatus()
|
||||||
# logger.debug(f'<<< USB out transfer callback: status={status}')
|
|
||||||
|
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
if status == usb1.TRANSFER_COMPLETED:
|
if status == usb1.TRANSFER_COMPLETED:
|
||||||
self.loop.call_soon_threadsafe(self.on_packet_sent_)
|
self.loop.call_soon_threadsafe(self.on_packet_sent)
|
||||||
elif status == usb1.TRANSFER_CANCELLED:
|
elif status == usb1.TRANSFER_CANCELLED:
|
||||||
self.loop.call_soon_threadsafe(self.cancel_done.set_result, None)
|
self.loop.call_soon_threadsafe(self.cancel_done.set_result, None)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
color(f'!!! out transfer not completed: status={status}', 'red')
|
color(f'!!! OUT transfer not completed: status={status}', 'red')
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_packet_sent_(self):
|
def on_packet_sent(self):
|
||||||
if self.packets:
|
if self.packets:
|
||||||
self.packets.popleft()
|
self.packets.popleft()
|
||||||
self.process_queue()
|
self.process_queue()
|
||||||
@@ -163,22 +163,20 @@ async def open_usb_transport(spec):
|
|||||||
packet = self.packets[0]
|
packet = self.packets[0]
|
||||||
packet_type = packet[0]
|
packet_type = packet[0]
|
||||||
if packet_type == hci.HCI_ACL_DATA_PACKET:
|
if packet_type == hci.HCI_ACL_DATA_PACKET:
|
||||||
self.transfer.setBulk(
|
self.acl_out_transfer.setBulk(
|
||||||
self.acl_out, packet[1:], callback=self.on_packet_sent
|
self.acl_out, packet[1:], callback=self.transfer_callback
|
||||||
)
|
)
|
||||||
logger.debug('submit ACL')
|
self.acl_out_transfer.submit()
|
||||||
self.transfer.submit()
|
|
||||||
elif packet_type == hci.HCI_COMMAND_PACKET:
|
elif packet_type == hci.HCI_COMMAND_PACKET:
|
||||||
self.transfer.setControl(
|
self.acl_out_transfer.setControl(
|
||||||
USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS,
|
USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
packet[1:],
|
packet[1:],
|
||||||
callback=self.on_packet_sent,
|
callback=self.transfer_callback,
|
||||||
)
|
)
|
||||||
logger.debug('submit COMMAND')
|
self.acl_out_transfer.submit()
|
||||||
self.transfer.submit()
|
|
||||||
else:
|
else:
|
||||||
logger.warning(color(f'unsupported packet type {packet_type}', 'red'))
|
logger.warning(color(f'unsupported packet type {packet_type}', 'red'))
|
||||||
|
|
||||||
@@ -193,11 +191,11 @@ async def open_usb_transport(spec):
|
|||||||
self.packets.clear()
|
self.packets.clear()
|
||||||
|
|
||||||
# If we have a transfer in flight, cancel it
|
# If we have a transfer in flight, cancel it
|
||||||
if self.transfer.isSubmitted():
|
if self.acl_out_transfer.isSubmitted():
|
||||||
# Try to cancel the transfer, but that may fail because it may have
|
# Try to cancel the transfer, but that may fail because it may have
|
||||||
# already completed
|
# already completed
|
||||||
try:
|
try:
|
||||||
self.transfer.cancel()
|
self.acl_out_transfer.cancel()
|
||||||
|
|
||||||
logger.debug('waiting for OUT transfer cancellation to be done...')
|
logger.debug('waiting for OUT transfer cancellation to be done...')
|
||||||
await self.cancel_done
|
await self.cancel_done
|
||||||
@@ -206,27 +204,22 @@ async def open_usb_transport(spec):
|
|||||||
logger.debug('OUT transfer likely already completed')
|
logger.debug('OUT transfer likely already completed')
|
||||||
|
|
||||||
class UsbPacketSource(asyncio.Protocol, ParserSource):
|
class UsbPacketSource(asyncio.Protocol, ParserSource):
|
||||||
def __init__(self, context, device, metadata, acl_in, events_in):
|
def __init__(self, device, metadata, acl_in, events_in):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.context = context
|
|
||||||
self.device = device
|
self.device = device
|
||||||
self.metadata = metadata
|
self.metadata = metadata
|
||||||
self.acl_in = acl_in
|
self.acl_in = acl_in
|
||||||
|
self.acl_in_transfer = None
|
||||||
self.events_in = events_in
|
self.events_in = events_in
|
||||||
|
self.events_in_transfer = None
|
||||||
self.loop = asyncio.get_running_loop()
|
self.loop = asyncio.get_running_loop()
|
||||||
self.queue = asyncio.Queue()
|
self.queue = asyncio.Queue()
|
||||||
self.dequeue_task = None
|
self.dequeue_task = None
|
||||||
self.closed = False
|
|
||||||
self.event_loop_done = self.loop.create_future()
|
|
||||||
self.cancel_done = {
|
self.cancel_done = {
|
||||||
hci.HCI_EVENT_PACKET: self.loop.create_future(),
|
hci.HCI_EVENT_PACKET: self.loop.create_future(),
|
||||||
hci.HCI_ACL_DATA_PACKET: self.loop.create_future(),
|
hci.HCI_ACL_DATA_PACKET: self.loop.create_future(),
|
||||||
}
|
}
|
||||||
self.events_in_transfer = None
|
self.closed = False
|
||||||
self.acl_in_transfer = None
|
|
||||||
|
|
||||||
# Create a thread to process events
|
|
||||||
self.event_thread = threading.Thread(target=self.run)
|
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
# Set up transfer objects for input
|
# Set up transfer objects for input
|
||||||
@@ -234,7 +227,7 @@ async def open_usb_transport(spec):
|
|||||||
self.events_in_transfer.setInterrupt(
|
self.events_in_transfer.setInterrupt(
|
||||||
self.events_in,
|
self.events_in,
|
||||||
READ_SIZE,
|
READ_SIZE,
|
||||||
callback=self.on_packet_received,
|
callback=self.transfer_callback,
|
||||||
user_data=hci.HCI_EVENT_PACKET,
|
user_data=hci.HCI_EVENT_PACKET,
|
||||||
)
|
)
|
||||||
self.events_in_transfer.submit()
|
self.events_in_transfer.submit()
|
||||||
@@ -243,22 +236,23 @@ async def open_usb_transport(spec):
|
|||||||
self.acl_in_transfer.setBulk(
|
self.acl_in_transfer.setBulk(
|
||||||
self.acl_in,
|
self.acl_in,
|
||||||
READ_SIZE,
|
READ_SIZE,
|
||||||
callback=self.on_packet_received,
|
callback=self.transfer_callback,
|
||||||
user_data=hci.HCI_ACL_DATA_PACKET,
|
user_data=hci.HCI_ACL_DATA_PACKET,
|
||||||
)
|
)
|
||||||
self.acl_in_transfer.submit()
|
self.acl_in_transfer.submit()
|
||||||
|
|
||||||
self.dequeue_task = self.loop.create_task(self.dequeue())
|
self.dequeue_task = self.loop.create_task(self.dequeue())
|
||||||
self.event_thread.start()
|
|
||||||
|
|
||||||
def on_packet_received(self, transfer):
|
@property
|
||||||
|
def usb_transfer_submitted(self):
|
||||||
|
return (
|
||||||
|
self.events_in_transfer.isSubmitted()
|
||||||
|
or self.acl_in_transfer.isSubmitted()
|
||||||
|
)
|
||||||
|
|
||||||
|
def transfer_callback(self, transfer):
|
||||||
packet_type = transfer.getUserData()
|
packet_type = transfer.getUserData()
|
||||||
status = transfer.getStatus()
|
status = transfer.getStatus()
|
||||||
# logger.debug(
|
|
||||||
# f'<<< USB IN transfer callback: status={status} '
|
|
||||||
# f'packet_type={packet_type} '
|
|
||||||
# f'length={transfer.getActualLength()}'
|
|
||||||
# )
|
|
||||||
|
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
if status == usb1.TRANSFER_COMPLETED:
|
if status == usb1.TRANSFER_COMPLETED:
|
||||||
@@ -267,18 +261,18 @@ async def open_usb_transport(spec):
|
|||||||
+ transfer.getBuffer()[: transfer.getActualLength()]
|
+ transfer.getBuffer()[: transfer.getActualLength()]
|
||||||
)
|
)
|
||||||
self.loop.call_soon_threadsafe(self.queue.put_nowait, packet)
|
self.loop.call_soon_threadsafe(self.queue.put_nowait, packet)
|
||||||
|
|
||||||
|
# Re-submit the transfer so we can receive more data
|
||||||
|
transfer.submit()
|
||||||
elif status == usb1.TRANSFER_CANCELLED:
|
elif status == usb1.TRANSFER_CANCELLED:
|
||||||
self.loop.call_soon_threadsafe(
|
self.loop.call_soon_threadsafe(
|
||||||
self.cancel_done[packet_type].set_result, None
|
self.cancel_done[packet_type].set_result, None
|
||||||
)
|
)
|
||||||
return
|
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
color(f'!!! transfer not completed: status={status}', 'red')
|
color(f'!!! IN transfer not completed: status={status}', 'red')
|
||||||
)
|
)
|
||||||
|
self.loop.call_soon_threadsafe(self.on_transport_lost)
|
||||||
# Re-submit the transfer so we can receive more data
|
|
||||||
transfer.submit()
|
|
||||||
|
|
||||||
async def dequeue(self):
|
async def dequeue(self):
|
||||||
while not self.closed:
|
while not self.closed:
|
||||||
@@ -288,21 +282,6 @@ async def open_usb_transport(spec):
|
|||||||
return
|
return
|
||||||
self.parser.feed_data(packet)
|
self.parser.feed_data(packet)
|
||||||
|
|
||||||
def run(self):
|
|
||||||
logger.debug('starting USB event loop')
|
|
||||||
while (
|
|
||||||
self.events_in_transfer.isSubmitted()
|
|
||||||
or self.acl_in_transfer.isSubmitted()
|
|
||||||
):
|
|
||||||
# pylint: disable=no-member
|
|
||||||
try:
|
|
||||||
self.context.handleEvents()
|
|
||||||
except usb1.USBErrorInterrupted:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger.debug('USB event loop done')
|
|
||||||
self.loop.call_soon_threadsafe(self.event_loop_done.set_result, None)
|
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.closed = True
|
self.closed = True
|
||||||
|
|
||||||
@@ -331,15 +310,14 @@ async def open_usb_transport(spec):
|
|||||||
f'IN[{packet_type}] transfer likely already completed'
|
f'IN[{packet_type}] transfer likely already completed'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wait for the thread to terminate
|
|
||||||
await self.event_loop_done
|
|
||||||
|
|
||||||
class UsbTransport(Transport):
|
class UsbTransport(Transport):
|
||||||
def __init__(self, context, device, interface, setting, source, sink):
|
def __init__(self, context, device, interface, setting, source, sink):
|
||||||
super().__init__(source, sink)
|
super().__init__(source, sink)
|
||||||
self.context = context
|
self.context = context
|
||||||
self.device = device
|
self.device = device
|
||||||
self.interface = interface
|
self.interface = interface
|
||||||
|
self.loop = asyncio.get_running_loop()
|
||||||
|
self.event_loop_done = self.loop.create_future()
|
||||||
|
|
||||||
# Get exclusive access
|
# Get exclusive access
|
||||||
device.claimInterface(interface)
|
device.claimInterface(interface)
|
||||||
@@ -352,6 +330,22 @@ async def open_usb_transport(spec):
|
|||||||
source.start()
|
source.start()
|
||||||
sink.start()
|
sink.start()
|
||||||
|
|
||||||
|
# Create a thread to process events
|
||||||
|
self.event_thread = threading.Thread(target=self.run)
|
||||||
|
self.event_thread.start()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
logger.debug('starting USB event loop')
|
||||||
|
while self.source.usb_transfer_submitted:
|
||||||
|
# pylint: disable=no-member
|
||||||
|
try:
|
||||||
|
self.context.handleEvents()
|
||||||
|
except usb1.USBErrorInterrupted:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.debug('USB event loop done')
|
||||||
|
self.loop.call_soon_threadsafe(self.event_loop_done.set_result, None)
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
self.source.close()
|
self.source.close()
|
||||||
self.sink.close()
|
self.sink.close()
|
||||||
@@ -361,6 +355,9 @@ async def open_usb_transport(spec):
|
|||||||
self.device.close()
|
self.device.close()
|
||||||
self.context.close()
|
self.context.close()
|
||||||
|
|
||||||
|
# Wait for the thread to terminate
|
||||||
|
await self.event_loop_done
|
||||||
|
|
||||||
# Find the device according to the spec moniker
|
# Find the device according to the spec moniker
|
||||||
load_libusb()
|
load_libusb()
|
||||||
context = usb1.USBContext()
|
context = usb1.USBContext()
|
||||||
@@ -540,7 +537,7 @@ async def open_usb_transport(spec):
|
|||||||
except usb1.USBError:
|
except usb1.USBError:
|
||||||
logger.warning('failed to set configuration')
|
logger.warning('failed to set configuration')
|
||||||
|
|
||||||
source = UsbPacketSource(context, device, device_metadata, acl_in, events_in)
|
source = UsbPacketSource(device, device_metadata, acl_in, events_in)
|
||||||
sink = UsbPacketSink(device, acl_out)
|
sink = UsbPacketSink(device, acl_out)
|
||||||
return UsbTransport(context, device, interface, setting, source, sink)
|
return UsbTransport(context, device, interface, setting, source, sink)
|
||||||
except usb1.USBError as error:
|
except usb1.USBError as error:
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .common import Transport
|
||||||
from .file import open_file_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).
|
Open a VHCI transport (only available on some platforms).
|
||||||
The parameter string is either empty (to use the default VHCI device
|
The parameter string is either empty (to use the default VHCI device
|
||||||
@@ -42,15 +45,15 @@ async def open_vhci_transport(spec):
|
|||||||
# Override the source's `data_received` method so that we can
|
# Override the source's `data_received` method so that we can
|
||||||
# filter out the vendor packet that is received just after the
|
# filter out the vendor packet that is received just after the
|
||||||
# initial open
|
# initial open
|
||||||
def vhci_data_received(data):
|
def vhci_data_received(data: bytes) -> None:
|
||||||
if len(data) > 0 and data[0] == HCI_VENDOR_PKT:
|
if len(data) > 0 and data[0] == HCI_VENDOR_PKT:
|
||||||
if len(data) == 4:
|
if len(data) == 4:
|
||||||
hci_index = data[2] << 8 | data[3]
|
hci_index = data[2] << 8 | data[3]
|
||||||
logger.info(f'HCI index {hci_index}')
|
logger.info(f'HCI index {hci_index}')
|
||||||
else:
|
else:
|
||||||
transport.source.parser.feed_data(data)
|
transport.source.parser.feed_data(data) # type: ignore
|
||||||
|
|
||||||
transport.source.data_received = vhci_data_received
|
transport.source.data_received = vhci_data_received # type: ignore
|
||||||
|
|
||||||
# Write the initial config
|
# Write the initial config
|
||||||
transport.sink.on_packet(bytes([HCI_VENDOR_PKT, HCI_BREDR]))
|
transport.sink.on_packet(bytes([HCI_VENDOR_PKT, HCI_BREDR]))
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
import websockets
|
import websockets.client
|
||||||
|
|
||||||
from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport
|
from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport, Transport
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -27,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.
|
Open a WebSocket client transport.
|
||||||
The parameter string has this syntax:
|
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(':')
|
websocket = await websockets.client.connect(spec)
|
||||||
uri = f'ws://{remote_host}:{remote_port}'
|
|
||||||
websocket = await websockets.connect(uri)
|
|
||||||
|
|
||||||
transport = PumpedTransport(
|
class WsTransport(PumpedTransport):
|
||||||
|
async def close(self):
|
||||||
|
await super().close()
|
||||||
|
await websocket.close()
|
||||||
|
|
||||||
|
transport = WsTransport(
|
||||||
PumpedPacketSource(websocket.recv),
|
PumpedPacketSource(websocket.recv),
|
||||||
PumpedPacketSink(websocket.send),
|
PumpedPacketSink(websocket.send),
|
||||||
websocket.close,
|
|
||||||
)
|
)
|
||||||
transport.start()
|
transport.start()
|
||||||
return transport
|
return transport
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_ws_server_transport(spec):
|
async def open_ws_server_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a WebSocket server transport.
|
Open a WebSocket server transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
@@ -43,7 +42,7 @@ async def open_ws_server_transport(spec):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
source = ParserSource()
|
source = ParserSource()
|
||||||
sink = PumpedPacketSink(self.send_packet)
|
sink = PumpedPacketSink(self.send_packet)
|
||||||
self.connection = asyncio.get_running_loop().create_future()
|
self.connection = None
|
||||||
self.server = None
|
self.server = None
|
||||||
|
|
||||||
super().__init__(source, sink)
|
super().__init__(source, sink)
|
||||||
@@ -63,7 +62,7 @@ async def open_ws_server_transport(spec):
|
|||||||
f'new connection on {connection.local_address} '
|
f'new connection on {connection.local_address} '
|
||||||
f'from {connection.remote_address}'
|
f'from {connection.remote_address}'
|
||||||
)
|
)
|
||||||
self.connection.set_result(connection)
|
self.connection = connection
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
try:
|
try:
|
||||||
async for packet in connection:
|
async for packet in connection:
|
||||||
@@ -74,12 +73,14 @@ async def open_ws_server_transport(spec):
|
|||||||
except websockets.WebSocketException as error:
|
except websockets.WebSocketException as error:
|
||||||
logger.debug(f'exception while receiving packet: {error}')
|
logger.debug(f'exception while receiving packet: {error}')
|
||||||
|
|
||||||
# Wait for a new connection
|
# We're now disconnected
|
||||||
self.connection = asyncio.get_running_loop().create_future()
|
self.connection = None
|
||||||
|
|
||||||
async def send_packet(self, packet):
|
async def send_packet(self, packet):
|
||||||
connection = await self.connection
|
if self.connection is None:
|
||||||
return await connection.send(packet)
|
logger.debug('no connection, dropping packet')
|
||||||
|
return
|
||||||
|
return await self.connection.send(packet)
|
||||||
|
|
||||||
local_host, local_port = spec.split(':')
|
local_host, local_port = spec.split(':')
|
||||||
transport = WsServerTransport()
|
transport = WsServerTransport()
|
||||||
|
|||||||
162
bumble/utils.py
162
bumble/utils.py
@@ -15,13 +15,26 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
import collections
|
import collections
|
||||||
import sys
|
import sys
|
||||||
from typing import Awaitable, Set, TypeVar
|
import warnings
|
||||||
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 pyee import EventEmitter
|
||||||
|
|
||||||
from .colors import color
|
from .colors import color
|
||||||
@@ -64,6 +77,102 @@ def composite_listener(cls):
|
|||||||
return 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')
|
_T = TypeVar('_T')
|
||||||
|
|
||||||
@@ -302,3 +411,52 @@ class FlowControlAsyncPipe:
|
|||||||
self.resume_source()
|
self.resume_source()
|
||||||
|
|
||||||
self.check_pump()
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def deprecated(msg: str):
|
||||||
|
"""
|
||||||
|
Throw deprecation warning before execution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def wrapper(function):
|
||||||
|
@wraps(function)
|
||||||
|
def inner(*args, **kwargs):
|
||||||
|
warnings.warn(msg, DeprecationWarning)
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def experimental(msg: str):
|
||||||
|
"""
|
||||||
|
Throws a future warning before execution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def wrapper(function):
|
||||||
|
@wraps(function)
|
||||||
|
def inner(*args, **kwargs):
|
||||||
|
warnings.warn(msg, FutureWarning)
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|||||||
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.
|
||||||
|
'''
|
||||||
@@ -10,7 +10,7 @@ nav:
|
|||||||
- Contributing: development/contributing.md
|
- Contributing: development/contributing.md
|
||||||
- Code Style: development/code_style.md
|
- Code Style: development/code_style.md
|
||||||
- Use Cases:
|
- Use Cases:
|
||||||
- Overview: use_cases/index.md
|
- use_cases/index.md
|
||||||
- Use Case 1: use_cases/use_case_1.md
|
- Use Case 1: use_cases/use_case_1.md
|
||||||
- Use Case 2: use_cases/use_case_2.md
|
- Use Case 2: use_cases/use_case_2.md
|
||||||
- Use Case 3: use_cases/use_case_3.md
|
- Use Case 3: use_cases/use_case_3.md
|
||||||
@@ -23,7 +23,7 @@ nav:
|
|||||||
- GATT: components/gatt.md
|
- GATT: components/gatt.md
|
||||||
- Security Manager: components/security_manager.md
|
- Security Manager: components/security_manager.md
|
||||||
- Transports:
|
- Transports:
|
||||||
- Overview: transports/index.md
|
- transports/index.md
|
||||||
- Serial: transports/serial.md
|
- Serial: transports/serial.md
|
||||||
- USB: transports/usb.md
|
- USB: transports/usb.md
|
||||||
- PTY: transports/pty.md
|
- PTY: transports/pty.md
|
||||||
@@ -37,14 +37,14 @@ nav:
|
|||||||
- Android Emulator: transports/android_emulator.md
|
- Android Emulator: transports/android_emulator.md
|
||||||
- File: transports/file.md
|
- File: transports/file.md
|
||||||
- Drivers:
|
- Drivers:
|
||||||
- Overview: drivers/index.md
|
- drivers/index.md
|
||||||
- Realtek: drivers/realtek.md
|
- Realtek: drivers/realtek.md
|
||||||
- API:
|
- API:
|
||||||
- Guide: api/guide.md
|
- Guide: api/guide.md
|
||||||
- Examples: api/examples.md
|
- Examples: api/examples.md
|
||||||
- Reference: api/reference.md
|
- Reference: api/reference.md
|
||||||
- Apps & Tools:
|
- Apps & Tools:
|
||||||
- Overview: apps_and_tools/index.md
|
- apps_and_tools/index.md
|
||||||
- Console: apps_and_tools/console.md
|
- Console: apps_and_tools/console.md
|
||||||
- Bench: apps_and_tools/bench.md
|
- Bench: apps_and_tools/bench.md
|
||||||
- Speaker: apps_and_tools/speaker.md
|
- Speaker: apps_and_tools/speaker.md
|
||||||
@@ -57,15 +57,25 @@ nav:
|
|||||||
- USB Probe: apps_and_tools/usb_probe.md
|
- USB Probe: apps_and_tools/usb_probe.md
|
||||||
- Link Relay: apps_and_tools/link_relay.md
|
- Link Relay: apps_and_tools/link_relay.md
|
||||||
- Hardware:
|
- Hardware:
|
||||||
- Overview: hardware/index.md
|
- hardware/index.md
|
||||||
- Platforms:
|
- Platforms:
|
||||||
- Overview: platforms/index.md
|
- platforms/index.md
|
||||||
- macOS: platforms/macos.md
|
- macOS: platforms/macos.md
|
||||||
- Linux: platforms/linux.md
|
- Linux: platforms/linux.md
|
||||||
- Windows: platforms/windows.md
|
- Windows: platforms/windows.md
|
||||||
- Android: platforms/android.md
|
- Android: platforms/android.md
|
||||||
|
- Zephyr: platforms/zephyr.md
|
||||||
- Examples:
|
- Examples:
|
||||||
- Overview: examples/index.md
|
- examples/index.md
|
||||||
|
- Extras:
|
||||||
|
- extras/index.md
|
||||||
|
- Android Remote HCI: extras/android_remote_hci.md
|
||||||
|
- Android BT Bench: extras/android_bt_bench.md
|
||||||
|
- Hive:
|
||||||
|
- hive/index.md
|
||||||
|
- Speaker: hive/web/speaker/speaker.html
|
||||||
|
- Scanner: hive/web/scanner/scanner.html
|
||||||
|
- Heart Rate Monitor: hive/web/heart_rate_monitor/heart_rate_monitor.html
|
||||||
|
|
||||||
copyright: Copyright 2021-2023 Google LLC
|
copyright: Copyright 2021-2023 Google LLC
|
||||||
|
|
||||||
@@ -74,6 +84,8 @@ theme:
|
|||||||
logo: 'images/logo.png'
|
logo: 'images/logo.png'
|
||||||
favicon: 'images/favicon.ico'
|
favicon: 'images/favicon.ico'
|
||||||
custom_dir: 'theme'
|
custom_dir: 'theme'
|
||||||
|
features:
|
||||||
|
- navigation.indexes
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
- mkdocstrings:
|
- mkdocstrings:
|
||||||
@@ -98,6 +110,8 @@ markdown_extensions:
|
|||||||
- pymdownx.emoji:
|
- pymdownx.emoji:
|
||||||
emoji_index: !!python/name:materialx.emoji.twemoji
|
emoji_index: !!python/name:materialx.emoji.twemoji
|
||||||
emoji_generator: !!python/name:materialx.emoji.to_svg
|
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||||
|
- pymdownx.tabbed:
|
||||||
|
alternate_style: true
|
||||||
- codehilite:
|
- codehilite:
|
||||||
guess_lang: false
|
guess_lang: false
|
||||||
- toc:
|
- toc:
|
||||||
|
|||||||
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.
64
docs/mkdocs/src/extras/android_bt_bench.md
Normal file
64
docs/mkdocs/src/extras/android_bt_bench.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
ANDROID BENCH APP
|
||||||
|
=================
|
||||||
|
|
||||||
|
This Android app that is compatible with the Bumble `bench` command line app.
|
||||||
|
This app can be used to test the throughput and latency between two Android
|
||||||
|
devices, or between an Android device and another device running the Bumble
|
||||||
|
`bench` app.
|
||||||
|
Only the RFComm Client, RFComm Server, L2CAP Client and L2CAP Server modes are
|
||||||
|
supported.
|
||||||
|
|
||||||
|
Building
|
||||||
|
--------
|
||||||
|
|
||||||
|
You can build the app by running `./gradlew build` (use `gradlew.bat` on Windows) from the `BtBench` top level directory.
|
||||||
|
You can also build with Android Studio: open the `BtBench` project. You can build and/or debug from there.
|
||||||
|
|
||||||
|
If the build succeeds, you can find the app APKs (debug and release) at:
|
||||||
|
|
||||||
|
* [Release] ``app/build/outputs/apk/release/app-release-unsigned.apk``
|
||||||
|
* [Debug] ``app/build/outputs/apk/debug/app-debug.apk``
|
||||||
|
|
||||||
|
|
||||||
|
Running
|
||||||
|
-------
|
||||||
|
|
||||||
|
### Starting the app
|
||||||
|
You can start the app from the Android launcher, from Android Studio, or with `adb`
|
||||||
|
|
||||||
|
#### Launching from the launcher
|
||||||
|
Just tap the app icon on the launcher, check the parameters, and tap
|
||||||
|
one of the benchmark action buttons.
|
||||||
|
|
||||||
|
#### Launching with `adb`
|
||||||
|
Using the `am` command, you can start the activity, and pass it arguments so that you can
|
||||||
|
automatically start the benchmark test, and/or set the parameters.
|
||||||
|
|
||||||
|
| Parameter Name | Parameter Type | Description
|
||||||
|
|------------------------|----------------|------------
|
||||||
|
| autostart | String | Benchmark to start. (rfcomm-client, rfcomm-server, l2cap-client or l2cap-server)
|
||||||
|
| packet-count | Integer | Number of packets to send (rfcomm-client and l2cap-client only)
|
||||||
|
| packet-size | Integer | Number of bytes per packet (rfcomm-client and l2cap-client only)
|
||||||
|
| peer-bluetooth-address | Integer | Peer Bluetooth address to connect to (rfcomm-client and l2cap-client | only)
|
||||||
|
|
||||||
|
|
||||||
|
!!! tip "Launching from adb with auto-start"
|
||||||
|
In this example, we auto-start the Rfcomm Server bench action.
|
||||||
|
```bash
|
||||||
|
$ adb shell am start -n com.github.google.bumble.btbench/.MainActivity --es autostart rfcomm-server
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! tip "Launching from adb with auto-start and some parameters"
|
||||||
|
In this example, we auto-start the Rfcomm Client bench action, set the packet count to 100,
|
||||||
|
and the packet size to 1024, and connect to DA:4C:10:DE:17:02
|
||||||
|
```bash
|
||||||
|
$ adb shell am start -n com.github.google.bumble.btbench/.MainActivity --es autostart rfcomm-client --ei packet-count 100 --ei packet-size 1024 --es peer-bluetooth-address DA:4C:10:DE:17:02
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Selecting a Peer Bluetooth Address
|
||||||
|
The app's main activity has a "Peer Bluetooth Address" setting where you can change the address.
|
||||||
|
|
||||||
|
!!! note "Bluetooth Address for L2CAP vs RFComm"
|
||||||
|
For BLE (L2CAP mode), the address of a device typically changes regularly (it is randomized for privacy), whereas the Bluetooth Classic addresses will remain the same (RFComm mode).
|
||||||
|
If two devices are paired and bonded, then they will each "see" a non-changing address for each other even with BLE (Resolvable Private Address)
|
||||||
|
|
||||||
181
docs/mkdocs/src/extras/android_remote_hci.md
Normal file
181
docs/mkdocs/src/extras/android_remote_hci.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
ANDROID REMOTE HCI APP
|
||||||
|
======================
|
||||||
|
|
||||||
|
This application allows using an android phone's built-in Bluetooth controller with
|
||||||
|
a Bumble host stack running outside the phone (typically a development laptop or desktop).
|
||||||
|
The app runs an HCI proxy between a TCP socket on the "outside" and the Bluetooth HCI HAL
|
||||||
|
on the "inside". (See [this page](https://source.android.com/docs/core/connect/bluetooth) for a high level
|
||||||
|
description of the Android Bluetooth HCI HAL).
|
||||||
|
The HCI packets received on the TCP socket are forwarded to the phone's controller, and the
|
||||||
|
packets coming from the controller are forwarded to the TCP socket.
|
||||||
|
|
||||||
|
|
||||||
|
Building
|
||||||
|
--------
|
||||||
|
|
||||||
|
You can build the app by running `./gradlew build` (use `gradlew.bat` on Windows) from the `extras/android/RemoteHCI` top level directory.
|
||||||
|
You can also build with Android Studio: open the `RemoteHCI` project. You can build and/or debug from there.
|
||||||
|
|
||||||
|
If the build succeeds, you can find the app APKs (debug and release) at:
|
||||||
|
|
||||||
|
* [Release] ``app/build/outputs/apk/release/app-release-unsigned.apk``
|
||||||
|
* [Debug] ``app/build/outputs/apk/debug/app-debug.apk``
|
||||||
|
|
||||||
|
|
||||||
|
Running
|
||||||
|
-------
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
In the following examples, it is assumed that shell commands are executed while in the
|
||||||
|
app's root directory, `extras/android/RemoteHCI`. If you are in a different directory,
|
||||||
|
adjust the relative paths accordingly.
|
||||||
|
|
||||||
|
### Preconditions
|
||||||
|
When the proxy starts (tapping the "Start" button in the app's main activity, or running the proxy
|
||||||
|
from an `adb shell` command line), it will try to bind to the Bluetooth HAL.
|
||||||
|
This requires that there is no other HAL client, and requires certain privileges.
|
||||||
|
For running as a regular app, this requires disabling SELinux temporarily.
|
||||||
|
For running as a command-line executable, this just requires a root shell.
|
||||||
|
|
||||||
|
#### Root Shell
|
||||||
|
!!! tip "Restart `adb` as root"
|
||||||
|
```bash
|
||||||
|
$ adb root
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Disabling SELinux
|
||||||
|
Binding to the Bluetooth HCI HAL requires certain SELinux permissions that can't simply be changed
|
||||||
|
on a device without rebuilding its system image. To bypass these restrictions, you will need
|
||||||
|
to disable SELinux on your phone (please be aware that this is global, not just for the proxy app,
|
||||||
|
so proceed with caution).
|
||||||
|
In order to disable SELinux, you need to root the phone (it may be advisable to do this on a
|
||||||
|
development phone).
|
||||||
|
|
||||||
|
!!! tip "Disabling SELinux Temporarily"
|
||||||
|
Restart `adb` as root:
|
||||||
|
```bash
|
||||||
|
$ adb root
|
||||||
|
```
|
||||||
|
|
||||||
|
Then disable SELinux
|
||||||
|
```bash
|
||||||
|
$ adb shell setenforce 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Once you're done using the proxy, you can restore SELinux, if you need to, with
|
||||||
|
```bash
|
||||||
|
$ adb shell setenforce 1
|
||||||
|
```
|
||||||
|
|
||||||
|
This state will also reset to the normal SELinux enforcement when you reboot.
|
||||||
|
|
||||||
|
#### Stopping the bluetooth process
|
||||||
|
Since the Bluetooth HAL service can only accept one client, and that in normal conditions
|
||||||
|
that client is the Android's bluetooth stack, it is required to first shut down the
|
||||||
|
Android bluetooth stack process.
|
||||||
|
|
||||||
|
!!! tip "Checking if the Bluetooth process is running"
|
||||||
|
```bash
|
||||||
|
$ adb shell "ps -A | grep com.google.android.bluetooth"
|
||||||
|
```
|
||||||
|
If the process is running, you will get a line like:
|
||||||
|
```
|
||||||
|
bluetooth 10759 876 17455796 136620 do_epoll_wait 0 S com.google.android.bluetooth
|
||||||
|
```
|
||||||
|
If you don't, it means that the process is not running and you are clear to proceed.
|
||||||
|
|
||||||
|
Simply turning Bluetooth off from the phone's settings does not ensure that the bluetooth process will exit.
|
||||||
|
If the bluetooth process is still running after toggling Bluetooth off from the settings, you may try enabling
|
||||||
|
Airplane Mode, then rebooting. The bluetooth process should, in theory, not restart after the reboot.
|
||||||
|
|
||||||
|
!!! tip "Stopping the bluetooth process with adb"
|
||||||
|
```bash
|
||||||
|
$ adb shell cmd bluetooth_manager disable
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running as a command line app
|
||||||
|
|
||||||
|
You push the built APK to a temporary location on the phone's filesystem, then launch the command
|
||||||
|
line executable with an `adb shell` command.
|
||||||
|
|
||||||
|
!!! tip "Pushing the executable"
|
||||||
|
```bash
|
||||||
|
$ adb push app/build/outputs/apk/release/app-release-unsigned.apk /data/local/tmp/remotehci.apk
|
||||||
|
```
|
||||||
|
Do this every time you rebuild. Alternatively, you can push the `debug` APK instead:
|
||||||
|
```bash
|
||||||
|
$ adb push app/build/outputs/apk/debug/app-debug.apk /data/local/tmp/remotehci.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! tip "Start the proxy from the command line"
|
||||||
|
```bash
|
||||||
|
adb shell "CLASSPATH=/data/local/tmp/remotehci.apk app_process /system/bin com.github.google.bumble.remotehci.CommandLineInterface"
|
||||||
|
```
|
||||||
|
This will run the proxy, listening on the default TCP port.
|
||||||
|
If you want a different port, pass it as a command line parameter
|
||||||
|
|
||||||
|
!!! tip "Start the proxy from the command line with a specific TCP port"
|
||||||
|
```bash
|
||||||
|
adb shell "CLASSPATH=/data/local/tmp/remotehci.apk app_process /system/bin com.github.google.bumble.remotehci.CommandLineInterface 12345"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running as a normal app
|
||||||
|
You can start the app from the Android launcher, from Android Studio, or with `adb`
|
||||||
|
|
||||||
|
#### Launching from the launcher
|
||||||
|
Just tap the app icon on the launcher, check the TCP port that is configured, and tap
|
||||||
|
the "Start" button.
|
||||||
|
|
||||||
|
#### Launching with `adb`
|
||||||
|
Using the `am` command, you can start the activity, and pass it arguments so that you can
|
||||||
|
automatically start the proxy, and/or set the port number.
|
||||||
|
|
||||||
|
!!! tip "Launching from adb with auto-start"
|
||||||
|
```bash
|
||||||
|
$ adb shell am start -n com.github.google.bumble.remotehci/.MainActivity --ez autostart true
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! tip "Launching from adb with auto-start and a port"
|
||||||
|
In this example, we auto-start the proxy upon launch, with the port set to 9995
|
||||||
|
```bash
|
||||||
|
$ adb shell am start -n com.github.google.bumble.remotehci/.MainActivity --ez autostart true --ei port 9995
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Selecting a TCP port
|
||||||
|
The RemoteHCI app's main activity has a "TCP Port" setting where you can change the port on
|
||||||
|
which the proxy is accepting connections. If the default value isn't suitable, you can
|
||||||
|
change it there (you can also use the special value 0 to let the OS assign a port number for you).
|
||||||
|
|
||||||
|
### Connecting to the proxy
|
||||||
|
To connect the Bumble stack to the proxy, you need to be able to reach the phone's network
|
||||||
|
stack. This can be done over the phone's WiFi connection, or, alternatively, using an `adb`
|
||||||
|
TCP forward (which should be faster than over WiFi).
|
||||||
|
|
||||||
|
!!! tip "Forwarding TCP with `adb`"
|
||||||
|
To connect to the proxy via an `adb` TCP forward, use:
|
||||||
|
```bash
|
||||||
|
$ adb forward tcp:<outside-port> tcp:<inside-port>
|
||||||
|
```
|
||||||
|
Where ``<outside-port>`` is the port number for a listening socket on your laptop or
|
||||||
|
desktop machine, and <inside-port> is the TCP port selected in the app's user interface.
|
||||||
|
Those two ports may be the same, of course.
|
||||||
|
For example, with the default TCP port 9993:
|
||||||
|
```bash
|
||||||
|
$ adb forward tcp:9993 tcp:9993
|
||||||
|
```
|
||||||
|
|
||||||
|
Once you've ensured that you can reach the proxy's TCP port on the phone, either directly or
|
||||||
|
via an `adb` forward, you can then use it as a Bumble transport, using the transport name:
|
||||||
|
``tcp-client:<host>:<port>`` syntax.
|
||||||
|
|
||||||
|
!!! example "Connecting a Bumble client"
|
||||||
|
Connecting the `bumble-controller-info` app to the phone's controller.
|
||||||
|
Assuming you have set up an `adb` forward on port 9993:
|
||||||
|
```bash
|
||||||
|
$ bumble-controller-info tcp-client:localhost:9993
|
||||||
|
```
|
||||||
|
|
||||||
|
Or over WiFi with, in this example, the IP address of the phone being ```192.168.86.27```
|
||||||
|
```bash
|
||||||
|
$ bumble-controller-info tcp-client:192.168.86.27:9993
|
||||||
|
```
|
||||||
19
docs/mkdocs/src/extras/index.md
Normal file
19
docs/mkdocs/src/extras/index.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
EXTRAS
|
||||||
|
======
|
||||||
|
|
||||||
|
A collection of add-ons, apps and tools, to the Bumble project.
|
||||||
|
|
||||||
|
Android Remote HCI
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Allows using an Android phone's built-in Bluetooth controller with a Bumble
|
||||||
|
stack running on a development machine.
|
||||||
|
See [Android Remote HCI](android_remote_hci.md) for details.
|
||||||
|
|
||||||
|
Android BT Bench
|
||||||
|
----------------
|
||||||
|
|
||||||
|
An Android app that is compatible with the Bumble `bench` command line app.
|
||||||
|
This app can be used to test the throughput and latency between two Android
|
||||||
|
devices, or between an Android device and another device running the Bumble
|
||||||
|
`bench` app.
|
||||||
@@ -3,7 +3,7 @@ HARDWARE
|
|||||||
|
|
||||||
The Bumble Host connects to a controller over an [HCI Transport](../transports/index.md).
|
The Bumble Host connects to a controller over an [HCI Transport](../transports/index.md).
|
||||||
To use a hardware controller attached to the host on which the host application is running, the transport is typically either [HCI over UART](../transports/serial.md) or [HCI over USB](../transports/usb.md).
|
To use a hardware controller attached to the host on which the host application is running, the transport is typically either [HCI over UART](../transports/serial.md) or [HCI over USB](../transports/usb.md).
|
||||||
On Linux, the [VHCI Transport](../transports/vhci.md) can be used to communicate with any controller hardware managed by the operating system. Alternatively, a remote controller (a phyiscal controller attached to a remote host) can be used by connecting one of the networked transports (such as the [TCP Client transport](../transports/tcp_client.md), the [TCP Server transport](../transports/tcp_server.md) or the [UDP Transport](../transports/udp.md)) to an [HCI Bridge](../apps_and_tools/hci_bridge) bridging the network transport to a physical controller on a remote host.
|
On Linux, the [VHCI Transport](../transports/vhci.md) can be used to communicate with any controller hardware managed by the operating system. Alternatively, a remote controller (a phyiscal controller attached to a remote host) can be used by connecting one of the networked transports (such as the [TCP Client transport](../transports/tcp_client.md), the [TCP Server transport](../transports/tcp_server.md) or the [UDP Transport](../transports/udp.md)) to an [HCI Bridge](../apps_and_tools/hci_bridge.md) bridging the network transport to a physical controller on a remote host.
|
||||||
|
|
||||||
In theory, any controller that is compliant with the HCI over UART or HCI over USB protocols can be used.
|
In theory, any controller that is compliant with the HCI over UART or HCI over USB protocols can be used.
|
||||||
|
|
||||||
|
|||||||
59
docs/mkdocs/src/hive/index.md
Normal file
59
docs/mkdocs/src/hive/index.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
HIVE
|
||||||
|
====
|
||||||
|
|
||||||
|
Welcome to the Bumble Hive.
|
||||||
|
This is a collection of apps and virtual devices that can run entirely in a browser page.
|
||||||
|
The code for the apps and devices, as well as the Bumble runtime code, runs via [Pyodide](https://pyodide.org/).
|
||||||
|
Pyodide is a Python distribution for the browser and Node.js based on WebAssembly.
|
||||||
|
|
||||||
|
The Bumble stack uses a WebSocket to exchange HCI packets with a virtual or physical
|
||||||
|
Bluetooth controller.
|
||||||
|
|
||||||
|
The apps and devices in the hive can be accessed by following the links below. Each
|
||||||
|
page has a settings button that may be used to configure the WebSocket URL to use for
|
||||||
|
the virtual HCI connection. This will typically be the WebSocket URL for a `netsim`
|
||||||
|
daemon.
|
||||||
|
There is also a [TOML index](index.toml) that can be used by tools to know at which URL to access
|
||||||
|
each of the apps and devices, as well as their names and short descriptions.
|
||||||
|
|
||||||
|
!!! tip "Using `netsim`"
|
||||||
|
When the `netsimd` daemon is running (for example when using the Android Emulator that
|
||||||
|
is included in Android Studio), the daemon listens for connections on a TCP port.
|
||||||
|
To find out what this TCP port is, you can read the `netsim.ini` file that `netsimd`
|
||||||
|
creates, it includes a line with `web.port=<tcp-port>` (for example `web.port=7681`).
|
||||||
|
The location of the `netsim.ini` file is platform-specific.
|
||||||
|
|
||||||
|
=== "macOS"
|
||||||
|
On macOS, the directory where `netsim.ini` is stored is $TMPDIR
|
||||||
|
```bash
|
||||||
|
$ cat $TMPDIR/netsim.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Linux"
|
||||||
|
On Linux, the directory where `netsim.ini` is stored is $XDG_RUNTIME_DIR
|
||||||
|
```bash
|
||||||
|
$ cat $XDG_RUNTIME_DIR/netsim.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
!!! tip "Using a local radio"
|
||||||
|
You can connect the hive virtual apps and devices to a local Bluetooth radio, like,
|
||||||
|
for example, a USB dongle.
|
||||||
|
For that, you need to run a local HCI bridge to bridge a local HCI device to a WebSocket
|
||||||
|
that a web page can connect to.
|
||||||
|
Use the `bumble-hci-bridge` app, with the host transport set to a WebSocket server on an
|
||||||
|
available port (ex: `ws-server:_:7682`) and the controller transport set to the transport
|
||||||
|
name for the radio you want to use (ex: `usb:0` for the first USB dongle)
|
||||||
|
|
||||||
|
|
||||||
|
Applications
|
||||||
|
------------
|
||||||
|
|
||||||
|
* [Scanner](web/scanner/scanner.html) - Scans for BLE devices.
|
||||||
|
|
||||||
|
Virtual Devices
|
||||||
|
---------------
|
||||||
|
|
||||||
|
* [Speaker](web/speaker/speaker.html) - Virtual speaker that plays audio in a browser page.
|
||||||
|
* [Heart Rate Monitor](web/heart_rate_monitor/heart_rate_monitor.html) - Virtual heart rate monitor.
|
||||||
|
|
||||||
21
docs/mkdocs/src/hive/index.toml
Normal file
21
docs/mkdocs/src/hive/index.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
version = "1.0.0"
|
||||||
|
base_url = "https://google.github.io/bumble/hive/web"
|
||||||
|
default_hci_query_param = "hci"
|
||||||
|
|
||||||
|
[[index]]
|
||||||
|
name = "speaker"
|
||||||
|
description = "Bumble Virtual Speaker"
|
||||||
|
type = "Device"
|
||||||
|
url = "speaker/speaker.html"
|
||||||
|
|
||||||
|
[[index]]
|
||||||
|
name = "scanner"
|
||||||
|
description = "Simple Scanner Application"
|
||||||
|
type = "Application"
|
||||||
|
url = "scanner/scanner.html"
|
||||||
|
|
||||||
|
[[index]]
|
||||||
|
name = "heart-rate-monitor"
|
||||||
|
description = "Virtual Heart Rate Monitor"
|
||||||
|
type = "Device"
|
||||||
|
url = "heart_rate_monitor/heart_rate_monitor.html"
|
||||||
1
docs/mkdocs/src/hive/web/bumble.js
Symbolic link
1
docs/mkdocs/src/hive/web/bumble.js
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../../../web/bumble.js
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/heart_rate_monitor/heart_rate_monitor.html
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/heart_rate_monitor/heart_rate_monitor.js
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/heart_rate_monitor/heart_rate_monitor.py
|
||||||
1
docs/mkdocs/src/hive/web/scanner/scanner.css
Symbolic link
1
docs/mkdocs/src/hive/web/scanner/scanner.css
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/scanner/scanner.css
|
||||||
1
docs/mkdocs/src/hive/web/scanner/scanner.html
Symbolic link
1
docs/mkdocs/src/hive/web/scanner/scanner.html
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/scanner/scanner.html
|
||||||
1
docs/mkdocs/src/hive/web/scanner/scanner.js
Symbolic link
1
docs/mkdocs/src/hive/web/scanner/scanner.js
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/scanner/scanner.js
|
||||||
1
docs/mkdocs/src/hive/web/scanner/scanner.py
Symbolic link
1
docs/mkdocs/src/hive/web/scanner/scanner.py
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/scanner/scanner.py
|
||||||
1
docs/mkdocs/src/hive/web/speaker/logo.svg
Symbolic link
1
docs/mkdocs/src/hive/web/speaker/logo.svg
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/speaker/logo.svg
|
||||||
1
docs/mkdocs/src/hive/web/speaker/speaker.css
Symbolic link
1
docs/mkdocs/src/hive/web/speaker/speaker.css
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/speaker/speaker.css
|
||||||
1
docs/mkdocs/src/hive/web/speaker/speaker.html
Symbolic link
1
docs/mkdocs/src/hive/web/speaker/speaker.html
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/speaker/speaker.html
|
||||||
1
docs/mkdocs/src/hive/web/speaker/speaker.js
Symbolic link
1
docs/mkdocs/src/hive/web/speaker/speaker.js
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/speaker/speaker.js
|
||||||
1
docs/mkdocs/src/hive/web/speaker/speaker.py
Symbolic link
1
docs/mkdocs/src/hive/web/speaker/speaker.py
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/speaker/speaker.py
|
||||||
1
docs/mkdocs/src/hive/web/ui.js
Symbolic link
1
docs/mkdocs/src/hive/web/ui.js
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../../../web/ui.js
|
||||||
@@ -152,11 +152,23 @@ Some platforms support features that not all platforms support
|
|||||||
|
|
||||||
See the [Platforms page](platforms/index.md) for details.
|
See the [Platforms page](platforms/index.md) for details.
|
||||||
|
|
||||||
|
|
||||||
|
Hive
|
||||||
|
----
|
||||||
|
|
||||||
|
The Hive is a collection of example apps and virtual devices that are implemented using the
|
||||||
|
Python Bumble API, running entirely in a web page. This is a convenient way to try out some
|
||||||
|
of the examples without any Python installation, when you have some other virtual Bluetooth
|
||||||
|
device that you can connect to or from, such as the Android Emulator.
|
||||||
|
|
||||||
|
See the [Bumble Hive](hive/index.md) for details.
|
||||||
|
|
||||||
Roadmap
|
Roadmap
|
||||||
-------
|
-------
|
||||||
|
|
||||||
Future features to be considered include:
|
Future features to be considered include:
|
||||||
|
|
||||||
|
* More profiles
|
||||||
* More device examples
|
* More device examples
|
||||||
* Add a new type of virtual link (beyond the two existing ones) to allow for link-level simulation (timing, loss, etc)
|
* Add a new type of virtual link (beyond the two existing ones) to allow for link-level simulation (timing, loss, etc)
|
||||||
* Bindings for languages other than Python
|
* Bindings for languages other than Python
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ For platform-specific information, see the following pages:
|
|||||||
* :material-linux: Linux - see the [Linux platform page](linux.md)
|
* :material-linux: Linux - see the [Linux platform page](linux.md)
|
||||||
* :material-microsoft-windows: Windows - see the [Windows platform page](windows.md)
|
* :material-microsoft-windows: Windows - see the [Windows platform page](windows.md)
|
||||||
* :material-android: Android - see the [Android platform page](android.md)
|
* :material-android: Android - see the [Android platform page](android.md)
|
||||||
|
* :material-memory: Zephyr - see the [Zephyr platform page](zephyr.md)
|
||||||
|
|||||||
51
docs/mkdocs/src/platforms/zephyr.md
Normal file
51
docs/mkdocs/src/platforms/zephyr.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
:material-memory: ZEPHYR PLATFORM
|
||||||
|
=================================
|
||||||
|
|
||||||
|
Set TX Power on nRF52840
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
The Nordic nRF52840 supports Zephyr's vendor specific HCI command for setting TX
|
||||||
|
power during advertising, connection, or scanning. With the example [HCI
|
||||||
|
USB](https://docs.zephyrproject.org/latest/samples/bluetooth/hci_usb/README.html)
|
||||||
|
application, an [nRF52840
|
||||||
|
dongle](https://www.nordicsemi.com/Products/Development-
|
||||||
|
hardware/nRF52840-Dongle) can be used as a Bumble controller.
|
||||||
|
|
||||||
|
To add dynamic TX power support to the HCI USB application, add the following to
|
||||||
|
`zephyr/samples/bluetooth/hci_usb/prj.conf` and build.
|
||||||
|
|
||||||
|
```
|
||||||
|
CONFIG_BT_CTLR_ADVANCED_FEATURES=y
|
||||||
|
CONFIG_BT_CTLR_CONN_RSSI=y
|
||||||
|
CONFIG_BT_CTLR_TX_PWR_DYNAMIC_CONTROL=y
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, a prebuilt firmware application can be downloaded here:
|
||||||
|
[hci_usb.zip](../downloads/zephyr/hci_usb.zip).
|
||||||
|
|
||||||
|
Put the nRF52840 dongle into bootloader mode by pressing the RESET button. The
|
||||||
|
LED should pulse red. Load the firmware application with the `nrfutil` tool:
|
||||||
|
|
||||||
|
```
|
||||||
|
nrfutil dfu usb-serial -pkg hci_usb.zip -p /dev/ttyACM0
|
||||||
|
```
|
||||||
|
|
||||||
|
The vendor specific HCI commands to read and write TX power are defined in
|
||||||
|
`bumble/vendor/zephyr/hci.py` and may be used as such:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bumble.vendor.zephyr.hci import HCI_Write_Tx_Power_Level_Command
|
||||||
|
|
||||||
|
# set advertising power to -4 dB
|
||||||
|
response = await host.send_command(
|
||||||
|
HCI_Write_Tx_Power_Level_Command(
|
||||||
|
handle_type=HCI_Write_Tx_Power_Level_Command.TX_POWER_HANDLE_TYPE_ADV,
|
||||||
|
connection_handle=0,
|
||||||
|
tx_power_level=-4,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.return_parameters.status == HCI_SUCCESS:
|
||||||
|
print(f"TX power set to {response.return_parameters.selected_tx_power_level}")
|
||||||
|
|
||||||
|
```
|
||||||
@@ -14,7 +14,7 @@ connections.
|
|||||||
|
|
||||||
## Moniker
|
## Moniker
|
||||||
The moniker syntax for an Android Emulator "netsim" transport is: `android-netsim:[<host>:<port>][<options>]`,
|
The moniker syntax for an Android Emulator "netsim" transport is: `android-netsim:[<host>:<port>][<options>]`,
|
||||||
where `<options>` is a ','-separated list of `<name>=<value>` pairs`.
|
where `<options>` is a comma-separated list of `<name>=<value>` pairs.
|
||||||
The `mode` parameter name can specify running as a host or a controller, and `<hostname>:<port>` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator (in "host" mode), or to accept gRPC connections (in "controller" mode).
|
The `mode` parameter name can specify running as a host or a controller, and `<hostname>:<port>` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator (in "host" mode), or to accept gRPC connections (in "controller" mode).
|
||||||
Both the `mode=<host|controller>` and `<hostname>:<port>` parameters are optional (so the moniker `android-netsim` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the Netsim background process).
|
Both the `mode=<host|controller>` and `<hostname>:<port>` parameters are optional (so the moniker `android-netsim` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the Netsim background process).
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ channels:
|
|||||||
- defaults
|
- defaults
|
||||||
- conda-forge
|
- conda-forge
|
||||||
dependencies:
|
dependencies:
|
||||||
- pip=20
|
- pip=23
|
||||||
- python=3.8
|
- python=3.8
|
||||||
- pip:
|
- pip:
|
||||||
- --editable .[development,documentation,test]
|
- --editable .[development,documentation,test]
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from bumble.device import Device
|
|||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
from bumble.profiles.device_information_service import DeviceInformationService
|
from bumble.profiles.device_information_service import DeviceInformationService
|
||||||
from bumble.profiles.heart_rate_service import HeartRateService
|
from bumble.profiles.heart_rate_service import HeartRateService
|
||||||
|
from bumble.utils import AsyncRunner
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -98,6 +99,17 @@ async def main():
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Notify subscribers of the current value as soon as they subscribe
|
||||||
|
@heart_rate_service.heart_rate_measurement_characteristic.on('subscription')
|
||||||
|
def on_subscription(connection, notify_enabled, indicate_enabled):
|
||||||
|
if notify_enabled or indicate_enabled:
|
||||||
|
AsyncRunner.spawn(
|
||||||
|
device.notify_subscriber(
|
||||||
|
connection,
|
||||||
|
heart_rate_service.heart_rate_measurement_characteristic,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Go!
|
# Go!
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
await device.start_advertising(auto_restart=True)
|
await device.start_advertising(auto_restart=True)
|
||||||
|
|||||||
248
examples/hid_key_map.py
Normal file
248
examples/hid_key_map.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# shift map
|
||||||
|
|
||||||
|
# letters
|
||||||
|
shift_map = {
|
||||||
|
'a': 'A',
|
||||||
|
'b': 'B',
|
||||||
|
'c': 'C',
|
||||||
|
'd': 'D',
|
||||||
|
'e': 'E',
|
||||||
|
'f': 'F',
|
||||||
|
'g': 'G',
|
||||||
|
'h': 'H',
|
||||||
|
'i': 'I',
|
||||||
|
'j': 'J',
|
||||||
|
'k': 'K',
|
||||||
|
'l': 'L',
|
||||||
|
'm': 'M',
|
||||||
|
'n': 'N',
|
||||||
|
'o': 'O',
|
||||||
|
'p': 'P',
|
||||||
|
'q': 'Q',
|
||||||
|
'r': 'R',
|
||||||
|
's': 'S',
|
||||||
|
't': 'T',
|
||||||
|
'u': 'U',
|
||||||
|
'v': 'V',
|
||||||
|
'w': 'W',
|
||||||
|
'x': 'X',
|
||||||
|
'y': 'Y',
|
||||||
|
'z': 'Z',
|
||||||
|
# numbers
|
||||||
|
'1': '!',
|
||||||
|
'2': '@',
|
||||||
|
'3': '#',
|
||||||
|
'4': '$',
|
||||||
|
'5': '%',
|
||||||
|
'6': '^',
|
||||||
|
'7': '&',
|
||||||
|
'8': '*',
|
||||||
|
'9': '(',
|
||||||
|
'0': ')',
|
||||||
|
# symbols
|
||||||
|
'-': '_',
|
||||||
|
'=': '+',
|
||||||
|
'[': '{',
|
||||||
|
']': '}',
|
||||||
|
'\\': '|',
|
||||||
|
';': ':',
|
||||||
|
'\'': '"',
|
||||||
|
',': '<',
|
||||||
|
'.': '>',
|
||||||
|
'/': '?',
|
||||||
|
'`': '~',
|
||||||
|
}
|
||||||
|
|
||||||
|
# hex map
|
||||||
|
|
||||||
|
# modifier keys
|
||||||
|
mod_keys = {
|
||||||
|
'00': '',
|
||||||
|
'01': 'left_ctrl',
|
||||||
|
'02': 'left_shift',
|
||||||
|
'04': 'left_alt',
|
||||||
|
'08': 'left_meta',
|
||||||
|
'10': 'right_ctrl',
|
||||||
|
'20': 'right_shift',
|
||||||
|
'40': 'right_alt',
|
||||||
|
'80': 'right_meta',
|
||||||
|
}
|
||||||
|
|
||||||
|
# base keys
|
||||||
|
|
||||||
|
base_keys = {
|
||||||
|
# meta
|
||||||
|
'00': '', # none
|
||||||
|
'01': 'error_ovf',
|
||||||
|
# letters
|
||||||
|
'04': 'a',
|
||||||
|
'05': 'b',
|
||||||
|
'06': 'c',
|
||||||
|
'07': 'd',
|
||||||
|
'08': 'e',
|
||||||
|
'09': 'f',
|
||||||
|
'0a': 'g',
|
||||||
|
'0b': 'h',
|
||||||
|
'0c': 'i',
|
||||||
|
'0d': 'j',
|
||||||
|
'0e': 'k',
|
||||||
|
'0f': 'l',
|
||||||
|
'10': 'm',
|
||||||
|
'11': 'n',
|
||||||
|
'12': 'o',
|
||||||
|
'13': 'p',
|
||||||
|
'14': 'q',
|
||||||
|
'15': 'r',
|
||||||
|
'16': 's',
|
||||||
|
'17': 't',
|
||||||
|
'18': 'u',
|
||||||
|
'19': 'v',
|
||||||
|
'1a': 'w',
|
||||||
|
'1b': 'x',
|
||||||
|
'1c': 'y',
|
||||||
|
'1d': 'z',
|
||||||
|
# numbers
|
||||||
|
'1e': '1',
|
||||||
|
'1f': '2',
|
||||||
|
'20': '3',
|
||||||
|
'21': '4',
|
||||||
|
'22': '5',
|
||||||
|
'23': '6',
|
||||||
|
'24': '7',
|
||||||
|
'25': '8',
|
||||||
|
'26': '9',
|
||||||
|
'27': '0',
|
||||||
|
# misc
|
||||||
|
'28': 'enter', # enter \n
|
||||||
|
'29': 'esc',
|
||||||
|
'2a': 'backspace',
|
||||||
|
'2b': 'tab',
|
||||||
|
'2c': 'spacebar', # space
|
||||||
|
'2d': '-',
|
||||||
|
'2e': '=',
|
||||||
|
'2f': '[',
|
||||||
|
'30': ']',
|
||||||
|
'31': '\\',
|
||||||
|
'32': '=',
|
||||||
|
'33': '_SEMICOLON',
|
||||||
|
'34': 'KEY_APOSTROPHE',
|
||||||
|
'35': 'KEY_GRAVE',
|
||||||
|
'36': 'KEY_COMMA',
|
||||||
|
'37': 'KEY_DOT',
|
||||||
|
'38': 'KEY_SLASH',
|
||||||
|
'39': 'KEY_CAPSLOCK',
|
||||||
|
'3a': 'KEY_F1',
|
||||||
|
'3b': 'KEY_F2',
|
||||||
|
'3c': 'KEY_F3',
|
||||||
|
'3d': 'KEY_F4',
|
||||||
|
'3e': 'KEY_F5',
|
||||||
|
'3f': 'KEY_F6',
|
||||||
|
'40': 'KEY_F7',
|
||||||
|
'41': 'KEY_F8',
|
||||||
|
'42': 'KEY_F9',
|
||||||
|
'43': 'KEY_F10',
|
||||||
|
'44': 'KEY_F11',
|
||||||
|
'45': 'KEY_F12',
|
||||||
|
'46': 'KEY_SYSRQ',
|
||||||
|
'47': 'KEY_SCROLLLOCK',
|
||||||
|
'48': 'KEY_PAUSE',
|
||||||
|
'49': 'KEY_INSERT',
|
||||||
|
'4a': 'KEY_HOME',
|
||||||
|
'4b': 'KEY_PAGEUP',
|
||||||
|
'4c': 'KEY_DELETE',
|
||||||
|
'4d': 'KEY_END',
|
||||||
|
'4e': 'KEY_PAGEDOWN',
|
||||||
|
'4f': 'KEY_RIGHT',
|
||||||
|
'50': 'KEY_LEFT',
|
||||||
|
'51': 'KEY_DOWN',
|
||||||
|
'52': 'KEY_UP',
|
||||||
|
'53': 'KEY_NUMLOCK',
|
||||||
|
'54': 'KEY_KPSLASH',
|
||||||
|
'55': 'KEY_KPASTERISK',
|
||||||
|
'56': 'KEY_KPMINUS',
|
||||||
|
'57': 'KEY_KPPLUS',
|
||||||
|
'58': 'KEY_KPENTER',
|
||||||
|
'59': 'KEY_KP1',
|
||||||
|
'5a': 'KEY_KP2',
|
||||||
|
'5b': 'KEY_KP3',
|
||||||
|
'5c': 'KEY_KP4',
|
||||||
|
'5d': 'KEY_KP5',
|
||||||
|
'5e': 'KEY_KP6',
|
||||||
|
'5f': 'KEY_KP7',
|
||||||
|
'60': 'KEY_KP8',
|
||||||
|
'61': 'KEY_KP9',
|
||||||
|
'62': 'KEY_KP0',
|
||||||
|
'63': 'KEY_KPDOT',
|
||||||
|
'64': 'KEY_102ND',
|
||||||
|
'65': 'KEY_COMPOSE',
|
||||||
|
'66': 'KEY_POWER',
|
||||||
|
'67': 'KEY_KPEQUAL',
|
||||||
|
'68': 'KEY_F13',
|
||||||
|
'69': 'KEY_F14',
|
||||||
|
'6a': 'KEY_F15',
|
||||||
|
'6b': 'KEY_F16',
|
||||||
|
'6c': 'KEY_F17',
|
||||||
|
'6d': 'KEY_F18',
|
||||||
|
'6e': 'KEY_F19',
|
||||||
|
'6f': 'KEY_F20',
|
||||||
|
'70': 'KEY_F21',
|
||||||
|
'71': 'KEY_F22',
|
||||||
|
'72': 'KEY_F23',
|
||||||
|
'73': 'KEY_F24',
|
||||||
|
'74': 'KEY_OPEN',
|
||||||
|
'75': 'KEY_HELP',
|
||||||
|
'76': 'KEY_PROPS',
|
||||||
|
'77': 'KEY_FRONT',
|
||||||
|
'78': 'KEY_STOP',
|
||||||
|
'79': 'KEY_AGAIN',
|
||||||
|
'7a': 'KEY_UNDO',
|
||||||
|
'7b': 'KEY_CUT',
|
||||||
|
'7c': 'KEY_COPY',
|
||||||
|
'7d': 'KEY_PASTE',
|
||||||
|
'7e': 'KEY_FIND',
|
||||||
|
'7f': 'KEY_MUTE',
|
||||||
|
'80': 'KEY_VOLUMEUP',
|
||||||
|
'81': 'KEY_VOLUMEDOWN',
|
||||||
|
'85': 'KEY_KPCOMMA',
|
||||||
|
'87': 'KEY_RO',
|
||||||
|
'88': 'KEY_KATAKANAHIRAGANA',
|
||||||
|
'89': 'KEY_YEN',
|
||||||
|
'8a': 'KEY_HENKAN',
|
||||||
|
'8b': 'KEY_MUHENKAN',
|
||||||
|
'8c': 'KEY_KPJPCOMMA',
|
||||||
|
'90': 'KEY_HANGEUL',
|
||||||
|
'91': 'KEY_HANJA',
|
||||||
|
'92': 'KEY_KATAKANA',
|
||||||
|
'93': 'KEY_HIRAGANA',
|
||||||
|
'94': 'KEY_ZENKAKUHANKAKU',
|
||||||
|
'b6': 'KEY_KPLEFTPAREN',
|
||||||
|
'b7': 'KEY_KPRIGHTPAREN',
|
||||||
|
'e0': 'KEY_LEFTCTRL',
|
||||||
|
'e1': 'KEY_LEFTSHIFT',
|
||||||
|
'e2': 'KEY_LEFTALT',
|
||||||
|
'e3': 'KEY_LEFTMETA',
|
||||||
|
'e4': 'KEY_RIGHTCTRL',
|
||||||
|
'e5': 'KEY_RIGHTSHIFT',
|
||||||
|
'e6': 'KEY_RIGHTALT',
|
||||||
|
'e7': 'KEY_RIGHTMETA',
|
||||||
|
'e8': 'KEY_MEDIA_PLAYPAUSE',
|
||||||
|
'e9': 'KEY_MEDIA_STOPCD',
|
||||||
|
'ea': 'KEY_MEDIA_PREVIOUSSONG',
|
||||||
|
'eb': 'KEY_MEDIA_NEXTSONG',
|
||||||
|
'ec': 'KEY_MEDIA_EJECTCD',
|
||||||
|
'ed': 'KEY_MEDIA_VOLUMEUP',
|
||||||
|
'ee': 'KEY_MEDIA_VOLUMEDOWN',
|
||||||
|
'ef': 'KEY_MEDIA_MUTE',
|
||||||
|
'f0': 'KEY_MEDIA_WWW',
|
||||||
|
'f1': 'KEY_MEDIA_BACK',
|
||||||
|
'f2': 'KEY_MEDIA_FORWARD',
|
||||||
|
'f3': 'KEY_MEDIA_STOP',
|
||||||
|
'f4': 'KEY_MEDIA_FIND',
|
||||||
|
'f5': 'KEY_MEDIA_SCROLLUP',
|
||||||
|
'f6': 'KEY_MEDIA_SCROLLDOWN',
|
||||||
|
'f7': 'KEY_MEDIA_EDIT',
|
||||||
|
'f8': 'KEY_MEDIA_SLEEP',
|
||||||
|
'f9': 'KEY_MEDIA_COFFEE',
|
||||||
|
'fa': 'KEY_MEDIA_REFRESH',
|
||||||
|
'fb': 'KEY_MEDIA_CALC',
|
||||||
|
}
|
||||||
159
examples/hid_report_parser.py
Normal file
159
examples/hid_report_parser.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
from bumble.colors import color
|
||||||
|
from hid_key_map import base_keys, mod_keys, shift_map
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
def get_key(modifier: str, key: str) -> str:
|
||||||
|
if modifier == '22':
|
||||||
|
modifier = '02'
|
||||||
|
if modifier in mod_keys:
|
||||||
|
modifier = mod_keys[modifier]
|
||||||
|
else:
|
||||||
|
return ''
|
||||||
|
if key in base_keys:
|
||||||
|
key = base_keys[key]
|
||||||
|
else:
|
||||||
|
return ''
|
||||||
|
if (modifier == 'left_shift' or modifier == 'right_shift') and key in shift_map:
|
||||||
|
key = shift_map[key]
|
||||||
|
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
class Keyboard:
|
||||||
|
def __init__(self): # type: ignore
|
||||||
|
self.report = [
|
||||||
|
[ # Bit array for Modifier keys
|
||||||
|
0, # Right GUI - (usually the Windows key)
|
||||||
|
0, # Right ALT
|
||||||
|
0, # Right Shift
|
||||||
|
0, # Right Control
|
||||||
|
0, # Left GUI - (usually the Windows key)
|
||||||
|
0, # Left ALT
|
||||||
|
0, # Left Shift
|
||||||
|
0, # Left Control
|
||||||
|
],
|
||||||
|
0x00, # Vendor reserved
|
||||||
|
'', # Rest is space for 6 keys
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
|
||||||
|
def decode_keyboard_report(self, input_report: bytes, report_length: int) -> None:
|
||||||
|
if report_length >= 8:
|
||||||
|
modifier = input_report[1]
|
||||||
|
self.report[0] = [int(x) for x in '{0:08b}'.format(modifier)]
|
||||||
|
self.report[0].reverse() # type: ignore
|
||||||
|
|
||||||
|
modifier_key = str((modifier & 0x22).to_bytes(1, "big").hex())
|
||||||
|
keycodes = []
|
||||||
|
for k in range(3, report_length):
|
||||||
|
keycodes.append(str(input_report[k].to_bytes(1, "big").hex()))
|
||||||
|
self.report[k - 1] = get_key(modifier_key, keycodes[k - 3])
|
||||||
|
else:
|
||||||
|
print(color('Warning: Not able to parse report', 'yellow'))
|
||||||
|
|
||||||
|
def print_keyboard_report(self) -> None:
|
||||||
|
print(color('\tKeyboard Input Received', 'green', None, 'bold'))
|
||||||
|
print(color(f'Keys:', 'white', None, 'bold'))
|
||||||
|
for i in range(1, 7):
|
||||||
|
print(
|
||||||
|
color(f' Key{i}{" ":>8s}= ', 'cyan', None, 'bold'), self.report[i + 1]
|
||||||
|
)
|
||||||
|
print(color(f'\nModifier Keys:', 'white', None, 'bold'))
|
||||||
|
print(
|
||||||
|
color(f' Left Ctrl : ', 'cyan'),
|
||||||
|
f'{self.report[0][0] == 1!s:<5}', # type: ignore
|
||||||
|
color(f' Left Shift : ', 'cyan'),
|
||||||
|
f'{self.report[0][1] == 1!s:<5}', # type: ignore
|
||||||
|
color(f' Left ALT : ', 'cyan'),
|
||||||
|
f'{self.report[0][2] == 1!s:<5}', # type: ignore
|
||||||
|
color(f' Left GUI : ', 'cyan'),
|
||||||
|
f'{self.report[0][3] == 1!s:<5}\n', # type: ignore
|
||||||
|
color(f' Right Ctrl : ', 'cyan'),
|
||||||
|
f'{self.report[0][4] == 1!s:<5}', # type: ignore
|
||||||
|
color(f' Right Shift : ', 'cyan'),
|
||||||
|
f'{self.report[0][5] == 1!s:<5}', # type: ignore
|
||||||
|
color(f' Right ALT : ', 'cyan'),
|
||||||
|
f'{self.report[0][6] == 1!s:<5}', # type: ignore
|
||||||
|
color(f' Right GUI : ', 'cyan'),
|
||||||
|
f'{self.report[0][7] == 1!s:<5}', # type: ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
class Mouse:
|
||||||
|
def __init__(self): # type: ignore
|
||||||
|
self.report = [
|
||||||
|
[ # Bit array for Buttons
|
||||||
|
0, # Button 1 (primary/trigger
|
||||||
|
0, # Button 2 (secondary)
|
||||||
|
0, # Button 3 (tertiary)
|
||||||
|
0, # Button 4
|
||||||
|
0, # Button 5
|
||||||
|
0, # unused padding bits
|
||||||
|
0, # unused padding bits
|
||||||
|
0, # unused padding bits
|
||||||
|
],
|
||||||
|
0, # X
|
||||||
|
0, # Y
|
||||||
|
0, # Wheel
|
||||||
|
0, # AC Pan
|
||||||
|
]
|
||||||
|
|
||||||
|
def decode_mouse_report(self, input_report: bytes, report_length: int) -> None:
|
||||||
|
self.report[0] = [int(x) for x in '{0:08b}'.format(input_report[1])]
|
||||||
|
self.report[0].reverse() # type: ignore
|
||||||
|
self.report[1] = input_report[2]
|
||||||
|
self.report[2] = input_report[3]
|
||||||
|
if report_length in [5, 6]:
|
||||||
|
self.report[3] = input_report[4]
|
||||||
|
self.report[4] = input_report[5] if report_length == 6 else 0
|
||||||
|
|
||||||
|
def print_mouse_report(self) -> None:
|
||||||
|
print(color('\tMouse Input Received', 'green', None, 'bold'))
|
||||||
|
print(
|
||||||
|
color(f' Button 1 (primary/trigger) = ', 'cyan'),
|
||||||
|
self.report[0][0] == 1, # type: ignore
|
||||||
|
color(f'\n Button 2 (secondary) = ', 'cyan'),
|
||||||
|
self.report[0][1] == 1, # type: ignore
|
||||||
|
color(f'\n Button 3 (tertiary) = ', 'cyan'),
|
||||||
|
self.report[0][2] == 1, # type: ignore
|
||||||
|
color(f'\n Button4 = ', 'cyan'),
|
||||||
|
self.report[0][3] == 1, # type: ignore
|
||||||
|
color(f'\n Button5 = ', 'cyan'),
|
||||||
|
self.report[0][4] == 1, # type: ignore
|
||||||
|
color(f'\n X (X-axis displacement) = ', 'cyan'),
|
||||||
|
self.report[1],
|
||||||
|
color(f'\n Y (Y-axis displacement) = ', 'cyan'),
|
||||||
|
self.report[2],
|
||||||
|
color(f'\n Wheel = ', 'cyan'),
|
||||||
|
self.report[3],
|
||||||
|
color(f'\n AC PAN = ', 'cyan'),
|
||||||
|
self.report[4],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
class ReportParser:
|
||||||
|
@staticmethod
|
||||||
|
def parse_input_report(input_report: bytes) -> None:
|
||||||
|
|
||||||
|
report_id = input_report[0] # pylint: disable=unsubscriptable-object
|
||||||
|
report_length = len(input_report)
|
||||||
|
|
||||||
|
# Keyboard input report (report id = 1)
|
||||||
|
if report_id == 1 and report_length >= 8:
|
||||||
|
keyboard = Keyboard() # type: ignore
|
||||||
|
keyboard.decode_keyboard_report(input_report, report_length)
|
||||||
|
keyboard.print_keyboard_report()
|
||||||
|
# Mouse input report (report id = 2)
|
||||||
|
elif report_id == 2 and report_length in [4, 5, 6]:
|
||||||
|
mouse = Mouse() # type: ignore
|
||||||
|
mouse.decode_mouse_report(input_report, report_length)
|
||||||
|
mouse.print_mouse_report()
|
||||||
|
else:
|
||||||
|
print(color(f'Warning: Parse Error Report ID {report_id}', 'yellow'))
|
||||||
5
examples/leaudio.json
Normal file
5
examples/leaudio.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "Bumble-LEA",
|
||||||
|
"keystore": "JsonKeyStore",
|
||||||
|
"advertising_interval": 100
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user