mirror of
https://github.com/google/bumble.git
synced 2026-04-17 00:35:31 +00:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f2b623e3c | ||
|
|
fa12165cd3 | ||
|
|
c0c6f3329d | ||
|
|
406a932467 | ||
|
|
cc96d4245f | ||
|
|
c6cdca8923 | ||
|
|
10347765cb | ||
|
|
c12dee4e76 | ||
|
|
772c188674 | ||
|
|
7c1a3bb8f9 | ||
|
|
8c3c0b1e13 | ||
|
|
1ad84ad51c | ||
|
|
64937c3f77 | ||
|
|
4c29a16271 | ||
|
|
762d3e92de | ||
|
|
2f97531d78 | ||
|
|
f6c7cae661 | ||
|
|
f1777a5bd2 | ||
|
|
78a06ae8cf | ||
|
|
d290df4aa9 | ||
|
|
e559744f32 | ||
|
|
67418e649a | ||
|
|
5adf9fab53 | ||
|
|
2491b686fa | ||
|
|
efd02b2f3e | ||
|
|
3b14078646 | ||
|
|
eb9d5632bc | ||
|
|
45f60edbb6 | ||
|
|
393ea6a7bb | ||
|
|
6ec6f1efe5 | ||
|
|
5d9598ea51 | ||
|
|
0d36d99a73 | ||
|
|
d8a9f5a724 | ||
|
|
2c66e1a042 | ||
|
|
d5eccdb00f | ||
|
|
32626573a6 | ||
|
|
caa82b8f7e | ||
|
|
5af347b499 | ||
|
|
4ed5bb5a9e | ||
|
|
2478d45673 | ||
|
|
1bc7d94111 | ||
|
|
6432414cd5 | ||
|
|
179064ba15 | ||
|
|
783b2d70a5 | ||
|
|
80824f3fc1 | ||
|
|
f39f5f531c | ||
|
|
56139c622f | ||
|
|
da02f6a39b | ||
|
|
548d5597c0 | ||
|
|
7fd65d2412 | ||
|
|
05a54a4af9 | ||
|
|
1e00c8f456 | ||
|
|
90d165aa01 | ||
|
|
01603ca9e4 | ||
|
|
a1b6eb61f2 | ||
|
|
25f300d3ec | ||
|
|
41fe63df06 | ||
|
|
b312170d5f | ||
|
|
cf7f2e8f44 | ||
|
|
d292083ed1 | ||
|
|
9b11142b45 | ||
|
|
acdbc4d7b9 | ||
|
|
838d10a09d | ||
|
|
3852aa056b | ||
|
|
ae77e4528f | ||
|
|
8be9f4cb0e | ||
|
|
1ea12b1bf7 | ||
|
|
65e6d68355 |
4
.github/workflows/code-check.yml
vendored
4
.github/workflows/code-check.yml
vendored
@@ -14,6 +14,10 @@ jobs:
|
||||
check:
|
||||
name: Check Code
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Check out from Git
|
||||
|
||||
7
.github/workflows/python-build-test.yml
vendored
7
.github/workflows/python-build-test.yml
vendored
@@ -12,10 +12,10 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||
fail-fast: false
|
||||
|
||||
@@ -41,6 +41,7 @@ jobs:
|
||||
run: |
|
||||
inv build
|
||||
inv build.mkdocs
|
||||
|
||||
build-rust:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -64,6 +65,8 @@ jobs:
|
||||
with:
|
||||
components: clippy,rustfmt
|
||||
toolchain: ${{ matrix.rust-version }}
|
||||
- name: Check License Headers
|
||||
run: cd rust && cargo run --features dev-tools --bin file-header check-all
|
||||
- name: Rust Build
|
||||
run: cd rust && cargo build --all-targets && cargo build --all-features --all-targets
|
||||
# Lints after build so what clippy needs is already built
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -39,10 +39,12 @@
|
||||
"libusb",
|
||||
"MITM",
|
||||
"NDIS",
|
||||
"netsim",
|
||||
"NONBLOCK",
|
||||
"NONCONN",
|
||||
"OXIMETER",
|
||||
"popleft",
|
||||
"protobuf",
|
||||
"psms",
|
||||
"pyee",
|
||||
"pyusb",
|
||||
|
||||
@@ -1172,7 +1172,7 @@ class ScanResult:
|
||||
name = ''
|
||||
|
||||
# Remove any '/P' qualifier suffix from the address string
|
||||
address_str = str(self.address).replace('/P', '')
|
||||
address_str = self.address.to_string(with_type_qualifier=False)
|
||||
|
||||
# RSSI bar
|
||||
bar_string = rssi_bar(self.rssi)
|
||||
|
||||
@@ -63,7 +63,8 @@ async def get_classic_info(host):
|
||||
if command_succeeded(response):
|
||||
print()
|
||||
print(
|
||||
color('Classic Address:', 'yellow'), response.return_parameters.bd_addr
|
||||
color('Classic Address:', 'yellow'),
|
||||
response.return_parameters.bd_addr.to_string(False),
|
||||
)
|
||||
|
||||
if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND):
|
||||
|
||||
@@ -3,7 +3,7 @@ import click
|
||||
import logging
|
||||
import json
|
||||
|
||||
from bumble.pandora import PandoraDevice, serve
|
||||
from bumble.pandora import PandoraDevice, Config, serve
|
||||
from typing import Dict, Any
|
||||
|
||||
BUMBLE_SERVER_GRPC_PORT = 7999
|
||||
@@ -29,12 +29,14 @@ def main(grpc_port: int, rootcanal_port: int, transport: str, config: str) -> No
|
||||
transport = transport.replace('<rootcanal-port>', str(rootcanal_port))
|
||||
|
||||
bumble_config = retrieve_config(config)
|
||||
if 'transport' not in bumble_config.keys():
|
||||
bumble_config.update({'transport': transport})
|
||||
bumble_config.setdefault('transport', transport)
|
||||
device = PandoraDevice(bumble_config)
|
||||
|
||||
server_config = Config()
|
||||
server_config.load_from_dict(bumble_config.get('server', {}))
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
asyncio.run(serve(device, port=grpc_port))
|
||||
asyncio.run(serve(device, config=server_config, port=grpc_port))
|
||||
|
||||
|
||||
def retrieve_config(config: str) -> Dict[str, Any]:
|
||||
|
||||
15
apps/show.py
15
apps/show.py
@@ -102,9 +102,21 @@ class SnoopPacketReader:
|
||||
default='h4',
|
||||
help='Format of the input file',
|
||||
)
|
||||
@click.option(
|
||||
'--vendors',
|
||||
type=click.Choice(['android', 'zephyr']),
|
||||
multiple=True,
|
||||
help='Support vendor-specific commands (list one or more)',
|
||||
)
|
||||
@click.argument('filename')
|
||||
# pylint: disable=redefined-builtin
|
||||
def main(format, filename):
|
||||
def main(format, vendors, filename):
|
||||
for vendor in vendors:
|
||||
if vendor == 'android':
|
||||
import bumble.vendor.android.hci
|
||||
elif vendor == 'zephyr':
|
||||
import bumble.vendor.zephyr.hci
|
||||
|
||||
input = open(filename, 'rb')
|
||||
if format == 'h4':
|
||||
packet_reader = PacketReader(input)
|
||||
@@ -124,7 +136,6 @@ def main(format, filename):
|
||||
if packet is None:
|
||||
break
|
||||
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
|
||||
|
||||
except Exception as error:
|
||||
print(color(f'!!! {error}', 'red'))
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ body, h1, h2, h3, h4, h5, h6 {
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
margin: 6px;
|
||||
margin-left: 0px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
@@ -65,7 +65,7 @@ th, td {
|
||||
}
|
||||
|
||||
.properties td:nth-child(even) {
|
||||
background-color: #D6EEEE;
|
||||
background-color: #d6eeee;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Bumble Speaker</title>
|
||||
<script type="text/javascript" src="speaker.js"></script>
|
||||
<script src="speaker.js"></script>
|
||||
<link rel="stylesheet" href="speaker.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -195,7 +195,7 @@ class WebSocketOutput(QueuedOutput):
|
||||
except HCI_StatusError:
|
||||
pass
|
||||
peer_name = '' if connection.peer_name is None else connection.peer_name
|
||||
peer_address = str(connection.peer_address).replace('/P', '')
|
||||
peer_address = connection.peer_address.to_string(False)
|
||||
await self.send_message(
|
||||
'connection',
|
||||
peer_address=peer_address,
|
||||
@@ -376,7 +376,7 @@ class UiServer:
|
||||
if connection := self.speaker().connection:
|
||||
await self.send_message(
|
||||
'connection',
|
||||
peer_address=str(connection.peer_address).replace('/P', ''),
|
||||
peer_address=connection.peer_address.to_string(False),
|
||||
peer_name=connection.peer_name,
|
||||
)
|
||||
|
||||
|
||||
124
bumble/att.py
124
bumble/att.py
@@ -23,13 +23,14 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
import functools
|
||||
import struct
|
||||
from pyee import EventEmitter
|
||||
from typing import Dict, Type, TYPE_CHECKING
|
||||
from typing import Dict, Type, List, Protocol, Union, Optional, Any, TYPE_CHECKING
|
||||
|
||||
from bumble.core import UUID, name_or_number, get_dict_key_by_value, ProtocolError
|
||||
from bumble.hci import HCI_Object, key_with_value, HCI_Constant
|
||||
from bumble.core import UUID, name_or_number, ProtocolError
|
||||
from bumble.hci import HCI_Object, key_with_value
|
||||
from bumble.colors import color
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -182,6 +183,7 @@ UUID_2_FIELD_SPEC = lambda x, y: UUID.parse_uuid_2(x, y) # noqa: E731
|
||||
# pylint: enable=line-too-long
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Exceptions
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -209,7 +211,7 @@ class ATT_PDU:
|
||||
|
||||
pdu_classes: Dict[int, Type[ATT_PDU]] = {}
|
||||
op_code = 0
|
||||
name = None
|
||||
name: str
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(pdu):
|
||||
@@ -719,48 +721,68 @@ class ATT_Handle_Value_Confirmation(ATT_PDU):
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class ConnectionValue(Protocol):
|
||||
def read(self, connection) -> bytes:
|
||||
...
|
||||
|
||||
def write(self, connection, value: bytes) -> None:
|
||||
...
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Attribute(EventEmitter):
|
||||
# Permission flags
|
||||
READABLE = 0x01
|
||||
WRITEABLE = 0x02
|
||||
READ_REQUIRES_ENCRYPTION = 0x04
|
||||
WRITE_REQUIRES_ENCRYPTION = 0x08
|
||||
READ_REQUIRES_AUTHENTICATION = 0x10
|
||||
WRITE_REQUIRES_AUTHENTICATION = 0x20
|
||||
READ_REQUIRES_AUTHORIZATION = 0x40
|
||||
WRITE_REQUIRES_AUTHORIZATION = 0x80
|
||||
class Permissions(enum.IntFlag):
|
||||
READABLE = 0x01
|
||||
WRITEABLE = 0x02
|
||||
READ_REQUIRES_ENCRYPTION = 0x04
|
||||
WRITE_REQUIRES_ENCRYPTION = 0x08
|
||||
READ_REQUIRES_AUTHENTICATION = 0x10
|
||||
WRITE_REQUIRES_AUTHENTICATION = 0x20
|
||||
READ_REQUIRES_AUTHORIZATION = 0x40
|
||||
WRITE_REQUIRES_AUTHORIZATION = 0x80
|
||||
|
||||
PERMISSION_NAMES = {
|
||||
READABLE: 'READABLE',
|
||||
WRITEABLE: 'WRITEABLE',
|
||||
READ_REQUIRES_ENCRYPTION: 'READ_REQUIRES_ENCRYPTION',
|
||||
WRITE_REQUIRES_ENCRYPTION: 'WRITE_REQUIRES_ENCRYPTION',
|
||||
READ_REQUIRES_AUTHENTICATION: 'READ_REQUIRES_AUTHENTICATION',
|
||||
WRITE_REQUIRES_AUTHENTICATION: 'WRITE_REQUIRES_AUTHENTICATION',
|
||||
READ_REQUIRES_AUTHORIZATION: 'READ_REQUIRES_AUTHORIZATION',
|
||||
WRITE_REQUIRES_AUTHORIZATION: 'WRITE_REQUIRES_AUTHORIZATION',
|
||||
}
|
||||
@classmethod
|
||||
def from_string(cls, permissions_str: str) -> Attribute.Permissions:
|
||||
try:
|
||||
return functools.reduce(
|
||||
lambda x, y: x | Attribute.Permissions[y],
|
||||
permissions_str.replace('|', ',').split(","),
|
||||
Attribute.Permissions(0),
|
||||
)
|
||||
except TypeError as exc:
|
||||
# The check for `p.name is not None` here is needed because for InFlag
|
||||
# enums, the .name property can be None, when the enum value is 0,
|
||||
# so the type hint for .name is Optional[str].
|
||||
enum_list: List[str] = [p.name for p in cls if p.name is not None]
|
||||
enum_list_str = ",".join(enum_list)
|
||||
raise TypeError(
|
||||
f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {enum_list_str }\nGot: {permissions_str}"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def string_to_permissions(permissions_str: str):
|
||||
try:
|
||||
return functools.reduce(
|
||||
lambda x, y: x | get_dict_key_by_value(Attribute.PERMISSION_NAMES, y),
|
||||
permissions_str.split(","),
|
||||
0,
|
||||
)
|
||||
except TypeError as exc:
|
||||
raise TypeError(
|
||||
f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {','.join(Attribute.PERMISSION_NAMES.values())}\nGot: {permissions_str}"
|
||||
) from exc
|
||||
# Permission flags(legacy-use only)
|
||||
READABLE = Permissions.READABLE
|
||||
WRITEABLE = Permissions.WRITEABLE
|
||||
READ_REQUIRES_ENCRYPTION = Permissions.READ_REQUIRES_ENCRYPTION
|
||||
WRITE_REQUIRES_ENCRYPTION = Permissions.WRITE_REQUIRES_ENCRYPTION
|
||||
READ_REQUIRES_AUTHENTICATION = Permissions.READ_REQUIRES_AUTHENTICATION
|
||||
WRITE_REQUIRES_AUTHENTICATION = Permissions.WRITE_REQUIRES_AUTHENTICATION
|
||||
READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
|
||||
WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
|
||||
|
||||
def __init__(self, attribute_type, permissions, value=b''):
|
||||
value: Union[str, bytes, ConnectionValue]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
attribute_type: Union[str, bytes, UUID],
|
||||
permissions: Union[str, Attribute.Permissions],
|
||||
value: Union[str, bytes, ConnectionValue] = b'',
|
||||
) -> None:
|
||||
EventEmitter.__init__(self)
|
||||
self.handle = 0
|
||||
self.end_group_handle = 0
|
||||
if isinstance(permissions, str):
|
||||
self.permissions = self.string_to_permissions(permissions)
|
||||
self.permissions = Attribute.Permissions.from_string(permissions)
|
||||
else:
|
||||
self.permissions = permissions
|
||||
|
||||
@@ -778,22 +800,26 @@ class Attribute(EventEmitter):
|
||||
else:
|
||||
self.value = value
|
||||
|
||||
def encode_value(self, value):
|
||||
def encode_value(self, value: Any) -> bytes:
|
||||
return value
|
||||
|
||||
def decode_value(self, value_bytes):
|
||||
def decode_value(self, value_bytes: bytes) -> Any:
|
||||
return value_bytes
|
||||
|
||||
def read_value(self, connection: Connection):
|
||||
def read_value(self, connection: Optional[Connection]) -> bytes:
|
||||
if (
|
||||
self.permissions & self.READ_REQUIRES_ENCRYPTION
|
||||
) and not connection.encryption:
|
||||
(self.permissions & self.READ_REQUIRES_ENCRYPTION)
|
||||
and connection is not None
|
||||
and not connection.encryption
|
||||
):
|
||||
raise ATT_Error(
|
||||
error_code=ATT_INSUFFICIENT_ENCRYPTION_ERROR, att_handle=self.handle
|
||||
)
|
||||
if (
|
||||
self.permissions & self.READ_REQUIRES_AUTHENTICATION
|
||||
) and not connection.authenticated:
|
||||
(self.permissions & self.READ_REQUIRES_AUTHENTICATION)
|
||||
and connection is not None
|
||||
and not connection.authenticated
|
||||
):
|
||||
raise ATT_Error(
|
||||
error_code=ATT_INSUFFICIENT_AUTHENTICATION_ERROR, att_handle=self.handle
|
||||
)
|
||||
@@ -803,9 +829,9 @@ class Attribute(EventEmitter):
|
||||
error_code=ATT_INSUFFICIENT_AUTHORIZATION_ERROR, att_handle=self.handle
|
||||
)
|
||||
|
||||
if read := getattr(self.value, 'read', None):
|
||||
if hasattr(self.value, 'read'):
|
||||
try:
|
||||
value = read(connection) # pylint: disable=not-callable
|
||||
value = self.value.read(connection)
|
||||
except ATT_Error as error:
|
||||
raise ATT_Error(
|
||||
error_code=error.error_code, att_handle=self.handle
|
||||
@@ -815,7 +841,7 @@ class Attribute(EventEmitter):
|
||||
|
||||
return self.encode_value(value)
|
||||
|
||||
def write_value(self, connection: Connection, value_bytes):
|
||||
def write_value(self, connection: Connection, value_bytes: bytes) -> None:
|
||||
if (
|
||||
self.permissions & self.WRITE_REQUIRES_ENCRYPTION
|
||||
) and not connection.encryption:
|
||||
@@ -836,9 +862,9 @@ class Attribute(EventEmitter):
|
||||
|
||||
value = self.decode_value(value_bytes)
|
||||
|
||||
if write := getattr(self.value, 'write', None):
|
||||
if hasattr(self.value, 'write'):
|
||||
try:
|
||||
write(connection, value) # pylint: disable=not-callable
|
||||
self.value.write(connection, value) # pylint: disable=not-callable
|
||||
except ATT_Error as error:
|
||||
raise ATT_Error(
|
||||
error_code=error.error_code, att_handle=self.handle
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import itertools
|
||||
@@ -58,8 +60,10 @@ from bumble.hci import (
|
||||
HCI_Packet,
|
||||
HCI_Role_Change_Event,
|
||||
)
|
||||
from typing import Optional, Union, Dict
|
||||
from typing import Optional, Union, Dict, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble.transport.common import TransportSink, TransportSource
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -104,7 +108,7 @@ class Controller:
|
||||
self,
|
||||
name,
|
||||
host_source=None,
|
||||
host_sink=None,
|
||||
host_sink: Optional[TransportSink] = None,
|
||||
link=None,
|
||||
public_address: Optional[Union[bytes, str, Address]] = None,
|
||||
):
|
||||
|
||||
@@ -78,7 +78,13 @@ def get_dict_key_by_value(dictionary, value):
|
||||
class BaseError(Exception):
|
||||
"""Base class for errors with an error code, error name and namespace"""
|
||||
|
||||
def __init__(self, error_code, error_namespace='', error_name='', details=''):
|
||||
def __init__(
|
||||
self,
|
||||
error_code: Optional[int],
|
||||
error_namespace: str = '',
|
||||
error_name: str = '',
|
||||
details: str = '',
|
||||
):
|
||||
super().__init__()
|
||||
self.error_code = error_code
|
||||
self.error_namespace = error_namespace
|
||||
@@ -90,12 +96,14 @@ class BaseError(Exception):
|
||||
namespace = f'{self.error_namespace}/'
|
||||
else:
|
||||
namespace = ''
|
||||
if self.error_name:
|
||||
name = f'{self.error_name} [0x{self.error_code:X}]'
|
||||
else:
|
||||
name = f'0x{self.error_code:X}'
|
||||
error_text = {
|
||||
(True, True): f'{self.error_name} [0x{self.error_code:X}]',
|
||||
(True, False): self.error_name,
|
||||
(False, True): f'0x{self.error_code:X}',
|
||||
(False, False): '',
|
||||
}[(self.error_name != '', self.error_code is not None)]
|
||||
|
||||
return f'{type(self).__name__}({namespace}{name})'
|
||||
return f'{type(self).__name__}({namespace}{error_text})'
|
||||
|
||||
|
||||
class ProtocolError(BaseError):
|
||||
@@ -134,6 +142,10 @@ class ConnectionError(BaseError): # pylint: disable=redefined-builtin
|
||||
self.peer_address = peer_address
|
||||
|
||||
|
||||
class ConnectionParameterUpdateError(BaseError):
|
||||
"""Connection Parameter Update Error"""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# UUID
|
||||
#
|
||||
|
||||
@@ -23,7 +23,18 @@ import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager, AsyncExitStack
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple, Type, Union
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Type,
|
||||
Union,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
from .colors import color
|
||||
from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
|
||||
@@ -130,6 +141,7 @@ from .core import (
|
||||
BT_LE_TRANSPORT,
|
||||
BT_PERIPHERAL_ROLE,
|
||||
AdvertisingData,
|
||||
ConnectionParameterUpdateError,
|
||||
CommandTimeoutError,
|
||||
ConnectionPHY,
|
||||
InvalidStateError,
|
||||
@@ -152,6 +164,9 @@ from . import sdp
|
||||
from . import l2cap
|
||||
from . import core
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .transport.common import TransportSource, TransportSink
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -709,6 +724,7 @@ class Connection(CompositeEventEmitter):
|
||||
connection_interval_max,
|
||||
max_latency,
|
||||
supervision_timeout,
|
||||
use_l2cap=False,
|
||||
):
|
||||
return await self.device.update_connection_parameters(
|
||||
self,
|
||||
@@ -716,6 +732,7 @@ class Connection(CompositeEventEmitter):
|
||||
connection_interval_max,
|
||||
max_latency,
|
||||
supervision_timeout,
|
||||
use_l2cap=use_l2cap,
|
||||
)
|
||||
|
||||
async def set_phy(self, tx_phys=None, rx_phys=None, phy_options=None):
|
||||
@@ -942,7 +959,13 @@ class Device(CompositeEventEmitter):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def with_hci(cls, name, address, hci_source, hci_sink):
|
||||
def with_hci(
|
||||
cls,
|
||||
name: str,
|
||||
address: Address,
|
||||
hci_source: TransportSource,
|
||||
hci_sink: TransportSink,
|
||||
) -> Device:
|
||||
'''
|
||||
Create a Device instance with a Host configured to communicate with a controller
|
||||
through an HCI source/sink
|
||||
@@ -951,18 +974,25 @@ class Device(CompositeEventEmitter):
|
||||
return cls(name=name, address=address, host=host)
|
||||
|
||||
@classmethod
|
||||
def from_config_file(cls, filename):
|
||||
def from_config_file(cls, filename: str) -> Device:
|
||||
config = DeviceConfiguration()
|
||||
config.load_from_file(filename)
|
||||
return cls(config=config)
|
||||
|
||||
@classmethod
|
||||
def from_config_with_hci(cls, config, hci_source, hci_sink):
|
||||
def from_config_with_hci(
|
||||
cls,
|
||||
config: DeviceConfiguration,
|
||||
hci_source: TransportSource,
|
||||
hci_sink: TransportSink,
|
||||
) -> Device:
|
||||
host = Host(controller_source=hci_source, controller_sink=hci_sink)
|
||||
return cls(config=config, host=host)
|
||||
|
||||
@classmethod
|
||||
def from_config_file_with_hci(cls, filename, hci_source, hci_sink):
|
||||
def from_config_file_with_hci(
|
||||
cls, filename: str, hci_source: TransportSource, hci_sink: TransportSink
|
||||
) -> Device:
|
||||
config = DeviceConfiguration()
|
||||
config.load_from_file(filename)
|
||||
return cls.from_config_with_hci(config, hci_source, hci_sink)
|
||||
@@ -1156,8 +1186,8 @@ class Device(CompositeEventEmitter):
|
||||
def create_l2cap_registrar(self, psm):
|
||||
return lambda handler: self.register_l2cap_server(psm, handler)
|
||||
|
||||
def register_l2cap_server(self, psm, server):
|
||||
self.l2cap_channel_manager.register_server(psm, server)
|
||||
def register_l2cap_server(self, psm, server) -> int:
|
||||
return self.l2cap_channel_manager.register_server(psm, server)
|
||||
|
||||
def register_l2cap_channel_server(
|
||||
self,
|
||||
@@ -2083,11 +2113,30 @@ class Device(CompositeEventEmitter):
|
||||
supervision_timeout,
|
||||
min_ce_length=0,
|
||||
max_ce_length=0,
|
||||
):
|
||||
use_l2cap=False,
|
||||
) -> None:
|
||||
'''
|
||||
NOTE: the name of the parameters may look odd, but it just follows the names
|
||||
used in the Bluetooth spec.
|
||||
'''
|
||||
|
||||
if use_l2cap:
|
||||
if connection.role != BT_PERIPHERAL_ROLE:
|
||||
raise InvalidStateError(
|
||||
'only peripheral can update connection parameters with l2cap'
|
||||
)
|
||||
l2cap_result = (
|
||||
await self.l2cap_channel_manager.update_connection_parameters(
|
||||
connection,
|
||||
connection_interval_min,
|
||||
connection_interval_max,
|
||||
max_latency,
|
||||
supervision_timeout,
|
||||
)
|
||||
)
|
||||
if l2cap_result != l2cap.L2CAP_CONNECTION_PARAMETERS_ACCEPTED_RESULT:
|
||||
raise ConnectionParameterUpdateError(l2cap_result)
|
||||
|
||||
result = await self.send_command(
|
||||
HCI_LE_Connection_Update_Command(
|
||||
connection_handle=connection.handle,
|
||||
@@ -2097,7 +2146,7 @@ class Device(CompositeEventEmitter):
|
||||
supervision_timeout=supervision_timeout,
|
||||
min_ce_length=min_ce_length,
|
||||
max_ce_length=max_ce_length,
|
||||
)
|
||||
) # type: ignore[call-arg]
|
||||
)
|
||||
if result.status != HCI_Command_Status_Event.PENDING:
|
||||
raise HCI_StatusError(result)
|
||||
@@ -2238,9 +2287,11 @@ class Device(CompositeEventEmitter):
|
||||
def request_pairing(self, connection):
|
||||
return self.smp_manager.request_pairing(connection)
|
||||
|
||||
async def get_long_term_key(self, connection_handle, rand, ediv):
|
||||
async def get_long_term_key(
|
||||
self, connection_handle: int, rand: bytes, ediv: int
|
||||
) -> Optional[bytes]:
|
||||
if (connection := self.lookup_connection(connection_handle)) is None:
|
||||
return
|
||||
return None
|
||||
|
||||
# Start by looking for the key in an SMP session
|
||||
ltk = self.smp_manager.get_long_term_key(connection, rand, ediv)
|
||||
@@ -2260,6 +2311,7 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
if connection.role == BT_PERIPHERAL_ROLE and keys.ltk_peripheral:
|
||||
return keys.ltk_peripheral.value
|
||||
return None
|
||||
|
||||
async def get_link_key(self, address: Address) -> Optional[bytes]:
|
||||
if self.keystore is None:
|
||||
@@ -2706,7 +2758,9 @@ class Device(CompositeEventEmitter):
|
||||
self.abort_on(
|
||||
'flush',
|
||||
self.start_advertising(
|
||||
advertising_type=self.advertising_type, auto_restart=True
|
||||
advertising_type=self.advertising_type,
|
||||
own_address_type=self.advertising_own_address_type,
|
||||
auto_restart=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import abc
|
||||
import logging
|
||||
import pathlib
|
||||
import platform
|
||||
import platformdirs
|
||||
from . import rtk
|
||||
|
||||
|
||||
@@ -77,6 +76,8 @@ def project_data_dir() -> pathlib.Path:
|
||||
A path to an OS-specific directory for bumble data. The directory is created if
|
||||
it doesn't exist.
|
||||
"""
|
||||
import platformdirs
|
||||
|
||||
if platform.system() == 'Darwin':
|
||||
# platformdirs doesn't handle macOS right: it doesn't assemble a bundle id
|
||||
# out of author & project
|
||||
|
||||
@@ -34,10 +34,9 @@ import weakref
|
||||
|
||||
|
||||
from bumble.hci import (
|
||||
hci_command_op_code,
|
||||
hci_vendor_command_op_code,
|
||||
STATUS_SPEC,
|
||||
HCI_SUCCESS,
|
||||
HCI_COMMAND_NAMES,
|
||||
HCI_Command,
|
||||
HCI_Reset_Command,
|
||||
HCI_Read_Local_Version_Information_Command,
|
||||
@@ -179,8 +178,10 @@ RTK_USB_PRODUCTS = {
|
||||
# -----------------------------------------------------------------------------
|
||||
# HCI Commands
|
||||
# -----------------------------------------------------------------------------
|
||||
HCI_RTK_READ_ROM_VERSION_COMMAND = hci_command_op_code(0x3F, 0x6D)
|
||||
HCI_COMMAND_NAMES[HCI_RTK_READ_ROM_VERSION_COMMAND] = "HCI_RTK_READ_ROM_VERSION_COMMAND"
|
||||
HCI_RTK_READ_ROM_VERSION_COMMAND = hci_vendor_command_op_code(0x6D)
|
||||
HCI_RTK_DOWNLOAD_COMMAND = hci_vendor_command_op_code(0x20)
|
||||
HCI_RTK_DROP_FIRMWARE_COMMAND = hci_vendor_command_op_code(0x66)
|
||||
HCI_Command.register_commands(globals())
|
||||
|
||||
|
||||
@HCI_Command.command(return_parameters_fields=[("status", STATUS_SPEC), ("version", 1)])
|
||||
@@ -188,10 +189,6 @@ class HCI_RTK_Read_ROM_Version_Command(HCI_Command):
|
||||
pass
|
||||
|
||||
|
||||
HCI_RTK_DOWNLOAD_COMMAND = hci_command_op_code(0x3F, 0x20)
|
||||
HCI_COMMAND_NAMES[HCI_RTK_DOWNLOAD_COMMAND] = "HCI_RTK_DOWNLOAD_COMMAND"
|
||||
|
||||
|
||||
@HCI_Command.command(
|
||||
fields=[("index", 1), ("payload", RTK_FRAGMENT_LENGTH)],
|
||||
return_parameters_fields=[("status", STATUS_SPEC), ("index", 1)],
|
||||
@@ -200,10 +197,6 @@ class HCI_RTK_Download_Command(HCI_Command):
|
||||
pass
|
||||
|
||||
|
||||
HCI_RTK_DROP_FIRMWARE_COMMAND = hci_command_op_code(0x3F, 0x66)
|
||||
HCI_COMMAND_NAMES[HCI_RTK_DROP_FIRMWARE_COMMAND] = "HCI_RTK_DROP_FIRMWARE_COMMAND"
|
||||
|
||||
|
||||
@HCI_Command.command()
|
||||
class HCI_RTK_Drop_Firmware_Command(HCI_Command):
|
||||
pass
|
||||
|
||||
@@ -28,7 +28,7 @@ import enum
|
||||
import functools
|
||||
import logging
|
||||
import struct
|
||||
from typing import Optional, Sequence, List
|
||||
from typing import Optional, Sequence, Iterable, List, Union
|
||||
|
||||
from .colors import color
|
||||
from .core import UUID, get_dict_key_by_value
|
||||
@@ -187,7 +187,7 @@ GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bi
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def show_services(services):
|
||||
def show_services(services: Iterable[Service]) -> None:
|
||||
for service in services:
|
||||
print(color(str(service), 'cyan'))
|
||||
|
||||
@@ -210,11 +210,11 @@ class Service(Attribute):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
uuid,
|
||||
uuid: Union[str, UUID],
|
||||
characteristics: List[Characteristic],
|
||||
primary=True,
|
||||
included_services: List[Service] = [],
|
||||
):
|
||||
) -> None:
|
||||
# Convert the uuid to a UUID object if it isn't already
|
||||
if isinstance(uuid, str):
|
||||
uuid = UUID(uuid)
|
||||
@@ -239,7 +239,7 @@ class Service(Attribute):
|
||||
"""
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'Service(handle=0x{self.handle:04X}, '
|
||||
f'end=0x{self.end_group_handle:04X}, '
|
||||
@@ -255,9 +255,11 @@ class TemplateService(Service):
|
||||
to expose their UUID as a class property
|
||||
'''
|
||||
|
||||
UUID: Optional[UUID] = None
|
||||
UUID: UUID
|
||||
|
||||
def __init__(self, characteristics, primary=True):
|
||||
def __init__(
|
||||
self, characteristics: List[Characteristic], primary: bool = True
|
||||
) -> None:
|
||||
super().__init__(self.UUID, characteristics, primary)
|
||||
|
||||
|
||||
@@ -269,7 +271,7 @@ class IncludedServiceDeclaration(Attribute):
|
||||
|
||||
service: Service
|
||||
|
||||
def __init__(self, service):
|
||||
def __init__(self, service: Service) -> None:
|
||||
declaration_bytes = struct.pack(
|
||||
'<HH2s', service.handle, service.end_group_handle, service.uuid.to_bytes()
|
||||
)
|
||||
@@ -278,7 +280,7 @@ class IncludedServiceDeclaration(Attribute):
|
||||
)
|
||||
self.service = service
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'IncludedServiceDefinition(handle=0x{self.handle:04X}, '
|
||||
f'group_starting_handle=0x{self.service.handle:04X}, '
|
||||
@@ -326,7 +328,7 @@ class Characteristic(Attribute):
|
||||
f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by , or |: {enum_list_str}\nGot: {properties_str}"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
# NOTE: we override this method to offer a consistent result between python
|
||||
# versions: the value returned by IntFlag.__str__() changed in version 11.
|
||||
return '|'.join(
|
||||
@@ -348,10 +350,10 @@ class Characteristic(Attribute):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
uuid,
|
||||
uuid: Union[str, bytes, UUID],
|
||||
properties: Characteristic.Properties,
|
||||
permissions,
|
||||
value=b'',
|
||||
permissions: Union[str, Attribute.Permissions],
|
||||
value: Union[str, bytes, CharacteristicValue] = b'',
|
||||
descriptors: Sequence[Descriptor] = (),
|
||||
):
|
||||
super().__init__(uuid, permissions, value)
|
||||
@@ -369,7 +371,7 @@ class Characteristic(Attribute):
|
||||
def has_properties(self, properties: Characteristic.Properties) -> bool:
|
||||
return self.properties & properties == properties
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'Characteristic(handle=0x{self.handle:04X}, '
|
||||
f'end=0x{self.end_group_handle:04X}, '
|
||||
@@ -386,7 +388,7 @@ class CharacteristicDeclaration(Attribute):
|
||||
|
||||
characteristic: Characteristic
|
||||
|
||||
def __init__(self, characteristic, value_handle):
|
||||
def __init__(self, characteristic: Characteristic, value_handle: int) -> None:
|
||||
declaration_bytes = (
|
||||
struct.pack('<BH', characteristic.properties, value_handle)
|
||||
+ characteristic.uuid.to_pdu_bytes()
|
||||
@@ -397,7 +399,7 @@ class CharacteristicDeclaration(Attribute):
|
||||
self.value_handle = value_handle
|
||||
self.characteristic = characteristic
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
|
||||
f'value_handle=0x{self.value_handle:04X}, '
|
||||
@@ -520,7 +522,7 @@ class CharacteristicAdapter:
|
||||
|
||||
return self.wrapped_characteristic.unsubscribe(subscriber)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
wrapped = str(self.wrapped_characteristic)
|
||||
return f'{self.__class__.__name__}({wrapped})'
|
||||
|
||||
@@ -600,10 +602,10 @@ class UTF8CharacteristicAdapter(CharacteristicAdapter):
|
||||
Adapter that converts strings to/from bytes using UTF-8 encoding
|
||||
'''
|
||||
|
||||
def encode_value(self, value):
|
||||
def encode_value(self, value: str) -> bytes:
|
||||
return value.encode('utf-8')
|
||||
|
||||
def decode_value(self, value):
|
||||
def decode_value(self, value: bytes) -> str:
|
||||
return value.decode('utf-8')
|
||||
|
||||
|
||||
@@ -613,7 +615,7 @@ class Descriptor(Attribute):
|
||||
See Vol 3, Part G - 3.3.3 Characteristic Descriptor Declarations
|
||||
'''
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'Descriptor(handle=0x{self.handle:04X}, '
|
||||
f'type={self.type}, '
|
||||
|
||||
@@ -28,7 +28,18 @@ import asyncio
|
||||
import logging
|
||||
import struct
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Tuple, Callable, Union, Any
|
||||
from typing import (
|
||||
List,
|
||||
Optional,
|
||||
Dict,
|
||||
Tuple,
|
||||
Callable,
|
||||
Union,
|
||||
Any,
|
||||
Iterable,
|
||||
Type,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
from pyee import EventEmitter
|
||||
|
||||
@@ -66,8 +77,12 @@ from .gatt import (
|
||||
GATT_INCLUDE_ATTRIBUTE_TYPE,
|
||||
Characteristic,
|
||||
ClientCharacteristicConfigurationBits,
|
||||
TemplateService,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble.device import Connection
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -78,16 +93,16 @@ logger = logging.getLogger(__name__)
|
||||
# Proxies
|
||||
# -----------------------------------------------------------------------------
|
||||
class AttributeProxy(EventEmitter):
|
||||
client: Client
|
||||
|
||||
def __init__(self, client, handle, end_group_handle, attribute_type):
|
||||
def __init__(
|
||||
self, client: Client, handle: int, end_group_handle: int, attribute_type: UUID
|
||||
) -> None:
|
||||
EventEmitter.__init__(self)
|
||||
self.client = client
|
||||
self.handle = handle
|
||||
self.end_group_handle = end_group_handle
|
||||
self.type = attribute_type
|
||||
|
||||
async def read_value(self, no_long_read=False):
|
||||
async def read_value(self, no_long_read: bool = False) -> bytes:
|
||||
return self.decode_value(
|
||||
await self.client.read_value(self.handle, no_long_read)
|
||||
)
|
||||
@@ -97,13 +112,13 @@ class AttributeProxy(EventEmitter):
|
||||
self.handle, self.encode_value(value), with_response
|
||||
)
|
||||
|
||||
def encode_value(self, value):
|
||||
def encode_value(self, value: Any) -> bytes:
|
||||
return value
|
||||
|
||||
def decode_value(self, value_bytes):
|
||||
def decode_value(self, value_bytes: bytes) -> Any:
|
||||
return value_bytes
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return f'Attribute(handle=0x{self.handle:04X}, type={self.type})'
|
||||
|
||||
|
||||
@@ -136,14 +151,14 @@ class ServiceProxy(AttributeProxy):
|
||||
def get_characteristics_by_uuid(self, uuid):
|
||||
return self.client.get_characteristics_by_uuid(uuid, self)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return f'Service(handle=0x{self.handle:04X}, uuid={self.uuid})'
|
||||
|
||||
|
||||
class CharacteristicProxy(AttributeProxy):
|
||||
properties: Characteristic.Properties
|
||||
descriptors: List[DescriptorProxy]
|
||||
subscribers: Dict[Any, Callable]
|
||||
subscribers: Dict[Any, Callable[[bytes], Any]]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -171,7 +186,9 @@ class CharacteristicProxy(AttributeProxy):
|
||||
return await self.client.discover_descriptors(self)
|
||||
|
||||
async def subscribe(
|
||||
self, subscriber: Optional[Callable] = None, prefer_notify=True
|
||||
self,
|
||||
subscriber: Optional[Callable[[bytes], Any]] = None,
|
||||
prefer_notify: bool = True,
|
||||
):
|
||||
if subscriber is not None:
|
||||
if subscriber in self.subscribers:
|
||||
@@ -195,7 +212,7 @@ class CharacteristicProxy(AttributeProxy):
|
||||
|
||||
return await self.client.unsubscribe(self, subscriber)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'Characteristic(handle=0x{self.handle:04X}, '
|
||||
f'uuid={self.uuid}, '
|
||||
@@ -207,7 +224,7 @@ class DescriptorProxy(AttributeProxy):
|
||||
def __init__(self, client, handle, descriptor_type):
|
||||
super().__init__(client, handle, 0, descriptor_type)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return f'Descriptor(handle=0x{self.handle:04X}, type={self.type})'
|
||||
|
||||
|
||||
@@ -216,8 +233,10 @@ class ProfileServiceProxy:
|
||||
Base class for profile-specific service proxies
|
||||
'''
|
||||
|
||||
SERVICE_CLASS: Type[TemplateService]
|
||||
|
||||
@classmethod
|
||||
def from_client(cls, client):
|
||||
def from_client(cls, client: Client) -> ProfileServiceProxy:
|
||||
return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID)
|
||||
|
||||
|
||||
@@ -227,8 +246,12 @@ class ProfileServiceProxy:
|
||||
class Client:
|
||||
services: List[ServiceProxy]
|
||||
cached_values: Dict[int, Tuple[datetime, bytes]]
|
||||
notification_subscribers: Dict[int, Callable[[bytes], Any]]
|
||||
indication_subscribers: Dict[int, Callable[[bytes], Any]]
|
||||
pending_response: Optional[asyncio.futures.Future[ATT_PDU]]
|
||||
pending_request: Optional[ATT_PDU]
|
||||
|
||||
def __init__(self, connection):
|
||||
def __init__(self, connection: Connection) -> None:
|
||||
self.connection = connection
|
||||
self.mtu_exchange_done = False
|
||||
self.request_semaphore = asyncio.Semaphore(1)
|
||||
@@ -241,16 +264,16 @@ class Client:
|
||||
self.services = []
|
||||
self.cached_values = {}
|
||||
|
||||
def send_gatt_pdu(self, pdu):
|
||||
def send_gatt_pdu(self, pdu: bytes) -> None:
|
||||
self.connection.send_l2cap_pdu(ATT_CID, pdu)
|
||||
|
||||
async def send_command(self, command):
|
||||
async def send_command(self, command: ATT_PDU) -> None:
|
||||
logger.debug(
|
||||
f'GATT Command from client: [0x{self.connection.handle:04X}] {command}'
|
||||
)
|
||||
self.send_gatt_pdu(command.to_bytes())
|
||||
|
||||
async def send_request(self, request):
|
||||
async def send_request(self, request: ATT_PDU):
|
||||
logger.debug(
|
||||
f'GATT Request from client: [0x{self.connection.handle:04X}] {request}'
|
||||
)
|
||||
@@ -279,14 +302,14 @@ class Client:
|
||||
|
||||
return response
|
||||
|
||||
def send_confirmation(self, confirmation):
|
||||
def send_confirmation(self, confirmation: ATT_Handle_Value_Confirmation) -> None:
|
||||
logger.debug(
|
||||
f'GATT Confirmation from client: [0x{self.connection.handle:04X}] '
|
||||
f'{confirmation}'
|
||||
)
|
||||
self.send_gatt_pdu(confirmation.to_bytes())
|
||||
|
||||
async def request_mtu(self, mtu):
|
||||
async def request_mtu(self, mtu: int) -> int:
|
||||
# Check the range
|
||||
if mtu < ATT_DEFAULT_MTU:
|
||||
raise ValueError(f'MTU must be >= {ATT_DEFAULT_MTU}')
|
||||
@@ -313,10 +336,12 @@ class Client:
|
||||
|
||||
return self.connection.att_mtu
|
||||
|
||||
def get_services_by_uuid(self, uuid):
|
||||
def get_services_by_uuid(self, uuid: UUID) -> List[ServiceProxy]:
|
||||
return [service for service in self.services if service.uuid == uuid]
|
||||
|
||||
def get_characteristics_by_uuid(self, uuid, service=None):
|
||||
def get_characteristics_by_uuid(
|
||||
self, uuid: UUID, service: Optional[ServiceProxy] = None
|
||||
) -> List[CharacteristicProxy]:
|
||||
services = [service] if service else self.services
|
||||
return [
|
||||
c
|
||||
@@ -363,7 +388,7 @@ class Client:
|
||||
if not already_known:
|
||||
self.services.append(service)
|
||||
|
||||
async def discover_services(self, uuids=None) -> List[ServiceProxy]:
|
||||
async def discover_services(self, uuids: Iterable[UUID] = []) -> List[ServiceProxy]:
|
||||
'''
|
||||
See Vol 3, Part G - 4.4.1 Discover All Primary Services
|
||||
'''
|
||||
@@ -435,7 +460,7 @@ class Client:
|
||||
|
||||
return services
|
||||
|
||||
async def discover_service(self, uuid):
|
||||
async def discover_service(self, uuid: Union[str, UUID]) -> List[ServiceProxy]:
|
||||
'''
|
||||
See Vol 3, Part G - 4.4.2 Discover Primary Service by Service UUID
|
||||
'''
|
||||
@@ -468,7 +493,7 @@ class Client:
|
||||
f'{HCI_Constant.error_name(response.error_code)}'
|
||||
)
|
||||
# TODO raise appropriate exception
|
||||
return
|
||||
return []
|
||||
break
|
||||
|
||||
for attribute_handle, end_group_handle in response.handles_information:
|
||||
@@ -480,7 +505,7 @@ class Client:
|
||||
logger.warning(
|
||||
f'bogus handle values: {attribute_handle} {end_group_handle}'
|
||||
)
|
||||
return
|
||||
return []
|
||||
|
||||
# Create a service proxy for this service
|
||||
service = ServiceProxy(
|
||||
@@ -721,7 +746,7 @@ class Client:
|
||||
|
||||
return descriptors
|
||||
|
||||
async def discover_attributes(self):
|
||||
async def discover_attributes(self) -> List[AttributeProxy]:
|
||||
'''
|
||||
Discover all attributes, regardless of type
|
||||
'''
|
||||
@@ -844,7 +869,9 @@ class Client:
|
||||
# No more subscribers left
|
||||
await self.write_value(cccd, b'\x00\x00', with_response=True)
|
||||
|
||||
async def read_value(self, attribute, no_long_read=False):
|
||||
async def read_value(
|
||||
self, attribute: Union[int, AttributeProxy], no_long_read: bool = False
|
||||
) -> Any:
|
||||
'''
|
||||
See Vol 3, Part G - 4.8.1 Read Characteristic Value
|
||||
|
||||
@@ -905,7 +932,9 @@ class Client:
|
||||
# Return the value as bytes
|
||||
return attribute_value
|
||||
|
||||
async def read_characteristics_by_uuid(self, uuid, service):
|
||||
async def read_characteristics_by_uuid(
|
||||
self, uuid: UUID, service: Optional[ServiceProxy]
|
||||
) -> List[bytes]:
|
||||
'''
|
||||
See Vol 3, Part G - 4.8.2 Read Using Characteristic UUID
|
||||
'''
|
||||
@@ -960,7 +989,12 @@ class Client:
|
||||
|
||||
return characteristics_values
|
||||
|
||||
async def write_value(self, attribute, value, with_response=False):
|
||||
async def write_value(
|
||||
self,
|
||||
attribute: Union[int, AttributeProxy],
|
||||
value: bytes,
|
||||
with_response: bool = False,
|
||||
) -> None:
|
||||
'''
|
||||
See Vol 3, Part G - 4.9.1 Write Without Response & 4.9.3 Write Characteristic
|
||||
Value
|
||||
@@ -990,7 +1024,7 @@ class Client:
|
||||
)
|
||||
)
|
||||
|
||||
def on_gatt_pdu(self, att_pdu):
|
||||
def on_gatt_pdu(self, att_pdu: ATT_PDU) -> None:
|
||||
logger.debug(
|
||||
f'GATT Response to client: [0x{self.connection.handle:04X}] {att_pdu}'
|
||||
)
|
||||
@@ -1013,6 +1047,7 @@ class Client:
|
||||
return
|
||||
|
||||
# Return the response to the coroutine that is waiting for it
|
||||
assert self.pending_response is not None
|
||||
self.pending_response.set_result(att_pdu)
|
||||
else:
|
||||
handler_name = f'on_{att_pdu.name.lower()}'
|
||||
@@ -1060,7 +1095,7 @@ class Client:
|
||||
# Confirm that we received the indication
|
||||
self.send_confirmation(ATT_Handle_Value_Confirmation())
|
||||
|
||||
def cache_value(self, attribute_handle: int, value: bytes):
|
||||
def cache_value(self, attribute_handle: int, value: bytes) -> None:
|
||||
self.cached_values[attribute_handle] = (
|
||||
datetime.now(),
|
||||
value,
|
||||
|
||||
@@ -23,11 +23,12 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
import struct
|
||||
from typing import List, Tuple, Optional, TypeVar, Type
|
||||
from typing import List, Tuple, Optional, TypeVar, Type, Dict, Iterable, TYPE_CHECKING
|
||||
from pyee import EventEmitter
|
||||
|
||||
from .colors import color
|
||||
@@ -42,6 +43,7 @@ from .att import (
|
||||
ATT_INVALID_OFFSET_ERROR,
|
||||
ATT_REQUEST_NOT_SUPPORTED_ERROR,
|
||||
ATT_REQUESTS,
|
||||
ATT_PDU,
|
||||
ATT_UNLIKELY_ERROR_ERROR,
|
||||
ATT_UNSUPPORTED_GROUP_TYPE_ERROR,
|
||||
ATT_Error,
|
||||
@@ -73,6 +75,8 @@ from .gatt import (
|
||||
Service,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble.device import Device, Connection
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -91,8 +95,13 @@ GATT_SERVER_DEFAULT_MAX_MTU = 517
|
||||
# -----------------------------------------------------------------------------
|
||||
class Server(EventEmitter):
|
||||
attributes: List[Attribute]
|
||||
services: List[Service]
|
||||
attributes_by_handle: Dict[int, Attribute]
|
||||
subscribers: Dict[int, Dict[int, bytes]]
|
||||
indication_semaphores: defaultdict[int, asyncio.Semaphore]
|
||||
pending_confirmations: defaultdict[int, Optional[asyncio.futures.Future]]
|
||||
|
||||
def __init__(self, device):
|
||||
def __init__(self, device: Device) -> None:
|
||||
super().__init__()
|
||||
self.device = device
|
||||
self.services = []
|
||||
@@ -107,16 +116,16 @@ class Server(EventEmitter):
|
||||
self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1))
|
||||
self.pending_confirmations = defaultdict(lambda: None)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return "\n".join(map(str, self.attributes))
|
||||
|
||||
def send_gatt_pdu(self, connection_handle, pdu):
|
||||
def send_gatt_pdu(self, connection_handle: int, pdu: bytes) -> None:
|
||||
self.device.send_l2cap_pdu(connection_handle, ATT_CID, pdu)
|
||||
|
||||
def next_handle(self):
|
||||
def next_handle(self) -> int:
|
||||
return 1 + len(self.attributes)
|
||||
|
||||
def get_advertising_service_data(self):
|
||||
def get_advertising_service_data(self) -> Dict[Attribute, bytes]:
|
||||
return {
|
||||
attribute: data
|
||||
for attribute in self.attributes
|
||||
@@ -124,7 +133,7 @@ class Server(EventEmitter):
|
||||
and (data := attribute.get_advertising_data())
|
||||
}
|
||||
|
||||
def get_attribute(self, handle):
|
||||
def get_attribute(self, handle: int) -> Optional[Attribute]:
|
||||
attribute = self.attributes_by_handle.get(handle)
|
||||
if attribute:
|
||||
return attribute
|
||||
@@ -173,12 +182,17 @@ class Server(EventEmitter):
|
||||
|
||||
return next(
|
||||
(
|
||||
(attribute, self.get_attribute(attribute.characteristic.handle))
|
||||
(
|
||||
attribute,
|
||||
self.get_attribute(attribute.characteristic.handle),
|
||||
) # type: ignore
|
||||
for attribute in map(
|
||||
self.get_attribute,
|
||||
range(service_handle.handle, service_handle.end_group_handle + 1),
|
||||
)
|
||||
if attribute.type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
|
||||
if attribute is not None
|
||||
and attribute.type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
|
||||
and isinstance(attribute, CharacteristicDeclaration)
|
||||
and attribute.characteristic.uuid == characteristic_uuid
|
||||
),
|
||||
None,
|
||||
@@ -197,7 +211,7 @@ class Server(EventEmitter):
|
||||
|
||||
return next(
|
||||
(
|
||||
attribute
|
||||
attribute # type: ignore
|
||||
for attribute in map(
|
||||
self.get_attribute,
|
||||
range(
|
||||
@@ -205,12 +219,12 @@ class Server(EventEmitter):
|
||||
characteristic_value.end_group_handle + 1,
|
||||
),
|
||||
)
|
||||
if attribute.type == descriptor_uuid
|
||||
if attribute is not None and attribute.type == descriptor_uuid
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def add_attribute(self, attribute):
|
||||
def add_attribute(self, attribute: Attribute) -> None:
|
||||
# Assign a handle to this attribute
|
||||
attribute.handle = self.next_handle()
|
||||
attribute.end_group_handle = (
|
||||
@@ -220,7 +234,7 @@ class Server(EventEmitter):
|
||||
# Add this attribute to the list
|
||||
self.attributes.append(attribute)
|
||||
|
||||
def add_service(self, service: Service):
|
||||
def add_service(self, service: Service) -> None:
|
||||
# Add the service attribute to the DB
|
||||
self.add_attribute(service)
|
||||
|
||||
@@ -285,11 +299,13 @@ class Server(EventEmitter):
|
||||
service.end_group_handle = self.attributes[-1].handle
|
||||
self.services.append(service)
|
||||
|
||||
def add_services(self, services):
|
||||
def add_services(self, services: Iterable[Service]) -> None:
|
||||
for service in services:
|
||||
self.add_service(service)
|
||||
|
||||
def read_cccd(self, connection, characteristic):
|
||||
def read_cccd(
|
||||
self, connection: Optional[Connection], characteristic: Characteristic
|
||||
) -> bytes:
|
||||
if connection is None:
|
||||
return bytes([0, 0])
|
||||
|
||||
@@ -300,7 +316,12 @@ class Server(EventEmitter):
|
||||
|
||||
return cccd or bytes([0, 0])
|
||||
|
||||
def write_cccd(self, connection, characteristic, value):
|
||||
def write_cccd(
|
||||
self,
|
||||
connection: Connection,
|
||||
characteristic: Characteristic,
|
||||
value: bytes,
|
||||
) -> None:
|
||||
logger.debug(
|
||||
f'Subscription update for connection=0x{connection.handle:04X}, '
|
||||
f'handle=0x{characteristic.handle:04X}: {value.hex()}'
|
||||
@@ -327,13 +348,19 @@ class Server(EventEmitter):
|
||||
indicate_enabled,
|
||||
)
|
||||
|
||||
def send_response(self, connection, response):
|
||||
def send_response(self, connection: Connection, response: ATT_PDU) -> None:
|
||||
logger.debug(
|
||||
f'GATT Response from server: [0x{connection.handle:04X}] {response}'
|
||||
)
|
||||
self.send_gatt_pdu(connection.handle, response.to_bytes())
|
||||
|
||||
async def notify_subscriber(self, connection, attribute, value=None, force=False):
|
||||
async def notify_subscriber(
|
||||
self,
|
||||
connection: Connection,
|
||||
attribute: Attribute,
|
||||
value: Optional[bytes] = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
# Check if there's a subscriber
|
||||
if not force:
|
||||
subscribers = self.subscribers.get(connection.handle)
|
||||
@@ -370,7 +397,13 @@ class Server(EventEmitter):
|
||||
)
|
||||
self.send_gatt_pdu(connection.handle, bytes(notification))
|
||||
|
||||
async def indicate_subscriber(self, connection, attribute, value=None, force=False):
|
||||
async def indicate_subscriber(
|
||||
self,
|
||||
connection: Connection,
|
||||
attribute: Attribute,
|
||||
value: Optional[bytes] = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
# Check if there's a subscriber
|
||||
if not force:
|
||||
subscribers = self.subscribers.get(connection.handle)
|
||||
@@ -411,15 +444,13 @@ class Server(EventEmitter):
|
||||
assert self.pending_confirmations[connection.handle] is None
|
||||
|
||||
# Create a future value to hold the eventual response
|
||||
self.pending_confirmations[
|
||||
pending_confirmation = self.pending_confirmations[
|
||||
connection.handle
|
||||
] = asyncio.get_running_loop().create_future()
|
||||
|
||||
try:
|
||||
self.send_gatt_pdu(connection.handle, indication.to_bytes())
|
||||
await asyncio.wait_for(
|
||||
self.pending_confirmations[connection.handle], GATT_REQUEST_TIMEOUT
|
||||
)
|
||||
await asyncio.wait_for(pending_confirmation, GATT_REQUEST_TIMEOUT)
|
||||
except asyncio.TimeoutError as error:
|
||||
logger.warning(color('!!! GATT Indicate timeout', 'red'))
|
||||
raise TimeoutError(f'GATT timeout for {indication.name}') from error
|
||||
@@ -427,8 +458,12 @@ class Server(EventEmitter):
|
||||
self.pending_confirmations[connection.handle] = None
|
||||
|
||||
async def notify_or_indicate_subscribers(
|
||||
self, indicate, attribute, value=None, force=False
|
||||
):
|
||||
self,
|
||||
indicate: bool,
|
||||
attribute: Attribute,
|
||||
value: Optional[bytes] = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
# Get all the connections for which there's at least one subscription
|
||||
connections = [
|
||||
connection
|
||||
@@ -450,13 +485,23 @@ class Server(EventEmitter):
|
||||
]
|
||||
)
|
||||
|
||||
async def notify_subscribers(self, attribute, value=None, force=False):
|
||||
async def notify_subscribers(
|
||||
self,
|
||||
attribute: Attribute,
|
||||
value: Optional[bytes] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
return await self.notify_or_indicate_subscribers(False, attribute, value, force)
|
||||
|
||||
async def indicate_subscribers(self, attribute, value=None, force=False):
|
||||
async def indicate_subscribers(
|
||||
self,
|
||||
attribute: Attribute,
|
||||
value: Optional[bytes] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
return await self.notify_or_indicate_subscribers(True, attribute, value, force)
|
||||
|
||||
def on_disconnection(self, connection):
|
||||
def on_disconnection(self, connection: Connection) -> None:
|
||||
if connection.handle in self.subscribers:
|
||||
del self.subscribers[connection.handle]
|
||||
if connection.handle in self.indication_semaphores:
|
||||
@@ -464,7 +509,7 @@ class Server(EventEmitter):
|
||||
if connection.handle in self.pending_confirmations:
|
||||
del self.pending_confirmations[connection.handle]
|
||||
|
||||
def on_gatt_pdu(self, connection, att_pdu):
|
||||
def on_gatt_pdu(self, connection: Connection, att_pdu: ATT_PDU) -> None:
|
||||
logger.debug(f'GATT Request to server: [0x{connection.handle:04X}] {att_pdu}')
|
||||
handler_name = f'on_{att_pdu.name.lower()}'
|
||||
handler = getattr(self, handler_name, None)
|
||||
@@ -506,7 +551,7 @@ class Server(EventEmitter):
|
||||
#######################################################
|
||||
# ATT handlers
|
||||
#######################################################
|
||||
def on_att_request(self, connection, pdu):
|
||||
def on_att_request(self, connection: Connection, pdu: ATT_PDU) -> None:
|
||||
'''
|
||||
Handler for requests without a more specific handler
|
||||
'''
|
||||
@@ -679,7 +724,6 @@ class Server(EventEmitter):
|
||||
and attribute.handle <= request.ending_handle
|
||||
and pdu_space_available
|
||||
):
|
||||
|
||||
try:
|
||||
attribute_value = attribute.read_value(connection)
|
||||
except ATT_Error as error:
|
||||
|
||||
203
bumble/hci.py
203
bumble/hci.py
@@ -16,11 +16,11 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import struct
|
||||
import collections
|
||||
import logging
|
||||
import functools
|
||||
from typing import Dict, Type, Union, Callable, Any, Optional
|
||||
import logging
|
||||
import struct
|
||||
from typing import Any, Dict, Callable, Optional, Type, Union
|
||||
|
||||
from .colors import color
|
||||
from .core import (
|
||||
@@ -47,6 +47,10 @@ def hci_command_op_code(ogf, ocf):
|
||||
return ogf << 10 | ocf
|
||||
|
||||
|
||||
def hci_vendor_command_op_code(ocf):
|
||||
return hci_command_op_code(HCI_VENDOR_OGF, ocf)
|
||||
|
||||
|
||||
def key_with_value(dictionary, target_value):
|
||||
for key, value in dictionary.items():
|
||||
if value == target_value:
|
||||
@@ -101,6 +105,8 @@ def phy_list_to_bits(phys):
|
||||
# fmt: off
|
||||
# pylint: disable=line-too-long
|
||||
|
||||
HCI_VENDOR_OGF = 0x3F
|
||||
|
||||
# HCI Version
|
||||
HCI_VERSION_BLUETOOTH_CORE_1_0B = 0
|
||||
HCI_VERSION_BLUETOOTH_CORE_1_1 = 1
|
||||
@@ -206,10 +212,8 @@ HCI_INQUIRY_RESPONSE_NOTIFICATION_EVENT = 0X56
|
||||
HCI_AUTHENTICATED_PAYLOAD_TIMEOUT_EXPIRED_EVENT = 0X57
|
||||
HCI_SAM_STATUS_CHANGE_EVENT = 0X58
|
||||
|
||||
HCI_EVENT_NAMES = {
|
||||
event_code: event_name for (event_name, event_code) in globals().items()
|
||||
if event_name.startswith('HCI_') and event_name.endswith('_EVENT')
|
||||
}
|
||||
HCI_VENDOR_EVENT = 0xFF
|
||||
|
||||
|
||||
# HCI Subevent Codes
|
||||
HCI_LE_CONNECTION_COMPLETE_EVENT = 0x01
|
||||
@@ -248,10 +252,6 @@ HCI_LE_TRANSMIT_POWER_REPORTING_EVENT = 0X21
|
||||
HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT = 0X22
|
||||
HCI_LE_SUBRATE_CHANGE_EVENT = 0X23
|
||||
|
||||
HCI_SUBEVENT_NAMES = {
|
||||
event_code: event_name for (event_name, event_code) in globals().items()
|
||||
if event_name.startswith('HCI_LE_') and event_name.endswith('_EVENT') and event_code != HCI_LE_META_EVENT
|
||||
}
|
||||
|
||||
# HCI Command
|
||||
HCI_INQUIRY_COMMAND = hci_command_op_code(0x01, 0x0001)
|
||||
@@ -557,10 +557,6 @@ HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND = hci_c
|
||||
HCI_LE_SET_DEFAULT_SUBRATE_COMMAND = hci_command_op_code(0x08, 0x007D)
|
||||
HCI_LE_SUBRATE_REQUEST_COMMAND = hci_command_op_code(0x08, 0x007E)
|
||||
|
||||
HCI_COMMAND_NAMES = {
|
||||
command_code: command_name for (command_name, command_code) in globals().items()
|
||||
if command_name.startswith('HCI_') and command_name.endswith('_COMMAND')
|
||||
}
|
||||
|
||||
# HCI Error Codes
|
||||
# See Bluetooth spec Vol 2, Part D - 1.3 LIST OF ERROR CODES
|
||||
@@ -1960,6 +1956,7 @@ class HCI_Command(HCI_Packet):
|
||||
'''
|
||||
|
||||
hci_packet_type = HCI_COMMAND_PACKET
|
||||
command_names: Dict[int, str] = {}
|
||||
command_classes: Dict[int, Type[HCI_Command]] = {}
|
||||
|
||||
@staticmethod
|
||||
@@ -1970,9 +1967,9 @@ class HCI_Command(HCI_Packet):
|
||||
|
||||
def inner(cls):
|
||||
cls.name = cls.__name__.upper()
|
||||
cls.op_code = key_with_value(HCI_COMMAND_NAMES, cls.name)
|
||||
cls.op_code = key_with_value(cls.command_names, cls.name)
|
||||
if cls.op_code is None:
|
||||
raise KeyError(f'command {cls.name} not found in HCI_COMMAND_NAMES')
|
||||
raise KeyError(f'command {cls.name} not found in command_names')
|
||||
cls.fields = fields
|
||||
cls.return_parameters_fields = return_parameters_fields
|
||||
|
||||
@@ -1991,6 +1988,18 @@ class HCI_Command(HCI_Packet):
|
||||
|
||||
return inner
|
||||
|
||||
@staticmethod
|
||||
def command_map(symbols: Dict[str, Any]) -> Dict[int, str]:
|
||||
return {
|
||||
command_code: command_name
|
||||
for (command_name, command_code) in symbols.items()
|
||||
if command_name.startswith('HCI_') and command_name.endswith('_COMMAND')
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def register_commands(cls, symbols: Dict[str, Any]) -> None:
|
||||
cls.command_names.update(cls.command_map(symbols))
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(packet: bytes) -> HCI_Command:
|
||||
op_code, length = struct.unpack_from('<HB', packet, 1)
|
||||
@@ -2015,7 +2024,7 @@ class HCI_Command(HCI_Packet):
|
||||
|
||||
@staticmethod
|
||||
def command_name(op_code):
|
||||
name = HCI_COMMAND_NAMES.get(op_code)
|
||||
name = HCI_Command.command_names.get(op_code)
|
||||
if name is not None:
|
||||
return name
|
||||
return f'[OGF=0x{op_code >> 10:02x}, OCF=0x{op_code & 0x3FF:04x}]'
|
||||
@@ -2024,6 +2033,16 @@ class HCI_Command(HCI_Packet):
|
||||
def create_return_parameters(cls, **kwargs):
|
||||
return HCI_Object(cls.return_parameters_fields, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def parse_return_parameters(cls, parameters):
|
||||
if not cls.return_parameters_fields:
|
||||
return None
|
||||
return_parameters = HCI_Object.from_bytes(
|
||||
parameters, 0, cls.return_parameters_fields
|
||||
)
|
||||
return_parameters.fields = cls.return_parameters_fields
|
||||
return return_parameters
|
||||
|
||||
def __init__(self, op_code, parameters=None, **kwargs):
|
||||
super().__init__(HCI_Command.command_name(op_code))
|
||||
if (fields := getattr(self, 'fields', None)) and kwargs:
|
||||
@@ -2053,6 +2072,9 @@ class HCI_Command(HCI_Packet):
|
||||
return result
|
||||
|
||||
|
||||
HCI_Command.register_commands(globals())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
[
|
||||
@@ -4308,8 +4330,8 @@ class HCI_Event(HCI_Packet):
|
||||
'''
|
||||
|
||||
hci_packet_type = HCI_EVENT_PACKET
|
||||
event_names: Dict[int, str] = {}
|
||||
event_classes: Dict[int, Type[HCI_Event]] = {}
|
||||
meta_event_classes: Dict[int, Type[HCI_LE_Meta_Event]] = {}
|
||||
|
||||
@staticmethod
|
||||
def event(fields=()):
|
||||
@@ -4319,9 +4341,9 @@ class HCI_Event(HCI_Packet):
|
||||
|
||||
def inner(cls):
|
||||
cls.name = cls.__name__.upper()
|
||||
cls.event_code = key_with_value(HCI_EVENT_NAMES, cls.name)
|
||||
cls.event_code = key_with_value(cls.event_names, cls.name)
|
||||
if cls.event_code is None:
|
||||
raise KeyError('event not found in HCI_EVENT_NAMES')
|
||||
raise KeyError(f'event {cls.name} not found in event_names')
|
||||
cls.fields = fields
|
||||
|
||||
# Patch the __init__ method to fix the event_code
|
||||
@@ -4337,12 +4359,30 @@ class HCI_Event(HCI_Packet):
|
||||
|
||||
return inner
|
||||
|
||||
@staticmethod
|
||||
def event_map(symbols: Dict[str, Any]) -> Dict[int, str]:
|
||||
return {
|
||||
event_code: event_name
|
||||
for (event_name, event_code) in symbols.items()
|
||||
if event_name.startswith('HCI_')
|
||||
and not event_name.startswith('HCI_LE_')
|
||||
and event_name.endswith('_EVENT')
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def event_name(event_code):
|
||||
return name_or_number(HCI_Event.event_names, event_code)
|
||||
|
||||
@staticmethod
|
||||
def register_events(symbols: Dict[str, Any]) -> None:
|
||||
HCI_Event.event_names.update(HCI_Event.event_map(symbols))
|
||||
|
||||
@staticmethod
|
||||
def registered(event_class):
|
||||
event_class.name = event_class.__name__.upper()
|
||||
event_class.event_code = key_with_value(HCI_EVENT_NAMES, event_class.name)
|
||||
event_class.event_code = key_with_value(HCI_Event.event_names, event_class.name)
|
||||
if event_class.event_code is None:
|
||||
raise KeyError('event not found in HCI_EVENT_NAMES')
|
||||
raise KeyError(f'event {event_class.name} not found in event_names')
|
||||
|
||||
# Register a factory for this class
|
||||
HCI_Event.event_classes[event_class.event_code] = event_class
|
||||
@@ -4357,16 +4397,21 @@ class HCI_Event(HCI_Packet):
|
||||
if len(parameters) != length:
|
||||
raise ValueError('invalid packet length')
|
||||
|
||||
cls: Type[HCI_Event | HCI_LE_Meta_Event] | None
|
||||
cls: Any
|
||||
if event_code == HCI_LE_META_EVENT:
|
||||
# We do this dispatch here and not in the subclass in order to avoid call
|
||||
# loops
|
||||
subevent_code = parameters[0]
|
||||
cls = HCI_Event.meta_event_classes.get(subevent_code)
|
||||
cls = HCI_LE_Meta_Event.subevent_classes.get(subevent_code)
|
||||
if cls is None:
|
||||
# No class registered, just use a generic class instance
|
||||
return HCI_LE_Meta_Event(subevent_code, parameters)
|
||||
|
||||
elif event_code == HCI_VENDOR_EVENT:
|
||||
subevent_code = parameters[0]
|
||||
cls = HCI_Vendor_Event.subevent_classes.get(subevent_code)
|
||||
if cls is None:
|
||||
# No class registered, just use a generic class instance
|
||||
return HCI_Vendor_Event(subevent_code, parameters)
|
||||
else:
|
||||
cls = HCI_Event.event_classes.get(event_code)
|
||||
if cls is None:
|
||||
@@ -4384,10 +4429,6 @@ class HCI_Event(HCI_Packet):
|
||||
HCI_Object.init_from_bytes(self, parameters, 0, fields)
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def event_name(event_code):
|
||||
return name_or_number(HCI_EVENT_NAMES, event_code)
|
||||
|
||||
def __init__(self, event_code, parameters=None, **kwargs):
|
||||
super().__init__(HCI_Event.event_name(event_code))
|
||||
if (fields := getattr(self, 'fields', None)) and kwargs:
|
||||
@@ -4414,71 +4455,111 @@ class HCI_Event(HCI_Packet):
|
||||
return result
|
||||
|
||||
|
||||
HCI_Event.register_events(globals())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HCI_LE_Meta_Event(HCI_Event):
|
||||
class HCI_Extended_Event(HCI_Event):
|
||||
'''
|
||||
See Bluetooth spec @ 7.7.65 LE Meta Event
|
||||
HCI_Event subclass for events that has a subevent code.
|
||||
'''
|
||||
|
||||
@staticmethod
|
||||
def event(fields=()):
|
||||
subevent_names: Dict[int, str] = {}
|
||||
subevent_classes: Dict[int, Type[HCI_Extended_Event]]
|
||||
|
||||
@classmethod
|
||||
def event(cls, fields=()):
|
||||
'''
|
||||
Decorator used to declare and register subclasses
|
||||
'''
|
||||
|
||||
def inner(cls):
|
||||
cls.name = cls.__name__.upper()
|
||||
cls.subevent_code = key_with_value(HCI_SUBEVENT_NAMES, cls.name)
|
||||
cls.subevent_code = key_with_value(cls.subevent_names, cls.name)
|
||||
if cls.subevent_code is None:
|
||||
raise KeyError('subevent not found in HCI_SUBEVENT_NAMES')
|
||||
raise KeyError(f'subevent {cls.name} not found in subevent_names')
|
||||
cls.fields = fields
|
||||
|
||||
# Patch the __init__ method to fix the subevent_code
|
||||
original_init = cls.__init__
|
||||
|
||||
def init(self, parameters=None, **kwargs):
|
||||
return HCI_LE_Meta_Event.__init__(
|
||||
self, cls.subevent_code, parameters, **kwargs
|
||||
)
|
||||
return original_init(self, cls.subevent_code, parameters, **kwargs)
|
||||
|
||||
cls.__init__ = init
|
||||
|
||||
# Register a factory for this class
|
||||
HCI_Event.meta_event_classes[cls.subevent_code] = cls
|
||||
cls.subevent_classes[cls.subevent_code] = cls
|
||||
|
||||
return cls
|
||||
|
||||
return inner
|
||||
|
||||
@classmethod
|
||||
def subevent_name(cls, subevent_code):
|
||||
subevent_name = cls.subevent_names.get(subevent_code)
|
||||
if subevent_name is not None:
|
||||
return subevent_name
|
||||
|
||||
return f'{cls.__name__.upper()}[0x{subevent_code:02X}]'
|
||||
|
||||
@staticmethod
|
||||
def subevent_map(symbols: Dict[str, Any]) -> Dict[int, str]:
|
||||
return {
|
||||
subevent_code: subevent_name
|
||||
for (subevent_name, subevent_code) in symbols.items()
|
||||
if subevent_name.startswith('HCI_') and subevent_name.endswith('_EVENT')
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def register_subevents(cls, symbols: Dict[str, Any]) -> None:
|
||||
cls.subevent_names.update(cls.subevent_map(symbols))
|
||||
|
||||
@classmethod
|
||||
def from_parameters(cls, parameters):
|
||||
self = cls.__new__(cls)
|
||||
HCI_LE_Meta_Event.__init__(self, self.subevent_code, parameters)
|
||||
HCI_Extended_Event.__init__(self, self.subevent_code, parameters)
|
||||
if fields := getattr(self, 'fields', None):
|
||||
HCI_Object.init_from_bytes(self, parameters, 1, fields)
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def subevent_name(subevent_code):
|
||||
return name_or_number(HCI_SUBEVENT_NAMES, subevent_code)
|
||||
|
||||
def __init__(self, subevent_code, parameters, **kwargs):
|
||||
self.subevent_code = subevent_code
|
||||
if parameters is None and (fields := getattr(self, 'fields', None)) and kwargs:
|
||||
parameters = bytes([subevent_code]) + HCI_Object.dict_to_bytes(
|
||||
kwargs, fields
|
||||
)
|
||||
super().__init__(HCI_LE_META_EVENT, parameters, **kwargs)
|
||||
super().__init__(self.event_code, parameters, **kwargs)
|
||||
|
||||
# Override the name in order to adopt the subevent name instead
|
||||
self.name = self.subevent_name(subevent_code)
|
||||
|
||||
def __str__(self):
|
||||
result = color(self.subevent_name(self.subevent_code), 'magenta')
|
||||
if fields := getattr(self, 'fields', None):
|
||||
result += ':\n' + HCI_Object.format_fields(self.__dict__, fields, ' ')
|
||||
else:
|
||||
if self.parameters:
|
||||
result += f': {self.parameters.hex()}'
|
||||
return result
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HCI_LE_Meta_Event(HCI_Extended_Event):
|
||||
'''
|
||||
See Bluetooth spec @ 7.7.65 LE Meta Event
|
||||
'''
|
||||
|
||||
event_code: int = HCI_LE_META_EVENT
|
||||
subevent_classes = {}
|
||||
|
||||
@staticmethod
|
||||
def subevent_map(symbols: Dict[str, Any]) -> Dict[int, str]:
|
||||
return {
|
||||
subevent_code: subevent_name
|
||||
for (subevent_name, subevent_code) in symbols.items()
|
||||
if subevent_name.startswith('HCI_LE_') and subevent_name.endswith('_EVENT')
|
||||
}
|
||||
|
||||
|
||||
HCI_LE_Meta_Event.register_subevents(globals())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HCI_Vendor_Event(HCI_Extended_Event):
|
||||
event_code: int = HCI_VENDOR_EVENT
|
||||
subevent_classes = {}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -4592,7 +4673,7 @@ class HCI_LE_Advertising_Report_Event(HCI_LE_Meta_Event):
|
||||
return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
|
||||
|
||||
|
||||
HCI_Event.meta_event_classes[
|
||||
HCI_LE_Meta_Event.subevent_classes[
|
||||
HCI_LE_ADVERTISING_REPORT_EVENT
|
||||
] = HCI_LE_Advertising_Report_Event
|
||||
|
||||
@@ -4846,7 +4927,7 @@ class HCI_LE_Extended_Advertising_Report_Event(HCI_LE_Meta_Event):
|
||||
return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
|
||||
|
||||
|
||||
HCI_Event.meta_event_classes[
|
||||
HCI_LE_Meta_Event.subevent_classes[
|
||||
HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT
|
||||
] = HCI_LE_Extended_Advertising_Report_Event
|
||||
|
||||
@@ -5120,11 +5201,11 @@ class HCI_Command_Complete_Event(HCI_Event):
|
||||
self.return_parameters = self.return_parameters[0]
|
||||
else:
|
||||
cls = HCI_Command.command_classes.get(self.command_opcode)
|
||||
if cls and cls.return_parameters_fields:
|
||||
self.return_parameters = HCI_Object.from_bytes(
|
||||
self.return_parameters, 0, cls.return_parameters_fields
|
||||
)
|
||||
self.return_parameters.fields = cls.return_parameters_fields
|
||||
if cls:
|
||||
# Try to parse the return parameters bytes into an object.
|
||||
return_parameters = cls.parse_return_parameters(self.return_parameters)
|
||||
if return_parameters is not None:
|
||||
self.return_parameters = return_parameters
|
||||
|
||||
return self
|
||||
|
||||
|
||||
@@ -15,16 +15,19 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import collections.abc
|
||||
import logging
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import enum
|
||||
import traceback
|
||||
from typing import Dict, List, Union, Set
|
||||
import warnings
|
||||
from typing import Dict, List, Union, Set, TYPE_CHECKING
|
||||
|
||||
from . import at
|
||||
from . import rfcomm
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.core import (
|
||||
ProtocolError,
|
||||
BT_GENERIC_AUDIO_SERVICE,
|
||||
@@ -48,6 +51,71 @@ from bumble.sdp import (
|
||||
# -----------------------------------------------------------------------------
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Error
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HfpProtocolError(ProtocolError):
|
||||
def __init__(self, error_name: str = '', details: str = ''):
|
||||
super().__init__(None, 'hfp', error_name, details)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Protocol Support
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HfpProtocol:
|
||||
dlc: rfcomm.DLC
|
||||
buffer: str
|
||||
lines: collections.deque
|
||||
lines_available: asyncio.Event
|
||||
|
||||
def __init__(self, dlc: rfcomm.DLC) -> None:
|
||||
warnings.warn("See HfProtocol", DeprecationWarning)
|
||||
self.dlc = dlc
|
||||
self.buffer = ''
|
||||
self.lines = collections.deque()
|
||||
self.lines_available = asyncio.Event()
|
||||
|
||||
dlc.sink = self.feed
|
||||
|
||||
def feed(self, data: Union[bytes, str]) -> None:
|
||||
# Convert the data to a string if needed
|
||||
if isinstance(data, bytes):
|
||||
data = data.decode('utf-8')
|
||||
|
||||
logger.debug(f'<<< Data received: {data}')
|
||||
|
||||
# Add to the buffer and look for lines
|
||||
self.buffer += data
|
||||
while (separator := self.buffer.find('\r')) >= 0:
|
||||
line = self.buffer[:separator].strip()
|
||||
self.buffer = self.buffer[separator + 1 :]
|
||||
if len(line) > 0:
|
||||
self.on_line(line)
|
||||
|
||||
def on_line(self, line: str) -> None:
|
||||
self.lines.append(line)
|
||||
self.lines_available.set()
|
||||
|
||||
def send_command_line(self, line: str) -> None:
|
||||
logger.debug(color(f'>>> {line}', 'yellow'))
|
||||
self.dlc.write(line + '\r')
|
||||
|
||||
def send_response_line(self, line: str) -> None:
|
||||
logger.debug(color(f'>>> {line}', 'yellow'))
|
||||
self.dlc.write('\r\n' + line + '\r\n')
|
||||
|
||||
async def next_line(self) -> str:
|
||||
await self.lines_available.wait()
|
||||
line = self.lines.popleft()
|
||||
if not self.lines:
|
||||
self.lines_available.clear()
|
||||
logger.debug(color(f'<<< {line}', 'green'))
|
||||
return line
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Normative protocol definitions
|
||||
@@ -302,8 +370,12 @@ class HfProtocol:
|
||||
|
||||
dlc: rfcomm.DLC
|
||||
command_lock: asyncio.Lock
|
||||
response_queue: asyncio.Queue
|
||||
unsolicited_queue: asyncio.Queue
|
||||
if TYPE_CHECKING:
|
||||
response_queue: asyncio.Queue[AtResponse]
|
||||
unsolicited_queue: asyncio.Queue[AtResponse]
|
||||
else:
|
||||
response_queue: asyncio.Queue
|
||||
unsolicited_queue: asyncio.Queue
|
||||
read_buffer: bytearray
|
||||
|
||||
def __init__(self, dlc: rfcomm.DLC, configuration: Configuration):
|
||||
@@ -368,7 +440,7 @@ class HfProtocol:
|
||||
else:
|
||||
logger.warning(f"dropping unexpected response with code '{response.code}'")
|
||||
|
||||
# Send an AT command and wait for the peer resposne.
|
||||
# Send an AT command and wait for the peer response.
|
||||
# Wait for the AT responses sent by the peer, to the status code.
|
||||
# Raises asyncio.TimeoutError if the status is not received
|
||||
# after a timeout (default 1 second).
|
||||
@@ -390,7 +462,7 @@ class HfProtocol:
|
||||
)
|
||||
if result.code == 'OK':
|
||||
if response_type == AtResponseType.SINGLE and len(responses) != 1:
|
||||
raise ProtocolError("NO ANSWER")
|
||||
raise HfpProtocolError("NO ANSWER")
|
||||
|
||||
if response_type == AtResponseType.MULTIPLE:
|
||||
return responses
|
||||
@@ -398,7 +470,7 @@ class HfProtocol:
|
||||
return responses[0]
|
||||
return None
|
||||
if result.code in STATUS_CODES:
|
||||
raise ProtocolError(result.code)
|
||||
raise HfpProtocolError(result.code)
|
||||
responses.append(result)
|
||||
|
||||
# 4.2.1 Service Level Connection Initialization.
|
||||
|
||||
@@ -21,7 +21,7 @@ import collections
|
||||
import logging
|
||||
import struct
|
||||
|
||||
from typing import Optional
|
||||
from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.l2cap import L2CAP_PDU
|
||||
@@ -73,10 +73,14 @@ from .core import (
|
||||
BT_LE_TRANSPORT,
|
||||
ConnectionPHY,
|
||||
ConnectionParameters,
|
||||
InvalidStateError,
|
||||
)
|
||||
from .utils import AbortableEventEmitter
|
||||
from .transport.common import TransportLostError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .transport.common import TransportSink, TransportSource
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -116,10 +120,21 @@ class Connection:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Host(AbortableEventEmitter):
|
||||
def __init__(self, controller_source=None, controller_sink=None):
|
||||
connections: Dict[int, Connection]
|
||||
acl_packet_queue: collections.deque[HCI_AclDataPacket]
|
||||
hci_sink: TransportSink
|
||||
long_term_key_provider: Optional[
|
||||
Callable[[int, bytes, int], Awaitable[Optional[bytes]]]
|
||||
]
|
||||
link_key_provider: Optional[Callable[[Address], Awaitable[Optional[bytes]]]]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller_source: Optional[TransportSource] = None,
|
||||
controller_sink: Optional[TransportSink] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.hci_sink = None
|
||||
self.hci_metadata = None
|
||||
self.ready = False # True when we can accept incoming packets
|
||||
self.reset_done = False
|
||||
@@ -299,7 +314,7 @@ class Host(AbortableEventEmitter):
|
||||
self.reset_done = True
|
||||
|
||||
@property
|
||||
def controller(self):
|
||||
def controller(self) -> TransportSink:
|
||||
return self.hci_sink
|
||||
|
||||
@controller.setter
|
||||
@@ -308,13 +323,12 @@ class Host(AbortableEventEmitter):
|
||||
if controller:
|
||||
controller.set_packet_sink(self)
|
||||
|
||||
def set_packet_sink(self, sink):
|
||||
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||
self.hci_sink = sink
|
||||
|
||||
def send_hci_packet(self, packet: HCI_Packet) -> None:
|
||||
if self.snooper:
|
||||
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
|
||||
|
||||
self.hci_sink.on_packet(bytes(packet))
|
||||
|
||||
async def send_command(self, command, check_result=False):
|
||||
|
||||
264
bumble/l2cap.py
264
bumble/l2cap.py
@@ -17,6 +17,7 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import enum
|
||||
import logging
|
||||
import struct
|
||||
|
||||
@@ -676,56 +677,35 @@ class L2CAP_LE_Flow_Control_Credit(L2CAP_Control_Frame):
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Channel(EventEmitter):
|
||||
# States
|
||||
CLOSED = 0x00
|
||||
WAIT_CONNECT = 0x01
|
||||
WAIT_CONNECT_RSP = 0x02
|
||||
OPEN = 0x03
|
||||
WAIT_DISCONNECT = 0x04
|
||||
WAIT_CREATE = 0x05
|
||||
WAIT_CREATE_RSP = 0x06
|
||||
WAIT_MOVE = 0x07
|
||||
WAIT_MOVE_RSP = 0x08
|
||||
WAIT_MOVE_CONFIRM = 0x09
|
||||
WAIT_CONFIRM_RSP = 0x0A
|
||||
class State(enum.IntEnum):
|
||||
# States
|
||||
CLOSED = 0x00
|
||||
WAIT_CONNECT = 0x01
|
||||
WAIT_CONNECT_RSP = 0x02
|
||||
OPEN = 0x03
|
||||
WAIT_DISCONNECT = 0x04
|
||||
WAIT_CREATE = 0x05
|
||||
WAIT_CREATE_RSP = 0x06
|
||||
WAIT_MOVE = 0x07
|
||||
WAIT_MOVE_RSP = 0x08
|
||||
WAIT_MOVE_CONFIRM = 0x09
|
||||
WAIT_CONFIRM_RSP = 0x0A
|
||||
|
||||
# CONFIG substates
|
||||
WAIT_CONFIG = 0x10
|
||||
WAIT_SEND_CONFIG = 0x11
|
||||
WAIT_CONFIG_REQ_RSP = 0x12
|
||||
WAIT_CONFIG_RSP = 0x13
|
||||
WAIT_CONFIG_REQ = 0x14
|
||||
WAIT_IND_FINAL_RSP = 0x15
|
||||
WAIT_FINAL_RSP = 0x16
|
||||
WAIT_CONTROL_IND = 0x17
|
||||
|
||||
STATE_NAMES = {
|
||||
CLOSED: 'CLOSED',
|
||||
WAIT_CONNECT: 'WAIT_CONNECT',
|
||||
WAIT_CONNECT_RSP: 'WAIT_CONNECT_RSP',
|
||||
OPEN: 'OPEN',
|
||||
WAIT_DISCONNECT: 'WAIT_DISCONNECT',
|
||||
WAIT_CREATE: 'WAIT_CREATE',
|
||||
WAIT_CREATE_RSP: 'WAIT_CREATE_RSP',
|
||||
WAIT_MOVE: 'WAIT_MOVE',
|
||||
WAIT_MOVE_RSP: 'WAIT_MOVE_RSP',
|
||||
WAIT_MOVE_CONFIRM: 'WAIT_MOVE_CONFIRM',
|
||||
WAIT_CONFIRM_RSP: 'WAIT_CONFIRM_RSP',
|
||||
WAIT_CONFIG: 'WAIT_CONFIG',
|
||||
WAIT_SEND_CONFIG: 'WAIT_SEND_CONFIG',
|
||||
WAIT_CONFIG_REQ_RSP: 'WAIT_CONFIG_REQ_RSP',
|
||||
WAIT_CONFIG_RSP: 'WAIT_CONFIG_RSP',
|
||||
WAIT_CONFIG_REQ: 'WAIT_CONFIG_REQ',
|
||||
WAIT_IND_FINAL_RSP: 'WAIT_IND_FINAL_RSP',
|
||||
WAIT_FINAL_RSP: 'WAIT_FINAL_RSP',
|
||||
WAIT_CONTROL_IND: 'WAIT_CONTROL_IND',
|
||||
}
|
||||
# CONFIG substates
|
||||
WAIT_CONFIG = 0x10
|
||||
WAIT_SEND_CONFIG = 0x11
|
||||
WAIT_CONFIG_REQ_RSP = 0x12
|
||||
WAIT_CONFIG_RSP = 0x13
|
||||
WAIT_CONFIG_REQ = 0x14
|
||||
WAIT_IND_FINAL_RSP = 0x15
|
||||
WAIT_FINAL_RSP = 0x16
|
||||
WAIT_CONTROL_IND = 0x17
|
||||
|
||||
connection_result: Optional[asyncio.Future[None]]
|
||||
disconnection_result: Optional[asyncio.Future[None]]
|
||||
response: Optional[asyncio.Future[bytes]]
|
||||
sink: Optional[Callable[[bytes], Any]]
|
||||
state: int
|
||||
state: State
|
||||
connection: Connection
|
||||
|
||||
def __init__(
|
||||
@@ -741,7 +721,7 @@ class Channel(EventEmitter):
|
||||
self.manager = manager
|
||||
self.connection = connection
|
||||
self.signaling_cid = signaling_cid
|
||||
self.state = Channel.CLOSED
|
||||
self.state = self.State.CLOSED
|
||||
self.mtu = mtu
|
||||
self.psm = psm
|
||||
self.source_cid = source_cid
|
||||
@@ -751,13 +731,11 @@ class Channel(EventEmitter):
|
||||
self.disconnection_result = None
|
||||
self.sink = None
|
||||
|
||||
def change_state(self, new_state: int) -> None:
|
||||
logger.debug(
|
||||
f'{self} state change -> {color(Channel.STATE_NAMES[new_state], "cyan")}'
|
||||
)
|
||||
def _change_state(self, new_state: State) -> None:
|
||||
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
|
||||
self.state = new_state
|
||||
|
||||
def send_pdu(self, pdu: SupportsBytes | bytes) -> None:
|
||||
def send_pdu(self, pdu: Union[SupportsBytes, bytes]) -> None:
|
||||
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
|
||||
|
||||
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
|
||||
@@ -767,7 +745,7 @@ class Channel(EventEmitter):
|
||||
# Check that there isn't already a request pending
|
||||
if self.response:
|
||||
raise InvalidStateError('request already pending')
|
||||
if self.state != Channel.OPEN:
|
||||
if self.state != self.State.OPEN:
|
||||
raise InvalidStateError('channel not open')
|
||||
|
||||
self.response = asyncio.get_running_loop().create_future()
|
||||
@@ -787,14 +765,14 @@ class Channel(EventEmitter):
|
||||
)
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self.state != Channel.CLOSED:
|
||||
if self.state != self.State.CLOSED:
|
||||
raise InvalidStateError('invalid state')
|
||||
|
||||
# Check that we can start a new connection
|
||||
if self.connection_result:
|
||||
raise RuntimeError('connection already pending')
|
||||
|
||||
self.change_state(Channel.WAIT_CONNECT_RSP)
|
||||
self._change_state(self.State.WAIT_CONNECT_RSP)
|
||||
self.send_control_frame(
|
||||
L2CAP_Connection_Request(
|
||||
identifier=self.manager.next_identifier(self.connection),
|
||||
@@ -814,10 +792,10 @@ class Channel(EventEmitter):
|
||||
self.connection_result = None
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self.state != Channel.OPEN:
|
||||
if self.state != self.State.OPEN:
|
||||
raise InvalidStateError('invalid state')
|
||||
|
||||
self.change_state(Channel.WAIT_DISCONNECT)
|
||||
self._change_state(self.State.WAIT_DISCONNECT)
|
||||
self.send_control_frame(
|
||||
L2CAP_Disconnection_Request(
|
||||
identifier=self.manager.next_identifier(self.connection),
|
||||
@@ -832,8 +810,8 @@ class Channel(EventEmitter):
|
||||
return await self.disconnection_result
|
||||
|
||||
def abort(self) -> None:
|
||||
if self.state == self.OPEN:
|
||||
self.change_state(self.CLOSED)
|
||||
if self.state == self.State.OPEN:
|
||||
self._change_state(self.State.CLOSED)
|
||||
self.emit('close')
|
||||
|
||||
def send_configure_request(self) -> None:
|
||||
@@ -856,7 +834,7 @@ class Channel(EventEmitter):
|
||||
|
||||
def on_connection_request(self, request) -> None:
|
||||
self.destination_cid = request.source_cid
|
||||
self.change_state(Channel.WAIT_CONNECT)
|
||||
self._change_state(self.State.WAIT_CONNECT)
|
||||
self.send_control_frame(
|
||||
L2CAP_Connection_Response(
|
||||
identifier=request.identifier,
|
||||
@@ -866,24 +844,24 @@ class Channel(EventEmitter):
|
||||
status=0x0000,
|
||||
)
|
||||
)
|
||||
self.change_state(Channel.WAIT_CONFIG)
|
||||
self._change_state(self.State.WAIT_CONFIG)
|
||||
self.send_configure_request()
|
||||
self.change_state(Channel.WAIT_CONFIG_REQ_RSP)
|
||||
self._change_state(self.State.WAIT_CONFIG_REQ_RSP)
|
||||
|
||||
def on_connection_response(self, response):
|
||||
if self.state != Channel.WAIT_CONNECT_RSP:
|
||||
if self.state != self.State.WAIT_CONNECT_RSP:
|
||||
logger.warning(color('invalid state', 'red'))
|
||||
return
|
||||
|
||||
if response.result == L2CAP_Connection_Response.CONNECTION_SUCCESSFUL:
|
||||
self.destination_cid = response.destination_cid
|
||||
self.change_state(Channel.WAIT_CONFIG)
|
||||
self._change_state(self.State.WAIT_CONFIG)
|
||||
self.send_configure_request()
|
||||
self.change_state(Channel.WAIT_CONFIG_REQ_RSP)
|
||||
self._change_state(self.State.WAIT_CONFIG_REQ_RSP)
|
||||
elif response.result == L2CAP_Connection_Response.CONNECTION_PENDING:
|
||||
pass
|
||||
else:
|
||||
self.change_state(Channel.CLOSED)
|
||||
self._change_state(self.State.CLOSED)
|
||||
self.connection_result.set_exception(
|
||||
ProtocolError(
|
||||
response.result,
|
||||
@@ -895,9 +873,9 @@ class Channel(EventEmitter):
|
||||
|
||||
def on_configure_request(self, request) -> None:
|
||||
if self.state not in (
|
||||
Channel.WAIT_CONFIG,
|
||||
Channel.WAIT_CONFIG_REQ,
|
||||
Channel.WAIT_CONFIG_REQ_RSP,
|
||||
self.State.WAIT_CONFIG,
|
||||
self.State.WAIT_CONFIG_REQ,
|
||||
self.State.WAIT_CONFIG_REQ_RSP,
|
||||
):
|
||||
logger.warning(color('invalid state', 'red'))
|
||||
return
|
||||
@@ -918,25 +896,28 @@ class Channel(EventEmitter):
|
||||
options=request.options, # TODO: don't accept everything blindly
|
||||
)
|
||||
)
|
||||
if self.state == Channel.WAIT_CONFIG:
|
||||
self.change_state(Channel.WAIT_SEND_CONFIG)
|
||||
if self.state == self.State.WAIT_CONFIG:
|
||||
self._change_state(self.State.WAIT_SEND_CONFIG)
|
||||
self.send_configure_request()
|
||||
self.change_state(Channel.WAIT_CONFIG_RSP)
|
||||
elif self.state == Channel.WAIT_CONFIG_REQ:
|
||||
self.change_state(Channel.OPEN)
|
||||
self._change_state(self.State.WAIT_CONFIG_RSP)
|
||||
elif self.state == self.State.WAIT_CONFIG_REQ:
|
||||
self._change_state(self.State.OPEN)
|
||||
if self.connection_result:
|
||||
self.connection_result.set_result(None)
|
||||
self.connection_result = None
|
||||
self.emit('open')
|
||||
elif self.state == Channel.WAIT_CONFIG_REQ_RSP:
|
||||
self.change_state(Channel.WAIT_CONFIG_RSP)
|
||||
elif self.state == self.State.WAIT_CONFIG_REQ_RSP:
|
||||
self._change_state(self.State.WAIT_CONFIG_RSP)
|
||||
|
||||
def on_configure_response(self, response) -> None:
|
||||
if response.result == L2CAP_Configure_Response.SUCCESS:
|
||||
if self.state == Channel.WAIT_CONFIG_REQ_RSP:
|
||||
self.change_state(Channel.WAIT_CONFIG_REQ)
|
||||
elif self.state in (Channel.WAIT_CONFIG_RSP, Channel.WAIT_CONTROL_IND):
|
||||
self.change_state(Channel.OPEN)
|
||||
if self.state == self.State.WAIT_CONFIG_REQ_RSP:
|
||||
self._change_state(self.State.WAIT_CONFIG_REQ)
|
||||
elif self.state in (
|
||||
self.State.WAIT_CONFIG_RSP,
|
||||
self.State.WAIT_CONTROL_IND,
|
||||
):
|
||||
self._change_state(self.State.OPEN)
|
||||
if self.connection_result:
|
||||
self.connection_result.set_result(None)
|
||||
self.connection_result = None
|
||||
@@ -966,7 +947,7 @@ class Channel(EventEmitter):
|
||||
# TODO: decide how to fail gracefully
|
||||
|
||||
def on_disconnection_request(self, request) -> None:
|
||||
if self.state in (Channel.OPEN, Channel.WAIT_DISCONNECT):
|
||||
if self.state in (self.State.OPEN, self.State.WAIT_DISCONNECT):
|
||||
self.send_control_frame(
|
||||
L2CAP_Disconnection_Response(
|
||||
identifier=request.identifier,
|
||||
@@ -974,14 +955,14 @@ class Channel(EventEmitter):
|
||||
source_cid=request.source_cid,
|
||||
)
|
||||
)
|
||||
self.change_state(Channel.CLOSED)
|
||||
self._change_state(self.State.CLOSED)
|
||||
self.emit('close')
|
||||
self.manager.on_channel_closed(self)
|
||||
else:
|
||||
logger.warning(color('invalid state', 'red'))
|
||||
|
||||
def on_disconnection_response(self, response) -> None:
|
||||
if self.state != Channel.WAIT_DISCONNECT:
|
||||
if self.state != self.State.WAIT_DISCONNECT:
|
||||
logger.warning(color('invalid state', 'red'))
|
||||
return
|
||||
|
||||
@@ -992,7 +973,7 @@ class Channel(EventEmitter):
|
||||
logger.warning('unexpected source or destination CID')
|
||||
return
|
||||
|
||||
self.change_state(Channel.CLOSED)
|
||||
self._change_state(self.State.CLOSED)
|
||||
if self.disconnection_result:
|
||||
self.disconnection_result.set_result(None)
|
||||
self.disconnection_result = None
|
||||
@@ -1004,7 +985,7 @@ class Channel(EventEmitter):
|
||||
f'Channel({self.source_cid}->{self.destination_cid}, '
|
||||
f'PSM={self.psm}, '
|
||||
f'MTU={self.mtu}, '
|
||||
f'state={Channel.STATE_NAMES[self.state]})'
|
||||
f'state={self.state.name})'
|
||||
)
|
||||
|
||||
|
||||
@@ -1014,33 +995,21 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
LE Credit-based Connection Oriented Channel
|
||||
"""
|
||||
|
||||
INIT = 0
|
||||
CONNECTED = 1
|
||||
CONNECTING = 2
|
||||
DISCONNECTING = 3
|
||||
DISCONNECTED = 4
|
||||
CONNECTION_ERROR = 5
|
||||
|
||||
STATE_NAMES = {
|
||||
INIT: 'INIT',
|
||||
CONNECTED: 'CONNECTED',
|
||||
CONNECTING: 'CONNECTING',
|
||||
DISCONNECTING: 'DISCONNECTING',
|
||||
DISCONNECTED: 'DISCONNECTED',
|
||||
CONNECTION_ERROR: 'CONNECTION_ERROR',
|
||||
}
|
||||
class State(enum.IntEnum):
|
||||
INIT = 0
|
||||
CONNECTED = 1
|
||||
CONNECTING = 2
|
||||
DISCONNECTING = 3
|
||||
DISCONNECTED = 4
|
||||
CONNECTION_ERROR = 5
|
||||
|
||||
out_queue: Deque[bytes]
|
||||
connection_result: Optional[asyncio.Future[LeConnectionOrientedChannel]]
|
||||
disconnection_result: Optional[asyncio.Future[None]]
|
||||
out_sdu: Optional[bytes]
|
||||
state: int
|
||||
state: State
|
||||
connection: Connection
|
||||
|
||||
@staticmethod
|
||||
def state_name(state: int) -> str:
|
||||
return name_or_number(LeConnectionOrientedChannel.STATE_NAMES, state)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manager: ChannelManager,
|
||||
@@ -1083,22 +1052,20 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
self.drained.set()
|
||||
|
||||
if connected:
|
||||
self.state = LeConnectionOrientedChannel.CONNECTED
|
||||
self.state = self.State.CONNECTED
|
||||
else:
|
||||
self.state = LeConnectionOrientedChannel.INIT
|
||||
self.state = self.State.INIT
|
||||
|
||||
def change_state(self, new_state: int) -> None:
|
||||
logger.debug(
|
||||
f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
|
||||
)
|
||||
def _change_state(self, new_state: State) -> None:
|
||||
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
|
||||
self.state = new_state
|
||||
|
||||
if new_state == self.CONNECTED:
|
||||
if new_state == self.State.CONNECTED:
|
||||
self.emit('open')
|
||||
elif new_state == self.DISCONNECTED:
|
||||
elif new_state == self.State.DISCONNECTED:
|
||||
self.emit('close')
|
||||
|
||||
def send_pdu(self, pdu: SupportsBytes | bytes) -> None:
|
||||
def send_pdu(self, pdu: Union[SupportsBytes, bytes]) -> None:
|
||||
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
|
||||
|
||||
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
|
||||
@@ -1106,7 +1073,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
|
||||
async def connect(self) -> LeConnectionOrientedChannel:
|
||||
# Check that we're in the right state
|
||||
if self.state != self.INIT:
|
||||
if self.state != self.State.INIT:
|
||||
raise InvalidStateError('not in a connectable state')
|
||||
|
||||
# Check that we can start a new connection
|
||||
@@ -1114,7 +1081,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
if identifier in self.manager.le_coc_requests:
|
||||
raise RuntimeError('too many concurrent connection requests')
|
||||
|
||||
self.change_state(self.CONNECTING)
|
||||
self._change_state(self.State.CONNECTING)
|
||||
request = L2CAP_LE_Credit_Based_Connection_Request(
|
||||
identifier=identifier,
|
||||
le_psm=self.le_psm,
|
||||
@@ -1134,10 +1101,10 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
# Check that we're connected
|
||||
if self.state != self.CONNECTED:
|
||||
if self.state != self.State.CONNECTED:
|
||||
raise InvalidStateError('not connected')
|
||||
|
||||
self.change_state(self.DISCONNECTING)
|
||||
self._change_state(self.State.DISCONNECTING)
|
||||
self.flush_output()
|
||||
self.send_control_frame(
|
||||
L2CAP_Disconnection_Request(
|
||||
@@ -1153,15 +1120,15 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
return await self.disconnection_result
|
||||
|
||||
def abort(self) -> None:
|
||||
if self.state == self.CONNECTED:
|
||||
self.change_state(self.DISCONNECTED)
|
||||
if self.state == self.State.CONNECTED:
|
||||
self._change_state(self.State.DISCONNECTED)
|
||||
|
||||
def on_pdu(self, pdu: bytes) -> None:
|
||||
if self.sink is None:
|
||||
logger.warning('received pdu without a sink')
|
||||
return
|
||||
|
||||
if self.state != self.CONNECTED:
|
||||
if self.state != self.State.CONNECTED:
|
||||
logger.warning('received PDU while not connected, dropping')
|
||||
|
||||
# Manage the peer credits
|
||||
@@ -1240,7 +1207,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
self.credits = response.initial_credits
|
||||
self.connected = True
|
||||
self.connection_result.set_result(self)
|
||||
self.change_state(self.CONNECTED)
|
||||
self._change_state(self.State.CONNECTED)
|
||||
else:
|
||||
self.connection_result.set_exception(
|
||||
ProtocolError(
|
||||
@@ -1251,7 +1218,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
),
|
||||
)
|
||||
)
|
||||
self.change_state(self.CONNECTION_ERROR)
|
||||
self._change_state(self.State.CONNECTION_ERROR)
|
||||
|
||||
# Cleanup
|
||||
self.connection_result = None
|
||||
@@ -1271,11 +1238,11 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
source_cid=request.source_cid,
|
||||
)
|
||||
)
|
||||
self.change_state(self.DISCONNECTED)
|
||||
self._change_state(self.State.DISCONNECTED)
|
||||
self.flush_output()
|
||||
|
||||
def on_disconnection_response(self, response) -> None:
|
||||
if self.state != self.DISCONNECTING:
|
||||
if self.state != self.State.DISCONNECTING:
|
||||
logger.warning(color('invalid state', 'red'))
|
||||
return
|
||||
|
||||
@@ -1286,7 +1253,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
logger.warning('unexpected source or destination CID')
|
||||
return
|
||||
|
||||
self.change_state(self.DISCONNECTED)
|
||||
self._change_state(self.State.DISCONNECTED)
|
||||
if self.disconnection_result:
|
||||
self.disconnection_result.set_result(None)
|
||||
self.disconnection_result = None
|
||||
@@ -1339,7 +1306,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
return
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
if self.state != self.CONNECTED:
|
||||
if self.state != self.State.CONNECTED:
|
||||
logger.warning('not connected, dropping data')
|
||||
return
|
||||
|
||||
@@ -1367,7 +1334,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'CoC({self.source_cid}->{self.destination_cid}, '
|
||||
f'State={self.state_name(self.state)}, '
|
||||
f'State={self.state.name}, '
|
||||
f'PSM={self.le_psm}, '
|
||||
f'MTU={self.mtu}/{self.peer_mtu}, '
|
||||
f'MPS={self.mps}/{self.peer_mps}, '
|
||||
@@ -1387,6 +1354,7 @@ class ChannelManager:
|
||||
le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request]
|
||||
fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]]
|
||||
_host: Optional[Host]
|
||||
connection_parameters_update_response: Optional[asyncio.Future[int]]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1408,6 +1376,7 @@ class ChannelManager:
|
||||
self.le_coc_requests = {} # LE CoC connection requests, by identifier
|
||||
self.extended_features = extended_features
|
||||
self.connectionless_mtu = connectionless_mtu
|
||||
self.connection_parameters_update_response = None
|
||||
|
||||
@property
|
||||
def host(self) -> Host:
|
||||
@@ -1569,7 +1538,7 @@ class ChannelManager:
|
||||
if connection_handle in self.identifiers:
|
||||
del self.identifiers[connection_handle]
|
||||
|
||||
def send_pdu(self, connection, cid: int, pdu: SupportsBytes | bytes) -> None:
|
||||
def send_pdu(self, connection, cid: int, pdu: Union[SupportsBytes, bytes]) -> None:
|
||||
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
|
||||
logger.debug(
|
||||
f'{color(">>> Sending L2CAP PDU", "blue")} '
|
||||
@@ -1865,11 +1834,45 @@ class ChannelManager:
|
||||
),
|
||||
)
|
||||
|
||||
async def update_connection_parameters(
|
||||
self,
|
||||
connection: Connection,
|
||||
interval_min: int,
|
||||
interval_max: int,
|
||||
latency: int,
|
||||
timeout: int,
|
||||
) -> int:
|
||||
# Check that there isn't already a request pending
|
||||
if self.connection_parameters_update_response:
|
||||
raise InvalidStateError('request already pending')
|
||||
self.connection_parameters_update_response = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
self.send_control_frame(
|
||||
connection,
|
||||
L2CAP_LE_SIGNALING_CID,
|
||||
L2CAP_Connection_Parameter_Update_Request(
|
||||
interval_min=interval_min,
|
||||
interval_max=interval_max,
|
||||
latency=latency,
|
||||
timeout=timeout,
|
||||
),
|
||||
)
|
||||
return await self.connection_parameters_update_response
|
||||
|
||||
def on_l2cap_connection_parameter_update_response(
|
||||
self, connection: Connection, cid: int, response
|
||||
) -> None:
|
||||
# TODO: check response
|
||||
pass
|
||||
if self.connection_parameters_update_response:
|
||||
self.connection_parameters_update_response.set_result(response.result)
|
||||
self.connection_parameters_update_response = None
|
||||
else:
|
||||
logger.warning(
|
||||
color(
|
||||
'received l2cap_connection_parameter_update_response without a pending request',
|
||||
'red',
|
||||
)
|
||||
)
|
||||
|
||||
def on_l2cap_le_credit_based_connection_request(
|
||||
self, connection: Connection, cid: int, request
|
||||
@@ -2078,7 +2081,8 @@ class ChannelManager:
|
||||
# Connect
|
||||
try:
|
||||
await channel.connect()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
del connection_channels[source_cid]
|
||||
raise e
|
||||
|
||||
return channel
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
# limitations under the License.
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import grpc
|
||||
import logging
|
||||
|
||||
@@ -27,8 +28,8 @@ from bumble.core import (
|
||||
)
|
||||
from bumble.device import Connection as BumbleConnection, Device
|
||||
from bumble.hci import HCI_Error
|
||||
from bumble.utils import EventWatcher
|
||||
from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate
|
||||
from contextlib import suppress
|
||||
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
||||
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
||||
from google.protobuf import wrappers_pb2 # pytype: disable=pyi-error
|
||||
@@ -232,7 +233,11 @@ class SecurityService(SecurityServicer):
|
||||
sc=config.pairing_sc_enable,
|
||||
mitm=config.pairing_mitm_enable,
|
||||
bonding=config.pairing_bonding_enable,
|
||||
identity_address_type=config.identity_address_type,
|
||||
identity_address_type=(
|
||||
PairingConfig.AddressType.PUBLIC
|
||||
if connection.self_address.is_public
|
||||
else config.identity_address_type
|
||||
),
|
||||
delegate=PairingDelegate(
|
||||
connection,
|
||||
self,
|
||||
@@ -294,23 +299,35 @@ class SecurityService(SecurityServicer):
|
||||
try:
|
||||
self.log.debug('Pair...')
|
||||
|
||||
if (
|
||||
connection.transport == BT_LE_TRANSPORT
|
||||
and connection.role == BT_PERIPHERAL_ROLE
|
||||
):
|
||||
wait_for_security: asyncio.Future[
|
||||
bool
|
||||
] = asyncio.get_running_loop().create_future()
|
||||
connection.on("pairing", lambda *_: wait_for_security.set_result(True)) # type: ignore
|
||||
connection.on("pairing_failure", wait_for_security.set_exception)
|
||||
security_result = asyncio.get_running_loop().create_future()
|
||||
|
||||
connection.request_pairing()
|
||||
with contextlib.closing(EventWatcher()) as watcher:
|
||||
|
||||
await wait_for_security
|
||||
else:
|
||||
await connection.pair()
|
||||
@watcher.on(connection, 'pairing')
|
||||
def on_pairing(*_: Any) -> None:
|
||||
security_result.set_result('success')
|
||||
|
||||
self.log.debug('Paired')
|
||||
@watcher.on(connection, 'pairing_failure')
|
||||
def on_pairing_failure(*_: Any) -> None:
|
||||
security_result.set_result('pairing_failure')
|
||||
|
||||
@watcher.on(connection, 'disconnection')
|
||||
def on_disconnection(*_: Any) -> None:
|
||||
security_result.set_result('connection_died')
|
||||
|
||||
if (
|
||||
connection.transport == BT_LE_TRANSPORT
|
||||
and connection.role == BT_PERIPHERAL_ROLE
|
||||
):
|
||||
connection.request_pairing()
|
||||
else:
|
||||
await connection.pair()
|
||||
|
||||
result = await security_result
|
||||
|
||||
self.log.debug(f'Pairing session complete, status={result}')
|
||||
if result != 'success':
|
||||
return SecureResponse(**{result: empty_pb2.Empty()})
|
||||
except asyncio.CancelledError:
|
||||
self.log.warning("Connection died during encryption")
|
||||
return SecureResponse(connection_died=empty_pb2.Empty())
|
||||
@@ -369,6 +386,7 @@ class SecurityService(SecurityServicer):
|
||||
str
|
||||
] = asyncio.get_running_loop().create_future()
|
||||
authenticate_task: Optional[asyncio.Future[None]] = None
|
||||
pair_task: Optional[asyncio.Future[None]] = None
|
||||
|
||||
async def authenticate() -> None:
|
||||
assert connection
|
||||
@@ -415,6 +433,10 @@ class SecurityService(SecurityServicer):
|
||||
if authenticate_task is None:
|
||||
authenticate_task = asyncio.create_task(authenticate())
|
||||
|
||||
def pair(*_: Any) -> None:
|
||||
if self.need_pairing(connection, level):
|
||||
pair_task = asyncio.create_task(connection.pair())
|
||||
|
||||
listeners: Dict[str, Callable[..., None]] = {
|
||||
'disconnection': set_failure('connection_died'),
|
||||
'pairing_failure': set_failure('pairing_failure'),
|
||||
@@ -425,6 +447,7 @@ class SecurityService(SecurityServicer):
|
||||
'connection_encryption_change': on_encryption_change,
|
||||
'classic_pairing': try_set_success,
|
||||
'classic_pairing_failure': set_failure('pairing_failure'),
|
||||
'security_request': pair,
|
||||
}
|
||||
|
||||
# register event handlers
|
||||
@@ -452,6 +475,15 @@ class SecurityService(SecurityServicer):
|
||||
pass
|
||||
self.log.debug('Authenticated')
|
||||
|
||||
# wait for `pair` to finish if any
|
||||
if pair_task is not None:
|
||||
self.log.debug('Wait for authentication...')
|
||||
try:
|
||||
await pair_task # type: ignore
|
||||
except:
|
||||
pass
|
||||
self.log.debug('paired')
|
||||
|
||||
return WaitSecurityResponse(**kwargs)
|
||||
|
||||
def reached_security_level(
|
||||
@@ -523,7 +555,7 @@ class SecurityStorageService(SecurityStorageServicer):
|
||||
self.log.debug(f"DeleteBond: {address}")
|
||||
|
||||
if self.device.keystore is not None:
|
||||
with suppress(KeyError):
|
||||
with contextlib.suppress(KeyError):
|
||||
await self.device.keystore.delete(str(address))
|
||||
|
||||
return empty_pb2.Empty()
|
||||
|
||||
@@ -20,13 +20,29 @@ from __future__ import annotations
|
||||
import logging
|
||||
import asyncio
|
||||
import enum
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
||||
|
||||
from pyee import EventEmitter
|
||||
from typing import Optional, Tuple, Callable, Dict, Union, TYPE_CHECKING
|
||||
|
||||
from . import core, l2cap
|
||||
from .colors import color
|
||||
from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError
|
||||
from .core import (
|
||||
UUID,
|
||||
BT_RFCOMM_PROTOCOL_ID,
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
BT_L2CAP_PROTOCOL_ID,
|
||||
InvalidStateError,
|
||||
ProtocolError,
|
||||
)
|
||||
from .sdp import (
|
||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
SDP_PUBLIC_BROWSE_ROOT,
|
||||
DataElement,
|
||||
ServiceAttribute,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble.device import Device, Connection
|
||||
@@ -111,6 +127,50 @@ RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
||||
# fmt: on
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def make_service_sdp_records(
|
||||
service_record_handle: int, channel: int, uuid: Optional[UUID] = None
|
||||
) -> List[ServiceAttribute]:
|
||||
"""
|
||||
Create SDP records for an RFComm service given a channel number and an
|
||||
optional UUID. A Service Class Attribute is included only if the UUID is not None.
|
||||
"""
|
||||
records = [
|
||||
ServiceAttribute(
|
||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||
DataElement.unsigned_integer_32(service_record_handle),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
|
||||
DataElement.unsigned_integer_8(channel),
|
||||
]
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
if uuid:
|
||||
records.append(
|
||||
ServiceAttribute(
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence([DataElement.uuid(uuid)]),
|
||||
)
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def compute_fcs(buffer: bytes) -> int:
|
||||
result = 0xFF
|
||||
|
||||
@@ -37,6 +37,7 @@ from typing import (
|
||||
Optional,
|
||||
Tuple,
|
||||
Type,
|
||||
cast,
|
||||
)
|
||||
|
||||
from pyee import EventEmitter
|
||||
@@ -1771,7 +1772,26 @@ class Manager(EventEmitter):
|
||||
cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID
|
||||
connection.send_l2cap_pdu(cid, command.to_bytes())
|
||||
|
||||
def on_smp_security_request_command(
|
||||
self, connection: Connection, request: SMP_Security_Request_Command
|
||||
) -> None:
|
||||
connection.emit('security_request', request.auth_req)
|
||||
|
||||
def on_smp_pdu(self, connection: Connection, pdu: bytes) -> None:
|
||||
# Parse the L2CAP payload into an SMP Command object
|
||||
command = SMP_Command.from_bytes(pdu)
|
||||
logger.debug(
|
||||
f'<<< Received SMP Command on connection [0x{connection.handle:04X}] '
|
||||
f'{connection.peer_address}: {command}'
|
||||
)
|
||||
|
||||
# Security request is more than just pairing, so let applications handle them
|
||||
if command.code == SMP_SECURITY_REQUEST_COMMAND:
|
||||
self.on_smp_security_request_command(
|
||||
connection, cast(SMP_Security_Request_Command, command)
|
||||
)
|
||||
return
|
||||
|
||||
# Look for a session with this connection, and create one if none exists
|
||||
if not (session := self.sessions.get(connection.handle)):
|
||||
if connection.role == BT_CENTRAL_ROLE:
|
||||
@@ -1782,13 +1802,6 @@ class Manager(EventEmitter):
|
||||
)
|
||||
self.sessions[connection.handle] = session
|
||||
|
||||
# Parse the L2CAP payload into an SMP Command object
|
||||
command = SMP_Command.from_bytes(pdu)
|
||||
logger.debug(
|
||||
f'<<< Received SMP Command on connection [0x{connection.handle:04X}] '
|
||||
f'{connection.peer_address}: {command}'
|
||||
)
|
||||
|
||||
# Delegate the handling of the command to the session
|
||||
session.on_smp_command(command)
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import logging
|
||||
import os
|
||||
|
||||
from .common import Transport, AsyncPipeSink, SnoopingTransport
|
||||
from ..controller import Controller
|
||||
from ..snoop import create_snooper
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -119,7 +118,8 @@ async def _open_transport(name: str) -> Transport:
|
||||
if scheme == 'file':
|
||||
from .file import open_file_transport
|
||||
|
||||
return await open_file_transport(spec[0] if spec else None)
|
||||
assert spec is not None
|
||||
return await open_file_transport(spec[0])
|
||||
|
||||
if scheme == 'vhci':
|
||||
from .vhci import open_vhci_transport
|
||||
@@ -134,12 +134,14 @@ async def _open_transport(name: str) -> Transport:
|
||||
if scheme == 'usb':
|
||||
from .usb import open_usb_transport
|
||||
|
||||
return await open_usb_transport(spec[0] if spec else None)
|
||||
assert spec is not None
|
||||
return await open_usb_transport(spec[0])
|
||||
|
||||
if scheme == 'pyusb':
|
||||
from .pyusb import open_pyusb_transport
|
||||
|
||||
return await open_pyusb_transport(spec[0] if spec else None)
|
||||
assert spec is not None
|
||||
return await open_pyusb_transport(spec[0])
|
||||
|
||||
if scheme == 'android-emulator':
|
||||
from .android_emulator import open_android_emulator_transport
|
||||
@@ -168,6 +170,7 @@ async def open_transport_or_link(name: str) -> Transport:
|
||||
|
||||
"""
|
||||
if name.startswith('link-relay:'):
|
||||
from ..controller import Controller
|
||||
from ..link import RemoteLink # lazy import
|
||||
|
||||
link = RemoteLink(name[11:])
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
import logging
|
||||
import grpc.aio
|
||||
|
||||
from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink
|
||||
from typing import Optional, Union
|
||||
|
||||
from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink, Transport
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from .grpc_protobuf.emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
|
||||
@@ -33,7 +35,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_android_emulator_transport(spec):
|
||||
async def open_android_emulator_transport(spec: Optional[str]) -> Transport:
|
||||
'''
|
||||
Open a transport connection to an Android emulator via its gRPC interface.
|
||||
The parameter string has this syntax:
|
||||
@@ -66,7 +68,7 @@ async def open_android_emulator_transport(spec):
|
||||
# Parse the parameters
|
||||
mode = 'host'
|
||||
server_host = 'localhost'
|
||||
server_port = 8554
|
||||
server_port = '8554'
|
||||
if spec is not None:
|
||||
params = spec.split(',')
|
||||
for param in params:
|
||||
@@ -82,6 +84,7 @@ async def open_android_emulator_transport(spec):
|
||||
logger.debug(f'connecting to gRPC server at {server_address}')
|
||||
channel = grpc.aio.insecure_channel(server_address)
|
||||
|
||||
service: Union[EmulatedBluetoothServiceStub, VhciForwardingServiceStub]
|
||||
if mode == 'host':
|
||||
# Connect as a host
|
||||
service = EmulatedBluetoothServiceStub(channel)
|
||||
@@ -94,10 +97,13 @@ async def open_android_emulator_transport(spec):
|
||||
raise ValueError('invalid mode')
|
||||
|
||||
# Create the transport object
|
||||
transport = PumpedTransport(
|
||||
PumpedPacketSource(hci_device.read),
|
||||
PumpedPacketSink(hci_device.write),
|
||||
channel.close,
|
||||
class EmulatorTransport(PumpedTransport):
|
||||
async def close(self):
|
||||
await super().close()
|
||||
await channel.close()
|
||||
|
||||
transport = EmulatorTransport(
|
||||
PumpedPacketSource(hci_device.read), PumpedPacketSink(hci_device.write)
|
||||
)
|
||||
transport.start()
|
||||
|
||||
|
||||
@@ -18,11 +18,12 @@
|
||||
import asyncio
|
||||
import atexit
|
||||
import logging
|
||||
import grpc.aio
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
from typing import Optional
|
||||
from typing import Dict, Optional
|
||||
|
||||
import grpc.aio
|
||||
|
||||
from .common import (
|
||||
ParserSource,
|
||||
@@ -33,8 +34,8 @@ from .common import (
|
||||
)
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from .grpc_protobuf.packet_streamer_pb2_grpc import PacketStreamerStub
|
||||
from .grpc_protobuf.packet_streamer_pb2_grpc import (
|
||||
PacketStreamerStub,
|
||||
PacketStreamerServicer,
|
||||
add_PacketStreamerServicer_to_server,
|
||||
)
|
||||
@@ -43,6 +44,7 @@ from .grpc_protobuf.hci_packet_pb2 import HCIPacket
|
||||
from .grpc_protobuf.startup_pb2 import Chip, ChipInfo
|
||||
from .grpc_protobuf.common_pb2 import ChipKind
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -74,14 +76,20 @@ def get_ini_dir() -> Optional[pathlib.Path]:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def find_grpc_port() -> int:
|
||||
def ini_file_name(instance_number: int) -> str:
|
||||
suffix = f'_{instance_number}' if instance_number > 0 else ''
|
||||
return f'netsim{suffix}.ini'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def find_grpc_port(instance_number: int) -> int:
|
||||
if not (ini_dir := get_ini_dir()):
|
||||
logger.debug('no known directory for .ini file')
|
||||
return 0
|
||||
|
||||
ini_file = ini_dir / 'netsim.ini'
|
||||
ini_file = ini_dir / ini_file_name(instance_number)
|
||||
logger.debug(f'Looking for .ini file at {ini_file}')
|
||||
if ini_file.is_file():
|
||||
logger.debug(f'Found .ini file at {ini_file}')
|
||||
with open(ini_file, 'r') as ini_file_data:
|
||||
for line in ini_file_data.readlines():
|
||||
if '=' in line:
|
||||
@@ -90,12 +98,14 @@ def find_grpc_port() -> int:
|
||||
logger.debug(f'gRPC port = {value}')
|
||||
return int(value)
|
||||
|
||||
logger.debug('no grpc.port property found in .ini file')
|
||||
|
||||
# Not found
|
||||
return 0
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def publish_grpc_port(grpc_port) -> bool:
|
||||
def publish_grpc_port(grpc_port: int, instance_number: int) -> bool:
|
||||
if not (ini_dir := get_ini_dir()):
|
||||
logger.debug('no known directory for .ini file')
|
||||
return False
|
||||
@@ -104,7 +114,7 @@ def publish_grpc_port(grpc_port) -> bool:
|
||||
logger.debug('ini directory does not exist')
|
||||
return False
|
||||
|
||||
ini_file = ini_dir / 'netsim.ini'
|
||||
ini_file = ini_dir / ini_file_name(instance_number)
|
||||
try:
|
||||
ini_file.write_text(f'grpc.port={grpc_port}\n')
|
||||
logger.debug(f"published gRPC port at {ini_file}")
|
||||
@@ -121,13 +131,16 @@ def publish_grpc_port(grpc_port) -> bool:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_android_netsim_controller_transport(server_host, server_port):
|
||||
async def open_android_netsim_controller_transport(
|
||||
server_host: Optional[str], server_port: int, options: Dict[str, str]
|
||||
) -> Transport:
|
||||
if not server_port:
|
||||
raise ValueError('invalid port')
|
||||
if server_host == '_' or not server_host:
|
||||
server_host = 'localhost'
|
||||
|
||||
if not publish_grpc_port(server_port):
|
||||
instance_number = int(options.get('instance', "0"))
|
||||
if not publish_grpc_port(server_port, instance_number):
|
||||
logger.warning("unable to publish gRPC port")
|
||||
|
||||
class HciDevice:
|
||||
@@ -184,15 +197,12 @@ async def open_android_netsim_controller_transport(server_host, server_port):
|
||||
logger.debug(f'<<< PACKET: {data.hex()}')
|
||||
self.on_data_received(data)
|
||||
|
||||
def send_packet(self, data):
|
||||
async def send():
|
||||
await self.context.write(
|
||||
PacketResponse(
|
||||
hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
|
||||
)
|
||||
async def send_packet(self, data):
|
||||
return await self.context.write(
|
||||
PacketResponse(
|
||||
hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
|
||||
)
|
||||
|
||||
self.loop.create_task(send())
|
||||
)
|
||||
|
||||
def terminate(self):
|
||||
self.task.cancel()
|
||||
@@ -226,17 +236,17 @@ async def open_android_netsim_controller_transport(server_host, server_port):
|
||||
logger.debug('gRPC server cancelled')
|
||||
await self.grpc_server.stop(None)
|
||||
|
||||
def on_packet(self, packet):
|
||||
async def send_packet(self, packet):
|
||||
if not self.device:
|
||||
logger.debug('no device, dropping packet')
|
||||
return
|
||||
|
||||
self.device.send_packet(packet)
|
||||
return await self.device.send_packet(packet)
|
||||
|
||||
async def StreamPackets(self, _request_iterator, context):
|
||||
logger.debug('StreamPackets request')
|
||||
|
||||
# Check that we won't already have a device
|
||||
# Check that we don't already have a device
|
||||
if self.device:
|
||||
logger.debug('busy, already serving a device')
|
||||
return PacketResponse(error='Busy')
|
||||
@@ -259,15 +269,42 @@ async def open_android_netsim_controller_transport(server_host, server_port):
|
||||
await server.start()
|
||||
asyncio.get_running_loop().create_task(server.serve())
|
||||
|
||||
class GrpcServerTransport(Transport):
|
||||
async def close(self):
|
||||
await super().close()
|
||||
|
||||
return GrpcServerTransport(server, server)
|
||||
sink = PumpedPacketSink(server.send_packet)
|
||||
sink.start()
|
||||
return Transport(server, sink)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_android_netsim_host_transport(server_host, server_port, options):
|
||||
async def open_android_netsim_host_transport_with_address(
|
||||
server_host: Optional[str],
|
||||
server_port: int,
|
||||
options: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
if server_host == '_' or not server_host:
|
||||
server_host = 'localhost'
|
||||
|
||||
if not server_port:
|
||||
# Look for the gRPC config in a .ini file
|
||||
instance_number = 0 if options is None else int(options.get('instance', '0'))
|
||||
server_port = find_grpc_port(instance_number)
|
||||
if not server_port:
|
||||
raise RuntimeError('gRPC server port not found')
|
||||
|
||||
# Connect to the gRPC server
|
||||
server_address = f'{server_host}:{server_port}'
|
||||
logger.debug(f'Connecting to gRPC server at {server_address}')
|
||||
channel = grpc.aio.insecure_channel(server_address)
|
||||
|
||||
return await open_android_netsim_host_transport_with_channel(
|
||||
channel,
|
||||
options,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_android_netsim_host_transport_with_channel(
|
||||
channel, options: Optional[Dict[str, str]] = None
|
||||
):
|
||||
# Wrapper for I/O operations
|
||||
class HciDevice:
|
||||
def __init__(self, name, manufacturer, hci_device):
|
||||
@@ -286,10 +323,12 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
|
||||
async def read(self):
|
||||
response = await self.hci_device.read()
|
||||
response_type = response.WhichOneof('response_type')
|
||||
|
||||
if response_type == 'error':
|
||||
logger.warning(f'received error: {response.error}')
|
||||
raise RuntimeError(response.error)
|
||||
elif response_type == 'hci_packet':
|
||||
|
||||
if response_type == 'hci_packet':
|
||||
return (
|
||||
bytes([response.hci_packet.packet_type])
|
||||
+ response.hci_packet.packet
|
||||
@@ -304,24 +343,9 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
|
||||
)
|
||||
)
|
||||
|
||||
name = options.get('name', DEFAULT_NAME)
|
||||
name = DEFAULT_NAME if options is None else options.get('name', DEFAULT_NAME)
|
||||
manufacturer = DEFAULT_MANUFACTURER
|
||||
|
||||
if server_host == '_' or not server_host:
|
||||
server_host = 'localhost'
|
||||
|
||||
if not server_port:
|
||||
# Look for the gRPC config in a .ini file
|
||||
server_host = 'localhost'
|
||||
server_port = find_grpc_port()
|
||||
if not server_port:
|
||||
raise RuntimeError('gRPC server port not found')
|
||||
|
||||
# Connect to the gRPC server
|
||||
server_address = f'{server_host}:{server_port}'
|
||||
logger.debug(f'Connecting to gRPC server at {server_address}')
|
||||
channel = grpc.aio.insecure_channel(server_address)
|
||||
|
||||
# Connect as a host
|
||||
service = PacketStreamerStub(channel)
|
||||
hci_device = HciDevice(
|
||||
@@ -332,10 +356,14 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
|
||||
await hci_device.start()
|
||||
|
||||
# Create the transport object
|
||||
transport = PumpedTransport(
|
||||
class GrpcTransport(PumpedTransport):
|
||||
async def close(self):
|
||||
await super().close()
|
||||
await channel.close()
|
||||
|
||||
transport = GrpcTransport(
|
||||
PumpedPacketSource(hci_device.read),
|
||||
PumpedPacketSink(hci_device.write),
|
||||
channel.close,
|
||||
)
|
||||
transport.start()
|
||||
|
||||
@@ -343,7 +371,7 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_android_netsim_transport(spec):
|
||||
async def open_android_netsim_transport(spec: Optional[str]) -> Transport:
|
||||
'''
|
||||
Open a transport connection as a client or server, implementing Android's `netsim`
|
||||
simulator protocol over gRPC.
|
||||
@@ -357,6 +385,11 @@ async def open_android_netsim_transport(spec):
|
||||
to connect *to* a netsim server (netsim is the controller), or accept
|
||||
connections *as* a netsim-compatible server.
|
||||
|
||||
instance=<n>
|
||||
Specifies an instance number, with <n> > 0. This is used to determine which
|
||||
.init file to use. In `host` mode, it is ignored when the <host>:<port>
|
||||
specifier is present, since in that case no .ini file is used.
|
||||
|
||||
In `host` mode:
|
||||
The <host>:<port> part is optional. When not specified, the transport
|
||||
looks for a netsim .ini file, from which it will read the `grpc.backend.port`
|
||||
@@ -385,14 +418,15 @@ async def open_android_netsim_transport(spec):
|
||||
params = spec.split(',') if spec else []
|
||||
if params and ':' in params[0]:
|
||||
# Explicit <host>:<port>
|
||||
host, port = params[0].split(':')
|
||||
host, port_str = params[0].split(':')
|
||||
port = int(port_str)
|
||||
params_offset = 1
|
||||
else:
|
||||
host = None
|
||||
port = 0
|
||||
params_offset = 0
|
||||
|
||||
options = {}
|
||||
options: Dict[str, str] = {}
|
||||
for param in params[params_offset:]:
|
||||
if '=' not in param:
|
||||
raise ValueError('invalid parameter, expected <name>=<value>')
|
||||
@@ -401,10 +435,12 @@ async def open_android_netsim_transport(spec):
|
||||
|
||||
mode = options.get('mode', 'host')
|
||||
if mode == 'host':
|
||||
return await open_android_netsim_host_transport(host, port, options)
|
||||
return await open_android_netsim_host_transport_with_address(
|
||||
host, port, options
|
||||
)
|
||||
if mode == 'controller':
|
||||
if host is None:
|
||||
raise ValueError('<host>:<port> missing')
|
||||
return await open_android_netsim_controller_transport(host, port)
|
||||
return await open_android_netsim_controller_transport(host, port, options)
|
||||
|
||||
raise ValueError('invalid mode option')
|
||||
|
||||
@@ -20,11 +20,12 @@ import contextlib
|
||||
import struct
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import ContextManager
|
||||
import io
|
||||
from typing import ContextManager, Tuple, Optional, Protocol, Dict
|
||||
|
||||
from .. import hci
|
||||
from ..colors import color
|
||||
from ..snoop import Snooper
|
||||
from bumble import hci
|
||||
from bumble.colors import color
|
||||
from bumble.snoop import Snooper
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -36,7 +37,7 @@ logger = logging.getLogger(__name__)
|
||||
# Information needed to parse HCI packets with a generic parser:
|
||||
# For each packet type, the info represents:
|
||||
# (length-size, length-offset, unpack-type)
|
||||
HCI_PACKET_INFO = {
|
||||
HCI_PACKET_INFO: Dict[int, Tuple[int, int, str]] = {
|
||||
hci.HCI_COMMAND_PACKET: (1, 2, 'B'),
|
||||
hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'),
|
||||
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
|
||||
@@ -44,6 +45,8 @@ HCI_PACKET_INFO = {
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Errors
|
||||
# -----------------------------------------------------------------------------
|
||||
class TransportLostError(Exception):
|
||||
"""
|
||||
@@ -51,24 +54,36 @@ class TransportLostError(Exception):
|
||||
"""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Typing Protocols
|
||||
# -----------------------------------------------------------------------------
|
||||
class TransportSink(Protocol):
|
||||
def on_packet(self, packet: bytes) -> None:
|
||||
...
|
||||
|
||||
|
||||
class TransportSource(Protocol):
|
||||
terminated: asyncio.Future[None]
|
||||
|
||||
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||
...
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class PacketPump:
|
||||
"""
|
||||
Pump HCI packets from a reader to a sink.
|
||||
"""
|
||||
|
||||
def __init__(self, reader, sink):
|
||||
def __init__(self, reader: AsyncPacketReader, sink: TransportSink) -> None:
|
||||
self.reader = reader
|
||||
self.sink = sink
|
||||
|
||||
async def run(self):
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
# Get a packet from the source
|
||||
packet = hci.HCI_Packet.from_bytes(await self.reader.next_packet())
|
||||
|
||||
# Deliver the packet to the sink
|
||||
self.sink.on_packet(packet)
|
||||
self.sink.on_packet(await self.reader.next_packet())
|
||||
except Exception as error:
|
||||
logger.warning(f'!!! {error}')
|
||||
|
||||
@@ -86,18 +101,22 @@ class PacketParser:
|
||||
NEED_LENGTH = 1
|
||||
NEED_BODY = 2
|
||||
|
||||
def __init__(self, sink=None):
|
||||
sink: Optional[TransportSink]
|
||||
extended_packet_info: Dict[int, Tuple[int, int, str]]
|
||||
packet_info: Optional[Tuple[int, int, str]] = None
|
||||
|
||||
def __init__(self, sink: Optional[TransportSink] = None) -> None:
|
||||
self.sink = sink
|
||||
self.extended_packet_info = {}
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
def reset(self) -> None:
|
||||
self.state = PacketParser.NEED_TYPE
|
||||
self.bytes_needed = 1
|
||||
self.packet = bytearray()
|
||||
self.packet_info = None
|
||||
|
||||
def feed_data(self, data):
|
||||
def feed_data(self, data: bytes) -> None:
|
||||
data_offset = 0
|
||||
data_left = len(data)
|
||||
while data_left and self.bytes_needed:
|
||||
@@ -118,6 +137,7 @@ class PacketParser:
|
||||
self.state = PacketParser.NEED_LENGTH
|
||||
self.bytes_needed = self.packet_info[0] + self.packet_info[1]
|
||||
elif self.state == PacketParser.NEED_LENGTH:
|
||||
assert self.packet_info is not None
|
||||
body_length = struct.unpack_from(
|
||||
self.packet_info[2], self.packet, 1 + self.packet_info[1]
|
||||
)[0]
|
||||
@@ -135,7 +155,7 @@ class PacketParser:
|
||||
)
|
||||
self.reset()
|
||||
|
||||
def set_packet_sink(self, sink):
|
||||
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||
self.sink = sink
|
||||
|
||||
|
||||
@@ -145,10 +165,10 @@ class PacketReader:
|
||||
Reader that reads HCI packets from a sync source.
|
||||
"""
|
||||
|
||||
def __init__(self, source):
|
||||
def __init__(self, source: io.BufferedReader) -> None:
|
||||
self.source = source
|
||||
|
||||
def next_packet(self):
|
||||
def next_packet(self) -> Optional[bytes]:
|
||||
# Get the packet type
|
||||
packet_type = self.source.read(1)
|
||||
if len(packet_type) != 1:
|
||||
@@ -157,7 +177,7 @@ class PacketReader:
|
||||
# Get the packet info based on its type
|
||||
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
||||
if packet_info is None:
|
||||
raise ValueError(f'invalid packet type {packet_type} found')
|
||||
raise ValueError(f'invalid packet type {packet_type[0]} found')
|
||||
|
||||
# Read the header (that includes the length)
|
||||
header_size = packet_info[0] + packet_info[1]
|
||||
@@ -180,17 +200,17 @@ class AsyncPacketReader:
|
||||
Reader that reads HCI packets from an async source.
|
||||
"""
|
||||
|
||||
def __init__(self, source):
|
||||
def __init__(self, source: asyncio.StreamReader) -> None:
|
||||
self.source = source
|
||||
|
||||
async def next_packet(self):
|
||||
async def next_packet(self) -> bytes:
|
||||
# Get the packet type
|
||||
packet_type = await self.source.readexactly(1)
|
||||
|
||||
# Get the packet info based on its type
|
||||
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
||||
if packet_info is None:
|
||||
raise ValueError(f'invalid packet type {packet_type} found')
|
||||
raise ValueError(f'invalid packet type {packet_type[0]} found')
|
||||
|
||||
# Read the header (that includes the length)
|
||||
header_size = packet_info[0] + packet_info[1]
|
||||
@@ -209,11 +229,11 @@ class AsyncPipeSink:
|
||||
Sink that forwards packets asynchronously to another sink.
|
||||
"""
|
||||
|
||||
def __init__(self, sink):
|
||||
def __init__(self, sink: TransportSink) -> None:
|
||||
self.sink = sink
|
||||
self.loop = asyncio.get_running_loop()
|
||||
|
||||
def on_packet(self, packet):
|
||||
def on_packet(self, packet: bytes) -> None:
|
||||
self.loop.call_soon(self.sink.on_packet, packet)
|
||||
|
||||
|
||||
@@ -223,50 +243,48 @@ class ParserSource:
|
||||
Base class designed to be subclassed by transport-specific source classes
|
||||
"""
|
||||
|
||||
terminated: asyncio.Future
|
||||
terminated: asyncio.Future[None]
|
||||
parser: PacketParser
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.parser = PacketParser()
|
||||
self.terminated = asyncio.get_running_loop().create_future()
|
||||
|
||||
def set_packet_sink(self, sink):
|
||||
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||
self.parser.set_packet_sink(sink)
|
||||
|
||||
def on_transport_lost(self):
|
||||
def on_transport_lost(self) -> None:
|
||||
self.terminated.set_result(None)
|
||||
if self.parser.sink:
|
||||
try:
|
||||
if hasattr(self.parser.sink, 'on_transport_lost'):
|
||||
self.parser.sink.on_transport_lost()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
async def wait_for_termination(self):
|
||||
async def wait_for_termination(self) -> None:
|
||||
"""
|
||||
Convenience method for backward compatibility. Prefer using the `terminated`
|
||||
attribute instead.
|
||||
"""
|
||||
return await self.terminated
|
||||
|
||||
def close(self):
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class StreamPacketSource(asyncio.Protocol, ParserSource):
|
||||
def data_received(self, data):
|
||||
def data_received(self, data: bytes) -> None:
|
||||
self.parser.feed_data(data)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class StreamPacketSink:
|
||||
def __init__(self, transport):
|
||||
def __init__(self, transport: asyncio.WriteTransport) -> None:
|
||||
self.transport = transport
|
||||
|
||||
def on_packet(self, packet):
|
||||
def on_packet(self, packet: bytes) -> None:
|
||||
self.transport.write(packet)
|
||||
|
||||
def close(self):
|
||||
def close(self) -> None:
|
||||
self.transport.close()
|
||||
|
||||
|
||||
@@ -286,7 +304,7 @@ class Transport:
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(self, source, sink):
|
||||
def __init__(self, source: TransportSource, sink: TransportSink) -> None:
|
||||
self.source = source
|
||||
self.sink = sink
|
||||
|
||||
@@ -300,34 +318,39 @@ class Transport:
|
||||
return iter((self.source, self.sink))
|
||||
|
||||
async def close(self) -> None:
|
||||
self.source.close()
|
||||
self.sink.close()
|
||||
if hasattr(self.source, 'close'):
|
||||
self.source.close()
|
||||
if hasattr(self.sink, 'close'):
|
||||
self.sink.close()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class PumpedPacketSource(ParserSource):
|
||||
def __init__(self, receive):
|
||||
pump_task: Optional[asyncio.Task[None]]
|
||||
|
||||
def __init__(self, receive) -> None:
|
||||
super().__init__()
|
||||
self.receive_function = receive
|
||||
self.pump_task = None
|
||||
|
||||
def start(self):
|
||||
async def pump_packets():
|
||||
def start(self) -> None:
|
||||
async def pump_packets() -> None:
|
||||
while True:
|
||||
try:
|
||||
packet = await self.receive_function()
|
||||
self.parser.feed_data(packet)
|
||||
except asyncio.exceptions.CancelledError:
|
||||
except asyncio.CancelledError:
|
||||
logger.debug('source pump task done')
|
||||
self.terminated.set_result(None)
|
||||
break
|
||||
except Exception as error:
|
||||
logger.warning(f'exception while waiting for packet: {error}')
|
||||
self.terminated.set_result(error)
|
||||
self.terminated.set_exception(error)
|
||||
break
|
||||
|
||||
self.pump_task = asyncio.create_task(pump_packets())
|
||||
|
||||
def close(self):
|
||||
def close(self) -> None:
|
||||
if self.pump_task:
|
||||
self.pump_task.cancel()
|
||||
|
||||
@@ -339,7 +362,7 @@ class PumpedPacketSink:
|
||||
self.packet_queue = asyncio.Queue()
|
||||
self.pump_task = None
|
||||
|
||||
def on_packet(self, packet):
|
||||
def on_packet(self, packet: bytes) -> None:
|
||||
self.packet_queue.put_nowait(packet)
|
||||
|
||||
def start(self):
|
||||
@@ -348,7 +371,7 @@ class PumpedPacketSink:
|
||||
try:
|
||||
packet = await self.packet_queue.get()
|
||||
await self.send_function(packet)
|
||||
except asyncio.exceptions.CancelledError:
|
||||
except asyncio.CancelledError:
|
||||
logger.debug('sink pump task done')
|
||||
break
|
||||
except Exception as error:
|
||||
@@ -364,18 +387,20 @@ class PumpedPacketSink:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class PumpedTransport(Transport):
|
||||
def __init__(self, source, sink, close_function):
|
||||
super().__init__(source, sink)
|
||||
self.close_function = close_function
|
||||
source: PumpedPacketSource
|
||||
sink: PumpedPacketSink
|
||||
|
||||
def start(self):
|
||||
def __init__(
|
||||
self,
|
||||
source: PumpedPacketSource,
|
||||
sink: PumpedPacketSink,
|
||||
) -> None:
|
||||
super().__init__(source, sink)
|
||||
|
||||
def start(self) -> None:
|
||||
self.source.start()
|
||||
self.sink.start()
|
||||
|
||||
async def close(self):
|
||||
await super().close()
|
||||
await self.close_function()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class SnoopingTransport(Transport):
|
||||
@@ -397,31 +422,38 @@ class SnoopingTransport(Transport):
|
||||
raise RuntimeError('unexpected code path') # Satisfy the type checker
|
||||
|
||||
class Source:
|
||||
def __init__(self, source, snooper):
|
||||
sink: TransportSink
|
||||
|
||||
def __init__(self, source: TransportSource, snooper: Snooper):
|
||||
self.source = source
|
||||
self.snooper = snooper
|
||||
self.sink = None
|
||||
self.terminated = source.terminated
|
||||
|
||||
def set_packet_sink(self, sink):
|
||||
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||
self.sink = sink
|
||||
self.source.set_packet_sink(self)
|
||||
|
||||
def on_packet(self, packet):
|
||||
def on_packet(self, packet: bytes) -> None:
|
||||
self.snooper.snoop(packet, Snooper.Direction.CONTROLLER_TO_HOST)
|
||||
if self.sink:
|
||||
self.sink.on_packet(packet)
|
||||
|
||||
class Sink:
|
||||
def __init__(self, sink, snooper):
|
||||
def __init__(self, sink: TransportSink, snooper: Snooper) -> None:
|
||||
self.sink = sink
|
||||
self.snooper = snooper
|
||||
|
||||
def on_packet(self, packet):
|
||||
def on_packet(self, packet: bytes) -> None:
|
||||
self.snooper.snoop(packet, Snooper.Direction.HOST_TO_CONTROLLER)
|
||||
if self.sink:
|
||||
self.sink.on_packet(packet)
|
||||
|
||||
def __init__(self, transport, snooper, close_snooper=None):
|
||||
def __init__(
|
||||
self,
|
||||
transport: Transport,
|
||||
snooper: Snooper,
|
||||
close_snooper=None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
self.Source(transport.source, snooper), self.Sink(transport.sink, snooper)
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_file_transport(spec):
|
||||
async def open_file_transport(spec: str) -> Transport:
|
||||
'''
|
||||
Open a File transport (typically not for a real file, but for a PTY or other unix
|
||||
virtual files).
|
||||
|
||||
@@ -23,6 +23,8 @@ import socket
|
||||
import ctypes
|
||||
import collections
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .common import Transport, ParserSource
|
||||
|
||||
|
||||
@@ -33,7 +35,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_hci_socket_transport(spec):
|
||||
async def open_hci_socket_transport(spec: Optional[str]) -> Transport:
|
||||
'''
|
||||
Open an HCI Socket (only available on some platforms).
|
||||
The parameter string is either empty (to use the first/default Bluetooth adapter)
|
||||
@@ -45,9 +47,9 @@ async def open_hci_socket_transport(spec):
|
||||
# Create a raw HCI socket
|
||||
try:
|
||||
hci_socket = socket.socket(
|
||||
socket.AF_BLUETOOTH,
|
||||
socket.SOCK_RAW | socket.SOCK_NONBLOCK,
|
||||
socket.BTPROTO_HCI,
|
||||
socket.AF_BLUETOOTH, # type: ignore[attr-defined]
|
||||
socket.SOCK_RAW | socket.SOCK_NONBLOCK, # type: ignore[attr-defined]
|
||||
socket.BTPROTO_HCI, # type: ignore[attr-defined]
|
||||
)
|
||||
except AttributeError as error:
|
||||
# Not supported on this platform
|
||||
@@ -78,7 +80,7 @@ async def open_hci_socket_transport(spec):
|
||||
bind_address = struct.pack(
|
||||
# pylint: disable=no-member
|
||||
'<HHH',
|
||||
socket.AF_BLUETOOTH,
|
||||
socket.AF_BLUETOOTH, # type: ignore[attr-defined]
|
||||
adapter_index,
|
||||
HCI_CHANNEL_USER,
|
||||
)
|
||||
|
||||
@@ -23,6 +23,8 @@ import atexit
|
||||
import os
|
||||
import logging
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .common import Transport, StreamPacketSource, StreamPacketSink
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -32,7 +34,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_pty_transport(spec):
|
||||
async def open_pty_transport(spec: Optional[str]) -> Transport:
|
||||
'''
|
||||
Open a PTY transport.
|
||||
The parameter string may be empty, or a path name where a symbolic link
|
||||
|
||||
@@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_pyusb_transport(spec):
|
||||
async def open_pyusb_transport(spec: str) -> Transport:
|
||||
'''
|
||||
Open a USB transport. [Implementation based on PyUSB]
|
||||
The parameter string has this syntax:
|
||||
|
||||
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_serial_transport(spec):
|
||||
async def open_serial_transport(spec: str) -> Transport:
|
||||
'''
|
||||
Open a serial port transport.
|
||||
The parameter string has this syntax:
|
||||
|
||||
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_tcp_client_transport(spec):
|
||||
async def open_tcp_client_transport(spec: str) -> Transport:
|
||||
'''
|
||||
Open a TCP client transport.
|
||||
The parameter string has this syntax:
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
@@ -27,7 +28,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_tcp_server_transport(spec):
|
||||
async def open_tcp_server_transport(spec: str) -> Transport:
|
||||
'''
|
||||
Open a TCP server transport.
|
||||
The parameter string has this syntax:
|
||||
@@ -42,7 +43,7 @@ async def open_tcp_server_transport(spec):
|
||||
async def close(self):
|
||||
await super().close()
|
||||
|
||||
class TcpServerProtocol:
|
||||
class TcpServerProtocol(asyncio.BaseProtocol):
|
||||
def __init__(self, packet_source, packet_sink):
|
||||
self.packet_source = packet_source
|
||||
self.packet_sink = packet_sink
|
||||
|
||||
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_udp_transport(spec):
|
||||
async def open_udp_transport(spec: str) -> Transport:
|
||||
'''
|
||||
Open a UDP transport.
|
||||
The parameter string has this syntax:
|
||||
|
||||
@@ -60,7 +60,7 @@ def load_libusb():
|
||||
usb1.loadLibrary(libusb_dll)
|
||||
|
||||
|
||||
async def open_usb_transport(spec):
|
||||
async def open_usb_transport(spec: str) -> Transport:
|
||||
'''
|
||||
Open a USB transport.
|
||||
The moniker string has this syntax:
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .common import Transport
|
||||
from .file import open_file_transport
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -26,7 +29,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_vhci_transport(spec):
|
||||
async def open_vhci_transport(spec: Optional[str]) -> Transport:
|
||||
'''
|
||||
Open a VHCI transport (only available on some platforms).
|
||||
The parameter string is either empty (to use the default VHCI device
|
||||
@@ -42,15 +45,15 @@ async def open_vhci_transport(spec):
|
||||
# Override the source's `data_received` method so that we can
|
||||
# filter out the vendor packet that is received just after the
|
||||
# initial open
|
||||
def vhci_data_received(data):
|
||||
def vhci_data_received(data: bytes) -> None:
|
||||
if len(data) > 0 and data[0] == HCI_VENDOR_PKT:
|
||||
if len(data) == 4:
|
||||
hci_index = data[2] << 8 | data[3]
|
||||
logger.info(f'HCI index {hci_index}')
|
||||
else:
|
||||
transport.source.parser.feed_data(data)
|
||||
transport.source.parser.feed_data(data) # type: ignore
|
||||
|
||||
transport.source.data_received = vhci_data_received
|
||||
transport.source.data_received = vhci_data_received # type: ignore
|
||||
|
||||
# Write the initial config
|
||||
transport.sink.on_packet(bytes([HCI_VENDOR_PKT, HCI_BREDR]))
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
import websockets
|
||||
import websockets.client
|
||||
|
||||
from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport
|
||||
from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport, Transport
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -27,23 +27,25 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_ws_client_transport(spec):
|
||||
async def open_ws_client_transport(spec: str) -> Transport:
|
||||
'''
|
||||
Open a WebSocket client transport.
|
||||
The parameter string has this syntax:
|
||||
<remote-host>:<remote-port>
|
||||
<websocket-url>
|
||||
|
||||
Example: 127.0.0.1:9001
|
||||
Example: ws://localhost:7681/v1/websocket/bt
|
||||
'''
|
||||
|
||||
remote_host, remote_port = spec.split(':')
|
||||
uri = f'ws://{remote_host}:{remote_port}'
|
||||
websocket = await websockets.connect(uri)
|
||||
websocket = await websockets.client.connect(spec)
|
||||
|
||||
transport = PumpedTransport(
|
||||
class WsTransport(PumpedTransport):
|
||||
async def close(self):
|
||||
await super().close()
|
||||
await websocket.close()
|
||||
|
||||
transport = WsTransport(
|
||||
PumpedPacketSource(websocket.recv),
|
||||
PumpedPacketSink(websocket.send),
|
||||
websocket.close,
|
||||
)
|
||||
transport.start()
|
||||
return transport
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import logging
|
||||
import websockets
|
||||
|
||||
@@ -28,7 +27,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_ws_server_transport(spec):
|
||||
async def open_ws_server_transport(spec: str) -> Transport:
|
||||
'''
|
||||
Open a WebSocket server transport.
|
||||
The parameter string has this syntax:
|
||||
|
||||
110
bumble/utils.py
110
bumble/utils.py
@@ -15,12 +15,24 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
import collections
|
||||
import sys
|
||||
from typing import Awaitable, Set, TypeVar
|
||||
from typing import (
|
||||
Awaitable,
|
||||
Set,
|
||||
TypeVar,
|
||||
List,
|
||||
Tuple,
|
||||
Callable,
|
||||
Any,
|
||||
Optional,
|
||||
Union,
|
||||
overload,
|
||||
)
|
||||
from functools import wraps
|
||||
from pyee import EventEmitter
|
||||
|
||||
@@ -64,6 +76,102 @@ def composite_listener(cls):
|
||||
return cls
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
_Handler = TypeVar('_Handler', bound=Callable)
|
||||
|
||||
|
||||
class EventWatcher:
|
||||
'''A wrapper class to control the lifecycle of event handlers better.
|
||||
|
||||
Usage:
|
||||
```
|
||||
watcher = EventWatcher()
|
||||
|
||||
def on_foo():
|
||||
...
|
||||
watcher.on(emitter, 'foo', on_foo)
|
||||
|
||||
@watcher.on(emitter, 'bar')
|
||||
def on_bar():
|
||||
...
|
||||
|
||||
# Close all event handlers watching through this watcher
|
||||
watcher.close()
|
||||
```
|
||||
|
||||
As context:
|
||||
```
|
||||
with contextlib.closing(EventWatcher()) as context:
|
||||
@context.on(emitter, 'foo')
|
||||
def on_foo():
|
||||
...
|
||||
# on_foo() has been removed here!
|
||||
```
|
||||
'''
|
||||
|
||||
handlers: List[Tuple[EventEmitter, str, Callable[..., Any]]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.handlers = []
|
||||
|
||||
@overload
|
||||
def on(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]:
|
||||
...
|
||||
|
||||
@overload
|
||||
def on(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler:
|
||||
...
|
||||
|
||||
def on(
|
||||
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
|
||||
) -> Union[_Handler, Callable[[_Handler], _Handler]]:
|
||||
'''Watch an event until the context is closed.
|
||||
|
||||
Args:
|
||||
emitter: EventEmitter to watch
|
||||
event: Event name
|
||||
handler: (Optional) Event handler. When nothing is passed, this method works as a decorator.
|
||||
'''
|
||||
|
||||
def wrapper(f: _Handler) -> _Handler:
|
||||
self.handlers.append((emitter, event, f))
|
||||
emitter.on(event, f)
|
||||
return f
|
||||
|
||||
return wrapper if handler is None else wrapper(handler)
|
||||
|
||||
@overload
|
||||
def once(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]:
|
||||
...
|
||||
|
||||
@overload
|
||||
def once(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler:
|
||||
...
|
||||
|
||||
def once(
|
||||
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
|
||||
) -> Union[_Handler, Callable[[_Handler], _Handler]]:
|
||||
'''Watch an event for once.
|
||||
|
||||
Args:
|
||||
emitter: EventEmitter to watch
|
||||
event: Event name
|
||||
handler: (Optional) Event handler. When nothing passed, this method works as a decorator.
|
||||
'''
|
||||
|
||||
def wrapper(f: _Handler) -> _Handler:
|
||||
self.handlers.append((emitter, event, f))
|
||||
emitter.once(event, f)
|
||||
return f
|
||||
|
||||
return wrapper if handler is None else wrapper(handler)
|
||||
|
||||
def close(self) -> None:
|
||||
for emitter, event, handler in self.handlers:
|
||||
if handler in emitter.listeners(event):
|
||||
emitter.remove_listener(event, handler)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
_T = TypeVar('_T')
|
||||
|
||||
|
||||
0
bumble/vendor/__init__.py
vendored
Normal file
0
bumble/vendor/__init__.py
vendored
Normal file
0
bumble/vendor/android/__init__.py
vendored
Normal file
0
bumble/vendor/android/__init__.py
vendored
Normal file
318
bumble/vendor/android/hci.py
vendored
Normal file
318
bumble/vendor/android/hci.py
vendored
Normal file
@@ -0,0 +1,318 @@
|
||||
# Copyright 2021-2023 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import struct
|
||||
|
||||
from bumble.hci import (
|
||||
name_or_number,
|
||||
hci_vendor_command_op_code,
|
||||
Address,
|
||||
HCI_Constant,
|
||||
HCI_Object,
|
||||
HCI_Command,
|
||||
HCI_Vendor_Event,
|
||||
STATUS_SPEC,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Constants
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Android Vendor Specific Commands and Events.
|
||||
# Only a subset of the commands are implemented here currently.
|
||||
#
|
||||
# pylint: disable-next=line-too-long
|
||||
# See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#chip-capabilities-and-configuration
|
||||
HCI_LE_GET_VENDOR_CAPABILITIES_COMMAND = hci_vendor_command_op_code(0x153)
|
||||
HCI_LE_APCF_COMMAND = hci_vendor_command_op_code(0x157)
|
||||
HCI_GET_CONTROLLER_ACTIVITY_ENERGY_INFO_COMMAND = hci_vendor_command_op_code(0x159)
|
||||
HCI_A2DP_HARDWARE_OFFLOAD_COMMAND = hci_vendor_command_op_code(0x15D)
|
||||
HCI_BLUETOOTH_QUALITY_REPORT_COMMAND = hci_vendor_command_op_code(0x15E)
|
||||
HCI_DYNAMIC_AUDIO_BUFFER_COMMAND = hci_vendor_command_op_code(0x15F)
|
||||
|
||||
HCI_BLUETOOTH_QUALITY_REPORT_EVENT = 0x58
|
||||
|
||||
HCI_Command.register_commands(globals())
|
||||
HCI_Vendor_Event.register_subevents(globals())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
('max_advt_instances', 1),
|
||||
('offloaded_resolution_of_private_address', 1),
|
||||
('total_scan_results_storage', 2),
|
||||
('max_irk_list_sz', 1),
|
||||
('filtering_support', 1),
|
||||
('max_filter', 1),
|
||||
('activity_energy_info_support', 1),
|
||||
('version_supported', 2),
|
||||
('total_num_of_advt_tracked', 2),
|
||||
('extended_scan_support', 1),
|
||||
('debug_logging_supported', 1),
|
||||
('le_address_generation_offloading_support', 1),
|
||||
('a2dp_source_offload_capability_mask', 4),
|
||||
('bluetooth_quality_report_support', 1),
|
||||
('dynamic_audio_buffer_support', 4),
|
||||
]
|
||||
)
|
||||
class HCI_LE_Get_Vendor_Capabilities_Command(HCI_Command):
|
||||
# pylint: disable=line-too-long
|
||||
'''
|
||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#vendor-specific-capabilities
|
||||
'''
|
||||
|
||||
@classmethod
|
||||
def parse_return_parameters(cls, parameters):
|
||||
# There are many versions of this data structure, so we need to parse until
|
||||
# there are no more bytes to parse, and leave un-signal parameters set to
|
||||
# None (older versions)
|
||||
nones = {field: None for field, _ in cls.return_parameters_fields}
|
||||
return_parameters = HCI_Object(cls.return_parameters_fields, **nones)
|
||||
|
||||
try:
|
||||
offset = 0
|
||||
for field in cls.return_parameters_fields:
|
||||
field_name, field_type = field
|
||||
field_value, field_size = HCI_Object.parse_field(
|
||||
parameters, offset, field_type
|
||||
)
|
||||
setattr(return_parameters, field_name, field_value)
|
||||
offset += field_size
|
||||
except struct.error:
|
||||
pass
|
||||
|
||||
return return_parameters
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
fields=[
|
||||
(
|
||||
'opcode',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_LE_APCF_Command.opcode_name(x),
|
||||
},
|
||||
),
|
||||
('payload', '*'),
|
||||
],
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
(
|
||||
'opcode',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_LE_APCF_Command.opcode_name(x),
|
||||
},
|
||||
),
|
||||
('payload', '*'),
|
||||
],
|
||||
)
|
||||
class HCI_LE_APCF_Command(HCI_Command):
|
||||
# pylint: disable=line-too-long
|
||||
'''
|
||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_apcf_command
|
||||
|
||||
NOTE: the subcommand-specific payloads are left as opaque byte arrays in this
|
||||
implementation. A future enhancement may define subcommand-specific data structures.
|
||||
'''
|
||||
|
||||
# APCF Subcommands
|
||||
# TODO: use the OpenIntEnum class (when upcoming PR is merged)
|
||||
APCF_ENABLE = 0x00
|
||||
APCF_SET_FILTERING_PARAMETERS = 0x01
|
||||
APCF_BROADCASTER_ADDRESS = 0x02
|
||||
APCF_SERVICE_UUID = 0x03
|
||||
APCF_SERVICE_SOLICITATION_UUID = 0x04
|
||||
APCF_LOCAL_NAME = 0x05
|
||||
APCF_MANUFACTURER_DATA = 0x06
|
||||
APCF_SERVICE_DATA = 0x07
|
||||
APCF_TRANSPORT_DISCOVERY_SERVICE = 0x08
|
||||
APCF_AD_TYPE_FILTER = 0x09
|
||||
APCF_READ_EXTENDED_FEATURES = 0xFF
|
||||
|
||||
OPCODE_NAMES = {
|
||||
APCF_ENABLE: 'APCF_ENABLE',
|
||||
APCF_SET_FILTERING_PARAMETERS: 'APCF_SET_FILTERING_PARAMETERS',
|
||||
APCF_BROADCASTER_ADDRESS: 'APCF_BROADCASTER_ADDRESS',
|
||||
APCF_SERVICE_UUID: 'APCF_SERVICE_UUID',
|
||||
APCF_SERVICE_SOLICITATION_UUID: 'APCF_SERVICE_SOLICITATION_UUID',
|
||||
APCF_LOCAL_NAME: 'APCF_LOCAL_NAME',
|
||||
APCF_MANUFACTURER_DATA: 'APCF_MANUFACTURER_DATA',
|
||||
APCF_SERVICE_DATA: 'APCF_SERVICE_DATA',
|
||||
APCF_TRANSPORT_DISCOVERY_SERVICE: 'APCF_TRANSPORT_DISCOVERY_SERVICE',
|
||||
APCF_AD_TYPE_FILTER: 'APCF_AD_TYPE_FILTER',
|
||||
APCF_READ_EXTENDED_FEATURES: 'APCF_READ_EXTENDED_FEATURES',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def opcode_name(cls, opcode):
|
||||
return name_or_number(cls.OPCODE_NAMES, opcode)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
('total_tx_time_ms', 4),
|
||||
('total_rx_time_ms', 4),
|
||||
('total_idle_time_ms', 4),
|
||||
('total_energy_used', 4),
|
||||
],
|
||||
)
|
||||
class HCI_Get_Controller_Activity_Energy_Info_Command(HCI_Command):
|
||||
# pylint: disable=line-too-long
|
||||
'''
|
||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_get_controller_activity_energy_info
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
fields=[
|
||||
(
|
||||
'opcode',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_A2DP_Hardware_Offload_Command.opcode_name(x),
|
||||
},
|
||||
),
|
||||
('payload', '*'),
|
||||
],
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
(
|
||||
'opcode',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_A2DP_Hardware_Offload_Command.opcode_name(x),
|
||||
},
|
||||
),
|
||||
('payload', '*'),
|
||||
],
|
||||
)
|
||||
class HCI_A2DP_Hardware_Offload_Command(HCI_Command):
|
||||
# pylint: disable=line-too-long
|
||||
'''
|
||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#a2dp-hardware-offload-support
|
||||
|
||||
NOTE: the subcommand-specific payloads are left as opaque byte arrays in this
|
||||
implementation. A future enhancement may define subcommand-specific data structures.
|
||||
'''
|
||||
|
||||
# A2DP Hardware Offload Subcommands
|
||||
# TODO: use the OpenIntEnum class (when upcoming PR is merged)
|
||||
START_A2DP_OFFLOAD = 0x01
|
||||
STOP_A2DP_OFFLOAD = 0x02
|
||||
|
||||
OPCODE_NAMES = {
|
||||
START_A2DP_OFFLOAD: 'START_A2DP_OFFLOAD',
|
||||
STOP_A2DP_OFFLOAD: 'STOP_A2DP_OFFLOAD',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def opcode_name(cls, opcode):
|
||||
return name_or_number(cls.OPCODE_NAMES, opcode)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
fields=[
|
||||
(
|
||||
'opcode',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_Dynamic_Audio_Buffer_Command.opcode_name(x),
|
||||
},
|
||||
),
|
||||
('payload', '*'),
|
||||
],
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
(
|
||||
'opcode',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_Dynamic_Audio_Buffer_Command.opcode_name(x),
|
||||
},
|
||||
),
|
||||
('payload', '*'),
|
||||
],
|
||||
)
|
||||
class HCI_Dynamic_Audio_Buffer_Command(HCI_Command):
|
||||
# pylint: disable=line-too-long
|
||||
'''
|
||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#dynamic-audio-buffer-command
|
||||
|
||||
NOTE: the subcommand-specific payloads are left as opaque byte arrays in this
|
||||
implementation. A future enhancement may define subcommand-specific data structures.
|
||||
'''
|
||||
|
||||
# Dynamic Audio Buffer Subcommands
|
||||
# TODO: use the OpenIntEnum class (when upcoming PR is merged)
|
||||
GET_AUDIO_BUFFER_TIME_CAPABILITY = 0x01
|
||||
|
||||
OPCODE_NAMES = {
|
||||
GET_AUDIO_BUFFER_TIME_CAPABILITY: 'GET_AUDIO_BUFFER_TIME_CAPABILITY',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def opcode_name(cls, opcode):
|
||||
return name_or_number(cls.OPCODE_NAMES, opcode)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Vendor_Event.event(
|
||||
fields=[
|
||||
('quality_report_id', 1),
|
||||
('packet_types', 1),
|
||||
('connection_handle', 2),
|
||||
('connection_role', {'size': 1, 'mapper': HCI_Constant.role_name}),
|
||||
('tx_power_level', -1),
|
||||
('rssi', -1),
|
||||
('snr', 1),
|
||||
('unused_afh_channel_count', 1),
|
||||
('afh_select_unideal_channel_count', 1),
|
||||
('lsto', 2),
|
||||
('connection_piconet_clock', 4),
|
||||
('retransmission_count', 4),
|
||||
('no_rx_count', 4),
|
||||
('nak_count', 4),
|
||||
('last_tx_ack_timestamp', 4),
|
||||
('flow_off_count', 4),
|
||||
('last_flow_on_timestamp', 4),
|
||||
('buffer_overflow_bytes', 4),
|
||||
('buffer_underflow_bytes', 4),
|
||||
('bdaddr', Address.parse_address),
|
||||
('cal_failed_item_count', 1),
|
||||
('tx_total_packets', 4),
|
||||
('tx_unacked_packets', 4),
|
||||
('tx_flushed_packets', 4),
|
||||
('tx_last_subevent_packets', 4),
|
||||
('crc_error_packets', 4),
|
||||
('rx_duplicate_packets', 4),
|
||||
('vendor_specific_parameters', '*'),
|
||||
]
|
||||
)
|
||||
class HCI_Bluetooth_Quality_Report_Event(HCI_Vendor_Event):
|
||||
# pylint: disable=line-too-long
|
||||
'''
|
||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#bluetooth-quality-report-sub-event
|
||||
'''
|
||||
0
bumble/vendor/zephyr/__init__.py
vendored
Normal file
0
bumble/vendor/zephyr/__init__.py
vendored
Normal file
88
bumble/vendor/zephyr/hci.py
vendored
Normal file
88
bumble/vendor/zephyr/hci.py
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
# Copyright 2021-2023 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from bumble.hci import (
|
||||
hci_vendor_command_op_code,
|
||||
HCI_Command,
|
||||
STATUS_SPEC,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Constants
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Zephyr RTOS Vendor Specific Commands and Events.
|
||||
# Only a subset of the commands are implemented here currently.
|
||||
#
|
||||
# pylint: disable-next=line-too-long
|
||||
# See https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
|
||||
HCI_WRITE_TX_POWER_LEVEL_COMMAND = hci_vendor_command_op_code(0x000E)
|
||||
HCI_READ_TX_POWER_LEVEL_COMMAND = hci_vendor_command_op_code(0x000F)
|
||||
|
||||
HCI_Command.register_commands(globals())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class TX_Power_Level_Command:
|
||||
'''
|
||||
Base class for read and write TX power level HCI commands
|
||||
'''
|
||||
|
||||
TX_POWER_HANDLE_TYPE_ADV = 0x00
|
||||
TX_POWER_HANDLE_TYPE_SCAN = 0x01
|
||||
TX_POWER_HANDLE_TYPE_CONN = 0x02
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
fields=[('handle_type', 1), ('connection_handle', 2), ('tx_power_level', -1)],
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
('handle_type', 1),
|
||||
('connection_handle', 2),
|
||||
('selected_tx_power_level', -1),
|
||||
],
|
||||
)
|
||||
class HCI_Write_Tx_Power_Level_Command(HCI_Command, TX_Power_Level_Command):
|
||||
'''
|
||||
Write TX power level. See BT_HCI_OP_VS_WRITE_TX_POWER_LEVEL in
|
||||
https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
|
||||
|
||||
Power level is in dB. Connection handle for TX_POWER_HANDLE_TYPE_ADV and
|
||||
TX_POWER_HANDLE_TYPE_SCAN should be zero.
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
fields=[('handle_type', 1), ('connection_handle', 2)],
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
('handle_type', 1),
|
||||
('connection_handle', 2),
|
||||
('tx_power_level', -1),
|
||||
],
|
||||
)
|
||||
class HCI_Read_Tx_Power_Level_Command(HCI_Command, TX_Power_Level_Command):
|
||||
'''
|
||||
Read TX power level. See BT_HCI_OP_VS_READ_TX_POWER_LEVEL in
|
||||
https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
|
||||
|
||||
Power level is in dB. Connection handle for TX_POWER_HANDLE_TYPE_ADV and
|
||||
TX_POWER_HANDLE_TYPE_SCAN should be zero.
|
||||
'''
|
||||
@@ -64,6 +64,7 @@ nav:
|
||||
- Linux: platforms/linux.md
|
||||
- Windows: platforms/windows.md
|
||||
- Android: platforms/android.md
|
||||
- Zephyr: platforms/zephyr.md
|
||||
- Examples:
|
||||
- Overview: examples/index.md
|
||||
|
||||
|
||||
BIN
docs/mkdocs/src/downloads/zephyr/hci_usb.zip
Normal file
BIN
docs/mkdocs/src/downloads/zephyr/hci_usb.zip
Normal file
Binary file not shown.
@@ -9,3 +9,4 @@ For platform-specific information, see the following pages:
|
||||
* :material-linux: Linux - see the [Linux platform page](linux.md)
|
||||
* :material-microsoft-windows: Windows - see the [Windows platform page](windows.md)
|
||||
* :material-android: Android - see the [Android platform page](android.md)
|
||||
* :material-memory: Zephyr - see the [Zephyr platform page](zephyr.md)
|
||||
|
||||
51
docs/mkdocs/src/platforms/zephyr.md
Normal file
51
docs/mkdocs/src/platforms/zephyr.md
Normal file
@@ -0,0 +1,51 @@
|
||||
:material-memory: ZEPHYR PLATFORM
|
||||
=================================
|
||||
|
||||
Set TX Power on nRF52840
|
||||
------------------------
|
||||
|
||||
The Nordic nRF52840 supports Zephyr's vendor specific HCI command for setting TX
|
||||
power during advertising, connection, or scanning. With the example [HCI
|
||||
USB](https://docs.zephyrproject.org/latest/samples/bluetooth/hci_usb/README.html)
|
||||
application, an [nRF52840
|
||||
dongle](https://www.nordicsemi.com/Products/Development-
|
||||
hardware/nRF52840-Dongle) can be used as a Bumble controller.
|
||||
|
||||
To add dynamic TX power support to the HCI USB application, add the following to
|
||||
`zephyr/samples/bluetooth/hci_usb/prj.conf` and build.
|
||||
|
||||
```
|
||||
CONFIG_BT_CTLR_ADVANCED_FEATURES=y
|
||||
CONFIG_BT_CTLR_CONN_RSSI=y
|
||||
CONFIG_BT_CTLR_TX_PWR_DYNAMIC_CONTROL=y
|
||||
```
|
||||
|
||||
Alternatively, a prebuilt firmware application can be downloaded here:
|
||||
[hci_usb.zip](../downloads/zephyr/hci_usb.zip).
|
||||
|
||||
Put the nRF52840 dongle into bootloader mode by pressing the RESET button. The
|
||||
LED should pulse red. Load the firmware application with the `nrfutil` tool:
|
||||
|
||||
```
|
||||
nrfutil dfu usb-serial -pkg hci_usb.zip -p /dev/ttyACM0
|
||||
```
|
||||
|
||||
The vendor specific HCI commands to read and write TX power are defined in
|
||||
`bumble/vendor/zephyr/hci.py` and may be used as such:
|
||||
|
||||
```python
|
||||
from bumble.vendor.zephyr.hci import HCI_Write_Tx_Power_Level_Command
|
||||
|
||||
# set advertising power to -4 dB
|
||||
response = await host.send_command(
|
||||
HCI_Write_Tx_Power_Level_Command(
|
||||
handle_type=HCI_Write_Tx_Power_Level_Command.TX_POWER_HANDLE_TYPE_ADV,
|
||||
connection_handle=0,
|
||||
tx_power_level=-4,
|
||||
)
|
||||
)
|
||||
|
||||
if response.return_parameters.status == HCI_SUCCESS:
|
||||
print(f"TX power set to {response.return_parameters.selected_tx_power_level}")
|
||||
|
||||
```
|
||||
@@ -16,11 +16,9 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import collections
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from typing import Union
|
||||
|
||||
from bumble.colors import color
|
||||
|
||||
@@ -32,8 +30,7 @@ from bumble.core import (
|
||||
BT_RFCOMM_PROTOCOL_ID,
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
)
|
||||
from bumble import rfcomm
|
||||
from bumble.rfcomm import Client
|
||||
from bumble import rfcomm, hfp
|
||||
from bumble.sdp import (
|
||||
Client as SDP_Client,
|
||||
DataElement,
|
||||
@@ -47,61 +44,6 @@ from bumble.sdp import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Protocol Support
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HfpProtocol:
|
||||
dlc: rfcomm.DLC
|
||||
buffer: str
|
||||
lines: collections.deque
|
||||
lines_available: asyncio.Event
|
||||
|
||||
def __init__(self, dlc: rfcomm.DLC) -> None:
|
||||
self.dlc = dlc
|
||||
self.buffer = ''
|
||||
self.lines = collections.deque()
|
||||
self.lines_available = asyncio.Event()
|
||||
|
||||
dlc.sink = self.feed
|
||||
|
||||
def feed(self, data: Union[bytes, str]) -> None:
|
||||
# Convert the data to a string if needed
|
||||
if isinstance(data, bytes):
|
||||
data = data.decode('utf-8')
|
||||
|
||||
logger.debug(f'<<< Data received: {data}')
|
||||
|
||||
# Add to the buffer and look for lines
|
||||
self.buffer += data
|
||||
while (separator := self.buffer.find('\r')) >= 0:
|
||||
line = self.buffer[:separator].strip()
|
||||
self.buffer = self.buffer[separator + 1 :]
|
||||
if len(line) > 0:
|
||||
self.on_line(line)
|
||||
|
||||
def on_line(self, line: str) -> None:
|
||||
self.lines.append(line)
|
||||
self.lines_available.set()
|
||||
|
||||
def send_command_line(self, line: str) -> None:
|
||||
logger.debug(color(f'>>> {line}', 'yellow'))
|
||||
self.dlc.write(line + '\r')
|
||||
|
||||
def send_response_line(self, line: str) -> None:
|
||||
logger.debug(color(f'>>> {line}', 'yellow'))
|
||||
self.dlc.write('\r\n' + line + '\r\n')
|
||||
|
||||
async def next_line(self) -> str:
|
||||
await self.lines_available.wait()
|
||||
line = self.lines.popleft()
|
||||
if not self.lines:
|
||||
self.lines_available.clear()
|
||||
logger.debug(color(f'<<< {line}', 'green'))
|
||||
return line
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# pylint: disable-next=too-many-nested-blocks
|
||||
async def list_rfcomm_channels(device, connection):
|
||||
@@ -241,7 +183,7 @@ async def main():
|
||||
|
||||
# Create a client and start it
|
||||
print('@@@ Starting to RFCOMM client...')
|
||||
rfcomm_client = Client(device, connection)
|
||||
rfcomm_client = rfcomm.Client(device, connection)
|
||||
rfcomm_mux = await rfcomm_client.start()
|
||||
print('@@@ Started')
|
||||
|
||||
@@ -256,7 +198,7 @@ async def main():
|
||||
return
|
||||
|
||||
# Protocol loop (just for testing at this point)
|
||||
protocol = HfpProtocol(session)
|
||||
protocol = hfp.HfpProtocol(session)
|
||||
while True:
|
||||
line = await protocol.next_line()
|
||||
|
||||
|
||||
@@ -20,83 +20,109 @@ import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
from bumble.core import UUID
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.core import BT_L2CAP_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID, UUID
|
||||
from bumble.rfcomm import Server
|
||||
from bumble.sdp import (
|
||||
DataElement,
|
||||
ServiceAttribute,
|
||||
SDP_PUBLIC_BROWSE_ROOT,
|
||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
)
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.rfcomm import make_service_sdp_records
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def sdp_records(channel):
|
||||
def sdp_records(channel, uuid):
|
||||
service_record_handle = 0x00010001
|
||||
return {
|
||||
0x00010001: [
|
||||
ServiceAttribute(
|
||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||
DataElement.unsigned_integer_32(0x00010001),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence(
|
||||
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
|
||||
),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
|
||||
DataElement.unsigned_integer_8(channel),
|
||||
]
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
service_record_handle: make_service_sdp_records(
|
||||
service_record_handle, channel, UUID(uuid)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_dlc(dlc):
|
||||
print('*** DLC connected', dlc)
|
||||
dlc.sink = lambda data: on_rfcomm_data_received(dlc, data)
|
||||
def on_rfcomm_session(rfcomm_session, tcp_server):
|
||||
print('*** RFComm session connected', rfcomm_session)
|
||||
tcp_server.attach_session(rfcomm_session)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_rfcomm_data_received(dlc, data):
|
||||
print(f'<<< Data received: {data.hex()}')
|
||||
try:
|
||||
message = data.decode('utf-8')
|
||||
print(f'<<< Message = {message}')
|
||||
except Exception:
|
||||
pass
|
||||
class TcpServerProtocol(asyncio.Protocol):
|
||||
def __init__(self, server):
|
||||
self.server = server
|
||||
|
||||
# Echo everything back
|
||||
dlc.write(data)
|
||||
def connection_made(self, transport):
|
||||
peer_name = transport.get_extra_info('peer_name')
|
||||
print(f'<<< TCP Server: connection from {peer_name}')
|
||||
if self.server:
|
||||
self.server.tcp_transport = transport
|
||||
else:
|
||||
transport.close()
|
||||
|
||||
def connection_lost(self, exc):
|
||||
print('<<< TCP Server: connection lost')
|
||||
if self.server:
|
||||
self.server.tcp_transport = None
|
||||
|
||||
def data_received(self, data):
|
||||
print(f'<<< TCP Server: data received: {len(data)} bytes - {data.hex()}')
|
||||
if self.server:
|
||||
self.server.tcp_data_received(data)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class TcpServer:
|
||||
def __init__(self, port):
|
||||
self.rfcomm_session = None
|
||||
self.tcp_transport = None
|
||||
AsyncRunner.spawn(self.run(port))
|
||||
|
||||
def attach_session(self, rfcomm_session):
|
||||
if self.rfcomm_session:
|
||||
self.rfcomm_session.sink = None
|
||||
|
||||
self.rfcomm_session = rfcomm_session
|
||||
rfcomm_session.sink = self.rfcomm_data_received
|
||||
|
||||
def rfcomm_data_received(self, data):
|
||||
print(f'<<< RFCOMM Data: {data.hex()}')
|
||||
if self.tcp_transport:
|
||||
self.tcp_transport.write(data)
|
||||
else:
|
||||
print('!!! no TCP connection, dropping data')
|
||||
|
||||
def tcp_data_received(self, data):
|
||||
if self.rfcomm_session:
|
||||
self.rfcomm_session.write(data)
|
||||
else:
|
||||
print('!!! no RFComm session, dropping data')
|
||||
|
||||
async def run(self, port):
|
||||
print(f'$$$ Starting TCP server on port {port}')
|
||||
|
||||
server = await asyncio.get_running_loop().create_server(
|
||||
lambda: TcpServerProtocol(self), '127.0.0.1', port
|
||||
)
|
||||
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def main():
|
||||
if len(sys.argv) < 3:
|
||||
print('Usage: run_rfcomm_server.py <device-config> <transport-spec>')
|
||||
print('example: run_rfcomm_server.py classic2.json usb:04b4:f901')
|
||||
if len(sys.argv) < 4:
|
||||
print(
|
||||
'Usage: run_rfcomm_server.py <device-config> <transport-spec> '
|
||||
'<tcp-port> [<uuid>]'
|
||||
)
|
||||
print('example: run_rfcomm_server.py classic2.json usb:0 8888')
|
||||
return
|
||||
|
||||
tcp_port = int(sys.argv[3])
|
||||
|
||||
if len(sys.argv) >= 5:
|
||||
uuid = sys.argv[4]
|
||||
else:
|
||||
uuid = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
||||
print('<<< connected')
|
||||
@@ -105,15 +131,20 @@ async def main():
|
||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
||||
device.classic_enabled = True
|
||||
|
||||
# Create and register a server
|
||||
# Create a TCP server
|
||||
tcp_server = TcpServer(tcp_port)
|
||||
|
||||
# Create and register an RFComm server
|
||||
rfcomm_server = Server(device)
|
||||
|
||||
# Listen for incoming DLC connections
|
||||
channel_number = rfcomm_server.listen(on_dlc)
|
||||
print(f'### Listening for connection on channel {channel_number}')
|
||||
channel_number = rfcomm_server.listen(
|
||||
lambda session: on_rfcomm_session(session, tcp_server)
|
||||
)
|
||||
print(f'### Listening for RFComm connections on channel {channel_number}')
|
||||
|
||||
# Setup the SDP to advertise this channel
|
||||
device.sdp_service_records = sdp_records(channel_number)
|
||||
device.sdp_service_records = sdp_records(channel_number, uuid)
|
||||
|
||||
# Start the controller
|
||||
await device.power_on()
|
||||
|
||||
159
rust/Cargo.lock
generated
159
rust/Cargo.lock
generated
@@ -130,6 +130,16 @@ version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635"
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c2f7349907b712260e64b0afe2f84692af14a454be26187d9df565c7f69266a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumble"
|
||||
version = "0.1.0"
|
||||
@@ -138,7 +148,9 @@ dependencies = [
|
||||
"clap 4.4.1",
|
||||
"directories",
|
||||
"env_logger",
|
||||
"file-header",
|
||||
"futures",
|
||||
"globset",
|
||||
"hex",
|
||||
"itertools",
|
||||
"lazy_static",
|
||||
@@ -272,6 +284,73 @@ version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2801af0d36612ae591caa9568261fddce32ce6e08a7275ea334a06a4ad021a2c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-channel",
|
||||
"crossbeam-deque",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-queue",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"memoffset 0.9.0",
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "directories"
|
||||
version = "5.0.1"
|
||||
@@ -348,6 +427,19 @@ version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764"
|
||||
|
||||
[[package]]
|
||||
name = "file-header"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5568149106e77ae33bc3a2c3ef3839cbe63ffa4a8dd4a81612a6f9dfdbc2e9f"
|
||||
dependencies = [
|
||||
"crossbeam",
|
||||
"lazy_static",
|
||||
"license",
|
||||
"thiserror",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -484,6 +576,19 @@ version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0"
|
||||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "759c97c1e17c55525b57192c06a267cda0ac5210b222d6b82189a2338fa1c13d"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"bstr",
|
||||
"fnv",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.3.21"
|
||||
@@ -710,6 +815,17 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "license"
|
||||
version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66615d42e949152327c402e03cd29dab8bff91ce470381ac2ca6d380d8d9946"
|
||||
dependencies = [
|
||||
"reword",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.5"
|
||||
@@ -756,6 +872,15 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -1200,6 +1325,15 @@ dependencies = [
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reword"
|
||||
version = "7.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe272098dce9ed76b479995953f748d1851261390b08f8a0ff619c885a1f0765"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusb"
|
||||
version = "0.9.3"
|
||||
@@ -1241,6 +1375,15 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.22"
|
||||
@@ -1589,6 +1732,12 @@ dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
|
||||
|
||||
[[package]]
|
||||
name = "unindent"
|
||||
version = "0.1.11"
|
||||
@@ -1618,6 +1767,16 @@ version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
|
||||
@@ -24,6 +24,10 @@ itertools = "0.11.0"
|
||||
lazy_static = "1.4.0"
|
||||
thiserror = "1.0.41"
|
||||
|
||||
# Dev tools
|
||||
file-header = { version = "0.1.2", optional = true }
|
||||
globset = { version = "0.4.13", optional = true }
|
||||
|
||||
# CLI
|
||||
anyhow = { version = "1.0.71", optional = true }
|
||||
clap = { version = "4.3.3", features = ["derive"], optional = true }
|
||||
@@ -52,10 +56,15 @@ env_logger = "0.10.0"
|
||||
[package.metadata.docs.rs]
|
||||
rustdoc-args = ["--generate-link-to-definition"]
|
||||
|
||||
[[bin]]
|
||||
name = "file-header"
|
||||
path = "tools/file_header.rs"
|
||||
required-features = ["dev-tools"]
|
||||
|
||||
[[bin]]
|
||||
name = "gen-assigned-numbers"
|
||||
path = "tools/gen_assigned_numbers.rs"
|
||||
required-features = ["bumble-codegen"]
|
||||
required-features = ["dev-tools"]
|
||||
|
||||
[[bin]]
|
||||
name = "bumble"
|
||||
@@ -71,7 +80,7 @@ harness = false
|
||||
[features]
|
||||
anyhow = ["pyo3/anyhow"]
|
||||
pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"]
|
||||
bumble-codegen = ["dep:anyhow"]
|
||||
dev-tools = ["dep:anyhow", "dep:clap", "dep:file-header", "dep:globset"]
|
||||
# separate feature for CLI so that dependencies don't spend time building these
|
||||
bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger", "dep:futures"]
|
||||
default = []
|
||||
|
||||
@@ -62,5 +62,5 @@ in tests at `pytests/assigned_numbers.rs`.
|
||||
To regenerate the assigned number tables based on the Python codebase:
|
||||
|
||||
```
|
||||
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features bumble-codegen
|
||||
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features dev-tools
|
||||
```
|
||||
@@ -1,3 +1,17 @@
|
||||
// Copyright 2023 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use bumble::wrapper::{self, core::Uuid16};
|
||||
use pyo3::{intern, prelude::*, types::PyDict};
|
||||
use std::collections;
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
// Copyright 2023 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! BLE advertisements.
|
||||
|
||||
use crate::wrapper::assigned_numbers::{COMPANY_IDS, SERVICE_IDS};
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
// Copyright 2023 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Bumble & Python logging
|
||||
|
||||
use pyo3::types::PyDict;
|
||||
|
||||
78
rust/tools/file_header.rs
Normal file
78
rust/tools/file_header.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright 2023 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use anyhow::anyhow;
|
||||
use clap::Parser as _;
|
||||
use file_header::{
|
||||
add_headers_recursively, check_headers_recursively,
|
||||
license::spdx::{YearCopyrightOwnerValue, APACHE_2_0},
|
||||
};
|
||||
use globset::{Glob, GlobSet, GlobSetBuilder};
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let rust_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
|
||||
let ignore_globset = ignore_globset()?;
|
||||
// Note: when adding headers, there is a bug where the line spacing is off for Apache 2.0 (see https://github.com/spdx/license-list-XML/issues/2127)
|
||||
let header = APACHE_2_0.build_header(YearCopyrightOwnerValue::new(2023, "Google LLC".into()));
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.subcommand {
|
||||
Subcommand::CheckAll => {
|
||||
let result =
|
||||
check_headers_recursively(&rust_dir, |p| !ignore_globset.is_match(p), header, 4)?;
|
||||
if result.has_failure() {
|
||||
return Err(anyhow!(
|
||||
"The following files do not have headers: {result:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Subcommand::AddAll => {
|
||||
let files_with_new_header =
|
||||
add_headers_recursively(&rust_dir, |p| !ignore_globset.is_match(p), header)?;
|
||||
files_with_new_header
|
||||
.iter()
|
||||
.for_each(|path| println!("Added header to: {path:?}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ignore_globset() -> anyhow::Result<GlobSet> {
|
||||
Ok(GlobSetBuilder::new()
|
||||
.add(Glob::new("**/.idea/**")?)
|
||||
.add(Glob::new("**/target/**")?)
|
||||
.add(Glob::new("**/.gitignore")?)
|
||||
.add(Glob::new("**/CHANGELOG.md")?)
|
||||
.add(Glob::new("**/Cargo.lock")?)
|
||||
.add(Glob::new("**/Cargo.toml")?)
|
||||
.add(Glob::new("**/README.md")?)
|
||||
.add(Glob::new("*.bin")?)
|
||||
.build()?)
|
||||
}
|
||||
|
||||
#[derive(clap::Parser)]
|
||||
struct Cli {
|
||||
#[clap(subcommand)]
|
||||
subcommand: Subcommand,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug, Clone)]
|
||||
enum Subcommand {
|
||||
/// Checks if a license is present in files that are not in the ignore list.
|
||||
CheckAll,
|
||||
/// Adds a license as needed to files that are not in the ignore list.
|
||||
AddAll,
|
||||
}
|
||||
@@ -36,6 +36,10 @@ install_requires =
|
||||
bt-test-interfaces >= 0.0.2; platform_system!='Emscripten'
|
||||
click == 8.1.3; platform_system!='Emscripten'
|
||||
cryptography == 39; platform_system!='Emscripten'
|
||||
# Pyodide bundles a version of cryptography that is built for wasm, which may not match the
|
||||
# versions available on PyPI. Relax the version requirement since it's better than being
|
||||
# completely unable to import the package in case of version mismatch.
|
||||
cryptography >= 39.0; platform_system=='Emscripten'
|
||||
grpcio == 1.57.0; platform_system!='Emscripten'
|
||||
humanize >= 4.6.0; platform_system!='Emscripten'
|
||||
libusb1 >= 2.0.1; platform_system!='Emscripten'
|
||||
@@ -84,7 +88,7 @@ development =
|
||||
black == 22.10
|
||||
grpcio-tools >= 1.57.0
|
||||
invoke >= 1.7.3
|
||||
mypy == 1.2.0
|
||||
mypy == 1.5.0
|
||||
nox >= 2022
|
||||
pylint == 2.15.8
|
||||
types-appdirs >= 1.4.3
|
||||
|
||||
@@ -891,10 +891,10 @@ async def async_main():
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_attribute_string_to_permissions():
|
||||
assert Attribute.string_to_permissions('READABLE') == 1
|
||||
assert Attribute.string_to_permissions('WRITEABLE') == 2
|
||||
assert Attribute.string_to_permissions('READABLE,WRITEABLE') == 3
|
||||
def test_permissions_from_string():
|
||||
assert Attribute.Permissions.from_string('READABLE') == 1
|
||||
assert Attribute.Permissions.from_string('WRITEABLE') == 2
|
||||
assert Attribute.Permissions.from_string('READABLE,WRITEABLE') == 3
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
100
tests/hfp_test.py
Normal file
100
tests/hfp_test.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# Copyright 2021-2022 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from .test_utils import TwoDevices
|
||||
from bumble import hfp
|
||||
from bumble import rfcomm
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def make_hfp_connections(
|
||||
hf_config: hfp.Configuration,
|
||||
) -> Tuple[hfp.HfProtocol, hfp.HfpProtocol]:
|
||||
# Setup devices
|
||||
devices = TwoDevices()
|
||||
await devices.setup_connection()
|
||||
|
||||
# Setup RFCOMM channel
|
||||
wait_dlc = asyncio.get_running_loop().create_future()
|
||||
rfcomm_channel = rfcomm.Server(devices.devices[0]).listen(
|
||||
lambda dlc: wait_dlc.set_result(dlc)
|
||||
)
|
||||
assert devices.connections[0]
|
||||
assert devices.connections[1]
|
||||
client_mux = await rfcomm.Client(devices.devices[1], devices.connections[1]).start()
|
||||
|
||||
client_dlc = await client_mux.open_dlc(rfcomm_channel)
|
||||
server_dlc = await wait_dlc
|
||||
|
||||
# Setup HFP connection
|
||||
hf = hfp.HfProtocol(client_dlc, hf_config)
|
||||
ag = hfp.HfpProtocol(server_dlc)
|
||||
return hf, ag
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slc():
|
||||
hf_config = hfp.Configuration(
|
||||
supported_hf_features=[], supported_hf_indicators=[], supported_audio_codecs=[]
|
||||
)
|
||||
hf, ag = await make_hfp_connections(hf_config)
|
||||
|
||||
async def ag_loop():
|
||||
while line := await ag.next_line():
|
||||
if line.startswith('AT+BRSF'):
|
||||
ag.send_response_line('+BRSF: 0')
|
||||
elif line.startswith('AT+CIND=?'):
|
||||
ag.send_response_line(
|
||||
'+CIND: ("call",(0,1)),("callsetup",(0-3)),("service",(0-1)),'
|
||||
'("signal",(0-5)),("roam",(0,1)),("battchg",(0-5)),'
|
||||
'("callheld",(0-2))'
|
||||
)
|
||||
elif line.startswith('AT+CIND?'):
|
||||
ag.send_response_line('+CIND: 0,0,1,4,1,5,0')
|
||||
ag.send_response_line('OK')
|
||||
|
||||
ag_task = asyncio.create_task(ag_loop())
|
||||
|
||||
await hf.initiate_slc()
|
||||
ag_task.cancel()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def run():
|
||||
await test_slc()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
asyncio.run(run())
|
||||
@@ -18,6 +18,8 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import pathlib
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
@@ -83,87 +85,95 @@ JSON3 = """
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def test_basic():
|
||||
with tempfile.NamedTemporaryFile(mode="r+", encoding='utf-8') as file:
|
||||
keystore = JsonKeyStore('my_namespace', file.name)
|
||||
@pytest.fixture
|
||||
def temporary_file():
|
||||
file = tempfile.NamedTemporaryFile(delete=False)
|
||||
file.close()
|
||||
yield file.name
|
||||
pathlib.Path(file.name).unlink()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def test_basic(temporary_file):
|
||||
with open(temporary_file, mode='w', encoding='utf-8') as file:
|
||||
file.write("{}")
|
||||
file.flush()
|
||||
|
||||
keys = await keystore.get_all()
|
||||
assert len(keys) == 0
|
||||
keystore = JsonKeyStore('my_namespace', temporary_file)
|
||||
|
||||
keys = PairingKeys()
|
||||
await keystore.update('foo', keys)
|
||||
foo = await keystore.get('foo')
|
||||
assert foo is not None
|
||||
assert foo.ltk is None
|
||||
ltk = bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
|
||||
keys.ltk = PairingKeys.Key(ltk)
|
||||
await keystore.update('foo', keys)
|
||||
foo = await keystore.get('foo')
|
||||
assert foo is not None
|
||||
assert foo.ltk is not None
|
||||
assert foo.ltk.value == ltk
|
||||
keys = await keystore.get_all()
|
||||
assert len(keys) == 0
|
||||
|
||||
file.flush()
|
||||
with open(file.name, "r", encoding="utf-8") as json_file:
|
||||
json_data = json.load(json_file)
|
||||
assert 'my_namespace' in json_data
|
||||
assert 'foo' in json_data['my_namespace']
|
||||
assert 'ltk' in json_data['my_namespace']['foo']
|
||||
keys = PairingKeys()
|
||||
await keystore.update('foo', keys)
|
||||
foo = await keystore.get('foo')
|
||||
assert foo is not None
|
||||
assert foo.ltk is None
|
||||
ltk = bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
|
||||
keys.ltk = PairingKeys.Key(ltk)
|
||||
await keystore.update('foo', keys)
|
||||
foo = await keystore.get('foo')
|
||||
assert foo is not None
|
||||
assert foo.ltk is not None
|
||||
assert foo.ltk.value == ltk
|
||||
|
||||
with open(file.name, "r", encoding="utf-8") as json_file:
|
||||
json_data = json.load(json_file)
|
||||
assert 'my_namespace' in json_data
|
||||
assert 'foo' in json_data['my_namespace']
|
||||
assert 'ltk' in json_data['my_namespace']['foo']
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def test_parsing():
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
|
||||
keystore = JsonKeyStore('my_namespace', file.name)
|
||||
async def test_parsing(temporary_file):
|
||||
with open(temporary_file, mode='w', encoding='utf-8') as file:
|
||||
file.write(JSON1)
|
||||
file.flush()
|
||||
|
||||
foo = await keystore.get('14:7D:DA:4E:53:A8/P')
|
||||
assert foo is not None
|
||||
assert foo.ltk.value == bytes.fromhex('d1897ee10016eb1a08e4e037fd54c683')
|
||||
keystore = JsonKeyStore('my_namespace', file.name)
|
||||
foo = await keystore.get('14:7D:DA:4E:53:A8/P')
|
||||
assert foo is not None
|
||||
assert foo.ltk.value == bytes.fromhex('d1897ee10016eb1a08e4e037fd54c683')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def test_default_namespace():
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
|
||||
keystore = JsonKeyStore(None, file.name)
|
||||
async def test_default_namespace(temporary_file):
|
||||
with open(temporary_file, mode='w', encoding='utf-8') as file:
|
||||
file.write(JSON1)
|
||||
file.flush()
|
||||
|
||||
all_keys = await keystore.get_all()
|
||||
assert len(all_keys) == 1
|
||||
name, keys = all_keys[0]
|
||||
assert name == '14:7D:DA:4E:53:A8/P'
|
||||
assert keys.irk.value == bytes.fromhex('e7b2543b206e4e46b44f9e51dad22bd1')
|
||||
keystore = JsonKeyStore(None, file.name)
|
||||
all_keys = await keystore.get_all()
|
||||
assert len(all_keys) == 1
|
||||
name, keys = all_keys[0]
|
||||
assert name == '14:7D:DA:4E:53:A8/P'
|
||||
assert keys.irk.value == bytes.fromhex('e7b2543b206e4e46b44f9e51dad22bd1')
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
|
||||
keystore = JsonKeyStore(None, file.name)
|
||||
with open(temporary_file, mode='w', encoding='utf-8') as file:
|
||||
file.write(JSON2)
|
||||
file.flush()
|
||||
|
||||
keys = PairingKeys()
|
||||
ltk = bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
|
||||
keys.ltk = PairingKeys.Key(ltk)
|
||||
await keystore.update('foo', keys)
|
||||
file.flush()
|
||||
with open(file.name, "r", encoding="utf-8") as json_file:
|
||||
json_data = json.load(json_file)
|
||||
assert '__DEFAULT__' in json_data
|
||||
assert 'foo' in json_data['__DEFAULT__']
|
||||
assert 'ltk' in json_data['__DEFAULT__']['foo']
|
||||
keystore = JsonKeyStore(None, file.name)
|
||||
keys = PairingKeys()
|
||||
ltk = bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
|
||||
keys.ltk = PairingKeys.Key(ltk)
|
||||
await keystore.update('foo', keys)
|
||||
with open(file.name, "r", encoding="utf-8") as json_file:
|
||||
json_data = json.load(json_file)
|
||||
assert '__DEFAULT__' in json_data
|
||||
assert 'foo' in json_data['__DEFAULT__']
|
||||
assert 'ltk' in json_data['__DEFAULT__']['foo']
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
|
||||
keystore = JsonKeyStore(None, file.name)
|
||||
with open(temporary_file, mode='w', encoding='utf-8') as file:
|
||||
file.write(JSON3)
|
||||
file.flush()
|
||||
|
||||
all_keys = await keystore.get_all()
|
||||
assert len(all_keys) == 1
|
||||
name, keys = all_keys[0]
|
||||
assert name == '14:7D:DA:4E:53:A8/P'
|
||||
assert keys.irk.value == bytes.fromhex('e7b2543b206e4e46b44f9e51dad22bd1')
|
||||
keystore = JsonKeyStore(None, file.name)
|
||||
all_keys = await keystore.get_all()
|
||||
assert len(all_keys) == 1
|
||||
name, keys = all_keys[0]
|
||||
assert name == '14:7D:DA:4E:53:A8/P'
|
||||
assert keys.irk.value == bytes.fromhex('e7b2543b206e4e46b44f9e51dad22bd1')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
77
tests/utils_test.py
Normal file
77
tests/utils_test.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# Copyright 2021-2023 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
from bumble import utils
|
||||
from pyee import EventEmitter
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def test_on() -> None:
|
||||
emitter = EventEmitter()
|
||||
with contextlib.closing(utils.EventWatcher()) as context:
|
||||
mock = MagicMock()
|
||||
context.on(emitter, 'event', mock)
|
||||
|
||||
emitter.emit('event')
|
||||
|
||||
assert not emitter.listeners('event')
|
||||
assert mock.call_count == 1
|
||||
|
||||
|
||||
def test_on_decorator() -> None:
|
||||
emitter = EventEmitter()
|
||||
with contextlib.closing(utils.EventWatcher()) as context:
|
||||
mock = MagicMock()
|
||||
|
||||
@context.on(emitter, 'event')
|
||||
def on_event(*_) -> None:
|
||||
mock()
|
||||
|
||||
emitter.emit('event')
|
||||
|
||||
assert not emitter.listeners('event')
|
||||
assert mock.call_count == 1
|
||||
|
||||
|
||||
def test_multiple_handlers() -> None:
|
||||
emitter = EventEmitter()
|
||||
with contextlib.closing(utils.EventWatcher()) as context:
|
||||
mock = MagicMock()
|
||||
|
||||
context.once(emitter, 'a', mock)
|
||||
context.once(emitter, 'b', mock)
|
||||
|
||||
emitter.emit('b', 'b')
|
||||
|
||||
assert not emitter.listeners('a')
|
||||
assert not emitter.listeners('b')
|
||||
|
||||
mock.assert_called_once_with('b')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def run_tests():
|
||||
test_on()
|
||||
test_on_decorator()
|
||||
test_multiple_handlers()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
run_tests()
|
||||
@@ -74,7 +74,6 @@ export async function loadBumble(pyodide, bumblePackage) {
|
||||
await pyodide.loadPackage("micropip");
|
||||
await pyodide.runPythonAsync(`
|
||||
import micropip
|
||||
await micropip.install("cryptography")
|
||||
await micropip.install("${bumblePackage}")
|
||||
package_list = micropip.list()
|
||||
print(package_list)
|
||||
|
||||
@@ -23,7 +23,7 @@ from bumble.device import Device
|
||||
# -----------------------------------------------------------------------------
|
||||
class ScanEntry:
|
||||
def __init__(self, advertisement):
|
||||
self.address = str(advertisement.address).replace("/P", "")
|
||||
self.address = advertisement.address.to_string(False)
|
||||
self.address_type = ('Public', 'Random', 'Public Identity', 'Random Identity')[
|
||||
advertisement.address.address_type
|
||||
]
|
||||
|
||||
@@ -171,7 +171,7 @@ class Speaker:
|
||||
self.connection = connection
|
||||
connection.on('disconnection', self.on_bluetooth_disconnection)
|
||||
peer_name = '' if connection.peer_name is None else connection.peer_name
|
||||
peer_address = str(connection.peer_address).replace('/P', '')
|
||||
peer_address = connection.peer_address.to_string(False)
|
||||
self.emit_event(
|
||||
'connection', {'peer_name': peer_name, 'peer_address': peer_address}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user