forked from auracaster/bumble_mirror
Compare commits
67 Commits
v0.0.212
...
gbg/passke
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c034297bc0 | ||
|
|
a1eff958e6 | ||
|
|
efdc770fde | ||
|
|
357d7f9c22 | ||
|
|
3bc08b4e0d | ||
|
|
1dc0950177 | ||
|
|
df0fd74533 | ||
|
|
822f97fa84 | ||
|
|
4a6b0ef840 | ||
|
|
a6ead0147e | ||
|
|
0665e9ca5c | ||
|
|
b8b78ca1ee | ||
|
|
d611d25802 | ||
|
|
bf8a2cdcb5 | ||
|
|
cce2e4d4e3 | ||
|
|
4bf7448a01 | ||
|
|
1b44e73f90 | ||
|
|
1a81c5d05c | ||
|
|
d8a43f0151 | ||
|
|
858788f05e | ||
|
|
41f8797a4c | ||
|
|
fc3fd7f25b | ||
|
|
48bbf9f1e0 | ||
|
|
3d6c595c6e | ||
|
|
d9d971b8b3 | ||
|
|
a5effb433b | ||
|
|
8802c95d31 | ||
|
|
a184cae560 | ||
|
|
fa6fe2aaca | ||
|
|
43a8cc37f8 | ||
|
|
e45143e33d | ||
|
|
1c1b947455 | ||
|
|
d7ddffd275 | ||
|
|
3cb97d2373 | ||
|
|
bad037b010 | ||
|
|
88777710a4 | ||
|
|
0ab5b6c49a | ||
|
|
22ff0d5e32 | ||
|
|
2f5de37d76 | ||
|
|
799d730f88 | ||
|
|
1a05eebfdb | ||
|
|
ebaa720e74 | ||
|
|
a505badffc | ||
|
|
45d938c901 | ||
|
|
a0498af626 | ||
|
|
bf027cf38f | ||
|
|
f2d7faa9af | ||
|
|
a0248a1cdf | ||
|
|
1e95e19f16 | ||
|
|
8137caf37b | ||
|
|
630243e243 | ||
|
|
39518c89f5 | ||
|
|
d631156f6c | ||
|
|
60e31884c8 | ||
|
|
8614e075b6 | ||
|
|
8a0cd5d0d1 | ||
|
|
3a64772cc5 | ||
|
|
1ecfb78d94 | ||
|
|
9ad276a757 | ||
|
|
4c4f8c8225 | ||
|
|
a00b2bd707 | ||
|
|
b8a055de45 | ||
|
|
4d07726acf | ||
|
|
2e523b6f49 | ||
|
|
8f9f12f1ee | ||
|
|
a875aa4055 | ||
|
|
775b2d5d7f |
12
.vscode/settings.json
vendored
12
.vscode/settings.json
vendored
@@ -94,13 +94,17 @@
|
||||
"ycursor"
|
||||
],
|
||||
"[python]": {
|
||||
"editor.rulers": [88]
|
||||
"editor.rulers": [88],
|
||||
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||
},
|
||||
"python.formatting.provider": "black",
|
||||
"pylint.importStrategy": "useBundled",
|
||||
"python.testing.pytestArgs": [
|
||||
"."
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python-envs.defaultEnvManager": "ms-python.python:system",
|
||||
"python-envs.pythonProjects": [],
|
||||
"python.terminal.launchArgs": [
|
||||
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,9 +12,6 @@ Apps
|
||||
## `show.py`
|
||||
Parse a file with HCI packets and print the details of each packet in a human readable form
|
||||
|
||||
## `link_relay.py`
|
||||
Simple WebSocket relay for virtual RemoteLink instances to communicate with each other through.
|
||||
|
||||
## `hci_bridge.py`
|
||||
This app acts as a simple bridge between two HCI transports, with a host on one side and
|
||||
a controller on the other. All the HCI packets bridged between the two are printed on the console
|
||||
|
||||
@@ -23,15 +23,12 @@ import contextlib
|
||||
import dataclasses
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncGenerator,
|
||||
Coroutine,
|
||||
Deque,
|
||||
Optional,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
import click
|
||||
@@ -56,6 +53,8 @@ from bumble.profiles import bass
|
||||
import bumble.device
|
||||
import bumble.transport
|
||||
import bumble.utils
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -130,8 +129,8 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
||||
broadcast_audio_announcement: Optional[bap.BroadcastAudioAnnouncement] = None
|
||||
basic_audio_announcement: Optional[bap.BasicAudioAnnouncement] = None
|
||||
appearance: Optional[core.Appearance] = None
|
||||
biginfo: Optional[bumble.device.BIGInfoAdvertisement] = None
|
||||
manufacturer_data: Optional[Tuple[str, bytes]] = None
|
||||
biginfo: Optional[bumble.device.BigInfoAdvertisement] = None
|
||||
manufacturer_data: Optional[tuple[str, bytes]] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__init__()
|
||||
@@ -257,8 +256,10 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
||||
print(color(' SDU Interval: ', 'magenta'), self.biginfo.sdu_interval)
|
||||
print(color(' Max SDU: ', 'magenta'), self.biginfo.max_sdu)
|
||||
print(color(' PHY: ', 'magenta'), self.biginfo.phy.name)
|
||||
print(color(' Framed: ', 'magenta'), self.biginfo.framed)
|
||||
print(color(' Encrypted: ', 'magenta'), self.biginfo.encrypted)
|
||||
print(color(' Framing: ', 'magenta'), self.biginfo.framing.name)
|
||||
print(
|
||||
color(' Encryption: ', 'magenta'), self.biginfo.encryption.name
|
||||
)
|
||||
|
||||
def on_sync_establishment(self) -> None:
|
||||
self.emit('sync_establishment')
|
||||
@@ -288,7 +289,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
||||
self.emit('change')
|
||||
|
||||
def on_biginfo_advertisement(
|
||||
self, advertisement: bumble.device.BIGInfoAdvertisement
|
||||
self, advertisement: bumble.device.BigInfoAdvertisement
|
||||
) -> None:
|
||||
self.biginfo = advertisement
|
||||
self.emit('change')
|
||||
@@ -748,7 +749,9 @@ async def run_receive(
|
||||
sample_rate_hz=sampling_frequency.hz,
|
||||
num_channels=num_bis,
|
||||
)
|
||||
lc3_queues: list[Deque[bytes]] = [collections.deque() for i in range(num_bis)]
|
||||
lc3_queues: list[collections.deque[bytes]] = [
|
||||
collections.deque() for i in range(num_bis)
|
||||
]
|
||||
packet_stats = [0, 0]
|
||||
|
||||
audio_output = await audio_io.create_audio_output(output)
|
||||
@@ -764,7 +767,7 @@ async def run_receive(
|
||||
)
|
||||
)
|
||||
|
||||
def sink(queue: Deque[bytes], packet: hci.HCI_IsoDataPacket):
|
||||
def sink(queue: collections.deque[bytes], packet: hci.HCI_IsoDataPacket):
|
||||
# TODO: re-assemble fragments and detect errors
|
||||
queue.append(packet.iso_sdu_fragment)
|
||||
|
||||
@@ -1233,7 +1236,7 @@ def transmit(
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
bumble.logging.setup_basic_logging()
|
||||
auracast()
|
||||
|
||||
|
||||
|
||||
519
apps/bench.py
519
apps/bench.py
@@ -19,10 +19,10 @@ import asyncio
|
||||
import dataclasses
|
||||
import enum
|
||||
import logging
|
||||
import os
|
||||
import statistics
|
||||
import struct
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
@@ -35,7 +35,15 @@ from bumble.core import (
|
||||
CommandTimeoutError,
|
||||
)
|
||||
from bumble.colors import color
|
||||
from bumble.device import Connection, ConnectionParametersPreferences, Device, Peer
|
||||
from bumble.core import ConnectionPHY
|
||||
from bumble.device import (
|
||||
CigParameters,
|
||||
CisLink,
|
||||
Connection,
|
||||
ConnectionParametersPreferences,
|
||||
Device,
|
||||
Peer,
|
||||
)
|
||||
from bumble.gatt import Characteristic, CharacteristicValue, Service
|
||||
from bumble.hci import (
|
||||
HCI_LE_1M_PHY,
|
||||
@@ -45,6 +53,7 @@ from bumble.hci import (
|
||||
HCI_Constant,
|
||||
HCI_Error,
|
||||
HCI_StatusError,
|
||||
HCI_IsoDataPacket,
|
||||
)
|
||||
from bumble.sdp import (
|
||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||
@@ -55,11 +64,12 @@ from bumble.sdp import (
|
||||
DataElement,
|
||||
ServiceAttribute,
|
||||
)
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
import bumble.rfcomm
|
||||
import bumble.core
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.pairing import PairingConfig
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -75,17 +85,28 @@ DEFAULT_CENTRAL_ADDRESS = 'F0:F0:F0:F0:F0:F0'
|
||||
DEFAULT_CENTRAL_NAME = 'Speed Central'
|
||||
DEFAULT_PERIPHERAL_ADDRESS = 'F1:F1:F1:F1:F1:F1'
|
||||
DEFAULT_PERIPHERAL_NAME = 'Speed Peripheral'
|
||||
DEFAULT_ADVERTISING_INTERVAL = 100
|
||||
|
||||
SPEED_SERVICE_UUID = '50DB505C-8AC4-4738-8448-3B1D9CC09CC5'
|
||||
SPEED_TX_UUID = 'E789C754-41A1-45F4-A948-A0A1A90DBA53'
|
||||
SPEED_RX_UUID = '016A2CC7-E14B-4819-935F-1F56EAE4098D'
|
||||
|
||||
DEFAULT_RFCOMM_UUID = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
|
||||
|
||||
DEFAULT_L2CAP_PSM = 128
|
||||
DEFAULT_L2CAP_MAX_CREDITS = 128
|
||||
DEFAULT_L2CAP_MTU = 1024
|
||||
DEFAULT_L2CAP_MPS = 1024
|
||||
|
||||
DEFAULT_ISO_MAX_SDU_C_TO_P = 251
|
||||
DEFAULT_ISO_MAX_SDU_P_TO_C = 251
|
||||
DEFAULT_ISO_SDU_INTERVAL_C_TO_P = 10000
|
||||
DEFAULT_ISO_SDU_INTERVAL_P_TO_C = 10000
|
||||
DEFAULT_ISO_MAX_TRANSPORT_LATENCY_C_TO_P = 35
|
||||
DEFAULT_ISO_MAX_TRANSPORT_LATENCY_P_TO_C = 35
|
||||
DEFAULT_ISO_RTN_C_TO_P = 3
|
||||
DEFAULT_ISO_RTN_P_TO_C = 3
|
||||
|
||||
DEFAULT_LINGER_TIME = 1.0
|
||||
DEFAULT_POST_CONNECTION_WAIT_TIME = 1.0
|
||||
|
||||
@@ -102,14 +123,14 @@ def le_phy_name(phy_id):
|
||||
)
|
||||
|
||||
|
||||
def print_connection_phy(phy):
|
||||
def print_connection_phy(phy: ConnectionPHY) -> None:
|
||||
logging.info(
|
||||
color('@@@ PHY: ', 'yellow') + f'TX:{le_phy_name(phy.tx_phy)}/'
|
||||
f'RX:{le_phy_name(phy.rx_phy)}'
|
||||
)
|
||||
|
||||
|
||||
def print_connection(connection):
|
||||
def print_connection(connection: Connection) -> None:
|
||||
params = []
|
||||
if connection.transport == PhysicalTransport.LE:
|
||||
params.append(
|
||||
@@ -134,6 +155,34 @@ def print_connection(connection):
|
||||
logging.info(color('@@@ Connection: ', 'yellow') + ' '.join(params))
|
||||
|
||||
|
||||
def print_cis_link(cis_link: CisLink) -> None:
|
||||
logging.info(color("@@@ CIS established", "green"))
|
||||
logging.info(color('@@@ ISO interval: ', 'green') + f"{cis_link.iso_interval}ms")
|
||||
logging.info(color('@@@ NSE: ', 'green') + f"{cis_link.nse}")
|
||||
logging.info(color('@@@ Central->Peripheral:', 'green'))
|
||||
if cis_link.phy_c_to_p is not None:
|
||||
logging.info(
|
||||
color('@@@ PHY: ', 'green') + f"{cis_link.phy_c_to_p.name}"
|
||||
)
|
||||
logging.info(
|
||||
color('@@@ Latency: ', 'green') + f"{cis_link.transport_latency_c_to_p}µs"
|
||||
)
|
||||
logging.info(color('@@@ BN: ', 'green') + f"{cis_link.bn_c_to_p}")
|
||||
logging.info(color('@@@ FT: ', 'green') + f"{cis_link.ft_c_to_p}")
|
||||
logging.info(color('@@@ Max PDU: ', 'green') + f"{cis_link.max_pdu_c_to_p}")
|
||||
logging.info(color('@@@ Peripheral->Central:', 'green'))
|
||||
if cis_link.phy_p_to_c is not None:
|
||||
logging.info(
|
||||
color('@@@ PHY: ', 'green') + f"{cis_link.phy_p_to_c.name}"
|
||||
)
|
||||
logging.info(
|
||||
color('@@@ Latency: ', 'green') + f"{cis_link.transport_latency_p_to_c}µs"
|
||||
)
|
||||
logging.info(color('@@@ BN: ', 'green') + f"{cis_link.bn_p_to_c}")
|
||||
logging.info(color('@@@ FT: ', 'green') + f"{cis_link.ft_p_to_c}")
|
||||
logging.info(color('@@@ Max PDU: ', 'green') + f"{cis_link.max_pdu_p_to_c}")
|
||||
|
||||
|
||||
def make_sdp_records(channel):
|
||||
return {
|
||||
0x00010001: [
|
||||
@@ -197,6 +246,51 @@ async def switch_roles(connection, role):
|
||||
logging.info(f'{color("### Role switch failed:", "red")} {error}')
|
||||
|
||||
|
||||
async def pre_power_on(device: Device, classic: bool) -> None:
|
||||
device.classic_enabled = classic
|
||||
|
||||
# Set up a pairing config factory with minimal requirements.
|
||||
device.config.keystore = "JsonKeyStore"
|
||||
device.pairing_config_factory = lambda _: PairingConfig(
|
||||
sc=False, mitm=False, bonding=False
|
||||
)
|
||||
|
||||
|
||||
async def post_power_on(
|
||||
device: Device,
|
||||
le_scan: Optional[tuple[int, int]],
|
||||
le_advertise: Optional[int],
|
||||
classic_page_scan: bool,
|
||||
classic_inquiry_scan: bool,
|
||||
) -> None:
|
||||
if classic_page_scan:
|
||||
logging.info(color("*** Enabling page scan", "blue"))
|
||||
await device.set_connectable(True)
|
||||
if classic_inquiry_scan:
|
||||
logging.info(color("*** Enabling inquiry scan", "blue"))
|
||||
await device.set_discoverable(True)
|
||||
|
||||
if le_scan:
|
||||
scan_window, scan_interval = le_scan
|
||||
logging.info(
|
||||
color(
|
||||
f"*** Starting LE scanning [{scan_window}ms/{scan_interval}ms]",
|
||||
"blue",
|
||||
)
|
||||
)
|
||||
await device.start_scanning(
|
||||
scan_interval=scan_interval, scan_window=scan_window
|
||||
)
|
||||
|
||||
if le_advertise:
|
||||
logging.info(color(f"*** Starting LE advertising [{le_advertise}ms]", "blue"))
|
||||
await device.start_advertising(
|
||||
advertising_interval_min=le_advertise,
|
||||
advertising_interval_max=le_advertise,
|
||||
auto_restart=True,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Packet
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -414,7 +508,8 @@ class Sender:
|
||||
self.bytes_sent += len(packet)
|
||||
await self.packet_io.send_packet(packet)
|
||||
|
||||
await self.done.wait()
|
||||
if self.packet_io.can_receive():
|
||||
await self.done.wait()
|
||||
|
||||
run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else ''
|
||||
logging.info(color(f'=== {run_counter} Done!', 'magenta'))
|
||||
@@ -444,6 +539,9 @@ class Sender:
|
||||
)
|
||||
self.done.set()
|
||||
|
||||
def is_sender(self):
|
||||
return True
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Receiver
|
||||
@@ -491,7 +589,8 @@ class Receiver:
|
||||
logging.info(
|
||||
color(
|
||||
f'!!! Unexpected packet, expected {self.expected_packet_index} '
|
||||
f'but received {packet.sequence}'
|
||||
f'but received {packet.sequence}',
|
||||
'red',
|
||||
)
|
||||
)
|
||||
|
||||
@@ -534,6 +633,9 @@ class Receiver:
|
||||
await self.done.wait()
|
||||
logging.info(color('=== Done!', 'magenta'))
|
||||
|
||||
def is_sender(self):
|
||||
return False
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Ping
|
||||
@@ -669,7 +771,8 @@ class Ping:
|
||||
color(
|
||||
f'!!! Unexpected packet, '
|
||||
f'expected {self.next_expected_packet_index} '
|
||||
f'but received {packet.sequence}'
|
||||
f'but received {packet.sequence}',
|
||||
'red',
|
||||
)
|
||||
)
|
||||
|
||||
@@ -677,6 +780,9 @@ class Ping:
|
||||
self.done.set()
|
||||
return
|
||||
|
||||
def is_sender(self):
|
||||
return True
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Pong
|
||||
@@ -721,7 +827,8 @@ class Pong:
|
||||
logging.info(
|
||||
color(
|
||||
f'!!! Unexpected packet, expected {self.expected_packet_index} '
|
||||
f'but received {packet.sequence}'
|
||||
f'but received {packet.sequence}',
|
||||
'red',
|
||||
)
|
||||
)
|
||||
|
||||
@@ -743,6 +850,9 @@ class Pong:
|
||||
await self.done.wait()
|
||||
logging.info(color('=== Done!', 'magenta'))
|
||||
|
||||
def is_sender(self):
|
||||
return False
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# GattClient
|
||||
@@ -906,6 +1016,9 @@ class StreamedPacketIO:
|
||||
# pylint: disable-next=not-callable
|
||||
self.io_sink(struct.pack('>H', len(packet)) + packet)
|
||||
|
||||
def can_receive(self):
|
||||
return True
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# L2capClient
|
||||
@@ -1177,6 +1290,96 @@ class RfcommServer(StreamedPacketIO):
|
||||
await self.dlc.drain()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# IsoClient
|
||||
# -----------------------------------------------------------------------------
|
||||
class IsoClient(StreamedPacketIO):
|
||||
def __init__(
|
||||
self,
|
||||
device: Device,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.device = device
|
||||
self.ready = asyncio.Event()
|
||||
self.cis_link: Optional[CisLink] = None
|
||||
|
||||
async def on_connection(
|
||||
self, connection: Connection, cis_link: CisLink, sender: bool
|
||||
) -> None:
|
||||
connection.on(connection.EVENT_DISCONNECTION, self.on_disconnection)
|
||||
self.cis_link = cis_link
|
||||
self.io_sink = cis_link.write
|
||||
await cis_link.setup_data_path(
|
||||
cis_link.Direction.HOST_TO_CONTROLLER
|
||||
if sender
|
||||
else cis_link.Direction.CONTROLLER_TO_HOST
|
||||
)
|
||||
cis_link.sink = self.on_iso_packet
|
||||
self.ready.set()
|
||||
|
||||
def on_iso_packet(self, iso_packet: HCI_IsoDataPacket) -> None:
|
||||
self.on_packet(iso_packet.iso_sdu_fragment)
|
||||
|
||||
def on_disconnection(self, _):
|
||||
pass
|
||||
|
||||
async def drain(self):
|
||||
if self.cis_link is None:
|
||||
return
|
||||
await self.cis_link.drain()
|
||||
|
||||
def can_receive(self):
|
||||
return False
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# IsoServer
|
||||
# -----------------------------------------------------------------------------
|
||||
class IsoServer(StreamedPacketIO):
|
||||
def __init__(
|
||||
self,
|
||||
device: Device,
|
||||
):
|
||||
super().__init__()
|
||||
self.device = device
|
||||
self.cis_link: Optional[CisLink] = None
|
||||
self.ready = asyncio.Event()
|
||||
|
||||
logging.info(
|
||||
color(
|
||||
'### Listening for ISO connection',
|
||||
'yellow',
|
||||
)
|
||||
)
|
||||
|
||||
async def on_connection(
|
||||
self, connection: Connection, cis_link: CisLink, sender: bool
|
||||
) -> None:
|
||||
connection.on(connection.EVENT_DISCONNECTION, self.on_disconnection)
|
||||
self.io_sink = cis_link.write
|
||||
await cis_link.setup_data_path(
|
||||
cis_link.Direction.HOST_TO_CONTROLLER
|
||||
if sender
|
||||
else cis_link.Direction.CONTROLLER_TO_HOST
|
||||
)
|
||||
cis_link.sink = self.on_iso_packet
|
||||
self.ready.set()
|
||||
|
||||
def on_iso_packet(self, iso_packet: HCI_IsoDataPacket) -> None:
|
||||
self.on_packet(iso_packet.iso_sdu_fragment)
|
||||
|
||||
def on_disconnection(self, _):
|
||||
pass
|
||||
|
||||
async def drain(self):
|
||||
if self.cis_link is None:
|
||||
return
|
||||
await self.cis_link.drain()
|
||||
|
||||
def can_receive(self):
|
||||
return False
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Central
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1185,26 +1388,52 @@ class Central(Connection.Listener):
|
||||
self,
|
||||
transport,
|
||||
peripheral_address,
|
||||
classic,
|
||||
scenario_factory,
|
||||
mode_factory,
|
||||
connection_interval,
|
||||
phy,
|
||||
authenticate,
|
||||
encrypt,
|
||||
iso,
|
||||
iso_sdu_interval_c_to_p,
|
||||
iso_sdu_interval_p_to_c,
|
||||
iso_max_sdu_c_to_p,
|
||||
iso_max_sdu_p_to_c,
|
||||
iso_max_transport_latency_c_to_p,
|
||||
iso_max_transport_latency_p_to_c,
|
||||
iso_rtn_c_to_p,
|
||||
iso_rtn_p_to_c,
|
||||
classic,
|
||||
extended_data_length,
|
||||
role_switch,
|
||||
le_scan,
|
||||
le_advertise,
|
||||
classic_page_scan,
|
||||
classic_inquiry_scan,
|
||||
):
|
||||
super().__init__()
|
||||
self.transport = transport
|
||||
self.peripheral_address = peripheral_address
|
||||
self.classic = classic
|
||||
self.iso = iso
|
||||
self.iso_sdu_interval_c_to_p = iso_sdu_interval_c_to_p
|
||||
self.iso_sdu_interval_p_to_c = iso_sdu_interval_p_to_c
|
||||
self.iso_max_sdu_c_to_p = iso_max_sdu_c_to_p
|
||||
self.iso_max_sdu_p_to_c = iso_max_sdu_p_to_c
|
||||
self.iso_max_transport_latency_c_to_p = iso_max_transport_latency_c_to_p
|
||||
self.iso_max_transport_latency_p_to_c = iso_max_transport_latency_p_to_c
|
||||
self.iso_rtn_c_to_p = iso_rtn_c_to_p
|
||||
self.iso_rtn_p_to_c = iso_rtn_p_to_c
|
||||
self.scenario_factory = scenario_factory
|
||||
self.mode_factory = mode_factory
|
||||
self.authenticate = authenticate
|
||||
self.encrypt = encrypt or authenticate
|
||||
self.extended_data_length = extended_data_length
|
||||
self.role_switch = role_switch
|
||||
self.le_scan = le_scan
|
||||
self.le_advertise = le_advertise
|
||||
self.classic_page_scan = classic_page_scan
|
||||
self.classic_inquiry_scan = classic_inquiry_scan
|
||||
self.device = None
|
||||
self.connection = None
|
||||
|
||||
@@ -1241,7 +1470,7 @@ class Central(Connection.Listener):
|
||||
|
||||
async def run(self):
|
||||
logging.info(color('>>> Connecting to HCI...', 'green'))
|
||||
async with await open_transport_or_link(self.transport) as (
|
||||
async with await open_transport(self.transport) as (
|
||||
hci_source,
|
||||
hci_sink,
|
||||
):
|
||||
@@ -1254,18 +1483,22 @@ class Central(Connection.Listener):
|
||||
mode = self.mode_factory(self.device)
|
||||
scenario = self.scenario_factory(mode)
|
||||
self.device.classic_enabled = self.classic
|
||||
self.device.cis_enabled = self.iso
|
||||
|
||||
# Set up a pairing config factory with minimal requirements.
|
||||
self.device.config.keystore = "JsonKeyStore"
|
||||
self.device.pairing_config_factory = lambda _: PairingConfig(
|
||||
sc=False, mitm=False, bonding=False
|
||||
)
|
||||
|
||||
await pre_power_on(self.device, self.classic)
|
||||
await self.device.power_on()
|
||||
|
||||
if self.classic:
|
||||
await self.device.set_discoverable(False)
|
||||
await self.device.set_connectable(False)
|
||||
await post_power_on(
|
||||
self.device,
|
||||
self.le_scan,
|
||||
self.le_advertise,
|
||||
self.classic_page_scan,
|
||||
self.classic_inquiry_scan,
|
||||
)
|
||||
|
||||
logging.info(
|
||||
color(f'### Connecting to {self.peripheral_address}...', 'cyan')
|
||||
@@ -1340,7 +1573,72 @@ class Central(Connection.Listener):
|
||||
)
|
||||
)
|
||||
|
||||
await mode.on_connection(self.connection)
|
||||
# Setup ISO streams.
|
||||
if self.iso:
|
||||
if scenario.is_sender():
|
||||
sdu_interval_c_to_p = (
|
||||
self.iso_sdu_interval_c_to_p or DEFAULT_ISO_SDU_INTERVAL_C_TO_P
|
||||
)
|
||||
sdu_interval_p_to_c = self.iso_sdu_interval_p_to_c or 0
|
||||
max_transport_latency_c_to_p = (
|
||||
self.iso_max_transport_latency_c_to_p
|
||||
or DEFAULT_ISO_MAX_TRANSPORT_LATENCY_C_TO_P
|
||||
)
|
||||
max_transport_latency_p_to_c = (
|
||||
self.iso_max_transport_latency_p_to_c or 0
|
||||
)
|
||||
max_sdu_c_to_p = (
|
||||
self.iso_max_sdu_c_to_p or DEFAULT_ISO_MAX_SDU_C_TO_P
|
||||
)
|
||||
max_sdu_p_to_c = self.iso_max_sdu_p_to_c or 0
|
||||
rtn_c_to_p = self.iso_rtn_c_to_p or DEFAULT_ISO_RTN_C_TO_P
|
||||
rtn_p_to_c = self.iso_rtn_p_to_c or 0
|
||||
else:
|
||||
sdu_interval_p_to_c = (
|
||||
self.iso_sdu_interval_p_to_c or DEFAULT_ISO_SDU_INTERVAL_P_TO_C
|
||||
)
|
||||
sdu_interval_c_to_p = self.iso_sdu_interval_c_to_p or 0
|
||||
max_transport_latency_p_to_c = (
|
||||
self.iso_max_transport_latency_p_to_c
|
||||
or DEFAULT_ISO_MAX_TRANSPORT_LATENCY_P_TO_C
|
||||
)
|
||||
max_transport_latency_c_to_p = (
|
||||
self.iso_max_transport_latency_c_to_p or 0
|
||||
)
|
||||
max_sdu_p_to_c = (
|
||||
self.iso_max_sdu_p_to_c or DEFAULT_ISO_MAX_SDU_P_TO_C
|
||||
)
|
||||
max_sdu_c_to_p = self.iso_max_sdu_c_to_p or 0
|
||||
rtn_p_to_c = self.iso_rtn_p_to_c or DEFAULT_ISO_RTN_P_TO_C
|
||||
rtn_c_to_p = self.iso_rtn_c_to_p or 0
|
||||
cis_handles = await self.device.setup_cig(
|
||||
CigParameters(
|
||||
cig_id=1,
|
||||
sdu_interval_c_to_p=sdu_interval_c_to_p,
|
||||
sdu_interval_p_to_c=sdu_interval_p_to_c,
|
||||
max_transport_latency_c_to_p=max_transport_latency_c_to_p,
|
||||
max_transport_latency_p_to_c=max_transport_latency_p_to_c,
|
||||
cis_parameters=[
|
||||
CigParameters.CisParameters(
|
||||
cis_id=2,
|
||||
max_sdu_c_to_p=max_sdu_c_to_p,
|
||||
max_sdu_p_to_c=max_sdu_p_to_c,
|
||||
rtn_c_to_p=rtn_c_to_p,
|
||||
rtn_p_to_c=rtn_p_to_c,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
cis_link = (
|
||||
await self.device.create_cis([(cis_handles[0], self.connection)])
|
||||
)[0]
|
||||
print_cis_link(cis_link)
|
||||
|
||||
await mode.on_connection(
|
||||
self.connection, cis_link, scenario.is_sender()
|
||||
)
|
||||
else:
|
||||
await mode.on_connection(self.connection)
|
||||
|
||||
await scenario.run()
|
||||
await asyncio.sleep(DEFAULT_LINGER_TIME)
|
||||
@@ -1376,24 +1674,38 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
scenario_factory,
|
||||
mode_factory,
|
||||
classic,
|
||||
iso,
|
||||
extended_data_length,
|
||||
role_switch,
|
||||
le_scan,
|
||||
le_advertise,
|
||||
classic_page_scan,
|
||||
classic_inquiry_scan,
|
||||
):
|
||||
self.transport = transport
|
||||
self.classic = classic
|
||||
self.iso = iso
|
||||
self.scenario_factory = scenario_factory
|
||||
self.mode_factory = mode_factory
|
||||
self.extended_data_length = extended_data_length
|
||||
self.role_switch = role_switch
|
||||
self.le_scan = le_scan
|
||||
self.classic_page_scan = classic_page_scan
|
||||
self.classic_inquiry_scan = classic_inquiry_scan
|
||||
self.scenario = None
|
||||
self.mode = None
|
||||
self.device = None
|
||||
self.connection = None
|
||||
self.connected = asyncio.Event()
|
||||
|
||||
if le_advertise:
|
||||
self.le_advertise = le_advertise
|
||||
else:
|
||||
self.le_advertise = 0 if classic else DEFAULT_ADVERTISING_INTERVAL
|
||||
|
||||
async def run(self):
|
||||
logging.info(color('>>> Connecting to HCI...', 'green'))
|
||||
async with await open_transport_or_link(self.transport) as (
|
||||
async with await open_transport(self.transport) as (
|
||||
hci_source,
|
||||
hci_sink,
|
||||
):
|
||||
@@ -1407,20 +1719,22 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
self.mode = self.mode_factory(self.device)
|
||||
self.scenario = self.scenario_factory(self.mode)
|
||||
self.device.classic_enabled = self.classic
|
||||
self.device.cis_enabled = self.iso
|
||||
|
||||
# Set up a pairing config factory with minimal requirements.
|
||||
self.device.config.keystore = "JsonKeyStore"
|
||||
self.device.pairing_config_factory = lambda _: PairingConfig(
|
||||
sc=False, mitm=False, bonding=False
|
||||
)
|
||||
|
||||
await pre_power_on(self.device, self.classic)
|
||||
await self.device.power_on()
|
||||
|
||||
if self.classic:
|
||||
await self.device.set_discoverable(True)
|
||||
await self.device.set_connectable(True)
|
||||
else:
|
||||
await self.device.start_advertising(auto_restart=True)
|
||||
await post_power_on(
|
||||
self.device,
|
||||
self.le_scan,
|
||||
self.le_advertise,
|
||||
self.classic or self.classic_page_scan,
|
||||
self.classic or self.classic_inquiry_scan,
|
||||
)
|
||||
|
||||
if self.classic:
|
||||
logging.info(
|
||||
@@ -1442,7 +1756,21 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
logging.info(color('### Connected', 'cyan'))
|
||||
print_connection(self.connection)
|
||||
|
||||
await self.mode.on_connection(self.connection)
|
||||
if self.iso:
|
||||
|
||||
async def on_cis_request(cis_link: CisLink) -> None:
|
||||
logging.info(color("@@@ Accepting CIS", "green"))
|
||||
await self.device.accept_cis_request(cis_link)
|
||||
print_cis_link(cis_link)
|
||||
|
||||
await self.mode.on_connection(
|
||||
self.connection, cis_link, self.scenario.is_sender()
|
||||
)
|
||||
|
||||
self.connection.on(self.connection.EVENT_CIS_REQUEST, on_cis_request)
|
||||
else:
|
||||
await self.mode.on_connection(self.connection)
|
||||
|
||||
await self.scenario.run()
|
||||
await asyncio.sleep(DEFAULT_LINGER_TIME)
|
||||
|
||||
@@ -1451,10 +1779,14 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
self.connection = connection
|
||||
self.connected.set()
|
||||
|
||||
# Stop being discoverable and connectable
|
||||
# Stop being discoverable and connectable if possible
|
||||
if self.classic:
|
||||
AsyncRunner.spawn(self.device.set_discoverable(False))
|
||||
AsyncRunner.spawn(self.device.set_connectable(False))
|
||||
if not self.classic_inquiry_scan:
|
||||
logging.info(color("*** Stopping inquiry scan", "blue"))
|
||||
AsyncRunner.spawn(self.device.set_discoverable(False))
|
||||
if not self.classic_page_scan:
|
||||
logging.info(color("*** Stopping page scan", "blue"))
|
||||
AsyncRunner.spawn(self.device.set_connectable(False))
|
||||
|
||||
# Request a new data length if needed
|
||||
if not self.classic and self.extended_data_length:
|
||||
@@ -1475,7 +1807,9 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
self.scenario.reset()
|
||||
|
||||
if self.classic:
|
||||
logging.info(color("*** Enabling inquiry scan", "blue"))
|
||||
AsyncRunner.spawn(self.device.set_discoverable(True))
|
||||
logging.info(color("*** Enabling page scan", "blue"))
|
||||
AsyncRunner.spawn(self.device.set_connectable(True))
|
||||
|
||||
def on_connection_parameters_update(self):
|
||||
@@ -1548,6 +1882,12 @@ def create_mode_factory(ctx, default_mode):
|
||||
credits_threshold=ctx.obj['rfcomm_credits_threshold'],
|
||||
)
|
||||
|
||||
if mode == 'iso-server':
|
||||
return IsoServer(device)
|
||||
|
||||
if mode == 'iso-client':
|
||||
return IsoClient(device)
|
||||
|
||||
raise ValueError('invalid mode')
|
||||
|
||||
return create_mode
|
||||
@@ -1575,6 +1915,9 @@ def create_scenario_factory(ctx, default_scenario):
|
||||
return Receiver(packet_io, ctx.obj['linger'])
|
||||
|
||||
if scenario == 'ping':
|
||||
if isinstance(packet_io, (IsoClient, IsoServer)):
|
||||
raise ValueError('ping not supported with ISO')
|
||||
|
||||
return Ping(
|
||||
packet_io,
|
||||
start_delay=ctx.obj['start_delay'],
|
||||
@@ -1586,6 +1929,9 @@ def create_scenario_factory(ctx, default_scenario):
|
||||
)
|
||||
|
||||
if scenario == 'pong':
|
||||
if isinstance(packet_io, (IsoClient, IsoServer)):
|
||||
raise ValueError('pong not supported with ISO')
|
||||
|
||||
return Pong(packet_io, ctx.obj['linger'])
|
||||
|
||||
raise ValueError('invalid scenario')
|
||||
@@ -1609,6 +1955,8 @@ def create_scenario_factory(ctx, default_scenario):
|
||||
'l2cap-server',
|
||||
'rfcomm-client',
|
||||
'rfcomm-server',
|
||||
'iso-client',
|
||||
'iso-server',
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -1621,6 +1969,7 @@ def create_scenario_factory(ctx, default_scenario):
|
||||
)
|
||||
@click.option(
|
||||
'--extended-data-length',
|
||||
metavar='<TX-OCTETS>/<TX-TIME>',
|
||||
help='Request a data length upon connection, specified as tx_octets/tx_time',
|
||||
)
|
||||
@click.option(
|
||||
@@ -1628,6 +1977,26 @@ def create_scenario_factory(ctx, default_scenario):
|
||||
type=click.Choice(['central', 'peripheral']),
|
||||
help='Request role switch upon connection (central or peripheral)',
|
||||
)
|
||||
@click.option(
|
||||
'--le-scan',
|
||||
metavar='<WINDOW>/<INTERVAL>',
|
||||
help='Perform an LE scan with a given window and interval (milliseconds)',
|
||||
)
|
||||
@click.option(
|
||||
'--le-advertise',
|
||||
metavar='<INTERVAL>',
|
||||
help='Advertise with a given interval (milliseconds)',
|
||||
)
|
||||
@click.option(
|
||||
'--classic-page-scan',
|
||||
is_flag=True,
|
||||
help='Enable Classic page scanning',
|
||||
)
|
||||
@click.option(
|
||||
'--classic-inquiry-scan',
|
||||
is_flag=True,
|
||||
help='Enable Classic enquiry scanning',
|
||||
)
|
||||
@click.option(
|
||||
'--rfcomm-channel',
|
||||
type=int,
|
||||
@@ -1753,6 +2122,10 @@ def bench(
|
||||
att_mtu,
|
||||
extended_data_length,
|
||||
role_switch,
|
||||
le_scan,
|
||||
le_advertise,
|
||||
classic_page_scan,
|
||||
classic_inquiry_scan,
|
||||
packet_size,
|
||||
packet_count,
|
||||
start_delay,
|
||||
@@ -1801,7 +2174,12 @@ def bench(
|
||||
else None
|
||||
)
|
||||
ctx.obj['role_switch'] = role_switch
|
||||
ctx.obj['le_scan'] = [float(x) for x in le_scan.split('/')] if le_scan else None
|
||||
ctx.obj['le_advertise'] = float(le_advertise) if le_advertise else None
|
||||
ctx.obj['classic_page_scan'] = classic_page_scan
|
||||
ctx.obj['classic_inquiry_scan'] = classic_inquiry_scan
|
||||
ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server')
|
||||
ctx.obj['iso'] = mode in ('iso-client', 'iso-server')
|
||||
|
||||
|
||||
@bench.command()
|
||||
@@ -1823,28 +2201,94 @@ def bench(
|
||||
@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.option(
|
||||
'--iso-sdu-interval-c-to-p',
|
||||
type=int,
|
||||
help='ISO SDU central -> peripheral (microseconds)',
|
||||
)
|
||||
@click.option(
|
||||
'--iso-sdu-interval-p-to-c',
|
||||
type=int,
|
||||
help='ISO SDU interval peripheral -> central (microseconds)',
|
||||
)
|
||||
@click.option(
|
||||
'--iso-max-sdu-c-to-p',
|
||||
type=int,
|
||||
help='ISO max SDU central -> peripheral',
|
||||
)
|
||||
@click.option(
|
||||
'--iso-max-sdu-p-to-c',
|
||||
type=int,
|
||||
help='ISO max SDU peripheral -> central',
|
||||
)
|
||||
@click.option(
|
||||
'--iso-max-transport-latency-c-to-p',
|
||||
type=int,
|
||||
help='ISO max transport latency central -> peripheral (milliseconds)',
|
||||
)
|
||||
@click.option(
|
||||
'--iso-max-transport-latency-p-to-c',
|
||||
type=int,
|
||||
help='ISO max transport latency peripheral -> central (milliseconds)',
|
||||
)
|
||||
@click.option(
|
||||
'--iso-rtn-c-to-p',
|
||||
type=int,
|
||||
help='ISO RTN central -> peripheral (integer count)',
|
||||
)
|
||||
@click.option(
|
||||
'--iso-rtn-p-to-c',
|
||||
type=int,
|
||||
help='ISO RTN peripheral -> central (integer count)',
|
||||
)
|
||||
@click.pass_context
|
||||
def central(
|
||||
ctx, transport, peripheral_address, connection_interval, phy, authenticate, encrypt
|
||||
ctx,
|
||||
transport,
|
||||
peripheral_address,
|
||||
connection_interval,
|
||||
phy,
|
||||
authenticate,
|
||||
encrypt,
|
||||
iso_sdu_interval_c_to_p,
|
||||
iso_sdu_interval_p_to_c,
|
||||
iso_max_sdu_c_to_p,
|
||||
iso_max_sdu_p_to_c,
|
||||
iso_max_transport_latency_c_to_p,
|
||||
iso_max_transport_latency_p_to_c,
|
||||
iso_rtn_c_to_p,
|
||||
iso_rtn_p_to_c,
|
||||
):
|
||||
"""Run as a central (initiates the connection)"""
|
||||
scenario_factory = create_scenario_factory(ctx, 'send')
|
||||
mode_factory = create_mode_factory(ctx, 'gatt-client')
|
||||
classic = ctx.obj['classic']
|
||||
|
||||
async def run_central():
|
||||
await Central(
|
||||
transport,
|
||||
peripheral_address,
|
||||
classic,
|
||||
scenario_factory,
|
||||
mode_factory,
|
||||
connection_interval,
|
||||
phy,
|
||||
authenticate,
|
||||
encrypt or authenticate,
|
||||
ctx.obj['iso'],
|
||||
iso_sdu_interval_c_to_p,
|
||||
iso_sdu_interval_p_to_c,
|
||||
iso_max_sdu_c_to_p,
|
||||
iso_max_sdu_p_to_c,
|
||||
iso_max_transport_latency_c_to_p,
|
||||
iso_max_transport_latency_p_to_c,
|
||||
iso_rtn_c_to_p,
|
||||
iso_rtn_p_to_c,
|
||||
ctx.obj['classic'],
|
||||
ctx.obj['extended_data_length'],
|
||||
ctx.obj['role_switch'],
|
||||
ctx.obj['le_scan'],
|
||||
ctx.obj['le_advertise'],
|
||||
ctx.obj['classic_page_scan'],
|
||||
ctx.obj['classic_inquiry_scan'],
|
||||
).run()
|
||||
|
||||
asyncio.run(run_central())
|
||||
@@ -1864,19 +2308,20 @@ def peripheral(ctx, transport):
|
||||
scenario_factory,
|
||||
mode_factory,
|
||||
ctx.obj['classic'],
|
||||
ctx.obj['iso'],
|
||||
ctx.obj['extended_data_length'],
|
||||
ctx.obj['role_switch'],
|
||||
ctx.obj['le_scan'],
|
||||
ctx.obj['le_advertise'],
|
||||
ctx.obj['classic_page_scan'],
|
||||
ctx.obj['classic_inquiry_scan'],
|
||||
).run()
|
||||
|
||||
asyncio.run(run_peripheral())
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(
|
||||
level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper(),
|
||||
format="[%(asctime)s.%(msecs)03d] %(levelname)s:%(name)s:%(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
bumble.logging.setup_basic_logging('INFO')
|
||||
bench()
|
||||
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ from prompt_toolkit.layout import (
|
||||
from bumble import __version__
|
||||
import bumble.core
|
||||
from bumble import colors
|
||||
from bumble.core import UUID, AdvertisingData, PhysicalTransport
|
||||
from bumble.core import UUID, AdvertisingData
|
||||
from bumble.device import (
|
||||
ConnectionParametersPreferences,
|
||||
ConnectionPHY,
|
||||
@@ -64,7 +64,7 @@ from bumble.device import (
|
||||
Peer,
|
||||
)
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
from bumble.gatt import Characteristic, Service, CharacteristicDeclaration, Descriptor
|
||||
from bumble.gatt_client import CharacteristicProxy
|
||||
from bumble.hci import (
|
||||
@@ -291,7 +291,7 @@ class ConsoleApp:
|
||||
async def run_async(self, device_config, transport):
|
||||
rssi_monitoring_task = asyncio.create_task(self.rssi_monitor_loop())
|
||||
|
||||
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
|
||||
async with await open_transport(transport) as (hci_source, hci_sink):
|
||||
if device_config:
|
||||
self.device = Device.from_config_file_with_hci(
|
||||
device_config, hci_source, hci_sink
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
|
||||
import click
|
||||
@@ -58,7 +56,8 @@ from bumble.hci import (
|
||||
HCI_Read_Local_Version_Information_Command,
|
||||
)
|
||||
from bumble.host import Host
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -242,28 +241,43 @@ async def get_codecs_info(host: Host) -> None:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def async_main(latency_probes, transport):
|
||||
async def async_main(
|
||||
latency_probes, latency_probe_interval, latency_probe_command, transport
|
||||
):
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
|
||||
async with await open_transport(transport) as (hci_source, hci_sink):
|
||||
print('<<< connected')
|
||||
|
||||
host = Host(hci_source, hci_sink)
|
||||
await host.reset()
|
||||
|
||||
# Measure the latency if requested
|
||||
# (we add an extra probe at the start, that we ignore, just to ensure that
|
||||
# the transport is primed)
|
||||
latencies = []
|
||||
if latency_probes:
|
||||
for _ in range(latency_probes):
|
||||
if latency_probe_command:
|
||||
probe_hci_command = HCI_Command.from_bytes(
|
||||
bytes.fromhex(latency_probe_command)
|
||||
)
|
||||
else:
|
||||
probe_hci_command = HCI_Read_Local_Version_Information_Command()
|
||||
|
||||
for iteration in range(1 + latency_probes):
|
||||
if latency_probe_interval:
|
||||
await asyncio.sleep(latency_probe_interval / 1000)
|
||||
start = time.time()
|
||||
await host.send_command(HCI_Read_Local_Version_Information_Command())
|
||||
latencies.append(1000 * (time.time() - start))
|
||||
await host.send_command(probe_hci_command)
|
||||
if iteration:
|
||||
latencies.append(1000 * (time.time() - start))
|
||||
print(
|
||||
color('HCI Command Latency:', 'yellow'),
|
||||
(
|
||||
f'min={min(latencies):.2f}, '
|
||||
f'max={max(latencies):.2f}, '
|
||||
f'average={sum(latencies)/len(latencies):.2f}'
|
||||
f'average={sum(latencies)/len(latencies):.2f},'
|
||||
),
|
||||
[f'{latency:.4}' for latency in latencies],
|
||||
'\n',
|
||||
)
|
||||
|
||||
@@ -311,10 +325,28 @@ async def async_main(latency_probes, transport):
|
||||
type=int,
|
||||
help='Send N commands to measure HCI transport latency statistics',
|
||||
)
|
||||
@click.option(
|
||||
'--latency-probe-interval',
|
||||
metavar='INTERVAL',
|
||||
type=int,
|
||||
help='Interval between latency probes (milliseconds)',
|
||||
)
|
||||
@click.option(
|
||||
'--latency-probe-command',
|
||||
metavar='COMMAND_HEX',
|
||||
help=(
|
||||
'Probe command (HCI Command packet bytes, in hex. Use 0177FC00 for'
|
||||
' a loopback test with the HCI remote proxy app)'
|
||||
),
|
||||
)
|
||||
@click.argument('transport')
|
||||
def main(latency_probes, transport):
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||
asyncio.run(async_main(latency_probes, transport))
|
||||
def main(latency_probes, latency_probe_interval, latency_probe_command, transport):
|
||||
bumble.logging.setup_basic_logging()
|
||||
asyncio.run(
|
||||
async_main(
|
||||
latency_probes, latency_probe_interval, latency_probe_command, transport
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.hci import (
|
||||
HCI_READ_LOOPBACK_MODE_COMMAND,
|
||||
@@ -29,8 +30,8 @@ from bumble.hci import (
|
||||
LoopbackMode,
|
||||
)
|
||||
from bumble.host import Host
|
||||
from bumble.transport import open_transport_or_link
|
||||
import click
|
||||
from bumble.transport import open_transport
|
||||
import bumble.logging
|
||||
|
||||
|
||||
class Loopback:
|
||||
@@ -88,7 +89,7 @@ class Loopback:
|
||||
async def run(self):
|
||||
"""Run a loopback throughput test"""
|
||||
print(color('>>> Connecting to HCI...', 'green'))
|
||||
async with await open_transport_or_link(self.transport) as (
|
||||
async with await open_transport(self.transport) as (
|
||||
hci_source,
|
||||
hci_sink,
|
||||
):
|
||||
@@ -194,8 +195,7 @@ class Loopback:
|
||||
)
|
||||
@click.argument('transport')
|
||||
def main(packet_size, packet_count, transport):
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||
|
||||
bumble.logging.setup_basic_logging()
|
||||
loopback = Loopback(packet_size, packet_count, transport)
|
||||
asyncio.run(loopback.run())
|
||||
|
||||
|
||||
@@ -15,14 +15,13 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
from bumble.controller import Controller
|
||||
from bumble.link import LocalLink
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -42,7 +41,7 @@ async def async_main():
|
||||
transports = []
|
||||
controllers = []
|
||||
for index, transport_name in enumerate(sys.argv[1:]):
|
||||
transport = await open_transport_or_link(transport_name)
|
||||
transport = await open_transport(transport_name)
|
||||
transports.append(transport)
|
||||
controller = Controller(
|
||||
f'C{index}',
|
||||
@@ -62,7 +61,7 @@ async def async_main():
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def main():
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
bumble.logging.setup_basic_logging()
|
||||
asyncio.run(async_main())
|
||||
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
from typing import Callable, Iterable, Optional
|
||||
|
||||
import click
|
||||
@@ -32,7 +30,8 @@ from bumble.profiles.gap import GenericAccessServiceProxy
|
||||
from bumble.profiles.pacs import PublishedAudioCapabilitiesServiceProxy
|
||||
from bumble.profiles.tmap import TelephonyAndMediaAudioServiceProxy
|
||||
from bumble.profiles.vcs import VolumeControlServiceProxy
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -215,7 +214,7 @@ async def show_device_info(peer, done: Optional[asyncio.Future]) -> None:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def async_main(device_config, encrypt, transport, address_or_name):
|
||||
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
|
||||
async with await open_transport(transport) as (hci_source, hci_sink):
|
||||
|
||||
# Create a device
|
||||
if device_config:
|
||||
@@ -267,7 +266,7 @@ def main(device_config, encrypt, transport, address_or_name):
|
||||
Dump the GATT database on a remote device. If ADDRESS_OR_NAME is not specified,
|
||||
wait for an incoming connection.
|
||||
"""
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
bumble.logging.setup_basic_logging()
|
||||
asyncio.run(async_main(device_config, encrypt, transport, address_or_name))
|
||||
|
||||
|
||||
|
||||
@@ -16,15 +16,15 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
|
||||
import click
|
||||
|
||||
import bumble.core
|
||||
from bumble.colors import color
|
||||
from bumble.device import Device, Peer
|
||||
from bumble.gatt import show_services
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -60,7 +60,7 @@ async def dump_gatt_db(peer, done):
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def async_main(device_config, encrypt, transport, address_or_name):
|
||||
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
|
||||
async with await open_transport(transport) as (hci_source, hci_sink):
|
||||
|
||||
# Create a device
|
||||
if device_config:
|
||||
@@ -112,7 +112,7 @@ def main(device_config, encrypt, transport, address_or_name):
|
||||
Dump the GATT database on a remote device. If ADDRESS_OR_NAME is not specified,
|
||||
wait for an incoming connection.
|
||||
"""
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
bumble.logging.setup_basic_logging()
|
||||
asyncio.run(async_main(device_config, encrypt, transport, address_or_name))
|
||||
|
||||
|
||||
|
||||
@@ -16,9 +16,8 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import os
|
||||
import struct
|
||||
import logging
|
||||
|
||||
import click
|
||||
|
||||
from bumble import l2cap
|
||||
@@ -27,8 +26,9 @@ from bumble.device import Device, Peer
|
||||
from bumble.core import AdvertisingData
|
||||
from bumble.gatt import Service, Characteristic, CharacteristicValue
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
from bumble.hci import HCI_Constant
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -325,7 +325,7 @@ async def run(
|
||||
receive_port,
|
||||
):
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
|
||||
async with await open_transport(hci_transport) as (hci_source, hci_sink):
|
||||
print('<<< connected')
|
||||
|
||||
# Instantiate a bridge object
|
||||
@@ -383,6 +383,7 @@ def main(
|
||||
receive_host,
|
||||
receive_port,
|
||||
):
|
||||
bumble.logging.setup_basic_logging('WARNING')
|
||||
asyncio.run(
|
||||
run(
|
||||
hci_transport,
|
||||
@@ -397,6 +398,5 @@ def main(
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -17,11 +17,12 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
from bumble import hci, transport
|
||||
from bumble.bridge import HCI_Bridge
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -46,14 +47,14 @@ async def async_main():
|
||||
return
|
||||
|
||||
print('>>> connecting to HCI...')
|
||||
async with await transport.open_transport_or_link(sys.argv[1]) as (
|
||||
async with await transport.open_transport(sys.argv[1]) as (
|
||||
hci_host_source,
|
||||
hci_host_sink,
|
||||
):
|
||||
print('>>> connected')
|
||||
|
||||
print('>>> connecting to HCI...')
|
||||
async with await transport.open_transport_or_link(sys.argv[2]) as (
|
||||
async with await transport.open_transport(sys.argv[2]) as (
|
||||
hci_controller_source,
|
||||
hci_controller_sink,
|
||||
):
|
||||
@@ -100,7 +101,7 @@ async def async_main():
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def main():
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
bumble.logging.setup_basic_logging()
|
||||
asyncio.run(async_main())
|
||||
|
||||
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
import click
|
||||
|
||||
from bumble import l2cap
|
||||
from bumble.colors import color
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
from bumble.device import Device
|
||||
from bumble.utils import FlowControlAsyncPipe
|
||||
from bumble.hci import HCI_Constant
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -258,7 +258,7 @@ class ClientBridge:
|
||||
# -----------------------------------------------------------------------------
|
||||
async def run(device_config, hci_transport, bridge):
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
|
||||
async with await open_transport(hci_transport) as (hci_source, hci_sink):
|
||||
print('<<< connected')
|
||||
|
||||
device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
|
||||
@@ -356,6 +356,6 @@ def client(context, bluetooth_address, tcp_host, tcp_port):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||
if __name__ == '__main__':
|
||||
bumble.logging.setup_basic_logging('WARNING')
|
||||
cli(obj={}) # pylint: disable=no-value-for-parameter
|
||||
|
||||
@@ -22,7 +22,6 @@ import datetime
|
||||
import functools
|
||||
from importlib import resources
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import pathlib
|
||||
import weakref
|
||||
@@ -44,6 +43,7 @@ from bumble.device import Device, DeviceConfiguration, AdvertisingParameters, Ci
|
||||
from bumble.transport import open_transport
|
||||
from bumble.profiles import ascs, bap, pacs
|
||||
from bumble.hci import Address, CodecID, CodingFormat, HCI_IsoDataPacket
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -337,7 +337,12 @@ class Speaker:
|
||||
),
|
||||
(
|
||||
AdvertisingData.FLAGS,
|
||||
bytes([AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG]),
|
||||
bytes(
|
||||
[
|
||||
AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
|
||||
| AdvertisingData.BR_EDR_NOT_SUPPORTED_FLAG
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||
@@ -449,7 +454,7 @@ def speaker(ui_port: int, device_config: str, transport: str, lc3_file: str) ->
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def main():
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
bumble.logging.setup_basic_logging()
|
||||
speaker()
|
||||
|
||||
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
# 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 sys
|
||||
import logging
|
||||
import json
|
||||
import asyncio
|
||||
import argparse
|
||||
import uuid
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
import websockets
|
||||
|
||||
from bumble.colors import color
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ----------------------------------------------------------------------------
|
||||
DEFAULT_RELAY_PORT = 10723
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Utils
|
||||
# ----------------------------------------------------------------------------
|
||||
def error_to_json(error):
|
||||
return json.dumps({'error': error})
|
||||
|
||||
|
||||
def error_to_result(error):
|
||||
return f'result:{error_to_json(error)}'
|
||||
|
||||
|
||||
async def broadcast_message(message, connections):
|
||||
# Send to all the connections
|
||||
tasks = [connection.send_message(message) for connection in connections]
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Connection class
|
||||
# ----------------------------------------------------------------------------
|
||||
class Connection:
|
||||
"""
|
||||
A Connection represents a client connected to the relay over a websocket
|
||||
"""
|
||||
|
||||
def __init__(self, room, websocket):
|
||||
self.room = room
|
||||
self.websocket = websocket
|
||||
self.address = str(uuid.uuid4())
|
||||
|
||||
async def send_message(self, message):
|
||||
try:
|
||||
logger.debug(color(f'->{self.address}: {message}', 'yellow'))
|
||||
return await self.websocket.send(message)
|
||||
except websockets.exceptions.WebSocketException as error:
|
||||
logger.info(f'! client "{self}" disconnected: {error}')
|
||||
await self.cleanup()
|
||||
|
||||
async def send_error(self, error):
|
||||
return await self.send_message(f'result:{error_to_json(error)}')
|
||||
|
||||
async def receive_message(self):
|
||||
try:
|
||||
message = await self.websocket.recv()
|
||||
logger.debug(color(f'<-{self.address}: {message}', 'blue'))
|
||||
return message
|
||||
except websockets.exceptions.WebSocketException as error:
|
||||
logger.info(color(f'! client "{self}" disconnected: {error}', 'red'))
|
||||
await self.cleanup()
|
||||
|
||||
async def cleanup(self):
|
||||
if self.room:
|
||||
await self.room.remove_connection(self)
|
||||
|
||||
def set_address(self, address):
|
||||
logger.info(f'Connection address changed: {self.address} -> {address}')
|
||||
self.address = address
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f'Connection(address="{self.address}", '
|
||||
f'client={self.websocket.remote_address[0]}:'
|
||||
f'{self.websocket.remote_address[1]})'
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Room class
|
||||
# ----------------------------------------------------------------------------
|
||||
class Room:
|
||||
"""
|
||||
A Room is a collection of bridged connections
|
||||
"""
|
||||
|
||||
def __init__(self, relay, name):
|
||||
self.relay = relay
|
||||
self.name = name
|
||||
self.observers = []
|
||||
self.connections = []
|
||||
|
||||
async def add_connection(self, connection):
|
||||
logger.info(f'New participant in {self.name}: {connection}')
|
||||
self.connections.append(connection)
|
||||
await self.broadcast_message(connection, f'joined:{connection.address}')
|
||||
|
||||
async def remove_connection(self, connection):
|
||||
if connection in self.connections:
|
||||
self.connections.remove(connection)
|
||||
await self.broadcast_message(connection, f'left:{connection.address}')
|
||||
|
||||
def find_connections_by_address(self, address):
|
||||
return [c for c in self.connections if c.address == address]
|
||||
|
||||
async def bridge_connection(self, connection):
|
||||
while True:
|
||||
# Wait for a message
|
||||
message = await connection.receive_message()
|
||||
|
||||
# Skip empty messages
|
||||
if message is None:
|
||||
return
|
||||
|
||||
# Parse the message to decide how to handle it
|
||||
if message.startswith('@'):
|
||||
# This is a targeted message
|
||||
await self.on_targeted_message(connection, message)
|
||||
elif message.startswith('/'):
|
||||
# This is an RPC request
|
||||
await self.on_rpc_request(connection, message)
|
||||
else:
|
||||
await connection.send_message(
|
||||
f'result:{error_to_json("error: invalid message")}'
|
||||
)
|
||||
|
||||
async def broadcast_message(self, sender, message):
|
||||
'''
|
||||
Send to all connections in the room except back to the sender
|
||||
'''
|
||||
await broadcast_message(message, [c for c in self.connections if c != sender])
|
||||
|
||||
async def on_rpc_request(self, connection, message):
|
||||
command, *params = message.split(' ', 1)
|
||||
if handler := getattr(
|
||||
self, f'on_{command[1:].lower().replace("-","_")}_command', None
|
||||
):
|
||||
try:
|
||||
result = await handler(connection, params)
|
||||
except Exception as error:
|
||||
result = error_to_result(error)
|
||||
else:
|
||||
result = error_to_result('unknown command')
|
||||
|
||||
await connection.send_message(result or 'result:{}')
|
||||
|
||||
async def on_targeted_message(self, connection, message):
|
||||
target, *payload = message.split(' ', 1)
|
||||
if not payload:
|
||||
return error_to_json('missing arguments')
|
||||
payload = payload[0]
|
||||
target = target[1:]
|
||||
|
||||
# Determine what targets to send to
|
||||
if target == '*':
|
||||
# Send to all connections in the room except the connection from which the
|
||||
# message was received
|
||||
connections = [c for c in self.connections if c != connection]
|
||||
else:
|
||||
connections = self.find_connections_by_address(target)
|
||||
if not connections:
|
||||
# Unicast with no recipient, let the sender know
|
||||
await connection.send_message(f'unreachable:{target}')
|
||||
|
||||
# Send to targets
|
||||
await broadcast_message(f'message:{connection.address}/{payload}', connections)
|
||||
|
||||
async def on_set_address_command(self, connection, params):
|
||||
if not params:
|
||||
return error_to_result('missing address')
|
||||
|
||||
current_address = connection.address
|
||||
new_address = params[0]
|
||||
connection.set_address(new_address)
|
||||
await self.broadcast_message(
|
||||
connection, f'address-changed:from={current_address},to={new_address}'
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
class Relay:
|
||||
"""
|
||||
A relay accepts connections with the following url: ws://<hostname>/<room>.
|
||||
Participants in a room can communicate with each other
|
||||
"""
|
||||
|
||||
def __init__(self, port):
|
||||
self.port = port
|
||||
self.rooms = {}
|
||||
self.observers = []
|
||||
|
||||
def start(self):
|
||||
logger.info(f'Starting Relay on port {self.port}')
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
return websockets.serve(self.serve, '0.0.0.0', self.port, ping_interval=None)
|
||||
|
||||
async def serve_as_controller(self, connection):
|
||||
pass
|
||||
|
||||
async def serve(self, websocket, path):
|
||||
logger.debug(f'New connection with path {path}')
|
||||
|
||||
# Parse the path
|
||||
parsed = urlparse(path)
|
||||
|
||||
# Check if this is a controller client
|
||||
if parsed.path == '/':
|
||||
return await self.serve_as_controller(Connection('', websocket))
|
||||
|
||||
# Find or create a room for this connection
|
||||
room_name = parsed.path[1:].split('/')[0]
|
||||
if room_name not in self.rooms:
|
||||
self.rooms[room_name] = Room(self, room_name)
|
||||
room = self.rooms[room_name]
|
||||
|
||||
# Add the connection to the room
|
||||
connection = Connection(room, websocket)
|
||||
await room.add_connection(connection)
|
||||
|
||||
# Bridge until the connection is closed
|
||||
await room.bridge_connection(connection)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
def main():
|
||||
# Check the Python version
|
||||
if sys.version_info < (3, 6, 1):
|
||||
print('ERROR: Python 3.6.1 or higher is required')
|
||||
sys.exit(1)
|
||||
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
|
||||
# Parse arguments
|
||||
arg_parser = argparse.ArgumentParser(description='Bumble Link Relay')
|
||||
arg_parser.add_argument('--log-level', default='INFO', help='logger level')
|
||||
arg_parser.add_argument('--log-config', help='logger config file (YAML)')
|
||||
arg_parser.add_argument(
|
||||
'--port', type=int, default=DEFAULT_RELAY_PORT, help='Port to listen on'
|
||||
)
|
||||
args = arg_parser.parse_args()
|
||||
|
||||
# Setup logger
|
||||
if args.log_config:
|
||||
from logging import config # pylint: disable=import-outside-toplevel
|
||||
|
||||
config.fileConfig(args.log_config)
|
||||
else:
|
||||
logging.basicConfig(level=getattr(logging, args.log_level.upper()))
|
||||
|
||||
# Start a relay
|
||||
relay = Relay(args.port)
|
||||
asyncio.get_event_loop().run_until_complete(relay.start())
|
||||
asyncio.get_event_loop().run_forever()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,21 +0,0 @@
|
||||
[loggers]
|
||||
keys=root
|
||||
|
||||
[handlers]
|
||||
keys=stream_handler
|
||||
|
||||
[formatters]
|
||||
keys=formatter
|
||||
|
||||
[logger_root]
|
||||
level=DEBUG
|
||||
handlers=stream_handler
|
||||
|
||||
[handler_stream_handler]
|
||||
class=StreamHandler
|
||||
level=DEBUG
|
||||
formatter=formatter
|
||||
args=(sys.stderr,)
|
||||
|
||||
[formatter_formatter]
|
||||
format=%(asctime)s %(name)-12s %(levelname)-8s %(message)s
|
||||
30
apps/pair.py
30
apps/pair.py
@@ -26,7 +26,7 @@ from prompt_toolkit.shortcuts import PromptSession
|
||||
from bumble.a2dp import make_audio_sink_service_sdp_records
|
||||
from bumble.colors import color
|
||||
from bumble.device import Device, Peer
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
from bumble.pairing import OobData, PairingDelegate, PairingConfig
|
||||
from bumble.smp import OobContext, OobLegacyContext
|
||||
from bumble.smp import error_name as smp_error_name
|
||||
@@ -349,7 +349,7 @@ async def pair(
|
||||
Waiter.instance = Waiter(linger=linger)
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
|
||||
async with await open_transport(hci_transport) as (hci_source, hci_sink):
|
||||
print('<<< connected')
|
||||
|
||||
# Create a device to manage the host
|
||||
@@ -402,14 +402,19 @@ async def pair(
|
||||
# Create an OOB context if needed
|
||||
if oob:
|
||||
our_oob_context = OobContext()
|
||||
shared_data = (
|
||||
None
|
||||
if oob == '-'
|
||||
else OobData.from_ad(
|
||||
if oob == '-':
|
||||
shared_data = None
|
||||
legacy_context = OobLegacyContext()
|
||||
else:
|
||||
oob_data = OobData.from_ad(
|
||||
AdvertisingData.from_bytes(bytes.fromhex(oob))
|
||||
).shared_data
|
||||
)
|
||||
legacy_context = OobLegacyContext()
|
||||
)
|
||||
shared_data = oob_data.shared_data
|
||||
legacy_context = oob_data.legacy_context
|
||||
if legacy_context is None and not sc:
|
||||
print(color('OOB pairing in legacy mode requires TK', 'red'))
|
||||
return
|
||||
|
||||
oob_contexts = PairingConfig.OobConfig(
|
||||
our_context=our_oob_context,
|
||||
peer_data=shared_data,
|
||||
@@ -419,7 +424,9 @@ async def pair(
|
||||
print(color('@@@ OOB Data:', 'yellow'))
|
||||
if shared_data is None:
|
||||
oob_data = OobData(
|
||||
address=device.random_address, shared_data=our_oob_context.share()
|
||||
address=device.random_address,
|
||||
shared_data=our_oob_context.share(),
|
||||
legacy_context=(None if sc else legacy_context),
|
||||
)
|
||||
print(
|
||||
color(
|
||||
@@ -427,7 +434,8 @@ async def pair(
|
||||
'yellow',
|
||||
)
|
||||
)
|
||||
print(color(f'@@@ TK={legacy_context.tk.hex()}', 'yellow'))
|
||||
if legacy_context:
|
||||
print(color(f'@@@ TK={legacy_context.tk.hex()}', 'yellow'))
|
||||
print(color('@@@-----------------------------------', 'yellow'))
|
||||
else:
|
||||
oob_contexts = None
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import json
|
||||
|
||||
from bumble.pandora import PandoraDevice, Config, serve
|
||||
from typing import Dict, Any
|
||||
from typing import Any
|
||||
|
||||
BUMBLE_SERVER_GRPC_PORT = 7999
|
||||
ROOTCANAL_PORT_CUTTLEFISH = 7300
|
||||
@@ -39,7 +39,7 @@ def main(grpc_port: int, rootcanal_port: int, transport: str, config: str) -> No
|
||||
asyncio.run(serve(device, config=server_config, port=grpc_port))
|
||||
|
||||
|
||||
def retrieve_config(config: str) -> Dict[str, Any]:
|
||||
def retrieve_config(config: str) -> dict[str, Any]:
|
||||
if not config:
|
||||
return {}
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import asyncio.subprocess
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, Union
|
||||
|
||||
@@ -63,6 +61,7 @@ from bumble.hci import Address, HCI_CONNECTION_ALREADY_EXISTS_ERROR, HCI_Constan
|
||||
from bumble.pairing import PairingConfig
|
||||
from bumble.transport import open_transport
|
||||
from bumble.utils import AsyncRunner
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -599,7 +598,7 @@ def play(context, address, audio_format, audio_file):
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def main():
|
||||
logging.basicConfig(level=os.environ.get("BUMBLE_LOGLEVEL", "WARNING").upper())
|
||||
bumble.logging.setup_basic_logging("WARNING")
|
||||
player_cli()
|
||||
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
@@ -30,6 +28,7 @@ from bumble import hci
|
||||
from bumble import rfcomm
|
||||
from bumble import transport
|
||||
from bumble import utils
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -406,7 +405,7 @@ class ClientBridge:
|
||||
# -----------------------------------------------------------------------------
|
||||
async def run(device_config, hci_transport, bridge):
|
||||
print("<<< connecting to HCI...")
|
||||
async with await transport.open_transport_or_link(hci_transport) as (
|
||||
async with await transport.open_transport(hci_transport) as (
|
||||
hci_source,
|
||||
hci_sink,
|
||||
):
|
||||
@@ -515,6 +514,6 @@ def client(context, bluetooth_address, tcp_host, tcp_port, authenticate, encrypt
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get("BUMBLE_LOGLEVEL", "WARNING").upper())
|
||||
if __name__ == "__main__":
|
||||
bumble.logging.setup_basic_logging("WARNING")
|
||||
cli(obj={}) # pylint: disable=no-value-for-parameter
|
||||
|
||||
@@ -16,17 +16,16 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
import click
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
from bumble.keys import JsonKeyStore
|
||||
from bumble.smp import AddressResolver
|
||||
from bumble.device import Advertisement
|
||||
from bumble.hci import Address, HCI_Constant, HCI_LE_1M_PHY, HCI_LE_CODED_PHY
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -127,7 +126,7 @@ async def scan(
|
||||
transport,
|
||||
):
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
|
||||
async with await open_transport(transport) as (hci_source, hci_sink):
|
||||
print('<<< connected')
|
||||
|
||||
if device_config:
|
||||
@@ -237,7 +236,7 @@ def main(
|
||||
device_config,
|
||||
transport,
|
||||
):
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||
bumble.logging.setup_basic_logging('WARNING')
|
||||
asyncio.run(
|
||||
scan(
|
||||
min_rssi,
|
||||
|
||||
10
apps/show.py
10
apps/show.py
@@ -16,8 +16,8 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import datetime
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
|
||||
import click
|
||||
@@ -26,6 +26,7 @@ from bumble.colors import color
|
||||
from bumble import hci
|
||||
from bumble.transport.common import PacketReader
|
||||
from bumble.helpers import PacketTracer
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -154,9 +155,10 @@ class Printer:
|
||||
def main(format, vendor, filename):
|
||||
for vendor_name in vendor:
|
||||
if vendor_name == 'android':
|
||||
import bumble.vendor.android.hci
|
||||
# Prevent being deleted by linter.
|
||||
importlib.import_module('bumble.vendor.android.hci')
|
||||
elif vendor_name == 'zephyr':
|
||||
import bumble.vendor.zephyr.hci
|
||||
importlib.import_module('bumble.vendor.zephyr.hci')
|
||||
|
||||
input = open(filename, 'rb')
|
||||
if format == 'h4':
|
||||
@@ -186,5 +188,5 @@ def main(format, vendor, filename):
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||
bumble.logging.setup_basic_logging('WARNING')
|
||||
main() # pylint: disable=no-value-for-parameter
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<tr><td>Codec</td><td><span id="codecText"></span></td></tr>
|
||||
<tr><td>Packets</td><td><span id="packetsReceivedText"></span></td></tr>
|
||||
<tr><td>Bytes</td><td><span id="bytesReceivedText"></span></td></tr>
|
||||
<tr><td>Bitrate</td><td><span id="bitrate"></span></td></tr>
|
||||
</table>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -7,17 +7,19 @@ let connectionText;
|
||||
let codecText;
|
||||
let packetsReceivedText;
|
||||
let bytesReceivedText;
|
||||
let bitrateText;
|
||||
let streamStateText;
|
||||
let connectionStateText;
|
||||
let controlsDiv;
|
||||
let audioOnButton;
|
||||
let mediaSource;
|
||||
let sourceBuffer;
|
||||
let audioElement;
|
||||
let audioDecoder;
|
||||
let audioCodec;
|
||||
let audioContext;
|
||||
let audioAnalyzer;
|
||||
let audioFrequencyBinCount;
|
||||
let audioFrequencyData;
|
||||
let nextAudioStartPosition = 0;
|
||||
let audioStartTime = 0;
|
||||
let packetsReceived = 0;
|
||||
let bytesReceived = 0;
|
||||
let audioState = "stopped";
|
||||
@@ -29,20 +31,17 @@ let bandwidthCanvas;
|
||||
let bandwidthCanvasContext;
|
||||
let bandwidthBinCount;
|
||||
let bandwidthBins = [];
|
||||
let bitrateSamples = [];
|
||||
|
||||
const FFT_WIDTH = 800;
|
||||
const FFT_HEIGHT = 256;
|
||||
const BANDWIDTH_WIDTH = 500;
|
||||
const BANDWIDTH_HEIGHT = 100;
|
||||
|
||||
function hexToBytes(hex) {
|
||||
return Uint8Array.from(hex.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
|
||||
}
|
||||
const BITRATE_WINDOW = 30;
|
||||
|
||||
function init() {
|
||||
initUI();
|
||||
initMediaSource();
|
||||
initAudioElement();
|
||||
initAudioContext();
|
||||
initAnalyzer();
|
||||
|
||||
connect();
|
||||
@@ -56,6 +55,7 @@ function initUI() {
|
||||
codecText = document.getElementById("codecText");
|
||||
packetsReceivedText = document.getElementById("packetsReceivedText");
|
||||
bytesReceivedText = document.getElementById("bytesReceivedText");
|
||||
bitrateText = document.getElementById("bitrate");
|
||||
streamStateText = document.getElementById("streamStateText");
|
||||
connectionStateText = document.getElementById("connectionStateText");
|
||||
audioSupportMessageText = document.getElementById("audioSupportMessageText");
|
||||
@@ -67,17 +67,9 @@ function initUI() {
|
||||
requestAnimationFrame(onAnimationFrame);
|
||||
}
|
||||
|
||||
function initMediaSource() {
|
||||
mediaSource = new MediaSource();
|
||||
mediaSource.onsourceopen = onMediaSourceOpen;
|
||||
mediaSource.onsourceclose = onMediaSourceClose;
|
||||
mediaSource.onsourceended = onMediaSourceEnd;
|
||||
}
|
||||
|
||||
function initAudioElement() {
|
||||
audioElement = document.getElementById("audio");
|
||||
audioElement.src = URL.createObjectURL(mediaSource);
|
||||
// audioElement.controls = true;
|
||||
function initAudioContext() {
|
||||
audioContext = new AudioContext();
|
||||
audioContext.onstatechange = () => console.log("AudioContext state:", audioContext.state);
|
||||
}
|
||||
|
||||
function initAnalyzer() {
|
||||
@@ -94,24 +86,16 @@ function initAnalyzer() {
|
||||
bandwidthCanvasContext = bandwidthCanvas.getContext('2d');
|
||||
bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
|
||||
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
|
||||
}
|
||||
|
||||
function startAnalyzer() {
|
||||
// FFT
|
||||
if (audioElement.captureStream !== undefined) {
|
||||
audioContext = new AudioContext();
|
||||
audioAnalyzer = audioContext.createAnalyser();
|
||||
audioAnalyzer.fftSize = 128;
|
||||
audioFrequencyBinCount = audioAnalyzer.frequencyBinCount;
|
||||
audioFrequencyData = new Uint8Array(audioFrequencyBinCount);
|
||||
const stream = audioElement.captureStream();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
source.connect(audioAnalyzer);
|
||||
}
|
||||
|
||||
// Bandwidth
|
||||
bandwidthBinCount = BANDWIDTH_WIDTH / 2;
|
||||
bandwidthBins = [];
|
||||
bitrateSamples = [];
|
||||
|
||||
audioAnalyzer = audioContext.createAnalyser();
|
||||
audioAnalyzer.fftSize = 128;
|
||||
audioFrequencyBinCount = audioAnalyzer.frequencyBinCount;
|
||||
audioFrequencyData = new Uint8Array(audioFrequencyBinCount);
|
||||
|
||||
audioAnalyzer.connect(audioContext.destination)
|
||||
}
|
||||
|
||||
function setConnectionText(message) {
|
||||
@@ -148,7 +132,8 @@ function onAnimationFrame() {
|
||||
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
|
||||
bandwidthCanvasContext.fillStyle = `rgb(100, 100, 100)`;
|
||||
for (let t = 0; t < bandwidthBins.length; t++) {
|
||||
const lineHeight = (bandwidthBins[t] / 1000) * BANDWIDTH_HEIGHT;
|
||||
const bytesReceived = bandwidthBins[t]
|
||||
const lineHeight = (bytesReceived / 1000) * BANDWIDTH_HEIGHT;
|
||||
bandwidthCanvasContext.fillRect(t * 2, BANDWIDTH_HEIGHT - lineHeight, 2, lineHeight);
|
||||
}
|
||||
|
||||
@@ -156,28 +141,14 @@ function onAnimationFrame() {
|
||||
requestAnimationFrame(onAnimationFrame);
|
||||
}
|
||||
|
||||
function onMediaSourceOpen() {
|
||||
console.log(this.readyState);
|
||||
sourceBuffer = mediaSource.addSourceBuffer("audio/aac");
|
||||
}
|
||||
|
||||
function onMediaSourceClose() {
|
||||
console.log(this.readyState);
|
||||
}
|
||||
|
||||
function onMediaSourceEnd() {
|
||||
console.log(this.readyState);
|
||||
}
|
||||
|
||||
async function startAudio() {
|
||||
try {
|
||||
console.log("starting audio...");
|
||||
audioOnButton.disabled = true;
|
||||
audioState = "starting";
|
||||
await audioElement.play();
|
||||
audioContext.resume();
|
||||
console.log("audio started");
|
||||
audioState = "playing";
|
||||
startAnalyzer();
|
||||
} catch(error) {
|
||||
console.error(`play failed: ${error}`);
|
||||
audioState = "stopped";
|
||||
@@ -185,12 +156,47 @@ async function startAudio() {
|
||||
}
|
||||
}
|
||||
|
||||
function onAudioPacket(packet) {
|
||||
if (audioState != "stopped") {
|
||||
// Queue the audio packet.
|
||||
sourceBuffer.appendBuffer(packet);
|
||||
function onDecodedAudio(audioData) {
|
||||
const bufferSource = audioContext.createBufferSource()
|
||||
|
||||
const now = audioContext.currentTime;
|
||||
let nextAudioStartTime = audioStartTime + (nextAudioStartPosition / audioData.sampleRate);
|
||||
if (nextAudioStartTime < now) {
|
||||
console.log("starting new audio time base")
|
||||
audioStartTime = now;
|
||||
nextAudioStartTime = now;
|
||||
nextAudioStartPosition = 0;
|
||||
} else {
|
||||
console.log(`audio buffer scheduled in ${nextAudioStartTime - now}`)
|
||||
}
|
||||
|
||||
const audioBuffer = audioContext.createBuffer(
|
||||
audioData.numberOfChannels,
|
||||
audioData.numberOfFrames,
|
||||
audioData.sampleRate
|
||||
);
|
||||
|
||||
for (let channel = 0; channel < audioData.numberOfChannels; channel++) {
|
||||
audioData.copyTo(
|
||||
audioBuffer.getChannelData(channel),
|
||||
{
|
||||
planeIndex: channel,
|
||||
format: "f32-planar"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
bufferSource.buffer = audioBuffer;
|
||||
bufferSource.connect(audioAnalyzer)
|
||||
bufferSource.start(nextAudioStartTime);
|
||||
nextAudioStartPosition += audioData.numberOfFrames;
|
||||
}
|
||||
|
||||
function onCodecError(error) {
|
||||
console.log("Codec error:", error)
|
||||
}
|
||||
|
||||
async function onAudioPacket(packet) {
|
||||
packetsReceived += 1;
|
||||
packetsReceivedText.innerText = packetsReceived;
|
||||
bytesReceived += packet.byteLength;
|
||||
@@ -200,6 +206,48 @@ function onAudioPacket(packet) {
|
||||
if (bandwidthBins.length > bandwidthBinCount) {
|
||||
bandwidthBins.shift();
|
||||
}
|
||||
bitrateSamples[bitrateSamples.length] = {ts: Date.now(), bytes: packet.byteLength}
|
||||
if (bitrateSamples.length > BITRATE_WINDOW) {
|
||||
bitrateSamples.shift();
|
||||
}
|
||||
if (bitrateSamples.length >= 2) {
|
||||
const windowBytes = bitrateSamples.reduce((accumulator, x) => accumulator + x.bytes, 0) - bitrateSamples[0].bytes;
|
||||
const elapsed = bitrateSamples[bitrateSamples.length-1].ts - bitrateSamples[0].ts;
|
||||
const bitrate = Math.floor(8 * windowBytes / elapsed)
|
||||
bitrateText.innerText = `${bitrate} kb/s`
|
||||
}
|
||||
|
||||
if (audioState == "stopped") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioDecoder === undefined) {
|
||||
let audioConfig;
|
||||
if (audioCodec == 'aac') {
|
||||
audioConfig = {
|
||||
codec: 'mp4a.40.2',
|
||||
sampleRate: 44100, // ignored
|
||||
numberOfChannels: 2, // ignored
|
||||
}
|
||||
} else if (audioCodec == 'opus') {
|
||||
audioConfig = {
|
||||
codec: 'opus',
|
||||
sampleRate: 48000, // ignored
|
||||
numberOfChannels: 2, // ignored
|
||||
}
|
||||
}
|
||||
audioDecoder = new AudioDecoder({ output: onDecodedAudio, error: onCodecError });
|
||||
audioDecoder.configure(audioConfig)
|
||||
}
|
||||
|
||||
const encodedAudio = new EncodedAudioChunk({
|
||||
type: "key",
|
||||
data: packet,
|
||||
timestamp: 0,
|
||||
transfer: [packet],
|
||||
});
|
||||
|
||||
audioDecoder.decode(encodedAudio);
|
||||
}
|
||||
|
||||
function onChannelOpen() {
|
||||
@@ -249,16 +297,19 @@ function onChannelMessage(message) {
|
||||
}
|
||||
}
|
||||
|
||||
function onHelloMessage(params) {
|
||||
async function onHelloMessage(params) {
|
||||
codecText.innerText = params.codec;
|
||||
if (params.codec != "aac") {
|
||||
audioOnButton.disabled = true;
|
||||
audioSupportMessageText.innerText = "Only AAC can be played, audio will be disabled";
|
||||
audioSupportMessageText.style.display = "inline-block";
|
||||
} else {
|
||||
|
||||
if (params.codec == "aac" || params.codec == "opus") {
|
||||
audioCodec = params.codec
|
||||
audioSupportMessageText.innerText = "";
|
||||
audioSupportMessageText.style.display = "none";
|
||||
} else {
|
||||
audioOnButton.disabled = true;
|
||||
audioSupportMessageText.innerText = "Only AAC and Opus can be played, audio will be disabled";
|
||||
audioSupportMessageText.style.display = "inline-block";
|
||||
}
|
||||
|
||||
if (params.streamState) {
|
||||
setStreamState(params.streamState);
|
||||
}
|
||||
|
||||
@@ -21,11 +21,10 @@ import asyncio.subprocess
|
||||
from importlib import resources
|
||||
import enum
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import pathlib
|
||||
import subprocess
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Optional
|
||||
import weakref
|
||||
|
||||
import click
|
||||
@@ -50,12 +49,15 @@ from bumble.a2dp import (
|
||||
make_audio_sink_service_sdp_records,
|
||||
A2DP_SBC_CODEC_TYPE,
|
||||
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
A2DP_NON_A2DP_CODEC_TYPE,
|
||||
SbcMediaCodecInformation,
|
||||
AacMediaCodecInformation,
|
||||
OpusMediaCodecInformation,
|
||||
)
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.codecs import AacAudioRtpPacket
|
||||
from bumble.rtp import MediaPacket
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -78,6 +80,8 @@ class AudioExtractor:
|
||||
return AacAudioExtractor()
|
||||
if codec == 'sbc':
|
||||
return SbcAudioExtractor()
|
||||
if codec == 'opus':
|
||||
return OpusAudioExtractor()
|
||||
|
||||
def extract_audio(self, packet: MediaPacket) -> bytes:
|
||||
raise NotImplementedError()
|
||||
@@ -102,6 +106,13 @@ class SbcAudioExtractor:
|
||||
return packet.payload[1:]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class OpusAudioExtractor:
|
||||
def extract_audio(self, packet: MediaPacket) -> bytes:
|
||||
# TODO: parse fields
|
||||
return packet.payload[1:]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Output:
|
||||
async def start(self) -> None:
|
||||
@@ -235,7 +246,7 @@ class FfplayOutput(QueuedOutput):
|
||||
await super().start()
|
||||
|
||||
self.subprocess = await asyncio.create_subprocess_shell(
|
||||
f'ffplay -f {self.codec} pipe:0',
|
||||
f'ffplay -probesize 32 -f {self.codec} pipe:0',
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
@@ -399,10 +410,24 @@ class Speaker:
|
||||
STARTED = 2
|
||||
SUSPENDED = 3
|
||||
|
||||
def __init__(self, device_config, transport, codec, discover, outputs, ui_port):
|
||||
def __init__(
|
||||
self,
|
||||
device_config,
|
||||
transport,
|
||||
codec,
|
||||
sampling_frequencies,
|
||||
bitrate,
|
||||
vbr,
|
||||
discover,
|
||||
outputs,
|
||||
ui_port,
|
||||
):
|
||||
self.device_config = device_config
|
||||
self.transport = transport
|
||||
self.codec = codec
|
||||
self.sampling_frequencies = sampling_frequencies
|
||||
self.bitrate = bitrate
|
||||
self.vbr = vbr
|
||||
self.discover = discover
|
||||
self.ui_port = ui_port
|
||||
self.device = None
|
||||
@@ -423,7 +448,7 @@ class Speaker:
|
||||
# Create an HTTP server for the UI
|
||||
self.ui_server = UiServer(speaker=self, port=ui_port)
|
||||
|
||||
def sdp_records(self) -> Dict[int, List[ServiceAttribute]]:
|
||||
def sdp_records(self) -> dict[int, list[ServiceAttribute]]:
|
||||
service_record_handle = 0x00010001
|
||||
return {
|
||||
service_record_handle: make_audio_sink_service_sdp_records(
|
||||
@@ -438,32 +463,56 @@ class Speaker:
|
||||
if self.codec == 'sbc':
|
||||
return self.sbc_codec_capabilities()
|
||||
|
||||
if self.codec == 'opus':
|
||||
return self.opus_codec_capabilities()
|
||||
|
||||
raise RuntimeError('unsupported codec')
|
||||
|
||||
def aac_codec_capabilities(self) -> MediaCodecCapabilities:
|
||||
supported_sampling_frequencies = AacMediaCodecInformation.SamplingFrequency(0)
|
||||
for sampling_frequency in self.sampling_frequencies or [
|
||||
8000,
|
||||
11025,
|
||||
12000,
|
||||
16000,
|
||||
22050,
|
||||
24000,
|
||||
32000,
|
||||
44100,
|
||||
48000,
|
||||
]:
|
||||
supported_sampling_frequencies |= (
|
||||
AacMediaCodecInformation.SamplingFrequency.from_int(sampling_frequency)
|
||||
)
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
media_codec_information=AacMediaCodecInformation(
|
||||
object_type=AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC,
|
||||
sampling_frequency=AacMediaCodecInformation.SamplingFrequency.SF_48000
|
||||
| AacMediaCodecInformation.SamplingFrequency.SF_44100,
|
||||
sampling_frequency=supported_sampling_frequencies,
|
||||
channels=AacMediaCodecInformation.Channels.MONO
|
||||
| AacMediaCodecInformation.Channels.STEREO,
|
||||
vbr=1,
|
||||
bitrate=256000,
|
||||
vbr=1 if self.vbr else 0,
|
||||
bitrate=self.bitrate or 256000,
|
||||
),
|
||||
)
|
||||
|
||||
def sbc_codec_capabilities(self) -> MediaCodecCapabilities:
|
||||
supported_sampling_frequencies = SbcMediaCodecInformation.SamplingFrequency(0)
|
||||
for sampling_frequency in self.sampling_frequencies or [
|
||||
16000,
|
||||
32000,
|
||||
44100,
|
||||
48000,
|
||||
]:
|
||||
supported_sampling_frequencies |= (
|
||||
SbcMediaCodecInformation.SamplingFrequency.from_int(sampling_frequency)
|
||||
)
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
||||
media_codec_information=SbcMediaCodecInformation(
|
||||
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_44100
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_32000
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_16000,
|
||||
sampling_frequency=supported_sampling_frequencies,
|
||||
channel_mode=SbcMediaCodecInformation.ChannelMode.MONO
|
||||
| SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
|
||||
| SbcMediaCodecInformation.ChannelMode.STEREO
|
||||
@@ -481,6 +530,25 @@ class Speaker:
|
||||
),
|
||||
)
|
||||
|
||||
def opus_codec_capabilities(self) -> MediaCodecCapabilities:
|
||||
supported_sampling_frequencies = OpusMediaCodecInformation.SamplingFrequency(0)
|
||||
for sampling_frequency in self.sampling_frequencies or [48000]:
|
||||
supported_sampling_frequencies |= (
|
||||
OpusMediaCodecInformation.SamplingFrequency.from_int(sampling_frequency)
|
||||
)
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_NON_A2DP_CODEC_TYPE,
|
||||
media_codec_information=OpusMediaCodecInformation(
|
||||
frame_size=OpusMediaCodecInformation.FrameSize.FS_10MS
|
||||
| OpusMediaCodecInformation.FrameSize.FS_20MS,
|
||||
channel_mode=OpusMediaCodecInformation.ChannelMode.MONO
|
||||
| OpusMediaCodecInformation.ChannelMode.STEREO
|
||||
| OpusMediaCodecInformation.ChannelMode.DUAL_MONO,
|
||||
sampling_frequency=supported_sampling_frequencies,
|
||||
),
|
||||
)
|
||||
|
||||
async def dispatch_to_outputs(self, function):
|
||||
for output in self.outputs:
|
||||
await function(output)
|
||||
@@ -675,7 +743,26 @@ def speaker_cli(ctx, device_config):
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
'--codec', type=click.Choice(['sbc', 'aac']), default='aac', show_default=True
|
||||
'--codec',
|
||||
type=click.Choice(['sbc', 'aac', 'opus']),
|
||||
default='aac',
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
'--sampling-frequency',
|
||||
metavar='SAMPLING-FREQUENCY',
|
||||
type=int,
|
||||
multiple=True,
|
||||
help='Enable a sampling frequency (may be specified more than once)',
|
||||
)
|
||||
@click.option(
|
||||
'--bitrate',
|
||||
metavar='BITRATE',
|
||||
type=int,
|
||||
help='Supported bitrate (AAC only)',
|
||||
)
|
||||
@click.option(
|
||||
'--vbr/--no-vbr', is_flag=True, default=True, help='Enable VBR (AAC only)'
|
||||
)
|
||||
@click.option(
|
||||
'--discover', is_flag=True, help='Discover remote endpoints once connected'
|
||||
@@ -706,7 +793,16 @@ def speaker_cli(ctx, device_config):
|
||||
@click.option('--device-config', metavar='FILENAME', help='Device configuration file')
|
||||
@click.argument('transport')
|
||||
def speaker(
|
||||
transport, codec, connect_address, discover, output, ui_port, device_config
|
||||
transport,
|
||||
codec,
|
||||
sampling_frequency,
|
||||
bitrate,
|
||||
vbr,
|
||||
connect_address,
|
||||
discover,
|
||||
output,
|
||||
ui_port,
|
||||
device_config,
|
||||
):
|
||||
"""Run the speaker."""
|
||||
|
||||
@@ -721,15 +817,23 @@ def speaker(
|
||||
output = list(filter(lambda x: x != '@ffplay', output))
|
||||
|
||||
asyncio.run(
|
||||
Speaker(device_config, transport, codec, discover, output, ui_port).run(
|
||||
connect_address
|
||||
)
|
||||
Speaker(
|
||||
device_config,
|
||||
transport,
|
||||
codec,
|
||||
sampling_frequency,
|
||||
bitrate,
|
||||
vbr,
|
||||
discover,
|
||||
output,
|
||||
ui_port,
|
||||
).run(connect_address)
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def main():
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||
bumble.logging.setup_basic_logging('WARNING')
|
||||
speaker()
|
||||
|
||||
|
||||
|
||||
@@ -16,13 +16,12 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
import click
|
||||
|
||||
from bumble.device import Device
|
||||
from bumble.keys import JsonKeyStore
|
||||
from bumble.transport import open_transport
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -68,7 +67,7 @@ def main(keystore_file, hci_transport, device_config, address):
|
||||
instantiated.
|
||||
If no address is passed, the existing pairing keys for all addresses are printed.
|
||||
"""
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
bumble.logging.setup_basic_logging()
|
||||
|
||||
if not keystore_file and not hci_transport:
|
||||
print('either --keystore-file or --hci-transport must be specified.')
|
||||
|
||||
@@ -26,13 +26,12 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import os
|
||||
import logging
|
||||
import click
|
||||
import usb1
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.transport.usb import load_libusb
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -169,7 +168,7 @@ def is_bluetooth_hci(device):
|
||||
@click.command()
|
||||
@click.option('--verbose', is_flag=True, default=False, help='Print more details')
|
||||
def main(verbose):
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||
bumble.logging.setup_basic_logging('WARNING')
|
||||
|
||||
load_libusb()
|
||||
with usb1.USBContext() as context:
|
||||
|
||||
@@ -479,6 +479,12 @@ class OpusMediaCodecInformation(VendorSpecificMediaCodecInformation):
|
||||
class SamplingFrequency(enum.IntFlag):
|
||||
SF_48000 = 1 << 0
|
||||
|
||||
@classmethod
|
||||
def from_int(cls, sampling_frequency: int) -> Self:
|
||||
if sampling_frequency != 48000:
|
||||
raise ValueError("no such sampling frequency")
|
||||
return cls(1)
|
||||
|
||||
VENDOR_ID: ClassVar[int] = 0x000000E0
|
||||
CODEC_ID: ClassVar[int] = 0x0001
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from typing import List, Union
|
||||
from typing import Union
|
||||
|
||||
from bumble import core
|
||||
|
||||
@@ -21,7 +21,7 @@ class AtParsingError(core.InvalidPacketError):
|
||||
"""Error raised when parsing AT commands fails."""
|
||||
|
||||
|
||||
def tokenize_parameters(buffer: bytes) -> List[bytes]:
|
||||
def tokenize_parameters(buffer: bytes) -> list[bytes]:
|
||||
"""Split input parameters into tokens.
|
||||
Removes space characters outside of double quote blocks:
|
||||
T-rec-V-25 - 5.2.1 Command line general format: "Space characters (IA5 2/0)
|
||||
@@ -63,12 +63,12 @@ def tokenize_parameters(buffer: bytes) -> List[bytes]:
|
||||
return [bytes(token) for token in tokens if len(token) > 0]
|
||||
|
||||
|
||||
def parse_parameters(buffer: bytes) -> List[Union[bytes, list]]:
|
||||
def parse_parameters(buffer: bytes) -> list[Union[bytes, list]]:
|
||||
"""Parse the parameters using the comma and parenthesis separators.
|
||||
Raises AtParsingError in case of invalid input string."""
|
||||
|
||||
tokens = tokenize_parameters(buffer)
|
||||
accumulator: List[list] = [[]]
|
||||
accumulator: list[list] = [[]]
|
||||
current: Union[bytes, list] = bytes()
|
||||
|
||||
for token in tokens:
|
||||
|
||||
@@ -32,10 +32,6 @@ from typing import (
|
||||
Awaitable,
|
||||
Callable,
|
||||
Generic,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
TYPE_CHECKING,
|
||||
@@ -251,7 +247,7 @@ class ATT_PDU:
|
||||
See Bluetooth spec @ Vol 3, Part F - 3.3 ATTRIBUTE PDU
|
||||
'''
|
||||
|
||||
pdu_classes: Dict[int, Type[ATT_PDU]] = {}
|
||||
pdu_classes: dict[int, type[ATT_PDU]] = {}
|
||||
op_code = 0
|
||||
name: str
|
||||
|
||||
@@ -818,7 +814,7 @@ class Attribute(utils.EventEmitter, Generic[_T]):
|
||||
# The check for `p.name is not None` here is needed because for InFlag
|
||||
# enums, the .name property can be None, when the enum value is 0,
|
||||
# so the type hint for .name is Optional[str].
|
||||
enum_list: List[str] = [p.name for p in cls if p.name is not None]
|
||||
enum_list: list[str] = [p.name for p in cls if p.name is not None]
|
||||
enum_list_str = ",".join(enum_list)
|
||||
raise TypeError(
|
||||
f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {enum_list_str}\nGot: {permissions_str}"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
import struct
|
||||
from typing import Dict, Type, Union, Tuple
|
||||
from typing import Union
|
||||
|
||||
from bumble import core
|
||||
from bumble import utils
|
||||
@@ -213,11 +213,11 @@ class CommandFrame(Frame):
|
||||
NOTIFY = 0x03
|
||||
GENERAL_INQUIRY = 0x04
|
||||
|
||||
subclasses: Dict[Frame.OperationCode, Type[CommandFrame]] = {}
|
||||
subclasses: dict[Frame.OperationCode, type[CommandFrame]] = {}
|
||||
ctype: CommandType
|
||||
|
||||
@staticmethod
|
||||
def parse_operands(operands: bytes) -> Tuple:
|
||||
def parse_operands(operands: bytes) -> tuple:
|
||||
raise NotImplementedError
|
||||
|
||||
def __init__(
|
||||
@@ -251,11 +251,11 @@ class ResponseFrame(Frame):
|
||||
CHANGED = 0x0D
|
||||
INTERIM = 0x0F
|
||||
|
||||
subclasses: Dict[Frame.OperationCode, Type[ResponseFrame]] = {}
|
||||
subclasses: dict[Frame.OperationCode, type[ResponseFrame]] = {}
|
||||
response: ResponseCode
|
||||
|
||||
@staticmethod
|
||||
def parse_operands(operands: bytes) -> Tuple:
|
||||
def parse_operands(operands: bytes) -> tuple:
|
||||
raise NotImplementedError
|
||||
|
||||
def __init__(
|
||||
@@ -282,7 +282,7 @@ class VendorDependentFrame:
|
||||
vendor_dependent_data: bytes
|
||||
|
||||
@staticmethod
|
||||
def parse_operands(operands: bytes) -> Tuple:
|
||||
def parse_operands(operands: bytes) -> tuple:
|
||||
return (
|
||||
struct.unpack(">I", b"\x00" + operands[:3])[0],
|
||||
operands[3:],
|
||||
@@ -432,7 +432,7 @@ class PassThroughFrame:
|
||||
operation_data: bytes
|
||||
|
||||
@staticmethod
|
||||
def parse_operands(operands: bytes) -> Tuple:
|
||||
def parse_operands(operands: bytes) -> tuple:
|
||||
return (
|
||||
PassThroughFrame.StateFlag(operands[0] >> 7),
|
||||
PassThroughFrame.OperationId(operands[0] & 0x7F),
|
||||
|
||||
@@ -19,7 +19,7 @@ from __future__ import annotations
|
||||
from enum import IntEnum
|
||||
import logging
|
||||
import struct
|
||||
from typing import Callable, cast, Dict, Optional
|
||||
from typing import Callable, cast, Optional
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble import avc
|
||||
@@ -146,9 +146,9 @@ class MessageAssembler:
|
||||
# -----------------------------------------------------------------------------
|
||||
class Protocol:
|
||||
CommandHandler = Callable[[int, avc.CommandFrame], None]
|
||||
command_handlers: Dict[int, CommandHandler] # Command handlers, by PID
|
||||
command_handlers: dict[int, CommandHandler] # Command handlers, by PID
|
||||
ResponseHandler = Callable[[int, Optional[avc.ResponseFrame]], None]
|
||||
response_handlers: Dict[int, ResponseHandler] # Response handlers, by PID
|
||||
response_handlers: dict[int, ResponseHandler] # Response handlers, by PID
|
||||
next_transaction_label: int
|
||||
message_assembler: MessageAssembler
|
||||
|
||||
|
||||
@@ -24,12 +24,8 @@ import warnings
|
||||
from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
Dict,
|
||||
Type,
|
||||
Tuple,
|
||||
Optional,
|
||||
Callable,
|
||||
List,
|
||||
AsyncGenerator,
|
||||
Iterable,
|
||||
Union,
|
||||
@@ -227,7 +223,7 @@ AVDTP_STATE_NAMES = {
|
||||
# -----------------------------------------------------------------------------
|
||||
async def find_avdtp_service_with_sdp_client(
|
||||
sdp_client: sdp.Client,
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
) -> Optional[tuple[int, int]]:
|
||||
'''
|
||||
Find an AVDTP service, using a connected SDP client, and return its version,
|
||||
or None if none is found
|
||||
@@ -257,7 +253,7 @@ async def find_avdtp_service_with_sdp_client(
|
||||
# -----------------------------------------------------------------------------
|
||||
async def find_avdtp_service_with_connection(
|
||||
connection: device.Connection,
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
) -> Optional[tuple[int, int]]:
|
||||
'''
|
||||
Find an AVDTP service, for a connection, and return its version,
|
||||
or None if none is found
|
||||
@@ -451,7 +447,7 @@ class ServiceCapabilities:
|
||||
service_category: int, service_capabilities_bytes: bytes
|
||||
) -> ServiceCapabilities:
|
||||
# Select the appropriate subclass
|
||||
cls: Type[ServiceCapabilities]
|
||||
cls: type[ServiceCapabilities]
|
||||
if service_category == AVDTP_MEDIA_CODEC_SERVICE_CATEGORY:
|
||||
cls = MediaCodecCapabilities
|
||||
else:
|
||||
@@ -466,7 +462,7 @@ class ServiceCapabilities:
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
def parse_capabilities(payload: bytes) -> List[ServiceCapabilities]:
|
||||
def parse_capabilities(payload: bytes) -> list[ServiceCapabilities]:
|
||||
capabilities = []
|
||||
while payload:
|
||||
service_category = payload[0]
|
||||
@@ -499,7 +495,7 @@ class ServiceCapabilities:
|
||||
self.service_category = service_category
|
||||
self.service_capabilities_bytes = service_capabilities_bytes
|
||||
|
||||
def to_string(self, details: Optional[List[str]] = None) -> str:
|
||||
def to_string(self, details: Optional[list[str]] = None) -> str:
|
||||
attributes = ','.join(
|
||||
[name_or_number(AVDTP_SERVICE_CATEGORY_NAMES, self.service_category)]
|
||||
+ (details or [])
|
||||
@@ -612,7 +608,7 @@ class Message: # pylint:disable=attribute-defined-outside-init
|
||||
RESPONSE_REJECT = 3
|
||||
|
||||
# Subclasses, by signal identifier and message type
|
||||
subclasses: Dict[int, Dict[int, Type[Message]]] = {}
|
||||
subclasses: dict[int, dict[int, type[Message]]] = {}
|
||||
message_type: MessageType
|
||||
signal_identifier: int
|
||||
|
||||
@@ -757,7 +753,7 @@ class Discover_Response(Message):
|
||||
See Bluetooth AVDTP spec - 8.6.2 Stream End Point Discovery Response
|
||||
'''
|
||||
|
||||
endpoints: List[EndPointInfo]
|
||||
endpoints: list[EndPointInfo]
|
||||
|
||||
def init_from_payload(self):
|
||||
self.endpoints = []
|
||||
@@ -1202,10 +1198,10 @@ class DelayReport_Reject(Simple_Reject):
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Protocol(utils.EventEmitter):
|
||||
local_endpoints: List[LocalStreamEndPoint]
|
||||
remote_endpoints: Dict[int, DiscoveredStreamEndPoint]
|
||||
streams: Dict[int, Stream]
|
||||
transaction_results: List[Optional[asyncio.Future[Message]]]
|
||||
local_endpoints: list[LocalStreamEndPoint]
|
||||
remote_endpoints: dict[int, DiscoveredStreamEndPoint]
|
||||
streams: dict[int, Stream]
|
||||
transaction_results: list[Optional[asyncio.Future[Message]]]
|
||||
channel_connector: Callable[[], Awaitable[l2cap.ClassicChannel]]
|
||||
|
||||
EVENT_OPEN = "open"
|
||||
@@ -1223,7 +1219,7 @@ class Protocol(utils.EventEmitter):
|
||||
|
||||
@staticmethod
|
||||
async def connect(
|
||||
connection: device.Connection, version: Tuple[int, int] = (1, 3)
|
||||
connection: device.Connection, version: tuple[int, int] = (1, 3)
|
||||
) -> Protocol:
|
||||
channel = await connection.create_l2cap_channel(
|
||||
spec=l2cap.ClassicChannelSpec(psm=AVDTP_PSM)
|
||||
@@ -1233,7 +1229,7 @@ class Protocol(utils.EventEmitter):
|
||||
return protocol
|
||||
|
||||
def __init__(
|
||||
self, l2cap_channel: l2cap.ClassicChannel, version: Tuple[int, int] = (1, 3)
|
||||
self, l2cap_channel: l2cap.ClassicChannel, version: tuple[int, int] = (1, 3)
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.l2cap_channel = l2cap_channel
|
||||
@@ -1502,7 +1498,7 @@ class Protocol(utils.EventEmitter):
|
||||
|
||||
return response
|
||||
|
||||
async def start_transaction(self) -> Tuple[int, asyncio.Future[Message]]:
|
||||
async def start_transaction(self) -> tuple[int, asyncio.Future[Message]]:
|
||||
# Wait until we can start a new transaction
|
||||
await self.transaction_semaphore.acquire()
|
||||
|
||||
@@ -1703,7 +1699,7 @@ class Protocol(utils.EventEmitter):
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Listener(utils.EventEmitter):
|
||||
servers: Dict[int, Protocol]
|
||||
servers: dict[int, Protocol]
|
||||
|
||||
EVENT_CONNECTION = "connection"
|
||||
|
||||
@@ -1735,7 +1731,7 @@ class Listener(utils.EventEmitter):
|
||||
|
||||
@classmethod
|
||||
def for_device(
|
||||
cls, device: device.Device, version: Tuple[int, int] = (1, 3)
|
||||
cls, device: device.Device, version: tuple[int, int] = (1, 3)
|
||||
) -> Listener:
|
||||
listener = Listener(registrar=None, version=version)
|
||||
l2cap_server = device.create_l2cap_server(
|
||||
|
||||
@@ -26,14 +26,11 @@ from typing import (
|
||||
Awaitable,
|
||||
Callable,
|
||||
cast,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
SupportsBytes,
|
||||
Tuple,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
@@ -53,19 +50,10 @@ from bumble.sdp import (
|
||||
ServiceAttribute,
|
||||
)
|
||||
from bumble import utils
|
||||
from bumble.core import (
|
||||
InvalidArgumentError,
|
||||
ProtocolError,
|
||||
BT_L2CAP_PROTOCOL_ID,
|
||||
BT_AVCTP_PROTOCOL_ID,
|
||||
BT_AV_REMOTE_CONTROL_SERVICE,
|
||||
BT_AV_REMOTE_CONTROL_CONTROLLER_SERVICE,
|
||||
BT_AV_REMOTE_CONTROL_TARGET_SERVICE,
|
||||
)
|
||||
from bumble import core
|
||||
from bumble import l2cap
|
||||
from bumble import avc
|
||||
from bumble import avctp
|
||||
from bumble import utils
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -84,10 +72,10 @@ AVRCP_BLUETOOTH_SIG_COMPANY_ID = 0x001958
|
||||
# -----------------------------------------------------------------------------
|
||||
def make_controller_service_sdp_records(
|
||||
service_record_handle: int,
|
||||
avctp_version: Tuple[int, int] = (1, 4),
|
||||
avrcp_version: Tuple[int, int] = (1, 6),
|
||||
avctp_version: tuple[int, int] = (1, 4),
|
||||
avrcp_version: tuple[int, int] = (1, 6),
|
||||
supported_features: int = 1,
|
||||
) -> List[ServiceAttribute]:
|
||||
) -> list[ServiceAttribute]:
|
||||
# TODO: support a way to compute the supported features from a feature list
|
||||
avctp_version_int = avctp_version[0] << 8 | avctp_version[1]
|
||||
avrcp_version_int = avrcp_version[0] << 8 | avrcp_version[1]
|
||||
@@ -105,8 +93,8 @@ def make_controller_service_sdp_records(
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_AV_REMOTE_CONTROL_SERVICE),
|
||||
DataElement.uuid(BT_AV_REMOTE_CONTROL_CONTROLLER_SERVICE),
|
||||
DataElement.uuid(core.BT_AV_REMOTE_CONTROL_SERVICE),
|
||||
DataElement.uuid(core.BT_AV_REMOTE_CONTROL_CONTROLLER_SERVICE),
|
||||
]
|
||||
),
|
||||
),
|
||||
@@ -116,13 +104,13 @@ def make_controller_service_sdp_records(
|
||||
[
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_L2CAP_PROTOCOL_ID),
|
||||
DataElement.uuid(core.BT_L2CAP_PROTOCOL_ID),
|
||||
DataElement.unsigned_integer_16(avctp.AVCTP_PSM),
|
||||
]
|
||||
),
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_AVCTP_PROTOCOL_ID),
|
||||
DataElement.uuid(core.BT_AVCTP_PROTOCOL_ID),
|
||||
DataElement.unsigned_integer_16(avctp_version_int),
|
||||
]
|
||||
),
|
||||
@@ -135,7 +123,7 @@ def make_controller_service_sdp_records(
|
||||
[
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_AV_REMOTE_CONTROL_SERVICE),
|
||||
DataElement.uuid(core.BT_AV_REMOTE_CONTROL_SERVICE),
|
||||
DataElement.unsigned_integer_16(avrcp_version_int),
|
||||
]
|
||||
),
|
||||
@@ -152,10 +140,10 @@ def make_controller_service_sdp_records(
|
||||
# -----------------------------------------------------------------------------
|
||||
def make_target_service_sdp_records(
|
||||
service_record_handle: int,
|
||||
avctp_version: Tuple[int, int] = (1, 4),
|
||||
avrcp_version: Tuple[int, int] = (1, 6),
|
||||
avctp_version: tuple[int, int] = (1, 4),
|
||||
avrcp_version: tuple[int, int] = (1, 6),
|
||||
supported_features: int = 0x23,
|
||||
) -> List[ServiceAttribute]:
|
||||
) -> list[ServiceAttribute]:
|
||||
# TODO: support a way to compute the supported features from a feature list
|
||||
avctp_version_int = avctp_version[0] << 8 | avctp_version[1]
|
||||
avrcp_version_int = avrcp_version[0] << 8 | avrcp_version[1]
|
||||
@@ -173,7 +161,7 @@ def make_target_service_sdp_records(
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_AV_REMOTE_CONTROL_TARGET_SERVICE),
|
||||
DataElement.uuid(core.BT_AV_REMOTE_CONTROL_TARGET_SERVICE),
|
||||
]
|
||||
),
|
||||
),
|
||||
@@ -183,13 +171,13 @@ def make_target_service_sdp_records(
|
||||
[
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_L2CAP_PROTOCOL_ID),
|
||||
DataElement.uuid(core.BT_L2CAP_PROTOCOL_ID),
|
||||
DataElement.unsigned_integer_16(avctp.AVCTP_PSM),
|
||||
]
|
||||
),
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_AVCTP_PROTOCOL_ID),
|
||||
DataElement.uuid(core.BT_AVCTP_PROTOCOL_ID),
|
||||
DataElement.unsigned_integer_16(avctp_version_int),
|
||||
]
|
||||
),
|
||||
@@ -202,7 +190,7 @@ def make_target_service_sdp_records(
|
||||
[
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_AV_REMOTE_CONTROL_SERVICE),
|
||||
DataElement.uuid(core.BT_AV_REMOTE_CONTROL_SERVICE),
|
||||
DataElement.unsigned_integer_16(avrcp_version_int),
|
||||
]
|
||||
),
|
||||
@@ -291,7 +279,7 @@ class Command:
|
||||
pdu_id: Protocol.PduId
|
||||
parameter: bytes
|
||||
|
||||
def to_string(self, properties: Dict[str, str]) -> str:
|
||||
def to_string(self, properties: dict[str, str]) -> str:
|
||||
properties_str = ",".join(
|
||||
[f"{name}={value}" for name, value in properties.items()]
|
||||
)
|
||||
@@ -337,7 +325,7 @@ class GetPlayStatusCommand(Command):
|
||||
# -----------------------------------------------------------------------------
|
||||
class GetElementAttributesCommand(Command):
|
||||
identifier: int
|
||||
attribute_ids: List[MediaAttributeId]
|
||||
attribute_ids: list[MediaAttributeId]
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, pdu: bytes) -> GetElementAttributesCommand:
|
||||
@@ -409,7 +397,7 @@ class Response:
|
||||
pdu_id: Protocol.PduId
|
||||
parameter: bytes
|
||||
|
||||
def to_string(self, properties: Dict[str, str]) -> str:
|
||||
def to_string(self, properties: dict[str, str]) -> str:
|
||||
properties_str = ",".join(
|
||||
[f"{name}={value}" for name, value in properties.items()]
|
||||
)
|
||||
@@ -454,7 +442,7 @@ class NotImplementedResponse(Response):
|
||||
# -----------------------------------------------------------------------------
|
||||
class GetCapabilitiesResponse(Response):
|
||||
capability_id: GetCapabilitiesCommand.CapabilityId
|
||||
capabilities: List[Union[SupportsBytes, bytes]]
|
||||
capabilities: list[Union[SupportsBytes, bytes]]
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, pdu: bytes) -> GetCapabilitiesResponse:
|
||||
@@ -467,7 +455,7 @@ class GetCapabilitiesResponse(Response):
|
||||
capability_id = GetCapabilitiesCommand.CapabilityId(pdu[0])
|
||||
capability_count = pdu[1]
|
||||
|
||||
capabilities: List[Union[SupportsBytes, bytes]]
|
||||
capabilities: list[Union[SupportsBytes, bytes]]
|
||||
if capability_id == GetCapabilitiesCommand.CapabilityId.EVENTS_SUPPORTED:
|
||||
capabilities = [EventId(pdu[2 + x]) for x in range(capability_count)]
|
||||
else:
|
||||
@@ -540,13 +528,13 @@ class GetPlayStatusResponse(Response):
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class GetElementAttributesResponse(Response):
|
||||
attributes: List[MediaAttribute]
|
||||
attributes: list[MediaAttribute]
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, pdu: bytes) -> GetElementAttributesResponse:
|
||||
num_attributes = pdu[0]
|
||||
offset = 1
|
||||
attributes: List[MediaAttribute] = []
|
||||
attributes: list[MediaAttribute] = []
|
||||
for _ in range(num_attributes):
|
||||
(
|
||||
attribute_id_int,
|
||||
@@ -817,7 +805,7 @@ class PlayerApplicationSettingChangedEvent(Event):
|
||||
attribute_id: ApplicationSetting.AttributeId
|
||||
value_id: utils.OpenIntEnum
|
||||
|
||||
player_application_settings: List[Setting]
|
||||
player_application_settings: list[Setting]
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, pdu: bytes) -> PlayerApplicationSettingChangedEvent:
|
||||
@@ -939,7 +927,7 @@ class VolumeChangedEvent(Event):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
EVENT_SUBCLASSES: Dict[EventId, Type[Event]] = {
|
||||
EVENT_SUBCLASSES: dict[EventId, type[Event]] = {
|
||||
EventId.PLAYBACK_STATUS_CHANGED: PlaybackStatusChangedEvent,
|
||||
EventId.PLAYBACK_POS_CHANGED: PlaybackPositionChangedEvent,
|
||||
EventId.TRACK_CHANGED: TrackChangedEvent,
|
||||
@@ -967,14 +955,14 @@ class Delegate:
|
||||
def __init__(self, status_code: Protocol.StatusCode) -> None:
|
||||
self.status_code = status_code
|
||||
|
||||
supported_events: List[EventId]
|
||||
supported_events: list[EventId]
|
||||
volume: int
|
||||
|
||||
def __init__(self, supported_events: Iterable[EventId] = ()) -> None:
|
||||
self.supported_events = list(supported_events)
|
||||
self.volume = 0
|
||||
|
||||
async def get_supported_events(self) -> List[EventId]:
|
||||
async def get_supported_events(self) -> list[EventId]:
|
||||
return self.supported_events
|
||||
|
||||
async def set_absolute_volume(self, volume: int) -> None:
|
||||
@@ -1124,12 +1112,12 @@ class Protocol(utils.EventEmitter):
|
||||
receive_response_state: Optional[ReceiveResponseState]
|
||||
avctp_protocol: Optional[avctp.Protocol]
|
||||
free_commands: asyncio.Queue
|
||||
pending_commands: Dict[int, PendingCommand] # Pending commands, by label
|
||||
notification_listeners: Dict[EventId, NotificationListener]
|
||||
pending_commands: dict[int, PendingCommand] # Pending commands, by label
|
||||
notification_listeners: dict[EventId, NotificationListener]
|
||||
|
||||
@staticmethod
|
||||
def _check_vendor_dependent_frame(
|
||||
frame: Union[avc.VendorDependentCommandFrame, avc.VendorDependentResponseFrame]
|
||||
frame: Union[avc.VendorDependentCommandFrame, avc.VendorDependentResponseFrame],
|
||||
) -> bool:
|
||||
if frame.company_id != AVRCP_BLUETOOTH_SIG_COMPANY_ID:
|
||||
logger.debug("unsupported company id, ignoring")
|
||||
@@ -1190,7 +1178,7 @@ class Protocol(utils.EventEmitter):
|
||||
|
||||
@staticmethod
|
||||
def _check_response(
|
||||
response_context: ResponseContext, expected_type: Type[_R]
|
||||
response_context: ResponseContext, expected_type: type[_R]
|
||||
) -> _R:
|
||||
if isinstance(response_context, Protocol.FinalResponse):
|
||||
if (
|
||||
@@ -1211,7 +1199,7 @@ class Protocol(utils.EventEmitter):
|
||||
def _delegate_command(
|
||||
self, transaction_label: int, command: Command, method: Awaitable
|
||||
) -> None:
|
||||
async def call():
|
||||
async def call() -> None:
|
||||
try:
|
||||
await method
|
||||
except Delegate.Error as error:
|
||||
@@ -1230,7 +1218,7 @@ class Protocol(utils.EventEmitter):
|
||||
|
||||
utils.AsyncRunner.spawn(call())
|
||||
|
||||
async def get_supported_events(self) -> List[EventId]:
|
||||
async def get_supported_events(self) -> list[EventId]:
|
||||
"""Get the list of events supported by the connected peer."""
|
||||
response_context = await self.send_avrcp_command(
|
||||
avc.CommandFrame.CommandType.STATUS,
|
||||
@@ -1253,7 +1241,7 @@ class Protocol(utils.EventEmitter):
|
||||
|
||||
async def get_element_attributes(
|
||||
self, element_identifier: int, attribute_ids: Sequence[MediaAttributeId]
|
||||
) -> List[MediaAttribute]:
|
||||
) -> list[MediaAttribute]:
|
||||
"""Get element attributes from the connected peer."""
|
||||
response_context = await self.send_avrcp_command(
|
||||
avc.CommandFrame.CommandType.STATUS,
|
||||
@@ -1335,7 +1323,7 @@ class Protocol(utils.EventEmitter):
|
||||
|
||||
async def monitor_player_application_settings(
|
||||
self,
|
||||
) -> AsyncIterator[List[PlayerApplicationSettingChangedEvent.Setting]]:
|
||||
) -> AsyncIterator[list[PlayerApplicationSettingChangedEvent.Setting]]:
|
||||
"""Monitor Player Application Setting changes from the connected peer."""
|
||||
async for event in self.monitor_events(
|
||||
EventId.PLAYER_APPLICATION_SETTING_CHANGED, 0
|
||||
@@ -1415,7 +1403,7 @@ class Protocol(utils.EventEmitter):
|
||||
def notify_track_changed(self, identifier: bytes) -> None:
|
||||
"""Notify the connected peer of a Track change."""
|
||||
if len(identifier) != 8:
|
||||
raise InvalidArgumentError("identifier must be 8 bytes")
|
||||
raise core.InvalidArgumentError("identifier must be 8 bytes")
|
||||
self.notify_event(TrackChangedEvent(identifier))
|
||||
|
||||
def notify_playback_position_changed(self, position: int) -> None:
|
||||
@@ -1682,7 +1670,7 @@ class Protocol(utils.EventEmitter):
|
||||
else:
|
||||
logger.debug("unexpected PDU ID")
|
||||
pending_command.response.set_exception(
|
||||
ProtocolError(
|
||||
core.ProtocolError(
|
||||
error_code=None,
|
||||
error_namespace="avrcp",
|
||||
details="unexpected PDU ID",
|
||||
@@ -1691,7 +1679,7 @@ class Protocol(utils.EventEmitter):
|
||||
else:
|
||||
logger.debug("unexpected response code")
|
||||
pending_command.response.set_exception(
|
||||
ProtocolError(
|
||||
core.ProtocolError(
|
||||
error_code=None,
|
||||
error_namespace="avrcp",
|
||||
details="unexpected response code",
|
||||
@@ -1869,12 +1857,12 @@ class Protocol(utils.EventEmitter):
|
||||
) -> None:
|
||||
logger.debug(f"<<< AVRCP command PDU: {command}")
|
||||
|
||||
async def get_supported_events():
|
||||
async def get_supported_events() -> None:
|
||||
if (
|
||||
command.capability_id
|
||||
!= GetCapabilitiesCommand.CapabilityId.EVENTS_SUPPORTED
|
||||
):
|
||||
raise Protocol.InvalidParameterError
|
||||
raise core.InvalidArgumentError()
|
||||
|
||||
supported_events = await self.delegate.get_supported_events()
|
||||
self.send_avrcp_response(
|
||||
@@ -1890,7 +1878,7 @@ class Protocol(utils.EventEmitter):
|
||||
) -> None:
|
||||
logger.debug(f"<<< AVRCP command PDU: {command}")
|
||||
|
||||
async def set_absolute_volume():
|
||||
async def set_absolute_volume() -> None:
|
||||
await self.delegate.set_absolute_volume(command.volume)
|
||||
effective_volume = await self.delegate.get_absolute_volume()
|
||||
self.send_avrcp_response(
|
||||
@@ -1906,7 +1894,7 @@ class Protocol(utils.EventEmitter):
|
||||
) -> None:
|
||||
logger.debug(f"<<< AVRCP command PDU: {command}")
|
||||
|
||||
async def register_notification():
|
||||
async def register_notification() -> None:
|
||||
# Check if the event is supported.
|
||||
supported_events = await self.delegate.get_supported_events()
|
||||
if command.event_id not in supported_events:
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
from functools import partial
|
||||
from typing import List, Optional, Union
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
class ColorError(ValueError):
|
||||
@@ -65,7 +65,7 @@ def color(
|
||||
bg: Optional[ColorSpec] = None,
|
||||
style: Optional[str] = None,
|
||||
) -> str:
|
||||
codes: List[ColorSpec] = []
|
||||
codes: list[ColorSpec] = []
|
||||
|
||||
if fg:
|
||||
codes.append(_color_code(fg, 30))
|
||||
|
||||
@@ -27,7 +27,7 @@ from bumble.colors import color
|
||||
from bumble.core import (
|
||||
PhysicalTransport,
|
||||
)
|
||||
|
||||
from bumble import hci
|
||||
from bumble.hci import (
|
||||
HCI_ACL_DATA_PACKET,
|
||||
HCI_COMMAND_DISALLOWED_ERROR,
|
||||
@@ -63,7 +63,7 @@ from bumble.hci import (
|
||||
HCI_Packet,
|
||||
HCI_Role_Change_Event,
|
||||
)
|
||||
from typing import Optional, Union, Dict, Any, TYPE_CHECKING
|
||||
from typing import Optional, Union, Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble.link import LocalLink
|
||||
@@ -108,7 +108,9 @@ class Connection:
|
||||
def on_hci_acl_data_packet(self, packet):
|
||||
self.assembler.feed_packet(packet)
|
||||
self.controller.send_hci_packet(
|
||||
HCI_Number_Of_Completed_Packets_Event([(self.handle, 1)])
|
||||
HCI_Number_Of_Completed_Packets_Event(
|
||||
connection_handles=[self.handle], num_completed_packets=[1]
|
||||
)
|
||||
)
|
||||
|
||||
def on_acl_pdu(self, data):
|
||||
@@ -132,17 +134,17 @@ class Controller:
|
||||
self.hci_sink = None
|
||||
self.link = link
|
||||
|
||||
self.central_connections: Dict[Address, Connection] = (
|
||||
self.central_connections: dict[Address, Connection] = (
|
||||
{}
|
||||
) # Connections where this controller is the central
|
||||
self.peripheral_connections: Dict[Address, Connection] = (
|
||||
self.peripheral_connections: dict[Address, Connection] = (
|
||||
{}
|
||||
) # Connections where this controller is the peripheral
|
||||
self.classic_connections: Dict[Address, Connection] = (
|
||||
self.classic_connections: dict[Address, Connection] = (
|
||||
{}
|
||||
) # Connections in BR/EDR
|
||||
self.central_cis_links: Dict[int, CisLink] = {} # CIS links by handle
|
||||
self.peripheral_cis_links: Dict[int, CisLink] = {} # CIS links by handle
|
||||
self.central_cis_links: dict[int, CisLink] = {} # CIS links by handle
|
||||
self.peripheral_cis_links: dict[int, CisLink] = {} # CIS links by handle
|
||||
|
||||
self.hci_version = HCI_VERSION_BLUETOOTH_CORE_5_0
|
||||
self.hci_revision = 0
|
||||
@@ -368,6 +370,12 @@ class Controller:
|
||||
return connection
|
||||
return None
|
||||
|
||||
def find_peripheral_connection_by_handle(self, handle):
|
||||
for connection in self.peripheral_connections.values():
|
||||
if connection.handle == handle:
|
||||
return connection
|
||||
return None
|
||||
|
||||
def find_classic_connection_by_handle(self, handle):
|
||||
for connection in self.classic_connections.values():
|
||||
if connection.handle == handle:
|
||||
@@ -392,7 +400,7 @@ class Controller:
|
||||
peer_address=peer_address,
|
||||
link=self.link,
|
||||
transport=PhysicalTransport.LE,
|
||||
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
|
||||
link_type=HCI_Connection_Complete_Event.LinkType.ACL,
|
||||
)
|
||||
self.peripheral_connections[peer_address] = connection
|
||||
logger.debug(f'New PERIPHERAL connection handle: 0x{connection_handle:04X}')
|
||||
@@ -412,7 +420,7 @@ class Controller:
|
||||
)
|
||||
)
|
||||
|
||||
def on_link_central_disconnected(self, peer_address, reason):
|
||||
def on_link_disconnected(self, peer_address, reason):
|
||||
'''
|
||||
Called when an active disconnection occurs from a peer
|
||||
'''
|
||||
@@ -429,6 +437,17 @@ class Controller:
|
||||
|
||||
# Remove the connection
|
||||
del self.peripheral_connections[peer_address]
|
||||
elif connection := self.central_connections.get(peer_address):
|
||||
self.send_hci_packet(
|
||||
HCI_Disconnection_Complete_Event(
|
||||
status=HCI_SUCCESS,
|
||||
connection_handle=connection.handle,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
|
||||
# Remove the connection
|
||||
del self.central_connections[peer_address]
|
||||
else:
|
||||
logger.warning(f'!!! No peripheral connection found for {peer_address}')
|
||||
|
||||
@@ -452,7 +471,7 @@ class Controller:
|
||||
peer_address=peer_address,
|
||||
link=self.link,
|
||||
transport=PhysicalTransport.LE,
|
||||
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
|
||||
link_type=HCI_Connection_Complete_Event.LinkType.ACL,
|
||||
)
|
||||
self.central_connections[peer_address] = connection
|
||||
logger.debug(
|
||||
@@ -477,7 +496,7 @@ class Controller:
|
||||
)
|
||||
)
|
||||
|
||||
def on_link_peripheral_disconnection_complete(self, disconnection_command, status):
|
||||
def on_link_disconnection_complete(self, disconnection_command, status):
|
||||
'''
|
||||
Called when a disconnection has been completed
|
||||
'''
|
||||
@@ -497,26 +516,11 @@ class Controller:
|
||||
):
|
||||
logger.debug(f'CENTRAL Connection removed: {connection}')
|
||||
del self.central_connections[connection.peer_address]
|
||||
|
||||
def on_link_peripheral_disconnected(self, peer_address):
|
||||
'''
|
||||
Called when a connection to a peripheral is broken
|
||||
'''
|
||||
|
||||
# Send a disconnection complete event
|
||||
if connection := self.central_connections.get(peer_address):
|
||||
self.send_hci_packet(
|
||||
HCI_Disconnection_Complete_Event(
|
||||
status=HCI_SUCCESS,
|
||||
connection_handle=connection.handle,
|
||||
reason=HCI_CONNECTION_TIMEOUT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
# Remove the connection
|
||||
del self.central_connections[peer_address]
|
||||
else:
|
||||
logger.warning(f'!!! No central connection found for {peer_address}')
|
||||
elif connection := self.find_peripheral_connection_by_handle(
|
||||
disconnection_command.connection_handle
|
||||
):
|
||||
logger.debug(f'PERIPHERAL Connection removed: {connection}')
|
||||
del self.peripheral_connections[connection.peer_address]
|
||||
|
||||
def on_link_encrypted(self, peer_address, _rand, _ediv, _ltk):
|
||||
# For now, just setup the encryption without asking the host
|
||||
@@ -542,15 +546,14 @@ class Controller:
|
||||
acl_packet = HCI_AclDataPacket(connection.handle, 2, 0, len(data), data)
|
||||
self.send_hci_packet(acl_packet)
|
||||
|
||||
def on_link_advertising_data(self, sender_address, data):
|
||||
def on_link_advertising_data(self, sender_address: Address, data: bytes):
|
||||
# Ignore if we're not scanning
|
||||
if self.le_scan_enable == 0:
|
||||
return
|
||||
|
||||
# Send a scan report
|
||||
report = HCI_LE_Advertising_Report_Event.Report(
|
||||
HCI_LE_Advertising_Report_Event.Report.FIELDS,
|
||||
event_type=HCI_LE_Advertising_Report_Event.ADV_IND,
|
||||
event_type=HCI_LE_Advertising_Report_Event.EventType.ADV_IND,
|
||||
address_type=sender_address.address_type,
|
||||
address=sender_address,
|
||||
data=data,
|
||||
@@ -560,8 +563,7 @@ class Controller:
|
||||
|
||||
# Simulate a scan response
|
||||
report = HCI_LE_Advertising_Report_Event.Report(
|
||||
HCI_LE_Advertising_Report_Event.Report.FIELDS,
|
||||
event_type=HCI_LE_Advertising_Report_Event.SCAN_RSP,
|
||||
event_type=HCI_LE_Advertising_Report_Event.EventType.SCAN_RSP,
|
||||
address_type=sender_address.address_type,
|
||||
address=sender_address,
|
||||
data=data,
|
||||
@@ -618,8 +620,8 @@ class Controller:
|
||||
cis_sync_delay=0,
|
||||
transport_latency_c_to_p=0,
|
||||
transport_latency_p_to_c=0,
|
||||
phy_c_to_p=0,
|
||||
phy_p_to_c=0,
|
||||
phy_c_to_p=1,
|
||||
phy_p_to_c=1,
|
||||
nse=0,
|
||||
bn_c_to_p=0,
|
||||
bn_p_to_c=0,
|
||||
@@ -695,7 +697,7 @@ class Controller:
|
||||
peer_address=peer_address,
|
||||
link=self.link,
|
||||
transport=PhysicalTransport.BR_EDR,
|
||||
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
|
||||
link_type=HCI_Connection_Complete_Event.LinkType.ACL,
|
||||
)
|
||||
self.classic_connections[peer_address] = connection
|
||||
logger.debug(
|
||||
@@ -709,7 +711,7 @@ class Controller:
|
||||
connection_handle=connection_handle,
|
||||
bd_addr=peer_address,
|
||||
encryption_enabled=False,
|
||||
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
|
||||
link_type=HCI_Connection_Complete_Event.LinkType.ACL,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -720,7 +722,7 @@ class Controller:
|
||||
connection_handle=0,
|
||||
bd_addr=peer_address,
|
||||
encryption_enabled=False,
|
||||
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
|
||||
link_type=HCI_Connection_Complete_Event.LinkType.ACL,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -877,6 +879,14 @@ class Controller:
|
||||
else:
|
||||
# Remove the connection
|
||||
del self.central_connections[connection.peer_address]
|
||||
elif connection := self.find_peripheral_connection_by_handle(handle):
|
||||
if self.link:
|
||||
self.link.disconnect(
|
||||
self.random_address, connection.peer_address, command
|
||||
)
|
||||
else:
|
||||
# Remove the connection
|
||||
del self.peripheral_connections[connection.peer_address]
|
||||
elif connection := self.find_classic_connection_by_handle(handle):
|
||||
if self.link:
|
||||
self.link.classic_disconnect(
|
||||
@@ -945,7 +955,7 @@ class Controller:
|
||||
)
|
||||
)
|
||||
self.link.classic_sco_connect(
|
||||
self, connection.peer_address, HCI_Connection_Complete_Event.ESCO_LINK_TYPE
|
||||
self, connection.peer_address, HCI_Connection_Complete_Event.LinkType.ESCO
|
||||
)
|
||||
|
||||
def on_hci_enhanced_accept_synchronous_connection_request_command(self, command):
|
||||
@@ -974,10 +984,71 @@ class Controller:
|
||||
)
|
||||
)
|
||||
self.link.classic_accept_sco_connection(
|
||||
self, connection.peer_address, HCI_Connection_Complete_Event.ESCO_LINK_TYPE
|
||||
self, connection.peer_address, HCI_Connection_Complete_Event.LinkType.ESCO
|
||||
)
|
||||
|
||||
def on_hci_switch_role_command(self, command):
|
||||
def on_hci_sniff_mode_command(self, command: hci.HCI_Sniff_Mode_Command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.2.2 Sniff Mode command
|
||||
'''
|
||||
if self.link is None:
|
||||
self.send_hci_packet(
|
||||
hci.HCI_Command_Status_Event(
|
||||
status=hci.HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
||||
num_hci_command_packets=1,
|
||||
command_opcode=command.op_code,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
self.send_hci_packet(
|
||||
hci.HCI_Command_Status_Event(
|
||||
status=HCI_SUCCESS,
|
||||
num_hci_command_packets=1,
|
||||
command_opcode=command.op_code,
|
||||
)
|
||||
)
|
||||
self.send_hci_packet(
|
||||
hci.HCI_Mode_Change_Event(
|
||||
status=HCI_SUCCESS,
|
||||
connection_handle=command.connection_handle,
|
||||
current_mode=hci.HCI_Mode_Change_Event.Mode.SNIFF,
|
||||
interval=2,
|
||||
)
|
||||
)
|
||||
|
||||
def on_hci_exit_sniff_mode_command(self, command: hci.HCI_Exit_Sniff_Mode_Command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.2.3 Exit Sniff Mode command
|
||||
'''
|
||||
|
||||
if self.link is None:
|
||||
self.send_hci_packet(
|
||||
hci.HCI_Command_Status_Event(
|
||||
status=hci.HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
||||
num_hci_command_packets=1,
|
||||
command_opcode=command.op_code,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
self.send_hci_packet(
|
||||
hci.HCI_Command_Status_Event(
|
||||
status=HCI_SUCCESS,
|
||||
num_hci_command_packets=1,
|
||||
command_opcode=command.op_code,
|
||||
)
|
||||
)
|
||||
self.send_hci_packet(
|
||||
hci.HCI_Mode_Change_Event(
|
||||
status=HCI_SUCCESS,
|
||||
connection_handle=command.connection_handle,
|
||||
current_mode=hci.HCI_Mode_Change_Event.Mode.ACTIVE,
|
||||
interval=2,
|
||||
)
|
||||
)
|
||||
|
||||
def on_hci_switch_role_command(self, command: hci.HCI_Switch_Role_Command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.2.8 Switch Role command
|
||||
'''
|
||||
|
||||
530
bumble/device.py
530
bumble/device.py
@@ -35,12 +35,10 @@ import secrets
|
||||
import sys
|
||||
from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Deque,
|
||||
Dict,
|
||||
Optional,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
@@ -87,6 +85,7 @@ from bumble.profiles import gatt_service
|
||||
if TYPE_CHECKING:
|
||||
from bumble.transport.common import TransportSource, TransportSink
|
||||
|
||||
_T = TypeVar('_T')
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -99,9 +98,9 @@ logger = logging.getLogger(__name__)
|
||||
# fmt: off
|
||||
# pylint: disable=line-too-long
|
||||
|
||||
DEVICE_MIN_SCAN_INTERVAL = 25
|
||||
DEVICE_MIN_SCAN_INTERVAL = 2.5
|
||||
DEVICE_MAX_SCAN_INTERVAL = 10240
|
||||
DEVICE_MIN_SCAN_WINDOW = 25
|
||||
DEVICE_MIN_SCAN_WINDOW = 2.5
|
||||
DEVICE_MAX_SCAN_WINDOW = 10240
|
||||
DEVICE_MIN_LE_RSSI = -127
|
||||
DEVICE_MAX_LE_RSSI = 20
|
||||
@@ -140,6 +139,9 @@ DEVICE_DEFAULT_ADVERTISING_TX_POWER = (
|
||||
DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_SKIP = 0
|
||||
DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_TIMEOUT = 5.0
|
||||
DEVICE_DEFAULT_LE_RPA_TIMEOUT = 15 * 60 # 15 minutes (in seconds)
|
||||
DEVICE_DEFAULT_ISO_CIS_MAX_SDU = 251
|
||||
DEVICE_DEFAULT_ISO_CIS_RTN = 10
|
||||
DEVICE_DEFAULT_ISO_CIS_MAX_TRANSPORT_LATENCY = 100
|
||||
|
||||
# fmt: on
|
||||
# pylint: enable=line-too-long
|
||||
@@ -202,25 +204,35 @@ class Advertisement:
|
||||
# -----------------------------------------------------------------------------
|
||||
class LegacyAdvertisement(Advertisement):
|
||||
@classmethod
|
||||
def from_advertising_report(cls, report):
|
||||
def from_advertising_report(
|
||||
cls, report: hci.HCI_LE_Advertising_Report_Event.Report
|
||||
) -> Self:
|
||||
return cls(
|
||||
address=report.address,
|
||||
rssi=report.rssi,
|
||||
is_legacy=True,
|
||||
is_connectable=report.event_type
|
||||
in (
|
||||
hci.HCI_LE_Advertising_Report_Event.ADV_IND,
|
||||
hci.HCI_LE_Advertising_Report_Event.ADV_DIRECT_IND,
|
||||
is_connectable=(
|
||||
report.event_type
|
||||
in (
|
||||
hci.HCI_LE_Advertising_Report_Event.EventType.ADV_IND,
|
||||
hci.HCI_LE_Advertising_Report_Event.EventType.ADV_DIRECT_IND,
|
||||
)
|
||||
),
|
||||
is_directed=report.event_type
|
||||
== hci.HCI_LE_Advertising_Report_Event.ADV_DIRECT_IND,
|
||||
is_scannable=report.event_type
|
||||
in (
|
||||
hci.HCI_LE_Advertising_Report_Event.ADV_IND,
|
||||
hci.HCI_LE_Advertising_Report_Event.ADV_SCAN_IND,
|
||||
is_directed=(
|
||||
report.event_type
|
||||
== hci.HCI_LE_Advertising_Report_Event.EventType.ADV_DIRECT_IND
|
||||
),
|
||||
is_scannable=(
|
||||
report.event_type
|
||||
in (
|
||||
hci.HCI_LE_Advertising_Report_Event.EventType.ADV_IND,
|
||||
hci.HCI_LE_Advertising_Report_Event.EventType.ADV_SCAN_IND,
|
||||
)
|
||||
),
|
||||
is_scan_response=(
|
||||
report.event_type
|
||||
== hci.HCI_LE_Advertising_Report_Event.EventType.SCAN_RSP
|
||||
),
|
||||
is_scan_response=report.event_type
|
||||
== hci.HCI_LE_Advertising_Report_Event.SCAN_RSP,
|
||||
data_bytes=report.data,
|
||||
)
|
||||
|
||||
@@ -228,18 +240,20 @@ class LegacyAdvertisement(Advertisement):
|
||||
# -----------------------------------------------------------------------------
|
||||
class ExtendedAdvertisement(Advertisement):
|
||||
@classmethod
|
||||
def from_advertising_report(cls, report):
|
||||
def from_advertising_report(
|
||||
cls, report: hci.HCI_LE_Extended_Advertising_Report_Event.Report
|
||||
) -> Self:
|
||||
# fmt: off
|
||||
# pylint: disable=line-too-long
|
||||
return cls(
|
||||
address = report.address,
|
||||
rssi = report.rssi,
|
||||
is_legacy = report.event_type & (1 << hci.HCI_LE_Extended_Advertising_Report_Event.LEGACY_ADVERTISING_PDU_USED) != 0,
|
||||
is_legacy = (report.event_type & hci.HCI_LE_Extended_Advertising_Report_Event.EventType.LEGACY_ADVERTISING_PDU_USED) != 0,
|
||||
is_anonymous = report.address.address_type == hci.HCI_LE_Extended_Advertising_Report_Event.ANONYMOUS_ADDRESS_TYPE,
|
||||
is_connectable = report.event_type & (1 << hci.HCI_LE_Extended_Advertising_Report_Event.CONNECTABLE_ADVERTISING) != 0,
|
||||
is_directed = report.event_type & (1 << hci.HCI_LE_Extended_Advertising_Report_Event.DIRECTED_ADVERTISING) != 0,
|
||||
is_scannable = report.event_type & (1 << hci.HCI_LE_Extended_Advertising_Report_Event.SCANNABLE_ADVERTISING) != 0,
|
||||
is_scan_response = report.event_type & (1 << hci.HCI_LE_Extended_Advertising_Report_Event.SCAN_RESPONSE) != 0,
|
||||
is_connectable = (report.event_type & hci.HCI_LE_Extended_Advertising_Report_Event.EventType.CONNECTABLE_ADVERTISING) != 0,
|
||||
is_directed = (report.event_type & hci.HCI_LE_Extended_Advertising_Report_Event.EventType.DIRECTED_ADVERTISING) != 0,
|
||||
is_scannable = (report.event_type & hci.HCI_LE_Extended_Advertising_Report_Event.EventType.SCANNABLE_ADVERTISING) != 0,
|
||||
is_scan_response = (report.event_type & hci.HCI_LE_Extended_Advertising_Report_Event.EventType.SCAN_RESPONSE) != 0,
|
||||
is_complete = (report.event_type >> 5 & 3) == hci.HCI_LE_Extended_Advertising_Report_Event.DATA_COMPLETE,
|
||||
is_truncated = (report.event_type >> 5 & 3) == hci.HCI_LE_Extended_Advertising_Report_Event.DATA_INCOMPLETE_TRUNCATED_NO_MORE_TO_COME,
|
||||
primary_phy = report.primary_phy,
|
||||
@@ -436,7 +450,7 @@ class AdvertisingEventProperties:
|
||||
|
||||
@classmethod
|
||||
def from_advertising_type(
|
||||
cls: Type[AdvertisingEventProperties],
|
||||
cls: type[AdvertisingEventProperties],
|
||||
advertising_type: AdvertisingType,
|
||||
) -> AdvertisingEventProperties:
|
||||
return cls(
|
||||
@@ -478,7 +492,18 @@ class PeriodicAdvertisement:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class BIGInfoAdvertisement:
|
||||
class BigInfoAdvertisement:
|
||||
class Framing(utils.OpenIntEnum):
|
||||
# fmt: off
|
||||
UNFRAMED = 0X00
|
||||
FRAMED_SEGMENTABLE_MODE = 0X01
|
||||
FRAMED_UNSEGMENTED_MODE = 0X02
|
||||
|
||||
class Encryption(utils.OpenIntEnum):
|
||||
# fmt: off
|
||||
UNENCRYPTED = 0x00
|
||||
ENCRYPTED = 0x01
|
||||
|
||||
address: hci.Address
|
||||
sid: int
|
||||
num_bis: int
|
||||
@@ -491,8 +516,8 @@ class BIGInfoAdvertisement:
|
||||
sdu_interval: int
|
||||
max_sdu: int
|
||||
phy: hci.Phy
|
||||
framed: bool
|
||||
encrypted: bool
|
||||
framing: Framing
|
||||
encryption: Encryption
|
||||
|
||||
@classmethod
|
||||
def from_report(cls, address: hci.Address, sid: int, report) -> Self:
|
||||
@@ -509,8 +534,8 @@ class BIGInfoAdvertisement:
|
||||
report.sdu_interval,
|
||||
report.max_sdu,
|
||||
hci.Phy(report.phy),
|
||||
report.framing != 0,
|
||||
report.encryption != 0,
|
||||
cls.Framing(report.framing),
|
||||
cls.Encryption(report.encryption),
|
||||
)
|
||||
|
||||
|
||||
@@ -1002,7 +1027,7 @@ class PeriodicAdvertisingSync(utils.EventEmitter):
|
||||
def on_biginfo_advertising_report(self, report) -> None:
|
||||
self.emit(
|
||||
self.EVENT_BIGINFO_ADVERTISEMENT,
|
||||
BIGInfoAdvertisement.from_report(self.advertiser_address, self.sid, report),
|
||||
BigInfoAdvertisement.from_report(self.advertiser_address, self.sid, report),
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -1020,14 +1045,24 @@ class PeriodicAdvertisingSync(utils.EventEmitter):
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class BigParameters:
|
||||
class Packing(utils.OpenIntEnum):
|
||||
# fmt: off
|
||||
SEQUENTIAL = 0x00
|
||||
INTERLEAVED = 0x01
|
||||
|
||||
class Framing(utils.OpenIntEnum):
|
||||
# fmt: off
|
||||
UNFRAMED = 0x00
|
||||
FRAMED = 0x01
|
||||
|
||||
num_bis: int
|
||||
sdu_interval: int
|
||||
sdu_interval: int # SDU interval, in microseconds
|
||||
max_sdu: int
|
||||
max_transport_latency: int
|
||||
max_transport_latency: int # Max transport latency, in milliseconds
|
||||
rtn: int
|
||||
phy: hci.PhyBit = hci.PhyBit.LE_2M
|
||||
packing: int = 0
|
||||
framing: int = 0
|
||||
packing: Packing = Packing.SEQUENTIAL
|
||||
framing: Framing = Framing.UNFRAMED
|
||||
broadcast_code: bytes | None = None
|
||||
|
||||
|
||||
@@ -1050,15 +1085,15 @@ class Big(utils.EventEmitter):
|
||||
state: State = State.PENDING
|
||||
|
||||
# Attributes provided by BIG Create Complete event
|
||||
big_sync_delay: int = 0
|
||||
transport_latency_big: int = 0
|
||||
phy: int = 0
|
||||
big_sync_delay: int = 0 # Sync delay, in microseconds
|
||||
transport_latency_big: int = 0 # Transport latency, in microseconds
|
||||
phy: hci.Phy = hci.Phy.LE_1M
|
||||
nse: int = 0
|
||||
bn: int = 0
|
||||
pto: int = 0
|
||||
irc: int = 0
|
||||
max_pdu: int = 0
|
||||
iso_interval: float = 0.0
|
||||
iso_interval: float = 0.0 # ISO interval, in milliseconds
|
||||
bis_links: Sequence[BisLink] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
@@ -1343,7 +1378,7 @@ class Peer:
|
||||
return self.gatt_client.get_characteristics_by_uuid(uuid, service)
|
||||
|
||||
def create_service_proxy(
|
||||
self, proxy_class: Type[_PROXY_CLASS]
|
||||
self, proxy_class: type[_PROXY_CLASS]
|
||||
) -> Optional[_PROXY_CLASS]:
|
||||
if proxy := proxy_class.from_client(self.gatt_client):
|
||||
return cast(_PROXY_CLASS, proxy)
|
||||
@@ -1351,7 +1386,7 @@ class Peer:
|
||||
return None
|
||||
|
||||
async def discover_service_and_create_proxy(
|
||||
self, proxy_class: Type[_PROXY_CLASS]
|
||||
self, proxy_class: type[_PROXY_CLASS]
|
||||
) -> Optional[_PROXY_CLASS]:
|
||||
# Discover the first matching service and its characteristics
|
||||
services = await self.discover_service(proxy_class.SERVICE_CLASS.UUID)
|
||||
@@ -1464,7 +1499,7 @@ class _IsoLink:
|
||||
check_result=True,
|
||||
)
|
||||
|
||||
async def remove_data_path(self, direction: _IsoLink.Direction) -> int:
|
||||
async def remove_data_path(self, directions: Iterable[_IsoLink.Direction]) -> int:
|
||||
"""Remove a data path with controller on given direction.
|
||||
|
||||
Args:
|
||||
@@ -1476,7 +1511,9 @@ class _IsoLink:
|
||||
response = await self.device.send_command(
|
||||
hci.HCI_LE_Remove_ISO_Data_Path_Command(
|
||||
connection_handle=self.handle,
|
||||
data_path_direction=direction,
|
||||
data_path_direction=sum(
|
||||
1 << direction for direction in set(directions)
|
||||
),
|
||||
),
|
||||
check_result=False,
|
||||
)
|
||||
@@ -1486,10 +1523,74 @@ class _IsoLink:
|
||||
"""Write an ISO SDU."""
|
||||
self.device.host.send_iso_sdu(connection_handle=self.handle, sdu=sdu)
|
||||
|
||||
async def get_tx_time_stamp(self) -> tuple[int, int, int]:
|
||||
response = await self.device.host.send_command(
|
||||
hci.HCI_LE_Read_ISO_TX_Sync_Command(connection_handle=self.handle),
|
||||
check_result=True,
|
||||
)
|
||||
return (
|
||||
response.return_parameters.packet_sequence_number,
|
||||
response.return_parameters.tx_time_stamp,
|
||||
response.return_parameters.time_offset,
|
||||
)
|
||||
|
||||
@property
|
||||
def data_packet_queue(self) -> DataPacketQueue | None:
|
||||
return self.device.host.get_data_packet_queue(self.handle)
|
||||
|
||||
async def drain(self) -> None:
|
||||
if data_packet_queue := self.data_packet_queue:
|
||||
await data_packet_queue.drain(self.handle)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class CigParameters:
|
||||
class WorstCaseSca(utils.OpenIntEnum):
|
||||
# fmt: off
|
||||
SCA_251_TO_500_PPM = 0x00
|
||||
SCA_151_TO_250_PPM = 0x01
|
||||
SCA_101_TO_150_PPM = 0x02
|
||||
SCA_76_TO_100_PPM = 0x03
|
||||
SCA_51_TO_75_PPM = 0x04
|
||||
SCA_31_TO_50_PPM = 0x05
|
||||
SCA_21_TO_30_PPM = 0x06
|
||||
SCA_0_TO_20_PPM = 0x07
|
||||
|
||||
class Packing(utils.OpenIntEnum):
|
||||
# fmt: off
|
||||
SEQUENTIAL = 0x00
|
||||
INTERLEAVED = 0x01
|
||||
|
||||
class Framing(utils.OpenIntEnum):
|
||||
# fmt: off
|
||||
UNFRAMED = 0x00
|
||||
FRAMED = 0x01
|
||||
|
||||
@dataclass
|
||||
class CisParameters:
|
||||
cis_id: int
|
||||
max_sdu_c_to_p: int = DEVICE_DEFAULT_ISO_CIS_MAX_SDU
|
||||
max_sdu_p_to_c: int = DEVICE_DEFAULT_ISO_CIS_MAX_SDU
|
||||
phy_c_to_p: hci.PhyBit = hci.PhyBit.LE_2M
|
||||
phy_p_to_c: hci.PhyBit = hci.PhyBit.LE_2M
|
||||
rtn_c_to_p: int = DEVICE_DEFAULT_ISO_CIS_RTN # Number of C->P retransmissions
|
||||
rtn_p_to_c: int = DEVICE_DEFAULT_ISO_CIS_RTN # Number of P->C retransmissions
|
||||
|
||||
cig_id: int
|
||||
cis_parameters: list[CisParameters]
|
||||
sdu_interval_c_to_p: int # C->P SDU interval, in microseconds
|
||||
sdu_interval_p_to_c: int # P->C SDU interval, in microseconds
|
||||
worst_case_sca: WorstCaseSca = WorstCaseSca.SCA_251_TO_500_PPM
|
||||
packing: Packing = Packing.SEQUENTIAL
|
||||
framing: Framing = Framing.UNFRAMED
|
||||
max_transport_latency_c_to_p: int = (
|
||||
DEVICE_DEFAULT_ISO_CIS_MAX_TRANSPORT_LATENCY # Max C->P transport latency, in milliseconds
|
||||
)
|
||||
max_transport_latency_p_to_c: int = (
|
||||
DEVICE_DEFAULT_ISO_CIS_MAX_TRANSPORT_LATENCY # Max C->P transport latency, in milliseconds
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclass
|
||||
@@ -1503,6 +1604,20 @@ class CisLink(utils.EventEmitter, _IsoLink):
|
||||
handle: int # CIS handle assigned by Controller (in LE_Set_CIG_Parameters Complete or LE_CIS_Request events)
|
||||
cis_id: int # CIS ID assigned by Central device
|
||||
cig_id: int # CIG ID assigned by Central device
|
||||
cig_sync_delay: int = 0 # CIG sync delay, in microseconds
|
||||
cis_sync_delay: int = 0 # CIS sync delay, in microseconds
|
||||
transport_latency_c_to_p: int = 0 # C->P transport latency, in microseconds
|
||||
transport_latency_p_to_c: int = 0 # P->C transport latency, in microseconds
|
||||
phy_c_to_p: Optional[hci.Phy] = None
|
||||
phy_p_to_c: Optional[hci.Phy] = None
|
||||
nse: int = 0
|
||||
bn_c_to_p: int = 0
|
||||
bn_p_to_c: int = 0
|
||||
ft_c_to_p: int = 0
|
||||
ft_p_to_c: int = 0
|
||||
max_pdu_c_to_p: int = 0
|
||||
max_pdu_p_to_c: int = 0
|
||||
iso_interval: float = 0.0 # ISO interval, in milliseconds
|
||||
state: State = State.PENDING
|
||||
sink: Callable[[hci.HCI_IsoDataPacket], Any] | None = None
|
||||
|
||||
@@ -1545,7 +1660,7 @@ class IsoPacketStream:
|
||||
self.iso_link = iso_link
|
||||
self.data_packet_queue = iso_link.data_packet_queue
|
||||
self.data_packet_queue.on('flow', self._on_flow)
|
||||
self._thresholds: Deque[int] = collections.deque()
|
||||
self._thresholds: collections.deque[int] = collections.deque()
|
||||
self._semaphore = asyncio.Semaphore(max_queue_size)
|
||||
|
||||
def _on_flow(self) -> None:
|
||||
@@ -1585,6 +1700,7 @@ class Connection(utils.CompositeEventEmitter):
|
||||
peer_resolvable_address: Optional[hci.Address]
|
||||
peer_le_features: Optional[hci.LeFeatureMask]
|
||||
role: hci.Role
|
||||
parameters: Parameters
|
||||
encryption: int
|
||||
encryption_key_size: int
|
||||
authenticated: bool
|
||||
@@ -1594,6 +1710,8 @@ class Connection(utils.CompositeEventEmitter):
|
||||
pairing_peer_authentication_requirements: Optional[int]
|
||||
cs_configs: dict[int, ChannelSoundingConfig] # Config ID to Configuration
|
||||
cs_procedures: dict[int, ChannelSoundingProcedure] # Config ID to Procedures
|
||||
classic_mode: int = hci.HCI_Mode_Change_Event.Mode.ACTIVE
|
||||
classic_interval: int = 0
|
||||
|
||||
EVENT_CONNECTION_ATT_MTU_UPDATE = "connection_att_mtu_update"
|
||||
EVENT_DISCONNECTION = "disconnection"
|
||||
@@ -1620,6 +1738,8 @@ class Connection(utils.CompositeEventEmitter):
|
||||
EVENT_CHANNEL_SOUNDING_CONFIG_REMOVED = "channel_sounding_config_removed"
|
||||
EVENT_CHANNEL_SOUNDING_PROCEDURE_FAILURE = "channel_sounding_procedure_failure"
|
||||
EVENT_CHANNEL_SOUNDING_PROCEDURE = "channel_sounding_procedure"
|
||||
EVENT_MODE_CHANGE = "mode_change"
|
||||
EVENT_MODE_CHANGE_FAILURE = "mode_change_failure"
|
||||
EVENT_ROLE_CHANGE = "role_change"
|
||||
EVENT_ROLE_CHANGE_FAILURE = "role_change_failure"
|
||||
EVENT_CLASSIC_PAIRING = "classic_pairing"
|
||||
@@ -1629,6 +1749,9 @@ class Connection(utils.CompositeEventEmitter):
|
||||
EVENT_PAIRING_FAILURE = "pairing_failure"
|
||||
EVENT_SECURITY_REQUEST = "security_request"
|
||||
EVENT_LINK_KEY = "link_key"
|
||||
EVENT_CIS_REQUEST = "cis_request"
|
||||
EVENT_CIS_ESTABLISHMENT = "cis_establishment"
|
||||
EVENT_CIS_ESTABLISHMENT_FAILURE = "cis_establishment_failure"
|
||||
|
||||
@utils.composite_listener
|
||||
class Listener:
|
||||
@@ -1884,6 +2007,12 @@ class Connection(utils.CompositeEventEmitter):
|
||||
def data_packet_queue(self) -> DataPacketQueue | None:
|
||||
return self.device.host.get_data_packet_queue(self.handle)
|
||||
|
||||
def cancel_on_disconnection(self, awaitable: Awaitable[_T]) -> Awaitable[_T]:
|
||||
"""
|
||||
Helper method to call `utils.cancel_on_event` for the 'disconnection' event
|
||||
"""
|
||||
return utils.cancel_on_event(self, self.EVENT_DISCONNECTION, awaitable)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
@@ -1954,9 +2083,9 @@ class DeviceConfiguration:
|
||||
gatt_service_enabled: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.gatt_services: list[Dict[str, Any]] = []
|
||||
self.gatt_services: list[dict[str, Any]] = []
|
||||
|
||||
def load_from_dict(self, config: Dict[str, Any]) -> None:
|
||||
def load_from_dict(self, config: dict[str, Any]) -> None:
|
||||
config = copy.deepcopy(config)
|
||||
|
||||
# Load simple properties
|
||||
@@ -2016,13 +2145,13 @@ class DeviceConfiguration:
|
||||
self.load_from_dict(json.load(file))
|
||||
|
||||
@classmethod
|
||||
def from_file(cls: Type[Self], filename: str) -> Self:
|
||||
def from_file(cls: type[Self], filename: str) -> Self:
|
||||
config = cls()
|
||||
config.load_from_file(filename)
|
||||
return config
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: Type[Self], config: Dict[str, Any]) -> Self:
|
||||
def from_dict(cls: type[Self], config: dict[str, Any]) -> Self:
|
||||
device_config = cls()
|
||||
device_config.load_from_dict(config)
|
||||
return device_config
|
||||
@@ -2119,22 +2248,22 @@ class Device(utils.CompositeEventEmitter):
|
||||
advertising_data: bytes
|
||||
scan_response_data: bytes
|
||||
cs_capabilities: ChannelSoundingCapabilities | None = None
|
||||
connections: Dict[int, Connection]
|
||||
pending_connections: Dict[hci.Address, Connection]
|
||||
classic_pending_accepts: Dict[
|
||||
connections: dict[int, Connection]
|
||||
pending_connections: dict[hci.Address, Connection]
|
||||
classic_pending_accepts: dict[
|
||||
hci.Address,
|
||||
list[asyncio.Future[Union[Connection, tuple[hci.Address, int, int]]]],
|
||||
]
|
||||
advertisement_accumulators: Dict[hci.Address, AdvertisementDataAccumulator]
|
||||
advertisement_accumulators: dict[hci.Address, AdvertisementDataAccumulator]
|
||||
periodic_advertising_syncs: list[PeriodicAdvertisingSync]
|
||||
config: DeviceConfiguration
|
||||
legacy_advertiser: Optional[LegacyAdvertiser]
|
||||
sco_links: Dict[int, ScoLink]
|
||||
cis_links: Dict[int, CisLink]
|
||||
sco_links: dict[int, ScoLink]
|
||||
cis_links: dict[int, CisLink]
|
||||
bigs: dict[int, Big]
|
||||
bis_links: dict[int, BisLink]
|
||||
big_syncs: dict[int, BigSync]
|
||||
_pending_cis: Dict[int, tuple[int, int]]
|
||||
_pending_cis: dict[int, tuple[int, int]]
|
||||
gatt_service: gatt_service.GenericAttributeProfileService | None = None
|
||||
|
||||
EVENT_ADVERTISEMENT = "advertisement"
|
||||
@@ -2294,8 +2423,8 @@ class Device(utils.CompositeEventEmitter):
|
||||
self.address_generation_offload = config.address_generation_offload
|
||||
|
||||
# Extended advertising.
|
||||
self.extended_advertising_sets: Dict[int, AdvertisingSet] = {}
|
||||
self.connecting_extended_advertising_sets: Dict[int, AdvertisingSet] = {}
|
||||
self.extended_advertising_sets: dict[int, AdvertisingSet] = {}
|
||||
self.connecting_extended_advertising_sets: dict[int, AdvertisingSet] = {}
|
||||
|
||||
# Legacy advertising.
|
||||
# The advertising and scan response data, as well as the advertising interval
|
||||
@@ -4271,11 +4400,11 @@ class Device(utils.CompositeEventEmitter):
|
||||
self.smp_manager.pairing_config_factory = pairing_config_factory
|
||||
|
||||
@property
|
||||
def smp_session_proxy(self) -> Type[smp.Session]:
|
||||
def smp_session_proxy(self) -> type[smp.Session]:
|
||||
return self.smp_manager.session_proxy
|
||||
|
||||
@smp_session_proxy.setter
|
||||
def smp_session_proxy(self, session_proxy: Type[smp.Session]) -> None:
|
||||
def smp_session_proxy(self, session_proxy: type[smp.Session]) -> None:
|
||||
self.smp_manager.session_proxy = session_proxy
|
||||
|
||||
async def pair(self, connection):
|
||||
@@ -4359,9 +4488,7 @@ class Device(utils.CompositeEventEmitter):
|
||||
raise hci.HCI_StatusError(result)
|
||||
|
||||
# Wait for the authentication to complete
|
||||
await utils.cancel_on_event(
|
||||
connection, Connection.EVENT_DISCONNECTION, pending_authentication
|
||||
)
|
||||
await connection.cancel_on_disconnection(pending_authentication)
|
||||
finally:
|
||||
connection.remove_listener(
|
||||
connection.EVENT_CONNECTION_AUTHENTICATION, on_authentication
|
||||
@@ -4448,9 +4575,7 @@ class Device(utils.CompositeEventEmitter):
|
||||
raise hci.HCI_StatusError(result)
|
||||
|
||||
# Wait for the result
|
||||
await utils.cancel_on_event(
|
||||
connection, Connection.EVENT_DISCONNECTION, pending_encryption
|
||||
)
|
||||
await connection.cancel_on_disconnection(pending_encryption)
|
||||
finally:
|
||||
connection.remove_listener(
|
||||
connection.EVENT_CONNECTION_ENCRYPTION_CHANGE, on_encryption_change
|
||||
@@ -4494,9 +4619,7 @@ class Device(utils.CompositeEventEmitter):
|
||||
f'{hci.HCI_Constant.error_name(result.status)}'
|
||||
)
|
||||
raise hci.HCI_StatusError(result)
|
||||
await utils.cancel_on_event(
|
||||
connection, Connection.EVENT_DISCONNECTION, pending_role_change
|
||||
)
|
||||
await connection.cancel_on_disconnection(pending_role_change)
|
||||
finally:
|
||||
connection.remove_listener(connection.EVENT_ROLE_CHANGE, on_role_change)
|
||||
connection.remove_listener(
|
||||
@@ -4556,48 +4679,39 @@ class Device(utils.CompositeEventEmitter):
|
||||
@utils.experimental('Only for testing.')
|
||||
async def setup_cig(
|
||||
self,
|
||||
cig_id: int,
|
||||
cis_id: Sequence[int],
|
||||
sdu_interval: tuple[int, int],
|
||||
framing: int,
|
||||
max_sdu: tuple[int, int],
|
||||
retransmission_number: int,
|
||||
max_transport_latency: tuple[int, int],
|
||||
parameters: CigParameters,
|
||||
) -> list[int]:
|
||||
"""Sends hci.HCI_LE_Set_CIG_Parameters_Command.
|
||||
|
||||
Args:
|
||||
cig_id: CIG_ID.
|
||||
cis_id: CID ID list.
|
||||
sdu_interval: SDU intervals of (Central->Peripheral, Peripheral->Cental).
|
||||
framing: Un-framing(0) or Framing(1).
|
||||
max_sdu: Max SDU counts of (Central->Peripheral, Peripheral->Cental).
|
||||
retransmission_number: retransmission_number.
|
||||
max_transport_latency: Max transport latencies of
|
||||
(Central->Peripheral, Peripheral->Cental).
|
||||
parameters: CIG parameters.
|
||||
|
||||
Returns:
|
||||
List of created CIS handles corresponding to the same order of [cid_id].
|
||||
"""
|
||||
num_cis = len(cis_id)
|
||||
num_cis = len(parameters.cis_parameters)
|
||||
|
||||
response = await self.send_command(
|
||||
hci.HCI_LE_Set_CIG_Parameters_Command(
|
||||
cig_id=cig_id,
|
||||
sdu_interval_c_to_p=sdu_interval[0],
|
||||
sdu_interval_p_to_c=sdu_interval[1],
|
||||
worst_case_sca=0x00, # 251-500 ppm
|
||||
packing=0x00, # Sequential
|
||||
framing=framing,
|
||||
max_transport_latency_c_to_p=max_transport_latency[0],
|
||||
max_transport_latency_p_to_c=max_transport_latency[1],
|
||||
cis_id=cis_id,
|
||||
max_sdu_c_to_p=[max_sdu[0]] * num_cis,
|
||||
max_sdu_p_to_c=[max_sdu[1]] * num_cis,
|
||||
phy_c_to_p=[hci.HCI_LE_2M_PHY] * num_cis,
|
||||
phy_p_to_c=[hci.HCI_LE_2M_PHY] * num_cis,
|
||||
rtn_c_to_p=[retransmission_number] * num_cis,
|
||||
rtn_p_to_c=[retransmission_number] * num_cis,
|
||||
cig_id=parameters.cig_id,
|
||||
sdu_interval_c_to_p=parameters.sdu_interval_c_to_p,
|
||||
sdu_interval_p_to_c=parameters.sdu_interval_p_to_c,
|
||||
worst_case_sca=parameters.worst_case_sca,
|
||||
packing=int(parameters.packing),
|
||||
framing=int(parameters.framing),
|
||||
max_transport_latency_c_to_p=parameters.max_transport_latency_c_to_p,
|
||||
max_transport_latency_p_to_c=parameters.max_transport_latency_p_to_c,
|
||||
cis_id=[cis.cis_id for cis in parameters.cis_parameters],
|
||||
max_sdu_c_to_p=[
|
||||
cis.max_sdu_c_to_p for cis in parameters.cis_parameters
|
||||
],
|
||||
max_sdu_p_to_c=[
|
||||
cis.max_sdu_p_to_c for cis in parameters.cis_parameters
|
||||
],
|
||||
phy_c_to_p=[cis.phy_c_to_p for cis in parameters.cis_parameters],
|
||||
phy_p_to_c=[cis.phy_p_to_c for cis in parameters.cis_parameters],
|
||||
rtn_c_to_p=[cis.rtn_c_to_p for cis in parameters.cis_parameters],
|
||||
rtn_p_to_c=[cis.rtn_p_to_c for cis in parameters.cis_parameters],
|
||||
),
|
||||
check_result=True,
|
||||
)
|
||||
@@ -4605,19 +4719,17 @@ class Device(utils.CompositeEventEmitter):
|
||||
# Ideally, we should manage CIG lifecycle, but they are not useful for Unicast
|
||||
# Server, so here it only provides a basic functionality for testing.
|
||||
cis_handles = response.return_parameters.connection_handle[:]
|
||||
for id, cis_handle in zip(cis_id, cis_handles):
|
||||
self._pending_cis[cis_handle] = (id, cig_id)
|
||||
for cis, cis_handle in zip(parameters.cis_parameters, cis_handles):
|
||||
self._pending_cis[cis_handle] = (cis.cis_id, parameters.cig_id)
|
||||
|
||||
return cis_handles
|
||||
|
||||
# [LE only]
|
||||
@utils.experimental('Only for testing.')
|
||||
async def create_cis(
|
||||
self, cis_acl_pairs: Sequence[tuple[int, int]]
|
||||
self, cis_acl_pairs: Sequence[tuple[int, Connection]]
|
||||
) -> list[CisLink]:
|
||||
for cis_handle, acl_handle in cis_acl_pairs:
|
||||
acl_connection = self.lookup_connection(acl_handle)
|
||||
assert acl_connection
|
||||
for cis_handle, acl_connection in cis_acl_pairs:
|
||||
cis_id, cig_id = self._pending_cis.pop(cis_handle)
|
||||
self.cis_links[cis_handle] = CisLink(
|
||||
device=self,
|
||||
@@ -4637,8 +4749,8 @@ class Device(utils.CompositeEventEmitter):
|
||||
if pending_future := pending_cis_establishments.get(cis_link.handle):
|
||||
pending_future.set_result(cis_link)
|
||||
|
||||
def on_cis_establishment_failure(cis_handle: int, status: int) -> None:
|
||||
if pending_future := pending_cis_establishments.get(cis_handle):
|
||||
def on_cis_establishment_failure(cis_link: CisLink, status: int) -> None:
|
||||
if pending_future := pending_cis_establishments.get(cis_link.handle):
|
||||
pending_future.set_exception(hci.HCI_Error(status))
|
||||
|
||||
watcher.on(self, self.EVENT_CIS_ESTABLISHMENT, on_cis_establishment)
|
||||
@@ -4648,7 +4760,7 @@ class Device(utils.CompositeEventEmitter):
|
||||
await self.send_command(
|
||||
hci.HCI_LE_Create_CIS_Command(
|
||||
cis_connection_handle=[p[0] for p in cis_acl_pairs],
|
||||
acl_connection_handle=[p[1] for p in cis_acl_pairs],
|
||||
acl_connection_handle=[p[1].handle for p in cis_acl_pairs],
|
||||
),
|
||||
check_result=True,
|
||||
)
|
||||
@@ -4657,26 +4769,21 @@ class Device(utils.CompositeEventEmitter):
|
||||
|
||||
# [LE only]
|
||||
@utils.experimental('Only for testing.')
|
||||
async def accept_cis_request(self, handle: int) -> CisLink:
|
||||
async def accept_cis_request(self, cis_link: CisLink) -> None:
|
||||
"""[LE Only] Accepts an incoming CIS request.
|
||||
|
||||
When the specified CIS handle is already created, this method returns the
|
||||
existed CIS link object immediately.
|
||||
This method returns when the CIS is established, or raises an exception if
|
||||
the CIS establishment fails.
|
||||
|
||||
Args:
|
||||
handle: CIS handle to accept.
|
||||
|
||||
Returns:
|
||||
CIS link object on the given handle.
|
||||
"""
|
||||
if not (cis_link := self.cis_links.get(handle)):
|
||||
raise InvalidStateError(f'No pending CIS request of handle {handle}')
|
||||
|
||||
# There might be multiple ASE sharing a CIS channel.
|
||||
# If one of them has accepted the request, the others should just leverage it.
|
||||
async with self._cis_lock:
|
||||
if cis_link.state == CisLink.State.ESTABLISHED:
|
||||
return cis_link
|
||||
return
|
||||
|
||||
with closing(utils.EventWatcher()) as watcher:
|
||||
pending_establishment = asyncio.get_running_loop().create_future()
|
||||
@@ -4695,26 +4802,24 @@ class Device(utils.CompositeEventEmitter):
|
||||
)
|
||||
|
||||
await self.send_command(
|
||||
hci.HCI_LE_Accept_CIS_Request_Command(connection_handle=handle),
|
||||
hci.HCI_LE_Accept_CIS_Request_Command(
|
||||
connection_handle=cis_link.handle
|
||||
),
|
||||
check_result=True,
|
||||
)
|
||||
|
||||
await pending_establishment
|
||||
return cis_link
|
||||
|
||||
# Mypy believes this is reachable when context is an ExitStack.
|
||||
raise UnreachableError()
|
||||
|
||||
# [LE only]
|
||||
@utils.experimental('Only for testing.')
|
||||
async def reject_cis_request(
|
||||
self,
|
||||
handle: int,
|
||||
cis_link: CisLink,
|
||||
reason: int = hci.HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||
) -> None:
|
||||
await self.send_command(
|
||||
hci.HCI_LE_Reject_CIS_Request_Command(
|
||||
connection_handle=handle, reason=reason
|
||||
connection_handle=cis_link.handle, reason=reason
|
||||
),
|
||||
check_result=True,
|
||||
)
|
||||
@@ -5071,8 +5176,8 @@ class Device(utils.CompositeEventEmitter):
|
||||
# Store the keys in the key store
|
||||
if self.keystore:
|
||||
authenticated = key_type in (
|
||||
hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192_TYPE,
|
||||
hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
|
||||
hci.LinkKeyType.AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192,
|
||||
hci.LinkKeyType.AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256,
|
||||
)
|
||||
pairing_keys = PairingKeys(
|
||||
link_key=PairingKeys.Key(value=link_key, authenticated=authenticated),
|
||||
@@ -5252,7 +5357,7 @@ class Device(utils.CompositeEventEmitter):
|
||||
big.bis_links = [BisLink(handle=handle, big=big) for handle in bis_handles]
|
||||
big.big_sync_delay = big_sync_delay
|
||||
big.transport_latency_big = transport_latency_big
|
||||
big.phy = phy
|
||||
big.phy = hci.Phy(phy)
|
||||
big.nse = nse
|
||||
big.bn = bn
|
||||
big.pto = pto
|
||||
@@ -5519,8 +5624,8 @@ class Device(utils.CompositeEventEmitter):
|
||||
|
||||
# Handle SCO request.
|
||||
if link_type in (
|
||||
hci.HCI_Connection_Complete_Event.SCO_LINK_TYPE,
|
||||
hci.HCI_Connection_Complete_Event.ESCO_LINK_TYPE,
|
||||
hci.HCI_Connection_Complete_Event.LinkType.SCO,
|
||||
hci.HCI_Connection_Complete_Event.LinkType.ESCO,
|
||||
):
|
||||
if connection := self.find_connection_by_bd_addr(
|
||||
bd_addr, transport=PhysicalTransport.BR_EDR
|
||||
@@ -5628,7 +5733,7 @@ class Device(utils.CompositeEventEmitter):
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@with_connection_from_address
|
||||
def on_authentication_io_capability_request(self, connection):
|
||||
def on_authentication_io_capability_request(self, connection: Connection):
|
||||
# Ask what the pairing config should be for this connection
|
||||
pairing_config = self.pairing_config_factory(connection)
|
||||
|
||||
@@ -5636,13 +5741,13 @@ class Device(utils.CompositeEventEmitter):
|
||||
authentication_requirements = (
|
||||
# No Bonding
|
||||
(
|
||||
hci.HCI_MITM_NOT_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS,
|
||||
hci.HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS,
|
||||
hci.AuthenticationRequirements.MITM_NOT_REQUIRED_NO_BONDING,
|
||||
hci.AuthenticationRequirements.MITM_REQUIRED_NO_BONDING,
|
||||
),
|
||||
# General Bonding
|
||||
(
|
||||
hci.HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS,
|
||||
hci.HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS,
|
||||
hci.AuthenticationRequirements.MITM_NOT_REQUIRED_GENERAL_BONDING,
|
||||
hci.AuthenticationRequirements.MITM_REQUIRED_GENERAL_BONDING,
|
||||
),
|
||||
)[1 if pairing_config.bonding else 0][1 if pairing_config.mitm else 0]
|
||||
|
||||
@@ -5697,30 +5802,30 @@ class Device(utils.CompositeEventEmitter):
|
||||
raise UnreachableError()
|
||||
|
||||
# See Bluetooth spec @ Vol 3, Part C 5.2.2.6
|
||||
methods = {
|
||||
hci.HCI_DISPLAY_ONLY_IO_CAPABILITY: {
|
||||
hci.HCI_DISPLAY_ONLY_IO_CAPABILITY: display_auto_confirm,
|
||||
hci.HCI_DISPLAY_YES_NO_IO_CAPABILITY: display_confirm,
|
||||
hci.HCI_KEYBOARD_ONLY_IO_CAPABILITY: na,
|
||||
hci.HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm,
|
||||
methods: dict[int, dict[int, Callable[[], Awaitable[bool]]]] = {
|
||||
hci.IoCapability.DISPLAY_ONLY: {
|
||||
hci.IoCapability.DISPLAY_ONLY: display_auto_confirm,
|
||||
hci.IoCapability.DISPLAY_YES_NO: display_confirm,
|
||||
hci.IoCapability.KEYBOARD_ONLY: na,
|
||||
hci.IoCapability.NO_INPUT_NO_OUTPUT: auto_confirm,
|
||||
},
|
||||
hci.HCI_DISPLAY_YES_NO_IO_CAPABILITY: {
|
||||
hci.HCI_DISPLAY_ONLY_IO_CAPABILITY: display_auto_confirm,
|
||||
hci.HCI_DISPLAY_YES_NO_IO_CAPABILITY: display_confirm,
|
||||
hci.HCI_KEYBOARD_ONLY_IO_CAPABILITY: na,
|
||||
hci.HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm,
|
||||
hci.IoCapability.DISPLAY_YES_NO: {
|
||||
hci.IoCapability.DISPLAY_ONLY: display_auto_confirm,
|
||||
hci.IoCapability.DISPLAY_YES_NO: display_confirm,
|
||||
hci.IoCapability.KEYBOARD_ONLY: na,
|
||||
hci.IoCapability.NO_INPUT_NO_OUTPUT: auto_confirm,
|
||||
},
|
||||
hci.HCI_KEYBOARD_ONLY_IO_CAPABILITY: {
|
||||
hci.HCI_DISPLAY_ONLY_IO_CAPABILITY: na,
|
||||
hci.HCI_DISPLAY_YES_NO_IO_CAPABILITY: na,
|
||||
hci.HCI_KEYBOARD_ONLY_IO_CAPABILITY: na,
|
||||
hci.HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm,
|
||||
hci.IoCapability.KEYBOARD_ONLY: {
|
||||
hci.IoCapability.DISPLAY_ONLY: na,
|
||||
hci.IoCapability.DISPLAY_YES_NO: na,
|
||||
hci.IoCapability.KEYBOARD_ONLY: na,
|
||||
hci.IoCapability.NO_INPUT_NO_OUTPUT: auto_confirm,
|
||||
},
|
||||
hci.HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: {
|
||||
hci.HCI_DISPLAY_ONLY_IO_CAPABILITY: confirm,
|
||||
hci.HCI_DISPLAY_YES_NO_IO_CAPABILITY: confirm,
|
||||
hci.HCI_KEYBOARD_ONLY_IO_CAPABILITY: auto_confirm,
|
||||
hci.HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm,
|
||||
hci.IoCapability.NO_INPUT_NO_OUTPUT: {
|
||||
hci.IoCapability.DISPLAY_ONLY: confirm,
|
||||
hci.IoCapability.DISPLAY_YES_NO: confirm,
|
||||
hci.IoCapability.KEYBOARD_ONLY: auto_confirm,
|
||||
hci.IoCapability.NO_INPUT_NO_OUTPUT: auto_confirm,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5728,9 +5833,7 @@ class Device(utils.CompositeEventEmitter):
|
||||
|
||||
async def reply() -> None:
|
||||
try:
|
||||
if await utils.cancel_on_event(
|
||||
connection, Connection.EVENT_DISCONNECTION, method()
|
||||
):
|
||||
if await connection.cancel_on_disconnection(method()):
|
||||
await self.host.send_command(
|
||||
hci.HCI_User_Confirmation_Request_Reply_Command(
|
||||
bd_addr=connection.peer_address
|
||||
@@ -5757,10 +5860,8 @@ class Device(utils.CompositeEventEmitter):
|
||||
|
||||
async def reply() -> None:
|
||||
try:
|
||||
number = await utils.cancel_on_event(
|
||||
connection,
|
||||
Connection.EVENT_DISCONNECTION,
|
||||
pairing_config.delegate.get_number(),
|
||||
number = await connection.cancel_on_disconnection(
|
||||
pairing_config.delegate.get_number()
|
||||
)
|
||||
if number is not None:
|
||||
await self.host.send_command(
|
||||
@@ -5780,6 +5881,19 @@ class Device(utils.CompositeEventEmitter):
|
||||
|
||||
utils.AsyncRunner.spawn(reply())
|
||||
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@with_connection_from_handle
|
||||
def on_mode_change(
|
||||
self, connection: Connection, status: int, current_mode: int, interval: int
|
||||
):
|
||||
if status == hci.HCI_SUCCESS:
|
||||
connection.classic_mode = current_mode
|
||||
connection.classic_interval = interval
|
||||
connection.emit(connection.EVENT_MODE_CHANGE)
|
||||
else:
|
||||
connection.emit(connection.EVENT_MODE_CHANGE_FAILURE, status)
|
||||
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@with_connection_from_address
|
||||
@@ -5790,13 +5904,11 @@ class Device(utils.CompositeEventEmitter):
|
||||
io_capability = pairing_config.delegate.classic_io_capability
|
||||
|
||||
# Respond
|
||||
if io_capability == hci.HCI_KEYBOARD_ONLY_IO_CAPABILITY:
|
||||
if io_capability == hci.IoCapability.KEYBOARD_ONLY:
|
||||
# Ask the user to enter a string
|
||||
async def get_pin_code():
|
||||
pin_code = await utils.cancel_on_event(
|
||||
connection,
|
||||
Connection.EVENT_DISCONNECTION,
|
||||
pairing_config.delegate.get_string(16),
|
||||
pin_code = await connection.cancel_on_disconnection(
|
||||
pairing_config.delegate.get_string(16)
|
||||
)
|
||||
|
||||
if pin_code is not None:
|
||||
@@ -5834,10 +5946,8 @@ class Device(utils.CompositeEventEmitter):
|
||||
pairing_config = self.pairing_config_factory(connection)
|
||||
|
||||
# Show the passkey to the user
|
||||
utils.cancel_on_event(
|
||||
connection,
|
||||
Connection.EVENT_DISCONNECTION,
|
||||
pairing_config.delegate.display_number(passkey, digits=6),
|
||||
connection.cancel_on_disconnection(
|
||||
pairing_config.delegate.display_number(passkey, digits=6)
|
||||
)
|
||||
|
||||
# [Classic only]
|
||||
@@ -5924,24 +6034,63 @@ class Device(utils.CompositeEventEmitter):
|
||||
f'cis_id=[0x{cis_id:02X}] ***'
|
||||
)
|
||||
# LE_CIS_Established event doesn't provide info, so we must store them here.
|
||||
self.cis_links[cis_handle] = CisLink(
|
||||
cis_link = CisLink(
|
||||
device=self,
|
||||
acl_connection=acl_connection,
|
||||
handle=cis_handle,
|
||||
cig_id=cig_id,
|
||||
cis_id=cis_id,
|
||||
)
|
||||
self.emit(self.EVENT_CIS_REQUEST, acl_connection, cis_handle, cig_id, cis_id)
|
||||
self.cis_links[cis_handle] = cis_link
|
||||
acl_connection.emit(acl_connection.EVENT_CIS_REQUEST, cis_link)
|
||||
self.emit(self.EVENT_CIS_REQUEST, cis_link)
|
||||
|
||||
# [LE only]
|
||||
@host_event_handler
|
||||
@utils.experimental('Only for testing')
|
||||
def on_cis_establishment(self, cis_handle: int) -> None:
|
||||
def on_cis_establishment(
|
||||
self,
|
||||
cis_handle: int,
|
||||
cig_sync_delay: int,
|
||||
cis_sync_delay: int,
|
||||
transport_latency_c_to_p: int,
|
||||
transport_latency_p_to_c: int,
|
||||
phy_c_to_p: int,
|
||||
phy_p_to_c: int,
|
||||
nse: int,
|
||||
bn_c_to_p: int,
|
||||
bn_p_to_c: int,
|
||||
ft_c_to_p: int,
|
||||
ft_p_to_c: int,
|
||||
max_pdu_c_to_p: int,
|
||||
max_pdu_p_to_c: int,
|
||||
iso_interval: int,
|
||||
) -> None:
|
||||
if cis_handle not in self.cis_links:
|
||||
logger.warning("CIS link not found")
|
||||
return
|
||||
|
||||
cis_link = self.cis_links[cis_handle]
|
||||
cis_link.state = CisLink.State.ESTABLISHED
|
||||
|
||||
assert cis_link.acl_connection
|
||||
|
||||
# Update the CIS
|
||||
cis_link.cig_sync_delay = cig_sync_delay
|
||||
cis_link.cis_sync_delay = cis_sync_delay
|
||||
cis_link.transport_latency_c_to_p = transport_latency_c_to_p
|
||||
cis_link.transport_latency_p_to_c = transport_latency_p_to_c
|
||||
cis_link.phy_c_to_p = hci.Phy(phy_c_to_p)
|
||||
cis_link.phy_p_to_c = hci.Phy(phy_p_to_c)
|
||||
cis_link.nse = nse
|
||||
cis_link.bn_c_to_p = bn_c_to_p
|
||||
cis_link.bn_p_to_c = bn_p_to_c
|
||||
cis_link.ft_c_to_p = ft_c_to_p
|
||||
cis_link.ft_p_to_c = ft_p_to_c
|
||||
cis_link.max_pdu_c_to_p = max_pdu_c_to_p
|
||||
cis_link.max_pdu_p_to_c = max_pdu_p_to_c
|
||||
cis_link.iso_interval = iso_interval * 1.25
|
||||
|
||||
logger.debug(
|
||||
f'*** CIS Establishment '
|
||||
f'{cis_link.acl_connection.peer_address}, '
|
||||
@@ -5951,16 +6100,27 @@ class Device(utils.CompositeEventEmitter):
|
||||
)
|
||||
|
||||
cis_link.emit(cis_link.EVENT_ESTABLISHMENT)
|
||||
cis_link.acl_connection.emit(
|
||||
cis_link.acl_connection.EVENT_CIS_ESTABLISHMENT, cis_link
|
||||
)
|
||||
self.emit(self.EVENT_CIS_ESTABLISHMENT, cis_link)
|
||||
|
||||
# [LE only]
|
||||
@host_event_handler
|
||||
@utils.experimental('Only for testing')
|
||||
def on_cis_establishment_failure(self, cis_handle: int, status: int) -> None:
|
||||
if (cis_link := self.cis_links.pop(cis_handle, None)) is None:
|
||||
logger.warning("CIS link not found")
|
||||
return
|
||||
|
||||
logger.debug(f'*** CIS Establishment Failure: cis=[0x{cis_handle:04X}] ***')
|
||||
if cis_link := self.cis_links.pop(cis_handle):
|
||||
cis_link.emit(cis_link.EVENT_ESTABLISHMENT_FAILURE, status)
|
||||
self.emit(self.EVENT_CIS_ESTABLISHMENT_FAILURE, cis_handle, status)
|
||||
cis_link.emit(cis_link.EVENT_ESTABLISHMENT_FAILURE, status)
|
||||
cis_link.acl_connection.emit(
|
||||
cis_link.acl_connection.EVENT_CIS_ESTABLISHMENT_FAILURE,
|
||||
cis_link,
|
||||
status,
|
||||
)
|
||||
self.emit(self.EVENT_CIS_ESTABLISHMENT_FAILURE, cis_link, status)
|
||||
|
||||
# [LE only]
|
||||
@host_event_handler
|
||||
@@ -5974,7 +6134,7 @@ class Device(utils.CompositeEventEmitter):
|
||||
@host_event_handler
|
||||
@with_connection_from_handle
|
||||
def on_connection_encryption_change(
|
||||
self, connection, encryption, encryption_key_size
|
||||
self, connection: Connection, encryption: int, encryption_key_size: int
|
||||
):
|
||||
logger.debug(
|
||||
f'*** Connection Encryption Change: [0x{connection.handle:04X}] '
|
||||
@@ -5987,14 +6147,14 @@ class Device(utils.CompositeEventEmitter):
|
||||
if (
|
||||
not connection.authenticated
|
||||
and connection.transport == PhysicalTransport.BR_EDR
|
||||
and encryption == hci.HCI_Encryption_Change_Event.AES_CCM
|
||||
and encryption == hci.HCI_Encryption_Change_Event.Enabled.AES_CCM
|
||||
):
|
||||
connection.authenticated = True
|
||||
connection.sc = True
|
||||
if (
|
||||
not connection.authenticated
|
||||
and connection.transport == PhysicalTransport.LE
|
||||
and encryption == hci.HCI_Encryption_Change_Event.E0_OR_AES_CCM
|
||||
and encryption == hci.HCI_Encryption_Change_Event.Enabled.E0_OR_AES_CCM
|
||||
):
|
||||
connection.authenticated = True
|
||||
connection.sc = True
|
||||
@@ -6021,13 +6181,19 @@ class Device(utils.CompositeEventEmitter):
|
||||
|
||||
@host_event_handler
|
||||
@with_connection_from_handle
|
||||
def on_connection_parameters_update(self, connection, connection_parameters):
|
||||
def on_connection_parameters_update(
|
||||
self, connection: Connection, connection_parameters: core.ConnectionParameters
|
||||
):
|
||||
logger.debug(
|
||||
f'*** Connection Parameters Update: [0x{connection.handle:04X}] '
|
||||
f'{connection.peer_address} as {connection.role_name}, '
|
||||
f'{connection_parameters}'
|
||||
)
|
||||
connection.parameters = connection_parameters
|
||||
connection.parameters = Connection.Parameters(
|
||||
connection_parameters.connection_interval * 1.25,
|
||||
connection_parameters.peripheral_latency,
|
||||
connection_parameters.supervision_timeout * 10.0,
|
||||
)
|
||||
connection.emit(connection.EVENT_CONNECTION_PARAMETERS_UPDATE)
|
||||
|
||||
@host_event_handler
|
||||
|
||||
@@ -23,7 +23,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import pathlib
|
||||
import platform
|
||||
from typing import Dict, Iterable, Optional, Type, TYPE_CHECKING
|
||||
from typing import Iterable, Optional, TYPE_CHECKING
|
||||
|
||||
from bumble.drivers import rtk, intel
|
||||
from bumble.drivers.common import Driver
|
||||
@@ -45,7 +45,7 @@ async def get_driver_for_host(host: Host) -> Optional[Driver]:
|
||||
found.
|
||||
If a "driver" HCI metadata entry is present, only that driver class will be probed.
|
||||
"""
|
||||
driver_classes: Dict[str, Type[Driver]] = {"rtk": rtk.Driver, "intel": intel.Driver}
|
||||
driver_classes: dict[str, type[Driver]] = {"rtk": rtk.Driver, "intel": intel.Driver}
|
||||
probe_list: Iterable[str]
|
||||
if driver_name := host.hci_metadata.get("driver"):
|
||||
# Only probe a single driver
|
||||
|
||||
@@ -20,8 +20,6 @@ Common types for drivers.
|
||||
# -----------------------------------------------------------------------------
|
||||
import abc
|
||||
|
||||
from bumble import core
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
|
||||
@@ -28,7 +28,7 @@ import os
|
||||
import pathlib
|
||||
import platform
|
||||
import struct
|
||||
from typing import Any, Deque, Optional, TYPE_CHECKING
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
|
||||
from bumble import core
|
||||
from bumble.drivers import common
|
||||
@@ -90,54 +90,51 @@ HCI_INTEL_WRITE_BOOT_PARAMS_COMMAND = hci.hci_vendor_command_op_code(0x000E)
|
||||
hci.HCI_Command.register_commands(globals())
|
||||
|
||||
|
||||
@hci.HCI_Command.command(
|
||||
fields=[
|
||||
("param0", 1),
|
||||
],
|
||||
return_parameters_fields=[
|
||||
@hci.HCI_Command.command
|
||||
@dataclasses.dataclass
|
||||
class HCI_Intel_Read_Version_Command(hci.HCI_Command):
|
||||
param0: int = dataclasses.field(metadata=hci.metadata(1))
|
||||
|
||||
return_parameters_fields = [
|
||||
("status", hci.STATUS_SPEC),
|
||||
("tlv", "*"),
|
||||
],
|
||||
)
|
||||
class HCI_Intel_Read_Version_Command(hci.HCI_Command):
|
||||
pass
|
||||
]
|
||||
|
||||
|
||||
@hci.HCI_Command.command(
|
||||
fields=[("data_type", 1), ("data", "*")],
|
||||
return_parameters_fields=[
|
||||
("status", 1),
|
||||
],
|
||||
)
|
||||
@hci.HCI_Command.command
|
||||
@dataclasses.dataclass
|
||||
class Hci_Intel_Secure_Send_Command(hci.HCI_Command):
|
||||
pass
|
||||
data_type: int = dataclasses.field(metadata=hci.metadata(1))
|
||||
data: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
||||
|
||||
return_parameters_fields = [
|
||||
("status", 1),
|
||||
]
|
||||
|
||||
|
||||
@hci.HCI_Command.command(
|
||||
fields=[
|
||||
("reset_type", 1),
|
||||
("patch_enable", 1),
|
||||
("ddc_reload", 1),
|
||||
("boot_option", 1),
|
||||
("boot_address", 4),
|
||||
],
|
||||
return_parameters_fields=[
|
||||
("data", "*"),
|
||||
],
|
||||
)
|
||||
@hci.HCI_Command.command
|
||||
@dataclasses.dataclass
|
||||
class HCI_Intel_Reset_Command(hci.HCI_Command):
|
||||
pass
|
||||
reset_type: int = dataclasses.field(metadata=hci.metadata(1))
|
||||
patch_enable: int = dataclasses.field(metadata=hci.metadata(1))
|
||||
ddc_reload: int = dataclasses.field(metadata=hci.metadata(1))
|
||||
boot_option: int = dataclasses.field(metadata=hci.metadata(1))
|
||||
boot_address: int = dataclasses.field(metadata=hci.metadata(4))
|
||||
|
||||
return_parameters_fields = [
|
||||
("data", "*"),
|
||||
]
|
||||
|
||||
|
||||
@hci.HCI_Command.command(
|
||||
fields=[("data", "*")],
|
||||
return_parameters_fields=[
|
||||
@hci.HCI_Command.command
|
||||
@dataclasses.dataclass
|
||||
class Hci_Intel_Write_Device_Config_Command(hci.HCI_Command):
|
||||
data: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
||||
|
||||
return_parameters_fields = [
|
||||
("status", hci.STATUS_SPEC),
|
||||
("params", "*"),
|
||||
],
|
||||
)
|
||||
class Hci_Intel_Write_Device_Config_Command(hci.HCI_Command):
|
||||
pass
|
||||
]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -348,7 +345,7 @@ class Driver(common.Driver):
|
||||
def __init__(self, host: Host) -> None:
|
||||
self.host = host
|
||||
self.max_in_flight_firmware_load_commands = 1
|
||||
self.pending_firmware_load_commands: Deque[hci.HCI_Command] = (
|
||||
self.pending_firmware_load_commands: collections.deque[hci.HCI_Command] = (
|
||||
collections.deque()
|
||||
)
|
||||
self.can_send_firmware_load_command = asyncio.Event()
|
||||
|
||||
@@ -20,7 +20,7 @@ Based on various online bits of information, including the Linux kernel.
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
import asyncio
|
||||
import enum
|
||||
import logging
|
||||
@@ -29,19 +29,11 @@ import os
|
||||
import pathlib
|
||||
import platform
|
||||
import struct
|
||||
from typing import Tuple
|
||||
import weakref
|
||||
|
||||
|
||||
from bumble import core
|
||||
from bumble.hci import (
|
||||
hci_vendor_command_op_code,
|
||||
STATUS_SPEC,
|
||||
HCI_SUCCESS,
|
||||
HCI_Command,
|
||||
HCI_Reset_Command,
|
||||
HCI_Read_Local_Version_Information_Command,
|
||||
)
|
||||
from bumble import hci
|
||||
from bumble.drivers import common
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -183,27 +175,29 @@ RTK_USB_PRODUCTS = {
|
||||
# -----------------------------------------------------------------------------
|
||||
# HCI Commands
|
||||
# -----------------------------------------------------------------------------
|
||||
HCI_RTK_READ_ROM_VERSION_COMMAND = hci_vendor_command_op_code(0x6D)
|
||||
HCI_RTK_DOWNLOAD_COMMAND = hci_vendor_command_op_code(0x20)
|
||||
HCI_RTK_DROP_FIRMWARE_COMMAND = hci_vendor_command_op_code(0x66)
|
||||
HCI_Command.register_commands(globals())
|
||||
HCI_RTK_READ_ROM_VERSION_COMMAND = hci.hci_vendor_command_op_code(0x6D)
|
||||
HCI_RTK_DOWNLOAD_COMMAND = hci.hci_vendor_command_op_code(0x20)
|
||||
HCI_RTK_DROP_FIRMWARE_COMMAND = hci.hci_vendor_command_op_code(0x66)
|
||||
hci.HCI_Command.register_commands(globals())
|
||||
|
||||
|
||||
@HCI_Command.command(return_parameters_fields=[("status", STATUS_SPEC), ("version", 1)])
|
||||
class HCI_RTK_Read_ROM_Version_Command(HCI_Command):
|
||||
pass
|
||||
@hci.HCI_Command.command
|
||||
@dataclass
|
||||
class HCI_RTK_Read_ROM_Version_Command(hci.HCI_Command):
|
||||
return_parameters_fields = [("status", hci.STATUS_SPEC), ("version", 1)]
|
||||
|
||||
|
||||
@HCI_Command.command(
|
||||
fields=[("index", 1), ("payload", RTK_FRAGMENT_LENGTH)],
|
||||
return_parameters_fields=[("status", STATUS_SPEC), ("index", 1)],
|
||||
)
|
||||
class HCI_RTK_Download_Command(HCI_Command):
|
||||
pass
|
||||
@hci.HCI_Command.command
|
||||
@dataclass
|
||||
class HCI_RTK_Download_Command(hci.HCI_Command):
|
||||
index: int = field(metadata=hci.metadata(1))
|
||||
payload: bytes = field(metadata=hci.metadata(RTK_FRAGMENT_LENGTH))
|
||||
return_parameters_fields = [("status", hci.STATUS_SPEC), ("index", 1)]
|
||||
|
||||
|
||||
@HCI_Command.command()
|
||||
class HCI_RTK_Drop_Firmware_Command(HCI_Command):
|
||||
@hci.HCI_Command.command
|
||||
@dataclass
|
||||
class HCI_RTK_Drop_Firmware_Command(hci.HCI_Command):
|
||||
pass
|
||||
|
||||
|
||||
@@ -294,7 +288,7 @@ class Driver(common.Driver):
|
||||
@dataclass
|
||||
class DriverInfo:
|
||||
rom: int
|
||||
hci: Tuple[int, int]
|
||||
hci: tuple[int, int]
|
||||
config_needed: bool
|
||||
has_rom_version: bool
|
||||
has_msft_ext: bool = False
|
||||
@@ -499,17 +493,17 @@ class Driver(common.Driver):
|
||||
async def driver_info_for_host(cls, host):
|
||||
try:
|
||||
await host.send_command(
|
||||
HCI_Reset_Command(),
|
||||
hci.HCI_Reset_Command(),
|
||||
check_result=True,
|
||||
response_timeout=cls.POST_RESET_DELAY,
|
||||
)
|
||||
host.ready = True # Needed to let the host know the controller is ready.
|
||||
except asyncio.exceptions.TimeoutError:
|
||||
logger.warning("timeout waiting for hci reset, retrying")
|
||||
await host.send_command(HCI_Reset_Command(), check_result=True)
|
||||
await host.send_command(hci.HCI_Reset_Command(), check_result=True)
|
||||
host.ready = True
|
||||
|
||||
command = HCI_Read_Local_Version_Information_Command()
|
||||
command = hci.HCI_Read_Local_Version_Information_Command()
|
||||
response = await host.send_command(command, check_result=True)
|
||||
if response.command_opcode != command.op_code:
|
||||
logger.error("failed to probe local version information")
|
||||
@@ -596,7 +590,7 @@ class Driver(common.Driver):
|
||||
response = await self.host.send_command(
|
||||
HCI_RTK_Read_ROM_Version_Command(), check_result=True
|
||||
)
|
||||
if response.return_parameters.status != HCI_SUCCESS:
|
||||
if response.return_parameters.status != hci.HCI_SUCCESS:
|
||||
logger.warning("can't get ROM version")
|
||||
return
|
||||
rom_version = response.return_parameters.version
|
||||
@@ -634,9 +628,8 @@ class Driver(common.Driver):
|
||||
fragment = payload[fragment_offset : fragment_offset + RTK_FRAGMENT_LENGTH]
|
||||
logger.debug(f"downloading fragment {fragment_index}")
|
||||
await self.host.send_command(
|
||||
HCI_RTK_Download_Command(
|
||||
index=download_index, payload=fragment, check_result=True
|
||||
)
|
||||
HCI_RTK_Download_Command(index=download_index, payload=fragment),
|
||||
check_result=True,
|
||||
)
|
||||
|
||||
logger.debug("download complete!")
|
||||
@@ -645,7 +638,7 @@ class Driver(common.Driver):
|
||||
response = await self.host.send_command(
|
||||
HCI_RTK_Read_ROM_Version_Command(), check_result=True
|
||||
)
|
||||
if response.return_parameters.status != HCI_SUCCESS:
|
||||
if response.return_parameters.status != hci.HCI_SUCCESS:
|
||||
logger.warning("can't get ROM version")
|
||||
else:
|
||||
rom_version = response.return_parameters.version
|
||||
@@ -668,7 +661,7 @@ class Driver(common.Driver):
|
||||
|
||||
async def init_controller(self):
|
||||
await self.download_firmware()
|
||||
await self.host.send_command(HCI_Reset_Command(), check_result=True)
|
||||
await self.host.send_command(hci.HCI_Reset_Command(), check_result=True)
|
||||
logger.info(f"loaded FW image {self.driver_info.fw_name}")
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import enum
|
||||
import functools
|
||||
import logging
|
||||
import struct
|
||||
from typing import Iterable, List, Optional, Sequence, TypeVar, Union
|
||||
from typing import Iterable, Optional, Sequence, TypeVar, Union
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.core import BaseBumbleError, UUID
|
||||
@@ -350,8 +350,8 @@ class Service(Attribute):
|
||||
'''
|
||||
|
||||
uuid: UUID
|
||||
characteristics: List[Characteristic]
|
||||
included_services: List[Service]
|
||||
characteristics: list[Characteristic]
|
||||
included_services: list[Service]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -474,7 +474,7 @@ class Characteristic(Attribute[_T]):
|
||||
# The check for `p.name is not None` here is needed because for InFlag
|
||||
# enums, the .name property can be None, when the enum value is 0,
|
||||
# so the type hint for .name is Optional[str].
|
||||
enum_list: List[str] = [p.name for p in cls if p.name is not None]
|
||||
enum_list: list[str] = [p.name for p in cls if p.name is not None]
|
||||
enum_list_str = ",".join(enum_list)
|
||||
raise TypeError(
|
||||
f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by , or |: {enum_list_str}\nGot: {properties_str}"
|
||||
|
||||
@@ -28,7 +28,6 @@ from typing import (
|
||||
Iterable,
|
||||
Literal,
|
||||
Optional,
|
||||
Type,
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
@@ -270,7 +269,7 @@ class SerializableCharacteristicAdapter(CharacteristicAdapter[_T2]):
|
||||
`to_bytes` and `__bytes__` methods, respectively.
|
||||
'''
|
||||
|
||||
def __init__(self, characteristic: Characteristic, cls: Type[_T2]) -> None:
|
||||
def __init__(self, characteristic: Characteristic, cls: type[_T2]) -> None:
|
||||
super().__init__(characteristic)
|
||||
self.cls = cls
|
||||
|
||||
@@ -289,7 +288,7 @@ class SerializableCharacteristicProxyAdapter(CharacteristicProxyAdapter[_T2]):
|
||||
'''
|
||||
|
||||
def __init__(
|
||||
self, characteristic_proxy: CharacteristicProxy, cls: Type[_T2]
|
||||
self, characteristic_proxy: CharacteristicProxy, cls: type[_T2]
|
||||
) -> None:
|
||||
super().__init__(characteristic_proxy)
|
||||
self.cls = cls
|
||||
@@ -311,7 +310,7 @@ class EnumCharacteristicAdapter(CharacteristicAdapter[_T3]):
|
||||
def __init__(
|
||||
self,
|
||||
characteristic: Characteristic,
|
||||
cls: Type[_T3],
|
||||
cls: type[_T3],
|
||||
length: int,
|
||||
byteorder: Literal['little', 'big'] = 'little',
|
||||
):
|
||||
@@ -347,7 +346,7 @@ class EnumCharacteristicProxyAdapter(CharacteristicProxyAdapter[_T3]):
|
||||
def __init__(
|
||||
self,
|
||||
characteristic_proxy: CharacteristicProxy,
|
||||
cls: Type[_T3],
|
||||
cls: type[_T3],
|
||||
length: int,
|
||||
byteorder: Literal['little', 'big'] = 'little',
|
||||
):
|
||||
|
||||
@@ -31,15 +31,10 @@ from datetime import datetime
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Generic,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Set,
|
||||
Tuple,
|
||||
Union,
|
||||
Type,
|
||||
TypeVar,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
@@ -149,8 +144,8 @@ class AttributeProxy(utils.EventEmitter, Generic[_T]):
|
||||
|
||||
class ServiceProxy(AttributeProxy):
|
||||
uuid: UUID
|
||||
characteristics: List[CharacteristicProxy[bytes]]
|
||||
included_services: List[ServiceProxy]
|
||||
characteristics: list[CharacteristicProxy[bytes]]
|
||||
included_services: list[ServiceProxy]
|
||||
|
||||
@staticmethod
|
||||
def from_client(service_class, client: Client, service_uuid: UUID):
|
||||
@@ -199,8 +194,8 @@ class ServiceProxy(AttributeProxy):
|
||||
|
||||
class CharacteristicProxy(AttributeProxy[_T]):
|
||||
properties: Characteristic.Properties
|
||||
descriptors: List[DescriptorProxy]
|
||||
subscribers: Dict[Any, Callable[[_T], Any]]
|
||||
descriptors: list[DescriptorProxy]
|
||||
subscribers: dict[Any, Callable[[_T], Any]]
|
||||
|
||||
EVENT_UPDATE = "update"
|
||||
|
||||
@@ -277,7 +272,7 @@ class ProfileServiceProxy:
|
||||
Base class for profile-specific service proxies
|
||||
'''
|
||||
|
||||
SERVICE_CLASS: Type[TemplateService]
|
||||
SERVICE_CLASS: type[TemplateService]
|
||||
|
||||
@classmethod
|
||||
def from_client(cls, client: Client) -> Optional[ProfileServiceProxy]:
|
||||
@@ -288,13 +283,13 @@ class ProfileServiceProxy:
|
||||
# GATT Client
|
||||
# -----------------------------------------------------------------------------
|
||||
class Client:
|
||||
services: List[ServiceProxy]
|
||||
cached_values: Dict[int, Tuple[datetime, bytes]]
|
||||
notification_subscribers: Dict[
|
||||
int, Set[Union[CharacteristicProxy, Callable[[bytes], Any]]]
|
||||
services: list[ServiceProxy]
|
||||
cached_values: dict[int, tuple[datetime, bytes]]
|
||||
notification_subscribers: dict[
|
||||
int, set[Union[CharacteristicProxy, Callable[[bytes], Any]]]
|
||||
]
|
||||
indication_subscribers: Dict[
|
||||
int, Set[Union[CharacteristicProxy, Callable[[bytes], Any]]]
|
||||
indication_subscribers: dict[
|
||||
int, set[Union[CharacteristicProxy, Callable[[bytes], Any]]]
|
||||
]
|
||||
pending_response: Optional[asyncio.futures.Future[ATT_PDU]]
|
||||
pending_request: Optional[ATT_PDU]
|
||||
@@ -379,12 +374,12 @@ class Client:
|
||||
|
||||
return self.connection.att_mtu
|
||||
|
||||
def get_services_by_uuid(self, uuid: UUID) -> List[ServiceProxy]:
|
||||
def get_services_by_uuid(self, uuid: UUID) -> list[ServiceProxy]:
|
||||
return [service for service in self.services if service.uuid == uuid]
|
||||
|
||||
def get_characteristics_by_uuid(
|
||||
self, uuid: UUID, service: Optional[ServiceProxy] = None
|
||||
) -> List[CharacteristicProxy[bytes]]:
|
||||
) -> list[CharacteristicProxy[bytes]]:
|
||||
services = [service] if service else self.services
|
||||
return [
|
||||
c
|
||||
@@ -395,8 +390,8 @@ class Client:
|
||||
def get_attribute_grouping(self, attribute_handle: int) -> Optional[
|
||||
Union[
|
||||
ServiceProxy,
|
||||
Tuple[ServiceProxy, CharacteristicProxy],
|
||||
Tuple[ServiceProxy, CharacteristicProxy, DescriptorProxy],
|
||||
tuple[ServiceProxy, CharacteristicProxy],
|
||||
tuple[ServiceProxy, CharacteristicProxy, DescriptorProxy],
|
||||
]
|
||||
]:
|
||||
"""
|
||||
@@ -429,7 +424,7 @@ class Client:
|
||||
if not already_known:
|
||||
self.services.append(service)
|
||||
|
||||
async def discover_services(self, uuids: Iterable[UUID] = ()) -> List[ServiceProxy]:
|
||||
async def discover_services(self, uuids: Iterable[UUID] = ()) -> list[ServiceProxy]:
|
||||
'''
|
||||
See Vol 3, Part G - 4.4.1 Discover All Primary Services
|
||||
'''
|
||||
@@ -501,7 +496,7 @@ class Client:
|
||||
|
||||
return services
|
||||
|
||||
async def discover_service(self, uuid: Union[str, UUID]) -> List[ServiceProxy]:
|
||||
async def discover_service(self, uuid: Union[str, UUID]) -> list[ServiceProxy]:
|
||||
'''
|
||||
See Vol 3, Part G - 4.4.2 Discover Primary Service by Service UUID
|
||||
'''
|
||||
@@ -572,7 +567,7 @@ class Client:
|
||||
|
||||
async def discover_included_services(
|
||||
self, service: ServiceProxy
|
||||
) -> List[ServiceProxy]:
|
||||
) -> list[ServiceProxy]:
|
||||
'''
|
||||
See Vol 3, Part G - 4.5.1 Find Included Services
|
||||
'''
|
||||
@@ -580,7 +575,7 @@ class Client:
|
||||
starting_handle = service.handle
|
||||
ending_handle = service.end_group_handle
|
||||
|
||||
included_services: List[ServiceProxy] = []
|
||||
included_services: list[ServiceProxy] = []
|
||||
while starting_handle <= ending_handle:
|
||||
response = await self.send_request(
|
||||
ATT_Read_By_Type_Request(
|
||||
@@ -636,7 +631,7 @@ class Client:
|
||||
|
||||
async def discover_characteristics(
|
||||
self, uuids, service: Optional[ServiceProxy]
|
||||
) -> List[CharacteristicProxy[bytes]]:
|
||||
) -> list[CharacteristicProxy[bytes]]:
|
||||
'''
|
||||
See Vol 3, Part G - 4.6.1 Discover All Characteristics of a Service and 4.6.2
|
||||
Discover Characteristics by UUID
|
||||
@@ -649,12 +644,12 @@ class Client:
|
||||
services = [service] if service else self.services
|
||||
|
||||
# Perform characteristic discovery for each service
|
||||
discovered_characteristics: List[CharacteristicProxy[bytes]] = []
|
||||
discovered_characteristics: list[CharacteristicProxy[bytes]] = []
|
||||
for service in services:
|
||||
starting_handle = service.handle
|
||||
ending_handle = service.end_group_handle
|
||||
|
||||
characteristics: List[CharacteristicProxy[bytes]] = []
|
||||
characteristics: list[CharacteristicProxy[bytes]] = []
|
||||
while starting_handle <= ending_handle:
|
||||
response = await self.send_request(
|
||||
ATT_Read_By_Type_Request(
|
||||
@@ -725,7 +720,7 @@ class Client:
|
||||
characteristic: Optional[CharacteristicProxy] = None,
|
||||
start_handle: Optional[int] = None,
|
||||
end_handle: Optional[int] = None,
|
||||
) -> List[DescriptorProxy]:
|
||||
) -> list[DescriptorProxy]:
|
||||
'''
|
||||
See Vol 3, Part G - 4.7.1 Discover All Characteristic Descriptors
|
||||
'''
|
||||
@@ -738,7 +733,7 @@ class Client:
|
||||
else:
|
||||
return []
|
||||
|
||||
descriptors: List[DescriptorProxy] = []
|
||||
descriptors: list[DescriptorProxy] = []
|
||||
while starting_handle <= ending_handle:
|
||||
response = await self.send_request(
|
||||
ATT_Find_Information_Request(
|
||||
@@ -787,7 +782,7 @@ class Client:
|
||||
|
||||
return descriptors
|
||||
|
||||
async def discover_attributes(self) -> List[AttributeProxy[bytes]]:
|
||||
async def discover_attributes(self) -> list[AttributeProxy[bytes]]:
|
||||
'''
|
||||
Discover all attributes, regardless of type
|
||||
'''
|
||||
@@ -1002,7 +997,7 @@ class Client:
|
||||
|
||||
async def read_characteristics_by_uuid(
|
||||
self, uuid: UUID, service: Optional[ServiceProxy]
|
||||
) -> List[bytes]:
|
||||
) -> list[bytes]:
|
||||
'''
|
||||
See Vol 3, Part G - 4.8.2 Read Using Characteristic UUID
|
||||
'''
|
||||
|
||||
@@ -29,13 +29,9 @@ import logging
|
||||
from collections import defaultdict
|
||||
import struct
|
||||
from typing import (
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Type,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
@@ -103,10 +99,10 @@ GATT_SERVER_DEFAULT_MAX_MTU = 517
|
||||
# GATT Server
|
||||
# -----------------------------------------------------------------------------
|
||||
class Server(utils.EventEmitter):
|
||||
attributes: List[Attribute]
|
||||
services: List[Service]
|
||||
attributes_by_handle: Dict[int, Attribute]
|
||||
subscribers: Dict[int, Dict[int, bytes]]
|
||||
attributes: list[Attribute]
|
||||
services: list[Service]
|
||||
attributes_by_handle: dict[int, Attribute]
|
||||
subscribers: dict[int, dict[int, bytes]]
|
||||
indication_semaphores: defaultdict[int, asyncio.Semaphore]
|
||||
pending_confirmations: defaultdict[int, Optional[asyncio.futures.Future]]
|
||||
|
||||
@@ -136,7 +132,7 @@ class Server(utils.EventEmitter):
|
||||
def next_handle(self) -> int:
|
||||
return 1 + len(self.attributes)
|
||||
|
||||
def get_advertising_service_data(self) -> Dict[Attribute, bytes]:
|
||||
def get_advertising_service_data(self) -> dict[Attribute, bytes]:
|
||||
return {
|
||||
attribute: data
|
||||
for attribute in self.attributes
|
||||
@@ -160,7 +156,7 @@ class Server(utils.EventEmitter):
|
||||
AttributeGroupType = TypeVar('AttributeGroupType', Service, Characteristic)
|
||||
|
||||
def get_attribute_group(
|
||||
self, handle: int, group_type: Type[AttributeGroupType]
|
||||
self, handle: int, group_type: type[AttributeGroupType]
|
||||
) -> Optional[AttributeGroupType]:
|
||||
return next(
|
||||
(
|
||||
@@ -186,7 +182,7 @@ class Server(utils.EventEmitter):
|
||||
|
||||
def get_characteristic_attributes(
|
||||
self, service_uuid: UUID, characteristic_uuid: UUID
|
||||
) -> Optional[Tuple[CharacteristicDeclaration, Characteristic]]:
|
||||
) -> Optional[tuple[CharacteristicDeclaration, Characteristic]]:
|
||||
service_handle = self.get_service_attribute(service_uuid)
|
||||
if not service_handle:
|
||||
return None
|
||||
|
||||
5448
bumble/hci.py
5448
bumble/hci.py
File diff suppressed because it is too large
Load Diff
@@ -34,9 +34,8 @@ from bumble.att import ATT_CID, ATT_PDU
|
||||
from bumble.smp import SMP_CID, SMP_Command
|
||||
from bumble.core import name_or_number
|
||||
from bumble.l2cap import (
|
||||
CommandCode,
|
||||
L2CAP_PDU,
|
||||
L2CAP_CONNECTION_REQUEST,
|
||||
L2CAP_CONNECTION_RESPONSE,
|
||||
L2CAP_SIGNALING_CID,
|
||||
L2CAP_LE_SIGNALING_CID,
|
||||
L2CAP_Control_Frame,
|
||||
@@ -106,14 +105,14 @@ class PacketTracer:
|
||||
self.analyzer.emit(control_frame)
|
||||
|
||||
# Check if this signals a new channel
|
||||
if control_frame.code == L2CAP_CONNECTION_REQUEST:
|
||||
if control_frame.code == CommandCode.L2CAP_CONNECTION_REQUEST:
|
||||
connection_request = cast(L2CAP_Connection_Request, control_frame)
|
||||
self.psms[connection_request.source_cid] = connection_request.psm
|
||||
elif control_frame.code == L2CAP_CONNECTION_RESPONSE:
|
||||
elif control_frame.code == CommandCode.L2CAP_CONNECTION_RESPONSE:
|
||||
connection_response = cast(L2CAP_Connection_Response, control_frame)
|
||||
if (
|
||||
connection_response.result
|
||||
== L2CAP_Connection_Response.CONNECTION_SUCCESSFUL
|
||||
== L2CAP_Connection_Response.Result.CONNECTION_SUCCESSFUL
|
||||
):
|
||||
if self.peer and (
|
||||
psm := self.peer.psms.get(connection_response.source_cid)
|
||||
|
||||
@@ -26,14 +26,9 @@ import enum
|
||||
import traceback
|
||||
import re
|
||||
from typing import (
|
||||
Dict,
|
||||
List,
|
||||
Union,
|
||||
Set,
|
||||
Any,
|
||||
Optional,
|
||||
Type,
|
||||
Tuple,
|
||||
ClassVar,
|
||||
Iterable,
|
||||
TYPE_CHECKING,
|
||||
@@ -375,7 +370,7 @@ class CallLineIdentification:
|
||||
cli_validity: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def parse_from(cls: Type[Self], parameters: List[bytes]) -> Self:
|
||||
def parse_from(cls, parameters: list[bytes]) -> Self:
|
||||
return cls(
|
||||
number=parameters[0].decode(),
|
||||
type=int(parameters[1]),
|
||||
@@ -505,9 +500,9 @@ STATUS_CODES = {
|
||||
|
||||
@dataclasses.dataclass
|
||||
class HfConfiguration:
|
||||
supported_hf_features: List[HfFeature]
|
||||
supported_hf_indicators: List[HfIndicator]
|
||||
supported_audio_codecs: List[AudioCodec]
|
||||
supported_hf_features: list[HfFeature]
|
||||
supported_hf_indicators: list[HfIndicator]
|
||||
supported_audio_codecs: list[AudioCodec]
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -535,7 +530,7 @@ class AtResponse:
|
||||
parameters: list
|
||||
|
||||
@classmethod
|
||||
def parse_from(cls: Type[Self], buffer: bytearray) -> Self:
|
||||
def parse_from(cls: type[Self], buffer: bytearray) -> Self:
|
||||
code_and_parameters = buffer.split(b':')
|
||||
parameters = (
|
||||
code_and_parameters[1] if len(code_and_parameters) > 1 else bytearray()
|
||||
@@ -563,7 +558,7 @@ class AtCommand:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def parse_from(cls: Type[Self], buffer: bytearray) -> Self:
|
||||
def parse_from(cls: type[Self], buffer: bytearray) -> Self:
|
||||
if not (match := cls._PARSE_PATTERN.fullmatch(buffer.decode())):
|
||||
if buffer.startswith(b'ATA'):
|
||||
return cls(code='A', sub_code=AtCommand.SubCode.NONE, parameters=[])
|
||||
@@ -598,7 +593,7 @@ class AgIndicatorState:
|
||||
"""
|
||||
|
||||
indicator: AgIndicator
|
||||
supported_values: Set[int]
|
||||
supported_values: set[int]
|
||||
current_status: int
|
||||
index: Optional[int] = None
|
||||
enabled: bool = True
|
||||
@@ -616,14 +611,14 @@ class AgIndicatorState:
|
||||
return f'(\"{self.indicator.value}\",{supported_values_text})'
|
||||
|
||||
@classmethod
|
||||
def call(cls: Type[Self]) -> Self:
|
||||
def call(cls: type[Self]) -> Self:
|
||||
"""Default call indicator state."""
|
||||
return cls(
|
||||
indicator=AgIndicator.CALL, supported_values={0, 1}, current_status=0
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def callsetup(cls: Type[Self]) -> Self:
|
||||
def callsetup(cls: type[Self]) -> Self:
|
||||
"""Default callsetup indicator state."""
|
||||
return cls(
|
||||
indicator=AgIndicator.CALL_SETUP,
|
||||
@@ -632,7 +627,7 @@ class AgIndicatorState:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def callheld(cls: Type[Self]) -> Self:
|
||||
def callheld(cls: type[Self]) -> Self:
|
||||
"""Default call indicator state."""
|
||||
return cls(
|
||||
indicator=AgIndicator.CALL_HELD,
|
||||
@@ -641,14 +636,14 @@ class AgIndicatorState:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def service(cls: Type[Self]) -> Self:
|
||||
def service(cls: type[Self]) -> Self:
|
||||
"""Default service indicator state."""
|
||||
return cls(
|
||||
indicator=AgIndicator.SERVICE, supported_values={0, 1}, current_status=0
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def signal(cls: Type[Self]) -> Self:
|
||||
def signal(cls: type[Self]) -> Self:
|
||||
"""Default signal indicator state."""
|
||||
return cls(
|
||||
indicator=AgIndicator.SIGNAL,
|
||||
@@ -657,14 +652,14 @@ class AgIndicatorState:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def roam(cls: Type[Self]) -> Self:
|
||||
def roam(cls: type[Self]) -> Self:
|
||||
"""Default roam indicator state."""
|
||||
return cls(
|
||||
indicator=AgIndicator.CALL, supported_values={0, 1}, current_status=0
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def battchg(cls: Type[Self]) -> Self:
|
||||
def battchg(cls: type[Self]) -> Self:
|
||||
"""Default battery charge indicator state."""
|
||||
return cls(
|
||||
indicator=AgIndicator.BATTERY_CHARGE,
|
||||
@@ -732,13 +727,13 @@ class HfProtocol(utils.EventEmitter):
|
||||
"""Termination signal for run() loop."""
|
||||
|
||||
supported_hf_features: int
|
||||
supported_audio_codecs: List[AudioCodec]
|
||||
supported_audio_codecs: list[AudioCodec]
|
||||
|
||||
supported_ag_features: int
|
||||
supported_ag_call_hold_operations: List[CallHoldOperation]
|
||||
supported_ag_call_hold_operations: list[CallHoldOperation]
|
||||
|
||||
ag_indicators: List[AgIndicatorState]
|
||||
hf_indicators: Dict[HfIndicator, HfIndicatorState]
|
||||
ag_indicators: list[AgIndicatorState]
|
||||
hf_indicators: dict[HfIndicator, HfIndicatorState]
|
||||
|
||||
dlc: rfcomm.DLC
|
||||
command_lock: asyncio.Lock
|
||||
@@ -836,7 +831,7 @@ class HfProtocol(utils.EventEmitter):
|
||||
cmd: str,
|
||||
timeout: float = 1.0,
|
||||
response_type: AtResponseType = AtResponseType.NONE,
|
||||
) -> Union[None, AtResponse, List[AtResponse]]:
|
||||
) -> Union[None, AtResponse, list[AtResponse]]:
|
||||
"""
|
||||
Sends an AT command and wait for the peer response.
|
||||
Wait for the AT responses sent by the peer, to the status code.
|
||||
@@ -853,7 +848,7 @@ class HfProtocol(utils.EventEmitter):
|
||||
async with self.command_lock:
|
||||
logger.debug(f">>> {cmd}")
|
||||
self.dlc.write(cmd + '\r')
|
||||
responses: List[AtResponse] = []
|
||||
responses: list[AtResponse] = []
|
||||
|
||||
while True:
|
||||
result = await asyncio.wait_for(
|
||||
@@ -1073,7 +1068,7 @@ class HfProtocol(utils.EventEmitter):
|
||||
# code, with the value indicating (call=0).
|
||||
await self.execute_command("AT+CHUP")
|
||||
|
||||
async def query_current_calls(self) -> List[CallInfo]:
|
||||
async def query_current_calls(self) -> list[CallInfo]:
|
||||
"""4.32.1 Query List of Current Calls in AG.
|
||||
|
||||
Return:
|
||||
@@ -1204,27 +1199,27 @@ class AgProtocol(utils.EventEmitter):
|
||||
EVENT_MICROPHONE_VOLUME = "microphone_volume"
|
||||
|
||||
supported_hf_features: int
|
||||
supported_hf_indicators: Set[HfIndicator]
|
||||
supported_audio_codecs: List[AudioCodec]
|
||||
supported_hf_indicators: set[HfIndicator]
|
||||
supported_audio_codecs: list[AudioCodec]
|
||||
|
||||
supported_ag_features: int
|
||||
supported_ag_call_hold_operations: List[CallHoldOperation]
|
||||
supported_ag_call_hold_operations: list[CallHoldOperation]
|
||||
|
||||
ag_indicators: List[AgIndicatorState]
|
||||
ag_indicators: list[AgIndicatorState]
|
||||
hf_indicators: collections.OrderedDict[HfIndicator, HfIndicatorState]
|
||||
|
||||
dlc: rfcomm.DLC
|
||||
|
||||
read_buffer: bytearray
|
||||
active_codec: AudioCodec
|
||||
calls: List[CallInfo]
|
||||
calls: list[CallInfo]
|
||||
|
||||
indicator_report_enabled: bool
|
||||
inband_ringtone_enabled: bool
|
||||
cme_error_enabled: bool
|
||||
cli_notification_enabled: bool
|
||||
call_waiting_enabled: bool
|
||||
_remained_slc_setup_features: Set[HfFeature]
|
||||
_remained_slc_setup_features: set[HfFeature]
|
||||
|
||||
def __init__(self, dlc: rfcomm.DLC, configuration: AgConfiguration) -> None:
|
||||
super().__init__()
|
||||
@@ -1694,7 +1689,7 @@ def make_hf_sdp_records(
|
||||
rfcomm_channel: int,
|
||||
configuration: HfConfiguration,
|
||||
version: ProfileVersion = ProfileVersion.V1_8,
|
||||
) -> List[sdp.ServiceAttribute]:
|
||||
) -> list[sdp.ServiceAttribute]:
|
||||
"""
|
||||
Generates the SDP record for HFP Hands-Free support.
|
||||
|
||||
@@ -1780,7 +1775,7 @@ def make_ag_sdp_records(
|
||||
rfcomm_channel: int,
|
||||
configuration: AgConfiguration,
|
||||
version: ProfileVersion = ProfileVersion.V1_8,
|
||||
) -> List[sdp.ServiceAttribute]:
|
||||
) -> list[sdp.ServiceAttribute]:
|
||||
"""
|
||||
Generates the SDP record for HFP Audio-Gateway support.
|
||||
|
||||
@@ -1860,7 +1855,7 @@ def make_ag_sdp_records(
|
||||
|
||||
async def find_hf_sdp_record(
|
||||
connection: device.Connection,
|
||||
) -> Optional[Tuple[int, ProfileVersion, HfSdpFeature]]:
|
||||
) -> Optional[tuple[int, ProfileVersion, HfSdpFeature]]:
|
||||
"""Searches a Hands-Free SDP record from remote device.
|
||||
|
||||
Args:
|
||||
@@ -1912,7 +1907,7 @@ async def find_hf_sdp_record(
|
||||
|
||||
async def find_ag_sdp_record(
|
||||
connection: device.Connection,
|
||||
) -> Optional[Tuple[int, ProfileVersion, AgSdpFeature]]:
|
||||
) -> Optional[tuple[int, ProfileVersion, AgSdpFeature]]:
|
||||
"""Searches an Audio-Gateway SDP record from remote device.
|
||||
|
||||
Args:
|
||||
@@ -2010,7 +2005,7 @@ class EscoParameters:
|
||||
transmit_codec_frame_size: int = 60
|
||||
receive_codec_frame_size: int = 60
|
||||
|
||||
def asdict(self) -> Dict[str, Any]:
|
||||
def asdict(self) -> dict[str, Any]:
|
||||
# dataclasses.asdict() will recursively deep-copy the entire object,
|
||||
# which is expensive and breaks CodingFormat object, so let it simply copy here.
|
||||
return self.__dict__
|
||||
|
||||
129
bumble/host.py
129
bumble/host.py
@@ -26,10 +26,7 @@ from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Deque,
|
||||
Dict,
|
||||
Optional,
|
||||
Set,
|
||||
cast,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
@@ -41,7 +38,6 @@ from bumble.snoop import Snooper
|
||||
from bumble import drivers
|
||||
from bumble import hci
|
||||
from bumble.core import (
|
||||
PhysicalTransport,
|
||||
PhysicalTransport,
|
||||
ConnectionPHY,
|
||||
ConnectionParameters,
|
||||
@@ -75,6 +71,11 @@ class DataPacketQueue(utils.EventEmitter):
|
||||
|
||||
max_packet_size: int
|
||||
|
||||
class PerConnectionState:
|
||||
def __init__(self) -> None:
|
||||
self.in_flight = 0
|
||||
self.drained = asyncio.Event()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_packet_size: int,
|
||||
@@ -85,11 +86,16 @@ class DataPacketQueue(utils.EventEmitter):
|
||||
self.max_packet_size = max_packet_size
|
||||
self.max_in_flight = max_in_flight
|
||||
self._in_flight = 0 # Total number of packets in flight across all connections
|
||||
self._in_flight_per_connection: dict[int, int] = collections.defaultdict(
|
||||
int
|
||||
) # Number of packets in flight per connection
|
||||
self._connection_state: dict[int, DataPacketQueue.PerConnectionState] = (
|
||||
collections.defaultdict(DataPacketQueue.PerConnectionState)
|
||||
)
|
||||
self._drained_per_connection: dict[int, asyncio.Event] = (
|
||||
collections.defaultdict(asyncio.Event)
|
||||
)
|
||||
self._send = send
|
||||
self._packets: Deque[tuple[hci.HCI_Packet, int]] = collections.deque()
|
||||
self._packets: collections.deque[tuple[hci.HCI_Packet, int]] = (
|
||||
collections.deque()
|
||||
)
|
||||
self._queued = 0
|
||||
self._completed = 0
|
||||
|
||||
@@ -137,36 +143,40 @@ class DataPacketQueue(utils.EventEmitter):
|
||||
self._completed += flushed_count
|
||||
self._packets = collections.deque(packets_to_keep)
|
||||
|
||||
if connection_handle in self._in_flight_per_connection:
|
||||
in_flight = self._in_flight_per_connection[connection_handle]
|
||||
if connection_state := self._connection_state.pop(connection_handle, None):
|
||||
in_flight = connection_state.in_flight
|
||||
self._completed += in_flight
|
||||
self._in_flight -= in_flight
|
||||
del self._in_flight_per_connection[connection_handle]
|
||||
connection_state.drained.set()
|
||||
|
||||
def _check_queue(self) -> None:
|
||||
while self._packets and self._in_flight < self.max_in_flight:
|
||||
packet, connection_handle = self._packets.pop()
|
||||
self._send(packet)
|
||||
self._in_flight += 1
|
||||
self._in_flight_per_connection[connection_handle] += 1
|
||||
connection_state = self._connection_state[connection_handle]
|
||||
connection_state.in_flight += 1
|
||||
connection_state.drained.clear()
|
||||
|
||||
def on_packets_completed(self, packet_count: int, connection_handle: int) -> None:
|
||||
"""Mark one or more packets associated with a connection as completed."""
|
||||
if connection_handle not in self._in_flight_per_connection:
|
||||
if connection_handle not in self._connection_state:
|
||||
logger.warning(
|
||||
f'received completion for unknown connection {connection_handle}'
|
||||
)
|
||||
return
|
||||
|
||||
in_flight_for_connection = self._in_flight_per_connection[connection_handle]
|
||||
if packet_count <= in_flight_for_connection:
|
||||
self._in_flight_per_connection[connection_handle] -= packet_count
|
||||
connection_state = self._connection_state[connection_handle]
|
||||
if packet_count <= connection_state.in_flight:
|
||||
connection_state.in_flight -= packet_count
|
||||
else:
|
||||
logger.warning(
|
||||
f'{packet_count} completed for {connection_handle} '
|
||||
f'but only {in_flight_for_connection} in flight'
|
||||
f'but only {connection_state.in_flight} in flight'
|
||||
)
|
||||
self._in_flight_per_connection[connection_handle] = 0
|
||||
connection_state.in_flight = 0
|
||||
if connection_state.in_flight == 0:
|
||||
connection_state.drained.set()
|
||||
|
||||
if packet_count <= self._in_flight:
|
||||
self._in_flight -= packet_count
|
||||
@@ -181,6 +191,13 @@ class DataPacketQueue(utils.EventEmitter):
|
||||
self._check_queue()
|
||||
self.emit('flow')
|
||||
|
||||
async def drain(self, connection_handle: int) -> None:
|
||||
"""Wait until there are no pending packets for a connection."""
|
||||
if not (connection_state := self._connection_state.get(connection_handle)):
|
||||
raise ValueError('no such connection')
|
||||
|
||||
await connection_state.drained.wait()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Connection:
|
||||
@@ -234,16 +251,16 @@ class IsoLink:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Host(utils.EventEmitter):
|
||||
connections: Dict[int, Connection]
|
||||
cis_links: Dict[int, IsoLink]
|
||||
bis_links: Dict[int, IsoLink]
|
||||
sco_links: Dict[int, ScoLink]
|
||||
connections: dict[int, Connection]
|
||||
cis_links: dict[int, IsoLink]
|
||||
bis_links: dict[int, IsoLink]
|
||||
sco_links: dict[int, ScoLink]
|
||||
bigs: dict[int, set[int]]
|
||||
acl_packet_queue: Optional[DataPacketQueue] = None
|
||||
le_acl_packet_queue: Optional[DataPacketQueue] = None
|
||||
iso_packet_queue: Optional[DataPacketQueue] = None
|
||||
hci_sink: Optional[TransportSink] = None
|
||||
hci_metadata: Dict[str, Any]
|
||||
hci_metadata: dict[str, Any]
|
||||
long_term_key_provider: Optional[
|
||||
Callable[[int, bytes, int], Awaitable[Optional[bytes]]]
|
||||
]
|
||||
@@ -813,7 +830,7 @@ class Host(utils.EventEmitter):
|
||||
) != 0
|
||||
|
||||
@property
|
||||
def supported_commands(self) -> Set[int]:
|
||||
def supported_commands(self) -> set[int]:
|
||||
return set(
|
||||
op_code
|
||||
for op_code, mask in hci.HCI_SUPPORTED_COMMANDS_MASKS.items()
|
||||
@@ -836,8 +853,8 @@ class Host(utils.EventEmitter):
|
||||
def on_packet(self, packet: bytes) -> None:
|
||||
try:
|
||||
hci_packet = hci.HCI_Packet.from_bytes(packet)
|
||||
except Exception as error:
|
||||
logger.warning(f'!!! error parsing packet from bytes: {error}')
|
||||
except Exception:
|
||||
logger.exception('!!! error parsing packet from bytes')
|
||||
return
|
||||
|
||||
if self.ready or (
|
||||
@@ -1127,11 +1144,19 @@ class Host(utils.EventEmitter):
|
||||
else:
|
||||
self.emit('connection_phy_update_failure', connection.handle, event.status)
|
||||
|
||||
def on_hci_le_advertising_report_event(self, event):
|
||||
def on_hci_le_advertising_report_event(
|
||||
self,
|
||||
event: (
|
||||
hci.HCI_LE_Advertising_Report_Event
|
||||
| hci.HCI_LE_Extended_Advertising_Report_Event
|
||||
),
|
||||
):
|
||||
for report in event.reports:
|
||||
self.emit('advertising_report', report)
|
||||
|
||||
def on_hci_le_extended_advertising_report_event(self, event):
|
||||
def on_hci_le_extended_advertising_report_event(
|
||||
self, event: hci.HCI_LE_Extended_Advertising_Report_Event
|
||||
):
|
||||
self.on_hci_le_advertising_report_event(event)
|
||||
|
||||
def on_hci_le_advertising_set_terminated_event(self, event):
|
||||
@@ -1262,7 +1287,24 @@ class Host(utils.EventEmitter):
|
||||
self.cis_links[event.connection_handle] = IsoLink(
|
||||
handle=event.connection_handle, packet_queue=self.iso_packet_queue
|
||||
)
|
||||
self.emit('cis_establishment', event.connection_handle)
|
||||
self.emit(
|
||||
'cis_establishment',
|
||||
event.connection_handle,
|
||||
event.cig_sync_delay,
|
||||
event.cis_sync_delay,
|
||||
event.transport_latency_c_to_p,
|
||||
event.transport_latency_p_to_c,
|
||||
event.phy_c_to_p,
|
||||
event.phy_p_to_c,
|
||||
event.nse,
|
||||
event.bn_c_to_p,
|
||||
event.bn_p_to_c,
|
||||
event.ft_c_to_p,
|
||||
event.ft_p_to_c,
|
||||
event.max_pdu_c_to_p,
|
||||
event.max_pdu_p_to_c,
|
||||
event.iso_interval,
|
||||
)
|
||||
else:
|
||||
self.emit(
|
||||
'cis_establishment_failure', event.connection_handle, event.status
|
||||
@@ -1350,6 +1392,15 @@ class Host(utils.EventEmitter):
|
||||
def on_hci_synchronous_connection_changed_event(self, event):
|
||||
pass
|
||||
|
||||
def on_hci_mode_change_event(self, event: hci.HCI_Mode_Change_Event):
|
||||
self.emit(
|
||||
'mode_change',
|
||||
event.connection_handle,
|
||||
event.status,
|
||||
event.current_mode,
|
||||
event.interval,
|
||||
)
|
||||
|
||||
def on_hci_role_change_event(self, event):
|
||||
if event.status == hci.HCI_SUCCESS:
|
||||
logger.debug(
|
||||
@@ -1365,6 +1416,10 @@ class Host(utils.EventEmitter):
|
||||
self.emit('role_change_failure', event.bd_addr, event.status)
|
||||
|
||||
def on_hci_le_data_length_change_event(self, event):
|
||||
if (connection := self.connections.get(event.connection_handle)) is None:
|
||||
logger.warning('!!! DATA LENGTH CHANGE: unknown handle')
|
||||
return
|
||||
|
||||
self.emit(
|
||||
'connection_data_length_change',
|
||||
event.connection_handle,
|
||||
@@ -1385,7 +1440,7 @@ class Host(utils.EventEmitter):
|
||||
event.status,
|
||||
)
|
||||
|
||||
def on_hci_encryption_change_event(self, event):
|
||||
def on_hci_encryption_change_event(self, event: hci.HCI_Encryption_Change_Event):
|
||||
# Notify the client
|
||||
if event.status == hci.HCI_SUCCESS:
|
||||
self.emit(
|
||||
@@ -1399,7 +1454,9 @@ class Host(utils.EventEmitter):
|
||||
'connection_encryption_failure', event.connection_handle, event.status
|
||||
)
|
||||
|
||||
def on_hci_encryption_change_v2_event(self, event):
|
||||
def on_hci_encryption_change_v2_event(
|
||||
self, event: hci.HCI_Encryption_Change_V2_Event
|
||||
):
|
||||
# Notify the client
|
||||
if event.status == hci.HCI_SUCCESS:
|
||||
self.emit(
|
||||
@@ -1520,13 +1577,15 @@ class Host(utils.EventEmitter):
|
||||
self.emit('inquiry_complete')
|
||||
|
||||
def on_hci_inquiry_result_with_rssi_event(self, event):
|
||||
for response in event.responses:
|
||||
for bd_addr, class_of_device, rssi in zip(
|
||||
event.bd_addr, event.class_of_device, event.rssi
|
||||
):
|
||||
self.emit(
|
||||
'inquiry_result',
|
||||
response.bd_addr,
|
||||
response.class_of_device,
|
||||
bd_addr,
|
||||
class_of_device,
|
||||
b'',
|
||||
response.rssi,
|
||||
rssi,
|
||||
)
|
||||
|
||||
def on_hci_extended_inquiry_result_event(self, event):
|
||||
|
||||
@@ -26,7 +26,7 @@ import dataclasses
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type, Any
|
||||
from typing import TYPE_CHECKING, Optional, Any
|
||||
from typing_extensions import Self
|
||||
|
||||
from bumble.colors import color
|
||||
@@ -157,7 +157,7 @@ class KeyStore:
|
||||
async def get(self, _name: str) -> Optional[PairingKeys]:
|
||||
return None
|
||||
|
||||
async def get_all(self) -> List[Tuple[str, PairingKeys]]:
|
||||
async def get_all(self) -> list[tuple[str, PairingKeys]]:
|
||||
return []
|
||||
|
||||
async def delete_all(self) -> None:
|
||||
@@ -272,7 +272,7 @@ class JsonKeyStore(KeyStore):
|
||||
|
||||
@classmethod
|
||||
def from_device(
|
||||
cls: Type[Self], device: Device, filename: Optional[str] = None
|
||||
cls: type[Self], device: Device, filename: Optional[str] = None
|
||||
) -> Self:
|
||||
if not filename:
|
||||
# Extract the filename from the config if there is one
|
||||
@@ -356,7 +356,7 @@ class JsonKeyStore(KeyStore):
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class MemoryKeyStore(KeyStore):
|
||||
all_keys: Dict[str, PairingKeys]
|
||||
all_keys: dict[str, PairingKeys]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.all_keys = {}
|
||||
@@ -371,5 +371,5 @@ class MemoryKeyStore(KeyStore):
|
||||
async def get(self, name: str) -> Optional[PairingKeys]:
|
||||
return self.all_keys.get(name)
|
||||
|
||||
async def get_all(self) -> List[Tuple[str, PairingKeys]]:
|
||||
async def get_all(self) -> list[tuple[str, PairingKeys]]:
|
||||
return list(self.all_keys.items())
|
||||
|
||||
709
bumble/l2cap.py
709
bumble/l2cap.py
File diff suppressed because it is too large
Load Diff
296
bumble/link.py
296
bumble/link.py
@@ -17,26 +17,20 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
import asyncio
|
||||
from functools import partial
|
||||
|
||||
from bumble.core import (
|
||||
PhysicalTransport,
|
||||
InvalidStateError,
|
||||
)
|
||||
from bumble.colors import color
|
||||
from bumble import core
|
||||
from bumble.hci import (
|
||||
Address,
|
||||
Role,
|
||||
HCI_SUCCESS,
|
||||
HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR,
|
||||
HCI_CONNECTION_TIMEOUT_ERROR,
|
||||
HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
||||
HCI_PAGE_TIMEOUT_ERROR,
|
||||
HCI_Connection_Complete_Event,
|
||||
)
|
||||
from bumble import controller
|
||||
|
||||
from typing import Optional, Set
|
||||
from typing import Optional
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -65,7 +59,7 @@ class LocalLink:
|
||||
Link bus for controllers to communicate with each other
|
||||
'''
|
||||
|
||||
controllers: Set[controller.Controller]
|
||||
controllers: set[controller.Controller]
|
||||
|
||||
def __init__(self):
|
||||
self.controllers = set()
|
||||
@@ -115,10 +109,10 @@ class LocalLink:
|
||||
|
||||
def send_acl_data(self, sender_controller, destination_address, transport, data):
|
||||
# Send the data to the first controller with a matching address
|
||||
if transport == PhysicalTransport.LE:
|
||||
if transport == core.PhysicalTransport.LE:
|
||||
destination_controller = self.find_controller(destination_address)
|
||||
source_address = sender_controller.random_address
|
||||
elif transport == PhysicalTransport.BR_EDR:
|
||||
elif transport == core.PhysicalTransport.BR_EDR:
|
||||
destination_controller = self.find_classic_controller(destination_address)
|
||||
source_address = sender_controller.public_address
|
||||
else:
|
||||
@@ -165,29 +159,29 @@ class LocalLink:
|
||||
asyncio.get_running_loop().call_soon(self.on_connection_complete)
|
||||
|
||||
def on_disconnection_complete(
|
||||
self, central_address, peripheral_address, disconnect_command
|
||||
self, initiating_address, target_address, disconnect_command
|
||||
):
|
||||
# Find the controller that initiated the disconnection
|
||||
if not (central_controller := self.find_controller(central_address)):
|
||||
if not (initiating_controller := self.find_controller(initiating_address)):
|
||||
logger.warning('!!! Initiating controller not found')
|
||||
return
|
||||
|
||||
# Disconnect from the first controller with a matching address
|
||||
if peripheral_controller := self.find_controller(peripheral_address):
|
||||
peripheral_controller.on_link_central_disconnected(
|
||||
central_address, disconnect_command.reason
|
||||
if target_controller := self.find_controller(target_address):
|
||||
target_controller.on_link_disconnected(
|
||||
initiating_address, disconnect_command.reason
|
||||
)
|
||||
|
||||
central_controller.on_link_peripheral_disconnection_complete(
|
||||
initiating_controller.on_link_disconnection_complete(
|
||||
disconnect_command, HCI_SUCCESS
|
||||
)
|
||||
|
||||
def disconnect(self, central_address, peripheral_address, disconnect_command):
|
||||
def disconnect(self, initiating_address, target_address, disconnect_command):
|
||||
logger.debug(
|
||||
f'$$$ DISCONNECTION {central_address} -> '
|
||||
f'{peripheral_address}: reason = {disconnect_command.reason}'
|
||||
f'$$$ DISCONNECTION {initiating_address} -> '
|
||||
f'{target_address}: reason = {disconnect_command.reason}'
|
||||
)
|
||||
args = [central_address, peripheral_address, disconnect_command]
|
||||
args = [initiating_address, target_address, disconnect_command]
|
||||
asyncio.get_running_loop().call_soon(self.on_disconnection_complete, *args)
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
@@ -274,7 +268,7 @@ class LocalLink:
|
||||
|
||||
responder_controller.on_classic_connection_request(
|
||||
initiator_controller.public_address,
|
||||
HCI_Connection_Complete_Event.ACL_LINK_TYPE,
|
||||
HCI_Connection_Complete_Event.LinkType.ACL,
|
||||
)
|
||||
|
||||
def classic_accept_connection(
|
||||
@@ -384,261 +378,3 @@ class LocalLink:
|
||||
responder_controller.on_classic_sco_connection_complete(
|
||||
initiator_controller.public_address, HCI_SUCCESS, link_type
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class RemoteLink:
|
||||
'''
|
||||
A Link implementation that communicates with other virtual controllers via a
|
||||
WebSocket relay
|
||||
'''
|
||||
|
||||
def __init__(self, uri):
|
||||
self.controller = None
|
||||
self.uri = uri
|
||||
self.execution_queue = asyncio.Queue()
|
||||
self.websocket = asyncio.get_running_loop().create_future()
|
||||
self.rpc_result = None
|
||||
self.pending_connection = None
|
||||
self.central_connections = set() # List of addresses that we have connected to
|
||||
self.peripheral_connections = (
|
||||
set()
|
||||
) # List of addresses that have connected to us
|
||||
|
||||
# Connect and run asynchronously
|
||||
asyncio.create_task(self.run_connection())
|
||||
asyncio.create_task(self.run_executor_loop())
|
||||
|
||||
def add_controller(self, controller):
|
||||
if self.controller:
|
||||
raise InvalidStateError('controller already set')
|
||||
self.controller = controller
|
||||
|
||||
def remove_controller(self, controller):
|
||||
if self.controller != controller:
|
||||
raise InvalidStateError('controller mismatch')
|
||||
self.controller = None
|
||||
|
||||
def get_pending_connection(self):
|
||||
return self.pending_connection
|
||||
|
||||
def get_pending_classic_connection(self):
|
||||
return self.pending_classic_connection
|
||||
|
||||
async def wait_until_connected(self):
|
||||
await self.websocket
|
||||
|
||||
def execute(self, async_function):
|
||||
self.execution_queue.put_nowait(async_function())
|
||||
|
||||
async def run_executor_loop(self):
|
||||
logger.debug('executor loop starting')
|
||||
while True:
|
||||
item = await self.execution_queue.get()
|
||||
try:
|
||||
await item
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f'{color("!!! Exception in async handler:", "red")} {error}'
|
||||
)
|
||||
|
||||
async def run_connection(self):
|
||||
import websockets # lazy import
|
||||
|
||||
# Connect to the relay
|
||||
logger.debug(f'connecting to {self.uri}')
|
||||
# pylint: disable-next=no-member
|
||||
websocket = await websockets.connect(self.uri)
|
||||
self.websocket.set_result(websocket)
|
||||
logger.debug(f'connected to {self.uri}')
|
||||
|
||||
while True:
|
||||
message = await websocket.recv()
|
||||
logger.debug(f'received message: {message}')
|
||||
keyword, *payload = message.split(':', 1)
|
||||
|
||||
handler_name = f'on_{keyword}_received'
|
||||
handler = getattr(self, handler_name, None)
|
||||
if handler:
|
||||
await handler(payload[0] if payload else None)
|
||||
|
||||
def close(self):
|
||||
if self.websocket.done():
|
||||
logger.debug('closing websocket')
|
||||
websocket = self.websocket.result()
|
||||
asyncio.create_task(websocket.close())
|
||||
|
||||
async def on_result_received(self, result):
|
||||
if self.rpc_result:
|
||||
self.rpc_result.set_result(result)
|
||||
|
||||
async def on_left_received(self, address):
|
||||
if address in self.central_connections:
|
||||
self.controller.on_link_peripheral_disconnected(Address(address))
|
||||
self.central_connections.remove(address)
|
||||
|
||||
if address in self.peripheral_connections:
|
||||
self.controller.on_link_central_disconnected(
|
||||
address, HCI_CONNECTION_TIMEOUT_ERROR
|
||||
)
|
||||
self.peripheral_connections.remove(address)
|
||||
|
||||
async def on_unreachable_received(self, target):
|
||||
await self.on_left_received(target)
|
||||
|
||||
async def on_message_received(self, message):
|
||||
sender, *payload = message.split('/', 1)
|
||||
if payload:
|
||||
keyword, *payload = payload[0].split(':', 1)
|
||||
handler_name = f'on_{keyword}_message_received'
|
||||
handler = getattr(self, handler_name, None)
|
||||
if handler:
|
||||
await handler(sender, payload[0] if payload else None)
|
||||
|
||||
async def on_advertisement_message_received(self, sender, advertisement):
|
||||
try:
|
||||
self.controller.on_link_advertising_data(
|
||||
Address(sender), bytes.fromhex(advertisement)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('exception')
|
||||
|
||||
async def on_acl_message_received(self, sender, acl_data):
|
||||
try:
|
||||
self.controller.on_link_acl_data(Address(sender), bytes.fromhex(acl_data))
|
||||
except Exception:
|
||||
logger.exception('exception')
|
||||
|
||||
async def on_connect_message_received(self, sender, _):
|
||||
# Remember the connection
|
||||
self.peripheral_connections.add(sender)
|
||||
|
||||
# Notify the controller
|
||||
logger.debug(f'connection from central {sender}')
|
||||
self.controller.on_link_central_connected(Address(sender))
|
||||
|
||||
# Accept the connection by responding to it
|
||||
await self.send_targeted_message(sender, 'connected')
|
||||
|
||||
async def on_connected_message_received(self, sender, _):
|
||||
if not self.pending_connection:
|
||||
logger.warning('received a connection ack, but no connection is pending')
|
||||
return
|
||||
|
||||
# Remember the connection
|
||||
self.central_connections.add(sender)
|
||||
|
||||
# Notify the controller
|
||||
logger.debug(f'connected to peripheral {self.pending_connection.peer_address}')
|
||||
self.controller.on_link_peripheral_connection_complete(
|
||||
self.pending_connection, HCI_SUCCESS
|
||||
)
|
||||
|
||||
async def on_disconnect_message_received(self, sender, message):
|
||||
# Notify the controller
|
||||
params = parse_parameters(message)
|
||||
reason = int(params.get('reason', str(HCI_CONNECTION_TIMEOUT_ERROR)))
|
||||
self.controller.on_link_central_disconnected(Address(sender), reason)
|
||||
|
||||
# Forget the connection
|
||||
if sender in self.peripheral_connections:
|
||||
self.peripheral_connections.remove(sender)
|
||||
|
||||
async def on_encrypted_message_received(self, sender, _):
|
||||
# TODO parse params to get real args
|
||||
self.controller.on_link_encrypted(Address(sender), bytes(8), 0, bytes(16))
|
||||
|
||||
async def send_rpc_command(self, command):
|
||||
# Ensure we have a connection
|
||||
websocket = await self.websocket
|
||||
|
||||
# Create a future value to hold the eventual result
|
||||
assert self.rpc_result is None
|
||||
self.rpc_result = asyncio.get_running_loop().create_future()
|
||||
|
||||
# Send the command
|
||||
await websocket.send(command)
|
||||
|
||||
# Wait for the result
|
||||
rpc_result = await self.rpc_result
|
||||
self.rpc_result = None
|
||||
logger.debug(f'rpc_result: {rpc_result}')
|
||||
|
||||
# TODO: parse the result
|
||||
|
||||
async def send_targeted_message(self, target, message):
|
||||
# Ensure we have a connection
|
||||
websocket = await self.websocket
|
||||
|
||||
# Send the message
|
||||
await websocket.send(f'@{target} {message}')
|
||||
|
||||
async def notify_address_changed(self):
|
||||
await self.send_rpc_command(f'/set-address {self.controller.random_address}')
|
||||
|
||||
def on_address_changed(self, controller):
|
||||
logger.info(f'address changed for {controller}: {controller.random_address}')
|
||||
|
||||
# Notify the relay of the change
|
||||
self.execute(self.notify_address_changed)
|
||||
|
||||
async def send_advertising_data_to_relay(self, data):
|
||||
await self.send_targeted_message('*', f'advertisement:{data.hex()}')
|
||||
|
||||
def send_advertising_data(self, _, data):
|
||||
self.execute(partial(self.send_advertising_data_to_relay, data))
|
||||
|
||||
async def send_acl_data_to_relay(self, peer_address, data):
|
||||
await self.send_targeted_message(peer_address, f'acl:{data.hex()}')
|
||||
|
||||
def send_acl_data(self, _, peer_address, _transport, data):
|
||||
# TODO: handle different transport
|
||||
self.execute(partial(self.send_acl_data_to_relay, peer_address, data))
|
||||
|
||||
async def send_connection_request_to_relay(self, peer_address):
|
||||
await self.send_targeted_message(peer_address, 'connect')
|
||||
|
||||
def connect(self, _, le_create_connection_command):
|
||||
if self.pending_connection:
|
||||
logger.warning('connection already pending')
|
||||
return
|
||||
self.pending_connection = le_create_connection_command
|
||||
self.execute(
|
||||
partial(
|
||||
self.send_connection_request_to_relay,
|
||||
str(le_create_connection_command.peer_address),
|
||||
)
|
||||
)
|
||||
|
||||
def on_disconnection_complete(self, disconnect_command):
|
||||
self.controller.on_link_peripheral_disconnection_complete(
|
||||
disconnect_command, HCI_SUCCESS
|
||||
)
|
||||
|
||||
def disconnect(self, central_address, peripheral_address, disconnect_command):
|
||||
logger.debug(
|
||||
f'disconnect {central_address} -> '
|
||||
f'{peripheral_address}: reason = {disconnect_command.reason}'
|
||||
)
|
||||
self.execute(
|
||||
partial(
|
||||
self.send_targeted_message,
|
||||
peripheral_address,
|
||||
f'disconnect:reason={disconnect_command.reason}',
|
||||
)
|
||||
)
|
||||
asyncio.get_running_loop().call_soon(
|
||||
self.on_disconnection_complete, disconnect_command
|
||||
)
|
||||
|
||||
def on_connection_encrypted(self, _, peripheral_address, rand, ediv, ltk):
|
||||
asyncio.get_running_loop().call_soon(
|
||||
self.controller.on_link_encrypted, peripheral_address, rand, ediv, ltk
|
||||
)
|
||||
self.execute(
|
||||
partial(
|
||||
self.send_targeted_message,
|
||||
peripheral_address,
|
||||
f'encrypted:ltk={ltk.hex()}',
|
||||
)
|
||||
)
|
||||
|
||||
65
bumble/logging.py
Normal file
65
bumble/logging.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# Copyright 2025 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 functools
|
||||
import logging
|
||||
import os
|
||||
|
||||
from bumble import colors
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class ColorFormatter(logging.Formatter):
|
||||
_colorizers = {
|
||||
logging.DEBUG: functools.partial(colors.color, fg="white"),
|
||||
logging.INFO: functools.partial(colors.color, fg="green"),
|
||||
logging.WARNING: functools.partial(colors.color, fg="yellow"),
|
||||
logging.ERROR: functools.partial(colors.color, fg="red"),
|
||||
logging.CRITICAL: functools.partial(colors.color, fg="black", bg="red"),
|
||||
}
|
||||
|
||||
_formatters = {
|
||||
level: logging.Formatter(
|
||||
fmt=colorizer("{asctime}.{msecs:03.0f} {levelname:.1} {name}: ")
|
||||
+ "{message}",
|
||||
datefmt="%H:%M:%S",
|
||||
style="{",
|
||||
)
|
||||
for level, colorizer in _colorizers.items()
|
||||
}
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
return self._formatters[record.levelno].format(record)
|
||||
|
||||
|
||||
def setup_basic_logging(default_level: str = "INFO") -> None:
|
||||
"""
|
||||
Set up basic logging with logging.basicConfig, configured with a simple formatter
|
||||
that prints out the date and log level in color.
|
||||
If the BUMBLE_LOGLEVEL environment variable is set to the name of a log level, it
|
||||
is used. Otherwise the default_level argument is used.
|
||||
|
||||
Args:
|
||||
default_level: default logging level
|
||||
|
||||
"""
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(ColorFormatter())
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("BUMBLE_LOGLEVEL", default_level).upper(),
|
||||
handlers=[handler],
|
||||
)
|
||||
@@ -18,15 +18,10 @@
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from bumble.hci import (
|
||||
Address,
|
||||
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
||||
HCI_DISPLAY_ONLY_IO_CAPABILITY,
|
||||
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
|
||||
HCI_KEYBOARD_ONLY_IO_CAPABILITY,
|
||||
)
|
||||
from bumble import hci
|
||||
from bumble.smp import (
|
||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY,
|
||||
@@ -49,7 +44,7 @@ from bumble.core import AdvertisingData, LeRole
|
||||
class OobData:
|
||||
"""OOB data that can be sent from one device to another."""
|
||||
|
||||
address: Optional[Address] = None
|
||||
address: Optional[hci.Address] = None
|
||||
role: Optional[LeRole] = None
|
||||
shared_data: Optional[OobSharedData] = None
|
||||
legacy_context: Optional[OobLegacyContext] = None
|
||||
@@ -61,7 +56,7 @@ class OobData:
|
||||
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)
|
||||
instance.address = hci.Address(ad_data)
|
||||
elif ad_type == AdvertisingData.LE_ROLE:
|
||||
instance.role = LeRole(ad_data[0])
|
||||
elif ad_type == AdvertisingData.LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE:
|
||||
@@ -129,11 +124,11 @@ class PairingDelegate:
|
||||
# Default mapping from abstract to Classic I/O capabilities.
|
||||
# Subclasses may override this if they prefer a different mapping.
|
||||
CLASSIC_IO_CAPABILITIES_MAP = {
|
||||
NO_OUTPUT_NO_INPUT: HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
||||
KEYBOARD_INPUT_ONLY: HCI_KEYBOARD_ONLY_IO_CAPABILITY,
|
||||
DISPLAY_OUTPUT_ONLY: HCI_DISPLAY_ONLY_IO_CAPABILITY,
|
||||
DISPLAY_OUTPUT_AND_YES_NO_INPUT: HCI_DISPLAY_YES_NO_IO_CAPABILITY,
|
||||
DISPLAY_OUTPUT_AND_KEYBOARD_INPUT: HCI_DISPLAY_YES_NO_IO_CAPABILITY,
|
||||
NO_OUTPUT_NO_INPUT: hci.IoCapability.NO_INPUT_NO_OUTPUT,
|
||||
KEYBOARD_INPUT_ONLY: hci.IoCapability.KEYBOARD_ONLY,
|
||||
DISPLAY_OUTPUT_ONLY: hci.IoCapability.DISPLAY_ONLY,
|
||||
DISPLAY_OUTPUT_AND_YES_NO_INPUT: hci.IoCapability.DISPLAY_YES_NO,
|
||||
DISPLAY_OUTPUT_AND_KEYBOARD_INPUT: hci.IoCapability.DISPLAY_YES_NO,
|
||||
}
|
||||
|
||||
io_capability: IoCapability
|
||||
@@ -159,7 +154,7 @@ class PairingDelegate:
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
return self.CLASSIC_IO_CAPABILITIES_MAP.get(
|
||||
self.io_capability, HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
|
||||
self.io_capability, hci.IoCapability.NO_INPUT_NO_OUTPUT
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -205,7 +200,7 @@ class PairingDelegate:
|
||||
# [LE only]
|
||||
async def key_distribution_response(
|
||||
self, peer_initiator_key_distribution: int, peer_responder_key_distribution: int
|
||||
) -> Tuple[int, int]:
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
Return the key distribution response in an SMP protocol context.
|
||||
|
||||
@@ -222,14 +217,22 @@ class PairingDelegate:
|
||||
),
|
||||
)
|
||||
|
||||
async def generate_passkey(self) -> int:
|
||||
"""
|
||||
Return a passkey value between 0 and 999999 (inclusive).
|
||||
"""
|
||||
|
||||
# By default, generate a random passkey.
|
||||
return secrets.randbelow(1000000)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class PairingConfig:
|
||||
"""Configuration for the Pairing protocol."""
|
||||
|
||||
class AddressType(enum.IntEnum):
|
||||
PUBLIC = Address.PUBLIC_DEVICE_ADDRESS
|
||||
RANDOM = Address.RANDOM_DEVICE_ADDRESS
|
||||
PUBLIC = hci.Address.PUBLIC_DEVICE_ADDRESS
|
||||
RANDOM = hci.Address.RANDOM_DEVICE_ADDRESS
|
||||
|
||||
@dataclass
|
||||
class OobConfig:
|
||||
|
||||
@@ -45,11 +45,11 @@ __all__ = [
|
||||
|
||||
|
||||
# Add servicers hooks.
|
||||
_SERVICERS_HOOKS: List[Callable[[PandoraDevice, Config, grpc.aio.Server], None]] = []
|
||||
_SERVICERS_HOOKS: list[Callable[[PandoraDevice, Config, grpc.aio.Server], None]] = []
|
||||
|
||||
|
||||
def register_servicer_hook(
|
||||
hook: Callable[[PandoraDevice, Config, grpc.aio.Server], None]
|
||||
hook: Callable[[PandoraDevice, Config, grpc.aio.Server], None],
|
||||
) -> None:
|
||||
_SERVICERS_HOOKS.append(hook)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
from __future__ import annotations
|
||||
from bumble.pairing import PairingConfig, PairingDelegate
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -32,7 +32,7 @@ class Config:
|
||||
PairingDelegate.DEFAULT_KEY_DISTRIBUTION
|
||||
)
|
||||
|
||||
def load_from_dict(self, config: Dict[str, Any]) -> None:
|
||||
def load_from_dict(self, config: dict[str, Any]) -> None:
|
||||
io_capability_name: str = config.get(
|
||||
'io_capability', 'no_output_no_input'
|
||||
).upper()
|
||||
|
||||
@@ -32,7 +32,7 @@ from bumble.sdp import (
|
||||
DataElement,
|
||||
ServiceAttribute,
|
||||
)
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
# Default rootcanal HCI TCP address
|
||||
@@ -49,13 +49,13 @@ class PandoraDevice:
|
||||
|
||||
# Bumble device instance & configuration.
|
||||
device: Device
|
||||
config: Dict[str, Any]
|
||||
config: dict[str, Any]
|
||||
|
||||
# HCI transport name & instance.
|
||||
_hci_name: str
|
||||
_hci: Optional[transport.Transport] # type: ignore[name-defined]
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self.config = config
|
||||
self.device = _make_device(config)
|
||||
self._hci_name = config.get(
|
||||
@@ -95,14 +95,14 @@ class PandoraDevice:
|
||||
await self.close()
|
||||
await self.open()
|
||||
|
||||
def info(self) -> Optional[Dict[str, str]]:
|
||||
def info(self) -> Optional[dict[str, str]]:
|
||||
return {
|
||||
'public_bd_address': str(self.device.public_address),
|
||||
'random_address': str(self.device.random_address),
|
||||
}
|
||||
|
||||
|
||||
def _make_device(config: Dict[str, Any]) -> Device:
|
||||
def _make_device(config: dict[str, Any]) -> Device:
|
||||
"""Initialize an idle Bumble device instance."""
|
||||
|
||||
# initialize bumble device.
|
||||
@@ -117,7 +117,7 @@ def _make_device(config: Dict[str, Any]) -> Device:
|
||||
|
||||
|
||||
# TODO(b/267540823): remove when Pandora A2dp is supported
|
||||
def _make_sdp_records(rfcomm_channel: int) -> Dict[int, List[ServiceAttribute]]:
|
||||
def _make_sdp_records(rfcomm_channel: int) -> dict[int, list[ServiceAttribute]]:
|
||||
return {
|
||||
0x00010001: [
|
||||
ServiceAttribute(
|
||||
|
||||
@@ -73,7 +73,6 @@ from pandora.host_pb2 import (
|
||||
ConnectResponse,
|
||||
DataTypes,
|
||||
DisconnectRequest,
|
||||
DiscoverabilityMode,
|
||||
InquiryResponse,
|
||||
PrimaryPhy,
|
||||
ReadLocalAddressResponse,
|
||||
@@ -86,9 +85,9 @@ from pandora.host_pb2 import (
|
||||
WaitConnectionResponse,
|
||||
WaitDisconnectionRequest,
|
||||
)
|
||||
from typing import AsyncGenerator, Dict, List, Optional, Set, Tuple, cast
|
||||
from typing import AsyncGenerator, Optional, cast
|
||||
|
||||
PRIMARY_PHY_MAP: Dict[int, PrimaryPhy] = {
|
||||
PRIMARY_PHY_MAP: dict[int, PrimaryPhy] = {
|
||||
# Default value reported by Bumble for legacy Advertising reports.
|
||||
# FIXME(uael): `None` might be a better value, but Bumble need to change accordingly.
|
||||
0: PRIMARY_1M,
|
||||
@@ -96,26 +95,26 @@ PRIMARY_PHY_MAP: Dict[int, PrimaryPhy] = {
|
||||
3: PRIMARY_CODED,
|
||||
}
|
||||
|
||||
SECONDARY_PHY_MAP: Dict[int, SecondaryPhy] = {
|
||||
SECONDARY_PHY_MAP: dict[int, SecondaryPhy] = {
|
||||
0: SECONDARY_NONE,
|
||||
1: SECONDARY_1M,
|
||||
2: SECONDARY_2M,
|
||||
3: SECONDARY_CODED,
|
||||
}
|
||||
|
||||
PRIMARY_PHY_TO_BUMBLE_PHY_MAP: Dict[PrimaryPhy, Phy] = {
|
||||
PRIMARY_PHY_TO_BUMBLE_PHY_MAP: dict[PrimaryPhy, Phy] = {
|
||||
PRIMARY_1M: Phy.LE_1M,
|
||||
PRIMARY_CODED: Phy.LE_CODED,
|
||||
}
|
||||
|
||||
SECONDARY_PHY_TO_BUMBLE_PHY_MAP: Dict[SecondaryPhy, Phy] = {
|
||||
SECONDARY_PHY_TO_BUMBLE_PHY_MAP: dict[SecondaryPhy, Phy] = {
|
||||
SECONDARY_NONE: Phy.LE_1M,
|
||||
SECONDARY_1M: Phy.LE_1M,
|
||||
SECONDARY_2M: Phy.LE_2M,
|
||||
SECONDARY_CODED: Phy.LE_CODED,
|
||||
}
|
||||
|
||||
OWN_ADDRESS_MAP: Dict[host_pb2.OwnAddressType, OwnAddressType] = {
|
||||
OWN_ADDRESS_MAP: dict[host_pb2.OwnAddressType, OwnAddressType] = {
|
||||
host_pb2.PUBLIC: OwnAddressType.PUBLIC,
|
||||
host_pb2.RANDOM: OwnAddressType.RANDOM,
|
||||
host_pb2.RESOLVABLE_OR_PUBLIC: OwnAddressType.RESOLVABLE_OR_PUBLIC,
|
||||
@@ -124,7 +123,7 @@ OWN_ADDRESS_MAP: Dict[host_pb2.OwnAddressType, OwnAddressType] = {
|
||||
|
||||
|
||||
class HostService(HostServicer):
|
||||
waited_connections: Set[int]
|
||||
waited_connections: set[int]
|
||||
|
||||
def __init__(
|
||||
self, grpc_server: grpc.aio.Server, device: Device, config: Config
|
||||
@@ -618,7 +617,7 @@ class HostService(HostServicer):
|
||||
self.log.debug('Inquiry')
|
||||
|
||||
inquiry_queue: asyncio.Queue[
|
||||
Optional[Tuple[Address, int, AdvertisingData, int]]
|
||||
Optional[tuple[Address, int, AdvertisingData, int]]
|
||||
] = asyncio.Queue()
|
||||
complete_handler = self.device.on(
|
||||
self.device.EVENT_INQUIRY_COMPLETE, lambda: inquiry_queue.put_nowait(None)
|
||||
@@ -670,10 +669,10 @@ class HostService(HostServicer):
|
||||
return empty_pb2.Empty()
|
||||
|
||||
def unpack_data_types(self, dt: DataTypes) -> AdvertisingData:
|
||||
ad_structures: List[Tuple[int, bytes]] = []
|
||||
ad_structures: list[tuple[int, bytes]] = []
|
||||
|
||||
uuids: List[str]
|
||||
datas: Dict[str, bytes]
|
||||
uuids: list[str]
|
||||
datas: dict[str, bytes]
|
||||
|
||||
def uuid128_from_str(uuid: str) -> bytes:
|
||||
"""Decode a 128-bit uuid encoded as XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
|
||||
@@ -887,50 +886,50 @@ class HostService(HostServicer):
|
||||
|
||||
def pack_data_types(self, ad: AdvertisingData) -> DataTypes:
|
||||
dt = DataTypes()
|
||||
uuids: List[UUID]
|
||||
uuids: list[UUID]
|
||||
s: str
|
||||
i: int
|
||||
ij: Tuple[int, int]
|
||||
uuid_data: Tuple[UUID, bytes]
|
||||
ij: tuple[int, int]
|
||||
uuid_data: tuple[UUID, bytes]
|
||||
data: bytes
|
||||
|
||||
if uuids := cast(
|
||||
List[UUID],
|
||||
list[UUID],
|
||||
ad.get(AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS),
|
||||
):
|
||||
dt.incomplete_service_class_uuids16.extend(
|
||||
list(map(lambda x: x.to_hex_str('-'), uuids))
|
||||
)
|
||||
if uuids := cast(
|
||||
List[UUID],
|
||||
list[UUID],
|
||||
ad.get(AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS),
|
||||
):
|
||||
dt.complete_service_class_uuids16.extend(
|
||||
list(map(lambda x: x.to_hex_str('-'), uuids))
|
||||
)
|
||||
if uuids := cast(
|
||||
List[UUID],
|
||||
list[UUID],
|
||||
ad.get(AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS),
|
||||
):
|
||||
dt.incomplete_service_class_uuids32.extend(
|
||||
list(map(lambda x: x.to_hex_str('-'), uuids))
|
||||
)
|
||||
if uuids := cast(
|
||||
List[UUID],
|
||||
list[UUID],
|
||||
ad.get(AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS),
|
||||
):
|
||||
dt.complete_service_class_uuids32.extend(
|
||||
list(map(lambda x: x.to_hex_str('-'), uuids))
|
||||
)
|
||||
if uuids := cast(
|
||||
List[UUID],
|
||||
list[UUID],
|
||||
ad.get(AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS),
|
||||
):
|
||||
dt.incomplete_service_class_uuids128.extend(
|
||||
list(map(lambda x: x.to_hex_str('-'), uuids))
|
||||
)
|
||||
if uuids := cast(
|
||||
List[UUID],
|
||||
list[UUID],
|
||||
ad.get(AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS),
|
||||
):
|
||||
dt.complete_service_class_uuids128.extend(
|
||||
@@ -945,42 +944,42 @@ class HostService(HostServicer):
|
||||
if i := cast(int, ad.get(AdvertisingData.CLASS_OF_DEVICE)):
|
||||
dt.class_of_device = i
|
||||
if ij := cast(
|
||||
Tuple[int, int],
|
||||
tuple[int, int],
|
||||
ad.get(AdvertisingData.PERIPHERAL_CONNECTION_INTERVAL_RANGE),
|
||||
):
|
||||
dt.peripheral_connection_interval_min = ij[0]
|
||||
dt.peripheral_connection_interval_max = ij[1]
|
||||
if uuids := cast(
|
||||
List[UUID],
|
||||
list[UUID],
|
||||
ad.get(AdvertisingData.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS),
|
||||
):
|
||||
dt.service_solicitation_uuids16.extend(
|
||||
list(map(lambda x: x.to_hex_str('-'), uuids))
|
||||
)
|
||||
if uuids := cast(
|
||||
List[UUID],
|
||||
list[UUID],
|
||||
ad.get(AdvertisingData.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS),
|
||||
):
|
||||
dt.service_solicitation_uuids32.extend(
|
||||
list(map(lambda x: x.to_hex_str('-'), uuids))
|
||||
)
|
||||
if uuids := cast(
|
||||
List[UUID],
|
||||
list[UUID],
|
||||
ad.get(AdvertisingData.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS),
|
||||
):
|
||||
dt.service_solicitation_uuids128.extend(
|
||||
list(map(lambda x: x.to_hex_str('-'), uuids))
|
||||
)
|
||||
if uuid_data := cast(
|
||||
Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_16_BIT_UUID)
|
||||
tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_16_BIT_UUID)
|
||||
):
|
||||
dt.service_data_uuid16[uuid_data[0].to_hex_str('-')] = uuid_data[1]
|
||||
if uuid_data := cast(
|
||||
Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_32_BIT_UUID)
|
||||
tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_32_BIT_UUID)
|
||||
):
|
||||
dt.service_data_uuid32[uuid_data[0].to_hex_str('-')] = uuid_data[1]
|
||||
if uuid_data := cast(
|
||||
Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_128_BIT_UUID)
|
||||
tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_128_BIT_UUID)
|
||||
):
|
||||
dt.service_data_uuid128[uuid_data[0].to_hex_str('-')] = uuid_data[1]
|
||||
if data := cast(bytes, ad.get(AdvertisingData.PUBLIC_TARGET_ADDRESS, raw=True)):
|
||||
|
||||
@@ -51,7 +51,7 @@ from pandora.l2cap_pb2 import ( # pytype: disable=pyi-error
|
||||
WaitDisconnectionRequest,
|
||||
WaitDisconnectionResponse,
|
||||
)
|
||||
from typing import AsyncGenerator, Dict, Optional, Union
|
||||
from typing import AsyncGenerator, Optional, Union
|
||||
from dataclasses import dataclass
|
||||
|
||||
L2capChannel = Union[ClassicChannel, LeCreditBasedChannel]
|
||||
@@ -70,7 +70,7 @@ class L2CAPService(L2CAPServicer):
|
||||
)
|
||||
self.device = device
|
||||
self.config = config
|
||||
self.channels: Dict[bytes, ChannelContext] = {}
|
||||
self.channels: dict[bytes, ChannelContext] = {}
|
||||
|
||||
def register_event(self, l2cap_channel: L2capChannel) -> ChannelContext:
|
||||
close_future = asyncio.get_running_loop().create_future()
|
||||
|
||||
@@ -57,7 +57,7 @@ from pandora.security_pb2 import (
|
||||
WaitSecurityRequest,
|
||||
WaitSecurityResponse,
|
||||
)
|
||||
from typing import Any, AsyncGenerator, AsyncIterator, Callable, Dict, Optional, Union
|
||||
from typing import Any, AsyncGenerator, AsyncIterator, Callable, Optional, Union
|
||||
|
||||
|
||||
class PairingDelegate(BasePairingDelegate):
|
||||
@@ -244,16 +244,16 @@ class SecurityService(SecurityServicer):
|
||||
and connection.authenticated
|
||||
and link_key_type
|
||||
in (
|
||||
hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192_TYPE,
|
||||
hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
|
||||
hci.LinkKeyType.AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192,
|
||||
hci.LinkKeyType.AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256,
|
||||
)
|
||||
)
|
||||
if level == LEVEL4:
|
||||
return (
|
||||
connection.encryption == hci.HCI_Encryption_Change_Event.AES_CCM
|
||||
connection.encryption == hci.HCI_Encryption_Change_Event.Enabled.AES_CCM
|
||||
and connection.authenticated
|
||||
and link_key_type
|
||||
== hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE
|
||||
== hci.LinkKeyType.AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256
|
||||
)
|
||||
raise InvalidArgumentError(f"Unexpected level {level}")
|
||||
|
||||
@@ -457,7 +457,7 @@ class SecurityService(SecurityServicer):
|
||||
if self.need_pairing(connection, level):
|
||||
pair_task = asyncio.create_task(connection.pair())
|
||||
|
||||
listeners: Dict[str, Callable[..., Union[None, Awaitable[None]]]] = {
|
||||
listeners: dict[str, Callable[..., Union[None, Awaitable[None]]]] = {
|
||||
'disconnection': set_failure('connection_died'),
|
||||
'pairing_failure': set_failure('pairing_failure'),
|
||||
'connection_authentication_failure': set_failure('authentication_failure'),
|
||||
|
||||
@@ -22,9 +22,9 @@ import logging
|
||||
from bumble.device import Device
|
||||
from bumble.hci import Address, AddressType
|
||||
from google.protobuf.message import Message # pytype: disable=pyi-error
|
||||
from typing import Any, Dict, Generator, MutableMapping, Optional, Tuple
|
||||
from typing import Any, Generator, MutableMapping, Optional
|
||||
|
||||
ADDRESS_TYPES: Dict[str, AddressType] = {
|
||||
ADDRESS_TYPES: dict[str, AddressType] = {
|
||||
"public": Address.PUBLIC_DEVICE_ADDRESS,
|
||||
"random": Address.RANDOM_DEVICE_ADDRESS,
|
||||
"public_identity": Address.PUBLIC_IDENTITY_ADDRESS,
|
||||
@@ -43,7 +43,7 @@ class BumbleServerLoggerAdapter(logging.LoggerAdapter): # type: ignore
|
||||
|
||||
def process(
|
||||
self, msg: str, kwargs: MutableMapping[str, Any]
|
||||
) -> Tuple[str, MutableMapping[str, Any]]:
|
||||
) -> tuple[str, MutableMapping[str, Any]]:
|
||||
assert self.extra
|
||||
service_name = self.extra['service_name']
|
||||
assert isinstance(service_name, str)
|
||||
|
||||
404
bumble/profiles/ams.py
Normal file
404
bumble/profiles/ams.py
Normal file
@@ -0,0 +1,404 @@
|
||||
# Copyright 2025 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.
|
||||
|
||||
"""
|
||||
Apple Media Service (AMS).
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import enum
|
||||
import logging
|
||||
from typing import Optional, Iterable, Union
|
||||
|
||||
|
||||
from bumble.device import Peer
|
||||
from bumble.gatt import (
|
||||
Characteristic,
|
||||
GATT_AMS_SERVICE,
|
||||
GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC,
|
||||
GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC,
|
||||
GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC,
|
||||
TemplateService,
|
||||
)
|
||||
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
|
||||
from bumble import utils
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Protocol
|
||||
# -----------------------------------------------------------------------------
|
||||
class RemoteCommandId(utils.OpenIntEnum):
|
||||
PLAY = 0
|
||||
PAUSE = 1
|
||||
TOGGLE_PLAY_PAUSE = 2
|
||||
NEXT_TRACK = 3
|
||||
PREVIOUS_TRACK = 4
|
||||
VOLUME_UP = 5
|
||||
VOLUME_DOWN = 6
|
||||
ADVANCE_REPEAT_MODE = 7
|
||||
ADVANCE_SHUFFLE_MODE = 8
|
||||
SKIP_FORWARD = 9
|
||||
SKIP_BACKWARD = 10
|
||||
LIKE_TRACK = 11
|
||||
DISLIKE_TRACK = 12
|
||||
BOOKMARK_TRACK = 13
|
||||
|
||||
|
||||
class EntityId(utils.OpenIntEnum):
|
||||
PLAYER = 0
|
||||
QUEUE = 1
|
||||
TRACK = 2
|
||||
|
||||
|
||||
class ActionId(utils.OpenIntEnum):
|
||||
POSITIVE = 0
|
||||
NEGATIVE = 1
|
||||
|
||||
|
||||
class EntityUpdateFlags(enum.IntFlag):
|
||||
TRUNCATED = 1
|
||||
|
||||
|
||||
class PlayerAttributeId(utils.OpenIntEnum):
|
||||
NAME = 0
|
||||
PLAYBACK_INFO = 1
|
||||
VOLUME = 2
|
||||
|
||||
|
||||
class QueueAttributeId(utils.OpenIntEnum):
|
||||
INDEX = 0
|
||||
COUNT = 1
|
||||
SHUFFLE_MODE = 2
|
||||
REPEAT_MODE = 3
|
||||
|
||||
|
||||
class ShuffleMode(utils.OpenIntEnum):
|
||||
OFF = 0
|
||||
ONE = 1
|
||||
ALL = 2
|
||||
|
||||
|
||||
class RepeatMode(utils.OpenIntEnum):
|
||||
OFF = 0
|
||||
ONE = 1
|
||||
ALL = 2
|
||||
|
||||
|
||||
class TrackAttributeId(utils.OpenIntEnum):
|
||||
ARTIST = 0
|
||||
ALBUM = 1
|
||||
TITLE = 2
|
||||
DURATION = 3
|
||||
|
||||
|
||||
class PlaybackState(utils.OpenIntEnum):
|
||||
PAUSED = 0
|
||||
PLAYING = 1
|
||||
REWINDING = 2
|
||||
FAST_FORWARDING = 3
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class PlaybackInfo:
|
||||
playback_state: PlaybackState = PlaybackState.PAUSED
|
||||
playback_rate: float = 1.0
|
||||
elapsed_time: float = 0.0
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# GATT Server-side
|
||||
# -----------------------------------------------------------------------------
|
||||
class Ams(TemplateService):
|
||||
UUID = GATT_AMS_SERVICE
|
||||
|
||||
remote_command_characteristic: Characteristic
|
||||
entity_update_characteristic: Characteristic
|
||||
entity_attribute_characteristic: Characteristic
|
||||
|
||||
def __init__(self) -> None:
|
||||
# TODO not the final implementation
|
||||
self.remote_command_characteristic = Characteristic(
|
||||
GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC,
|
||||
Characteristic.Properties.NOTIFY
|
||||
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
|
||||
Characteristic.Permissions.WRITEABLE,
|
||||
)
|
||||
|
||||
# TODO not the final implementation
|
||||
self.entity_update_characteristic = Characteristic(
|
||||
GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC,
|
||||
Characteristic.Properties.NOTIFY | Characteristic.Properties.WRITE,
|
||||
Characteristic.Permissions.WRITEABLE,
|
||||
)
|
||||
|
||||
# TODO not the final implementation
|
||||
self.entity_attribute_characteristic = Characteristic(
|
||||
GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC,
|
||||
Characteristic.Properties.READ
|
||||
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
|
||||
Characteristic.Permissions.WRITEABLE | Characteristic.Permissions.READABLE,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
[
|
||||
self.remote_command_characteristic,
|
||||
self.entity_update_characteristic,
|
||||
self.entity_attribute_characteristic,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# GATT Client-side
|
||||
# -----------------------------------------------------------------------------
|
||||
class AmsProxy(ProfileServiceProxy):
|
||||
SERVICE_CLASS = Ams
|
||||
|
||||
# NOTE: these don't use adapters, because the format for write and notifications
|
||||
# are different.
|
||||
remote_command: CharacteristicProxy[bytes]
|
||||
entity_update: CharacteristicProxy[bytes]
|
||||
entity_attribute: CharacteristicProxy[bytes]
|
||||
|
||||
def __init__(self, service_proxy: ServiceProxy):
|
||||
self.remote_command = service_proxy.get_required_characteristic_by_uuid(
|
||||
GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC
|
||||
)
|
||||
|
||||
self.entity_update = service_proxy.get_required_characteristic_by_uuid(
|
||||
GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC
|
||||
)
|
||||
|
||||
self.entity_attribute = service_proxy.get_required_characteristic_by_uuid(
|
||||
GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC
|
||||
)
|
||||
|
||||
|
||||
class AmsClient(utils.EventEmitter):
|
||||
EVENT_SUPPORTED_COMMANDS = "supported_commands"
|
||||
EVENT_PLAYER_NAME = "player_name"
|
||||
EVENT_PLAYER_PLAYBACK_INFO = "player_playback_info"
|
||||
EVENT_PLAYER_VOLUME = "player_volume"
|
||||
EVENT_QUEUE_COUNT = "queue_count"
|
||||
EVENT_QUEUE_INDEX = "queue_index"
|
||||
EVENT_QUEUE_SHUFFLE_MODE = "queue_shuffle_mode"
|
||||
EVENT_QUEUE_REPEAT_MODE = "queue_repeat_mode"
|
||||
EVENT_TRACK_ARTIST = "track_artist"
|
||||
EVENT_TRACK_ALBUM = "track_album"
|
||||
EVENT_TRACK_TITLE = "track_title"
|
||||
EVENT_TRACK_DURATION = "track_duration"
|
||||
|
||||
supported_commands: set[RemoteCommandId]
|
||||
player_name: str = ""
|
||||
player_playback_info: PlaybackInfo = PlaybackInfo(PlaybackState.PAUSED, 0.0, 0.0)
|
||||
player_volume: float = 1.0
|
||||
queue_count: int = 0
|
||||
queue_index: int = 0
|
||||
queue_shuffle_mode: ShuffleMode = ShuffleMode.OFF
|
||||
queue_repeat_mode: RepeatMode = RepeatMode.OFF
|
||||
track_artist: str = ""
|
||||
track_album: str = ""
|
||||
track_title: str = ""
|
||||
track_duration: float = 0.0
|
||||
|
||||
def __init__(self, ams_proxy: AmsProxy) -> None:
|
||||
super().__init__()
|
||||
self._ams_proxy = ams_proxy
|
||||
self._started = False
|
||||
self._read_attribute_semaphore = asyncio.Semaphore()
|
||||
self.supported_commands = set()
|
||||
|
||||
@classmethod
|
||||
async def for_peer(cls, peer: Peer) -> Optional[AmsClient]:
|
||||
ams_proxy = await peer.discover_service_and_create_proxy(AmsProxy)
|
||||
if ams_proxy is None:
|
||||
return None
|
||||
return cls(ams_proxy)
|
||||
|
||||
async def start(self) -> None:
|
||||
logger.debug("subscribing to remote command characteristic")
|
||||
await self._ams_proxy.remote_command.subscribe(
|
||||
self._on_remote_command_notification
|
||||
)
|
||||
|
||||
logger.debug("subscribing to entity update characteristic")
|
||||
await self._ams_proxy.entity_update.subscribe(
|
||||
lambda data: utils.AsyncRunner.spawn(
|
||||
self._on_entity_update_notification(data)
|
||||
)
|
||||
)
|
||||
|
||||
self._started = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
await self._ams_proxy.remote_command.unsubscribe(
|
||||
self._on_remote_command_notification
|
||||
)
|
||||
await self._ams_proxy.entity_update.unsubscribe(
|
||||
self._on_entity_update_notification
|
||||
)
|
||||
self._started = False
|
||||
|
||||
async def observe(
|
||||
self,
|
||||
entity: EntityId,
|
||||
attributes: Iterable[
|
||||
Union[PlayerAttributeId, QueueAttributeId, TrackAttributeId]
|
||||
],
|
||||
) -> None:
|
||||
await self._ams_proxy.entity_update.write_value(
|
||||
bytes([entity] + list(attributes)), with_response=True
|
||||
)
|
||||
|
||||
async def command(self, command: RemoteCommandId) -> None:
|
||||
await self._ams_proxy.remote_command.write_value(
|
||||
bytes([command]), with_response=True
|
||||
)
|
||||
|
||||
async def play(self) -> None:
|
||||
await self.command(RemoteCommandId.PLAY)
|
||||
|
||||
async def pause(self) -> None:
|
||||
await self.command(RemoteCommandId.PAUSE)
|
||||
|
||||
async def toggle_play_pause(self) -> None:
|
||||
await self.command(RemoteCommandId.TOGGLE_PLAY_PAUSE)
|
||||
|
||||
async def next_track(self) -> None:
|
||||
await self.command(RemoteCommandId.NEXT_TRACK)
|
||||
|
||||
async def previous_track(self) -> None:
|
||||
await self.command(RemoteCommandId.PREVIOUS_TRACK)
|
||||
|
||||
async def volume_up(self) -> None:
|
||||
await self.command(RemoteCommandId.VOLUME_UP)
|
||||
|
||||
async def volume_down(self) -> None:
|
||||
await self.command(RemoteCommandId.VOLUME_DOWN)
|
||||
|
||||
async def advance_repeat_mode(self) -> None:
|
||||
await self.command(RemoteCommandId.ADVANCE_REPEAT_MODE)
|
||||
|
||||
async def advance_shuffle_mode(self) -> None:
|
||||
await self.command(RemoteCommandId.ADVANCE_SHUFFLE_MODE)
|
||||
|
||||
async def skip_forward(self) -> None:
|
||||
await self.command(RemoteCommandId.SKIP_FORWARD)
|
||||
|
||||
async def skip_backward(self) -> None:
|
||||
await self.command(RemoteCommandId.SKIP_BACKWARD)
|
||||
|
||||
async def like_track(self) -> None:
|
||||
await self.command(RemoteCommandId.LIKE_TRACK)
|
||||
|
||||
async def dislike_track(self) -> None:
|
||||
await self.command(RemoteCommandId.DISLIKE_TRACK)
|
||||
|
||||
async def bookmark_track(self) -> None:
|
||||
await self.command(RemoteCommandId.BOOKMARK_TRACK)
|
||||
|
||||
def _on_remote_command_notification(self, data: bytes) -> None:
|
||||
supported_commands = [RemoteCommandId(command) for command in data]
|
||||
logger.debug(
|
||||
f"supported commands: {[command.name for command in supported_commands]}"
|
||||
)
|
||||
for command in supported_commands:
|
||||
self.supported_commands.add(command)
|
||||
|
||||
self.emit(self.EVENT_SUPPORTED_COMMANDS)
|
||||
|
||||
async def _on_entity_update_notification(self, data: bytes) -> None:
|
||||
entity = EntityId(data[0])
|
||||
flags = EntityUpdateFlags(data[2])
|
||||
value = data[3:]
|
||||
|
||||
if flags & EntityUpdateFlags.TRUNCATED:
|
||||
logger.debug("truncated attribute, fetching full value")
|
||||
|
||||
# Write the entity and attribute we're interested in
|
||||
# (protected by a semaphore, so that we only read one attribute at a time)
|
||||
async with self._read_attribute_semaphore:
|
||||
await self._ams_proxy.entity_attribute.write_value(
|
||||
data[:2], with_response=True
|
||||
)
|
||||
value = await self._ams_proxy.entity_attribute.read_value()
|
||||
|
||||
if entity == EntityId.PLAYER:
|
||||
player_attribute = PlayerAttributeId(data[1])
|
||||
if player_attribute == PlayerAttributeId.NAME:
|
||||
self.player_name = value.decode()
|
||||
self.emit(self.EVENT_PLAYER_NAME)
|
||||
elif player_attribute == PlayerAttributeId.PLAYBACK_INFO:
|
||||
playback_state_str, playback_rate_str, elapsed_time_str = (
|
||||
value.decode().split(",")
|
||||
)
|
||||
self.player_playback_info = PlaybackInfo(
|
||||
PlaybackState(int(playback_state_str)),
|
||||
float(playback_rate_str),
|
||||
float(elapsed_time_str),
|
||||
)
|
||||
self.emit(self.EVENT_PLAYER_PLAYBACK_INFO)
|
||||
elif player_attribute == PlayerAttributeId.VOLUME:
|
||||
self.player_volume = float(value.decode())
|
||||
self.emit(self.EVENT_PLAYER_VOLUME)
|
||||
else:
|
||||
logger.warning(f"received unknown player attribute {player_attribute}")
|
||||
|
||||
elif entity == EntityId.QUEUE:
|
||||
queue_attribute = QueueAttributeId(data[1])
|
||||
if queue_attribute == QueueAttributeId.COUNT:
|
||||
self.queue_count = int(value)
|
||||
self.emit(self.EVENT_QUEUE_COUNT)
|
||||
elif queue_attribute == QueueAttributeId.INDEX:
|
||||
self.queue_index = int(value)
|
||||
self.emit(self.EVENT_QUEUE_INDEX)
|
||||
elif queue_attribute == QueueAttributeId.REPEAT_MODE:
|
||||
self.queue_repeat_mode = RepeatMode(int(value))
|
||||
self.emit(self.EVENT_QUEUE_REPEAT_MODE)
|
||||
elif queue_attribute == QueueAttributeId.SHUFFLE_MODE:
|
||||
self.queue_shuffle_mode = ShuffleMode(int(value))
|
||||
self.emit(self.EVENT_QUEUE_SHUFFLE_MODE)
|
||||
else:
|
||||
logger.warning(f"received unknown queue attribute {queue_attribute}")
|
||||
|
||||
elif entity == EntityId.TRACK:
|
||||
track_attribute = TrackAttributeId(data[1])
|
||||
if track_attribute == TrackAttributeId.ARTIST:
|
||||
self.track_artist = value.decode()
|
||||
self.emit(self.EVENT_TRACK_ARTIST)
|
||||
elif track_attribute == TrackAttributeId.ALBUM:
|
||||
self.track_album = value.decode()
|
||||
self.emit(self.EVENT_TRACK_ALBUM)
|
||||
elif track_attribute == TrackAttributeId.TITLE:
|
||||
self.track_title = value.decode()
|
||||
self.emit(self.EVENT_TRACK_TITLE)
|
||||
elif track_attribute == TrackAttributeId.DURATION:
|
||||
self.track_duration = float(value.decode())
|
||||
self.emit(self.EVENT_TRACK_DURATION)
|
||||
else:
|
||||
logger.warning(f"received unknown track attribute {track_attribute}")
|
||||
|
||||
else:
|
||||
logger.warning(f"received unknown attribute ID {data[1]}")
|
||||
@@ -18,10 +18,13 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import enum
|
||||
import functools
|
||||
import logging
|
||||
import struct
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, Union
|
||||
from typing import Any, Optional, Union, TypeVar
|
||||
from collections.abc import Sequence
|
||||
|
||||
from bumble import utils
|
||||
from bumble import colors
|
||||
@@ -48,11 +51,11 @@ class ASE_Operation:
|
||||
See Audio Stream Control Service - 5 ASE Control operations.
|
||||
'''
|
||||
|
||||
classes: Dict[int, Type[ASE_Operation]] = {}
|
||||
op_code: int
|
||||
classes: dict[int, type[ASE_Operation]] = {}
|
||||
op_code: Opcode
|
||||
name: str
|
||||
fields: Optional[Sequence[Any]] = None
|
||||
ase_id: List[int]
|
||||
ase_id: Sequence[int]
|
||||
|
||||
class Opcode(enum.IntEnum):
|
||||
# fmt: off
|
||||
@@ -65,51 +68,30 @@ class ASE_Operation:
|
||||
UPDATE_METADATA = 0x07
|
||||
RELEASE = 0x08
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(pdu: bytes) -> ASE_Operation:
|
||||
@classmethod
|
||||
def from_bytes(cls, pdu: bytes) -> ASE_Operation:
|
||||
op_code = pdu[0]
|
||||
|
||||
cls = ASE_Operation.classes.get(op_code)
|
||||
if cls is None:
|
||||
instance = ASE_Operation(pdu)
|
||||
instance.name = ASE_Operation.Opcode(op_code).name
|
||||
instance.op_code = op_code
|
||||
return instance
|
||||
self = cls.__new__(cls)
|
||||
ASE_Operation.__init__(self, pdu)
|
||||
if self.fields is not None:
|
||||
self.init_from_bytes(pdu, 1)
|
||||
return self
|
||||
clazz = ASE_Operation.classes[op_code]
|
||||
return clazz(
|
||||
**hci.HCI_Object.dict_from_bytes(pdu, offset=1, fields=clazz.fields)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def subclass(fields):
|
||||
def inner(cls: Type[ASE_Operation]):
|
||||
try:
|
||||
operation = ASE_Operation.Opcode[cls.__name__[4:].upper()]
|
||||
cls.name = operation.name
|
||||
cls.op_code = operation
|
||||
except:
|
||||
raise KeyError(f'PDU name {cls.name} not found in Ase_Operation.Opcode')
|
||||
cls.fields = fields
|
||||
_OP = TypeVar("_OP", bound="ASE_Operation")
|
||||
|
||||
# Register a factory for this class
|
||||
ASE_Operation.classes[cls.op_code] = cls
|
||||
@classmethod
|
||||
def subclass(cls, clazz: type[_OP]) -> type[_OP]:
|
||||
clazz.name = f"ASE_{clazz.op_code.name.upper()}"
|
||||
clazz.fields = hci.HCI_Object.fields_from_dataclass(clazz)
|
||||
# Register a factory for this class
|
||||
ASE_Operation.classes[clazz.op_code] = clazz
|
||||
return clazz
|
||||
|
||||
return cls
|
||||
|
||||
return inner
|
||||
|
||||
def __init__(self, pdu: Optional[bytes] = None, **kwargs) -> None:
|
||||
if self.fields is not None and kwargs:
|
||||
hci.HCI_Object.init_from_fields(self, self.fields, kwargs)
|
||||
if pdu is None:
|
||||
pdu = bytes([self.op_code]) + hci.HCI_Object.dict_to_bytes(
|
||||
kwargs, self.fields
|
||||
)
|
||||
self.pdu = pdu
|
||||
|
||||
def init_from_bytes(self, pdu: bytes, offset: int):
|
||||
return hci.HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
||||
@functools.cached_property
|
||||
def pdu(self) -> bytes:
|
||||
return bytes([self.op_code]) + hci.HCI_Object.dict_to_bytes(
|
||||
self.__dict__, self.fields
|
||||
)
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return self.pdu
|
||||
@@ -124,105 +106,128 @@ class ASE_Operation:
|
||||
return result
|
||||
|
||||
|
||||
@ASE_Operation.subclass(
|
||||
[
|
||||
[
|
||||
('ase_id', 1),
|
||||
('target_latency', 1),
|
||||
('target_phy', 1),
|
||||
('codec_id', hci.CodingFormat.parse_from_bytes),
|
||||
('codec_specific_configuration', 'v'),
|
||||
],
|
||||
]
|
||||
)
|
||||
@ASE_Operation.subclass
|
||||
@dataclass
|
||||
class ASE_Config_Codec(ASE_Operation):
|
||||
'''
|
||||
See Audio Stream Control Service 5.1 - Config Codec Operation
|
||||
'''
|
||||
|
||||
target_latency: List[int]
|
||||
target_phy: List[int]
|
||||
codec_id: List[hci.CodingFormat]
|
||||
codec_specific_configuration: List[bytes]
|
||||
op_code = ASE_Operation.Opcode.CONFIG_CODEC
|
||||
|
||||
ase_id: Sequence[int] = field(metadata=hci.metadata(1, list_begin=True))
|
||||
target_latency: Sequence[int] = field(metadata=hci.metadata(1))
|
||||
target_phy: Sequence[int] = field(metadata=hci.metadata(1))
|
||||
codec_id: Sequence[hci.CodingFormat] = field(
|
||||
metadata=hci.metadata(hci.CodingFormat.parse_from_bytes)
|
||||
)
|
||||
codec_specific_configuration: Sequence[bytes] = field(
|
||||
metadata=hci.metadata('v', list_end=True)
|
||||
)
|
||||
|
||||
|
||||
@ASE_Operation.subclass(
|
||||
[
|
||||
[
|
||||
('ase_id', 1),
|
||||
('cig_id', 1),
|
||||
('cis_id', 1),
|
||||
('sdu_interval', 3),
|
||||
('framing', 1),
|
||||
('phy', 1),
|
||||
('max_sdu', 2),
|
||||
('retransmission_number', 1),
|
||||
('max_transport_latency', 2),
|
||||
('presentation_delay', 3),
|
||||
],
|
||||
]
|
||||
)
|
||||
@ASE_Operation.subclass
|
||||
@dataclass
|
||||
class ASE_Config_QOS(ASE_Operation):
|
||||
'''
|
||||
See Audio Stream Control Service 5.2 - Config Qos Operation
|
||||
'''
|
||||
|
||||
cig_id: List[int]
|
||||
cis_id: List[int]
|
||||
sdu_interval: List[int]
|
||||
framing: List[int]
|
||||
phy: List[int]
|
||||
max_sdu: List[int]
|
||||
retransmission_number: List[int]
|
||||
max_transport_latency: List[int]
|
||||
presentation_delay: List[int]
|
||||
op_code = ASE_Operation.Opcode.CONFIG_QOS
|
||||
|
||||
ase_id: Sequence[int] = field(metadata=hci.metadata(1, list_begin=True))
|
||||
cig_id: Sequence[int] = field(metadata=hci.metadata(1))
|
||||
cis_id: Sequence[int] = field(metadata=hci.metadata(1))
|
||||
sdu_interval: Sequence[int] = field(metadata=hci.metadata(3))
|
||||
framing: Sequence[int] = field(metadata=hci.metadata(1))
|
||||
phy: Sequence[int] = field(metadata=hci.metadata(1))
|
||||
max_sdu: Sequence[int] = field(metadata=hci.metadata(2))
|
||||
retransmission_number: Sequence[int] = field(metadata=hci.metadata(1))
|
||||
max_transport_latency: Sequence[int] = field(metadata=hci.metadata(2))
|
||||
presentation_delay: Sequence[int] = field(metadata=hci.metadata(3, list_end=True))
|
||||
|
||||
|
||||
@ASE_Operation.subclass([[('ase_id', 1), ('metadata', 'v')]])
|
||||
@ASE_Operation.subclass
|
||||
@dataclass
|
||||
class ASE_Enable(ASE_Operation):
|
||||
'''
|
||||
See Audio Stream Control Service 5.3 - Enable Operation
|
||||
'''
|
||||
|
||||
metadata: bytes
|
||||
op_code = ASE_Operation.Opcode.ENABLE
|
||||
|
||||
ase_id: Sequence[int] = field(metadata=hci.metadata(1, list_begin=True))
|
||||
metadata: Sequence[bytes] = field(metadata=hci.metadata('v', list_end=True))
|
||||
|
||||
|
||||
@ASE_Operation.subclass([[('ase_id', 1)]])
|
||||
@ASE_Operation.subclass
|
||||
@dataclass
|
||||
class ASE_Receiver_Start_Ready(ASE_Operation):
|
||||
'''
|
||||
See Audio Stream Control Service 5.4 - Receiver Start Ready Operation
|
||||
'''
|
||||
|
||||
op_code = ASE_Operation.Opcode.RECEIVER_START_READY
|
||||
|
||||
@ASE_Operation.subclass([[('ase_id', 1)]])
|
||||
ase_id: Sequence[int] = field(
|
||||
metadata=hci.metadata(1, list_begin=True, list_end=True)
|
||||
)
|
||||
|
||||
|
||||
@ASE_Operation.subclass
|
||||
@dataclass
|
||||
class ASE_Disable(ASE_Operation):
|
||||
'''
|
||||
See Audio Stream Control Service 5.5 - Disable Operation
|
||||
'''
|
||||
|
||||
op_code = ASE_Operation.Opcode.DISABLE
|
||||
|
||||
@ASE_Operation.subclass([[('ase_id', 1)]])
|
||||
ase_id: Sequence[int] = field(
|
||||
metadata=hci.metadata(1, list_begin=True, list_end=True)
|
||||
)
|
||||
|
||||
|
||||
@ASE_Operation.subclass
|
||||
@dataclass
|
||||
class ASE_Receiver_Stop_Ready(ASE_Operation):
|
||||
'''
|
||||
See Audio Stream Control Service 5.6 - Receiver Stop Ready Operation
|
||||
'''
|
||||
|
||||
op_code = ASE_Operation.Opcode.RECEIVER_STOP_READY
|
||||
|
||||
@ASE_Operation.subclass([[('ase_id', 1), ('metadata', 'v')]])
|
||||
ase_id: Sequence[int] = field(
|
||||
metadata=hci.metadata(1, list_begin=True, list_end=True)
|
||||
)
|
||||
|
||||
|
||||
@ASE_Operation.subclass
|
||||
@dataclass
|
||||
class ASE_Update_Metadata(ASE_Operation):
|
||||
'''
|
||||
See Audio Stream Control Service 5.7 - Update Metadata Operation
|
||||
'''
|
||||
|
||||
metadata: List[bytes]
|
||||
op_code = ASE_Operation.Opcode.UPDATE_METADATA
|
||||
|
||||
ase_id: Sequence[int] = field(metadata=hci.metadata(1, list_begin=True))
|
||||
metadata: Sequence[bytes] = field(metadata=hci.metadata('v', list_end=True))
|
||||
|
||||
|
||||
@ASE_Operation.subclass([[('ase_id', 1)]])
|
||||
@ASE_Operation.subclass
|
||||
@dataclass
|
||||
class ASE_Release(ASE_Operation):
|
||||
'''
|
||||
See Audio Stream Control Service 5.8 - Release Operation
|
||||
'''
|
||||
|
||||
op_code = ASE_Operation.Opcode.RELEASE
|
||||
|
||||
ase_id: Sequence[int] = field(
|
||||
metadata=hci.metadata(1, list_begin=True, list_end=True)
|
||||
)
|
||||
|
||||
|
||||
class AseResponseCode(enum.IntEnum):
|
||||
# fmt: off
|
||||
@@ -338,22 +343,16 @@ class AseStateMachine(gatt.Characteristic):
|
||||
self.service.device.EVENT_CIS_ESTABLISHMENT, self.on_cis_establishment
|
||||
)
|
||||
|
||||
def on_cis_request(
|
||||
self,
|
||||
acl_connection: device.Connection,
|
||||
cis_handle: int,
|
||||
cig_id: int,
|
||||
cis_id: int,
|
||||
) -> None:
|
||||
def on_cis_request(self, cis_link: device.CisLink) -> None:
|
||||
if (
|
||||
cig_id == self.cig_id
|
||||
and cis_id == self.cis_id
|
||||
cis_link.cig_id == self.cig_id
|
||||
and cis_link.cis_id == self.cis_id
|
||||
and self.state == self.State.ENABLING
|
||||
):
|
||||
utils.cancel_on_event(
|
||||
acl_connection,
|
||||
cis_link.acl_connection,
|
||||
'flush',
|
||||
self.service.device.accept_cis_request(cis_handle),
|
||||
self.service.device.accept_cis_request(cis_link),
|
||||
)
|
||||
|
||||
def on_cis_establishment(self, cis_link: device.CisLink) -> None:
|
||||
@@ -384,7 +383,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
target_phy: int,
|
||||
codec_id: hci.CodingFormat,
|
||||
codec_specific_configuration: bytes,
|
||||
) -> Tuple[AseResponseCode, AseReasonCode]:
|
||||
) -> tuple[AseResponseCode, AseReasonCode]:
|
||||
if self.state not in (
|
||||
self.State.IDLE,
|
||||
self.State.CODEC_CONFIGURED,
|
||||
@@ -420,7 +419,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
retransmission_number: int,
|
||||
max_transport_latency: int,
|
||||
presentation_delay: int,
|
||||
) -> Tuple[AseResponseCode, AseReasonCode]:
|
||||
) -> tuple[AseResponseCode, AseReasonCode]:
|
||||
if self.state not in (
|
||||
AseStateMachine.State.CODEC_CONFIGURED,
|
||||
AseStateMachine.State.QOS_CONFIGURED,
|
||||
@@ -444,7 +443,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
|
||||
return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
|
||||
|
||||
def on_enable(self, metadata: bytes) -> Tuple[AseResponseCode, AseReasonCode]:
|
||||
def on_enable(self, metadata: bytes) -> tuple[AseResponseCode, AseReasonCode]:
|
||||
if self.state != AseStateMachine.State.QOS_CONFIGURED:
|
||||
return (
|
||||
AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
|
||||
@@ -456,7 +455,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
|
||||
return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
|
||||
|
||||
def on_receiver_start_ready(self) -> Tuple[AseResponseCode, AseReasonCode]:
|
||||
def on_receiver_start_ready(self) -> tuple[AseResponseCode, AseReasonCode]:
|
||||
if self.state != AseStateMachine.State.ENABLING:
|
||||
return (
|
||||
AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
|
||||
@@ -465,7 +464,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
self.state = self.State.STREAMING
|
||||
return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
|
||||
|
||||
def on_disable(self) -> Tuple[AseResponseCode, AseReasonCode]:
|
||||
def on_disable(self) -> tuple[AseResponseCode, AseReasonCode]:
|
||||
if self.state not in (
|
||||
AseStateMachine.State.ENABLING,
|
||||
AseStateMachine.State.STREAMING,
|
||||
@@ -480,7 +479,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
self.state = self.State.DISABLING
|
||||
return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
|
||||
|
||||
def on_receiver_stop_ready(self) -> Tuple[AseResponseCode, AseReasonCode]:
|
||||
def on_receiver_stop_ready(self) -> tuple[AseResponseCode, AseReasonCode]:
|
||||
if (
|
||||
self.role != AudioRole.SOURCE
|
||||
or self.state != AseStateMachine.State.DISABLING
|
||||
@@ -494,7 +493,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
|
||||
def on_update_metadata(
|
||||
self, metadata: bytes
|
||||
) -> Tuple[AseResponseCode, AseReasonCode]:
|
||||
) -> tuple[AseResponseCode, AseReasonCode]:
|
||||
if self.state not in (
|
||||
AseStateMachine.State.ENABLING,
|
||||
AseStateMachine.State.STREAMING,
|
||||
@@ -506,7 +505,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
self.metadata = le_audio.Metadata.from_bytes(metadata)
|
||||
return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
|
||||
|
||||
def on_release(self) -> Tuple[AseResponseCode, AseReasonCode]:
|
||||
def on_release(self) -> tuple[AseResponseCode, AseReasonCode]:
|
||||
if self.state == AseStateMachine.State.IDLE:
|
||||
return (
|
||||
AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
|
||||
@@ -516,7 +515,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
|
||||
async def remove_cis_async():
|
||||
if self.cis_link:
|
||||
await self.cis_link.remove_data_path(self.role)
|
||||
await self.cis_link.remove_data_path([self.role])
|
||||
self.state = self.State.IDLE
|
||||
await self.service.device.notify_subscribers(self, self.value)
|
||||
|
||||
@@ -604,7 +603,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
class AudioStreamControlService(gatt.TemplateService):
|
||||
UUID = gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE
|
||||
|
||||
ase_state_machines: Dict[int, AseStateMachine]
|
||||
ase_state_machines: dict[int, AseStateMachine]
|
||||
ase_control_point: gatt.Characteristic[bytes]
|
||||
_active_client: Optional[device.Connection] = None
|
||||
|
||||
@@ -649,7 +648,9 @@ class AudioStreamControlService(gatt.TemplateService):
|
||||
ase.state = AseStateMachine.State.IDLE
|
||||
self._active_client = None
|
||||
|
||||
def on_write_ase_control_point(self, connection, data):
|
||||
def on_write_ase_control_point(
|
||||
self, connection: device.Connection, data: bytes
|
||||
) -> None:
|
||||
if not self._active_client and connection:
|
||||
self._active_client = connection
|
||||
connection.once('disconnection', self._on_client_disconnected)
|
||||
@@ -658,7 +659,7 @@ class AudioStreamControlService(gatt.TemplateService):
|
||||
responses = []
|
||||
logger.debug(f'*** ASCS Write {operation} ***')
|
||||
|
||||
if operation.op_code == ASE_Operation.Opcode.CONFIG_CODEC:
|
||||
if isinstance(operation, ASE_Config_Codec):
|
||||
for ase_id, *args in zip(
|
||||
operation.ase_id,
|
||||
operation.target_latency,
|
||||
@@ -667,7 +668,7 @@ class AudioStreamControlService(gatt.TemplateService):
|
||||
operation.codec_specific_configuration,
|
||||
):
|
||||
responses.append(self.on_operation(operation.op_code, ase_id, args))
|
||||
elif operation.op_code == ASE_Operation.Opcode.CONFIG_QOS:
|
||||
elif isinstance(operation, ASE_Config_QOS):
|
||||
for ase_id, *args in zip(
|
||||
operation.ase_id,
|
||||
operation.cig_id,
|
||||
@@ -681,20 +682,20 @@ class AudioStreamControlService(gatt.TemplateService):
|
||||
operation.presentation_delay,
|
||||
):
|
||||
responses.append(self.on_operation(operation.op_code, ase_id, args))
|
||||
elif operation.op_code in (
|
||||
ASE_Operation.Opcode.ENABLE,
|
||||
ASE_Operation.Opcode.UPDATE_METADATA,
|
||||
):
|
||||
elif isinstance(operation, (ASE_Enable, ASE_Update_Metadata)):
|
||||
for ase_id, *args in zip(
|
||||
operation.ase_id,
|
||||
operation.metadata,
|
||||
):
|
||||
responses.append(self.on_operation(operation.op_code, ase_id, args))
|
||||
elif operation.op_code in (
|
||||
ASE_Operation.Opcode.RECEIVER_START_READY,
|
||||
ASE_Operation.Opcode.DISABLE,
|
||||
ASE_Operation.Opcode.RECEIVER_STOP_READY,
|
||||
ASE_Operation.Opcode.RELEASE,
|
||||
elif isinstance(
|
||||
operation,
|
||||
(
|
||||
ASE_Receiver_Start_Ready,
|
||||
ASE_Disable,
|
||||
ASE_Receiver_Stop_Ready,
|
||||
ASE_Release,
|
||||
),
|
||||
):
|
||||
for ase_id in operation.ase_id:
|
||||
responses.append(self.on_operation(operation.op_code, ase_id, []))
|
||||
@@ -723,8 +724,8 @@ class AudioStreamControlService(gatt.TemplateService):
|
||||
class AudioStreamControlServiceProxy(gatt_client.ProfileServiceProxy):
|
||||
SERVICE_CLASS = AudioStreamControlService
|
||||
|
||||
sink_ase: List[gatt_client.CharacteristicProxy[bytes]]
|
||||
source_ase: List[gatt_client.CharacteristicProxy[bytes]]
|
||||
sink_ase: list[gatt_client.CharacteristicProxy[bytes]]
|
||||
source_ase: list[gatt_client.CharacteristicProxy[bytes]]
|
||||
ase_control_point: gatt_client.CharacteristicProxy[bytes]
|
||||
|
||||
def __init__(self, service_proxy: gatt_client.ServiceProxy):
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import enum
|
||||
import struct
|
||||
import logging
|
||||
from typing import List, Optional, Callable, Union, Any
|
||||
from typing import Optional, Callable, Union, Any
|
||||
|
||||
from bumble import l2cap
|
||||
from bumble import utils
|
||||
@@ -103,7 +103,7 @@ class AshaService(gatt.TemplateService):
|
||||
def __init__(
|
||||
self,
|
||||
capability: int,
|
||||
hisyncid: Union[List[int], bytes],
|
||||
hisyncid: Union[list[int], bytes],
|
||||
device: Device,
|
||||
psm: int = 0,
|
||||
audio_sink: Optional[Callable[[bytes], Any]] = None,
|
||||
|
||||
@@ -24,7 +24,6 @@ import enum
|
||||
import struct
|
||||
import functools
|
||||
import logging
|
||||
from typing import List
|
||||
from typing_extensions import Self
|
||||
|
||||
from bumble import core
|
||||
@@ -282,7 +281,7 @@ class UnicastServerAdvertisingData:
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def bits_to_channel_counts(data: int) -> List[int]:
|
||||
def bits_to_channel_counts(data: int) -> list[int]:
|
||||
pos = 0
|
||||
counts = []
|
||||
while data != 0:
|
||||
@@ -527,7 +526,7 @@ class BasicAudioAnnouncement:
|
||||
codec_id: hci.CodingFormat
|
||||
codec_specific_configuration: CodecSpecificConfiguration
|
||||
metadata: le_audio.Metadata
|
||||
bis: List[BasicAudioAnnouncement.BIS]
|
||||
bis: list[BasicAudioAnnouncement.BIS]
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
metadata_bytes = bytes(self.metadata)
|
||||
@@ -545,7 +544,7 @@ class BasicAudioAnnouncement:
|
||||
)
|
||||
|
||||
presentation_delay: int
|
||||
subgroups: List[BasicAudioAnnouncement.Subgroup]
|
||||
subgroups: list[BasicAudioAnnouncement.Subgroup]
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> Self:
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
import struct
|
||||
from typing import Optional, Tuple
|
||||
from typing import Optional
|
||||
|
||||
from bumble import core
|
||||
from bumble import crypto
|
||||
@@ -228,7 +228,7 @@ class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
|
||||
):
|
||||
self.set_member_rank = characteristics[0]
|
||||
|
||||
async def read_set_identity_resolving_key(self) -> Tuple[SirkType, bytes]:
|
||||
async def read_set_identity_resolving_key(self) -> tuple[SirkType, bytes]:
|
||||
'''Reads SIRK and decrypts if encrypted.'''
|
||||
response = await self.set_identity_resolving_key.read_value()
|
||||
if len(response) != SET_IDENTITY_RESOLVING_KEY_LENGTH + 1:
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import struct
|
||||
from typing import Optional, Tuple
|
||||
from typing import Optional
|
||||
|
||||
from bumble.gatt import (
|
||||
GATT_DEVICE_INFORMATION_SERVICE,
|
||||
@@ -60,7 +60,7 @@ class DeviceInformationService(TemplateService):
|
||||
hardware_revision: Optional[str] = None,
|
||||
firmware_revision: Optional[str] = None,
|
||||
software_revision: Optional[str] = None,
|
||||
system_id: Optional[Tuple[int, int]] = None, # (OUI, Manufacturer ID)
|
||||
system_id: Optional[tuple[int, int]] = None, # (OUI, Manufacturer ID)
|
||||
ieee_regulatory_certification_data_list: Optional[bytes] = None,
|
||||
# TODO: pnp_id
|
||||
):
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
import struct
|
||||
from typing import Optional, Tuple, Union
|
||||
from typing import Optional, Union
|
||||
|
||||
from bumble.core import Appearance
|
||||
from bumble.gatt import (
|
||||
@@ -54,7 +54,7 @@ class GenericAccessService(TemplateService):
|
||||
appearance_characteristic: Characteristic[bytes]
|
||||
|
||||
def __init__(
|
||||
self, device_name: str, appearance: Union[Appearance, Tuple[int, int], int] = 0
|
||||
self, device_name: str, appearance: Union[Appearance, tuple[int, int], int] = 0
|
||||
):
|
||||
if isinstance(appearance, int):
|
||||
appearance_int = appearance
|
||||
|
||||
@@ -20,7 +20,7 @@ import asyncio
|
||||
import functools
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Set, Union
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from bumble import att, gatt, gatt_adapters, gatt_client
|
||||
from bumble.core import InvalidArgumentError, InvalidStateError
|
||||
@@ -228,23 +228,25 @@ class HearingAccessService(gatt.TemplateService):
|
||||
hearing_aid_preset_control_point: gatt.Characteristic[bytes]
|
||||
active_preset_index_characteristic: gatt.Characteristic[bytes]
|
||||
active_preset_index: int
|
||||
active_preset_index_per_device: Dict[Address, int]
|
||||
active_preset_index_per_device: dict[Address, int]
|
||||
|
||||
device: Device
|
||||
|
||||
server_features: HearingAidFeatures
|
||||
preset_records: Dict[int, PresetRecord] # key is the preset index
|
||||
preset_records: dict[int, PresetRecord] # key is the preset index
|
||||
read_presets_request_in_progress: bool
|
||||
|
||||
preset_changed_operations_history_per_device: Dict[
|
||||
Address, List[PresetChangedOperation]
|
||||
other_server_in_binaural_set: Optional[HearingAccessService] = None
|
||||
|
||||
preset_changed_operations_history_per_device: dict[
|
||||
Address, list[PresetChangedOperation]
|
||||
]
|
||||
|
||||
# Keep an updated list of connected client to send notification to
|
||||
currently_connected_clients: Set[Connection]
|
||||
currently_connected_clients: set[Connection]
|
||||
|
||||
def __init__(
|
||||
self, device: Device, features: HearingAidFeatures, presets: List[PresetRecord]
|
||||
self, device: Device, features: HearingAidFeatures, presets: list[PresetRecord]
|
||||
) -> None:
|
||||
self.active_preset_index_per_device = {}
|
||||
self.read_presets_request_in_progress = False
|
||||
@@ -333,7 +335,7 @@ class HearingAccessService(gatt.TemplateService):
|
||||
# Update the active preset index if needed
|
||||
await self.notify_active_preset_for_connection(connection)
|
||||
|
||||
utils.cancel_on_event(connection, 'disconnection', on_connection_async())
|
||||
connection.cancel_on_disconnection(on_connection_async())
|
||||
|
||||
def _on_read_active_preset_index(self, connection: Connection) -> bytes:
|
||||
del connection # Unused
|
||||
@@ -379,7 +381,7 @@ class HearingAccessService(gatt.TemplateService):
|
||||
utils.AsyncRunner.spawn(self._read_preset_response(connection, presets))
|
||||
|
||||
async def _read_preset_response(
|
||||
self, connection: Connection, presets: List[PresetRecord]
|
||||
self, connection: Connection, presets: list[PresetRecord]
|
||||
):
|
||||
# If the ATT bearer is terminated before all notifications or indications are sent, then the server shall consider the Read Presets Request operation aborted and shall not either continue or restart the operation when the client reconnects.
|
||||
try:
|
||||
@@ -513,7 +515,7 @@ class HearingAccessService(gatt.TemplateService):
|
||||
for connection in self.currently_connected_clients:
|
||||
await self.notify_active_preset_for_connection(connection)
|
||||
|
||||
async def set_active_preset(self, connection: Connection, value: bytes) -> None:
|
||||
async def set_active_preset(self, value: bytes) -> None:
|
||||
index = value[1]
|
||||
preset = self.preset_records.get(index, None)
|
||||
if (
|
||||
@@ -530,10 +532,10 @@ class HearingAccessService(gatt.TemplateService):
|
||||
self.active_preset_index = index
|
||||
await self.notify_active_preset()
|
||||
|
||||
async def _on_set_active_preset(self, connection: Connection, value: bytes):
|
||||
await self.set_active_preset(connection, value)
|
||||
async def _on_set_active_preset(self, _: Connection, value: bytes):
|
||||
await self.set_active_preset(value)
|
||||
|
||||
async def set_next_or_previous_preset(self, connection: Connection, is_previous):
|
||||
async def set_next_or_previous_preset(self, is_previous):
|
||||
'''Set the next or the previous preset as active'''
|
||||
|
||||
if self.active_preset_index == 0x00:
|
||||
@@ -563,48 +565,47 @@ class HearingAccessService(gatt.TemplateService):
|
||||
self.active_preset_index = first_preset.index
|
||||
await self.notify_active_preset()
|
||||
|
||||
async def _on_set_next_preset(
|
||||
self, connection: Connection, __value__: bytes
|
||||
) -> None:
|
||||
await self.set_next_or_previous_preset(connection, False)
|
||||
async def _on_set_next_preset(self, _: Connection, __value__: bytes) -> None:
|
||||
await self.set_next_or_previous_preset(False)
|
||||
|
||||
async def _on_set_previous_preset(
|
||||
self, connection: Connection, __value__: bytes
|
||||
) -> None:
|
||||
await self.set_next_or_previous_preset(connection, True)
|
||||
async def _on_set_previous_preset(self, _: Connection, __value__: bytes) -> None:
|
||||
await self.set_next_or_previous_preset(True)
|
||||
|
||||
async def _on_set_active_preset_synchronized_locally(
|
||||
self, connection: Connection, value: bytes
|
||||
self, _: Connection, value: bytes
|
||||
):
|
||||
if (
|
||||
self.server_features.preset_synchronization_support
|
||||
== PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_SUPPORTED
|
||||
== PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_NOT_SUPPORTED
|
||||
):
|
||||
raise att.ATT_Error(ErrorCode.PRESET_SYNCHRONIZATION_NOT_SUPPORTED)
|
||||
await self.set_active_preset(connection, value)
|
||||
# TODO (low priority) inform other server of the change
|
||||
await self.set_active_preset(value)
|
||||
if self.other_server_in_binaural_set:
|
||||
await self.other_server_in_binaural_set.set_active_preset(value)
|
||||
|
||||
async def _on_set_next_preset_synchronized_locally(
|
||||
self, connection: Connection, __value__: bytes
|
||||
self, _: Connection, __value__: bytes
|
||||
):
|
||||
if (
|
||||
self.server_features.preset_synchronization_support
|
||||
== PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_SUPPORTED
|
||||
== PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_NOT_SUPPORTED
|
||||
):
|
||||
raise att.ATT_Error(ErrorCode.PRESET_SYNCHRONIZATION_NOT_SUPPORTED)
|
||||
await self.set_next_or_previous_preset(connection, False)
|
||||
# TODO (low priority) inform other server of the change
|
||||
await self.set_next_or_previous_preset(False)
|
||||
if self.other_server_in_binaural_set:
|
||||
await self.other_server_in_binaural_set.set_next_or_previous_preset(False)
|
||||
|
||||
async def _on_set_previous_preset_synchronized_locally(
|
||||
self, connection: Connection, __value__: bytes
|
||||
self, _: Connection, __value__: bytes
|
||||
):
|
||||
if (
|
||||
self.server_features.preset_synchronization_support
|
||||
== PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_SUPPORTED
|
||||
== PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_NOT_SUPPORTED
|
||||
):
|
||||
raise att.ATT_Error(ErrorCode.PRESET_SYNCHRONIZATION_NOT_SUPPORTED)
|
||||
await self.set_next_or_previous_preset(connection, True)
|
||||
# TODO (low priority) inform other server of the change
|
||||
await self.set_next_or_previous_preset(True)
|
||||
if self.other_server_in_binaural_set:
|
||||
await self.other_server_in_binaural_set.set_next_or_previous_preset(True)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -19,7 +19,7 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import enum
|
||||
import struct
|
||||
from typing import Any, List, Type
|
||||
from typing import Any
|
||||
from typing_extensions import Self
|
||||
|
||||
from bumble.profiles import bap
|
||||
@@ -108,13 +108,13 @@ class Metadata:
|
||||
return self.data
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls: Type[Self], data: bytes) -> Self:
|
||||
def from_bytes(cls: type[Self], data: bytes) -> Self:
|
||||
return cls(tag=Metadata.Tag(data[0]), data=data[1:])
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return bytes([len(self.data) + 1, self.tag]) + self.data
|
||||
|
||||
entries: List[Entry] = dataclasses.field(default_factory=list)
|
||||
entries: list[Entry] = dataclasses.field(default_factory=list)
|
||||
|
||||
def pretty_print(self, indent: str) -> str:
|
||||
"""Convenience method to generate a string with one key-value pair per line."""
|
||||
@@ -140,7 +140,7 @@ class Metadata:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls: Type[Self], data: bytes) -> Self:
|
||||
def from_bytes(cls: type[Self], data: bytes) -> Self:
|
||||
entries = []
|
||||
offset = 0
|
||||
length = len(data)
|
||||
|
||||
@@ -29,7 +29,7 @@ from bumble import gatt
|
||||
from bumble import gatt_client
|
||||
from bumble import utils
|
||||
|
||||
from typing import Type, Optional, ClassVar, Dict, TYPE_CHECKING
|
||||
from typing import Optional, ClassVar, TYPE_CHECKING
|
||||
from typing_extensions import Self
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -167,7 +167,7 @@ class ObjectId(int):
|
||||
'''See Media Control Service 4.4.2. Object ID field.'''
|
||||
|
||||
@classmethod
|
||||
def create_from_bytes(cls: Type[Self], data: bytes) -> Self:
|
||||
def create_from_bytes(cls: type[Self], data: bytes) -> Self:
|
||||
return cls(int.from_bytes(data, byteorder='little', signed=False))
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
@@ -182,7 +182,7 @@ class GroupObjectType:
|
||||
object_id: ObjectId
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls: Type[Self], data: bytes) -> Self:
|
||||
def from_bytes(cls: type[Self], data: bytes) -> Self:
|
||||
return cls(
|
||||
object_type=ObjectType(data[0]),
|
||||
object_id=ObjectId.create_from_bytes(data[1:]),
|
||||
@@ -310,7 +310,7 @@ class MediaControlServiceProxy(
|
||||
):
|
||||
SERVICE_CLASS = MediaControlService
|
||||
|
||||
_CHARACTERISTICS: ClassVar[Dict[str, core.UUID]] = {
|
||||
_CHARACTERISTICS: ClassVar[dict[str, core.UUID]] = {
|
||||
'media_player_name': gatt.GATT_MEDIA_PLAYER_NAME_CHARACTERISTIC,
|
||||
'media_player_icon_object_id': gatt.GATT_MEDIA_PLAYER_ICON_OBJECT_ID_CHARACTERISTIC,
|
||||
'media_player_icon_url': gatt.GATT_MEDIA_PLAYER_ICON_URL_CHARACTERISTIC,
|
||||
|
||||
@@ -20,7 +20,7 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import enum
|
||||
|
||||
from typing import Optional, Sequence
|
||||
from typing import Sequence
|
||||
|
||||
from bumble import att
|
||||
from bumble import utils
|
||||
@@ -161,10 +161,8 @@ class VolumeControlService(gatt.TemplateService):
|
||||
handler = getattr(self, '_on_' + opcode.name.lower())
|
||||
if handler(*value[2:]):
|
||||
self.change_counter = (self.change_counter + 1) % 256
|
||||
utils.cancel_on_event(
|
||||
connection,
|
||||
'disconnection',
|
||||
connection.device.notify_subscribers(attribute=self.volume_state),
|
||||
connection.cancel_on_disconnection(
|
||||
connection.device.notify_subscribers(attribute=self.volume_state)
|
||||
)
|
||||
self.emit(self.EVENT_VOLUME_STATE_CHANGE)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import asyncio
|
||||
import collections
|
||||
import dataclasses
|
||||
import enum
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
||||
from typing import Callable, Optional, Union, TYPE_CHECKING
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
||||
# -----------------------------------------------------------------------------
|
||||
def make_service_sdp_records(
|
||||
service_record_handle: int, channel: int, uuid: Optional[UUID] = None
|
||||
) -> List[sdp.ServiceAttribute]:
|
||||
) -> list[sdp.ServiceAttribute]:
|
||||
"""
|
||||
Create SDP records for an RFComm service given a channel number and an
|
||||
optional UUID. A Service Class Attribute is included only if the UUID is not None.
|
||||
@@ -169,7 +169,7 @@ def make_service_sdp_records(
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def find_rfcomm_channels(connection: Connection) -> Dict[int, List[UUID]]:
|
||||
async def find_rfcomm_channels(connection: Connection) -> dict[int, list[UUID]]:
|
||||
"""Searches all RFCOMM channels and their associated UUID from SDP service records.
|
||||
|
||||
Args:
|
||||
@@ -188,7 +188,7 @@ async def find_rfcomm_channels(connection: Connection) -> Dict[int, List[UUID]]:
|
||||
],
|
||||
)
|
||||
for attribute_lists in search_result:
|
||||
service_classes: List[UUID] = []
|
||||
service_classes: list[UUID] = []
|
||||
channel: Optional[int] = None
|
||||
for attribute in attribute_lists:
|
||||
# The layout is [[L2CAP_PROTOCOL], [RFCOMM_PROTOCOL, RFCOMM_CHANNEL]].
|
||||
@@ -275,7 +275,7 @@ class RFCOMM_Frame:
|
||||
self.fcs = compute_fcs(bytes([self.address, self.control]) + self.length)
|
||||
|
||||
@staticmethod
|
||||
def parse_mcc(data) -> Tuple[int, bool, bytes]:
|
||||
def parse_mcc(data) -> tuple[int, bool, bytes]:
|
||||
mcc_type = data[0] >> 2
|
||||
c_r = bool((data[0] >> 1) & 1)
|
||||
length = data[1]
|
||||
@@ -771,8 +771,8 @@ class Multiplexer(utils.EventEmitter):
|
||||
connection_result: Optional[asyncio.Future]
|
||||
disconnection_result: Optional[asyncio.Future]
|
||||
open_result: Optional[asyncio.Future]
|
||||
acceptor: Optional[Callable[[int], Optional[Tuple[int, int]]]]
|
||||
dlcs: Dict[int, DLC]
|
||||
acceptor: Optional[Callable[[int], Optional[tuple[int, int]]]]
|
||||
dlcs: dict[int, DLC]
|
||||
|
||||
def __init__(self, l2cap_channel: l2cap.ClassicChannel, role: Role) -> None:
|
||||
super().__init__()
|
||||
@@ -1088,8 +1088,8 @@ class Server(utils.EventEmitter):
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.device = device
|
||||
self.acceptors: Dict[int, Callable[[DLC], None]] = {}
|
||||
self.dlc_configs: Dict[int, Tuple[int, int]] = {}
|
||||
self.acceptors: dict[int, Callable[[DLC], None]] = {}
|
||||
self.dlc_configs: dict[int, tuple[int, int]] = {}
|
||||
|
||||
# Register ourselves with the L2CAP channel manager
|
||||
self.l2cap_server = device.create_l2cap_server(
|
||||
@@ -1144,7 +1144,7 @@ class Server(utils.EventEmitter):
|
||||
# Notify
|
||||
self.emit(self.EVENT_START, multiplexer)
|
||||
|
||||
def accept_dlc(self, channel_number: int) -> Optional[Tuple[int, int]]:
|
||||
def accept_dlc(self, channel_number: int) -> Optional[tuple[int, int]]:
|
||||
return self.dlc_configs.get(channel_number)
|
||||
|
||||
def on_dlc(self, dlc: DLC) -> None:
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import struct
|
||||
from typing import List
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -60,7 +59,7 @@ class MediaPacket:
|
||||
sequence_number: int,
|
||||
timestamp: int,
|
||||
ssrc: int,
|
||||
csrc_list: List[int],
|
||||
csrc_list: list[int],
|
||||
payload_type: int,
|
||||
payload: bytes,
|
||||
) -> None:
|
||||
|
||||
@@ -19,7 +19,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
from typing import Iterable, NewType, Optional, Union, Sequence, Type, TYPE_CHECKING
|
||||
from typing import Iterable, NewType, Optional, Union, Sequence, TYPE_CHECKING
|
||||
from typing_extensions import Self
|
||||
|
||||
from bumble import core, l2cap
|
||||
@@ -547,7 +547,7 @@ class SDP_PDU:
|
||||
SDP_SERVICE_ATTRIBUTE_REQUEST: SDP_SERVICE_ATTRIBUTE_RESPONSE,
|
||||
SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST: SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE,
|
||||
}
|
||||
sdp_pdu_classes: dict[int, Type[SDP_PDU]] = {}
|
||||
sdp_pdu_classes: dict[int, type[SDP_PDU]] = {}
|
||||
name = None
|
||||
pdu_id = 0
|
||||
|
||||
|
||||
122
bumble/smp.py
122
bumble/smp.py
@@ -26,18 +26,13 @@ from __future__ import annotations
|
||||
import logging
|
||||
import asyncio
|
||||
import enum
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Type,
|
||||
cast,
|
||||
)
|
||||
|
||||
@@ -210,7 +205,7 @@ class SMP_Command:
|
||||
See Bluetooth spec @ Vol 3, Part H - 3 SECURITY MANAGER PROTOCOL
|
||||
'''
|
||||
|
||||
smp_classes: Dict[int, Type[SMP_Command]] = {}
|
||||
smp_classes: dict[int, type[SMP_Command]] = {}
|
||||
fields: Any
|
||||
code = 0
|
||||
name = ''
|
||||
@@ -254,7 +249,7 @@ class SMP_Command:
|
||||
|
||||
@staticmethod
|
||||
def key_distribution_str(value: int) -> str:
|
||||
key_types: List[str] = []
|
||||
key_types: list[str] = []
|
||||
if value & SMP_ENC_KEY_DISTRIBUTION_FLAG:
|
||||
key_types.append('ENC')
|
||||
if value & SMP_ID_KEY_DISTRIBUTION_FLAG:
|
||||
@@ -706,7 +701,7 @@ class Session:
|
||||
self.peer_identity_resolving_key = None
|
||||
self.peer_bd_addr: Optional[Address] = 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 = b''
|
||||
self.confirm_value = None
|
||||
self.passkey: Optional[int] = None
|
||||
@@ -767,7 +762,9 @@ class Session:
|
||||
|
||||
# OOB
|
||||
self.oob_data_flag = (
|
||||
1 if pairing_config.oob and pairing_config.oob.peer_data else 0
|
||||
1
|
||||
if pairing_config.oob and (not self.sc or pairing_config.oob.peer_data)
|
||||
else 0
|
||||
)
|
||||
|
||||
# Set up addresses
|
||||
@@ -814,7 +811,7 @@ class Session:
|
||||
self.tk = bytes(16)
|
||||
|
||||
@property
|
||||
def pkx(self) -> Tuple[bytes, bytes]:
|
||||
def pkx(self) -> tuple[bytes, bytes]:
|
||||
return (self.ecc_key.x[::-1], self.peer_public_key_x)
|
||||
|
||||
@property
|
||||
@@ -826,7 +823,7 @@ class Session:
|
||||
return self.pkx[0 if self.is_responder else 1]
|
||||
|
||||
@property
|
||||
def nx(self) -> Tuple[bytes, bytes]:
|
||||
def nx(self) -> tuple[bytes, bytes]:
|
||||
assert self.peer_random_value
|
||||
return (self.r, self.peer_random_value)
|
||||
|
||||
@@ -900,7 +897,7 @@ class Session:
|
||||
|
||||
self.send_pairing_failed(SMP_CONFIRM_VALUE_FAILED_ERROR)
|
||||
|
||||
utils.cancel_on_event(self.connection, 'disconnection', prompt())
|
||||
self.connection.cancel_on_disconnection(prompt())
|
||||
|
||||
def prompt_user_for_numeric_comparison(
|
||||
self, code: int, next_steps: Callable[[], None]
|
||||
@@ -919,7 +916,7 @@ class Session:
|
||||
|
||||
self.send_pairing_failed(SMP_CONFIRM_VALUE_FAILED_ERROR)
|
||||
|
||||
utils.cancel_on_event(self.connection, 'disconnection', prompt())
|
||||
self.connection.cancel_on_disconnection(prompt())
|
||||
|
||||
def prompt_user_for_number(self, next_steps: Callable[[int], None]) -> None:
|
||||
async def prompt() -> None:
|
||||
@@ -936,12 +933,11 @@ class Session:
|
||||
logger.warning(f'exception while prompting: {error}')
|
||||
self.send_pairing_failed(SMP_PASSKEY_ENTRY_FAILED_ERROR)
|
||||
|
||||
utils.cancel_on_event(self.connection, 'disconnection', prompt())
|
||||
self.connection.cancel_on_disconnection(prompt())
|
||||
|
||||
def display_passkey(self) -> None:
|
||||
# Generate random Passkey/PIN code
|
||||
self.passkey = secrets.randbelow(1000000)
|
||||
assert self.passkey is not None
|
||||
async def display_passkey(self) -> None:
|
||||
# Get the passkey value from the delegate
|
||||
self.passkey = await self.pairing_config.delegate.generate_passkey()
|
||||
logger.debug(f'Pairing PIN CODE: {self.passkey:06}')
|
||||
self.passkey_ready.set()
|
||||
|
||||
@@ -950,14 +946,9 @@ class Session:
|
||||
self.tk = self.passkey.to_bytes(16, byteorder='little')
|
||||
logger.debug(f'TK from passkey = {self.tk.hex()}')
|
||||
|
||||
try:
|
||||
utils.cancel_on_event(
|
||||
self.connection,
|
||||
'disconnection',
|
||||
self.pairing_config.delegate.display_number(self.passkey, digits=6),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(f'exception while displaying number: {error}')
|
||||
self.connection.cancel_on_disconnection(
|
||||
self.pairing_config.delegate.display_number(self.passkey, digits=6)
|
||||
)
|
||||
|
||||
def input_passkey(self, next_steps: Optional[Callable[[], None]] = None) -> None:
|
||||
# Prompt the user for the passkey displayed on the peer
|
||||
@@ -979,9 +970,16 @@ class Session:
|
||||
self, next_steps: Optional[Callable[[], None]] = None
|
||||
) -> None:
|
||||
if self.passkey_display:
|
||||
self.display_passkey()
|
||||
if next_steps is not None:
|
||||
next_steps()
|
||||
|
||||
async def display_passkey():
|
||||
await self.display_passkey()
|
||||
if next_steps is not None:
|
||||
next_steps()
|
||||
|
||||
try:
|
||||
self.connection.cancel_on_disconnection(display_passkey())
|
||||
except Exception as error:
|
||||
logger.warning(f'exception while displaying passkey: {error}')
|
||||
else:
|
||||
self.input_passkey(next_steps)
|
||||
|
||||
@@ -1051,7 +1049,7 @@ class Session:
|
||||
)
|
||||
|
||||
# Perform the next steps asynchronously in case we need to wait for input
|
||||
utils.cancel_on_event(self.connection, 'disconnection', next_steps())
|
||||
self.connection.cancel_on_disconnection(next_steps())
|
||||
else:
|
||||
confirm_value = crypto.c1(
|
||||
self.tk,
|
||||
@@ -1174,8 +1172,8 @@ class Session:
|
||||
self.connection.transport == PhysicalTransport.BR_EDR
|
||||
and self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
|
||||
):
|
||||
self.ctkd_task = utils.cancel_on_event(
|
||||
self.connection, 'disconnection', self.get_link_key_and_derive_ltk()
|
||||
self.ctkd_task = self.connection.cancel_on_disconnection(
|
||||
self.get_link_key_and_derive_ltk()
|
||||
)
|
||||
elif not self.sc:
|
||||
# Distribute the LTK, EDIV and RAND
|
||||
@@ -1213,8 +1211,8 @@ class Session:
|
||||
self.connection.transport == PhysicalTransport.BR_EDR
|
||||
and self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
|
||||
):
|
||||
self.ctkd_task = utils.cancel_on_event(
|
||||
self.connection, 'disconnection', self.get_link_key_and_derive_ltk()
|
||||
self.ctkd_task = self.connection.cancel_on_disconnection(
|
||||
self.get_link_key_and_derive_ltk()
|
||||
)
|
||||
# Distribute the LTK, EDIV and RAND
|
||||
elif not self.sc:
|
||||
@@ -1269,7 +1267,7 @@ class Session:
|
||||
f'{[c.__name__ for c in self.peer_expected_distributions]}'
|
||||
)
|
||||
|
||||
def check_key_distribution(self, command_class: Type[SMP_Command]) -> None:
|
||||
def check_key_distribution(self, command_class: type[SMP_Command]) -> None:
|
||||
# First, check that the connection is encrypted
|
||||
if not self.connection.is_encrypted:
|
||||
logger.warning(
|
||||
@@ -1306,9 +1304,7 @@ class Session:
|
||||
|
||||
# Wait for the pairing process to finish
|
||||
assert self.pairing_result
|
||||
await utils.cancel_on_event(
|
||||
self.connection, 'disconnection', self.pairing_result
|
||||
)
|
||||
await self.connection.cancel_on_disconnection(self.pairing_result)
|
||||
|
||||
def on_disconnection(self, _: int) -> None:
|
||||
self.connection.remove_listener(
|
||||
@@ -1329,7 +1325,7 @@ class Session:
|
||||
if self.is_initiator:
|
||||
self.distribute_keys()
|
||||
|
||||
utils.cancel_on_event(self.connection, 'disconnection', self.on_pairing())
|
||||
self.connection.cancel_on_disconnection(self.on_pairing())
|
||||
|
||||
def on_connection_encryption_change(self) -> None:
|
||||
if self.connection.is_encrypted and not self.completed:
|
||||
@@ -1440,10 +1436,8 @@ class Session:
|
||||
def on_smp_pairing_request_command(
|
||||
self, command: SMP_Pairing_Request_Command
|
||||
) -> None:
|
||||
utils.cancel_on_event(
|
||||
self.connection,
|
||||
'disconnection',
|
||||
self.on_smp_pairing_request_command_async(command),
|
||||
self.connection.cancel_on_disconnection(
|
||||
self.on_smp_pairing_request_command_async(command)
|
||||
)
|
||||
|
||||
async def on_smp_pairing_request_command_async(
|
||||
@@ -1507,7 +1501,7 @@ class Session:
|
||||
# Display a passkey if we need to
|
||||
if not self.sc:
|
||||
if self.pairing_method == PairingMethod.PASSKEY and self.passkey_display:
|
||||
self.display_passkey()
|
||||
await self.display_passkey()
|
||||
|
||||
# Respond
|
||||
self.send_pairing_response_command()
|
||||
@@ -1689,7 +1683,7 @@ class Session:
|
||||
):
|
||||
return
|
||||
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||
assert self.passkey and self.confirm_value
|
||||
assert self.passkey is not None and self.confirm_value is not None
|
||||
# Check that the random value matches what was committed to earlier
|
||||
confirm_verifier = crypto.f4(
|
||||
self.pkb,
|
||||
@@ -1718,7 +1712,7 @@ class Session:
|
||||
):
|
||||
self.send_pairing_random_command()
|
||||
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||
assert self.passkey and self.confirm_value
|
||||
assert self.passkey is not None and self.confirm_value is not None
|
||||
# Check that the random value matches what was committed to earlier
|
||||
confirm_verifier = crypto.f4(
|
||||
self.pka,
|
||||
@@ -1755,7 +1749,7 @@ class Session:
|
||||
ra = bytes(16)
|
||||
rb = ra
|
||||
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||
assert self.passkey
|
||||
assert self.passkey is not None
|
||||
ra = self.passkey.to_bytes(16, byteorder='little')
|
||||
rb = ra
|
||||
elif self.pairing_method == PairingMethod.OOB:
|
||||
@@ -1854,19 +1848,23 @@ class Session:
|
||||
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||
self.send_pairing_confirm_command()
|
||||
else:
|
||||
|
||||
def next_steps() -> None:
|
||||
# Send our public key back to the initiator
|
||||
self.send_public_key_command()
|
||||
|
||||
if self.pairing_method in (
|
||||
PairingMethod.JUST_WORKS,
|
||||
PairingMethod.NUMERIC_COMPARISON,
|
||||
PairingMethod.OOB,
|
||||
):
|
||||
# We can now send the confirmation value
|
||||
self.send_pairing_confirm_command()
|
||||
|
||||
if self.pairing_method == PairingMethod.PASSKEY:
|
||||
self.display_or_input_passkey()
|
||||
|
||||
# Send our public key back to the initiator
|
||||
self.send_public_key_command()
|
||||
|
||||
if self.pairing_method in (
|
||||
PairingMethod.JUST_WORKS,
|
||||
PairingMethod.NUMERIC_COMPARISON,
|
||||
PairingMethod.OOB,
|
||||
):
|
||||
# We can now send the confirmation value
|
||||
self.send_pairing_confirm_command()
|
||||
self.display_or_input_passkey(next_steps)
|
||||
else:
|
||||
next_steps()
|
||||
|
||||
def on_smp_pairing_dhkey_check_command(
|
||||
self, command: SMP_Pairing_DHKey_Check_Command
|
||||
@@ -1888,7 +1886,7 @@ class Session:
|
||||
self.wait_before_continuing = None
|
||||
self.send_pairing_dhkey_check_command()
|
||||
|
||||
utils.cancel_on_event(self.connection, 'disconnection', next_steps())
|
||||
self.connection.cancel_on_disconnection(next_steps())
|
||||
else:
|
||||
self.send_pairing_dhkey_check_command()
|
||||
else:
|
||||
@@ -1938,9 +1936,9 @@ class Manager(utils.EventEmitter):
|
||||
'''
|
||||
|
||||
device: Device
|
||||
sessions: Dict[int, Session]
|
||||
sessions: dict[int, Session]
|
||||
pairing_config_factory: Callable[[Connection], PairingConfig]
|
||||
session_proxy: Type[Session]
|
||||
session_proxy: type[Session]
|
||||
_ecc_key: Optional[crypto.EccKey]
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -20,9 +20,9 @@ import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from bumble import utils
|
||||
from bumble.transport.common import (
|
||||
Transport,
|
||||
AsyncPipeSink,
|
||||
SnoopingTransport,
|
||||
TransportSpecError,
|
||||
)
|
||||
@@ -195,6 +195,7 @@ async def _open_transport(scheme: str, spec: Optional[str]) -> Transport:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@utils.deprecated("RemoteLink has been removed. Use open_transport instead.")
|
||||
async def open_transport_or_link(name: str) -> Transport:
|
||||
"""
|
||||
Open a transport or a link relay.
|
||||
@@ -205,21 +206,6 @@ async def open_transport_or_link(name: str) -> Transport:
|
||||
When the name starts with "link-relay:", open a link relay (see RemoteLink
|
||||
for details on what the arguments are).
|
||||
For other namespaces, see `open_transport`.
|
||||
|
||||
"""
|
||||
if name.startswith('link-relay:'):
|
||||
logger.warning('Link Relay has been deprecated.')
|
||||
from bumble.controller import Controller
|
||||
from bumble.link import RemoteLink # lazy import
|
||||
|
||||
link = RemoteLink(name[11:])
|
||||
await link.wait_until_connected()
|
||||
controller = Controller('remote', link=link) # type:ignore[arg-type]
|
||||
|
||||
class LinkTransport(Transport):
|
||||
async def close(self):
|
||||
link.close()
|
||||
|
||||
return _wrap_transport(LinkTransport(controller, AsyncPipeSink(controller)))
|
||||
|
||||
return await open_transport(name)
|
||||
|
||||
@@ -22,7 +22,7 @@ import os
|
||||
import pathlib
|
||||
import platform
|
||||
import sys
|
||||
from typing import Dict, Optional
|
||||
from typing import Optional
|
||||
|
||||
import grpc.aio
|
||||
|
||||
@@ -143,7 +143,7 @@ def publish_grpc_port(grpc_port: int, instance_number: int) -> bool:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_android_netsim_controller_transport(
|
||||
server_host: Optional[str], server_port: int, options: Dict[str, str]
|
||||
server_host: Optional[str], server_port: int, options: dict[str, str]
|
||||
) -> Transport:
|
||||
if not server_port:
|
||||
raise TransportSpecError('invalid port')
|
||||
@@ -288,7 +288,7 @@ async def open_android_netsim_controller_transport(
|
||||
async def open_android_netsim_host_transport_with_address(
|
||||
server_host: Optional[str],
|
||||
server_port: int,
|
||||
options: Optional[Dict[str, str]] = None,
|
||||
options: Optional[dict[str, str]] = None,
|
||||
):
|
||||
if server_host == '_' or not server_host:
|
||||
server_host = 'localhost'
|
||||
@@ -313,7 +313,7 @@ async def open_android_netsim_host_transport_with_address(
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_android_netsim_host_transport_with_channel(
|
||||
channel, options: Optional[Dict[str, str]] = None
|
||||
channel, options: Optional[dict[str, str]] = None
|
||||
):
|
||||
# Wrapper for I/O operations
|
||||
class HciDevice:
|
||||
@@ -451,7 +451,7 @@ async def open_android_netsim_transport(spec: Optional[str]) -> Transport:
|
||||
port = 0
|
||||
params_offset = 0
|
||||
|
||||
options: Dict[str, str] = {}
|
||||
options: dict[str, str] = {}
|
||||
for param in params[params_offset:]:
|
||||
if '=' not in param:
|
||||
raise TransportSpecError('invalid parameter, expected <name>=<value>')
|
||||
|
||||
@@ -21,7 +21,7 @@ import struct
|
||||
import asyncio
|
||||
import logging
|
||||
import io
|
||||
from typing import Any, ContextManager, Tuple, Optional, Protocol, Dict
|
||||
from typing import Any, ContextManager, Optional, Protocol
|
||||
|
||||
from bumble import core
|
||||
from bumble import hci
|
||||
@@ -38,7 +38,7 @@ logger = logging.getLogger(__name__)
|
||||
# Information needed to parse HCI packets with a generic parser:
|
||||
# For each packet type, the info represents:
|
||||
# (length-size, length-offset, unpack-type)
|
||||
HCI_PACKET_INFO: Dict[int, Tuple[int, int, str]] = {
|
||||
HCI_PACKET_INFO: dict[int, tuple[int, int, str]] = {
|
||||
hci.HCI_COMMAND_PACKET: (1, 2, 'B'),
|
||||
hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'),
|
||||
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
|
||||
@@ -108,8 +108,8 @@ class PacketParser:
|
||||
NEED_BODY = 2
|
||||
|
||||
sink: Optional[TransportSink]
|
||||
extended_packet_info: Dict[int, Tuple[int, int, str]]
|
||||
packet_info: Optional[Tuple[int, int, str]] = None
|
||||
extended_packet_info: dict[int, tuple[int, int, str]]
|
||||
packet_info: Optional[tuple[int, int, str]] = None
|
||||
|
||||
def __init__(self, sink: Optional[TransportSink] = None) -> None:
|
||||
self.sink = sink
|
||||
|
||||
@@ -23,7 +23,7 @@ import time
|
||||
import usb.core
|
||||
import usb.util
|
||||
|
||||
from typing import Optional, Set
|
||||
from typing import Optional
|
||||
from usb.core import Device as UsbDevice
|
||||
from usb.core import USBError
|
||||
from usb.util import CTRL_TYPE_CLASS, CTRL_RECIPIENT_OTHER
|
||||
@@ -49,7 +49,7 @@ logger = logging.getLogger(__name__)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Global
|
||||
# -----------------------------------------------------------------------------
|
||||
devices_in_use: Set[int] = set()
|
||||
devices_in_use: set[int] = set()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -27,11 +27,8 @@ from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
List,
|
||||
Optional,
|
||||
Protocol,
|
||||
Set,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
overload,
|
||||
@@ -156,7 +153,7 @@ class EventWatcher:
|
||||
```
|
||||
'''
|
||||
|
||||
handlers: List[Tuple[pyee.EventEmitter, str, Callable[..., Any]]]
|
||||
handlers: list[tuple[pyee.EventEmitter, str, Callable[..., Any]]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.handlers = []
|
||||
@@ -329,7 +326,7 @@ class AsyncRunner:
|
||||
default_queue = WorkQueue()
|
||||
|
||||
# Shared set of running tasks
|
||||
running_tasks: Set[Awaitable] = set()
|
||||
running_tasks: set[Awaitable] = set()
|
||||
|
||||
@staticmethod
|
||||
def run_in_task(queue=None):
|
||||
|
||||
312
bumble/vendor/android/hci.py
vendored
312
bumble/vendor/android/hci.py
vendored
@@ -15,21 +15,12 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import dataclasses
|
||||
from dataclasses import field
|
||||
import struct
|
||||
from typing import Dict, Optional, Type
|
||||
from typing import Optional
|
||||
|
||||
from bumble.hci import (
|
||||
name_or_number,
|
||||
hci_vendor_command_op_code,
|
||||
Address,
|
||||
HCI_Constant,
|
||||
HCI_Object,
|
||||
HCI_Command,
|
||||
HCI_Event,
|
||||
HCI_Extended_Event,
|
||||
HCI_VENDOR_EVENT,
|
||||
STATUS_SPEC,
|
||||
)
|
||||
from bumble import hci
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -41,22 +32,28 @@ from bumble.hci import (
|
||||
#
|
||||
# pylint: disable-next=line-too-long
|
||||
# See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#chip-capabilities-and-configuration
|
||||
HCI_LE_GET_VENDOR_CAPABILITIES_COMMAND = hci_vendor_command_op_code(0x153)
|
||||
HCI_LE_APCF_COMMAND = hci_vendor_command_op_code(0x157)
|
||||
HCI_GET_CONTROLLER_ACTIVITY_ENERGY_INFO_COMMAND = hci_vendor_command_op_code(0x159)
|
||||
HCI_A2DP_HARDWARE_OFFLOAD_COMMAND = hci_vendor_command_op_code(0x15D)
|
||||
HCI_BLUETOOTH_QUALITY_REPORT_COMMAND = hci_vendor_command_op_code(0x15E)
|
||||
HCI_DYNAMIC_AUDIO_BUFFER_COMMAND = hci_vendor_command_op_code(0x15F)
|
||||
HCI_LE_GET_VENDOR_CAPABILITIES_COMMAND = hci.hci_vendor_command_op_code(0x153)
|
||||
HCI_LE_APCF_COMMAND = hci.hci_vendor_command_op_code(0x157)
|
||||
HCI_GET_CONTROLLER_ACTIVITY_ENERGY_INFO_COMMAND = hci.hci_vendor_command_op_code(0x159)
|
||||
HCI_A2DP_HARDWARE_OFFLOAD_COMMAND = hci.hci_vendor_command_op_code(0x15D)
|
||||
HCI_BLUETOOTH_QUALITY_REPORT_COMMAND = hci.hci_vendor_command_op_code(0x15E)
|
||||
HCI_DYNAMIC_AUDIO_BUFFER_COMMAND = hci.hci_vendor_command_op_code(0x15F)
|
||||
|
||||
HCI_BLUETOOTH_QUALITY_REPORT_EVENT = 0x58
|
||||
|
||||
HCI_Command.register_commands(globals())
|
||||
hci.HCI_Command.register_commands(globals())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
@hci.HCI_Command.command
|
||||
@dataclasses.dataclass
|
||||
class HCI_LE_Get_Vendor_Capabilities_Command(hci.HCI_Command):
|
||||
# pylint: disable=line-too-long
|
||||
'''
|
||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#vendor-specific-capabilities
|
||||
'''
|
||||
return_parameters_fields = [
|
||||
('status', hci.STATUS_SPEC),
|
||||
('max_advt_instances', 1),
|
||||
('offloaded_resolution_of_private_address', 1),
|
||||
('total_scan_results_storage', 2),
|
||||
@@ -73,12 +70,6 @@ HCI_Command.register_commands(globals())
|
||||
('bluetooth_quality_report_support', 1),
|
||||
('dynamic_audio_buffer_support', 4),
|
||||
]
|
||||
)
|
||||
class HCI_LE_Get_Vendor_Capabilities_Command(HCI_Command):
|
||||
# pylint: disable=line-too-long
|
||||
'''
|
||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#vendor-specific-capabilities
|
||||
'''
|
||||
|
||||
@classmethod
|
||||
def parse_return_parameters(cls, parameters):
|
||||
@@ -86,13 +77,13 @@ class HCI_LE_Get_Vendor_Capabilities_Command(HCI_Command):
|
||||
# there are no more bytes to parse, and leave un-signal parameters set to
|
||||
# None (older versions)
|
||||
nones = {field: None for field, _ in cls.return_parameters_fields}
|
||||
return_parameters = HCI_Object(cls.return_parameters_fields, **nones)
|
||||
return_parameters = hci.HCI_Object(cls.return_parameters_fields, **nones)
|
||||
|
||||
try:
|
||||
offset = 0
|
||||
for field in cls.return_parameters_fields:
|
||||
field_name, field_type = field
|
||||
field_value, field_size = HCI_Object.parse_field(
|
||||
field_value, field_size = hci.HCI_Object.parse_field(
|
||||
parameters, offset, field_type
|
||||
)
|
||||
setattr(return_parameters, field_name, field_value)
|
||||
@@ -104,30 +95,9 @@ class HCI_LE_Get_Vendor_Capabilities_Command(HCI_Command):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
fields=[
|
||||
(
|
||||
'opcode',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_LE_APCF_Command.opcode_name(x),
|
||||
},
|
||||
),
|
||||
('payload', '*'),
|
||||
],
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
(
|
||||
'opcode',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_LE_APCF_Command.opcode_name(x),
|
||||
},
|
||||
),
|
||||
('payload', '*'),
|
||||
],
|
||||
)
|
||||
class HCI_LE_APCF_Command(HCI_Command):
|
||||
@hci.HCI_Command.command
|
||||
@dataclasses.dataclass
|
||||
class HCI_LE_APCF_Command(hci.HCI_Command):
|
||||
# pylint: disable=line-too-long
|
||||
'''
|
||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_apcf_command
|
||||
@@ -137,80 +107,50 @@ class HCI_LE_APCF_Command(HCI_Command):
|
||||
'''
|
||||
|
||||
# APCF Subcommands
|
||||
# TODO: use the OpenIntEnum class (when upcoming PR is merged)
|
||||
APCF_ENABLE = 0x00
|
||||
APCF_SET_FILTERING_PARAMETERS = 0x01
|
||||
APCF_BROADCASTER_ADDRESS = 0x02
|
||||
APCF_SERVICE_UUID = 0x03
|
||||
APCF_SERVICE_SOLICITATION_UUID = 0x04
|
||||
APCF_LOCAL_NAME = 0x05
|
||||
APCF_MANUFACTURER_DATA = 0x06
|
||||
APCF_SERVICE_DATA = 0x07
|
||||
APCF_TRANSPORT_DISCOVERY_SERVICE = 0x08
|
||||
APCF_AD_TYPE_FILTER = 0x09
|
||||
APCF_READ_EXTENDED_FEATURES = 0xFF
|
||||
class Opcode(hci.SpecableEnum):
|
||||
ENABLE = 0x00
|
||||
SET_FILTERING_PARAMETERS = 0x01
|
||||
BROADCASTER_ADDRESS = 0x02
|
||||
SERVICE_UUID = 0x03
|
||||
SERVICE_SOLICITATION_UUID = 0x04
|
||||
LOCAL_NAME = 0x05
|
||||
MANUFACTURER_DATA = 0x06
|
||||
SERVICE_DATA = 0x07
|
||||
TRANSPORT_DISCOVERY_SERVICE = 0x08
|
||||
AD_TYPE_FILTER = 0x09
|
||||
READ_EXTENDED_FEATURES = 0xFF
|
||||
|
||||
OPCODE_NAMES = {
|
||||
APCF_ENABLE: 'APCF_ENABLE',
|
||||
APCF_SET_FILTERING_PARAMETERS: 'APCF_SET_FILTERING_PARAMETERS',
|
||||
APCF_BROADCASTER_ADDRESS: 'APCF_BROADCASTER_ADDRESS',
|
||||
APCF_SERVICE_UUID: 'APCF_SERVICE_UUID',
|
||||
APCF_SERVICE_SOLICITATION_UUID: 'APCF_SERVICE_SOLICITATION_UUID',
|
||||
APCF_LOCAL_NAME: 'APCF_LOCAL_NAME',
|
||||
APCF_MANUFACTURER_DATA: 'APCF_MANUFACTURER_DATA',
|
||||
APCF_SERVICE_DATA: 'APCF_SERVICE_DATA',
|
||||
APCF_TRANSPORT_DISCOVERY_SERVICE: 'APCF_TRANSPORT_DISCOVERY_SERVICE',
|
||||
APCF_AD_TYPE_FILTER: 'APCF_AD_TYPE_FILTER',
|
||||
APCF_READ_EXTENDED_FEATURES: 'APCF_READ_EXTENDED_FEATURES',
|
||||
}
|
||||
opcode: int = dataclasses.field(metadata=Opcode.type_metadata(1))
|
||||
payload: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
||||
|
||||
@classmethod
|
||||
def opcode_name(cls, opcode):
|
||||
return name_or_number(cls.OPCODE_NAMES, opcode)
|
||||
return_parameters_fields = [
|
||||
('status', hci.STATUS_SPEC),
|
||||
('opcode', Opcode.type_spec(1)),
|
||||
('payload', '*'),
|
||||
]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
('total_tx_time_ms', 4),
|
||||
('total_rx_time_ms', 4),
|
||||
('total_idle_time_ms', 4),
|
||||
('total_energy_used', 4),
|
||||
],
|
||||
)
|
||||
class HCI_Get_Controller_Activity_Energy_Info_Command(HCI_Command):
|
||||
@hci.HCI_Command.command
|
||||
@dataclasses.dataclass
|
||||
class HCI_Get_Controller_Activity_Energy_Info_Command(hci.HCI_Command):
|
||||
# pylint: disable=line-too-long
|
||||
'''
|
||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_get_controller_activity_energy_info
|
||||
'''
|
||||
return_parameters_fields = [
|
||||
('status', hci.STATUS_SPEC),
|
||||
('total_tx_time_ms', 4),
|
||||
('total_rx_time_ms', 4),
|
||||
('total_idle_time_ms', 4),
|
||||
('total_energy_used', 4),
|
||||
]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
fields=[
|
||||
(
|
||||
'opcode',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_A2DP_Hardware_Offload_Command.opcode_name(x),
|
||||
},
|
||||
),
|
||||
('payload', '*'),
|
||||
],
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
(
|
||||
'opcode',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_A2DP_Hardware_Offload_Command.opcode_name(x),
|
||||
},
|
||||
),
|
||||
('payload', '*'),
|
||||
],
|
||||
)
|
||||
class HCI_A2DP_Hardware_Offload_Command(HCI_Command):
|
||||
@hci.HCI_Command.command
|
||||
@dataclasses.dataclass
|
||||
class HCI_A2DP_Hardware_Offload_Command(hci.HCI_Command):
|
||||
# pylint: disable=line-too-long
|
||||
'''
|
||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#a2dp-hardware-offload-support
|
||||
@@ -220,45 +160,24 @@ class HCI_A2DP_Hardware_Offload_Command(HCI_Command):
|
||||
'''
|
||||
|
||||
# A2DP Hardware Offload Subcommands
|
||||
# TODO: use the OpenIntEnum class (when upcoming PR is merged)
|
||||
START_A2DP_OFFLOAD = 0x01
|
||||
STOP_A2DP_OFFLOAD = 0x02
|
||||
class Opcode(hci.SpecableEnum):
|
||||
START_A2DP_OFFLOAD = 0x01
|
||||
STOP_A2DP_OFFLOAD = 0x02
|
||||
|
||||
OPCODE_NAMES = {
|
||||
START_A2DP_OFFLOAD: 'START_A2DP_OFFLOAD',
|
||||
STOP_A2DP_OFFLOAD: 'STOP_A2DP_OFFLOAD',
|
||||
}
|
||||
opcode: int = dataclasses.field(metadata=Opcode.type_metadata(1))
|
||||
payload: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
||||
|
||||
@classmethod
|
||||
def opcode_name(cls, opcode):
|
||||
return name_or_number(cls.OPCODE_NAMES, opcode)
|
||||
return_parameters_fields = [
|
||||
('status', hci.STATUS_SPEC),
|
||||
('opcode', Opcode.type_spec(1)),
|
||||
('payload', '*'),
|
||||
]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
fields=[
|
||||
(
|
||||
'opcode',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_Dynamic_Audio_Buffer_Command.opcode_name(x),
|
||||
},
|
||||
),
|
||||
('payload', '*'),
|
||||
],
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
(
|
||||
'opcode',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_Dynamic_Audio_Buffer_Command.opcode_name(x),
|
||||
},
|
||||
),
|
||||
('payload', '*'),
|
||||
],
|
||||
)
|
||||
class HCI_Dynamic_Audio_Buffer_Command(HCI_Command):
|
||||
@hci.HCI_Command.command
|
||||
@dataclasses.dataclass
|
||||
class HCI_Dynamic_Audio_Buffer_Command(hci.HCI_Command):
|
||||
# pylint: disable=line-too-long
|
||||
'''
|
||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#dynamic-audio-buffer-command
|
||||
@@ -268,27 +187,28 @@ class HCI_Dynamic_Audio_Buffer_Command(HCI_Command):
|
||||
'''
|
||||
|
||||
# Dynamic Audio Buffer Subcommands
|
||||
# TODO: use the OpenIntEnum class (when upcoming PR is merged)
|
||||
GET_AUDIO_BUFFER_TIME_CAPABILITY = 0x01
|
||||
class Opcode(hci.SpecableEnum):
|
||||
GET_AUDIO_BUFFER_TIME_CAPABILITY = 0x01
|
||||
|
||||
OPCODE_NAMES = {
|
||||
GET_AUDIO_BUFFER_TIME_CAPABILITY: 'GET_AUDIO_BUFFER_TIME_CAPABILITY',
|
||||
}
|
||||
opcode: int = dataclasses.field(metadata=Opcode.type_metadata(1))
|
||||
payload: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
||||
|
||||
@classmethod
|
||||
def opcode_name(cls, opcode):
|
||||
return name_or_number(cls.OPCODE_NAMES, opcode)
|
||||
return_parameters_fields = [
|
||||
('status', hci.STATUS_SPEC),
|
||||
('opcode', Opcode.type_spec(1)),
|
||||
('payload', '*'),
|
||||
]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HCI_Android_Vendor_Event(HCI_Extended_Event):
|
||||
event_code: int = HCI_VENDOR_EVENT
|
||||
subevent_classes: Dict[int, Type[HCI_Extended_Event]] = {}
|
||||
class HCI_Android_Vendor_Event(hci.HCI_Extended_Event):
|
||||
event_code: int = hci.HCI_VENDOR_EVENT
|
||||
subevent_classes: dict[int, type[hci.HCI_Extended_Event]] = {}
|
||||
|
||||
@classmethod
|
||||
def subclass_from_parameters(
|
||||
cls, parameters: bytes
|
||||
) -> Optional[HCI_Extended_Event]:
|
||||
) -> Optional[hci.HCI_Extended_Event]:
|
||||
subevent_code = parameters[0]
|
||||
if subevent_code == HCI_BLUETOOTH_QUALITY_REPORT_EVENT:
|
||||
quality_report_id = parameters[1]
|
||||
@@ -299,45 +219,43 @@ class HCI_Android_Vendor_Event(HCI_Extended_Event):
|
||||
|
||||
|
||||
HCI_Android_Vendor_Event.register_subevents(globals())
|
||||
HCI_Event.add_vendor_factory(HCI_Android_Vendor_Event.subclass_from_parameters)
|
||||
hci.HCI_Event.add_vendor_factory(HCI_Android_Vendor_Event.subclass_from_parameters)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Extended_Event.event(
|
||||
fields=[
|
||||
('quality_report_id', 1),
|
||||
('packet_types', 1),
|
||||
('connection_handle', 2),
|
||||
('connection_role', {'size': 1, 'mapper': HCI_Constant.role_name}),
|
||||
('tx_power_level', -1),
|
||||
('rssi', -1),
|
||||
('snr', 1),
|
||||
('unused_afh_channel_count', 1),
|
||||
('afh_select_unideal_channel_count', 1),
|
||||
('lsto', 2),
|
||||
('connection_piconet_clock', 4),
|
||||
('retransmission_count', 4),
|
||||
('no_rx_count', 4),
|
||||
('nak_count', 4),
|
||||
('last_tx_ack_timestamp', 4),
|
||||
('flow_off_count', 4),
|
||||
('last_flow_on_timestamp', 4),
|
||||
('buffer_overflow_bytes', 4),
|
||||
('buffer_underflow_bytes', 4),
|
||||
('bdaddr', Address.parse_address),
|
||||
('cal_failed_item_count', 1),
|
||||
('tx_total_packets', 4),
|
||||
('tx_unacked_packets', 4),
|
||||
('tx_flushed_packets', 4),
|
||||
('tx_last_subevent_packets', 4),
|
||||
('crc_error_packets', 4),
|
||||
('rx_duplicate_packets', 4),
|
||||
('rx_unreceived_packets', 4),
|
||||
('vendor_specific_parameters', '*'),
|
||||
]
|
||||
)
|
||||
@hci.HCI_Extended_Event.event
|
||||
@dataclasses.dataclass
|
||||
class HCI_Bluetooth_Quality_Report_Event(HCI_Android_Vendor_Event):
|
||||
# pylint: disable=line-too-long
|
||||
'''
|
||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#bluetooth-quality-report-sub-event
|
||||
'''
|
||||
quality_report_id: int = field(metadata=hci.metadata(1))
|
||||
packet_types: int = field(metadata=hci.metadata(1))
|
||||
connection_handle: int = field(metadata=hci.metadata(2))
|
||||
connection_role: int = field(metadata=hci.Role.type_metadata(1))
|
||||
tx_power_level: int = field(metadata=hci.metadata(-1))
|
||||
rssi: int = field(metadata=hci.metadata(-1))
|
||||
snr: int = field(metadata=hci.metadata(1))
|
||||
unused_afh_channel_count: int = field(metadata=hci.metadata(1))
|
||||
afh_select_unideal_channel_count: int = field(metadata=hci.metadata(1))
|
||||
lsto: int = field(metadata=hci.metadata(2))
|
||||
connection_piconet_clock: int = field(metadata=hci.metadata(4))
|
||||
retransmission_count: int = field(metadata=hci.metadata(4))
|
||||
no_rx_count: int = field(metadata=hci.metadata(4))
|
||||
nak_count: int = field(metadata=hci.metadata(4))
|
||||
last_tx_ack_timestamp: int = field(metadata=hci.metadata(4))
|
||||
flow_off_count: int = field(metadata=hci.metadata(4))
|
||||
last_flow_on_timestamp: int = field(metadata=hci.metadata(4))
|
||||
buffer_overflow_bytes: int = field(metadata=hci.metadata(4))
|
||||
buffer_underflow_bytes: int = field(metadata=hci.metadata(4))
|
||||
bdaddr: hci.Address = field(metadata=hci.metadata(hci.Address.parse_address))
|
||||
cal_failed_item_count: int = field(metadata=hci.metadata(1))
|
||||
tx_total_packets: int = field(metadata=hci.metadata(4))
|
||||
tx_unacked_packets: int = field(metadata=hci.metadata(4))
|
||||
tx_flushed_packets: int = field(metadata=hci.metadata(4))
|
||||
tx_last_subevent_packets: int = field(metadata=hci.metadata(4))
|
||||
crc_error_packets: int = field(metadata=hci.metadata(4))
|
||||
rx_duplicate_packets: int = field(metadata=hci.metadata(4))
|
||||
rx_unreceived_packets: int = field(metadata=hci.metadata(4))
|
||||
vendor_specific_parameters: bytes = field(metadata=hci.metadata('*'))
|
||||
|
||||
59
bumble/vendor/zephyr/hci.py
vendored
59
bumble/vendor/zephyr/hci.py
vendored
@@ -15,11 +15,9 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from bumble.hci import (
|
||||
hci_vendor_command_op_code,
|
||||
HCI_Command,
|
||||
STATUS_SPEC,
|
||||
)
|
||||
import dataclasses
|
||||
|
||||
from bumble import hci
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -31,10 +29,10 @@ from bumble.hci import (
|
||||
#
|
||||
# pylint: disable-next=line-too-long
|
||||
# See https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
|
||||
HCI_WRITE_TX_POWER_LEVEL_COMMAND = hci_vendor_command_op_code(0x000E)
|
||||
HCI_READ_TX_POWER_LEVEL_COMMAND = hci_vendor_command_op_code(0x000F)
|
||||
HCI_WRITE_TX_POWER_LEVEL_COMMAND = hci.hci_vendor_command_op_code(0x000E)
|
||||
HCI_READ_TX_POWER_LEVEL_COMMAND = hci.hci_vendor_command_op_code(0x000F)
|
||||
|
||||
HCI_Command.register_commands(globals())
|
||||
hci.HCI_Command.register_commands(globals())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -49,16 +47,9 @@ class TX_Power_Level_Command:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
fields=[('handle_type', 1), ('connection_handle', 2), ('tx_power_level', -1)],
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
('handle_type', 1),
|
||||
('connection_handle', 2),
|
||||
('selected_tx_power_level', -1),
|
||||
],
|
||||
)
|
||||
class HCI_Write_Tx_Power_Level_Command(HCI_Command, TX_Power_Level_Command):
|
||||
@hci.HCI_Command.command
|
||||
@dataclasses.dataclass
|
||||
class HCI_Write_Tx_Power_Level_Command(hci.HCI_Command, TX_Power_Level_Command):
|
||||
'''
|
||||
Write TX power level. See BT_HCI_OP_VS_WRITE_TX_POWER_LEVEL in
|
||||
https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
|
||||
@@ -67,18 +58,22 @@ class HCI_Write_Tx_Power_Level_Command(HCI_Command, TX_Power_Level_Command):
|
||||
TX_POWER_HANDLE_TYPE_SCAN should be zero.
|
||||
'''
|
||||
|
||||
handle_type: int = dataclasses.field(metadata=hci.metadata(1))
|
||||
connection_handle: int = dataclasses.field(metadata=hci.metadata(2))
|
||||
tx_power_level: int = dataclasses.field(metadata=hci.metadata(-1))
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
fields=[('handle_type', 1), ('connection_handle', 2)],
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
return_parameters_fields = [
|
||||
('status', hci.STATUS_SPEC),
|
||||
('handle_type', 1),
|
||||
('connection_handle', 2),
|
||||
('tx_power_level', -1),
|
||||
],
|
||||
)
|
||||
class HCI_Read_Tx_Power_Level_Command(HCI_Command, TX_Power_Level_Command):
|
||||
('selected_tx_power_level', -1),
|
||||
]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@hci.HCI_Command.command
|
||||
@dataclasses.dataclass
|
||||
class HCI_Read_Tx_Power_Level_Command(hci.HCI_Command, TX_Power_Level_Command):
|
||||
'''
|
||||
Read TX power level. See BT_HCI_OP_VS_READ_TX_POWER_LEVEL in
|
||||
https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
|
||||
@@ -86,3 +81,13 @@ class HCI_Read_Tx_Power_Level_Command(HCI_Command, TX_Power_Level_Command):
|
||||
Power level is in dB. Connection handle for TX_POWER_HANDLE_TYPE_ADV and
|
||||
TX_POWER_HANDLE_TYPE_SCAN should be zero.
|
||||
'''
|
||||
|
||||
handle_type: int = dataclasses.field(metadata=hci.metadata(1))
|
||||
connection_handle: int = dataclasses.field(metadata=hci.metadata(2))
|
||||
|
||||
return_parameters_fields = [
|
||||
('status', hci.STATUS_SPEC),
|
||||
('handle_type', 1),
|
||||
('connection_handle', 2),
|
||||
('tx_power_level', -1),
|
||||
]
|
||||
|
||||
@@ -57,7 +57,6 @@ nav:
|
||||
- Pair: apps_and_tools/pair.md
|
||||
- Unbond: apps_and_tools/unbond.md
|
||||
- USB Probe: apps_and_tools/usb_probe.md
|
||||
- Link Relay: apps_and_tools/link_relay.md
|
||||
- Hardware:
|
||||
- hardware/index.md
|
||||
- Platforms:
|
||||
|
||||
@@ -13,4 +13,3 @@ These include:
|
||||
* [Golden Gate Bridge](gg_bridge.md) - Bridge between GATT and UDP to use with the Golden Gate "stack tool".
|
||||
* [Show](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form.
|
||||
* [Speaker](speaker.md) - Virtual Bluetooth speaker, with a command line and browser-based UI.
|
||||
* [Link Relay](link_relay.md) - WebSocket relay for virtual RemoteLink instances to communicate with each other.
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
LINK RELAY TOOL
|
||||
===============
|
||||
|
||||
The Link Relay is a WebSocket relay, which acts like an online chat system, where each "chat room" can be joined by multiple virtual controllers, which can then communicate with each other, as if connected with radio communication.
|
||||
|
||||
```
|
||||
usage: python link_relay.py [-h] [--log-level LOG_LEVEL] [--log-config LOG_CONFIG] [--port PORT]
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
--log-level LOG_LEVEL
|
||||
logger level
|
||||
--log-config LOG_CONFIG
|
||||
logger config file (YAML)
|
||||
--port PORT Port to listen on
|
||||
```
|
||||
|
||||
(the default port is `10723`)
|
||||
|
||||
When running, the link relay waits for connections on its listening port.
|
||||
The WebSocket path used by a connecting client indicates which virtual "chat room" to join.
|
||||
|
||||
|
||||
!!! tip "Connecting to the relay as a controller"
|
||||
Most of the examples and tools that take a transport moniker as an argument also accept a link relay moniker, which is equivalent to a transport to a virtual controller that is connected to a relay.
|
||||
The moniker syntax is: `link-relay:ws://<hostname>/<room>` where `<hostname>` is the hostname to connect to and `<room>` is the virtual "chat room" in a relay.
|
||||
|
||||
Example: `link-relay:ws://localhost:10723/test` will join the `test` "chat room"
|
||||
|
||||
!!! tip "Connecting to the relay as an observer"
|
||||
It is possible to connect to a "chat room" in a relay as an observer, rather than a virtual controller. In this case, a text-based console can be used to observe what is going on in the "chat room". Tools like [`wscat`](https://github.com/websockets/wscat#readme) or [`websocat`](https://github.com/vi/websocat) can be used for that.
|
||||
|
||||
Example: `wscat --connect ws://localhost:10723/test`
|
||||
@@ -56,13 +56,6 @@ Included in the project are two types of Link interface implementations:
|
||||
The LocalLink implementation is a simple object used by an application that instantiates
|
||||
more than one Controller objects and connects them in-memory and in-process.
|
||||
|
||||
#### Remote Link
|
||||
The RemoteLink implementation communicates with other virtual controllers over a WebSocket.
|
||||
Multiple instances of RemoteLink objects communicate with each other through a simple
|
||||
WebSocket relay that can host any number of virtual 'rooms', where each 'room' is
|
||||
a set of controllers that can communicate between themselves.
|
||||
The `link_relay` app is where this relay is implemented.
|
||||
|
||||
## Host
|
||||
The Host component connects to a controller over an HCI interface. It is responsible to sending commands and ACL data to the controller and receiving back events and ACL data.
|
||||
|
||||
|
||||
@@ -15,11 +15,10 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from bumble.utils import AsyncRunner
|
||||
import bumble.logging
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
my_work_queue1 = AsyncRunner.WorkQueue()
|
||||
@@ -83,5 +82,5 @@ async def main() -> None:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
bumble.logging.setup_basic_logging('DEBUG')
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -17,13 +17,12 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from bumble.colors import color
|
||||
from bumble.device import Device
|
||||
from bumble.hci import Address
|
||||
from bumble.transport import open_transport
|
||||
from bumble.profiles.battery_service import BatteryServiceProxy
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -72,5 +71,5 @@ async def main() -> None:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
bumble.logging.setup_basic_logging('DEBUG')
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -17,15 +17,14 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
import random
|
||||
import struct
|
||||
|
||||
from bumble.core import AdvertisingData
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
from bumble.profiles.battery_service import BatteryService
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -35,7 +34,7 @@ async def main() -> None:
|
||||
print('example: python battery_server.py device1.json usb:0')
|
||||
return
|
||||
|
||||
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||
async with await open_transport(sys.argv[2]) as hci_transport:
|
||||
device = Device.from_config_file_with_hci(
|
||||
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||
)
|
||||
@@ -74,5 +73,5 @@ async def main() -> None:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
bumble.logging.setup_basic_logging('DEBUG')
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.device import Device, Peer
|
||||
from bumble.hci import Address
|
||||
from bumble.profiles.device_information_service import DeviceInformationServiceProxy
|
||||
from bumble.transport import open_transport
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -116,5 +116,5 @@ async def main() -> None:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
bumble.logging.setup_basic_logging('DEBUG')
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -17,14 +17,13 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
import struct
|
||||
|
||||
from bumble.core import AdvertisingData
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
from bumble.profiles.device_information_service import DeviceInformationService
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -34,7 +33,7 @@ async def main() -> None:
|
||||
print('example: python device_info_server.py device1.json usb:0')
|
||||
return
|
||||
|
||||
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||
async with await open_transport(sys.argv[2]) as hci_transport:
|
||||
device = Device.from_config_file_with_hci(
|
||||
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||
)
|
||||
@@ -70,5 +69,5 @@ async def main() -> None:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
bumble.logging.setup_basic_logging('DEBUG')
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.device import Device
|
||||
from bumble.hci import Address
|
||||
from bumble.transport import open_transport
|
||||
from bumble.profiles.heart_rate_service import HeartRateServiceProxy
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -76,5 +76,5 @@ async def main() -> None:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
bumble.logging.setup_basic_logging('DEBUG')
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -20,16 +20,15 @@ import time
|
||||
import math
|
||||
import random
|
||||
import struct
|
||||
import logging
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from bumble.core import AdvertisingData
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
from bumble.profiles.device_information_service import DeviceInformationService
|
||||
from bumble.profiles.heart_rate_service import HeartRateService
|
||||
from bumble.utils import AsyncRunner
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -39,7 +38,7 @@ async def main() -> None:
|
||||
print('example: python heart_rate_server.py device1.json usb:0')
|
||||
return
|
||||
|
||||
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||
async with await open_transport(sys.argv[2]) as hci_transport:
|
||||
device = Device.from_config_file_with_hci(
|
||||
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||
)
|
||||
@@ -128,5 +127,5 @@ async def main() -> None:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
bumble.logging.setup_basic_logging('DEBUG')
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -17,17 +17,16 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
import struct
|
||||
import json
|
||||
import websockets
|
||||
from bumble.colors import color
|
||||
|
||||
import websockets
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.core import AdvertisingData
|
||||
from bumble.device import Device, Connection, Peer
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
from bumble.gatt import (
|
||||
Descriptor,
|
||||
Service,
|
||||
@@ -45,6 +44,8 @@ from bumble.gatt import (
|
||||
GATT_HID_CONTROL_POINT_CHARACTERISTIC,
|
||||
GATT_REPORT_REFERENCE_DESCRIPTOR,
|
||||
)
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -434,7 +435,7 @@ async def main() -> None:
|
||||
)
|
||||
return
|
||||
|
||||
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||
async with await open_transport(sys.argv[2]) as hci_transport:
|
||||
# Create a device to manage the host
|
||||
device = Device.from_config_file_with_hci(
|
||||
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||
@@ -450,5 +451,5 @@ async def main() -> None:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
bumble.logging.setup_basic_logging('DEBUG')
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -17,12 +17,10 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
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.transport import open_transport
|
||||
from bumble.core import (
|
||||
PhysicalTransport,
|
||||
BT_AVDTP_PROTOCOL_ID,
|
||||
@@ -39,6 +37,7 @@ from bumble.sdp import (
|
||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
)
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -146,7 +145,7 @@ async def main() -> None:
|
||||
return
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||
async with await open_transport(sys.argv[2]) as hci_transport:
|
||||
print('<<< connected')
|
||||
|
||||
# Create a device
|
||||
@@ -198,5 +197,5 @@ async def main() -> None:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
bumble.logging.setup_basic_logging('DEBUG')
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -17,12 +17,10 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
from bumble.core import PhysicalTransport
|
||||
from bumble.avdtp import (
|
||||
AVDTP_AUDIO_MEDIA_TYPE,
|
||||
@@ -35,8 +33,10 @@ from bumble.a2dp import (
|
||||
A2DP_SBC_CODEC_TYPE,
|
||||
SbcMediaCodecInformation,
|
||||
)
|
||||
import bumble.logging
|
||||
|
||||
Context: Dict[Any, Any] = {'output': None}
|
||||
|
||||
Context: dict[Any, Any] = {'output': None}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -112,7 +112,7 @@ async def main() -> None:
|
||||
return
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||
async with await open_transport(sys.argv[2]) as hci_transport:
|
||||
print('<<< connected')
|
||||
|
||||
with open(sys.argv[3], 'wb') as sbc_file:
|
||||
@@ -166,5 +166,5 @@ async def main() -> None:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
bumble.logging.setup_basic_logging('DEBUG')
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -17,12 +17,10 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
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.transport import open_transport
|
||||
from bumble.core import PhysicalTransport
|
||||
from bumble.avdtp import (
|
||||
find_avdtp_service_with_connection,
|
||||
@@ -38,6 +36,7 @@ from bumble.a2dp import (
|
||||
SbcMediaCodecInformation,
|
||||
SbcPacketSource,
|
||||
)
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -120,7 +119,7 @@ async def main() -> None:
|
||||
return
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||
async with await open_transport(sys.argv[2]) as hci_transport:
|
||||
print('<<< connected')
|
||||
|
||||
# Create a device
|
||||
@@ -186,5 +185,5 @@ async def main() -> None:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
bumble.logging.setup_basic_logging('DEBUG')
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -16,15 +16,14 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import struct
|
||||
|
||||
from bumble.core import AdvertisingData
|
||||
from bumble.device import AdvertisingType, Device
|
||||
from bumble.hci import Address
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.transport import open_transport
|
||||
import bumble.logging
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -50,7 +49,7 @@ async def main() -> None:
|
||||
target = None
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||
async with await open_transport(sys.argv[2]) as hci_transport:
|
||||
print('<<< connected')
|
||||
|
||||
device = Device.from_config_file_with_hci(
|
||||
@@ -72,5 +71,5 @@ async def main() -> None:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
bumble.logging.setup_basic_logging('DEBUG')
|
||||
asyncio.run(main())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user