Compare commits
85 Commits
v0.0.175
...
gbg/androi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dc2e4036c | ||
|
|
268f6b0d51 | ||
|
|
46239b321b | ||
|
|
8a536cd522 | ||
|
|
f9f5d7ccbd | ||
|
|
e08c84dd20 | ||
|
|
8b46136703 | ||
|
|
aac8d89cd0 | ||
|
|
24e75bfeab | ||
|
|
42868b08d3 | ||
|
|
19b61d9ac0 | ||
|
|
db2a2e2bb9 | ||
|
|
e1fdb12647 | ||
|
|
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 | ||
|
|
511ab4b630 | ||
|
|
45edcafb06 | ||
|
|
9f0bcc131f | ||
|
|
7e331c2944 | ||
|
|
50fd2218fa |
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
|
||||||
8
.github/workflows/python-build-test.yml
vendored
@@ -56,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]"
|
||||||
@@ -65,15 +65,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
components: clippy,rustfmt
|
components: clippy,rustfmt
|
||||||
toolchain: ${{ matrix.rust-version }}
|
toolchain: ${{ matrix.rust-version }}
|
||||||
|
- name: Install Rust dependencies
|
||||||
|
run: cargo install cargo-all-features # allows building/testing combinations of features
|
||||||
- name: Check License Headers
|
- name: Check License Headers
|
||||||
run: cd rust && cargo run --features dev-tools --bin file-header check-all
|
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 && cargo build --all-features --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
|
# Lints after build so what clippy needs is already built
|
||||||
- name: Rust Lints
|
- name: Rust Lints
|
||||||
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings && cargo clippy --all-features --all-targets -- --deny warnings
|
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.
|
# 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`.
|
# Once we're ready to publish binaries, this should be built with `--release`.
|
||||||
- name: Build Bumble CLI
|
- name: Build Bumble CLI
|
||||||
|
|||||||
1
.vscode/settings.json
vendored
@@ -47,6 +47,7 @@
|
|||||||
"protobuf",
|
"protobuf",
|
||||||
"psms",
|
"psms",
|
||||||
"pyee",
|
"pyee",
|
||||||
|
"Pyodide",
|
||||||
"pyusb",
|
"pyusb",
|
||||||
"rfcomm",
|
"rfcomm",
|
||||||
"ROHC",
|
"ROHC",
|
||||||
|
|||||||
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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -117,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'))
|
||||||
|
|
||||||
@@ -195,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
@@ -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,
|
||||||
|
|||||||
@@ -641,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")}')
|
||||||
|
|||||||
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
|
||||||
|
|||||||
484
bumble/avdtp.py
@@ -1000,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
|
||||||
|
|
||||||
@@ -1051,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
|
||||||
|
|||||||
129
bumble/device.py
@@ -33,6 +33,8 @@ from typing import (
|
|||||||
Tuple,
|
Tuple,
|
||||||
Type,
|
Type,
|
||||||
Union,
|
Union,
|
||||||
|
cast,
|
||||||
|
overload,
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -101,6 +103,7 @@ from .hci import (
|
|||||||
HCI_LE_Set_Advertising_Data_Command,
|
HCI_LE_Set_Advertising_Data_Command,
|
||||||
HCI_LE_Set_Advertising_Enable_Command,
|
HCI_LE_Set_Advertising_Enable_Command,
|
||||||
HCI_LE_Set_Advertising_Parameters_Command,
|
HCI_LE_Set_Advertising_Parameters_Command,
|
||||||
|
HCI_LE_Set_Data_Length_Command,
|
||||||
HCI_LE_Set_Default_PHY_Command,
|
HCI_LE_Set_Default_PHY_Command,
|
||||||
HCI_LE_Set_Extended_Scan_Enable_Command,
|
HCI_LE_Set_Extended_Scan_Enable_Command,
|
||||||
HCI_LE_Set_Extended_Scan_Parameters_Command,
|
HCI_LE_Set_Extended_Scan_Parameters_Command,
|
||||||
@@ -151,6 +154,7 @@ from .utils import (
|
|||||||
CompositeEventEmitter,
|
CompositeEventEmitter,
|
||||||
setup_event_forwarding,
|
setup_event_forwarding,
|
||||||
composite_listener,
|
composite_listener,
|
||||||
|
deprecated,
|
||||||
)
|
)
|
||||||
from .keys import (
|
from .keys import (
|
||||||
KeyStore,
|
KeyStore,
|
||||||
@@ -670,9 +674,7 @@ class Connection(CompositeEventEmitter):
|
|||||||
def send_l2cap_pdu(self, cid: int, pdu: bytes) -> None:
|
def send_l2cap_pdu(self, cid: int, pdu: bytes) -> None:
|
||||||
self.device.send_l2cap_pdu(self.handle, cid, pdu)
|
self.device.send_l2cap_pdu(self.handle, cid, pdu)
|
||||||
|
|
||||||
def create_l2cap_connector(self, psm):
|
@deprecated("Please use create_l2cap_channel()")
|
||||||
return self.device.create_l2cap_connector(self, psm)
|
|
||||||
|
|
||||||
async def open_l2cap_channel(
|
async def open_l2cap_channel(
|
||||||
self,
|
self,
|
||||||
psm,
|
psm,
|
||||||
@@ -682,6 +684,23 @@ class Connection(CompositeEventEmitter):
|
|||||||
):
|
):
|
||||||
return await self.device.open_l2cap_channel(self, psm, max_credits, mtu, mps)
|
return await self.device.open_l2cap_channel(self, psm, max_credits, mtu, mps)
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def create_l2cap_channel(
|
||||||
|
self, spec: l2cap.ClassicChannelSpec
|
||||||
|
) -> l2cap.ClassicChannel:
|
||||||
|
...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def create_l2cap_channel(
|
||||||
|
self, spec: l2cap.LeCreditBasedChannelSpec
|
||||||
|
) -> l2cap.LeCreditBasedChannel:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def create_l2cap_channel(
|
||||||
|
self, spec: Union[l2cap.ClassicChannelSpec, l2cap.LeCreditBasedChannelSpec]
|
||||||
|
) -> Union[l2cap.ClassicChannel, l2cap.LeCreditBasedChannel]:
|
||||||
|
return await self.device.create_l2cap_channel(connection=self, spec=spec)
|
||||||
|
|
||||||
async def disconnect(
|
async def disconnect(
|
||||||
self, reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR
|
self, reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -718,6 +737,9 @@ class Connection(CompositeEventEmitter):
|
|||||||
self.remove_listener('disconnection', abort.set_result)
|
self.remove_listener('disconnection', abort.set_result)
|
||||||
self.remove_listener('disconnection_failure', abort.set_exception)
|
self.remove_listener('disconnection_failure', abort.set_exception)
|
||||||
|
|
||||||
|
async def set_data_length(self, tx_octets, tx_time) -> None:
|
||||||
|
return await self.device.set_data_length(self, tx_octets, tx_time)
|
||||||
|
|
||||||
async def update_parameters(
|
async def update_parameters(
|
||||||
self,
|
self,
|
||||||
connection_interval_min,
|
connection_interval_min,
|
||||||
@@ -829,6 +851,9 @@ class DeviceConfiguration:
|
|||||||
self.connectable = config.get('connectable', self.connectable)
|
self.connectable = config.get('connectable', self.connectable)
|
||||||
self.discoverable = config.get('discoverable', self.discoverable)
|
self.discoverable = config.get('discoverable', self.discoverable)
|
||||||
self.gatt_services = config.get('gatt_services', self.gatt_services)
|
self.gatt_services = config.get('gatt_services', self.gatt_services)
|
||||||
|
self.address_resolution_offload = config.get(
|
||||||
|
'address_resolution_offload', self.address_resolution_offload
|
||||||
|
)
|
||||||
|
|
||||||
# Load or synthesize an IRK
|
# Load or synthesize an IRK
|
||||||
irk = config.get('irk')
|
irk = config.get('irk')
|
||||||
@@ -1180,15 +1205,11 @@ class Device(CompositeEventEmitter):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def create_l2cap_connector(self, connection, psm):
|
@deprecated("Please use create_l2cap_server()")
|
||||||
return lambda: self.l2cap_channel_manager.connect(connection, psm)
|
|
||||||
|
|
||||||
def create_l2cap_registrar(self, psm):
|
|
||||||
return lambda handler: self.register_l2cap_server(psm, handler)
|
|
||||||
|
|
||||||
def register_l2cap_server(self, psm, server) -> int:
|
def register_l2cap_server(self, psm, server) -> int:
|
||||||
return self.l2cap_channel_manager.register_server(psm, server)
|
return self.l2cap_channel_manager.register_server(psm, server)
|
||||||
|
|
||||||
|
@deprecated("Please use create_l2cap_server()")
|
||||||
def register_l2cap_channel_server(
|
def register_l2cap_channel_server(
|
||||||
self,
|
self,
|
||||||
psm,
|
psm,
|
||||||
@@ -1201,6 +1222,7 @@ class Device(CompositeEventEmitter):
|
|||||||
psm, server, max_credits, mtu, mps
|
psm, server, max_credits, mtu, mps
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@deprecated("Please use create_l2cap_channel()")
|
||||||
async def open_l2cap_channel(
|
async def open_l2cap_channel(
|
||||||
self,
|
self,
|
||||||
connection,
|
connection,
|
||||||
@@ -1213,6 +1235,74 @@ class Device(CompositeEventEmitter):
|
|||||||
connection, psm, max_credits, mtu, mps
|
connection, psm, max_credits, mtu, mps
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def create_l2cap_channel(
|
||||||
|
self,
|
||||||
|
connection: Connection,
|
||||||
|
spec: l2cap.ClassicChannelSpec,
|
||||||
|
) -> l2cap.ClassicChannel:
|
||||||
|
...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def create_l2cap_channel(
|
||||||
|
self,
|
||||||
|
connection: Connection,
|
||||||
|
spec: l2cap.LeCreditBasedChannelSpec,
|
||||||
|
) -> l2cap.LeCreditBasedChannel:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def create_l2cap_channel(
|
||||||
|
self,
|
||||||
|
connection: Connection,
|
||||||
|
spec: Union[l2cap.ClassicChannelSpec, l2cap.LeCreditBasedChannelSpec],
|
||||||
|
) -> Union[l2cap.ClassicChannel, l2cap.LeCreditBasedChannel]:
|
||||||
|
if isinstance(spec, l2cap.ClassicChannelSpec):
|
||||||
|
return await self.l2cap_channel_manager.create_classic_channel(
|
||||||
|
connection=connection, spec=spec
|
||||||
|
)
|
||||||
|
if isinstance(spec, l2cap.LeCreditBasedChannelSpec):
|
||||||
|
return await self.l2cap_channel_manager.create_le_credit_based_channel(
|
||||||
|
connection=connection, spec=spec
|
||||||
|
)
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def create_l2cap_server(
|
||||||
|
self,
|
||||||
|
spec: l2cap.ClassicChannelSpec,
|
||||||
|
handler: Optional[Callable[[l2cap.ClassicChannel], Any]] = None,
|
||||||
|
) -> l2cap.ClassicChannelServer:
|
||||||
|
...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def create_l2cap_server(
|
||||||
|
self,
|
||||||
|
spec: l2cap.LeCreditBasedChannelSpec,
|
||||||
|
handler: Optional[Callable[[l2cap.LeCreditBasedChannel], Any]] = None,
|
||||||
|
) -> l2cap.LeCreditBasedChannelServer:
|
||||||
|
...
|
||||||
|
|
||||||
|
def create_l2cap_server(
|
||||||
|
self,
|
||||||
|
spec: Union[l2cap.ClassicChannelSpec, l2cap.LeCreditBasedChannelSpec],
|
||||||
|
handler: Union[
|
||||||
|
Callable[[l2cap.ClassicChannel], Any],
|
||||||
|
Callable[[l2cap.LeCreditBasedChannel], Any],
|
||||||
|
None,
|
||||||
|
] = None,
|
||||||
|
) -> Union[l2cap.ClassicChannelServer, l2cap.LeCreditBasedChannelServer]:
|
||||||
|
if isinstance(spec, l2cap.ClassicChannelSpec):
|
||||||
|
return self.l2cap_channel_manager.create_classic_server(
|
||||||
|
spec=spec,
|
||||||
|
handler=cast(Callable[[l2cap.ClassicChannel], Any], handler),
|
||||||
|
)
|
||||||
|
elif isinstance(spec, l2cap.LeCreditBasedChannelSpec):
|
||||||
|
return self.l2cap_channel_manager.create_le_credit_based_server(
|
||||||
|
handler=cast(Callable[[l2cap.LeCreditBasedChannel], Any], handler),
|
||||||
|
spec=spec,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unexpected mode {spec}')
|
||||||
|
|
||||||
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
||||||
self.host.send_l2cap_pdu(connection_handle, cid, pdu)
|
self.host.send_l2cap_pdu(connection_handle, cid, pdu)
|
||||||
|
|
||||||
@@ -1222,7 +1312,7 @@ class Device(CompositeEventEmitter):
|
|||||||
self.host.send_command(command, check_result), self.command_timeout
|
self.host.send_command(command, check_result), self.command_timeout
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError as error:
|
except asyncio.TimeoutError as error:
|
||||||
logger.warning('!!! Command timed out')
|
logger.warning(f'!!! Command {command.name} timed out')
|
||||||
raise CommandTimeoutError() from error
|
raise CommandTimeoutError() from error
|
||||||
|
|
||||||
async def power_on(self) -> None:
|
async def power_on(self) -> None:
|
||||||
@@ -1323,6 +1413,9 @@ class Device(CompositeEventEmitter):
|
|||||||
# Done
|
# Done
|
||||||
self.powered_on = True
|
self.powered_on = True
|
||||||
|
|
||||||
|
async def reset(self) -> None:
|
||||||
|
await self.host.reset()
|
||||||
|
|
||||||
async def power_off(self) -> None:
|
async def power_off(self) -> None:
|
||||||
if self.powered_on:
|
if self.powered_on:
|
||||||
await self.host.flush()
|
await self.host.flush()
|
||||||
@@ -2104,6 +2197,22 @@ class Device(CompositeEventEmitter):
|
|||||||
)
|
)
|
||||||
self.disconnecting = False
|
self.disconnecting = False
|
||||||
|
|
||||||
|
async def set_data_length(self, connection, tx_octets, tx_time) -> None:
|
||||||
|
if tx_octets < 0x001B or tx_octets > 0x00FB:
|
||||||
|
raise ValueError('tx_octets must be between 0x001B and 0x00FB')
|
||||||
|
|
||||||
|
if tx_time < 0x0148 or tx_time > 0x4290:
|
||||||
|
raise ValueError('tx_time must be between 0x0148 and 0x4290')
|
||||||
|
|
||||||
|
return await self.send_command(
|
||||||
|
HCI_LE_Set_Data_Length_Command(
|
||||||
|
connection_handle=connection.handle,
|
||||||
|
tx_octets=tx_octets,
|
||||||
|
tx_time=tx_time,
|
||||||
|
), # type: ignore[call-arg]
|
||||||
|
check_result=True,
|
||||||
|
)
|
||||||
|
|
||||||
async def update_connection_parameters(
|
async def update_connection_parameters(
|
||||||
self,
|
self,
|
||||||
connection,
|
connection,
|
||||||
|
|||||||
426
bumble/hci.py
@@ -17,10 +17,11 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import collections
|
import collections
|
||||||
|
import enum
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Any, Dict, Callable, Optional, Type, Union
|
from typing import Any, Dict, Callable, Optional, Type, Union, List
|
||||||
|
|
||||||
from .colors import color
|
from .colors import color
|
||||||
from .core import (
|
from .core import (
|
||||||
@@ -121,6 +122,7 @@ HCI_VERSION_BLUETOOTH_CORE_5_0 = 9
|
|||||||
HCI_VERSION_BLUETOOTH_CORE_5_1 = 10
|
HCI_VERSION_BLUETOOTH_CORE_5_1 = 10
|
||||||
HCI_VERSION_BLUETOOTH_CORE_5_2 = 11
|
HCI_VERSION_BLUETOOTH_CORE_5_2 = 11
|
||||||
HCI_VERSION_BLUETOOTH_CORE_5_3 = 12
|
HCI_VERSION_BLUETOOTH_CORE_5_3 = 12
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_5_4 = 13
|
||||||
|
|
||||||
HCI_VERSION_NAMES = {
|
HCI_VERSION_NAMES = {
|
||||||
HCI_VERSION_BLUETOOTH_CORE_1_0B: 'HCI_VERSION_BLUETOOTH_CORE_1_0B',
|
HCI_VERSION_BLUETOOTH_CORE_1_0B: 'HCI_VERSION_BLUETOOTH_CORE_1_0B',
|
||||||
@@ -135,7 +137,8 @@ HCI_VERSION_NAMES = {
|
|||||||
HCI_VERSION_BLUETOOTH_CORE_5_0: 'HCI_VERSION_BLUETOOTH_CORE_5_0',
|
HCI_VERSION_BLUETOOTH_CORE_5_0: 'HCI_VERSION_BLUETOOTH_CORE_5_0',
|
||||||
HCI_VERSION_BLUETOOTH_CORE_5_1: 'HCI_VERSION_BLUETOOTH_CORE_5_1',
|
HCI_VERSION_BLUETOOTH_CORE_5_1: 'HCI_VERSION_BLUETOOTH_CORE_5_1',
|
||||||
HCI_VERSION_BLUETOOTH_CORE_5_2: 'HCI_VERSION_BLUETOOTH_CORE_5_2',
|
HCI_VERSION_BLUETOOTH_CORE_5_2: 'HCI_VERSION_BLUETOOTH_CORE_5_2',
|
||||||
HCI_VERSION_BLUETOOTH_CORE_5_3: 'HCI_VERSION_BLUETOOTH_CORE_5_3'
|
HCI_VERSION_BLUETOOTH_CORE_5_3: 'HCI_VERSION_BLUETOOTH_CORE_5_3',
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_5_4: 'HCI_VERSION_BLUETOOTH_CORE_5_4',
|
||||||
}
|
}
|
||||||
|
|
||||||
# LMP Version
|
# LMP Version
|
||||||
@@ -146,6 +149,7 @@ HCI_COMMAND_PACKET = 0x01
|
|||||||
HCI_ACL_DATA_PACKET = 0x02
|
HCI_ACL_DATA_PACKET = 0x02
|
||||||
HCI_SYNCHRONOUS_DATA_PACKET = 0x03
|
HCI_SYNCHRONOUS_DATA_PACKET = 0x03
|
||||||
HCI_EVENT_PACKET = 0x04
|
HCI_EVENT_PACKET = 0x04
|
||||||
|
HCI_ISO_DATA_PACKET = 0x05
|
||||||
|
|
||||||
# HCI Event Codes
|
# HCI Event Codes
|
||||||
HCI_INQUIRY_COMPLETE_EVENT = 0x01
|
HCI_INQUIRY_COMPLETE_EVENT = 0x01
|
||||||
@@ -1368,6 +1372,7 @@ HCI_LE_SUPPORTED_FEATURES_NAMES = {
|
|||||||
if feature_name.startswith('HCI_') and feature_name.endswith('_LE_SUPPORTED_FEATURE')
|
if feature_name.startswith('HCI_') and feature_name.endswith('_LE_SUPPORTED_FEATURE')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# fmt: on
|
# fmt: on
|
||||||
# pylint: enable=line-too-long
|
# pylint: enable=line-too-long
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@@ -1923,6 +1928,9 @@ class HCI_Packet:
|
|||||||
if packet_type == HCI_ACL_DATA_PACKET:
|
if packet_type == HCI_ACL_DATA_PACKET:
|
||||||
return HCI_AclDataPacket.from_bytes(packet)
|
return HCI_AclDataPacket.from_bytes(packet)
|
||||||
|
|
||||||
|
if packet_type == HCI_SYNCHRONOUS_DATA_PACKET:
|
||||||
|
return HCI_SynchronousDataPacket.from_bytes(packet)
|
||||||
|
|
||||||
if packet_type == HCI_EVENT_PACKET:
|
if packet_type == HCI_EVENT_PACKET:
|
||||||
return HCI_Event.from_bytes(packet)
|
return HCI_Event.from_bytes(packet)
|
||||||
|
|
||||||
@@ -2291,6 +2299,19 @@ class HCI_Read_Clock_Offset_Command(HCI_Command):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[
|
||||||
|
('bd_addr', Address.parse_address),
|
||||||
|
('reason', {'size': 1, 'mapper': HCI_Constant.error_name}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_Reject_Synchronous_Connection_Request_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.1.28 Reject Synchronous Connection Request Command
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command(
|
@HCI_Command.command(
|
||||||
fields=[
|
fields=[
|
||||||
@@ -2452,6 +2473,51 @@ class HCI_Enhanced_Setup_Synchronous_Connection_Command(HCI_Command):
|
|||||||
See Bluetooth spec @ 7.1.45 Enhanced Setup Synchronous Connection Command
|
See Bluetooth spec @ 7.1.45 Enhanced Setup Synchronous Connection Command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
class CodingFormat(enum.IntEnum):
|
||||||
|
U_LOG = 0x00
|
||||||
|
A_LOG = 0x01
|
||||||
|
CVSD = 0x02
|
||||||
|
TRANSPARENT = 0x03
|
||||||
|
PCM = 0x04
|
||||||
|
MSBC = 0x05
|
||||||
|
LC3 = 0x06
|
||||||
|
G729A = 0x07
|
||||||
|
|
||||||
|
def to_bytes(self):
|
||||||
|
return self.value.to_bytes(5, 'little')
|
||||||
|
|
||||||
|
def __bytes__(self):
|
||||||
|
return self.to_bytes()
|
||||||
|
|
||||||
|
class PcmDataFormat(enum.IntEnum):
|
||||||
|
NA = 0x00
|
||||||
|
ONES_COMPLEMENT = 0x01
|
||||||
|
TWOS_COMPLEMENT = 0x02
|
||||||
|
SIGN_MAGNITUDE = 0x03
|
||||||
|
UNSIGNED = 0x04
|
||||||
|
|
||||||
|
class DataPath(enum.IntEnum):
|
||||||
|
HCI = 0x00
|
||||||
|
PCM = 0x01
|
||||||
|
|
||||||
|
class RetransmissionEffort(enum.IntEnum):
|
||||||
|
NO_RETRANSMISSION = 0x00
|
||||||
|
OPTIMIZE_FOR_POWER = 0x01
|
||||||
|
OPTIMIZE_FOR_QUALITY = 0x02
|
||||||
|
DONT_CARE = 0xFF
|
||||||
|
|
||||||
|
class PacketType(enum.IntFlag):
|
||||||
|
HV1 = 0x0001
|
||||||
|
HV2 = 0x0002
|
||||||
|
HV3 = 0x0004
|
||||||
|
EV3 = 0x0008
|
||||||
|
EV4 = 0x0010
|
||||||
|
EV5 = 0x0020
|
||||||
|
NO_2_EV3 = 0x0040
|
||||||
|
NO_3_EV3 = 0x0080
|
||||||
|
NO_2_EV5 = 0x0100
|
||||||
|
NO_3_EV5 = 0x0200
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command(
|
@HCI_Command.command(
|
||||||
@@ -4321,6 +4387,158 @@ class HCI_LE_Set_Host_Feature_Command(HCI_Command):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[
|
||||||
|
('cig_id', 1),
|
||||||
|
('sdu_interval_c_to_p', 3),
|
||||||
|
('sdu_interval_p_to_c', 3),
|
||||||
|
('worst_case_sca', 1),
|
||||||
|
('packing', 1),
|
||||||
|
('framing', 1),
|
||||||
|
('max_transport_latency_c_to_p', 2),
|
||||||
|
('max_transport_latency_p_to_c', 2),
|
||||||
|
[
|
||||||
|
('cis_id', 1),
|
||||||
|
('max_sdu_c_to_p', 2),
|
||||||
|
('max_sdu_p_to_c', 2),
|
||||||
|
('phy_c_to_p', 1),
|
||||||
|
('phy_p_to_c', 1),
|
||||||
|
('rtn_c_to_p', 1),
|
||||||
|
('rtn_p_to_c', 1),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('cig_id', 1),
|
||||||
|
[('connection_handle', 2)],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_LE_Set_CIG_Parameters_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.97 LE Set CIG Parameters Command
|
||||||
|
'''
|
||||||
|
|
||||||
|
cig_id: int
|
||||||
|
sdu_interval_c_to_p: int
|
||||||
|
sdu_interval_p_to_c: int
|
||||||
|
worst_case_sca: int
|
||||||
|
packing: int
|
||||||
|
framing: int
|
||||||
|
max_transport_latency_c_to_p: int
|
||||||
|
max_transport_latency_p_to_c: int
|
||||||
|
cis_id: List[int]
|
||||||
|
max_sdu_c_to_p: List[int]
|
||||||
|
max_sdu_p_to_c: List[int]
|
||||||
|
phy_c_to_p: List[int]
|
||||||
|
phy_p_to_c: List[int]
|
||||||
|
rtn_c_to_p: List[int]
|
||||||
|
rtn_p_to_c: List[int]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[
|
||||||
|
[
|
||||||
|
('cis_connection_handle', 2),
|
||||||
|
('acl_connection_handle', 2),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_LE_Create_CIS_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.99 LE Create CIS command
|
||||||
|
'''
|
||||||
|
|
||||||
|
cis_connection_handle: List[int]
|
||||||
|
acl_connection_handle: List[int]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[('cig_id', 1)],
|
||||||
|
return_parameters_fields=[('status', STATUS_SPEC), ('cig_id', 1)],
|
||||||
|
)
|
||||||
|
class HCI_LE_Remove_CIG_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.100 LE Remove CIG command
|
||||||
|
'''
|
||||||
|
|
||||||
|
cig_id: int
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[('connection_handle', 2)],
|
||||||
|
)
|
||||||
|
class HCI_LE_Accept_CIS_Request_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.101 LE Accept CIS Request command
|
||||||
|
'''
|
||||||
|
|
||||||
|
connection_handle: int
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[('connection_handle', 2)],
|
||||||
|
)
|
||||||
|
class HCI_LE_Reject_CIS_Request_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.102 LE Reject CIS Request command
|
||||||
|
'''
|
||||||
|
|
||||||
|
connection_handle: int
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[
|
||||||
|
('connection_handle', 2),
|
||||||
|
('data_path_direction', 1),
|
||||||
|
('data_path_id', 1),
|
||||||
|
('codec_id', 5),
|
||||||
|
('controller_delay', 3),
|
||||||
|
('codec_configuration', '*'),
|
||||||
|
],
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('connection_handle', 2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_LE_Setup_ISO_Data_Path_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.109 LE Setup ISO Data Path command
|
||||||
|
'''
|
||||||
|
|
||||||
|
connection_handle: int
|
||||||
|
data_path_direction: int
|
||||||
|
data_path_id: int
|
||||||
|
codec_id: int
|
||||||
|
controller_delay: int
|
||||||
|
codec_configuration: int
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[
|
||||||
|
('connection_handle', 2),
|
||||||
|
('data_path_direction', 1),
|
||||||
|
],
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('connection_handle', 2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_LE_Remove_ISO_Data_Path_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.110 LE Remove ISO Data Path command
|
||||||
|
'''
|
||||||
|
|
||||||
|
connection_handle: int
|
||||||
|
data_path_direction: int
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# HCI Events
|
# HCI Events
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -4940,6 +5158,48 @@ class HCI_LE_Channel_Selection_Algorithm_Event(HCI_LE_Meta_Event):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_LE_Meta_Event.event(
|
||||||
|
[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('connection_handle', 2),
|
||||||
|
('cig_sync_delay', 3),
|
||||||
|
('cis_sync_delay', 3),
|
||||||
|
('transport_latency_c_to_p', 3),
|
||||||
|
('transport_latency_p_to_c', 3),
|
||||||
|
('phy_c_to_p', 1),
|
||||||
|
('phy_p_to_c', 1),
|
||||||
|
('nse', 1),
|
||||||
|
('bn_c_to_p', 1),
|
||||||
|
('bn_p_to_c', 1),
|
||||||
|
('ft_c_to_p', 1),
|
||||||
|
('ft_p_to_c', 1),
|
||||||
|
('max_pdu_c_to_p', 2),
|
||||||
|
('max_pdu_p_to_c', 2),
|
||||||
|
('iso_interval', 2),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class HCI_LE_CIS_Established_Event(HCI_LE_Meta_Event):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.7.65.25 LE CIS Established Event
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_LE_Meta_Event.event(
|
||||||
|
[
|
||||||
|
('acl_connection_handle', 2),
|
||||||
|
('cis_connection_handle', 2),
|
||||||
|
('cig_id', 1),
|
||||||
|
('cis_id', 1),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class HCI_LE_CIS_Request_Event(HCI_LE_Meta_Event):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.7.65.26 LE CIS Request Event
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Event.event([('status', STATUS_SPEC)])
|
@HCI_Event.event([('status', STATUS_SPEC)])
|
||||||
class HCI_Inquiry_Complete_Event(HCI_Event):
|
class HCI_Inquiry_Complete_Event(HCI_Event):
|
||||||
@@ -5736,6 +5996,168 @@ class HCI_AclDataPacket(HCI_Packet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class HCI_SynchronousDataPacket(HCI_Packet):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 5.4.3 HCI SCO Data Packets
|
||||||
|
'''
|
||||||
|
|
||||||
|
hci_packet_type = HCI_SYNCHRONOUS_DATA_PACKET
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_bytes(packet: bytes) -> HCI_SynchronousDataPacket:
|
||||||
|
# Read the header
|
||||||
|
h, data_total_length = struct.unpack_from('<HB', packet, 1)
|
||||||
|
connection_handle = h & 0xFFF
|
||||||
|
packet_status = (h >> 12) & 0b11
|
||||||
|
data = packet[4:]
|
||||||
|
if len(data) != data_total_length:
|
||||||
|
raise ValueError(
|
||||||
|
f'invalid packet length {len(data)} != {data_total_length}'
|
||||||
|
)
|
||||||
|
return HCI_SynchronousDataPacket(
|
||||||
|
connection_handle, packet_status, data_total_length, data
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_bytes(self) -> bytes:
|
||||||
|
h = (self.packet_status << 12) | self.connection_handle
|
||||||
|
return (
|
||||||
|
struct.pack('<BHB', HCI_SYNCHRONOUS_DATA_PACKET, h, self.data_total_length)
|
||||||
|
+ self.data
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
connection_handle: int,
|
||||||
|
packet_status: int,
|
||||||
|
data_total_length: int,
|
||||||
|
data: bytes,
|
||||||
|
) -> None:
|
||||||
|
self.connection_handle = connection_handle
|
||||||
|
self.packet_status = packet_status
|
||||||
|
self.data_total_length = data_total_length
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return self.to_bytes()
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return (
|
||||||
|
f'{color("SCO", "blue")}: '
|
||||||
|
f'handle=0x{self.connection_handle:04x}, '
|
||||||
|
f'ps={self.packet_status}, '
|
||||||
|
f'data_total_length={self.data_total_length}, '
|
||||||
|
f'data={self.data.hex()}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class HCI_IsoDataPacket(HCI_Packet):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 5.4.5 HCI ISO Data Packets
|
||||||
|
'''
|
||||||
|
|
||||||
|
hci_packet_type = HCI_ISO_DATA_PACKET
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_bytes(packet: bytes) -> HCI_IsoDataPacket:
|
||||||
|
time_stamp: Optional[int] = None
|
||||||
|
packet_sequence_number: Optional[int] = None
|
||||||
|
iso_sdu_length: Optional[int] = None
|
||||||
|
packet_status_flag: Optional[int] = None
|
||||||
|
|
||||||
|
pos = 1
|
||||||
|
pdu_info, data_total_length = struct.unpack_from('<HH', packet, pos)
|
||||||
|
connection_handle = pdu_info & 0xFFF
|
||||||
|
pb_flag = (pdu_info >> 12) & 0b11
|
||||||
|
ts_flag = (pdu_info >> 14) & 0b01
|
||||||
|
pos += 4
|
||||||
|
|
||||||
|
# pb_flag in (0b00, 0b10) but faster
|
||||||
|
should_include_sdu_info = not (pb_flag & 0b01)
|
||||||
|
|
||||||
|
if ts_flag:
|
||||||
|
if not should_include_sdu_info:
|
||||||
|
logger.warn(f'Timestamp included when pb_flag={bin(pb_flag)}')
|
||||||
|
time_stamp, _ = struct.unpack_from('<I', packet, pos)
|
||||||
|
pos += 4
|
||||||
|
|
||||||
|
if should_include_sdu_info:
|
||||||
|
packet_sequence_number, sdu_info = struct.unpack_from('<HH', packet, pos)
|
||||||
|
iso_sdu_length = sdu_info & 0xFFF
|
||||||
|
packet_status_flag = sdu_info >> 14
|
||||||
|
pos += 4
|
||||||
|
|
||||||
|
iso_sdu_fragment = packet[pos:]
|
||||||
|
return HCI_IsoDataPacket(
|
||||||
|
connection_handle=connection_handle,
|
||||||
|
pb_flag=pb_flag,
|
||||||
|
ts_flag=ts_flag,
|
||||||
|
data_total_length=data_total_length,
|
||||||
|
time_stamp=time_stamp,
|
||||||
|
packet_sequence_number=packet_sequence_number,
|
||||||
|
iso_sdu_length=iso_sdu_length,
|
||||||
|
packet_status_flag=packet_status_flag,
|
||||||
|
iso_sdu_fragment=iso_sdu_fragment,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
connection_handle: int,
|
||||||
|
pb_flag: int,
|
||||||
|
ts_flag: int,
|
||||||
|
data_total_length: int,
|
||||||
|
time_stamp: Optional[int],
|
||||||
|
packet_sequence_number: Optional[int],
|
||||||
|
iso_sdu_length: Optional[int],
|
||||||
|
packet_status_flag: Optional[int],
|
||||||
|
iso_sdu_fragment: bytes,
|
||||||
|
) -> None:
|
||||||
|
self.connection_handle = connection_handle
|
||||||
|
self.pb_flag = pb_flag
|
||||||
|
self.ts_flag = ts_flag
|
||||||
|
self.data_total_length = data_total_length
|
||||||
|
self.time_stamp = time_stamp
|
||||||
|
self.packet_sequence_number = packet_sequence_number
|
||||||
|
self.iso_sdu_length = iso_sdu_length
|
||||||
|
self.packet_status_flag = packet_status_flag
|
||||||
|
self.iso_sdu_fragment = iso_sdu_fragment
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return self.to_bytes()
|
||||||
|
|
||||||
|
def to_bytes(self) -> bytes:
|
||||||
|
fmt = '<BHH'
|
||||||
|
args = [
|
||||||
|
HCI_ISO_DATA_PACKET,
|
||||||
|
self.ts_flag << 14 | self.pb_flag << 12 | self.connection_handle,
|
||||||
|
self.data_total_length,
|
||||||
|
]
|
||||||
|
if self.time_stamp is not None:
|
||||||
|
fmt += 'I'
|
||||||
|
args.append(self.time_stamp)
|
||||||
|
if (
|
||||||
|
self.packet_sequence_number is not None
|
||||||
|
and self.iso_sdu_length is not None
|
||||||
|
and self.packet_status_flag is not None
|
||||||
|
):
|
||||||
|
fmt += 'HH'
|
||||||
|
args += [
|
||||||
|
self.packet_sequence_number,
|
||||||
|
self.iso_sdu_length | self.packet_status_flag << 14,
|
||||||
|
]
|
||||||
|
return struct.pack(fmt, args) + self.iso_sdu_fragment
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return (
|
||||||
|
f'{color("ISO", "blue")}: '
|
||||||
|
f'handle=0x{self.connection_handle:04x}, '
|
||||||
|
f'ps={self.packet_status_flag}, '
|
||||||
|
f'data_total_length={self.data_total_length}, '
|
||||||
|
f'sdu={self.iso_sdu_fragment.hex()}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class HCI_AclDataPacketAssembler:
|
class HCI_AclDataPacketAssembler:
|
||||||
current_data: Optional[bytes]
|
current_data: Optional[bytes]
|
||||||
|
|||||||
168
bumble/hfp.py
@@ -35,6 +35,7 @@ from bumble.core import (
|
|||||||
BT_L2CAP_PROTOCOL_ID,
|
BT_L2CAP_PROTOCOL_ID,
|
||||||
BT_RFCOMM_PROTOCOL_ID,
|
BT_RFCOMM_PROTOCOL_ID,
|
||||||
)
|
)
|
||||||
|
from bumble.hci import HCI_Enhanced_Setup_Synchronous_Connection_Command
|
||||||
from bumble.sdp import (
|
from bumble.sdp import (
|
||||||
DataElement,
|
DataElement,
|
||||||
ServiceAttribute,
|
ServiceAttribute,
|
||||||
@@ -819,3 +820,170 @@ def sdp_records(
|
|||||||
DataElement.unsigned_integer_16(hf_supported_features),
|
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: HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat
|
||||||
|
receive_coding_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.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: HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat = (
|
||||||
|
HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.TRANSPARENT
|
||||||
|
)
|
||||||
|
output_coding_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat = (
|
||||||
|
HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.TRANSPARENT
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
_ESCO_PARAMETERS_CVSD_D0 = EscoParameters(
|
||||||
|
transmit_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
|
||||||
|
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.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=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
|
||||||
|
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.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=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
|
||||||
|
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.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=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
|
||||||
|
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.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=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
|
||||||
|
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.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=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
|
||||||
|
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.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=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.MSBC,
|
||||||
|
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.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
|
||||||
|
),
|
||||||
|
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ESCO_PARAMETERS_MSBC_T2 = EscoParameters(
|
||||||
|
transmit_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.MSBC,
|
||||||
|
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.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
|
||||||
|
),
|
||||||
|
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
ESCO_PERAMETERS = {
|
||||||
|
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
@@ -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)
|
||||||
@@ -21,7 +21,7 @@ import collections
|
|||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable
|
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
|
||||||
@@ -43,6 +43,7 @@ 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,
|
||||||
@@ -67,6 +68,7 @@ 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,
|
||||||
|
HCI_SynchronousDataPacket,
|
||||||
)
|
)
|
||||||
from .core import (
|
from .core import (
|
||||||
BT_BR_EDR_TRANSPORT,
|
BT_BR_EDR_TRANSPORT,
|
||||||
@@ -485,12 +487,14 @@ class Host(AbortableEventEmitter):
|
|||||||
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
|
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
|
||||||
|
|
||||||
# If the packet is a command, invoke the handler for this packet
|
# If the packet is a command, invoke the handler for this packet
|
||||||
if isinstance(packet, HCI_Command):
|
if packet.hci_packet_type == HCI_COMMAND_PACKET:
|
||||||
self.on_hci_command_packet(packet)
|
self.on_hci_command_packet(cast(HCI_Command, packet))
|
||||||
elif isinstance(packet, HCI_Event):
|
elif packet.hci_packet_type == HCI_EVENT_PACKET:
|
||||||
self.on_hci_event_packet(packet)
|
self.on_hci_event_packet(cast(HCI_Event, packet))
|
||||||
elif isinstance(packet, HCI_AclDataPacket):
|
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))
|
||||||
else:
|
else:
|
||||||
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
|
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
|
||||||
|
|
||||||
@@ -507,6 +511,10 @@ class Host(AbortableEventEmitter):
|
|||||||
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_hci_sco_data_packet(self, packet: HCI_SynchronousDataPacket) -> None:
|
||||||
|
# Experimental
|
||||||
|
self.emit('sco_packet', packet.connection_handle, packet)
|
||||||
|
|
||||||
def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
|
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)
|
||||||
|
|
||||||
@@ -760,7 +768,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
|
||||||
|
|||||||
297
bumble/l2cap.py
@@ -17,6 +17,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
@@ -38,6 +39,7 @@ from typing import (
|
|||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .utils import deprecated
|
||||||
from .colors import color
|
from .colors import color
|
||||||
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
|
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
|
||||||
from .hci import (
|
from .hci import (
|
||||||
@@ -167,6 +169,34 @@ L2CAP_MTU_CONFIGURATION_PARAMETER_TYPE = 0x01
|
|||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class ClassicChannelSpec:
|
||||||
|
psm: Optional[int] = None
|
||||||
|
mtu: int = L2CAP_MIN_BR_EDR_MTU
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class LeCreditBasedChannelSpec:
|
||||||
|
psm: Optional[int] = None
|
||||||
|
mtu: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU
|
||||||
|
mps: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS
|
||||||
|
max_credits: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if (
|
||||||
|
self.max_credits < 1
|
||||||
|
or self.max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
|
||||||
|
):
|
||||||
|
raise ValueError('max credits out of range')
|
||||||
|
if self.mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU:
|
||||||
|
raise ValueError('MTU too small')
|
||||||
|
if (
|
||||||
|
self.mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
|
||||||
|
or self.mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
|
||||||
|
):
|
||||||
|
raise ValueError('MPS out of range')
|
||||||
|
|
||||||
|
|
||||||
class L2CAP_PDU:
|
class L2CAP_PDU:
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part A - 3 DATA PACKET FORMAT
|
See Bluetooth spec @ Vol 3, Part A - 3 DATA PACKET FORMAT
|
||||||
@@ -676,7 +706,7 @@ class L2CAP_LE_Flow_Control_Credit(L2CAP_Control_Frame):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Channel(EventEmitter):
|
class ClassicChannel(EventEmitter):
|
||||||
class State(enum.IntEnum):
|
class State(enum.IntEnum):
|
||||||
# States
|
# States
|
||||||
CLOSED = 0x00
|
CLOSED = 0x00
|
||||||
@@ -990,7 +1020,7 @@ class Channel(EventEmitter):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class LeConnectionOrientedChannel(EventEmitter):
|
class LeCreditBasedChannel(EventEmitter):
|
||||||
"""
|
"""
|
||||||
LE Credit-based Connection Oriented Channel
|
LE Credit-based Connection Oriented Channel
|
||||||
"""
|
"""
|
||||||
@@ -1004,11 +1034,13 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
CONNECTION_ERROR = 5
|
CONNECTION_ERROR = 5
|
||||||
|
|
||||||
out_queue: Deque[bytes]
|
out_queue: Deque[bytes]
|
||||||
connection_result: Optional[asyncio.Future[LeConnectionOrientedChannel]]
|
connection_result: Optional[asyncio.Future[LeCreditBasedChannel]]
|
||||||
disconnection_result: Optional[asyncio.Future[None]]
|
disconnection_result: Optional[asyncio.Future[None]]
|
||||||
|
in_sdu: Optional[bytes]
|
||||||
out_sdu: Optional[bytes]
|
out_sdu: Optional[bytes]
|
||||||
state: State
|
state: State
|
||||||
connection: Connection
|
connection: Connection
|
||||||
|
sink: Optional[Callable[[bytes], Any]]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -1071,7 +1103,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
|
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
|
||||||
self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
|
self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
|
||||||
|
|
||||||
async def connect(self) -> LeConnectionOrientedChannel:
|
async def connect(self) -> LeCreditBasedChannel:
|
||||||
# Check that we're in the right state
|
# Check that we're in the right state
|
||||||
if self.state != self.State.INIT:
|
if self.state != self.State.INIT:
|
||||||
raise InvalidStateError('not in a connectable state')
|
raise InvalidStateError('not in a connectable state')
|
||||||
@@ -1342,15 +1374,67 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class ClassicChannelServer(EventEmitter):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
manager: ChannelManager,
|
||||||
|
psm: int,
|
||||||
|
handler: Optional[Callable[[ClassicChannel], Any]],
|
||||||
|
mtu: int,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.manager = manager
|
||||||
|
self.handler = handler
|
||||||
|
self.psm = psm
|
||||||
|
self.mtu = mtu
|
||||||
|
|
||||||
|
def on_connection(self, channel: ClassicChannel) -> None:
|
||||||
|
self.emit('connection', channel)
|
||||||
|
if self.handler:
|
||||||
|
self.handler(channel)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self.psm in self.manager.servers:
|
||||||
|
del self.manager.servers[self.psm]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class LeCreditBasedChannelServer(EventEmitter):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
manager: ChannelManager,
|
||||||
|
psm: int,
|
||||||
|
handler: Optional[Callable[[LeCreditBasedChannel], Any]],
|
||||||
|
max_credits: int,
|
||||||
|
mtu: int,
|
||||||
|
mps: int,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.manager = manager
|
||||||
|
self.handler = handler
|
||||||
|
self.psm = psm
|
||||||
|
self.max_credits = max_credits
|
||||||
|
self.mtu = mtu
|
||||||
|
self.mps = mps
|
||||||
|
|
||||||
|
def on_connection(self, channel: LeCreditBasedChannel) -> None:
|
||||||
|
self.emit('connection', channel)
|
||||||
|
if self.handler:
|
||||||
|
self.handler(channel)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self.psm in self.manager.le_coc_servers:
|
||||||
|
del self.manager.le_coc_servers[self.psm]
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class ChannelManager:
|
class ChannelManager:
|
||||||
identifiers: Dict[int, int]
|
identifiers: Dict[int, int]
|
||||||
channels: Dict[int, Dict[int, Union[Channel, LeConnectionOrientedChannel]]]
|
channels: Dict[int, Dict[int, Union[ClassicChannel, LeCreditBasedChannel]]]
|
||||||
servers: Dict[int, Callable[[Channel], Any]]
|
servers: Dict[int, ClassicChannelServer]
|
||||||
le_coc_channels: Dict[int, Dict[int, LeConnectionOrientedChannel]]
|
le_coc_channels: Dict[int, Dict[int, LeCreditBasedChannel]]
|
||||||
le_coc_servers: Dict[
|
le_coc_servers: Dict[int, LeCreditBasedChannelServer]
|
||||||
int, Tuple[Callable[[LeConnectionOrientedChannel], Any], int, int, int]
|
|
||||||
]
|
|
||||||
le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request]
|
le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request]
|
||||||
fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]]
|
fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]]
|
||||||
_host: Optional[Host]
|
_host: Optional[Host]
|
||||||
@@ -1429,21 +1513,6 @@ class ChannelManager:
|
|||||||
|
|
||||||
raise RuntimeError('no free CID')
|
raise RuntimeError('no free CID')
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_le_coc_parameters(max_credits: int, mtu: int, mps: int) -> None:
|
|
||||||
if (
|
|
||||||
max_credits < 1
|
|
||||||
or max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
|
|
||||||
):
|
|
||||||
raise ValueError('max credits out of range')
|
|
||||||
if mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU:
|
|
||||||
raise ValueError('MTU too small')
|
|
||||||
if (
|
|
||||||
mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
|
|
||||||
or mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
|
|
||||||
):
|
|
||||||
raise ValueError('MPS out of range')
|
|
||||||
|
|
||||||
def next_identifier(self, connection: Connection) -> int:
|
def next_identifier(self, connection: Connection) -> int:
|
||||||
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
|
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
|
||||||
self.identifiers[connection.handle] = identifier
|
self.identifiers[connection.handle] = identifier
|
||||||
@@ -1458,8 +1527,22 @@ class ChannelManager:
|
|||||||
if cid in self.fixed_channels:
|
if cid in self.fixed_channels:
|
||||||
del self.fixed_channels[cid]
|
del self.fixed_channels[cid]
|
||||||
|
|
||||||
def register_server(self, psm: int, server: Callable[[Channel], Any]) -> int:
|
@deprecated("Please use create_classic_server")
|
||||||
if psm == 0:
|
def register_server(
|
||||||
|
self,
|
||||||
|
psm: int,
|
||||||
|
server: Callable[[ClassicChannel], Any],
|
||||||
|
) -> int:
|
||||||
|
return self.create_classic_server(
|
||||||
|
handler=server, spec=ClassicChannelSpec(psm=psm)
|
||||||
|
).psm
|
||||||
|
|
||||||
|
def create_classic_server(
|
||||||
|
self,
|
||||||
|
spec: ClassicChannelSpec,
|
||||||
|
handler: Optional[Callable[[ClassicChannel], Any]] = None,
|
||||||
|
) -> ClassicChannelServer:
|
||||||
|
if not spec.psm:
|
||||||
# Find a free PSM
|
# Find a free PSM
|
||||||
for candidate in range(
|
for candidate in range(
|
||||||
L2CAP_PSM_DYNAMIC_RANGE_START, L2CAP_PSM_DYNAMIC_RANGE_END + 1, 2
|
L2CAP_PSM_DYNAMIC_RANGE_START, L2CAP_PSM_DYNAMIC_RANGE_END + 1, 2
|
||||||
@@ -1468,62 +1551,75 @@ class ChannelManager:
|
|||||||
continue
|
continue
|
||||||
if candidate in self.servers:
|
if candidate in self.servers:
|
||||||
continue
|
continue
|
||||||
psm = candidate
|
spec.psm = candidate
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
raise InvalidStateError('no free PSM')
|
raise InvalidStateError('no free PSM')
|
||||||
else:
|
else:
|
||||||
# Check that the PSM isn't already in use
|
# Check that the PSM isn't already in use
|
||||||
if psm in self.servers:
|
if spec.psm in self.servers:
|
||||||
raise ValueError('PSM already in use')
|
raise ValueError('PSM already in use')
|
||||||
|
|
||||||
# Check that the PSM is valid
|
# Check that the PSM is valid
|
||||||
if psm % 2 == 0:
|
if spec.psm % 2 == 0:
|
||||||
raise ValueError('invalid PSM (not odd)')
|
raise ValueError('invalid PSM (not odd)')
|
||||||
check = psm >> 8
|
check = spec.psm >> 8
|
||||||
while check:
|
while check:
|
||||||
if check % 2 != 0:
|
if check % 2 != 0:
|
||||||
raise ValueError('invalid PSM')
|
raise ValueError('invalid PSM')
|
||||||
check >>= 8
|
check >>= 8
|
||||||
|
|
||||||
self.servers[psm] = server
|
self.servers[spec.psm] = ClassicChannelServer(self, spec.psm, handler, spec.mtu)
|
||||||
|
|
||||||
return psm
|
return self.servers[spec.psm]
|
||||||
|
|
||||||
|
@deprecated("Please use create_le_credit_based_server()")
|
||||||
def register_le_coc_server(
|
def register_le_coc_server(
|
||||||
self,
|
self,
|
||||||
psm: int,
|
psm: int,
|
||||||
server: Callable[[LeConnectionOrientedChannel], Any],
|
server: Callable[[LeCreditBasedChannel], Any],
|
||||||
max_credits: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
|
max_credits: int,
|
||||||
mtu: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
|
mtu: int,
|
||||||
mps: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
|
mps: int,
|
||||||
) -> int:
|
) -> int:
|
||||||
self.check_le_coc_parameters(max_credits, mtu, mps)
|
return self.create_le_credit_based_server(
|
||||||
|
spec=LeCreditBasedChannelSpec(
|
||||||
|
psm=None if psm == 0 else psm, mtu=mtu, mps=mps, max_credits=max_credits
|
||||||
|
),
|
||||||
|
handler=server,
|
||||||
|
).psm
|
||||||
|
|
||||||
if psm == 0:
|
def create_le_credit_based_server(
|
||||||
|
self,
|
||||||
|
spec: LeCreditBasedChannelSpec,
|
||||||
|
handler: Optional[Callable[[LeCreditBasedChannel], Any]] = None,
|
||||||
|
) -> LeCreditBasedChannelServer:
|
||||||
|
if not spec.psm:
|
||||||
# Find a free PSM
|
# Find a free PSM
|
||||||
for candidate in range(
|
for candidate in range(
|
||||||
L2CAP_LE_PSM_DYNAMIC_RANGE_START, L2CAP_LE_PSM_DYNAMIC_RANGE_END + 1
|
L2CAP_LE_PSM_DYNAMIC_RANGE_START, L2CAP_LE_PSM_DYNAMIC_RANGE_END + 1
|
||||||
):
|
):
|
||||||
if candidate in self.le_coc_servers:
|
if candidate in self.le_coc_servers:
|
||||||
continue
|
continue
|
||||||
psm = candidate
|
spec.psm = candidate
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
raise InvalidStateError('no free PSM')
|
raise InvalidStateError('no free PSM')
|
||||||
else:
|
else:
|
||||||
# Check that the PSM isn't already in use
|
# Check that the PSM isn't already in use
|
||||||
if psm in self.le_coc_servers:
|
if spec.psm in self.le_coc_servers:
|
||||||
raise ValueError('PSM already in use')
|
raise ValueError('PSM already in use')
|
||||||
|
|
||||||
self.le_coc_servers[psm] = (
|
self.le_coc_servers[spec.psm] = LeCreditBasedChannelServer(
|
||||||
server,
|
self,
|
||||||
max_credits or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
|
spec.psm,
|
||||||
mtu or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
|
handler,
|
||||||
mps or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
|
max_credits=spec.max_credits,
|
||||||
|
mtu=spec.mtu,
|
||||||
|
mps=spec.mps,
|
||||||
)
|
)
|
||||||
|
|
||||||
return psm
|
return self.le_coc_servers[spec.psm]
|
||||||
|
|
||||||
def on_disconnection(self, connection_handle: int, _reason: int) -> None:
|
def on_disconnection(self, connection_handle: int, _reason: int) -> None:
|
||||||
logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
|
logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
|
||||||
@@ -1650,13 +1746,13 @@ class ChannelManager:
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f'creating server channel with cid={source_cid} for psm {request.psm}'
|
f'creating server channel with cid={source_cid} for psm {request.psm}'
|
||||||
)
|
)
|
||||||
channel = Channel(
|
channel = ClassicChannel(
|
||||||
self, connection, cid, request.psm, source_cid, L2CAP_MIN_BR_EDR_MTU
|
self, connection, cid, request.psm, source_cid, server.mtu
|
||||||
)
|
)
|
||||||
connection_channels[source_cid] = channel
|
connection_channels[source_cid] = channel
|
||||||
|
|
||||||
# Notify
|
# Notify
|
||||||
server(channel)
|
server.on_connection(channel)
|
||||||
channel.on_connection_request(request)
|
channel.on_connection_request(request)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -1878,7 +1974,7 @@ class ChannelManager:
|
|||||||
self, connection: Connection, cid: int, request
|
self, connection: Connection, cid: int, request
|
||||||
) -> None:
|
) -> None:
|
||||||
if request.le_psm in self.le_coc_servers:
|
if request.le_psm in self.le_coc_servers:
|
||||||
(server, max_credits, mtu, mps) = self.le_coc_servers[request.le_psm]
|
server = self.le_coc_servers[request.le_psm]
|
||||||
|
|
||||||
# Check that the CID isn't already used
|
# Check that the CID isn't already used
|
||||||
le_connection_channels = self.le_coc_channels.setdefault(
|
le_connection_channels = self.le_coc_channels.setdefault(
|
||||||
@@ -1892,8 +1988,8 @@ class ChannelManager:
|
|||||||
L2CAP_LE_Credit_Based_Connection_Response(
|
L2CAP_LE_Credit_Based_Connection_Response(
|
||||||
identifier=request.identifier,
|
identifier=request.identifier,
|
||||||
destination_cid=0,
|
destination_cid=0,
|
||||||
mtu=mtu,
|
mtu=server.mtu,
|
||||||
mps=mps,
|
mps=server.mps,
|
||||||
initial_credits=0,
|
initial_credits=0,
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_SOURCE_CID_ALREADY_ALLOCATED,
|
result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_SOURCE_CID_ALREADY_ALLOCATED,
|
||||||
@@ -1911,8 +2007,8 @@ class ChannelManager:
|
|||||||
L2CAP_LE_Credit_Based_Connection_Response(
|
L2CAP_LE_Credit_Based_Connection_Response(
|
||||||
identifier=request.identifier,
|
identifier=request.identifier,
|
||||||
destination_cid=0,
|
destination_cid=0,
|
||||||
mtu=mtu,
|
mtu=server.mtu,
|
||||||
mps=mps,
|
mps=server.mps,
|
||||||
initial_credits=0,
|
initial_credits=0,
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE,
|
result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE,
|
||||||
@@ -1925,18 +2021,18 @@ class ChannelManager:
|
|||||||
f'creating LE CoC server channel with cid={source_cid} for psm '
|
f'creating LE CoC server channel with cid={source_cid} for psm '
|
||||||
f'{request.le_psm}'
|
f'{request.le_psm}'
|
||||||
)
|
)
|
||||||
channel = LeConnectionOrientedChannel(
|
channel = LeCreditBasedChannel(
|
||||||
self,
|
self,
|
||||||
connection,
|
connection,
|
||||||
request.le_psm,
|
request.le_psm,
|
||||||
source_cid,
|
source_cid,
|
||||||
request.source_cid,
|
request.source_cid,
|
||||||
mtu,
|
server.mtu,
|
||||||
mps,
|
server.mps,
|
||||||
request.initial_credits,
|
request.initial_credits,
|
||||||
request.mtu,
|
request.mtu,
|
||||||
request.mps,
|
request.mps,
|
||||||
max_credits,
|
server.max_credits,
|
||||||
True,
|
True,
|
||||||
)
|
)
|
||||||
connection_channels[source_cid] = channel
|
connection_channels[source_cid] = channel
|
||||||
@@ -1949,16 +2045,16 @@ class ChannelManager:
|
|||||||
L2CAP_LE_Credit_Based_Connection_Response(
|
L2CAP_LE_Credit_Based_Connection_Response(
|
||||||
identifier=request.identifier,
|
identifier=request.identifier,
|
||||||
destination_cid=source_cid,
|
destination_cid=source_cid,
|
||||||
mtu=mtu,
|
mtu=server.mtu,
|
||||||
mps=mps,
|
mps=server.mps,
|
||||||
initial_credits=max_credits,
|
initial_credits=server.max_credits,
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_SUCCESSFUL,
|
result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_SUCCESSFUL,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notify
|
# Notify
|
||||||
server(channel)
|
server.on_connection(channel)
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
f'No LE server for connection 0x{connection.handle:04X} '
|
f'No LE server for connection 0x{connection.handle:04X} '
|
||||||
@@ -2013,37 +2109,51 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel.on_credits(credit.credits)
|
channel.on_credits(credit.credits)
|
||||||
|
|
||||||
def on_channel_closed(self, channel: Channel) -> None:
|
def on_channel_closed(self, channel: ClassicChannel) -> None:
|
||||||
connection_channels = self.channels.get(channel.connection.handle)
|
connection_channels = self.channels.get(channel.connection.handle)
|
||||||
if connection_channels:
|
if connection_channels:
|
||||||
if channel.source_cid in connection_channels:
|
if channel.source_cid in connection_channels:
|
||||||
del connection_channels[channel.source_cid]
|
del connection_channels[channel.source_cid]
|
||||||
|
|
||||||
|
@deprecated("Please use create_le_credit_based_channel()")
|
||||||
async def open_le_coc(
|
async def open_le_coc(
|
||||||
self, connection: Connection, psm: int, max_credits: int, mtu: int, mps: int
|
self, connection: Connection, psm: int, max_credits: int, mtu: int, mps: int
|
||||||
) -> LeConnectionOrientedChannel:
|
) -> LeCreditBasedChannel:
|
||||||
self.check_le_coc_parameters(max_credits, mtu, mps)
|
return await self.create_le_credit_based_channel(
|
||||||
|
connection=connection,
|
||||||
|
spec=LeCreditBasedChannelSpec(
|
||||||
|
psm=psm, max_credits=max_credits, mtu=mtu, mps=mps
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_le_credit_based_channel(
|
||||||
|
self,
|
||||||
|
connection: Connection,
|
||||||
|
spec: LeCreditBasedChannelSpec,
|
||||||
|
) -> LeCreditBasedChannel:
|
||||||
# Find a free CID for the new channel
|
# Find a free CID for the new channel
|
||||||
connection_channels = self.channels.setdefault(connection.handle, {})
|
connection_channels = self.channels.setdefault(connection.handle, {})
|
||||||
source_cid = self.find_free_le_cid(connection_channels)
|
source_cid = self.find_free_le_cid(connection_channels)
|
||||||
if source_cid is None: # Should never happen!
|
if source_cid is None: # Should never happen!
|
||||||
raise RuntimeError('all CIDs already in use')
|
raise RuntimeError('all CIDs already in use')
|
||||||
|
|
||||||
|
if spec.psm is None:
|
||||||
|
raise ValueError('PSM cannot be None')
|
||||||
|
|
||||||
# Create the channel
|
# Create the channel
|
||||||
logger.debug(f'creating coc channel with cid={source_cid} for psm {psm}')
|
logger.debug(f'creating coc channel with cid={source_cid} for psm {spec.psm}')
|
||||||
channel = LeConnectionOrientedChannel(
|
channel = LeCreditBasedChannel(
|
||||||
manager=self,
|
manager=self,
|
||||||
connection=connection,
|
connection=connection,
|
||||||
le_psm=psm,
|
le_psm=spec.psm,
|
||||||
source_cid=source_cid,
|
source_cid=source_cid,
|
||||||
destination_cid=0,
|
destination_cid=0,
|
||||||
mtu=mtu,
|
mtu=spec.mtu,
|
||||||
mps=mps,
|
mps=spec.mps,
|
||||||
credits=0,
|
credits=0,
|
||||||
peer_mtu=0,
|
peer_mtu=0,
|
||||||
peer_mps=0,
|
peer_mps=0,
|
||||||
peer_credits=max_credits,
|
peer_credits=spec.max_credits,
|
||||||
connected=False,
|
connected=False,
|
||||||
)
|
)
|
||||||
connection_channels[source_cid] = channel
|
connection_channels[source_cid] = channel
|
||||||
@@ -2062,7 +2172,15 @@ class ChannelManager:
|
|||||||
|
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
async def connect(self, connection: Connection, psm: int) -> Channel:
|
@deprecated("Please use create_classic_channel()")
|
||||||
|
async def connect(self, connection: Connection, psm: int) -> ClassicChannel:
|
||||||
|
return await self.create_classic_channel(
|
||||||
|
connection=connection, spec=ClassicChannelSpec(psm=psm)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_classic_channel(
|
||||||
|
self, connection: Connection, spec: ClassicChannelSpec
|
||||||
|
) -> ClassicChannel:
|
||||||
# NOTE: this implementation hard-codes BR/EDR
|
# NOTE: this implementation hard-codes BR/EDR
|
||||||
|
|
||||||
# Find a free CID for a new channel
|
# Find a free CID for a new channel
|
||||||
@@ -2071,10 +2189,20 @@ class ChannelManager:
|
|||||||
if source_cid is None: # Should never happen!
|
if source_cid is None: # Should never happen!
|
||||||
raise RuntimeError('all CIDs already in use')
|
raise RuntimeError('all CIDs already in use')
|
||||||
|
|
||||||
|
if spec.psm is None:
|
||||||
|
raise ValueError('PSM cannot be None')
|
||||||
|
|
||||||
# Create the channel
|
# Create the channel
|
||||||
logger.debug(f'creating client channel with cid={source_cid} for psm {psm}')
|
logger.debug(
|
||||||
channel = Channel(
|
f'creating client channel with cid={source_cid} for psm {spec.psm}'
|
||||||
self, connection, L2CAP_SIGNALING_CID, psm, source_cid, L2CAP_MIN_BR_EDR_MTU
|
)
|
||||||
|
channel = ClassicChannel(
|
||||||
|
self,
|
||||||
|
connection,
|
||||||
|
L2CAP_SIGNALING_CID,
|
||||||
|
spec.psm,
|
||||||
|
source_cid,
|
||||||
|
spec.mtu,
|
||||||
)
|
)
|
||||||
connection_channels[source_cid] = channel
|
connection_channels[source_cid] = channel
|
||||||
|
|
||||||
@@ -2086,3 +2214,20 @@ class ChannelManager:
|
|||||||
raise e
|
raise e
|
||||||
|
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Deprecated Classes
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Channel(ClassicChannel):
|
||||||
|
@deprecated("Please use ClassicChannel")
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class LeConnectionOrientedChannel(LeCreditBasedChannel):
|
||||||
|
@deprecated("Please use LeCreditBasedChannel")
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# 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 (
|
||||||
@@ -35,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)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -173,6 +228,14 @@ class PairingConfig:
|
|||||||
PUBLIC = Address.PUBLIC_DEVICE_ADDRESS
|
PUBLIC = Address.PUBLIC_DEVICE_ADDRESS
|
||||||
RANDOM = Address.RANDOM_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,
|
||||||
@@ -180,17 +243,20 @@ class PairingConfig:
|
|||||||
bonding: bool = True,
|
bonding: bool = True,
|
||||||
delegate: Optional[PairingDelegate] = None,
|
delegate: Optional[PairingDelegate] = None,
|
||||||
identity_address_type: Optional[AddressType] = 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.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'identity_address_type={self.identity_address_type}, '
|
f'identity_address_type={self.identity_address_type}, '
|
||||||
f'delegate[{self.delegate.io_capability}])'
|
f'delegate[{self.delegate.io_capability}]), '
|
||||||
|
f'oob[{self.oob}])'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
from bumble.pairing import PairingConfig, PairingDelegate
|
from bumble.pairing import PairingConfig, PairingDelegate
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 contextlib
|
import contextlib
|
||||||
import grpc
|
import grpc
|
||||||
@@ -450,21 +451,18 @@ class SecurityService(SecurityServicer):
|
|||||||
'security_request': pair,
|
'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.debug('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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -674,7 +674,7 @@ 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: Role) -> 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
|
||||||
@@ -887,10 +887,9 @@ class Multiplexer(EventEmitter):
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Client:
|
class Client:
|
||||||
multiplexer: Optional[Multiplexer]
|
multiplexer: Optional[Multiplexer]
|
||||||
l2cap_channel: Optional[l2cap.Channel]
|
l2cap_channel: Optional[l2cap.ClassicChannel]
|
||||||
|
|
||||||
def __init__(self, device: Device, connection: 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
|
||||||
@@ -898,15 +897,15 @@ 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.Role.INITIATOR)
|
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.Role.INITIATOR)
|
||||||
|
|
||||||
# Connect the multiplexer
|
# Connect the multiplexer
|
||||||
@@ -936,7 +935,9 @@ class Server(EventEmitter):
|
|||||||
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:
|
||||||
@@ -960,11 +961,11 @@ 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
|
||||||
|
|||||||
@@ -167,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)
|
||||||
@@ -229,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
|
||||||
@@ -376,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])
|
||||||
@@ -758,16 +758,17 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Client:
|
class Client:
|
||||||
channel: Optional[l2cap.Channel]
|
channel: Optional[l2cap.ClassicChannel]
|
||||||
|
|
||||||
def __init__(self, device: Device) -> None:
|
def __init__(self, connection: Connection) -> None:
|
||||||
self.device = device
|
self.connection = connection
|
||||||
self.pending_request = None
|
self.pending_request = None
|
||||||
self.channel = None
|
self.channel = None
|
||||||
|
|
||||||
async def connect(self, connection: Connection) -> None:
|
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) -> None:
|
async def disconnect(self) -> None:
|
||||||
if self.channel:
|
if self.channel:
|
||||||
@@ -921,7 +922,7 @@ class Client:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Server:
|
class Server:
|
||||||
CONTINUATION_STATE = bytes([0x01, 0x43])
|
CONTINUATION_STATE = bytes([0x01, 0x43])
|
||||||
channel: Optional[l2cap.Channel]
|
channel: Optional[l2cap.ClassicChannel]
|
||||||
Service = NewType('Service', List[ServiceAttribute])
|
Service = NewType('Service', List[ServiceAttribute])
|
||||||
service_records: Dict[int, Service]
|
service_records: Dict[int, Service]
|
||||||
current_response: Union[None, bytes, Tuple[int, List[int]]]
|
current_response: Union[None, bytes, Tuple[int, List[int]]]
|
||||||
@@ -933,7 +934,9 @@ class Server:
|
|||||||
self.current_response = None
|
self.current_response = None
|
||||||
|
|
||||||
def register(self, l2cap_channel_manager: l2cap.ChannelManager) -> None:
|
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}')
|
||||||
|
|||||||
189
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,
|
||||||
@@ -53,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,
|
||||||
)
|
)
|
||||||
@@ -563,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 = bytes(reversed(self.ecc_key.x))
|
||||||
|
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
|
||||||
@@ -640,8 +690,6 @@ class Session:
|
|||||||
self.pres: Optional[bytes] = None
|
self.pres: Optional[bytes] = None
|
||||||
self.ea = None
|
self.ea = None
|
||||||
self.eb = None
|
self.eb = None
|
||||||
self.tk = bytes(16)
|
|
||||||
self.r = bytes(16)
|
|
||||||
self.stk = None
|
self.stk = None
|
||||||
self.ltk = None
|
self.ltk = None
|
||||||
self.ltk_ediv = 0
|
self.ltk_ediv = 0
|
||||||
@@ -659,7 +707,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()
|
||||||
@@ -712,8 +760,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
|
||||||
@@ -729,9 +777,37 @@ 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 None:
|
||||||
|
self.tk = None
|
||||||
|
else:
|
||||||
|
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 (bytes(reversed(self.ecc_key.x)), self.peer_public_key_x)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pka(self) -> bytes:
|
def pka(self) -> bytes:
|
||||||
@@ -768,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
|
||||||
@@ -909,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,
|
||||||
@@ -921,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,
|
||||||
@@ -982,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=bytes(reversed(self.ecc_key.x)),
|
||||||
public_key_y=bytes(reversed(self.manager.ecc_key.y)),
|
public_key_y=bytes(reversed(self.ecc_key.y)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1030,7 +1109,6 @@ class Session:
|
|||||||
self.ltk = crypto.h6(ilk, b'brle')
|
self.ltk = crypto.h6(ilk, b'brle')
|
||||||
|
|
||||||
def distribute_keys(self) -> None:
|
def distribute_keys(self) -> None:
|
||||||
|
|
||||||
# Distribute the keys as required
|
# Distribute the keys as required
|
||||||
if self.is_initiator:
|
if self.is_initiator:
|
||||||
# CTKD: Derive LTK from LinkKey
|
# CTKD: Derive LTK from LinkKey
|
||||||
@@ -1296,7 +1374,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
|
||||||
)
|
)
|
||||||
@@ -1333,15 +1411,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
|
||||||
@@ -1390,15 +1481,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
|
||||||
@@ -1549,12 +1651,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:
|
||||||
@@ -1591,6 +1694,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
|
||||||
@@ -1599,7 +1703,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
|
||||||
@@ -1653,7 +1756,7 @@ class Session:
|
|||||||
# Compute the DH key
|
# Compute the DH key
|
||||||
self.dh_key = bytes(
|
self.dh_key = bytes(
|
||||||
reversed(
|
reversed(
|
||||||
self.manager.ecc_key.dh(
|
self.ecc_key.dh(
|
||||||
bytes(reversed(command.public_key_x)),
|
bytes(reversed(command.public_key_x)),
|
||||||
bytes(reversed(command.public_key_y)),
|
bytes(reversed(command.public_key_y)),
|
||||||
)
|
)
|
||||||
@@ -1661,8 +1764,27 @@ class Session:
|
|||||||
)
|
)
|
||||||
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()
|
||||||
@@ -1673,6 +1795,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()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import logging
|
|||||||
import traceback
|
import traceback
|
||||||
import collections
|
import collections
|
||||||
import sys
|
import sys
|
||||||
|
import warnings
|
||||||
from typing import (
|
from typing import (
|
||||||
Awaitable,
|
Awaitable,
|
||||||
Set,
|
Set,
|
||||||
@@ -33,7 +34,7 @@ from typing import (
|
|||||||
Union,
|
Union,
|
||||||
overload,
|
overload,
|
||||||
)
|
)
|
||||||
from functools import wraps
|
from functools import wraps, partial
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
|
|
||||||
from .colors import color
|
from .colors import color
|
||||||
@@ -410,3 +411,36 @@ 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
|
||||||
|
|||||||
@@ -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,16 +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
|
- 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
|
||||||
|
|
||||||
@@ -75,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:
|
||||||
@@ -99,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:
|
||||||
|
|||||||
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)
|
||||||
|
|
||||||
141
docs/mkdocs/src/extras/android_remote_hci.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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 `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
|
||||||
|
-------
|
||||||
|
|
||||||
|
### Preconditions
|
||||||
|
When the proxy starts (tapping the "Start" button in the app's main activity), it will try to
|
||||||
|
bind to the Bluetooth HAL. This requires disabling SELinux temporarily, and being the only HAL client.
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/scanner/scanner.css
|
||||||
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
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/scanner/scanner.js
|
||||||
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
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/speaker/logo.svg
|
||||||
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
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/speaker/speaker.html
|
||||||
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
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../web/speaker/speaker.py
|
||||||
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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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'))
|
||||||
@@ -53,10 +53,10 @@ def sdp_records():
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# pylint: disable-next=too-many-nested-blocks
|
# pylint: disable-next=too-many-nested-blocks
|
||||||
async def find_a2dp_service(device, connection):
|
async def find_a2dp_service(connection):
|
||||||
# Connect to the SDP Server
|
# Connect to the SDP Server
|
||||||
sdp_client = SDP_Client(device)
|
sdp_client = SDP_Client(connection)
|
||||||
await sdp_client.connect(connection)
|
await sdp_client.connect()
|
||||||
|
|
||||||
# Search for services with an Audio Sink service class
|
# Search for services with an Audio Sink service class
|
||||||
search_result = await sdp_client.search_attributes(
|
search_result = await sdp_client.search_attributes(
|
||||||
@@ -177,7 +177,7 @@ async def main():
|
|||||||
print('*** Encryption on')
|
print('*** Encryption on')
|
||||||
|
|
||||||
# Look for an A2DP service
|
# Look for an A2DP service
|
||||||
avdtp_version = await find_a2dp_service(device, connection)
|
avdtp_version = await find_a2dp_service(connection)
|
||||||
if not avdtp_version:
|
if not avdtp_version:
|
||||||
print(color('!!! no AVDTP service found'))
|
print(color('!!! no AVDTP service found'))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ async def main():
|
|||||||
await device.power_on()
|
await device.power_on()
|
||||||
|
|
||||||
# Create a listener to wait for AVDTP connections
|
# Create a listener to wait for AVDTP connections
|
||||||
listener = Listener(Listener.create_registrar(device))
|
listener = Listener.for_device(device)
|
||||||
listener.on('connection', on_avdtp_connection)
|
listener.on('connection', on_avdtp_connection)
|
||||||
|
|
||||||
if len(sys.argv) >= 5:
|
if len(sys.argv) >= 5:
|
||||||
|
|||||||
@@ -165,9 +165,7 @@ async def main():
|
|||||||
print('*** Encryption on')
|
print('*** Encryption on')
|
||||||
|
|
||||||
# Look for an A2DP service
|
# Look for an A2DP service
|
||||||
avdtp_version = await find_avdtp_service_with_connection(
|
avdtp_version = await find_avdtp_service_with_connection(connection)
|
||||||
device, connection
|
|
||||||
)
|
|
||||||
if not avdtp_version:
|
if not avdtp_version:
|
||||||
print(color('!!! no A2DP service found'))
|
print(color('!!! no A2DP service found'))
|
||||||
return
|
return
|
||||||
@@ -179,7 +177,7 @@ async def main():
|
|||||||
await stream_packets(read, protocol)
|
await stream_packets(read, protocol)
|
||||||
else:
|
else:
|
||||||
# Create a listener to wait for AVDTP connections
|
# Create a listener to wait for AVDTP connections
|
||||||
listener = Listener(Listener.create_registrar(device), version=(1, 2))
|
listener = Listener.for_device(device=device, version=(1, 2))
|
||||||
listener.on(
|
listener.on(
|
||||||
'connection', lambda protocol: on_avdtp_connection(read, protocol)
|
'connection', lambda protocol: on_avdtp_connection(read, protocol)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from bumble import l2cap
|
||||||
from bumble.core import AdvertisingData
|
from bumble.core import AdvertisingData
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
@@ -95,8 +96,10 @@ async def main():
|
|||||||
|
|
||||||
channel.sink = on_data
|
channel.sink = on_data
|
||||||
|
|
||||||
psm = device.register_l2cap_channel_server(0, on_coc, 8)
|
server = device.create_l2cap_server(
|
||||||
print(f'### LE_PSM_OUT = {psm}')
|
spec=l2cap.LeCreditBasedChannelSpec(max_credits=8), handler=on_coc
|
||||||
|
)
|
||||||
|
print(f'### LE_PSM_OUT = {server.psm}')
|
||||||
|
|
||||||
# Add the ASHA service to the GATT server
|
# Add the ASHA service to the GATT server
|
||||||
read_only_properties_characteristic = Characteristic(
|
read_only_properties_characteristic = Characteristic(
|
||||||
@@ -147,7 +150,7 @@ async def main():
|
|||||||
ASHA_LE_PSM_OUT_CHARACTERISTIC,
|
ASHA_LE_PSM_OUT_CHARACTERISTIC,
|
||||||
Characteristic.Properties.READ,
|
Characteristic.Properties.READ,
|
||||||
Characteristic.READABLE,
|
Characteristic.READABLE,
|
||||||
struct.pack('<H', psm),
|
struct.pack('<H', server.psm),
|
||||||
)
|
)
|
||||||
device.add_service(
|
device.add_service(
|
||||||
Service(
|
Service(
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ async def main():
|
|||||||
print(f'=== Connected to {connection.peer_address}!')
|
print(f'=== Connected to {connection.peer_address}!')
|
||||||
|
|
||||||
# Connect to the SDP Server
|
# Connect to the SDP Server
|
||||||
sdp_client = SDP_Client(device)
|
sdp_client = SDP_Client(connection)
|
||||||
await sdp_client.connect(connection)
|
await sdp_client.connect()
|
||||||
|
|
||||||
# List all services in the root browse group
|
# List all services in the root browse group
|
||||||
service_record_handles = await sdp_client.search_services(
|
service_record_handles = await sdp_client.search_services(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from bumble.core import (
|
|||||||
BT_BR_EDR_TRANSPORT,
|
BT_BR_EDR_TRANSPORT,
|
||||||
)
|
)
|
||||||
from bumble import rfcomm, hfp
|
from bumble import rfcomm, hfp
|
||||||
|
from bumble.hci import HCI_SynchronousDataPacket
|
||||||
from bumble.sdp import (
|
from bumble.sdp import (
|
||||||
Client as SDP_Client,
|
Client as SDP_Client,
|
||||||
DataElement,
|
DataElement,
|
||||||
@@ -48,8 +49,8 @@ logger = logging.getLogger(__name__)
|
|||||||
# pylint: disable-next=too-many-nested-blocks
|
# pylint: disable-next=too-many-nested-blocks
|
||||||
async def list_rfcomm_channels(device, connection):
|
async def list_rfcomm_channels(device, connection):
|
||||||
# Connect to the SDP Server
|
# Connect to the SDP Server
|
||||||
sdp_client = SDP_Client(device)
|
sdp_client = SDP_Client(connection)
|
||||||
await sdp_client.connect(connection)
|
await sdp_client.connect()
|
||||||
|
|
||||||
# Search for services that support the Handsfree Profile
|
# Search for services that support the Handsfree Profile
|
||||||
search_result = await sdp_client.search_attributes(
|
search_result = await sdp_client.search_attributes(
|
||||||
@@ -183,7 +184,7 @@ async def main():
|
|||||||
|
|
||||||
# Create a client and start it
|
# Create a client and start it
|
||||||
print('@@@ Starting to RFCOMM client...')
|
print('@@@ Starting to RFCOMM client...')
|
||||||
rfcomm_client = rfcomm.Client(device, connection)
|
rfcomm_client = rfcomm.Client(connection)
|
||||||
rfcomm_mux = await rfcomm_client.start()
|
rfcomm_mux = await rfcomm_client.start()
|
||||||
print('@@@ Started')
|
print('@@@ Started')
|
||||||
|
|
||||||
@@ -197,6 +198,13 @@ async def main():
|
|||||||
print('@@@ Disconnected from RFCOMM server')
|
print('@@@ Disconnected from RFCOMM server')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def on_sco(connection_handle: int, packet: HCI_SynchronousDataPacket):
|
||||||
|
# Reset packet and loopback
|
||||||
|
packet.packet_status = 0
|
||||||
|
device.host.send_hci_packet(packet)
|
||||||
|
|
||||||
|
device.host.on('sco_packet', on_sco)
|
||||||
|
|
||||||
# Protocol loop (just for testing at this point)
|
# Protocol loop (just for testing at this point)
|
||||||
protocol = hfp.HfpProtocol(session)
|
protocol = hfp.HfpProtocol(session)
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
535
examples/run_hid_host.py
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
# 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
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from bumble.colors import color
|
||||||
|
|
||||||
|
from bumble.device import Device
|
||||||
|
from bumble.transport import open_transport_or_link
|
||||||
|
from bumble.core import (
|
||||||
|
BT_HUMAN_INTERFACE_DEVICE_SERVICE,
|
||||||
|
BT_BR_EDR_TRANSPORT,
|
||||||
|
)
|
||||||
|
from bumble.hci import Address
|
||||||
|
from bumble.hid import Host, Message
|
||||||
|
from bumble.sdp import (
|
||||||
|
Client as SDP_Client,
|
||||||
|
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_ALL_ATTRIBUTES_RANGE,
|
||||||
|
SDP_LANGUAGE_BASE_ATTRIBUTE_ID_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||||
|
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||||
|
)
|
||||||
|
from hid_report_parser import ReportParser
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# SDP attributes for Bluetooth HID devices
|
||||||
|
SDP_HID_SERVICE_NAME_ATTRIBUTE_ID = 0x0100
|
||||||
|
SDP_HID_SERVICE_DESCRIPTION_ATTRIBUTE_ID = 0x0101
|
||||||
|
SDP_HID_PROVIDER_NAME_ATTRIBUTE_ID = 0x0102
|
||||||
|
SDP_HID_DEVICE_RELEASE_NUMBER_ATTRIBUTE_ID = 0x0200 # [DEPRECATED]
|
||||||
|
SDP_HID_PARSER_VERSION_ATTRIBUTE_ID = 0x0201
|
||||||
|
SDP_HID_DEVICE_SUBCLASS_ATTRIBUTE_ID = 0x0202
|
||||||
|
SDP_HID_COUNTRY_CODE_ATTRIBUTE_ID = 0x0203
|
||||||
|
SDP_HID_VIRTUAL_CABLE_ATTRIBUTE_ID = 0x0204
|
||||||
|
SDP_HID_RECONNECT_INITIATE_ATTRIBUTE_ID = 0x0205
|
||||||
|
SDP_HID_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0x0206
|
||||||
|
SDP_HID_LANGID_BASE_LIST_ATTRIBUTE_ID = 0x0207
|
||||||
|
SDP_HID_SDP_DISABLE_ATTRIBUTE_ID = 0x0208 # [DEPRECATED]
|
||||||
|
SDP_HID_BATTERY_POWER_ATTRIBUTE_ID = 0x0209
|
||||||
|
SDP_HID_REMOTE_WAKE_ATTRIBUTE_ID = 0x020A
|
||||||
|
SDP_HID_PROFILE_VERSION_ATTRIBUTE_ID = 0x020B # DEPRECATED]
|
||||||
|
SDP_HID_SUPERVISION_TIMEOUT_ATTRIBUTE_ID = 0x020C
|
||||||
|
SDP_HID_NORMALLY_CONNECTABLE_ATTRIBUTE_ID = 0x020D
|
||||||
|
SDP_HID_BOOT_DEVICE_ATTRIBUTE_ID = 0x020E
|
||||||
|
SDP_HID_SSR_HOST_MAX_LATENCY_ATTRIBUTE_ID = 0x020F
|
||||||
|
SDP_HID_SSR_HOST_MIN_TIMEOUT_ATTRIBUTE_ID = 0x0210
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def get_hid_device_sdp_record(connection):
|
||||||
|
|
||||||
|
# Connect to the SDP Server
|
||||||
|
sdp_client = SDP_Client(connection)
|
||||||
|
await sdp_client.connect()
|
||||||
|
if sdp_client:
|
||||||
|
print(color('Connected to SDP Server', 'blue'))
|
||||||
|
else:
|
||||||
|
print(color('Failed to connect to SDP Server', 'red'))
|
||||||
|
|
||||||
|
# List BT HID Device service in the root browse group
|
||||||
|
service_record_handles = await sdp_client.search_services(
|
||||||
|
[BT_HUMAN_INTERFACE_DEVICE_SERVICE]
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(service_record_handles) < 1:
|
||||||
|
await sdp_client.disconnect()
|
||||||
|
raise Exception(
|
||||||
|
color(f'BT HID Device service not found on peer device!!!!', 'red')
|
||||||
|
)
|
||||||
|
|
||||||
|
# For BT_HUMAN_INTERFACE_DEVICE_SERVICE service, get all its attributes
|
||||||
|
for service_record_handle in service_record_handles:
|
||||||
|
attributes = await sdp_client.get_attributes(
|
||||||
|
service_record_handle, [SDP_ALL_ATTRIBUTES_RANGE]
|
||||||
|
)
|
||||||
|
print(color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow'))
|
||||||
|
print(color(f'SDP attributes for HID device', 'magenta'))
|
||||||
|
for attribute in attributes:
|
||||||
|
if attribute.id == SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' Service Record Handle : ', 'cyan'),
|
||||||
|
hex(attribute.value.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' Service Class : ', 'cyan'), attribute.value.value[0].value
|
||||||
|
)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' SDP Browse Group List : ', 'cyan'),
|
||||||
|
attribute.value.value[0].value,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' BT_L2CAP_PROTOCOL_ID : ', 'cyan'),
|
||||||
|
attribute.value.value[0].value[0].value,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' PSM for Bluetooth HID Control channel : ', 'cyan'),
|
||||||
|
hex(attribute.value.value[0].value[1].value),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' BT_HIDP_PROTOCOL_ID : ', 'cyan'),
|
||||||
|
attribute.value.value[1].value[0].value,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_LANGUAGE_BASE_ATTRIBUTE_ID_LIST_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' Lanugage : ', 'cyan'), hex(attribute.value.value[0].value)
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' Encoding : ', 'cyan'), hex(attribute.value.value[1].value)
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' PrimaryLanguageBaseID : ', 'cyan'),
|
||||||
|
hex(attribute.value.value[2].value),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' BT_HUMAN_INTERFACE_DEVICE_SERVICE ', 'cyan'),
|
||||||
|
attribute.value.value[0].value[0].value,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' HID Profileversion number : ', 'cyan'),
|
||||||
|
hex(attribute.value.value[0].value[1].value),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' BT_L2CAP_PROTOCOL_ID : ', 'cyan'),
|
||||||
|
attribute.value.value[0].value[0].value[0].value,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' PSM for Bluetooth HID Interrupt channel : ', 'cyan'),
|
||||||
|
hex(attribute.value.value[0].value[0].value[1].value),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' BT_HIDP_PROTOCOL_ID : ', 'cyan'),
|
||||||
|
attribute.value.value[0].value[1].value[0].value,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_SERVICE_NAME_ATTRIBUTE_ID:
|
||||||
|
print(color(' Service Name: ', 'cyan'), attribute.value.value)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_SERVICE_DESCRIPTION_ATTRIBUTE_ID:
|
||||||
|
print(color(' Service Description: ', 'cyan'), attribute.value.value)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_PROVIDER_NAME_ATTRIBUTE_ID:
|
||||||
|
print(color(' Provider Name: ', 'cyan'), attribute.value.value)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_DEVICE_RELEASE_NUMBER_ATTRIBUTE_ID:
|
||||||
|
print(color(' Release Number: ', 'cyan'), hex(attribute.value.value))
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_PARSER_VERSION_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' HID Parser Version: ', 'cyan'), hex(attribute.value.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_DEVICE_SUBCLASS_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' HIDDeviceSubclass: ', 'cyan'), hex(attribute.value.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_COUNTRY_CODE_ATTRIBUTE_ID:
|
||||||
|
print(color(' HIDCountryCode: ', 'cyan'), hex(attribute.value.value))
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_VIRTUAL_CABLE_ATTRIBUTE_ID:
|
||||||
|
print(color(' HIDVirtualCable: ', 'cyan'), attribute.value.value)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_RECONNECT_INITIATE_ATTRIBUTE_ID:
|
||||||
|
print(color(' HIDReconnectInitiate: ', 'cyan'), attribute.value.value)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_DESCRIPTOR_LIST_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' HID Report Descriptor type: ', 'cyan'),
|
||||||
|
hex(attribute.value.value[0].value[0].value),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' HID Report DescriptorList: ', 'cyan'),
|
||||||
|
attribute.value.value[0].value[1].value,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_LANGID_BASE_LIST_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' HID LANGID Base Language: ', 'cyan'),
|
||||||
|
hex(attribute.value.value[0].value[0].value),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' HID LANGID Base Bluetooth String Offset: ', 'cyan'),
|
||||||
|
hex(attribute.value.value[0].value[1].value),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_BATTERY_POWER_ATTRIBUTE_ID:
|
||||||
|
print(color(' HIDBatteryPower: ', 'cyan'), attribute.value.value)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_REMOTE_WAKE_ATTRIBUTE_ID:
|
||||||
|
print(color(' HIDRemoteWake: ', 'cyan'), attribute.value.value)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_PROFILE_VERSION_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' HIDProfileVersion : ', 'cyan'), hex(attribute.value.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_SUPERVISION_TIMEOUT_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' HIDSupervisionTimeout: ', 'cyan'),
|
||||||
|
hex(attribute.value.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_NORMALLY_CONNECTABLE_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' HIDNormallyConnectable: ', 'cyan'), attribute.value.value
|
||||||
|
)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_BOOT_DEVICE_ATTRIBUTE_ID:
|
||||||
|
print(color(' HIDBootDevice: ', 'cyan'), attribute.value.value)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_SSR_HOST_MAX_LATENCY_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' HIDSSRHostMaxLatency: ', 'cyan'),
|
||||||
|
hex(attribute.value.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif attribute.id == SDP_HID_SSR_HOST_MIN_TIMEOUT_ATTRIBUTE_ID:
|
||||||
|
print(
|
||||||
|
color(' HIDSSRHostMinTimeout: ', 'cyan'),
|
||||||
|
hex(attribute.value.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f' Warning: Attribute ID: {attribute.id} match not found.\n Attribute Info: {attribute}',
|
||||||
|
'yellow',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await sdp_client.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def get_stream_reader(pipe) -> asyncio.StreamReader:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
reader = asyncio.StreamReader(loop=loop)
|
||||||
|
protocol = asyncio.StreamReaderProtocol(reader)
|
||||||
|
await loop.connect_read_pipe(lambda: protocol, pipe)
|
||||||
|
return reader
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def main():
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
print(
|
||||||
|
'Usage: run_hid_host.py <device-config> <transport-spec> '
|
||||||
|
'<bluetooth-address> [test-mode]'
|
||||||
|
)
|
||||||
|
|
||||||
|
print('example: run_hid_host.py classic1.json usb:0 E1:CA:72:48:C4:E8/P')
|
||||||
|
return
|
||||||
|
|
||||||
|
def on_hid_data_cb(pdu):
|
||||||
|
report_type = pdu[0] & 0x0F
|
||||||
|
if len(pdu) == 1:
|
||||||
|
print(color(f'Warning: No report received', 'yellow'))
|
||||||
|
return
|
||||||
|
report_length = len(pdu[1:])
|
||||||
|
report_id = pdu[1]
|
||||||
|
|
||||||
|
if report_type != Message.ReportType.OTHER_REPORT:
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f' Report type = {report_type}, Report length = {report_length}, Report id = {report_id}',
|
||||||
|
'blue',
|
||||||
|
None,
|
||||||
|
'bold',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (report_length <= 1) or (report_id == 0):
|
||||||
|
return
|
||||||
|
|
||||||
|
if report_type == Message.ReportType.INPUT_REPORT:
|
||||||
|
ReportParser.parse_input_report(pdu[1:]) # type: ignore
|
||||||
|
|
||||||
|
async def handle_virtual_cable_unplug():
|
||||||
|
await hid_host.disconnect_interrupt_channel()
|
||||||
|
await hid_host.disconnect_control_channel()
|
||||||
|
await device.keystore.delete(target_address) # type: ignore
|
||||||
|
await connection.disconnect()
|
||||||
|
|
||||||
|
def on_hid_virtual_cable_unplug_cb():
|
||||||
|
asyncio.create_task(handle_virtual_cable_unplug())
|
||||||
|
|
||||||
|
print('<<< connecting to HCI...')
|
||||||
|
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
||||||
|
print('<<< CONNECTED')
|
||||||
|
|
||||||
|
# Create a device
|
||||||
|
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
||||||
|
device.classic_enabled = True
|
||||||
|
await device.power_on()
|
||||||
|
|
||||||
|
# Connect to a peer
|
||||||
|
target_address = sys.argv[3]
|
||||||
|
print(f'=== Connecting to {target_address}...')
|
||||||
|
connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
|
||||||
|
print(f'=== Connected to {connection.peer_address}!')
|
||||||
|
|
||||||
|
# Request authentication
|
||||||
|
print('*** Authenticating...')
|
||||||
|
await connection.authenticate()
|
||||||
|
print('*** Authenticated...')
|
||||||
|
|
||||||
|
# Enable encryption
|
||||||
|
print('*** Enabling encryption...')
|
||||||
|
await connection.encrypt()
|
||||||
|
print('*** Encryption on')
|
||||||
|
|
||||||
|
await get_hid_device_sdp_record(connection)
|
||||||
|
|
||||||
|
# Create HID host and start it
|
||||||
|
print('@@@ Starting HID Host...')
|
||||||
|
hid_host = Host(device, connection)
|
||||||
|
|
||||||
|
# Register for HID data call back
|
||||||
|
hid_host.on('data', on_hid_data_cb)
|
||||||
|
|
||||||
|
# Register for virtual cable unplug call back
|
||||||
|
hid_host.on('virtual_cable_unplug', on_hid_virtual_cable_unplug_cb)
|
||||||
|
|
||||||
|
async def menu():
|
||||||
|
reader = await get_stream_reader(sys.stdin)
|
||||||
|
while True:
|
||||||
|
print(
|
||||||
|
"\n************************ HID Host Menu *****************************\n"
|
||||||
|
)
|
||||||
|
print(" 1. Connect Control Channel")
|
||||||
|
print(" 2. Connect Interrupt Channel")
|
||||||
|
print(" 3. Disconnect Control Channel")
|
||||||
|
print(" 4. Disconnect Interrupt Channel")
|
||||||
|
print(" 5. Get Report")
|
||||||
|
print(" 6. Set Report")
|
||||||
|
print(" 7. Set Protocol Mode")
|
||||||
|
print(" 8. Get Protocol Mode")
|
||||||
|
print(" 9. Send Report")
|
||||||
|
print("10. Suspend")
|
||||||
|
print("11. Exit Suspend")
|
||||||
|
print("12. Virtual Cable Unplug")
|
||||||
|
print("13. Disconnect device")
|
||||||
|
print("14. Delete Bonding")
|
||||||
|
print("15. Re-connect to device")
|
||||||
|
print("\nEnter your choice : \n")
|
||||||
|
|
||||||
|
choice = await reader.readline()
|
||||||
|
choice = choice.decode('utf-8').strip()
|
||||||
|
|
||||||
|
if choice == '1':
|
||||||
|
await hid_host.connect_control_channel()
|
||||||
|
|
||||||
|
elif choice == '2':
|
||||||
|
await hid_host.connect_interrupt_channel()
|
||||||
|
|
||||||
|
elif choice == '3':
|
||||||
|
await hid_host.disconnect_control_channel()
|
||||||
|
|
||||||
|
elif choice == '4':
|
||||||
|
await hid_host.disconnect_interrupt_channel()
|
||||||
|
|
||||||
|
elif choice == '5':
|
||||||
|
print(" 1. Report ID 0x02")
|
||||||
|
print(" 2. Report ID 0x03")
|
||||||
|
print(" 3. Report ID 0x05")
|
||||||
|
choice1 = await reader.readline()
|
||||||
|
choice1 = choice1.decode('utf-8').strip()
|
||||||
|
|
||||||
|
if choice1 == '1':
|
||||||
|
hid_host.get_report(1, 2, 3)
|
||||||
|
|
||||||
|
elif choice1 == '2':
|
||||||
|
hid_host.get_report(2, 3, 2)
|
||||||
|
|
||||||
|
elif choice1 == '3':
|
||||||
|
hid_host.get_report(3, 5, 3)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print('Incorrect option selected')
|
||||||
|
|
||||||
|
elif choice == '6':
|
||||||
|
print(" 1. Report type 1 and Report id 0x01")
|
||||||
|
print(" 2. Report type 2 and Report id 0x03")
|
||||||
|
print(" 3. Report type 3 and Report id 0x05")
|
||||||
|
choice1 = await reader.readline()
|
||||||
|
choice1 = choice1.decode('utf-8').strip()
|
||||||
|
|
||||||
|
if choice1 == '1':
|
||||||
|
# data includes first octet as report id
|
||||||
|
data = bytearray(
|
||||||
|
[0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01]
|
||||||
|
)
|
||||||
|
hid_host.set_report(1, data)
|
||||||
|
|
||||||
|
elif choice1 == '2':
|
||||||
|
data = bytearray([0x03, 0x01, 0x01])
|
||||||
|
hid_host.set_report(2, data)
|
||||||
|
|
||||||
|
elif choice1 == '3':
|
||||||
|
data = bytearray([0x05, 0x01, 0x01, 0x01])
|
||||||
|
hid_host.set_report(3, data)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print('Incorrect option selected')
|
||||||
|
|
||||||
|
elif choice == '7':
|
||||||
|
print(" 0. Boot")
|
||||||
|
print(" 1. Report")
|
||||||
|
choice1 = await reader.readline()
|
||||||
|
choice1 = choice1.decode('utf-8').strip()
|
||||||
|
|
||||||
|
if choice1 == '0':
|
||||||
|
hid_host.set_protocol(Message.ProtocolMode.BOOT_PROTOCOL)
|
||||||
|
|
||||||
|
elif choice1 == '1':
|
||||||
|
hid_host.set_protocol(Message.ProtocolMode.REPORT_PROTOCOL)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print('Incorrect option selected')
|
||||||
|
|
||||||
|
elif choice == '8':
|
||||||
|
hid_host.get_protocol()
|
||||||
|
|
||||||
|
elif choice == '9':
|
||||||
|
print(" 1. Report ID 0x01")
|
||||||
|
print(" 2. Report ID 0x03")
|
||||||
|
choice1 = await reader.readline()
|
||||||
|
choice1 = choice1.decode('utf-8').strip()
|
||||||
|
|
||||||
|
if choice1 == '1':
|
||||||
|
data = bytearray(
|
||||||
|
[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
|
||||||
|
)
|
||||||
|
hid_host.send_data(data)
|
||||||
|
|
||||||
|
elif choice1 == '2':
|
||||||
|
data = bytearray([0x03, 0x00, 0x0D, 0xFD, 0x00, 0x00])
|
||||||
|
hid_host.send_data(data)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print('Incorrect option selected')
|
||||||
|
|
||||||
|
elif choice == '10':
|
||||||
|
hid_host.suspend()
|
||||||
|
|
||||||
|
elif choice == '11':
|
||||||
|
hid_host.exit_suspend()
|
||||||
|
|
||||||
|
elif choice == '12':
|
||||||
|
hid_host.virtual_cable_unplug()
|
||||||
|
try:
|
||||||
|
await device.keystore.delete(target_address)
|
||||||
|
except KeyError:
|
||||||
|
print('Device not found or Device already unpaired.')
|
||||||
|
|
||||||
|
elif choice == '13':
|
||||||
|
peer_address = Address.from_string_for_transport(
|
||||||
|
target_address, transport=BT_BR_EDR_TRANSPORT
|
||||||
|
)
|
||||||
|
connection = device.find_connection_by_bd_addr(
|
||||||
|
peer_address, transport=BT_BR_EDR_TRANSPORT
|
||||||
|
)
|
||||||
|
if connection is not None:
|
||||||
|
await connection.disconnect()
|
||||||
|
else:
|
||||||
|
print("Already disconnected from device")
|
||||||
|
|
||||||
|
elif choice == '14':
|
||||||
|
try:
|
||||||
|
await device.keystore.delete(target_address)
|
||||||
|
print("Unpair successful")
|
||||||
|
except KeyError:
|
||||||
|
print('Device not found or Device already unpaired.')
|
||||||
|
|
||||||
|
elif choice == '15':
|
||||||
|
connection = await device.connect(
|
||||||
|
target_address, transport=BT_BR_EDR_TRANSPORT
|
||||||
|
)
|
||||||
|
await connection.authenticate()
|
||||||
|
await connection.encrypt()
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Invalid option selected.")
|
||||||
|
|
||||||
|
if (len(sys.argv) > 4) and (sys.argv[4] == 'test-mode'):
|
||||||
|
# Enabling menu for testing
|
||||||
|
await menu()
|
||||||
|
else:
|
||||||
|
# HID Connection
|
||||||
|
# Control channel
|
||||||
|
await hid_host.connect_control_channel()
|
||||||
|
# Interrupt Channel
|
||||||
|
await hid_host.connect_interrupt_channel()
|
||||||
|
|
||||||
|
await hci_source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||||
|
asyncio.run(main())
|
||||||
@@ -42,10 +42,10 @@ from bumble.sdp import (
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def list_rfcomm_channels(device, connection):
|
async def list_rfcomm_channels(connection):
|
||||||
# Connect to the SDP Server
|
# Connect to the SDP Server
|
||||||
sdp_client = SDP_Client(device)
|
sdp_client = SDP_Client(connection)
|
||||||
await sdp_client.connect(connection)
|
await sdp_client.connect()
|
||||||
|
|
||||||
# Search for services with an L2CAP service attribute
|
# Search for services with an L2CAP service attribute
|
||||||
search_result = await sdp_client.search_attributes(
|
search_result = await sdp_client.search_attributes(
|
||||||
@@ -194,7 +194,7 @@ async def main():
|
|||||||
|
|
||||||
channel = sys.argv[4]
|
channel = sys.argv[4]
|
||||||
if channel == 'discover':
|
if channel == 'discover':
|
||||||
await list_rfcomm_channels(device, connection)
|
await list_rfcomm_channels(connection)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Request authentication
|
# Request authentication
|
||||||
@@ -209,7 +209,7 @@ async def main():
|
|||||||
|
|
||||||
# Create a client and start it
|
# Create a client and start it
|
||||||
print('@@@ Starting RFCOMM client...')
|
print('@@@ Starting RFCOMM client...')
|
||||||
rfcomm_client = Client(device, connection)
|
rfcomm_client = Client(connection)
|
||||||
rfcomm_mux = await rfcomm_client.start()
|
rfcomm_mux = await rfcomm_client.start()
|
||||||
print('@@@ Started')
|
print('@@@ Started')
|
||||||
|
|
||||||
|
|||||||
15
extras/android/BtBench/.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
1
extras/android/BtBench/app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
70
extras/android/BtBench/app/build.gradle.kts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.androidApplication)
|
||||||
|
alias(libs.plugins.kotlinAndroid)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.github.google.bumble.btbench"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.github.google.bumble.btbench"
|
||||||
|
minSdk = 30
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.1"
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
implementation(libs.core.ktx)
|
||||||
|
implementation(libs.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.activity.compose)
|
||||||
|
implementation(platform(libs.compose.bom))
|
||||||
|
implementation(libs.ui)
|
||||||
|
implementation(libs.ui.graphics)
|
||||||
|
implementation(libs.ui.tooling.preview)
|
||||||
|
implementation(libs.material3)
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||||
|
androidTestImplementation(libs.espresso.core)
|
||||||
|
androidTestImplementation(platform(libs.compose.bom))
|
||||||
|
androidTestImplementation(libs.ui.test.junit4)
|
||||||
|
debugImplementation(libs.ui.tooling)
|
||||||
|
debugImplementation(libs.ui.test.manifest)
|
||||||
|
}
|
||||||
21
extras/android/BtBench/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
40
extras/android/BtBench/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Request legacy Bluetooth permissions on older devices. -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.bluetooth" android:required="true"/>
|
||||||
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.BTBench"
|
||||||
|
tools:targetApi="31">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.BTBench">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- <profileable android:shell="true"/>-->
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
BIN
extras/android/BtBench/app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,35 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package com.github.google.bumble.btbench
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.logging.Logger
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
private val Log = Logger.getLogger("btbench.l2cap-client")
|
||||||
|
|
||||||
|
class L2capClient(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun run() {
|
||||||
|
viewModel.running = true
|
||||||
|
val remoteDevice = bluetoothAdapter.getRemoteDevice(viewModel.peerBluetoothAddress)
|
||||||
|
val socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm)
|
||||||
|
|
||||||
|
val client = SocketClient(viewModel, socket)
|
||||||
|
client.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package com.github.google.bumble.btbench
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
|
import android.bluetooth.le.AdvertiseCallback
|
||||||
|
import android.bluetooth.le.AdvertiseData
|
||||||
|
import android.bluetooth.le.AdvertiseSettings
|
||||||
|
import android.bluetooth.le.AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY
|
||||||
|
import android.os.Build
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.logging.Logger
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
private val Log = Logger.getLogger("btbench.l2cap-server")
|
||||||
|
|
||||||
|
class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdapter: BluetoothAdapter) {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun run() {
|
||||||
|
// Advertise to that the peer can find us and connect.
|
||||||
|
val callback = object: AdvertiseCallback() {
|
||||||
|
override fun onStartFailure(errorCode: Int) {
|
||||||
|
Log.warning("failed to start advertising: $errorCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
||||||
|
Log.info("advertising started: $settingsInEffect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val advertiseSettingsBuilder = AdvertiseSettings.Builder()
|
||||||
|
.setAdvertiseMode(ADVERTISE_MODE_LOW_LATENCY)
|
||||||
|
.setConnectable(true)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
advertiseSettingsBuilder.setDiscoverable(true)
|
||||||
|
}
|
||||||
|
val advertiseSettings = advertiseSettingsBuilder.build()
|
||||||
|
val advertiseData = AdvertiseData.Builder().build()
|
||||||
|
val scanData = AdvertiseData.Builder().setIncludeDeviceName(true).build()
|
||||||
|
val advertiser = bluetoothAdapter.bluetoothLeAdvertiser
|
||||||
|
advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback)
|
||||||
|
|
||||||
|
val serverSocket = bluetoothAdapter.listenUsingInsecureL2capChannel()
|
||||||
|
viewModel.l2capPsm = serverSocket.psm
|
||||||
|
Log.info("psm = $serverSocket.psm")
|
||||||
|
|
||||||
|
val server = SocketServer(viewModel, serverSocket)
|
||||||
|
server.run({ advertiser.stopAdvertising(callback) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package com.github.google.bumble.btbench
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Divider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.github.google.bumble.btbench.ui.theme.BTBenchTheme
|
||||||
|
import java.util.logging.Logger
|
||||||
|
|
||||||
|
private val Log = Logger.getLogger("bumble.main-activity")
|
||||||
|
|
||||||
|
const val PEER_BLUETOOTH_ADDRESS_PREF_KEY = "peer_bluetooth_address"
|
||||||
|
const val SENDER_PACKET_COUNT_PREF_KEY = "sender_packet_count"
|
||||||
|
const val SENDER_PACKET_SIZE_PREF_KEY = "sender_packet_size"
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val appViewModel = AppViewModel()
|
||||||
|
private var bluetoothAdapter: BluetoothAdapter? = null
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
appViewModel.loadPreferences(getPreferences(Context.MODE_PRIVATE))
|
||||||
|
checkPermissions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkPermissions() {
|
||||||
|
val neededPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.BLUETOOTH_ADVERTISE,
|
||||||
|
Manifest.permission.BLUETOOTH_SCAN,
|
||||||
|
Manifest.permission.BLUETOOTH_CONNECT
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
|
||||||
|
}
|
||||||
|
val missingPermissions = neededPermissions.filter {
|
||||||
|
ContextCompat.checkSelfPermission(baseContext, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingPermissions.isEmpty()) {
|
||||||
|
start()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val requestPermissionsLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { permissions ->
|
||||||
|
permissions.entries.forEach {
|
||||||
|
Log.info("permission: ${it.key} = ${it.value}")
|
||||||
|
}
|
||||||
|
val grantCount = permissions.count { it.value }
|
||||||
|
if (grantCount == neededPermissions.size) {
|
||||||
|
// We have all the permissions we need.
|
||||||
|
start()
|
||||||
|
} else {
|
||||||
|
Log.warning("not all permissions granted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestPermissionsLauncher.launch(missingPermissions.toTypedArray())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun initBluetooth() {
|
||||||
|
val bluetoothManager = ContextCompat.getSystemService(this, BluetoothManager::class.java)
|
||||||
|
bluetoothAdapter = bluetoothManager?.adapter
|
||||||
|
|
||||||
|
if (bluetoothAdapter == null) {
|
||||||
|
Log.warning("no bluetooth adapter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bluetoothAdapter!!.isEnabled) {
|
||||||
|
Log.warning("bluetooth not enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun start() {
|
||||||
|
initBluetooth()
|
||||||
|
setContent {
|
||||||
|
MainView(
|
||||||
|
appViewModel,
|
||||||
|
::becomeDiscoverable,
|
||||||
|
::runRfcommClient,
|
||||||
|
::runRfcommServer,
|
||||||
|
::runL2capClient,
|
||||||
|
::runL2capServer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process intent parameters, if any.
|
||||||
|
intent.getStringExtra("peer-bluetooth-address")?.let {
|
||||||
|
appViewModel.peerBluetoothAddress = it
|
||||||
|
}
|
||||||
|
val packetCount = intent.getIntExtra("packet-count", 0)
|
||||||
|
if (packetCount > 0) {
|
||||||
|
appViewModel.senderPacketCount = packetCount
|
||||||
|
}
|
||||||
|
appViewModel.updateSenderPacketCountSlider()
|
||||||
|
val packetSize = intent.getIntExtra("packet-size", 0)
|
||||||
|
if (packetSize > 0) {
|
||||||
|
appViewModel.senderPacketSize = packetSize
|
||||||
|
}
|
||||||
|
appViewModel.updateSenderPacketSizeSlider()
|
||||||
|
intent.getStringExtra("autostart")?.let {
|
||||||
|
when (it) {
|
||||||
|
"rfcomm-client" -> runRfcommClient()
|
||||||
|
"rfcomm-server" -> runRfcommServer()
|
||||||
|
"l2cap-client" -> runL2capClient()
|
||||||
|
"l2cap-server" -> runL2capServer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runRfcommClient() {
|
||||||
|
val rfcommClient = bluetoothAdapter?.let { RfcommClient(appViewModel, it) }
|
||||||
|
rfcommClient?.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runRfcommServer() {
|
||||||
|
val rfcommServer = bluetoothAdapter?.let { RfcommServer(appViewModel, it) }
|
||||||
|
rfcommServer?.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runL2capClient() {
|
||||||
|
val l2capClient = bluetoothAdapter?.let { L2capClient(appViewModel, it) }
|
||||||
|
l2capClient?.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runL2capServer() {
|
||||||
|
val l2capServer = bluetoothAdapter?.let { L2capServer(appViewModel, it) }
|
||||||
|
l2capServer?.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun becomeDiscoverable() {
|
||||||
|
val discoverableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
|
||||||
|
discoverableIntent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300)
|
||||||
|
startActivity(discoverableIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
fun MainView(
|
||||||
|
appViewModel: AppViewModel,
|
||||||
|
becomeDiscoverable: () -> Unit,
|
||||||
|
runRfcommClient: () -> Unit,
|
||||||
|
runRfcommServer: () -> Unit,
|
||||||
|
runL2capClient: () -> Unit,
|
||||||
|
runL2capServer: () -> Unit
|
||||||
|
) {
|
||||||
|
BTBenchTheme {
|
||||||
|
// A surface container using the 'background' color from the theme
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Bumble Bench",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
TextField(label = {
|
||||||
|
Text(text = "Peer Bluetooth Address")
|
||||||
|
},
|
||||||
|
value = appViewModel.peerBluetoothAddress,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
|
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
onValueChange = {
|
||||||
|
appViewModel.updatePeerBluetoothAddress(it)
|
||||||
|
},
|
||||||
|
keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
TextField(label = {
|
||||||
|
Text(text = "L2CAP PSM")
|
||||||
|
},
|
||||||
|
value = appViewModel.l2capPsm.toString(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
onValueChange = {
|
||||||
|
if (it.isNotEmpty()) {
|
||||||
|
val psm = it.toIntOrNull()
|
||||||
|
if (psm != null) {
|
||||||
|
appViewModel.l2capPsm = psm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }))
|
||||||
|
Divider()
|
||||||
|
Slider(
|
||||||
|
value = appViewModel.senderPacketCountSlider, onValueChange = {
|
||||||
|
appViewModel.senderPacketCountSlider = it
|
||||||
|
appViewModel.updateSenderPacketCount()
|
||||||
|
}, steps = 4
|
||||||
|
)
|
||||||
|
Text(text = "Packet Count: " + appViewModel.senderPacketCount.toString())
|
||||||
|
Divider()
|
||||||
|
Slider(
|
||||||
|
value = appViewModel.senderPacketSizeSlider, onValueChange = {
|
||||||
|
appViewModel.senderPacketSizeSlider = it
|
||||||
|
appViewModel.updateSenderPacketSize()
|
||||||
|
}, steps = 4
|
||||||
|
)
|
||||||
|
Text(text = "Packet Size: " + appViewModel.senderPacketSize.toString())
|
||||||
|
Divider()
|
||||||
|
ActionButton(
|
||||||
|
text = "Become Discoverable", onClick = becomeDiscoverable, true
|
||||||
|
)
|
||||||
|
Row() {
|
||||||
|
ActionButton(
|
||||||
|
text = "RFCOMM Client", onClick = runRfcommClient, !appViewModel.running
|
||||||
|
)
|
||||||
|
ActionButton(
|
||||||
|
text = "RFCOMM Server", onClick = runRfcommServer, !appViewModel.running
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row() {
|
||||||
|
ActionButton(
|
||||||
|
text = "L2CAP Client", onClick = runL2capClient, !appViewModel.running
|
||||||
|
)
|
||||||
|
ActionButton(
|
||||||
|
text = "L2CAP Server", onClick = runL2capServer, !appViewModel.running
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Text(
|
||||||
|
text = "Packets Sent: ${appViewModel.packetsSent}"
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Packets Received: ${appViewModel.packetsReceived}"
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Throughput: ${appViewModel.throughput}"
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
ActionButton(
|
||||||
|
text = "Abort", onClick = appViewModel::abort, appViewModel.running
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ActionButton(text: String, onClick: () -> Unit, enabled: Boolean) {
|
||||||
|
Button(onClick = onClick, enabled = enabled) {
|
||||||
|
Text(text = text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package com.github.google.bumble.btbench
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
val DEFAULT_RFCOMM_UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF6D3AE")
|
||||||
|
const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
|
||||||
|
const val DEFAULT_SENDER_PACKET_COUNT = 100
|
||||||
|
const val DEFAULT_SENDER_PACKET_SIZE = 1024
|
||||||
|
|
||||||
|
class AppViewModel : ViewModel() {
|
||||||
|
private var preferences: SharedPreferences? = null
|
||||||
|
var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
|
||||||
|
var l2capPsm by mutableStateOf(0)
|
||||||
|
var senderPacketCountSlider by mutableFloatStateOf(0.0F)
|
||||||
|
var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
|
||||||
|
var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
|
||||||
|
var senderPacketSize by mutableIntStateOf(DEFAULT_SENDER_PACKET_SIZE)
|
||||||
|
var packetsSent by mutableIntStateOf(0)
|
||||||
|
var packetsReceived by mutableIntStateOf(0)
|
||||||
|
var throughput by mutableIntStateOf(0)
|
||||||
|
var running by mutableStateOf(false)
|
||||||
|
var aborter: (() -> Unit)? = null
|
||||||
|
|
||||||
|
fun loadPreferences(preferences: SharedPreferences) {
|
||||||
|
this.preferences = preferences
|
||||||
|
|
||||||
|
val savedPeerBluetoothAddress = preferences.getString(PEER_BLUETOOTH_ADDRESS_PREF_KEY, null)
|
||||||
|
if (savedPeerBluetoothAddress != null) {
|
||||||
|
peerBluetoothAddress = savedPeerBluetoothAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
val savedSenderPacketCount = preferences.getInt(SENDER_PACKET_COUNT_PREF_KEY, 0)
|
||||||
|
if (savedSenderPacketCount != 0) {
|
||||||
|
senderPacketCount = savedSenderPacketCount
|
||||||
|
}
|
||||||
|
updateSenderPacketCountSlider()
|
||||||
|
|
||||||
|
val savedSenderPacketSize = preferences.getInt(SENDER_PACKET_SIZE_PREF_KEY, 0)
|
||||||
|
if (savedSenderPacketSize != 0) {
|
||||||
|
senderPacketSize = savedSenderPacketSize
|
||||||
|
}
|
||||||
|
updateSenderPacketSizeSlider()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePeerBluetoothAddress(peerBluetoothAddress: String) {
|
||||||
|
this.peerBluetoothAddress = peerBluetoothAddress
|
||||||
|
|
||||||
|
// Save the address to the preferences
|
||||||
|
with(preferences!!.edit()) {
|
||||||
|
putString(PEER_BLUETOOTH_ADDRESS_PREF_KEY, peerBluetoothAddress)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSenderPacketCountSlider() {
|
||||||
|
if (senderPacketCount <= 10) {
|
||||||
|
senderPacketCountSlider = 0.0F
|
||||||
|
} else if (senderPacketCount <= 50) {
|
||||||
|
senderPacketCountSlider = 0.2F
|
||||||
|
} else if (senderPacketCount <= 100) {
|
||||||
|
senderPacketCountSlider = 0.4F
|
||||||
|
} else if (senderPacketCount <= 500) {
|
||||||
|
senderPacketCountSlider = 0.6F
|
||||||
|
} else if (senderPacketCount <= 1000) {
|
||||||
|
senderPacketCountSlider = 0.8F
|
||||||
|
} else {
|
||||||
|
senderPacketCountSlider = 1.0F
|
||||||
|
}
|
||||||
|
|
||||||
|
with(preferences!!.edit()) {
|
||||||
|
putInt(SENDER_PACKET_COUNT_PREF_KEY, senderPacketCount)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSenderPacketCount() {
|
||||||
|
if (senderPacketCountSlider < 0.1F) {
|
||||||
|
senderPacketCount = 10
|
||||||
|
} else if (senderPacketCountSlider < 0.3F) {
|
||||||
|
senderPacketCount = 50
|
||||||
|
} else if (senderPacketCountSlider < 0.5F) {
|
||||||
|
senderPacketCount = 100
|
||||||
|
} else if (senderPacketCountSlider < 0.7F) {
|
||||||
|
senderPacketCount = 500
|
||||||
|
} else if (senderPacketCountSlider < 0.9F) {
|
||||||
|
senderPacketCount = 1000
|
||||||
|
} else {
|
||||||
|
senderPacketCount = 10000
|
||||||
|
}
|
||||||
|
|
||||||
|
with(preferences!!.edit()) {
|
||||||
|
putInt(SENDER_PACKET_COUNT_PREF_KEY, senderPacketCount)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSenderPacketSizeSlider() {
|
||||||
|
if (senderPacketSize <= 1) {
|
||||||
|
senderPacketSizeSlider = 0.0F
|
||||||
|
} else if (senderPacketSize <= 256) {
|
||||||
|
senderPacketSizeSlider = 0.02F
|
||||||
|
} else if (senderPacketSize <= 512) {
|
||||||
|
senderPacketSizeSlider = 0.4F
|
||||||
|
} else if (senderPacketSize <= 1024) {
|
||||||
|
senderPacketSizeSlider = 0.6F
|
||||||
|
} else if (senderPacketSize <= 2048) {
|
||||||
|
senderPacketSizeSlider = 0.8F
|
||||||
|
} else {
|
||||||
|
senderPacketSizeSlider = 1.0F
|
||||||
|
}
|
||||||
|
|
||||||
|
with(preferences!!.edit()) {
|
||||||
|
putInt(SENDER_PACKET_SIZE_PREF_KEY, senderPacketSize)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSenderPacketSize() {
|
||||||
|
if (senderPacketSizeSlider < 0.1F) {
|
||||||
|
senderPacketSize = 1
|
||||||
|
} else if (senderPacketSizeSlider < 0.3F) {
|
||||||
|
senderPacketSize = 256
|
||||||
|
} else if (senderPacketSizeSlider < 0.5F) {
|
||||||
|
senderPacketSize = 512
|
||||||
|
} else if (senderPacketSizeSlider < 0.7F) {
|
||||||
|
senderPacketSize = 1024
|
||||||
|
} else if (senderPacketSizeSlider < 0.9F) {
|
||||||
|
senderPacketSize = 2048
|
||||||
|
} else {
|
||||||
|
senderPacketSize = 4096
|
||||||
|
}
|
||||||
|
|
||||||
|
with(preferences!!.edit()) {
|
||||||
|
putInt(SENDER_PACKET_SIZE_PREF_KEY, senderPacketSize)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun abort() {
|
||||||
|
aborter?.let { it() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package com.github.google.bumble.btbench
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.util.logging.Logger
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
private val Log = Logger.getLogger("btbench.packet")
|
||||||
|
|
||||||
|
fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
|
||||||
|
|
||||||
|
abstract class Packet(val type: Int, val payload: ByteArray = ByteArray(0)) {
|
||||||
|
companion object {
|
||||||
|
const val RESET = 0
|
||||||
|
const val SEQUENCE = 1
|
||||||
|
const val ACK = 2
|
||||||
|
|
||||||
|
const val LAST_FLAG = 1
|
||||||
|
|
||||||
|
fun from(data: ByteArray): Packet {
|
||||||
|
return when (data[0].toInt()) {
|
||||||
|
RESET -> ResetPacket()
|
||||||
|
SEQUENCE -> SequencePacket(
|
||||||
|
data[1].toInt(),
|
||||||
|
ByteBuffer.wrap(data, 2, 4).getInt(),
|
||||||
|
data.sliceArray(6..<data.size)
|
||||||
|
)
|
||||||
|
|
||||||
|
ACK -> AckPacket(data[1].toInt(), ByteBuffer.wrap(data, 2, 4).getInt())
|
||||||
|
else -> GenericPacket(data[0].toInt(), data.sliceArray(1..<data.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun toBytes(): ByteArray {
|
||||||
|
return ByteBuffer.allocate(1 + payload.size).put(type.toByte()).put(payload).array()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GenericPacket(type: Int, payload: ByteArray) : Packet(type, payload)
|
||||||
|
class ResetPacket : Packet(RESET)
|
||||||
|
|
||||||
|
class AckPacket(val flags: Int, val sequenceNumber: Int) : Packet(ACK) {
|
||||||
|
override fun toBytes(): ByteArray {
|
||||||
|
return ByteBuffer.allocate(1 + 1 + 4).put(type.toByte()).put(flags.toByte())
|
||||||
|
.putInt(sequenceNumber).array()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SequencePacket(val flags: Int, val sequenceNumber: Int, payload: ByteArray) :
|
||||||
|
Packet(SEQUENCE, payload) {
|
||||||
|
override fun toBytes(): ByteArray {
|
||||||
|
return ByteBuffer.allocate(1 + 1 + 4 + payload.size).put(type.toByte()).put(flags.toByte())
|
||||||
|
.putInt(sequenceNumber).put(payload).array()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class PacketSink {
|
||||||
|
fun onPacket(packet: Packet) {
|
||||||
|
when (packet) {
|
||||||
|
is ResetPacket -> onResetPacket()
|
||||||
|
is AckPacket -> onAckPacket()
|
||||||
|
is SequencePacket -> onSequencePacket(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun onResetPacket()
|
||||||
|
abstract fun onAckPacket()
|
||||||
|
abstract fun onSequencePacket(packet: SequencePacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataSink {
|
||||||
|
fun onData(data: ByteArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PacketIO {
|
||||||
|
var packetSink: PacketSink?
|
||||||
|
fun sendPacket(packet: Packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
class StreamedPacketIO(private val dataSink: DataSink) : PacketIO {
|
||||||
|
private var bytesNeeded: Int = 0
|
||||||
|
private var rxPacket: ByteBuffer? = null
|
||||||
|
private var rxHeader = ByteBuffer.allocate(2)
|
||||||
|
|
||||||
|
override var packetSink: PacketSink? = null
|
||||||
|
|
||||||
|
fun onData(data: ByteArray) {
|
||||||
|
var current = data
|
||||||
|
while (current.isNotEmpty()) {
|
||||||
|
if (bytesNeeded > 0) {
|
||||||
|
val chunk = current.sliceArray(0..<min(bytesNeeded, current.size))
|
||||||
|
rxPacket!!.put(chunk)
|
||||||
|
current = current.sliceArray(chunk.size..<current.size)
|
||||||
|
bytesNeeded -= chunk.size
|
||||||
|
if (bytesNeeded == 0) {
|
||||||
|
// Packet completed.
|
||||||
|
//Log.fine("packet complete: ${current.toHex()}")
|
||||||
|
packetSink?.onPacket(Packet.from(rxPacket!!.array()))
|
||||||
|
|
||||||
|
// Reset.
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val headerBytesNeeded = 2 - rxHeader.position()
|
||||||
|
val headerBytes = current.sliceArray(0..<min(headerBytesNeeded, current.size))
|
||||||
|
current = current.sliceArray(headerBytes.size..<current.size)
|
||||||
|
rxHeader.put(headerBytes)
|
||||||
|
if (rxHeader.position() != 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bytesNeeded = rxHeader.getShort(0).toInt()
|
||||||
|
if (bytesNeeded == 0) {
|
||||||
|
Log.warning("found 0 size packet!")
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rxPacket = ByteBuffer.allocate(bytesNeeded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reset() {
|
||||||
|
rxPacket = null
|
||||||
|
rxHeader.position(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sendPacket(packet: Packet) {
|
||||||
|
val packetBytes = packet.toBytes()
|
||||||
|
val packetData =
|
||||||
|
ByteBuffer.allocate(2 + packetBytes.size).putShort(packetBytes.size.toShort())
|
||||||
|
.put(packetBytes).array()
|
||||||
|
dataSink.onData(packetData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SocketDataSink(private val socket: BluetoothSocket) : DataSink {
|
||||||
|
override fun onData(data: ByteArray) {
|
||||||
|
socket.outputStream.write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SocketDataSource(
|
||||||
|
private val socket: BluetoothSocket,
|
||||||
|
private val onData: (data: ByteArray) -> Unit
|
||||||
|
) {
|
||||||
|
fun receive() {
|
||||||
|
val buffer = ByteArray(4096)
|
||||||
|
do {
|
||||||
|
try {
|
||||||
|
val bytesRead = socket.inputStream.read(buffer)
|
||||||
|
if (bytesRead <= 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
onData(buffer.sliceArray(0..<bytesRead))
|
||||||
|
} catch (error: IOException) {
|
||||||
|
Log.warning("IO Exception: $error")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} while (true)
|
||||||
|
Log.info("end of stream")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package com.github.google.bumble.btbench
|
||||||
|
|
||||||
|
import java.util.logging.Logger
|
||||||
|
import kotlin.time.DurationUnit
|
||||||
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
|
private val Log = Logger.getLogger("btbench.receiver")
|
||||||
|
|
||||||
|
class Receiver(private val viewModel: AppViewModel, private val packetIO: PacketIO) : PacketSink() {
|
||||||
|
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||||
|
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||||
|
private var bytesReceived = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
packetIO.packetSink = this
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResetPacket() {
|
||||||
|
startTime = TimeSource.Monotonic.markNow()
|
||||||
|
lastPacketTime = startTime
|
||||||
|
bytesReceived = 0
|
||||||
|
viewModel.throughput = 0
|
||||||
|
viewModel.packetsSent = 0
|
||||||
|
viewModel.packetsReceived = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAckPacket() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSequencePacket(packet: SequencePacket) {
|
||||||
|
val received = packet.payload.size + 6
|
||||||
|
bytesReceived += received
|
||||||
|
val now = TimeSource.Monotonic.markNow()
|
||||||
|
lastPacketTime = now
|
||||||
|
viewModel.packetsReceived += 1
|
||||||
|
if (packet.flags and Packet.LAST_FLAG != 0) {
|
||||||
|
Log.info("received last packet")
|
||||||
|
val elapsed = now - startTime
|
||||||
|
val throughput = (bytesReceived / elapsed.toDouble(DurationUnit.SECONDS)).toInt()
|
||||||
|
Log.info("throughput: $throughput")
|
||||||
|
viewModel.throughput = throughput
|
||||||
|
packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package com.github.google.bumble.btbench
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.logging.Logger
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
private val Log = Logger.getLogger("btbench.rfcomm-client")
|
||||||
|
|
||||||
|
class RfcommClient(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun run() {
|
||||||
|
val remoteDevice = bluetoothAdapter.getRemoteDevice(viewModel.peerBluetoothAddress)
|
||||||
|
val socket = remoteDevice.createInsecureRfcommSocketToServiceRecord(
|
||||||
|
DEFAULT_RFCOMM_UUID
|
||||||
|
)
|
||||||
|
|
||||||
|
val client = SocketClient(viewModel, socket)
|
||||||
|
client.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package com.github.google.bumble.btbench
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.logging.Logger
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
private val Log = Logger.getLogger("btbench.rfcomm-server")
|
||||||
|
|
||||||
|
class RfcommServer(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun run() {
|
||||||
|
val serverSocket = bluetoothAdapter.listenUsingInsecureRfcommWithServiceRecord(
|
||||||
|
"BumbleBench", DEFAULT_RFCOMM_UUID
|
||||||
|
)
|
||||||
|
|
||||||
|
val server = SocketServer(viewModel, serverSocket)
|
||||||
|
server.run({})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package com.github.google.bumble.btbench
|
||||||
|
|
||||||
|
import java.util.concurrent.Semaphore
|
||||||
|
import java.util.logging.Logger
|
||||||
|
import kotlin.time.DurationUnit
|
||||||
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
|
private val Log = Logger.getLogger("btbench.sender")
|
||||||
|
|
||||||
|
class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO) : PacketSink() {
|
||||||
|
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||||
|
private var bytesSent = 0
|
||||||
|
private val done = Semaphore(0)
|
||||||
|
|
||||||
|
init {
|
||||||
|
packetIO.packetSink = this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun run() {
|
||||||
|
viewModel.packetsSent = 0
|
||||||
|
viewModel.packetsReceived = 0
|
||||||
|
viewModel.throughput = 0
|
||||||
|
|
||||||
|
Log.info("sending reset")
|
||||||
|
packetIO.sendPacket(ResetPacket())
|
||||||
|
|
||||||
|
startTime = TimeSource.Monotonic.markNow()
|
||||||
|
|
||||||
|
val packetCount = viewModel.senderPacketCount
|
||||||
|
val packetSize = viewModel.senderPacketSize
|
||||||
|
for (i in 0..<packetCount - 1) {
|
||||||
|
packetIO.sendPacket(SequencePacket(0, i, ByteArray(packetSize - 6)))
|
||||||
|
bytesSent += packetSize
|
||||||
|
viewModel.packetsSent = i + 1
|
||||||
|
}
|
||||||
|
packetIO.sendPacket(
|
||||||
|
SequencePacket(
|
||||||
|
Packet.LAST_FLAG,
|
||||||
|
packetCount - 1,
|
||||||
|
ByteArray(packetSize - 6)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bytesSent += packetSize
|
||||||
|
viewModel.packetsSent = packetCount
|
||||||
|
|
||||||
|
// Wait for the ACK
|
||||||
|
Log.info("waiting for ACK")
|
||||||
|
done.acquire()
|
||||||
|
Log.info("got ACK")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun abort() {
|
||||||
|
done.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResetPacket() {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAckPacket() {
|
||||||
|
Log.info("received ACK")
|
||||||
|
val elapsed = TimeSource.Monotonic.markNow() - startTime
|
||||||
|
val throughput = (bytesSent / elapsed.toDouble(DurationUnit.SECONDS)).toInt()
|
||||||
|
Log.info("throughput: $throughput")
|
||||||
|
viewModel.throughput = throughput
|
||||||
|
done.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSequencePacket(packet: SequencePacket) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package com.github.google.bumble.btbench
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.logging.Logger
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
private val Log = Logger.getLogger("btbench.socket-client")
|
||||||
|
|
||||||
|
class SocketClient(private val viewModel: AppViewModel, private val socket: BluetoothSocket) {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun run() {
|
||||||
|
viewModel.running = true
|
||||||
|
val socketDataSink = SocketDataSink(socket)
|
||||||
|
val streamIO = StreamedPacketIO(socketDataSink)
|
||||||
|
val socketDataSource = SocketDataSource(socket, streamIO::onData)
|
||||||
|
val sender = Sender(viewModel, streamIO)
|
||||||
|
|
||||||
|
fun cleanup() {
|
||||||
|
socket.close()
|
||||||
|
viewModel.aborter = {}
|
||||||
|
viewModel.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
thread(name = "SocketClient") {
|
||||||
|
viewModel.aborter = {
|
||||||
|
sender.abort()
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
Log.info("connecting to remote")
|
||||||
|
try {
|
||||||
|
socket.connect()
|
||||||
|
} catch (error: IOException) {
|
||||||
|
Log.warning("connection failed")
|
||||||
|
cleanup()
|
||||||
|
return@thread
|
||||||
|
}
|
||||||
|
Log.info("connected")
|
||||||
|
|
||||||
|
thread {
|
||||||
|
socketDataSource.receive()
|
||||||
|
}
|
||||||
|
|
||||||
|
sender.run()
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package com.github.google.bumble.btbench
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothServerSocket
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.logging.Logger
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
private val Log = Logger.getLogger("btbench.socket-server")
|
||||||
|
|
||||||
|
class SocketServer(private val viewModel: AppViewModel, private val serverSocket: BluetoothServerSocket) {
|
||||||
|
fun run(onTerminate: () -> Unit) {
|
||||||
|
var aborted = false
|
||||||
|
viewModel.running = true
|
||||||
|
|
||||||
|
fun cleanup() {
|
||||||
|
serverSocket.close()
|
||||||
|
viewModel.running = false
|
||||||
|
onTerminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
thread(name = "SocketServer") {
|
||||||
|
while (!aborted) {
|
||||||
|
viewModel.aborter = {
|
||||||
|
serverSocket.close()
|
||||||
|
}
|
||||||
|
Log.info("waiting for connection...")
|
||||||
|
val socket = try {
|
||||||
|
serverSocket.accept()
|
||||||
|
} catch (error: IOException) {
|
||||||
|
Log.warning("server socket closed")
|
||||||
|
cleanup()
|
||||||
|
return@thread
|
||||||
|
}
|
||||||
|
Log.info("got connection")
|
||||||
|
|
||||||
|
viewModel.aborter = {
|
||||||
|
aborted = true
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
viewModel.peerBluetoothAddress = socket.remoteDevice.address
|
||||||
|
|
||||||
|
val socketDataSink = SocketDataSink(socket)
|
||||||
|
val streamIO = StreamedPacketIO(socketDataSink)
|
||||||
|
val socketDataSource = SocketDataSource(socket, streamIO::onData)
|
||||||
|
val receiver = Receiver(viewModel, streamIO)
|
||||||
|
socketDataSource.receive()
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.github.google.bumble.btbench.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val Purple80 = Color(0xFFD0BCFF)
|
||||||
|
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||||
|
val Pink80 = Color(0xFFEFB8C8)
|
||||||
|
|
||||||
|
val Purple40 = Color(0xFF6650a4)
|
||||||
|
val PurpleGrey40 = Color(0xFF625b71)
|
||||||
|
val Pink40 = Color(0xFF7D5260)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.github.google.bumble.btbench.ui.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40
|
||||||
|
|
||||||
|
/* Other default colors to override
|
||||||
|
background = Color(0xFFFFFBFE),
|
||||||
|
surface = Color(0xFFFFFBFE),
|
||||||
|
onPrimary = Color.White,
|
||||||
|
onSecondary = Color.White,
|
||||||
|
onTertiary = Color.White,
|
||||||
|
onBackground = Color(0xFF1C1B1F),
|
||||||
|
onSurface = Color(0xFF1C1B1F),
|
||||||
|
*/
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BTBenchTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
// Dynamic color is available on Android 12+
|
||||||
|
dynamicColor: Boolean = true, content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
darkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
|
}
|
||||||
|
val view = LocalView.current
|
||||||
|
if (!view.isInEditMode) {
|
||||||
|
SideEffect {
|
||||||
|
val window = (view.context as Activity).window
|
||||||
|
window.statusBarColor = colorScheme.primary.toArgb()
|
||||||
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme, typography = Typography, content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.github.google.bumble.btbench.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
// Set of Material typography styles to start with
|
||||||
|
val Typography = Typography(
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)/* Other default text styles to override
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 28.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
labelSmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
android:height="108dp"
|
||||||
|
android:width="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 13 KiB |