forked from auracaster/bumble_mirror
Compare commits
33 Commits
v0.0.165
...
gbg/multi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2478d45673 | ||
|
|
1bc7d94111 | ||
|
|
6432414cd5 | ||
|
|
179064ba15 | ||
|
|
783b2d70a5 | ||
|
|
80824f3fc1 | ||
|
|
56139c622f | ||
|
|
da02f6a39b | ||
|
|
548d5597c0 | ||
|
|
7fd65d2412 | ||
|
|
05a54a4af9 | ||
|
|
1e00c8f456 | ||
|
|
90d165aa01 | ||
|
|
01603ca9e4 | ||
|
|
a1b6eb61f2 | ||
|
|
25f300d3ec | ||
|
|
41fe63df06 | ||
|
|
b312170d5f | ||
|
|
cf7f2e8f44 | ||
|
|
d292083ed1 | ||
|
|
9b11142b45 | ||
|
|
acdbc4d7b9 | ||
|
|
838d10a09d | ||
|
|
3852aa056b | ||
|
|
ae77e4528f | ||
|
|
9303f4fc5b | ||
|
|
8be9f4cb0e | ||
|
|
1ea12b1bf7 | ||
|
|
65e6d68355 | ||
|
|
9732eb8836 | ||
|
|
5ae668bc70 | ||
|
|
fd4d1bcca3 | ||
|
|
0e2fc80509 |
4
.github/workflows/code-check.yml
vendored
4
.github/workflows/code-check.yml
vendored
@@ -14,6 +14,10 @@ jobs:
|
|||||||
check:
|
check:
|
||||||
name: Check Code
|
name: Check Code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||||
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out from Git
|
- name: Check out from Git
|
||||||
|
|||||||
20
.github/workflows/python-build-test.yml
vendored
20
.github/workflows/python-build-test.yml
vendored
@@ -12,10 +12,10 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
||||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
||||||
@@ -41,11 +41,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
inv build
|
inv build
|
||||||
inv build.mkdocs
|
inv build.mkdocs
|
||||||
|
|
||||||
build-rust:
|
build-rust:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [ "3.8", "3.9", "3.10" ]
|
python-version: [ "3.8", "3.9", "3.10", "3.11" ]
|
||||||
|
rust-version: [ "1.70.0", "stable" ]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- name: Check out from Git
|
- name: Check out from Git
|
||||||
@@ -62,9 +64,15 @@ jobs:
|
|||||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
with:
|
with:
|
||||||
components: clippy,rustfmt
|
components: clippy,rustfmt
|
||||||
- name: Rust Lints
|
toolchain: ${{ matrix.rust-version }}
|
||||||
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings
|
|
||||||
- name: Rust Build
|
- name: Rust Build
|
||||||
run: cd rust && cargo build --all-targets
|
run: cd rust && cargo build --all-targets && cargo build --all-features --all-targets
|
||||||
|
# Lints after build so what clippy needs is already built
|
||||||
|
- name: Rust Lints
|
||||||
|
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings && cargo clippy --all-features --all-targets -- --deny warnings
|
||||||
- name: Rust Tests
|
- name: Rust Tests
|
||||||
run: cd rust && cargo test
|
run: cd rust && cargo test
|
||||||
|
# At some point, hook up publishing the binary. For now, just make sure it builds.
|
||||||
|
# Once we're ready to publish binaries, this should be built with `--release`.
|
||||||
|
- name: Build Bumble CLI
|
||||||
|
run: cd rust && cargo build --features bumble-tools --bin bumble
|
||||||
@@ -105,7 +105,7 @@ class ServerBridge:
|
|||||||
asyncio.create_task(self.pipe.l2cap_channel.disconnect())
|
asyncio.create_task(self.pipe.l2cap_channel.disconnect())
|
||||||
|
|
||||||
def data_received(self, data):
|
def data_received(self, data):
|
||||||
print(f'<<< Received on TCP: {len(data)}')
|
print(color(f'<<< [TCP DATA]: {len(data)} bytes', 'blue'))
|
||||||
self.pipe.l2cap_channel.write(data)
|
self.pipe.l2cap_channel.write(data)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -123,6 +123,7 @@ class ServerBridge:
|
|||||||
await self.l2cap_channel.disconnect()
|
await self.l2cap_channel.disconnect()
|
||||||
|
|
||||||
def on_l2cap_close(self):
|
def on_l2cap_close(self):
|
||||||
|
print(color('*** L2CAP channel closed', 'red'))
|
||||||
self.l2cap_channel = None
|
self.l2cap_channel = None
|
||||||
if self.tcp_transport is not None:
|
if self.tcp_transport is not None:
|
||||||
self.tcp_transport.close()
|
self.tcp_transport.close()
|
||||||
|
|||||||
15
apps/show.py
15
apps/show.py
@@ -102,9 +102,21 @@ class SnoopPacketReader:
|
|||||||
default='h4',
|
default='h4',
|
||||||
help='Format of the input file',
|
help='Format of the input file',
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
'--vendors',
|
||||||
|
type=click.Choice(['android', 'zephyr']),
|
||||||
|
multiple=True,
|
||||||
|
help='Support vendor-specific commands (list one or more)',
|
||||||
|
)
|
||||||
@click.argument('filename')
|
@click.argument('filename')
|
||||||
# pylint: disable=redefined-builtin
|
# pylint: disable=redefined-builtin
|
||||||
def main(format, filename):
|
def main(format, vendors, filename):
|
||||||
|
for vendor in vendors:
|
||||||
|
if vendor == 'android':
|
||||||
|
import bumble.vendor.android.hci
|
||||||
|
elif vendor == 'zephyr':
|
||||||
|
import bumble.vendor.zephyr.hci
|
||||||
|
|
||||||
input = open(filename, 'rb')
|
input = open(filename, 'rb')
|
||||||
if format == 'h4':
|
if format == 'h4':
|
||||||
packet_reader = PacketReader(input)
|
packet_reader = PacketReader(input)
|
||||||
@@ -124,7 +136,6 @@ def main(format, filename):
|
|||||||
if packet is None:
|
if packet is None:
|
||||||
break
|
break
|
||||||
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
|
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
|
||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
print(color(f'!!! {error}', 'red'))
|
print(color(f'!!! {error}', 'red'))
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ body, h1, h2, h3, h4, h5, h6 {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
margin: 6px;
|
margin: 6px;
|
||||||
margin-left: 0px;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
th, td {
|
||||||
@@ -65,7 +65,7 @@ th, td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.properties td:nth-child(even) {
|
.properties td:nth-child(even) {
|
||||||
background-color: #D6EEEE;
|
background-color: #d6eeee;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Bumble Speaker</title>
|
<title>Bumble Speaker</title>
|
||||||
<script type="text/javascript" src="speaker.js"></script>
|
<script src="speaker.js"></script>
|
||||||
<link rel="stylesheet" href="speaker.css">
|
<link rel="stylesheet" href="speaker.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import itertools
|
import itertools
|
||||||
@@ -58,8 +60,10 @@ from bumble.hci import (
|
|||||||
HCI_Packet,
|
HCI_Packet,
|
||||||
HCI_Role_Change_Event,
|
HCI_Role_Change_Event,
|
||||||
)
|
)
|
||||||
from typing import Optional, Union, Dict
|
from typing import Optional, Union, Dict, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble.transport.common import TransportSink, TransportSource
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -104,7 +108,7 @@ class Controller:
|
|||||||
self,
|
self,
|
||||||
name,
|
name,
|
||||||
host_source=None,
|
host_source=None,
|
||||||
host_sink=None,
|
host_sink: Optional[TransportSink] = None,
|
||||||
link=None,
|
link=None,
|
||||||
public_address: Optional[Union[bytes, str, Address]] = None,
|
public_address: Optional[Union[bytes, str, Address]] = None,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -78,7 +78,13 @@ def get_dict_key_by_value(dictionary, value):
|
|||||||
class BaseError(Exception):
|
class BaseError(Exception):
|
||||||
"""Base class for errors with an error code, error name and namespace"""
|
"""Base class for errors with an error code, error name and namespace"""
|
||||||
|
|
||||||
def __init__(self, error_code, error_namespace='', error_name='', details=''):
|
def __init__(
|
||||||
|
self,
|
||||||
|
error_code: int | None,
|
||||||
|
error_namespace: str = '',
|
||||||
|
error_name: str = '',
|
||||||
|
details: str = '',
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.error_code = error_code
|
self.error_code = error_code
|
||||||
self.error_namespace = error_namespace
|
self.error_namespace = error_namespace
|
||||||
@@ -90,12 +96,14 @@ class BaseError(Exception):
|
|||||||
namespace = f'{self.error_namespace}/'
|
namespace = f'{self.error_namespace}/'
|
||||||
else:
|
else:
|
||||||
namespace = ''
|
namespace = ''
|
||||||
if self.error_name:
|
error_text = {
|
||||||
name = f'{self.error_name} [0x{self.error_code:X}]'
|
(True, True): f'{self.error_name} [0x{self.error_code:X}]',
|
||||||
else:
|
(True, False): self.error_name,
|
||||||
name = f'0x{self.error_code:X}'
|
(False, True): f'0x{self.error_code:X}',
|
||||||
|
(False, False): '',
|
||||||
|
}[(self.error_name != '', self.error_code is not None)]
|
||||||
|
|
||||||
return f'{type(self).__name__}({namespace}{name})'
|
return f'{type(self).__name__}({namespace}{error_text})'
|
||||||
|
|
||||||
|
|
||||||
class ProtocolError(BaseError):
|
class ProtocolError(BaseError):
|
||||||
@@ -134,6 +142,10 @@ class ConnectionError(BaseError): # pylint: disable=redefined-builtin
|
|||||||
self.peer_address = peer_address
|
self.peer_address = peer_address
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionParameterUpdateError(BaseError):
|
||||||
|
"""Connection Parameter Update Error"""
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# UUID
|
# UUID
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -23,7 +23,18 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager, AsyncExitStack
|
from contextlib import asynccontextmanager, AsyncExitStack
|
||||||
from dataclasses import dataclass
|
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 .colors import color
|
||||||
from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
|
from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
|
||||||
@@ -130,6 +141,7 @@ from .core import (
|
|||||||
BT_LE_TRANSPORT,
|
BT_LE_TRANSPORT,
|
||||||
BT_PERIPHERAL_ROLE,
|
BT_PERIPHERAL_ROLE,
|
||||||
AdvertisingData,
|
AdvertisingData,
|
||||||
|
ConnectionParameterUpdateError,
|
||||||
CommandTimeoutError,
|
CommandTimeoutError,
|
||||||
ConnectionPHY,
|
ConnectionPHY,
|
||||||
InvalidStateError,
|
InvalidStateError,
|
||||||
@@ -152,6 +164,9 @@ from . import sdp
|
|||||||
from . import l2cap
|
from . import l2cap
|
||||||
from . import core
|
from . import core
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .transport.common import TransportSource, TransportSink
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -709,6 +724,7 @@ class Connection(CompositeEventEmitter):
|
|||||||
connection_interval_max,
|
connection_interval_max,
|
||||||
max_latency,
|
max_latency,
|
||||||
supervision_timeout,
|
supervision_timeout,
|
||||||
|
use_l2cap=False,
|
||||||
):
|
):
|
||||||
return await self.device.update_connection_parameters(
|
return await self.device.update_connection_parameters(
|
||||||
self,
|
self,
|
||||||
@@ -716,6 +732,7 @@ class Connection(CompositeEventEmitter):
|
|||||||
connection_interval_max,
|
connection_interval_max,
|
||||||
max_latency,
|
max_latency,
|
||||||
supervision_timeout,
|
supervision_timeout,
|
||||||
|
use_l2cap=use_l2cap,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_phy(self, tx_phys=None, rx_phys=None, phy_options=None):
|
async def set_phy(self, tx_phys=None, rx_phys=None, phy_options=None):
|
||||||
@@ -942,7 +959,13 @@ class Device(CompositeEventEmitter):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@classmethod
|
@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
|
Create a Device instance with a Host configured to communicate with a controller
|
||||||
through an HCI source/sink
|
through an HCI source/sink
|
||||||
@@ -951,18 +974,25 @@ class Device(CompositeEventEmitter):
|
|||||||
return cls(name=name, address=address, host=host)
|
return cls(name=name, address=address, host=host)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config_file(cls, filename):
|
def from_config_file(cls, filename: str) -> Device:
|
||||||
config = DeviceConfiguration()
|
config = DeviceConfiguration()
|
||||||
config.load_from_file(filename)
|
config.load_from_file(filename)
|
||||||
return cls(config=config)
|
return cls(config=config)
|
||||||
|
|
||||||
@classmethod
|
@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)
|
host = Host(controller_source=hci_source, controller_sink=hci_sink)
|
||||||
return cls(config=config, host=host)
|
return cls(config=config, host=host)
|
||||||
|
|
||||||
@classmethod
|
@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 = DeviceConfiguration()
|
||||||
config.load_from_file(filename)
|
config.load_from_file(filename)
|
||||||
return cls.from_config_with_hci(config, hci_source, hci_sink)
|
return cls.from_config_with_hci(config, hci_source, hci_sink)
|
||||||
@@ -2083,11 +2113,30 @@ class Device(CompositeEventEmitter):
|
|||||||
supervision_timeout,
|
supervision_timeout,
|
||||||
min_ce_length=0,
|
min_ce_length=0,
|
||||||
max_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
|
NOTE: the name of the parameters may look odd, but it just follows the names
|
||||||
used in the Bluetooth spec.
|
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(
|
result = await self.send_command(
|
||||||
HCI_LE_Connection_Update_Command(
|
HCI_LE_Connection_Update_Command(
|
||||||
connection_handle=connection.handle,
|
connection_handle=connection.handle,
|
||||||
@@ -2097,7 +2146,7 @@ class Device(CompositeEventEmitter):
|
|||||||
supervision_timeout=supervision_timeout,
|
supervision_timeout=supervision_timeout,
|
||||||
min_ce_length=min_ce_length,
|
min_ce_length=min_ce_length,
|
||||||
max_ce_length=max_ce_length,
|
max_ce_length=max_ce_length,
|
||||||
)
|
) # type: ignore[call-arg]
|
||||||
)
|
)
|
||||||
if result.status != HCI_Command_Status_Event.PENDING:
|
if result.status != HCI_Command_Status_Event.PENDING:
|
||||||
raise HCI_StatusError(result)
|
raise HCI_StatusError(result)
|
||||||
@@ -2238,9 +2287,11 @@ class Device(CompositeEventEmitter):
|
|||||||
def request_pairing(self, connection):
|
def request_pairing(self, connection):
|
||||||
return self.smp_manager.request_pairing(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:
|
if (connection := self.lookup_connection(connection_handle)) is None:
|
||||||
return
|
return None
|
||||||
|
|
||||||
# Start by looking for the key in an SMP session
|
# Start by looking for the key in an SMP session
|
||||||
ltk = self.smp_manager.get_long_term_key(connection, rand, ediv)
|
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:
|
if connection.role == BT_PERIPHERAL_ROLE and keys.ltk_peripheral:
|
||||||
return keys.ltk_peripheral.value
|
return keys.ltk_peripheral.value
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_link_key(self, address: Address) -> Optional[bytes]:
|
async def get_link_key(self, address: Address) -> Optional[bytes]:
|
||||||
if self.keystore is None:
|
if self.keystore is None:
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ like loading firmware after a cold start.
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import abc
|
import abc
|
||||||
import logging
|
import logging
|
||||||
|
import pathlib
|
||||||
|
import platform
|
||||||
from . import rtk
|
from . import rtk
|
||||||
|
|
||||||
|
|
||||||
@@ -66,3 +68,24 @@ async def get_driver_for_host(host):
|
|||||||
return driver
|
return driver
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def project_data_dir() -> pathlib.Path:
|
||||||
|
"""
|
||||||
|
Returns:
|
||||||
|
A path to an OS-specific directory for bumble data. The directory is created if
|
||||||
|
it doesn't exist.
|
||||||
|
"""
|
||||||
|
import platformdirs
|
||||||
|
|
||||||
|
if platform.system() == 'Darwin':
|
||||||
|
# platformdirs doesn't handle macOS right: it doesn't assemble a bundle id
|
||||||
|
# out of author & project
|
||||||
|
return platformdirs.user_data_path(
|
||||||
|
appname='com.google.bumble', ensure_exists=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# windows and linux don't use the com qualifier
|
||||||
|
return platformdirs.user_data_path(
|
||||||
|
appname='bumble', appauthor='google', ensure_exists=True
|
||||||
|
)
|
||||||
|
|||||||
@@ -34,10 +34,9 @@ import weakref
|
|||||||
|
|
||||||
|
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
hci_command_op_code,
|
hci_vendor_command_op_code,
|
||||||
STATUS_SPEC,
|
STATUS_SPEC,
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
HCI_COMMAND_NAMES,
|
|
||||||
HCI_Command,
|
HCI_Command,
|
||||||
HCI_Reset_Command,
|
HCI_Reset_Command,
|
||||||
HCI_Read_Local_Version_Information_Command,
|
HCI_Read_Local_Version_Information_Command,
|
||||||
@@ -179,8 +178,10 @@ RTK_USB_PRODUCTS = {
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# HCI Commands
|
# HCI Commands
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
HCI_RTK_READ_ROM_VERSION_COMMAND = hci_command_op_code(0x3F, 0x6D)
|
HCI_RTK_READ_ROM_VERSION_COMMAND = hci_vendor_command_op_code(0x6D)
|
||||||
HCI_COMMAND_NAMES[HCI_RTK_READ_ROM_VERSION_COMMAND] = "HCI_RTK_READ_ROM_VERSION_COMMAND"
|
HCI_RTK_DOWNLOAD_COMMAND = hci_vendor_command_op_code(0x20)
|
||||||
|
HCI_RTK_DROP_FIRMWARE_COMMAND = hci_vendor_command_op_code(0x66)
|
||||||
|
HCI_Command.register_commands(globals())
|
||||||
|
|
||||||
|
|
||||||
@HCI_Command.command(return_parameters_fields=[("status", STATUS_SPEC), ("version", 1)])
|
@HCI_Command.command(return_parameters_fields=[("status", STATUS_SPEC), ("version", 1)])
|
||||||
@@ -188,10 +189,6 @@ class HCI_RTK_Read_ROM_Version_Command(HCI_Command):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
HCI_RTK_DOWNLOAD_COMMAND = hci_command_op_code(0x3F, 0x20)
|
|
||||||
HCI_COMMAND_NAMES[HCI_RTK_DOWNLOAD_COMMAND] = "HCI_RTK_DOWNLOAD_COMMAND"
|
|
||||||
|
|
||||||
|
|
||||||
@HCI_Command.command(
|
@HCI_Command.command(
|
||||||
fields=[("index", 1), ("payload", RTK_FRAGMENT_LENGTH)],
|
fields=[("index", 1), ("payload", RTK_FRAGMENT_LENGTH)],
|
||||||
return_parameters_fields=[("status", STATUS_SPEC), ("index", 1)],
|
return_parameters_fields=[("status", STATUS_SPEC), ("index", 1)],
|
||||||
@@ -200,10 +197,6 @@ class HCI_RTK_Download_Command(HCI_Command):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
HCI_RTK_DROP_FIRMWARE_COMMAND = hci_command_op_code(0x3F, 0x66)
|
|
||||||
HCI_COMMAND_NAMES[HCI_RTK_DROP_FIRMWARE_COMMAND] = "HCI_RTK_DROP_FIRMWARE_COMMAND"
|
|
||||||
|
|
||||||
|
|
||||||
@HCI_Command.command()
|
@HCI_Command.command()
|
||||||
class HCI_RTK_Drop_Firmware_Command(HCI_Command):
|
class HCI_RTK_Drop_Firmware_Command(HCI_Command):
|
||||||
pass
|
pass
|
||||||
@@ -446,6 +439,11 @@ class Driver:
|
|||||||
# When the environment variable is set, don't look elsewhere
|
# When the environment variable is set, don't look elsewhere
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Then, look where the firmware download tool writes by default
|
||||||
|
if (path := rtk_firmware_dir() / file_name).is_file():
|
||||||
|
logger.debug(f"{file_name} found in project data dir")
|
||||||
|
return path
|
||||||
|
|
||||||
# Then, look in the package's driver directory
|
# Then, look in the package's driver directory
|
||||||
if (path := pathlib.Path(__file__).parent / "rtk_fw" / file_name).is_file():
|
if (path := pathlib.Path(__file__).parent / "rtk_fw" / file_name).is_file():
|
||||||
logger.debug(f"{file_name} found in package dir")
|
logger.debug(f"{file_name} found in package dir")
|
||||||
@@ -646,3 +644,16 @@ class Driver:
|
|||||||
await self.download_firmware()
|
await self.download_firmware()
|
||||||
await self.host.send_command(HCI_Reset_Command(), check_result=True)
|
await self.host.send_command(HCI_Reset_Command(), check_result=True)
|
||||||
logger.info(f"loaded FW image {self.driver_info.fw_name}")
|
logger.info(f"loaded FW image {self.driver_info.fw_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def rtk_firmware_dir() -> pathlib.Path:
|
||||||
|
"""
|
||||||
|
Returns:
|
||||||
|
A path to a subdir of the project data dir for Realtek firmware.
|
||||||
|
The directory is created if it doesn't exist.
|
||||||
|
"""
|
||||||
|
from bumble.drivers import project_data_dir
|
||||||
|
|
||||||
|
p = project_data_dir() / "firmware" / "realtek"
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
|||||||
201
bumble/hci.py
201
bumble/hci.py
@@ -16,11 +16,11 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import struct
|
|
||||||
import collections
|
import collections
|
||||||
import logging
|
|
||||||
import functools
|
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 .colors import color
|
||||||
from .core import (
|
from .core import (
|
||||||
@@ -47,6 +47,10 @@ def hci_command_op_code(ogf, ocf):
|
|||||||
return ogf << 10 | 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):
|
def key_with_value(dictionary, target_value):
|
||||||
for key, value in dictionary.items():
|
for key, value in dictionary.items():
|
||||||
if value == target_value:
|
if value == target_value:
|
||||||
@@ -101,6 +105,8 @@ def phy_list_to_bits(phys):
|
|||||||
# fmt: off
|
# fmt: off
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
|
|
||||||
|
HCI_VENDOR_OGF = 0x3F
|
||||||
|
|
||||||
# HCI Version
|
# HCI Version
|
||||||
HCI_VERSION_BLUETOOTH_CORE_1_0B = 0
|
HCI_VERSION_BLUETOOTH_CORE_1_0B = 0
|
||||||
HCI_VERSION_BLUETOOTH_CORE_1_1 = 1
|
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_AUTHENTICATED_PAYLOAD_TIMEOUT_EXPIRED_EVENT = 0X57
|
||||||
HCI_SAM_STATUS_CHANGE_EVENT = 0X58
|
HCI_SAM_STATUS_CHANGE_EVENT = 0X58
|
||||||
|
|
||||||
HCI_EVENT_NAMES = {
|
HCI_VENDOR_EVENT = 0xFF
|
||||||
event_code: event_name for (event_name, event_code) in globals().items()
|
|
||||||
if event_name.startswith('HCI_') and event_name.endswith('_EVENT')
|
|
||||||
}
|
|
||||||
|
|
||||||
# HCI Subevent Codes
|
# HCI Subevent Codes
|
||||||
HCI_LE_CONNECTION_COMPLETE_EVENT = 0x01
|
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_BIGINFO_ADVERTISING_REPORT_EVENT = 0X22
|
||||||
HCI_LE_SUBRATE_CHANGE_EVENT = 0X23
|
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 Command
|
||||||
HCI_INQUIRY_COMMAND = hci_command_op_code(0x01, 0x0001)
|
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_SET_DEFAULT_SUBRATE_COMMAND = hci_command_op_code(0x08, 0x007D)
|
||||||
HCI_LE_SUBRATE_REQUEST_COMMAND = hci_command_op_code(0x08, 0x007E)
|
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
|
# HCI Error Codes
|
||||||
# See Bluetooth spec Vol 2, Part D - 1.3 LIST OF 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
|
hci_packet_type = HCI_COMMAND_PACKET
|
||||||
|
command_names: Dict[int, str] = {}
|
||||||
command_classes: Dict[int, Type[HCI_Command]] = {}
|
command_classes: Dict[int, Type[HCI_Command]] = {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1970,9 +1967,9 @@ class HCI_Command(HCI_Packet):
|
|||||||
|
|
||||||
def inner(cls):
|
def inner(cls):
|
||||||
cls.name = cls.__name__.upper()
|
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:
|
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.fields = fields
|
||||||
cls.return_parameters_fields = return_parameters_fields
|
cls.return_parameters_fields = return_parameters_fields
|
||||||
|
|
||||||
@@ -1991,6 +1988,18 @@ class HCI_Command(HCI_Packet):
|
|||||||
|
|
||||||
return inner
|
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
|
@staticmethod
|
||||||
def from_bytes(packet: bytes) -> HCI_Command:
|
def from_bytes(packet: bytes) -> HCI_Command:
|
||||||
op_code, length = struct.unpack_from('<HB', packet, 1)
|
op_code, length = struct.unpack_from('<HB', packet, 1)
|
||||||
@@ -2015,7 +2024,7 @@ class HCI_Command(HCI_Packet):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def command_name(op_code):
|
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:
|
if name is not None:
|
||||||
return name
|
return name
|
||||||
return f'[OGF=0x{op_code >> 10:02x}, OCF=0x{op_code & 0x3FF:04x}]'
|
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):
|
def create_return_parameters(cls, **kwargs):
|
||||||
return HCI_Object(cls.return_parameters_fields, **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):
|
def __init__(self, op_code, parameters=None, **kwargs):
|
||||||
super().__init__(HCI_Command.command_name(op_code))
|
super().__init__(HCI_Command.command_name(op_code))
|
||||||
if (fields := getattr(self, 'fields', None)) and kwargs:
|
if (fields := getattr(self, 'fields', None)) and kwargs:
|
||||||
@@ -2053,6 +2072,9 @@ class HCI_Command(HCI_Packet):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
HCI_Command.register_commands(globals())
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command(
|
@HCI_Command.command(
|
||||||
[
|
[
|
||||||
@@ -4308,8 +4330,8 @@ class HCI_Event(HCI_Packet):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
hci_packet_type = HCI_EVENT_PACKET
|
hci_packet_type = HCI_EVENT_PACKET
|
||||||
|
event_names: Dict[int, str] = {}
|
||||||
event_classes: Dict[int, Type[HCI_Event]] = {}
|
event_classes: Dict[int, Type[HCI_Event]] = {}
|
||||||
meta_event_classes: Dict[int, Type[HCI_LE_Meta_Event]] = {}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def event(fields=()):
|
def event(fields=()):
|
||||||
@@ -4319,9 +4341,9 @@ class HCI_Event(HCI_Packet):
|
|||||||
|
|
||||||
def inner(cls):
|
def inner(cls):
|
||||||
cls.name = cls.__name__.upper()
|
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:
|
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
|
cls.fields = fields
|
||||||
|
|
||||||
# Patch the __init__ method to fix the event_code
|
# Patch the __init__ method to fix the event_code
|
||||||
@@ -4337,12 +4359,30 @@ class HCI_Event(HCI_Packet):
|
|||||||
|
|
||||||
return inner
|
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
|
@staticmethod
|
||||||
def registered(event_class):
|
def registered(event_class):
|
||||||
event_class.name = event_class.__name__.upper()
|
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:
|
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
|
# Register a factory for this class
|
||||||
HCI_Event.event_classes[event_class.event_code] = event_class
|
HCI_Event.event_classes[event_class.event_code] = event_class
|
||||||
@@ -4362,11 +4402,16 @@ class HCI_Event(HCI_Packet):
|
|||||||
# We do this dispatch here and not in the subclass in order to avoid call
|
# We do this dispatch here and not in the subclass in order to avoid call
|
||||||
# loops
|
# loops
|
||||||
subevent_code = parameters[0]
|
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:
|
if cls is None:
|
||||||
# No class registered, just use a generic class instance
|
# No class registered, just use a generic class instance
|
||||||
return HCI_LE_Meta_Event(subevent_code, parameters)
|
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:
|
else:
|
||||||
cls = HCI_Event.event_classes.get(event_code)
|
cls = HCI_Event.event_classes.get(event_code)
|
||||||
if cls is None:
|
if cls is None:
|
||||||
@@ -4384,10 +4429,6 @@ class HCI_Event(HCI_Packet):
|
|||||||
HCI_Object.init_from_bytes(self, parameters, 0, fields)
|
HCI_Object.init_from_bytes(self, parameters, 0, fields)
|
||||||
return self
|
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):
|
def __init__(self, event_code, parameters=None, **kwargs):
|
||||||
super().__init__(HCI_Event.event_name(event_code))
|
super().__init__(HCI_Event.event_name(event_code))
|
||||||
if (fields := getattr(self, 'fields', None)) and kwargs:
|
if (fields := getattr(self, 'fields', None)) and kwargs:
|
||||||
@@ -4414,71 +4455,111 @@ class HCI_Event(HCI_Packet):
|
|||||||
return result
|
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
|
subevent_names: Dict[int, str] = {}
|
||||||
def event(fields=()):
|
subevent_classes: Dict[int, Type[HCI_Extended_Event]]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def event(cls, fields=()):
|
||||||
'''
|
'''
|
||||||
Decorator used to declare and register subclasses
|
Decorator used to declare and register subclasses
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def inner(cls):
|
def inner(cls):
|
||||||
cls.name = cls.__name__.upper()
|
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:
|
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
|
cls.fields = fields
|
||||||
|
|
||||||
# Patch the __init__ method to fix the subevent_code
|
# Patch the __init__ method to fix the subevent_code
|
||||||
|
original_init = cls.__init__
|
||||||
|
|
||||||
def init(self, parameters=None, **kwargs):
|
def init(self, parameters=None, **kwargs):
|
||||||
return HCI_LE_Meta_Event.__init__(
|
return original_init(self, cls.subevent_code, parameters, **kwargs)
|
||||||
self, cls.subevent_code, parameters, **kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
cls.__init__ = init
|
cls.__init__ = init
|
||||||
|
|
||||||
# Register a factory for this class
|
# 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 cls
|
||||||
|
|
||||||
return inner
|
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
|
@classmethod
|
||||||
def from_parameters(cls, parameters):
|
def from_parameters(cls, parameters):
|
||||||
self = cls.__new__(cls)
|
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):
|
if fields := getattr(self, 'fields', None):
|
||||||
HCI_Object.init_from_bytes(self, parameters, 1, fields)
|
HCI_Object.init_from_bytes(self, parameters, 1, fields)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def subevent_name(subevent_code):
|
|
||||||
return name_or_number(HCI_SUBEVENT_NAMES, subevent_code)
|
|
||||||
|
|
||||||
def __init__(self, subevent_code, parameters, **kwargs):
|
def __init__(self, subevent_code, parameters, **kwargs):
|
||||||
self.subevent_code = subevent_code
|
self.subevent_code = subevent_code
|
||||||
if parameters is None and (fields := getattr(self, 'fields', None)) and kwargs:
|
if parameters is None and (fields := getattr(self, 'fields', None)) and kwargs:
|
||||||
parameters = bytes([subevent_code]) + HCI_Object.dict_to_bytes(
|
parameters = bytes([subevent_code]) + HCI_Object.dict_to_bytes(
|
||||||
kwargs, fields
|
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
|
# Override the name in order to adopt the subevent name instead
|
||||||
self.name = self.subevent_name(subevent_code)
|
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):
|
class HCI_LE_Meta_Event(HCI_Extended_Event):
|
||||||
result += ':\n' + HCI_Object.format_fields(self.__dict__, fields, ' ')
|
'''
|
||||||
else:
|
See Bluetooth spec @ 7.7.65 LE Meta Event
|
||||||
if self.parameters:
|
'''
|
||||||
result += f': {self.parameters.hex()}'
|
|
||||||
return result
|
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}'
|
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
|
||||||
] = 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}'
|
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
|
||||||
] = 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]
|
self.return_parameters = self.return_parameters[0]
|
||||||
else:
|
else:
|
||||||
cls = HCI_Command.command_classes.get(self.command_opcode)
|
cls = HCI_Command.command_classes.get(self.command_opcode)
|
||||||
if cls and cls.return_parameters_fields:
|
if cls:
|
||||||
self.return_parameters = HCI_Object.from_bytes(
|
# Try to parse the return parameters bytes into an object.
|
||||||
self.return_parameters, 0, cls.return_parameters_fields
|
return_parameters = cls.parse_return_parameters(self.return_parameters)
|
||||||
)
|
if return_parameters is not None:
|
||||||
self.return_parameters.fields = cls.return_parameters_fields
|
self.return_parameters = return_parameters
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,19 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
import collections.abc
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import traceback
|
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 at
|
||||||
from . import rfcomm
|
from . import rfcomm
|
||||||
|
|
||||||
|
from bumble.colors import color
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
BT_GENERIC_AUDIO_SERVICE,
|
BT_GENERIC_AUDIO_SERVICE,
|
||||||
@@ -48,6 +51,71 @@ from bumble.sdp import (
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Error
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class HfpProtocolError(ProtocolError):
|
||||||
|
def __init__(self, error_name: str = '', details: str = ''):
|
||||||
|
super().__init__(None, 'hfp', error_name, details)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Protocol Support
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
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
|
# Normative protocol definitions
|
||||||
@@ -302,6 +370,10 @@ class HfProtocol:
|
|||||||
|
|
||||||
dlc: rfcomm.DLC
|
dlc: rfcomm.DLC
|
||||||
command_lock: asyncio.Lock
|
command_lock: asyncio.Lock
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
response_queue: asyncio.Queue[AtResponse]
|
||||||
|
unsolicited_queue: asyncio.Queue[AtResponse]
|
||||||
|
else:
|
||||||
response_queue: asyncio.Queue
|
response_queue: asyncio.Queue
|
||||||
unsolicited_queue: asyncio.Queue
|
unsolicited_queue: asyncio.Queue
|
||||||
read_buffer: bytearray
|
read_buffer: bytearray
|
||||||
@@ -368,7 +440,7 @@ class HfProtocol:
|
|||||||
else:
|
else:
|
||||||
logger.warning(f"dropping unexpected response with code '{response.code}'")
|
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.
|
# Wait for the AT responses sent by the peer, to the status code.
|
||||||
# Raises asyncio.TimeoutError if the status is not received
|
# Raises asyncio.TimeoutError if the status is not received
|
||||||
# after a timeout (default 1 second).
|
# after a timeout (default 1 second).
|
||||||
@@ -390,7 +462,7 @@ class HfProtocol:
|
|||||||
)
|
)
|
||||||
if result.code == 'OK':
|
if result.code == 'OK':
|
||||||
if response_type == AtResponseType.SINGLE and len(responses) != 1:
|
if response_type == AtResponseType.SINGLE and len(responses) != 1:
|
||||||
raise ProtocolError("NO ANSWER")
|
raise HfpProtocolError("NO ANSWER")
|
||||||
|
|
||||||
if response_type == AtResponseType.MULTIPLE:
|
if response_type == AtResponseType.MULTIPLE:
|
||||||
return responses
|
return responses
|
||||||
@@ -398,7 +470,7 @@ class HfProtocol:
|
|||||||
return responses[0]
|
return responses[0]
|
||||||
return None
|
return None
|
||||||
if result.code in STATUS_CODES:
|
if result.code in STATUS_CODES:
|
||||||
raise ProtocolError(result.code)
|
raise HfpProtocolError(result.code)
|
||||||
responses.append(result)
|
responses.append(result)
|
||||||
|
|
||||||
# 4.2.1 Service Level Connection Initialization.
|
# 4.2.1 Service Level Connection Initialization.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import collections
|
|||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable
|
||||||
|
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.l2cap import L2CAP_PDU
|
from bumble.l2cap import L2CAP_PDU
|
||||||
@@ -73,10 +73,14 @@ from .core import (
|
|||||||
BT_LE_TRANSPORT,
|
BT_LE_TRANSPORT,
|
||||||
ConnectionPHY,
|
ConnectionPHY,
|
||||||
ConnectionParameters,
|
ConnectionParameters,
|
||||||
|
InvalidStateError,
|
||||||
)
|
)
|
||||||
from .utils import AbortableEventEmitter
|
from .utils import AbortableEventEmitter
|
||||||
from .transport.common import TransportLostError
|
from .transport.common import TransportLostError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .transport.common import TransportSink, TransportSource
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -116,10 +120,21 @@ class Connection:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Host(AbortableEventEmitter):
|
class Host(AbortableEventEmitter):
|
||||||
def __init__(self, controller_source=None, controller_sink=None):
|
connections: Dict[int, Connection]
|
||||||
|
acl_packet_queue: collections.deque[HCI_AclDataPacket]
|
||||||
|
hci_sink: TransportSink
|
||||||
|
long_term_key_provider: Optional[
|
||||||
|
Callable[[int, bytes, int], Awaitable[Optional[bytes]]]
|
||||||
|
]
|
||||||
|
link_key_provider: Optional[Callable[[Address], Awaitable[Optional[bytes]]]]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
controller_source: Optional[TransportSource] = None,
|
||||||
|
controller_sink: Optional[TransportSink] = None,
|
||||||
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.hci_sink = None
|
|
||||||
self.hci_metadata = None
|
self.hci_metadata = None
|
||||||
self.ready = False # True when we can accept incoming packets
|
self.ready = False # True when we can accept incoming packets
|
||||||
self.reset_done = False
|
self.reset_done = False
|
||||||
@@ -299,7 +314,7 @@ class Host(AbortableEventEmitter):
|
|||||||
self.reset_done = True
|
self.reset_done = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def controller(self):
|
def controller(self) -> TransportSink:
|
||||||
return self.hci_sink
|
return self.hci_sink
|
||||||
|
|
||||||
@controller.setter
|
@controller.setter
|
||||||
@@ -308,13 +323,12 @@ class Host(AbortableEventEmitter):
|
|||||||
if controller:
|
if controller:
|
||||||
controller.set_packet_sink(self)
|
controller.set_packet_sink(self)
|
||||||
|
|
||||||
def set_packet_sink(self, sink):
|
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||||
self.hci_sink = sink
|
self.hci_sink = sink
|
||||||
|
|
||||||
def send_hci_packet(self, packet: HCI_Packet) -> None:
|
def send_hci_packet(self, packet: HCI_Packet) -> None:
|
||||||
if self.snooper:
|
if self.snooper:
|
||||||
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
|
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
|
||||||
|
|
||||||
self.hci_sink.on_packet(bytes(packet))
|
self.hci_sink.on_packet(bytes(packet))
|
||||||
|
|
||||||
async def send_command(self, command, check_result=False):
|
async def send_command(self, command, check_result=False):
|
||||||
|
|||||||
@@ -1387,6 +1387,7 @@ class ChannelManager:
|
|||||||
le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request]
|
le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request]
|
||||||
fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]]
|
fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]]
|
||||||
_host: Optional[Host]
|
_host: Optional[Host]
|
||||||
|
connection_parameters_update_response: Optional[asyncio.Future[int]]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -1408,6 +1409,7 @@ class ChannelManager:
|
|||||||
self.le_coc_requests = {} # LE CoC connection requests, by identifier
|
self.le_coc_requests = {} # LE CoC connection requests, by identifier
|
||||||
self.extended_features = extended_features
|
self.extended_features = extended_features
|
||||||
self.connectionless_mtu = connectionless_mtu
|
self.connectionless_mtu = connectionless_mtu
|
||||||
|
self.connection_parameters_update_response = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def host(self) -> Host:
|
def host(self) -> Host:
|
||||||
@@ -1865,11 +1867,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(
|
def on_l2cap_connection_parameter_update_response(
|
||||||
self, connection: Connection, cid: int, response
|
self, connection: Connection, cid: int, response
|
||||||
) -> None:
|
) -> None:
|
||||||
# TODO: check response
|
if self.connection_parameters_update_response:
|
||||||
pass
|
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(
|
def on_l2cap_le_credit_based_connection_request(
|
||||||
self, connection: Connection, cid: int, request
|
self, connection: Connection, cid: int, request
|
||||||
@@ -2078,7 +2114,8 @@ class ChannelManager:
|
|||||||
# Connect
|
# Connect
|
||||||
try:
|
try:
|
||||||
await channel.connect()
|
await channel.connect()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
del connection_channels[source_cid]
|
del connection_channels[source_cid]
|
||||||
|
raise e
|
||||||
|
|
||||||
return channel
|
return channel
|
||||||
|
|||||||
@@ -20,13 +20,29 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import enum
|
import enum
|
||||||
|
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
||||||
|
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
from typing import Optional, Tuple, Callable, Dict, Union, TYPE_CHECKING
|
|
||||||
|
|
||||||
from . import core, l2cap
|
from . import core, l2cap
|
||||||
from .colors import color
|
from .colors import color
|
||||||
from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError
|
from .core import (
|
||||||
|
UUID,
|
||||||
|
BT_RFCOMM_PROTOCOL_ID,
|
||||||
|
BT_BR_EDR_TRANSPORT,
|
||||||
|
BT_L2CAP_PROTOCOL_ID,
|
||||||
|
InvalidStateError,
|
||||||
|
ProtocolError,
|
||||||
|
)
|
||||||
|
from .sdp import (
|
||||||
|
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||||
|
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_PUBLIC_BROWSE_ROOT,
|
||||||
|
DataElement,
|
||||||
|
ServiceAttribute,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bumble.device import Device, Connection
|
from bumble.device import Device, Connection
|
||||||
@@ -111,6 +127,50 @@ RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
|||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def make_service_sdp_records(
|
||||||
|
service_record_handle: int, channel: int, uuid: Optional[UUID] = None
|
||||||
|
) -> List[ServiceAttribute]:
|
||||||
|
"""
|
||||||
|
Create SDP records for an RFComm service given a channel number and an
|
||||||
|
optional UUID. A Service Class Attribute is included only if the UUID is not None.
|
||||||
|
"""
|
||||||
|
records = [
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||||
|
DataElement.unsigned_integer_32(service_record_handle),
|
||||||
|
),
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||||
|
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
|
||||||
|
),
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
|
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
|
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
|
||||||
|
DataElement.unsigned_integer_8(channel),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
if uuid:
|
||||||
|
records.append(
|
||||||
|
ServiceAttribute(
|
||||||
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
|
DataElement.sequence([DataElement.uuid(uuid)]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def compute_fcs(buffer: bytes) -> int:
|
def compute_fcs(buffer: bytes) -> int:
|
||||||
result = 0xFF
|
result = 0xFF
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from .common import Transport, AsyncPipeSink, SnoopingTransport
|
from .common import Transport, AsyncPipeSink, SnoopingTransport
|
||||||
from ..controller import Controller
|
|
||||||
from ..snoop import create_snooper
|
from ..snoop import create_snooper
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -119,7 +118,8 @@ async def _open_transport(name: str) -> Transport:
|
|||||||
if scheme == 'file':
|
if scheme == 'file':
|
||||||
from .file import open_file_transport
|
from .file import open_file_transport
|
||||||
|
|
||||||
return await open_file_transport(spec[0] if spec else None)
|
assert spec is not None
|
||||||
|
return await open_file_transport(spec[0])
|
||||||
|
|
||||||
if scheme == 'vhci':
|
if scheme == 'vhci':
|
||||||
from .vhci import open_vhci_transport
|
from .vhci import open_vhci_transport
|
||||||
@@ -134,12 +134,14 @@ async def _open_transport(name: str) -> Transport:
|
|||||||
if scheme == 'usb':
|
if scheme == 'usb':
|
||||||
from .usb import open_usb_transport
|
from .usb import open_usb_transport
|
||||||
|
|
||||||
return await open_usb_transport(spec[0] if spec else None)
|
assert spec is not None
|
||||||
|
return await open_usb_transport(spec[0])
|
||||||
|
|
||||||
if scheme == 'pyusb':
|
if scheme == 'pyusb':
|
||||||
from .pyusb import open_pyusb_transport
|
from .pyusb import open_pyusb_transport
|
||||||
|
|
||||||
return await open_pyusb_transport(spec[0] if spec else None)
|
assert spec is not None
|
||||||
|
return await open_pyusb_transport(spec[0])
|
||||||
|
|
||||||
if scheme == 'android-emulator':
|
if scheme == 'android-emulator':
|
||||||
from .android_emulator import open_android_emulator_transport
|
from .android_emulator import open_android_emulator_transport
|
||||||
@@ -168,6 +170,7 @@ async def open_transport_or_link(name: str) -> Transport:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
if name.startswith('link-relay:'):
|
if name.startswith('link-relay:'):
|
||||||
|
from ..controller import Controller
|
||||||
from ..link import RemoteLink # lazy import
|
from ..link import RemoteLink # lazy import
|
||||||
|
|
||||||
link = RemoteLink(name[11:])
|
link = RemoteLink(name[11:])
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import grpc.aio
|
import grpc.aio
|
||||||
|
|
||||||
from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink
|
from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink, Transport
|
||||||
|
|
||||||
# pylint: disable=no-name-in-module
|
# pylint: disable=no-name-in-module
|
||||||
from .grpc_protobuf.emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
|
from .grpc_protobuf.emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
|
||||||
@@ -33,7 +33,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_android_emulator_transport(spec):
|
async def open_android_emulator_transport(spec: str | None) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a transport connection to an Android emulator via its gRPC interface.
|
Open a transport connection to an Android emulator via its gRPC interface.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
@@ -66,7 +66,7 @@ async def open_android_emulator_transport(spec):
|
|||||||
# Parse the parameters
|
# Parse the parameters
|
||||||
mode = 'host'
|
mode = 'host'
|
||||||
server_host = 'localhost'
|
server_host = 'localhost'
|
||||||
server_port = 8554
|
server_port = '8554'
|
||||||
if spec is not None:
|
if spec is not None:
|
||||||
params = spec.split(',')
|
params = spec.split(',')
|
||||||
for param in params:
|
for param in params:
|
||||||
@@ -82,6 +82,7 @@ async def open_android_emulator_transport(spec):
|
|||||||
logger.debug(f'connecting to gRPC server at {server_address}')
|
logger.debug(f'connecting to gRPC server at {server_address}')
|
||||||
channel = grpc.aio.insecure_channel(server_address)
|
channel = grpc.aio.insecure_channel(server_address)
|
||||||
|
|
||||||
|
service: EmulatedBluetoothServiceStub | VhciForwardingServiceStub
|
||||||
if mode == 'host':
|
if mode == 'host':
|
||||||
# Connect as a host
|
# Connect as a host
|
||||||
service = EmulatedBluetoothServiceStub(channel)
|
service = EmulatedBluetoothServiceStub(channel)
|
||||||
|
|||||||
@@ -121,7 +121,9 @@ 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: str | None, server_port: int
|
||||||
|
) -> Transport:
|
||||||
if not server_port:
|
if not server_port:
|
||||||
raise ValueError('invalid port')
|
raise ValueError('invalid port')
|
||||||
if server_host == '_' or not server_host:
|
if server_host == '_' or not server_host:
|
||||||
|
|||||||
@@ -20,11 +20,12 @@ import contextlib
|
|||||||
import struct
|
import struct
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import ContextManager
|
import io
|
||||||
|
from typing import ContextManager, Tuple, Optional, Protocol, Dict
|
||||||
|
|
||||||
from .. import hci
|
from bumble import hci
|
||||||
from ..colors import color
|
from bumble.colors import color
|
||||||
from ..snoop import Snooper
|
from bumble.snoop import Snooper
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -36,7 +37,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Information needed to parse HCI packets with a generic parser:
|
# Information needed to parse HCI packets with a generic parser:
|
||||||
# For each packet type, the info represents:
|
# For each packet type, the info represents:
|
||||||
# (length-size, length-offset, unpack-type)
|
# (length-size, length-offset, unpack-type)
|
||||||
HCI_PACKET_INFO = {
|
HCI_PACKET_INFO: Dict[int, Tuple[int, int, str]] = {
|
||||||
hci.HCI_COMMAND_PACKET: (1, 2, 'B'),
|
hci.HCI_COMMAND_PACKET: (1, 2, 'B'),
|
||||||
hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'),
|
hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'),
|
||||||
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
|
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
|
||||||
@@ -44,6 +45,8 @@ HCI_PACKET_INFO = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Errors
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class TransportLostError(Exception):
|
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:
|
class PacketPump:
|
||||||
"""
|
"""
|
||||||
Pump HCI packets from a reader to a sink.
|
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.reader = reader
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
|
|
||||||
async def run(self):
|
async def run(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Get a packet from the source
|
|
||||||
packet = hci.HCI_Packet.from_bytes(await self.reader.next_packet())
|
|
||||||
|
|
||||||
# Deliver the packet to the sink
|
# Deliver the packet to the sink
|
||||||
self.sink.on_packet(packet)
|
self.sink.on_packet(await self.reader.next_packet())
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(f'!!! {error}')
|
logger.warning(f'!!! {error}')
|
||||||
|
|
||||||
@@ -86,18 +101,22 @@ class PacketParser:
|
|||||||
NEED_LENGTH = 1
|
NEED_LENGTH = 1
|
||||||
NEED_BODY = 2
|
NEED_BODY = 2
|
||||||
|
|
||||||
def __init__(self, sink=None):
|
sink: Optional[TransportSink]
|
||||||
|
extended_packet_info: Dict[int, Tuple[int, int, str]]
|
||||||
|
packet_info: Optional[Tuple[int, int, str]] = None
|
||||||
|
|
||||||
|
def __init__(self, sink: Optional[TransportSink] = None) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
self.extended_packet_info = {}
|
self.extended_packet_info = {}
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
def reset(self):
|
def reset(self) -> None:
|
||||||
self.state = PacketParser.NEED_TYPE
|
self.state = PacketParser.NEED_TYPE
|
||||||
self.bytes_needed = 1
|
self.bytes_needed = 1
|
||||||
self.packet = bytearray()
|
self.packet = bytearray()
|
||||||
self.packet_info = None
|
self.packet_info = None
|
||||||
|
|
||||||
def feed_data(self, data):
|
def feed_data(self, data: bytes) -> None:
|
||||||
data_offset = 0
|
data_offset = 0
|
||||||
data_left = len(data)
|
data_left = len(data)
|
||||||
while data_left and self.bytes_needed:
|
while data_left and self.bytes_needed:
|
||||||
@@ -118,6 +137,7 @@ class PacketParser:
|
|||||||
self.state = PacketParser.NEED_LENGTH
|
self.state = PacketParser.NEED_LENGTH
|
||||||
self.bytes_needed = self.packet_info[0] + self.packet_info[1]
|
self.bytes_needed = self.packet_info[0] + self.packet_info[1]
|
||||||
elif self.state == PacketParser.NEED_LENGTH:
|
elif self.state == PacketParser.NEED_LENGTH:
|
||||||
|
assert self.packet_info is not None
|
||||||
body_length = struct.unpack_from(
|
body_length = struct.unpack_from(
|
||||||
self.packet_info[2], self.packet, 1 + self.packet_info[1]
|
self.packet_info[2], self.packet, 1 + self.packet_info[1]
|
||||||
)[0]
|
)[0]
|
||||||
@@ -135,7 +155,7 @@ class PacketParser:
|
|||||||
)
|
)
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
def set_packet_sink(self, sink):
|
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
|
|
||||||
|
|
||||||
@@ -145,10 +165,10 @@ class PacketReader:
|
|||||||
Reader that reads HCI packets from a sync source.
|
Reader that reads HCI packets from a sync source.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, source):
|
def __init__(self, source: io.BufferedReader) -> None:
|
||||||
self.source = source
|
self.source = source
|
||||||
|
|
||||||
def next_packet(self):
|
def next_packet(self) -> Optional[bytes]:
|
||||||
# Get the packet type
|
# Get the packet type
|
||||||
packet_type = self.source.read(1)
|
packet_type = self.source.read(1)
|
||||||
if len(packet_type) != 1:
|
if len(packet_type) != 1:
|
||||||
@@ -157,7 +177,7 @@ class PacketReader:
|
|||||||
# Get the packet info based on its type
|
# Get the packet info based on its type
|
||||||
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
||||||
if packet_info is None:
|
if packet_info is None:
|
||||||
raise ValueError(f'invalid packet type {packet_type} found')
|
raise ValueError(f'invalid packet type {packet_type[0]} found')
|
||||||
|
|
||||||
# Read the header (that includes the length)
|
# Read the header (that includes the length)
|
||||||
header_size = packet_info[0] + packet_info[1]
|
header_size = packet_info[0] + packet_info[1]
|
||||||
@@ -180,17 +200,17 @@ class AsyncPacketReader:
|
|||||||
Reader that reads HCI packets from an async source.
|
Reader that reads HCI packets from an async source.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, source):
|
def __init__(self, source: asyncio.StreamReader) -> None:
|
||||||
self.source = source
|
self.source = source
|
||||||
|
|
||||||
async def next_packet(self):
|
async def next_packet(self) -> bytes:
|
||||||
# Get the packet type
|
# Get the packet type
|
||||||
packet_type = await self.source.readexactly(1)
|
packet_type = await self.source.readexactly(1)
|
||||||
|
|
||||||
# Get the packet info based on its type
|
# Get the packet info based on its type
|
||||||
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
||||||
if packet_info is None:
|
if packet_info is None:
|
||||||
raise ValueError(f'invalid packet type {packet_type} found')
|
raise ValueError(f'invalid packet type {packet_type[0]} found')
|
||||||
|
|
||||||
# Read the header (that includes the length)
|
# Read the header (that includes the length)
|
||||||
header_size = packet_info[0] + packet_info[1]
|
header_size = packet_info[0] + packet_info[1]
|
||||||
@@ -209,11 +229,11 @@ class AsyncPipeSink:
|
|||||||
Sink that forwards packets asynchronously to another sink.
|
Sink that forwards packets asynchronously to another sink.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, sink):
|
def __init__(self, sink: TransportSink) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
self.loop = asyncio.get_running_loop()
|
self.loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.loop.call_soon(self.sink.on_packet, packet)
|
self.loop.call_soon(self.sink.on_packet, packet)
|
||||||
|
|
||||||
|
|
||||||
@@ -223,50 +243,48 @@ class ParserSource:
|
|||||||
Base class designed to be subclassed by transport-specific source classes
|
Base class designed to be subclassed by transport-specific source classes
|
||||||
"""
|
"""
|
||||||
|
|
||||||
terminated: asyncio.Future
|
terminated: asyncio.Future[None]
|
||||||
parser: PacketParser
|
parser: PacketParser
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self.parser = PacketParser()
|
self.parser = PacketParser()
|
||||||
self.terminated = asyncio.get_running_loop().create_future()
|
self.terminated = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
def set_packet_sink(self, sink):
|
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||||
self.parser.set_packet_sink(sink)
|
self.parser.set_packet_sink(sink)
|
||||||
|
|
||||||
def on_transport_lost(self):
|
def on_transport_lost(self) -> None:
|
||||||
self.terminated.set_result(None)
|
self.terminated.set_result(None)
|
||||||
if self.parser.sink:
|
if self.parser.sink:
|
||||||
try:
|
if hasattr(self.parser.sink, 'on_transport_lost'):
|
||||||
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`
|
Convenience method for backward compatibility. Prefer using the `terminated`
|
||||||
attribute instead.
|
attribute instead.
|
||||||
"""
|
"""
|
||||||
return await self.terminated
|
return await self.terminated
|
||||||
|
|
||||||
def close(self):
|
def close(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class StreamPacketSource(asyncio.Protocol, ParserSource):
|
class StreamPacketSource(asyncio.Protocol, ParserSource):
|
||||||
def data_received(self, data):
|
def data_received(self, data: bytes) -> None:
|
||||||
self.parser.feed_data(data)
|
self.parser.feed_data(data)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class StreamPacketSink:
|
class StreamPacketSink:
|
||||||
def __init__(self, transport):
|
def __init__(self, transport: asyncio.WriteTransport) -> None:
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.transport.write(packet)
|
self.transport.write(packet)
|
||||||
|
|
||||||
def close(self):
|
def close(self) -> None:
|
||||||
self.transport.close()
|
self.transport.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -286,7 +304,7 @@ class Transport:
|
|||||||
...
|
...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, source, sink):
|
def __init__(self, source: TransportSource, sink: TransportSink) -> None:
|
||||||
self.source = source
|
self.source = source
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
|
|
||||||
@@ -300,19 +318,23 @@ class Transport:
|
|||||||
return iter((self.source, self.sink))
|
return iter((self.source, self.sink))
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
|
if hasattr(self.source, 'close'):
|
||||||
self.source.close()
|
self.source.close()
|
||||||
|
if hasattr(self.sink, 'close'):
|
||||||
self.sink.close()
|
self.sink.close()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PumpedPacketSource(ParserSource):
|
class PumpedPacketSource(ParserSource):
|
||||||
def __init__(self, receive):
|
pump_task: Optional[asyncio.Task[None]]
|
||||||
|
|
||||||
|
def __init__(self, receive) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.receive_function = receive
|
self.receive_function = receive
|
||||||
self.pump_task = None
|
self.pump_task = None
|
||||||
|
|
||||||
def start(self):
|
def start(self) -> None:
|
||||||
async def pump_packets():
|
async def pump_packets() -> None:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
packet = await self.receive_function()
|
packet = await self.receive_function()
|
||||||
@@ -322,12 +344,12 @@ class PumpedPacketSource(ParserSource):
|
|||||||
break
|
break
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(f'exception while waiting for packet: {error}')
|
logger.warning(f'exception while waiting for packet: {error}')
|
||||||
self.terminated.set_result(error)
|
self.terminated.set_exception(error)
|
||||||
break
|
break
|
||||||
|
|
||||||
self.pump_task = asyncio.create_task(pump_packets())
|
self.pump_task = asyncio.create_task(pump_packets())
|
||||||
|
|
||||||
def close(self):
|
def close(self) -> None:
|
||||||
if self.pump_task:
|
if self.pump_task:
|
||||||
self.pump_task.cancel()
|
self.pump_task.cancel()
|
||||||
|
|
||||||
@@ -339,7 +361,7 @@ class PumpedPacketSink:
|
|||||||
self.packet_queue = asyncio.Queue()
|
self.packet_queue = asyncio.Queue()
|
||||||
self.pump_task = None
|
self.pump_task = None
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.packet_queue.put_nowait(packet)
|
self.packet_queue.put_nowait(packet)
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
@@ -364,15 +386,23 @@ class PumpedPacketSink:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PumpedTransport(Transport):
|
class PumpedTransport(Transport):
|
||||||
def __init__(self, source, sink, close_function):
|
source: PumpedPacketSource
|
||||||
|
sink: PumpedPacketSink
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source: PumpedPacketSource,
|
||||||
|
sink: PumpedPacketSink,
|
||||||
|
close_function,
|
||||||
|
) -> None:
|
||||||
super().__init__(source, sink)
|
super().__init__(source, sink)
|
||||||
self.close_function = close_function
|
self.close_function = close_function
|
||||||
|
|
||||||
def start(self):
|
def start(self) -> None:
|
||||||
self.source.start()
|
self.source.start()
|
||||||
self.sink.start()
|
self.sink.start()
|
||||||
|
|
||||||
async def close(self):
|
async def close(self) -> None:
|
||||||
await super().close()
|
await super().close()
|
||||||
await self.close_function()
|
await self.close_function()
|
||||||
|
|
||||||
@@ -397,31 +427,38 @@ class SnoopingTransport(Transport):
|
|||||||
raise RuntimeError('unexpected code path') # Satisfy the type checker
|
raise RuntimeError('unexpected code path') # Satisfy the type checker
|
||||||
|
|
||||||
class Source:
|
class Source:
|
||||||
def __init__(self, source, snooper):
|
sink: TransportSink
|
||||||
|
|
||||||
|
def __init__(self, source: TransportSource, snooper: Snooper):
|
||||||
self.source = source
|
self.source = source
|
||||||
self.snooper = snooper
|
self.snooper = snooper
|
||||||
self.sink = None
|
self.terminated = source.terminated
|
||||||
|
|
||||||
def set_packet_sink(self, sink):
|
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
self.source.set_packet_sink(self)
|
self.source.set_packet_sink(self)
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.snooper.snoop(packet, Snooper.Direction.CONTROLLER_TO_HOST)
|
self.snooper.snoop(packet, Snooper.Direction.CONTROLLER_TO_HOST)
|
||||||
if self.sink:
|
if self.sink:
|
||||||
self.sink.on_packet(packet)
|
self.sink.on_packet(packet)
|
||||||
|
|
||||||
class Sink:
|
class Sink:
|
||||||
def __init__(self, sink, snooper):
|
def __init__(self, sink: TransportSink, snooper: Snooper) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
self.snooper = snooper
|
self.snooper = snooper
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.snooper.snoop(packet, Snooper.Direction.HOST_TO_CONTROLLER)
|
self.snooper.snoop(packet, Snooper.Direction.HOST_TO_CONTROLLER)
|
||||||
if self.sink:
|
if self.sink:
|
||||||
self.sink.on_packet(packet)
|
self.sink.on_packet(packet)
|
||||||
|
|
||||||
def __init__(self, transport, snooper, close_snooper=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
transport: Transport,
|
||||||
|
snooper: Snooper,
|
||||||
|
close_snooper=None,
|
||||||
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
self.Source(transport.source, snooper), self.Sink(transport.sink, snooper)
|
self.Source(transport.source, snooper), self.Sink(transport.sink, snooper)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_file_transport(spec):
|
async def open_file_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a File transport (typically not for a real file, but for a PTY or other unix
|
Open a File transport (typically not for a real file, but for a PTY or other unix
|
||||||
virtual files).
|
virtual files).
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_hci_socket_transport(spec):
|
async def open_hci_socket_transport(spec: str | None) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open an HCI Socket (only available on some platforms).
|
Open an HCI Socket (only available on some platforms).
|
||||||
The parameter string is either empty (to use the first/default Bluetooth adapter)
|
The parameter string is either empty (to use the first/default Bluetooth adapter)
|
||||||
@@ -47,7 +47,7 @@ async def open_hci_socket_transport(spec):
|
|||||||
hci_socket = socket.socket(
|
hci_socket = socket.socket(
|
||||||
socket.AF_BLUETOOTH,
|
socket.AF_BLUETOOTH,
|
||||||
socket.SOCK_RAW | socket.SOCK_NONBLOCK,
|
socket.SOCK_RAW | socket.SOCK_NONBLOCK,
|
||||||
socket.BTPROTO_HCI,
|
socket.BTPROTO_HCI, # type: ignore
|
||||||
)
|
)
|
||||||
except AttributeError as error:
|
except AttributeError as error:
|
||||||
# Not supported on this platform
|
# Not supported on this platform
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_pty_transport(spec):
|
async def open_pty_transport(spec: str | None) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a PTY transport.
|
Open a PTY transport.
|
||||||
The parameter string may be empty, or a path name where a symbolic link
|
The parameter string may be empty, or a path name where a symbolic link
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_pyusb_transport(spec):
|
async def open_pyusb_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a USB transport. [Implementation based on PyUSB]
|
Open a USB transport. [Implementation based on PyUSB]
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_serial_transport(spec):
|
async def open_serial_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a serial port transport.
|
Open a serial port transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_tcp_client_transport(spec):
|
async def open_tcp_client_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a TCP client transport.
|
Open a TCP client transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_tcp_server_transport(spec):
|
async def open_tcp_server_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a TCP server transport.
|
Open a TCP server transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
@@ -42,7 +43,7 @@ async def open_tcp_server_transport(spec):
|
|||||||
async def close(self):
|
async def close(self):
|
||||||
await super().close()
|
await super().close()
|
||||||
|
|
||||||
class TcpServerProtocol:
|
class TcpServerProtocol(asyncio.BaseProtocol):
|
||||||
def __init__(self, packet_source, packet_sink):
|
def __init__(self, packet_source, packet_sink):
|
||||||
self.packet_source = packet_source
|
self.packet_source = packet_source
|
||||||
self.packet_sink = packet_sink
|
self.packet_sink = packet_sink
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_udp_transport(spec):
|
async def open_udp_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a UDP transport.
|
Open a UDP transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ def load_libusb():
|
|||||||
usb1.loadLibrary(libusb_dll)
|
usb1.loadLibrary(libusb_dll)
|
||||||
|
|
||||||
|
|
||||||
async def open_usb_transport(spec):
|
async def open_usb_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a USB transport.
|
Open a USB transport.
|
||||||
The moniker string has this syntax:
|
The moniker string has this syntax:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from .common import Transport
|
||||||
from .file import open_file_transport
|
from .file import open_file_transport
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -26,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_vhci_transport(spec):
|
async def open_vhci_transport(spec: str | None) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a VHCI transport (only available on some platforms).
|
Open a VHCI transport (only available on some platforms).
|
||||||
The parameter string is either empty (to use the default VHCI device
|
The parameter string is either empty (to use the default VHCI device
|
||||||
@@ -42,15 +43,15 @@ async def open_vhci_transport(spec):
|
|||||||
# Override the source's `data_received` method so that we can
|
# Override the source's `data_received` method so that we can
|
||||||
# filter out the vendor packet that is received just after the
|
# filter out the vendor packet that is received just after the
|
||||||
# initial open
|
# initial open
|
||||||
def vhci_data_received(data):
|
def vhci_data_received(data: bytes) -> None:
|
||||||
if len(data) > 0 and data[0] == HCI_VENDOR_PKT:
|
if len(data) > 0 and data[0] == HCI_VENDOR_PKT:
|
||||||
if len(data) == 4:
|
if len(data) == 4:
|
||||||
hci_index = data[2] << 8 | data[3]
|
hci_index = data[2] << 8 | data[3]
|
||||||
logger.info(f'HCI index {hci_index}')
|
logger.info(f'HCI index {hci_index}')
|
||||||
else:
|
else:
|
||||||
transport.source.parser.feed_data(data)
|
transport.source.parser.feed_data(data) # type: ignore
|
||||||
|
|
||||||
transport.source.data_received = vhci_data_received
|
transport.source.data_received = vhci_data_received # type: ignore
|
||||||
|
|
||||||
# Write the initial config
|
# Write the initial config
|
||||||
transport.sink.on_packet(bytes([HCI_VENDOR_PKT, HCI_BREDR]))
|
transport.sink.on_packet(bytes([HCI_VENDOR_PKT, HCI_BREDR]))
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
import websockets
|
import websockets.client
|
||||||
|
|
||||||
from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport
|
from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport, Transport
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_ws_client_transport(spec):
|
async def open_ws_client_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a WebSocket client transport.
|
Open a WebSocket client transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
@@ -38,7 +38,7 @@ async def open_ws_client_transport(spec):
|
|||||||
|
|
||||||
remote_host, remote_port = spec.split(':')
|
remote_host, remote_port = spec.split(':')
|
||||||
uri = f'ws://{remote_host}:{remote_port}'
|
uri = f'ws://{remote_host}:{remote_port}'
|
||||||
websocket = await websockets.connect(uri)
|
websocket = await websockets.client.connect(uri)
|
||||||
|
|
||||||
transport = PumpedTransport(
|
transport = PumpedTransport(
|
||||||
PumpedPacketSource(websocket.recv),
|
PumpedPacketSource(websocket.recv),
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_ws_server_transport(spec):
|
async def open_ws_server_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a WebSocket server transport.
|
Open a WebSocket server transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
|
|||||||
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
|
- Linux: platforms/linux.md
|
||||||
- Windows: platforms/windows.md
|
- Windows: platforms/windows.md
|
||||||
- Android: platforms/android.md
|
- Android: platforms/android.md
|
||||||
|
- Zephyr: platforms/zephyr.md
|
||||||
- Examples:
|
- Examples:
|
||||||
- Overview: examples/index.md
|
- 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-linux: Linux - see the [Linux platform page](linux.md)
|
||||||
* :material-microsoft-windows: Windows - see the [Windows platform page](windows.md)
|
* :material-microsoft-windows: Windows - see the [Windows platform page](windows.md)
|
||||||
* :material-android: Android - see the [Android platform page](android.md)
|
* :material-android: Android - see the [Android platform page](android.md)
|
||||||
|
* :material-memory: Zephyr - see the [Zephyr platform page](zephyr.md)
|
||||||
|
|||||||
51
docs/mkdocs/src/platforms/zephyr.md
Normal file
51
docs/mkdocs/src/platforms/zephyr.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
:material-memory: ZEPHYR PLATFORM
|
||||||
|
=================================
|
||||||
|
|
||||||
|
Set TX Power on nRF52840
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
The Nordic nRF52840 supports Zephyr's vendor specific HCI command for setting TX
|
||||||
|
power during advertising, connection, or scanning. With the example [HCI
|
||||||
|
USB](https://docs.zephyrproject.org/latest/samples/bluetooth/hci_usb/README.html)
|
||||||
|
application, an [nRF52840
|
||||||
|
dongle](https://www.nordicsemi.com/Products/Development-
|
||||||
|
hardware/nRF52840-Dongle) can be used as a Bumble controller.
|
||||||
|
|
||||||
|
To add dynamic TX power support to the HCI USB application, add the following to
|
||||||
|
`zephyr/samples/bluetooth/hci_usb/prj.conf` and build.
|
||||||
|
|
||||||
|
```
|
||||||
|
CONFIG_BT_CTLR_ADVANCED_FEATURES=y
|
||||||
|
CONFIG_BT_CTLR_CONN_RSSI=y
|
||||||
|
CONFIG_BT_CTLR_TX_PWR_DYNAMIC_CONTROL=y
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, a prebuilt firmware application can be downloaded here:
|
||||||
|
[hci_usb.zip](../downloads/zephyr/hci_usb.zip).
|
||||||
|
|
||||||
|
Put the nRF52840 dongle into bootloader mode by pressing the RESET button. The
|
||||||
|
LED should pulse red. Load the firmware application with the `nrfutil` tool:
|
||||||
|
|
||||||
|
```
|
||||||
|
nrfutil dfu usb-serial -pkg hci_usb.zip -p /dev/ttyACM0
|
||||||
|
```
|
||||||
|
|
||||||
|
The vendor specific HCI commands to read and write TX power are defined in
|
||||||
|
`bumble/vendor/zephyr/hci.py` and may be used as such:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bumble.vendor.zephyr.hci import HCI_Write_Tx_Power_Level_Command
|
||||||
|
|
||||||
|
# set advertising power to -4 dB
|
||||||
|
response = await host.send_command(
|
||||||
|
HCI_Write_Tx_Power_Level_Command(
|
||||||
|
handle_type=HCI_Write_Tx_Power_Level_Command.TX_POWER_HANDLE_TYPE_ADV,
|
||||||
|
connection_handle=0,
|
||||||
|
tx_power_level=-4,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.return_parameters.status == HCI_SUCCESS:
|
||||||
|
print(f"TX power set to {response.return_parameters.selected_tx_power_level}")
|
||||||
|
|
||||||
|
```
|
||||||
@@ -16,11 +16,9 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
import collections
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
|
|
||||||
@@ -32,8 +30,7 @@ from bumble.core import (
|
|||||||
BT_RFCOMM_PROTOCOL_ID,
|
BT_RFCOMM_PROTOCOL_ID,
|
||||||
BT_BR_EDR_TRANSPORT,
|
BT_BR_EDR_TRANSPORT,
|
||||||
)
|
)
|
||||||
from bumble import rfcomm
|
from bumble import rfcomm, hfp
|
||||||
from bumble.rfcomm import Client
|
|
||||||
from bumble.sdp import (
|
from bumble.sdp import (
|
||||||
Client as SDP_Client,
|
Client as SDP_Client,
|
||||||
DataElement,
|
DataElement,
|
||||||
@@ -47,61 +44,6 @@ from bumble.sdp import (
|
|||||||
logger = logging.getLogger(__name__)
|
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
|
# pylint: disable-next=too-many-nested-blocks
|
||||||
async def list_rfcomm_channels(device, connection):
|
async def list_rfcomm_channels(device, connection):
|
||||||
@@ -241,7 +183,7 @@ async def main():
|
|||||||
|
|
||||||
# Create a client and start it
|
# Create a client and start it
|
||||||
print('@@@ Starting to RFCOMM client...')
|
print('@@@ Starting to RFCOMM client...')
|
||||||
rfcomm_client = Client(device, connection)
|
rfcomm_client = rfcomm.Client(device, connection)
|
||||||
rfcomm_mux = await rfcomm_client.start()
|
rfcomm_mux = await rfcomm_client.start()
|
||||||
print('@@@ Started')
|
print('@@@ Started')
|
||||||
|
|
||||||
@@ -256,7 +198,7 @@ async def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Protocol loop (just for testing at this point)
|
# Protocol loop (just for testing at this point)
|
||||||
protocol = HfpProtocol(session)
|
protocol = hfp.HfpProtocol(session)
|
||||||
while True:
|
while True:
|
||||||
line = await protocol.next_line()
|
line = await protocol.next_line()
|
||||||
|
|
||||||
|
|||||||
@@ -20,83 +20,109 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from bumble.core import UUID
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
from bumble.core import BT_L2CAP_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID, UUID
|
|
||||||
from bumble.rfcomm import Server
|
from bumble.rfcomm import Server
|
||||||
from bumble.sdp import (
|
from bumble.utils import AsyncRunner
|
||||||
DataElement,
|
from bumble.rfcomm import make_service_sdp_records
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def sdp_records(channel):
|
def sdp_records(channel, uuid):
|
||||||
|
service_record_handle = 0x00010001
|
||||||
return {
|
return {
|
||||||
0x00010001: [
|
service_record_handle: make_service_sdp_records(
|
||||||
ServiceAttribute(
|
service_record_handle, channel, UUID(uuid)
|
||||||
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),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def on_dlc(dlc):
|
def on_rfcomm_session(rfcomm_session, tcp_server):
|
||||||
print('*** DLC connected', dlc)
|
print('*** RFComm session connected', rfcomm_session)
|
||||||
dlc.sink = lambda data: on_rfcomm_data_received(dlc, data)
|
tcp_server.attach_session(rfcomm_session)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def on_rfcomm_data_received(dlc, data):
|
class TcpServerProtocol(asyncio.Protocol):
|
||||||
print(f'<<< Data received: {data.hex()}')
|
def __init__(self, server):
|
||||||
try:
|
self.server = server
|
||||||
message = data.decode('utf-8')
|
|
||||||
print(f'<<< Message = {message}')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Echo everything back
|
def connection_made(self, transport):
|
||||||
dlc.write(data)
|
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():
|
async def main():
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 4:
|
||||||
print('Usage: run_rfcomm_server.py <device-config> <transport-spec>')
|
print(
|
||||||
print('example: run_rfcomm_server.py classic2.json usb:04b4:f901')
|
'Usage: run_rfcomm_server.py <device-config> <transport-spec> '
|
||||||
|
'<tcp-port> [<uuid>]'
|
||||||
|
)
|
||||||
|
print('example: run_rfcomm_server.py classic2.json usb:0 8888')
|
||||||
return
|
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...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
@@ -105,15 +131,20 @@ async def main():
|
|||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
||||||
device.classic_enabled = True
|
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)
|
rfcomm_server = Server(device)
|
||||||
|
|
||||||
# Listen for incoming DLC connections
|
# Listen for incoming DLC connections
|
||||||
channel_number = rfcomm_server.listen(on_dlc)
|
channel_number = rfcomm_server.listen(
|
||||||
print(f'### Listening for connection on channel {channel_number}')
|
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
|
# 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
|
# Start the controller
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
|
|||||||
833
rust/Cargo.lock
generated
833
rust/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,12 @@ documentation = "https://docs.rs/crate/bumble"
|
|||||||
authors = ["Marshall Pierce <marshallpierce@google.com>"]
|
authors = ["Marshall Pierce <marshallpierce@google.com>"]
|
||||||
keywords = ["bluetooth", "ble"]
|
keywords = ["bluetooth", "ble"]
|
||||||
categories = ["api-bindings", "network-programming"]
|
categories = ["api-bindings", "network-programming"]
|
||||||
rust-version = "1.69.0"
|
rust-version = "1.70.0"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pyo3 = { version = "0.18.3", features = ["macros"] }
|
pyo3 = { version = "0.18.3", features = ["macros"] }
|
||||||
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime"] }
|
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime"] }
|
||||||
tokio = { version = "1.28.2" }
|
tokio = { version = "1.28.2", features = ["macros", "signal"] }
|
||||||
nom = "7.1.3"
|
nom = "7.1.3"
|
||||||
strum = "0.25.0"
|
strum = "0.25.0"
|
||||||
strum_macros = "0.25.0"
|
strum_macros = "0.25.0"
|
||||||
@@ -23,7 +23,17 @@ hex = "0.4.3"
|
|||||||
itertools = "0.11.0"
|
itertools = "0.11.0"
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
thiserror = "1.0.41"
|
thiserror = "1.0.41"
|
||||||
|
|
||||||
|
# CLI
|
||||||
anyhow = { version = "1.0.71", optional = true }
|
anyhow = { version = "1.0.71", optional = true }
|
||||||
|
clap = { version = "4.3.3", features = ["derive"], optional = true }
|
||||||
|
directories = { version = "5.0.1", optional = true }
|
||||||
|
env_logger = { version = "0.10.0", optional = true }
|
||||||
|
futures = { version = "0.3.28", optional = true }
|
||||||
|
log = { version = "0.4.19", optional = true }
|
||||||
|
owo-colors = { version = "3.5.0", optional = true }
|
||||||
|
reqwest = { version = "0.11.20", features = ["blocking"], optional = true }
|
||||||
|
rusb = { version = "0.9.2", optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.28.2", features = ["full"] }
|
tokio = { version = "1.28.2", features = ["full"] }
|
||||||
@@ -32,17 +42,25 @@ nix = "0.26.2"
|
|||||||
anyhow = "1.0.71"
|
anyhow = "1.0.71"
|
||||||
pyo3 = { version = "0.18.3", features = ["macros", "anyhow"] }
|
pyo3 = { version = "0.18.3", features = ["macros", "anyhow"] }
|
||||||
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime", "attributes", "testing"] }
|
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime", "attributes", "testing"] }
|
||||||
|
rusb = "0.9.2"
|
||||||
|
rand = "0.8.5"
|
||||||
clap = { version = "4.3.3", features = ["derive"] }
|
clap = { version = "4.3.3", features = ["derive"] }
|
||||||
owo-colors = "3.5.0"
|
owo-colors = "3.5.0"
|
||||||
log = "0.4.19"
|
log = "0.4.19"
|
||||||
env_logger = "0.10.0"
|
env_logger = "0.10.0"
|
||||||
rusb = "0.9.2"
|
|
||||||
rand = "0.8.5"
|
[package.metadata.docs.rs]
|
||||||
|
rustdoc-args = ["--generate-link-to-definition"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "gen-assigned-numbers"
|
name = "gen-assigned-numbers"
|
||||||
path = "tools/gen_assigned_numbers.rs"
|
path = "tools/gen_assigned_numbers.rs"
|
||||||
required-features = ["bumble-dev-tools"]
|
required-features = ["bumble-codegen"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "bumble"
|
||||||
|
path = "src/main.rs"
|
||||||
|
required-features = ["bumble-tools"]
|
||||||
|
|
||||||
# test entry point that uses pyo3_asyncio's test harness
|
# test entry point that uses pyo3_asyncio's test harness
|
||||||
[[test]]
|
[[test]]
|
||||||
@@ -53,4 +71,7 @@ harness = false
|
|||||||
[features]
|
[features]
|
||||||
anyhow = ["pyo3/anyhow"]
|
anyhow = ["pyo3/anyhow"]
|
||||||
pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"]
|
pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"]
|
||||||
bumble-dev-tools = ["dep:anyhow"]
|
bumble-codegen = ["dep:anyhow"]
|
||||||
|
# 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 = []
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ Rust wrappers around the [Bumble](https://github.com/google/bumble) Python API.
|
|||||||
Method calls are mapped to the equivalent Python, and return types adapted where
|
Method calls are mapped to the equivalent Python, and return types adapted where
|
||||||
relevant.
|
relevant.
|
||||||
|
|
||||||
See the `examples` directory for usage.
|
See the CLI in `src/main.rs` or the `examples` directory for how to use the
|
||||||
|
Bumble API.
|
||||||
|
|
||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
@@ -27,6 +28,15 @@ PYTHONPATH=..:~/.virtualenvs/bumble/lib/python3.10/site-packages/ \
|
|||||||
Run the corresponding `battery_server` Python example, and launch an emulator in
|
Run the corresponding `battery_server` Python example, and launch an emulator in
|
||||||
Android Studio (currently, Canary is required) to run netsim.
|
Android Studio (currently, Canary is required) to run netsim.
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
|
||||||
|
Explore the available subcommands:
|
||||||
|
|
||||||
|
```
|
||||||
|
PYTHONPATH=..:[virtualenv site-packages] \
|
||||||
|
cargo run --features bumble-tools --bin bumble -- --help
|
||||||
|
```
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
Run the tests:
|
Run the tests:
|
||||||
@@ -52,5 +62,5 @@ in tests at `pytests/assigned_numbers.rs`.
|
|||||||
To regenerate the assigned number tables based on the Python codebase:
|
To regenerate the assigned number tables based on the Python codebase:
|
||||||
|
|
||||||
```
|
```
|
||||||
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features bumble-dev-tools
|
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features bumble-codegen
|
||||||
```
|
```
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use bumble::wrapper::transport::Transport;
|
use bumble::wrapper::{drivers::rtk::DriverInfo, transport::Transport};
|
||||||
use nix::sys::stat::Mode;
|
use nix::sys::stat::Mode;
|
||||||
use pyo3::PyResult;
|
use pyo3::PyResult;
|
||||||
|
|
||||||
@@ -29,3 +29,9 @@ async fn fifo_transport_can_open() -> PyResult<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[pyo3_asyncio::tokio::test]
|
||||||
|
async fn realtek_driver_info_all_drivers() -> PyResult<()> {
|
||||||
|
assert_eq!(12, DriverInfo::all_drivers()?.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
4
rust/resources/test/firmware/realtek/README.md
Normal file
4
rust/resources/test/firmware/realtek/README.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
This dir contains samples firmware images in the format used for Realtek chips,
|
||||||
|
but with repetitions of the length of the section as a little-endian 32-bit int
|
||||||
|
for the patch data instead of actual firmware, since we only need the structure
|
||||||
|
to test parsing.
|
||||||
BIN
rust/resources/test/firmware/realtek/rtl8723b_fw_structure.bin
Normal file
BIN
rust/resources/test/firmware/realtek/rtl8723b_fw_structure.bin
Normal file
Binary file not shown.
BIN
rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin
Normal file
BIN
rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin
Normal file
Binary file not shown.
15
rust/src/cli/firmware/mod.rs
Normal file
15
rust/src/cli/firmware/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
pub(crate) mod rtk;
|
||||||
265
rust/src/cli/firmware/rtk.rs
Normal file
265
rust/src/cli/firmware/rtk.rs
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Realtek firmware tools
|
||||||
|
|
||||||
|
use crate::{Download, Source};
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use bumble::wrapper::{
|
||||||
|
drivers::rtk::{Driver, DriverInfo, Firmware},
|
||||||
|
host::{DriverFactory, Host},
|
||||||
|
transport::Transport,
|
||||||
|
};
|
||||||
|
use owo_colors::{colors::css, OwoColorize};
|
||||||
|
use pyo3::PyResult;
|
||||||
|
use std::{fs, path};
|
||||||
|
|
||||||
|
pub(crate) async fn download(dl: Download) -> PyResult<()> {
|
||||||
|
let data_dir = dl
|
||||||
|
.output_dir
|
||||||
|
.or_else(|| {
|
||||||
|
directories::ProjectDirs::from("com", "google", "bumble")
|
||||||
|
.map(|pd| pd.data_local_dir().join("firmware").join("realtek"))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
eprintln!("Could not determine standard data directory");
|
||||||
|
path::PathBuf::from(".")
|
||||||
|
});
|
||||||
|
fs::create_dir_all(&data_dir)?;
|
||||||
|
|
||||||
|
let (base_url, uses_bin_suffix) = match dl.source {
|
||||||
|
Source::LinuxKernel => ("https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git/plain/rtl_bt", true),
|
||||||
|
Source::RealtekOpensource => ("https://github.com/Realtek-OpenSource/android_hardware_realtek/raw/rtk1395/bt/rtkbt/Firmware/BT", false),
|
||||||
|
Source::LinuxFromScratch => ("https://anduin.linuxfromscratch.org/sources/linux-firmware/rtl_bt", true),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Downloading");
|
||||||
|
println!("{} {}", "FROM:".green(), base_url);
|
||||||
|
println!("{} {}", "TO:".green(), data_dir.to_string_lossy());
|
||||||
|
|
||||||
|
let url_for_file = |file_name: &str| {
|
||||||
|
let url_suffix = if uses_bin_suffix {
|
||||||
|
file_name
|
||||||
|
} else {
|
||||||
|
file_name.trim_end_matches(".bin")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut url = base_url.to_string();
|
||||||
|
url.push('/');
|
||||||
|
url.push_str(url_suffix);
|
||||||
|
url
|
||||||
|
};
|
||||||
|
|
||||||
|
let to_download = if let Some(single) = dl.single {
|
||||||
|
vec![(
|
||||||
|
format!("{single}_fw.bin"),
|
||||||
|
Some(format!("{single}_config.bin")),
|
||||||
|
false,
|
||||||
|
)]
|
||||||
|
} else {
|
||||||
|
DriverInfo::all_drivers()?
|
||||||
|
.iter()
|
||||||
|
.map(|di| Ok((di.firmware_name()?, di.config_name()?, di.config_needed()?)))
|
||||||
|
.collect::<PyResult<Vec<_>>>()?
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = SimpleClient::new();
|
||||||
|
|
||||||
|
for (fw_filename, config_filename, config_needed) in to_download {
|
||||||
|
println!("{}", "---".yellow());
|
||||||
|
let fw_path = data_dir.join(&fw_filename);
|
||||||
|
let config_path = config_filename.as_ref().map(|f| data_dir.join(f));
|
||||||
|
|
||||||
|
if fw_path.exists() && !dl.overwrite {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!("{} already exists, skipping", fw_path.to_string_lossy())
|
||||||
|
.fg::<css::Orange>()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(cp) = config_path.as_ref() {
|
||||||
|
if cp.exists() && !dl.overwrite {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!("{} already exists, skipping", cp.to_string_lossy())
|
||||||
|
.fg::<css::Orange>()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fw_contents = match client.get(&url_for_file(&fw_filename)).await {
|
||||||
|
Ok(data) => {
|
||||||
|
println!("Downloaded {}: {} bytes", fw_filename, data.len());
|
||||||
|
data
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"{} {} {:?}",
|
||||||
|
"Failed to download".red(),
|
||||||
|
fw_filename.red(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let config_contents = if let Some(cn) = &config_filename {
|
||||||
|
match client.get(&url_for_file(cn)).await {
|
||||||
|
Ok(data) => {
|
||||||
|
println!("Downloaded {}: {} bytes", cn, data.len());
|
||||||
|
Some(data)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if config_needed {
|
||||||
|
eprintln!("{} {} {:?}", "Failed to download".red(), cn.red(), e);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
format!("No config available as {cn}").fg::<css::Orange>()
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
fs::write(&fw_path, &fw_contents)?;
|
||||||
|
if !dl.no_parse && config_filename.is_some() {
|
||||||
|
println!("{} {}", "Parsing:".cyan(), &fw_filename);
|
||||||
|
match Firmware::parse(&fw_contents).map_err(|e| anyhow!("Parse error: {:?}", e)) {
|
||||||
|
Ok(fw) => dump_firmware_desc(&fw),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"{} {:?}",
|
||||||
|
"Could not parse firmware:".fg::<css::Orange>(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((cp, cd)) = config_path
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| config_contents.map(|c| (p, c)))
|
||||||
|
{
|
||||||
|
fs::write(cp, &cd)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse(firmware_path: &path::Path) -> PyResult<()> {
|
||||||
|
let contents = fs::read(firmware_path)?;
|
||||||
|
let fw = Firmware::parse(&contents)
|
||||||
|
// squish the error into a string to avoid the error type requiring that the input be
|
||||||
|
// 'static
|
||||||
|
.map_err(|e| anyhow!("Parse error: {:?}", e))?;
|
||||||
|
|
||||||
|
dump_firmware_desc(&fw);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn info(transport: &str, force: bool) -> PyResult<()> {
|
||||||
|
let transport = Transport::open(transport).await?;
|
||||||
|
|
||||||
|
let mut host = Host::new(transport.source()?, transport.sink()?)?;
|
||||||
|
host.reset(DriverFactory::None).await?;
|
||||||
|
|
||||||
|
if !force && !Driver::check(&host).await? {
|
||||||
|
println!("USB device not supported by this RTK driver");
|
||||||
|
} else if let Some(driver_info) = Driver::driver_info_for_host(&host).await? {
|
||||||
|
println!("Driver:");
|
||||||
|
println!(" {:10} {:04X}", "ROM:", driver_info.rom()?);
|
||||||
|
println!(" {:10} {}", "Firmware:", driver_info.firmware_name()?);
|
||||||
|
println!(
|
||||||
|
" {:10} {}",
|
||||||
|
"Config:",
|
||||||
|
driver_info.config_name()?.unwrap_or_default()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("Firmware already loaded or no supported driver for this device.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn load(transport: &str, force: bool) -> PyResult<()> {
|
||||||
|
let transport = Transport::open(transport).await?;
|
||||||
|
|
||||||
|
let mut host = Host::new(transport.source()?, transport.sink()?)?;
|
||||||
|
host.reset(DriverFactory::None).await?;
|
||||||
|
|
||||||
|
match Driver::for_host(&host, force).await? {
|
||||||
|
None => {
|
||||||
|
eprintln!("Firmware already loaded or no supported driver for this device.");
|
||||||
|
}
|
||||||
|
Some(mut d) => d.download_firmware().await?,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn drop(transport: &str) -> PyResult<()> {
|
||||||
|
let transport = Transport::open(transport).await?;
|
||||||
|
|
||||||
|
let mut host = Host::new(transport.source()?, transport.sink()?)?;
|
||||||
|
host.reset(DriverFactory::None).await?;
|
||||||
|
|
||||||
|
Driver::drop_firmware(&mut host).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dump_firmware_desc(fw: &Firmware) {
|
||||||
|
println!(
|
||||||
|
"Firmware: version=0x{:08X} project_id=0x{:04X}",
|
||||||
|
fw.version(),
|
||||||
|
fw.project_id()
|
||||||
|
);
|
||||||
|
for p in fw.patches() {
|
||||||
|
println!(
|
||||||
|
" Patch: chip_id=0x{:04X}, {} bytes, SVN Version={:08X}",
|
||||||
|
p.chip_id(),
|
||||||
|
p.contents().len(),
|
||||||
|
p.svn_version()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SimpleClient {
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SimpleClient {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, url: &str) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let resp = self.client.get(url).send().await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(anyhow!("Bad status: {}", resp.status()));
|
||||||
|
}
|
||||||
|
let bytes = resp.bytes().await?;
|
||||||
|
Ok(bytes.as_ref().to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
191
rust/src/cli/l2cap/client_bridge.rs
Normal file
191
rust/src/cli/l2cap/client_bridge.rs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
/// L2CAP CoC client bridge: connects to a BLE device, then waits for an inbound
|
||||||
|
/// TCP connection on a specified port number. When a TCP client connects, an
|
||||||
|
/// L2CAP CoC channel connection to the BLE device is established, and the data
|
||||||
|
/// is bridged in both directions, with flow control.
|
||||||
|
/// When the TCP connection is closed by the client, the L2CAP CoC channel is
|
||||||
|
/// disconnected, but the connection to the BLE device remains, ready for a new
|
||||||
|
/// TCP client to connect.
|
||||||
|
/// When the L2CAP CoC channel is closed, the TCP connection is closed as well.
|
||||||
|
use crate::cli::l2cap::{
|
||||||
|
proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
|
||||||
|
BridgeData,
|
||||||
|
};
|
||||||
|
use bumble::wrapper::{
|
||||||
|
device::{Connection, Device},
|
||||||
|
hci::HciConstant,
|
||||||
|
};
|
||||||
|
use futures::executor::block_on;
|
||||||
|
use owo_colors::OwoColorize;
|
||||||
|
use pyo3::{PyResult, Python};
|
||||||
|
use std::{net::SocketAddr, sync::Arc};
|
||||||
|
use tokio::{
|
||||||
|
join,
|
||||||
|
net::{TcpListener, TcpStream},
|
||||||
|
sync::{mpsc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Args {
|
||||||
|
pub psm: u16,
|
||||||
|
pub max_credits: Option<u16>,
|
||||||
|
pub mtu: Option<u16>,
|
||||||
|
pub mps: Option<u16>,
|
||||||
|
pub bluetooth_address: String,
|
||||||
|
pub tcp_host: String,
|
||||||
|
pub tcp_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!("### Connecting to {}...", args.bluetooth_address).yellow()
|
||||||
|
);
|
||||||
|
let mut ble_connection = device.connect(&args.bluetooth_address).await?;
|
||||||
|
ble_connection.on_disconnection(|_py, reason| {
|
||||||
|
let disconnection_info = match HciConstant::error_name(reason) {
|
||||||
|
Ok(info_string) => info_string,
|
||||||
|
Err(py_err) => format!("failed to get disconnection error name ({})", py_err),
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
"@@@ Bluetooth disconnection: ".red(),
|
||||||
|
disconnection_info,
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Start the TCP server.
|
||||||
|
let listener = TcpListener::bind(format!("{}:{}", args.tcp_host, args.tcp_port))
|
||||||
|
.await
|
||||||
|
.expect("failed to bind tcp to address");
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!(
|
||||||
|
"### Listening for TCP connections on port {}",
|
||||||
|
args.tcp_port
|
||||||
|
)
|
||||||
|
.magenta()
|
||||||
|
);
|
||||||
|
|
||||||
|
let psm = args.psm;
|
||||||
|
let max_credits = args.max_credits;
|
||||||
|
let mtu = args.mtu;
|
||||||
|
let mps = args.mps;
|
||||||
|
let ble_connection = Arc::new(Mutex::new(ble_connection));
|
||||||
|
// Ensure Python event loop is available to l2cap `disconnect`
|
||||||
|
let _ = run_future_with_current_task_locals(async move {
|
||||||
|
while let Ok((tcp_stream, addr)) = listener.accept().await {
|
||||||
|
let ble_connection = ble_connection.clone();
|
||||||
|
let _ = run_future_with_current_task_locals(proxy_data_between_tcp_and_l2cap(
|
||||||
|
ble_connection,
|
||||||
|
tcp_stream,
|
||||||
|
addr,
|
||||||
|
psm,
|
||||||
|
max_credits,
|
||||||
|
mtu,
|
||||||
|
mps,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_data_between_tcp_and_l2cap(
|
||||||
|
ble_connection: Arc<Mutex<Connection>>,
|
||||||
|
tcp_stream: TcpStream,
|
||||||
|
addr: SocketAddr,
|
||||||
|
psm: u16,
|
||||||
|
max_credits: Option<u16>,
|
||||||
|
mtu: Option<u16>,
|
||||||
|
mps: Option<u16>,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
println!("{}", format!("<<< TCP connection from {}", addr).magenta());
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!(">>> Opening L2CAP channel on PSM = {}", psm).yellow()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut l2cap_channel = match ble_connection
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.open_l2cap_channel(psm, max_credits, mtu, mps)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(channel) => channel,
|
||||||
|
Err(e) => {
|
||||||
|
println!("{}", format!("!!! Connection failed: {e}").red());
|
||||||
|
// TCP stream will get dropped after returning, automatically shutting it down.
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let channel_info = l2cap_channel
|
||||||
|
.debug_string()
|
||||||
|
.unwrap_or_else(|e| format!("failed to get l2cap channel info ({e})"));
|
||||||
|
|
||||||
|
println!("{}{}", "*** L2CAP channel: ".cyan(), channel_info);
|
||||||
|
|
||||||
|
let (l2cap_to_tcp_tx, l2cap_to_tcp_rx) = mpsc::channel::<BridgeData>(10);
|
||||||
|
|
||||||
|
// Set l2cap callback (`set_sink`) for when data is received.
|
||||||
|
let l2cap_to_tcp_tx_clone = l2cap_to_tcp_tx.clone();
|
||||||
|
l2cap_channel
|
||||||
|
.set_sink(move |_py, sdu| {
|
||||||
|
block_on(l2cap_to_tcp_tx_clone.send(BridgeData::Data(sdu.into())))
|
||||||
|
.expect("failed to channel data to tcp");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("failed to set sink for l2cap connection");
|
||||||
|
|
||||||
|
// Set l2cap callback for when the channel is closed.
|
||||||
|
l2cap_channel
|
||||||
|
.on_close(move |_py| {
|
||||||
|
println!("{}", "*** L2CAP channel closed".red());
|
||||||
|
block_on(l2cap_to_tcp_tx.send(BridgeData::CloseSignal))
|
||||||
|
.expect("failed to channel close signal to tcp");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("failed to set on_close callback for l2cap channel");
|
||||||
|
|
||||||
|
let l2cap_channel = Arc::new(Mutex::new(Some(l2cap_channel)));
|
||||||
|
let (tcp_reader, tcp_writer) = tcp_stream.into_split();
|
||||||
|
|
||||||
|
// Do tcp stuff when something happens on the l2cap channel.
|
||||||
|
let handle_l2cap_data_future =
|
||||||
|
proxy_l2cap_rx_to_tcp_tx(l2cap_to_tcp_rx, tcp_writer, l2cap_channel.clone());
|
||||||
|
|
||||||
|
// Do l2cap stuff when something happens on tcp.
|
||||||
|
let handle_tcp_data_future = proxy_tcp_rx_to_l2cap_tx(tcp_reader, l2cap_channel.clone(), true);
|
||||||
|
|
||||||
|
let (handle_l2cap_result, handle_tcp_result) =
|
||||||
|
join!(handle_l2cap_data_future, handle_tcp_data_future);
|
||||||
|
|
||||||
|
if let Err(e) = handle_l2cap_result {
|
||||||
|
println!("!!! Error: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = handle_tcp_result {
|
||||||
|
println!("!!! Error: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Python::with_gil(|_| {
|
||||||
|
// Must hold GIL at least once while/after dropping for Python heap object to ensure
|
||||||
|
// de-allocation.
|
||||||
|
drop(l2cap_channel);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
190
rust/src/cli/l2cap/mod.rs
Normal file
190
rust/src/cli/l2cap/mod.rs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Rust version of the Python `l2cap_bridge.py` found under the `apps` folder.
|
||||||
|
|
||||||
|
use crate::L2cap;
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use bumble::wrapper::{device::Device, l2cap::LeConnectionOrientedChannel, transport::Transport};
|
||||||
|
use owo_colors::{colors::css::Orange, OwoColorize};
|
||||||
|
use pyo3::{PyObject, PyResult, Python};
|
||||||
|
use std::{future::Future, path::PathBuf, sync::Arc};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
|
net::tcp::{OwnedReadHalf, OwnedWriteHalf},
|
||||||
|
sync::{mpsc::Receiver, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
mod client_bridge;
|
||||||
|
mod server_bridge;
|
||||||
|
|
||||||
|
pub(crate) async fn run(
|
||||||
|
command: L2cap,
|
||||||
|
device_config: PathBuf,
|
||||||
|
transport: String,
|
||||||
|
psm: u16,
|
||||||
|
max_credits: Option<u16>,
|
||||||
|
mtu: Option<u16>,
|
||||||
|
mps: Option<u16>,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
println!("<<< connecting to HCI...");
|
||||||
|
let transport = Transport::open(transport).await?;
|
||||||
|
println!("<<< connected");
|
||||||
|
|
||||||
|
let mut device =
|
||||||
|
Device::from_config_file_with_hci(&device_config, transport.source()?, transport.sink()?)?;
|
||||||
|
|
||||||
|
device.power_on().await?;
|
||||||
|
|
||||||
|
match command {
|
||||||
|
L2cap::Server { tcp_host, tcp_port } => {
|
||||||
|
let args = server_bridge::Args {
|
||||||
|
psm,
|
||||||
|
max_credits,
|
||||||
|
mtu,
|
||||||
|
mps,
|
||||||
|
tcp_host,
|
||||||
|
tcp_port,
|
||||||
|
};
|
||||||
|
|
||||||
|
server_bridge::start(&args, &mut device).await?
|
||||||
|
}
|
||||||
|
L2cap::Client {
|
||||||
|
bluetooth_address,
|
||||||
|
tcp_host,
|
||||||
|
tcp_port,
|
||||||
|
} => {
|
||||||
|
let args = client_bridge::Args {
|
||||||
|
psm,
|
||||||
|
max_credits,
|
||||||
|
mtu,
|
||||||
|
mps,
|
||||||
|
bluetooth_address,
|
||||||
|
tcp_host,
|
||||||
|
tcp_port,
|
||||||
|
};
|
||||||
|
|
||||||
|
client_bridge::start(&args, &mut device).await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// wait until user kills the process
|
||||||
|
tokio::signal::ctrl_c().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used for channeling data from Python callbacks to a Rust consumer.
|
||||||
|
enum BridgeData {
|
||||||
|
Data(Vec<u8>),
|
||||||
|
CloseSignal,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_l2cap_rx_to_tcp_tx(
|
||||||
|
mut l2cap_data_receiver: Receiver<BridgeData>,
|
||||||
|
mut tcp_writer: OwnedWriteHalf,
|
||||||
|
l2cap_channel: Arc<Mutex<Option<LeConnectionOrientedChannel>>>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
while let Some(bridge_data) = l2cap_data_receiver.recv().await {
|
||||||
|
match bridge_data {
|
||||||
|
BridgeData::Data(sdu) => {
|
||||||
|
println!("{}", format!("<<< [L2CAP SDU]: {} bytes", sdu.len()).cyan());
|
||||||
|
tcp_writer
|
||||||
|
.write_all(sdu.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("Failed to write to tcp stream"))?;
|
||||||
|
tcp_writer
|
||||||
|
.flush()
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("Failed to flush tcp stream"))?;
|
||||||
|
}
|
||||||
|
BridgeData::CloseSignal => {
|
||||||
|
l2cap_channel.lock().await.take();
|
||||||
|
tcp_writer
|
||||||
|
.shutdown()
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("Failed to shut down write half of tcp stream"))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_tcp_rx_to_l2cap_tx(
|
||||||
|
mut tcp_reader: OwnedReadHalf,
|
||||||
|
l2cap_channel: Arc<Mutex<Option<LeConnectionOrientedChannel>>>,
|
||||||
|
drain_l2cap_after_write: bool,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
let mut buf = [0; 4096];
|
||||||
|
loop {
|
||||||
|
match tcp_reader.read(&mut buf).await {
|
||||||
|
Ok(len) => {
|
||||||
|
if len == 0 {
|
||||||
|
println!("{}", "!!! End of stream".fg::<Orange>());
|
||||||
|
|
||||||
|
if let Some(mut channel) = l2cap_channel.lock().await.take() {
|
||||||
|
channel.disconnect().await.map_err(|e| {
|
||||||
|
eprintln!("Failed to call disconnect on l2cap channel: {e}");
|
||||||
|
e
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{}", format!("<<< [TCP DATA]: {len} bytes").blue());
|
||||||
|
match l2cap_channel.lock().await.as_mut() {
|
||||||
|
None => {
|
||||||
|
println!("{}", "!!! L2CAP channel not connected, dropping".red());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Some(channel) => {
|
||||||
|
channel.write(&buf[..len])?;
|
||||||
|
if drain_l2cap_after_write {
|
||||||
|
channel.drain().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("{}", format!("!!! TCP connection lost: {}", e).red());
|
||||||
|
if let Some(mut channel) = l2cap_channel.lock().await.take() {
|
||||||
|
let _ = channel.disconnect().await.map_err(|e| {
|
||||||
|
eprintln!("Failed to call disconnect on l2cap channel: {e}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies the current thread's TaskLocals into a Python "awaitable" and encapsulates it in a Rust
|
||||||
|
/// future, running it as a Python Task.
|
||||||
|
/// `TaskLocals` stores the current event loop, and allows the user to copy the current Python
|
||||||
|
/// context if necessary. In this case, the python event loop is used when calling `disconnect` on
|
||||||
|
/// an l2cap connection, or else the call will fail.
|
||||||
|
pub fn run_future_with_current_task_locals<F>(
|
||||||
|
fut: F,
|
||||||
|
) -> PyResult<impl Future<Output = PyResult<PyObject>> + Send>
|
||||||
|
where
|
||||||
|
F: Future<Output = PyResult<()>> + Send + 'static,
|
||||||
|
{
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
let locals = pyo3_asyncio::tokio::get_current_locals(py)?;
|
||||||
|
let future = pyo3_asyncio::tokio::scope(locals.clone(), fut);
|
||||||
|
pyo3_asyncio::tokio::future_into_py_with_locals(py, locals, future)
|
||||||
|
.and_then(pyo3_asyncio::tokio::into_future)
|
||||||
|
})
|
||||||
|
}
|
||||||
205
rust/src/cli/l2cap/server_bridge.rs
Normal file
205
rust/src/cli/l2cap/server_bridge.rs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
/// L2CAP CoC server bridge: waits for a peer to connect an L2CAP CoC channel
|
||||||
|
/// on a specified PSM. When the connection is made, the bridge connects a TCP
|
||||||
|
/// socket to a remote host and bridges the data in both directions, with flow
|
||||||
|
/// control.
|
||||||
|
/// When the L2CAP CoC channel is closed, the bridge disconnects the TCP socket
|
||||||
|
/// and waits for a new L2CAP CoC channel to be connected.
|
||||||
|
/// When the TCP connection is closed by the TCP server, the L2CAP connection is closed as well.
|
||||||
|
use crate::cli::l2cap::{
|
||||||
|
proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
|
||||||
|
BridgeData,
|
||||||
|
};
|
||||||
|
use bumble::wrapper::{device::Device, hci::HciConstant, l2cap::LeConnectionOrientedChannel};
|
||||||
|
use futures::executor::block_on;
|
||||||
|
use owo_colors::OwoColorize;
|
||||||
|
use pyo3::{PyResult, Python};
|
||||||
|
use std::{sync::Arc, time::Duration};
|
||||||
|
use tokio::{
|
||||||
|
join,
|
||||||
|
net::TcpStream,
|
||||||
|
select,
|
||||||
|
sync::{mpsc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Args {
|
||||||
|
pub psm: u16,
|
||||||
|
pub max_credits: Option<u16>,
|
||||||
|
pub mtu: Option<u16>,
|
||||||
|
pub mps: Option<u16>,
|
||||||
|
pub tcp_host: String,
|
||||||
|
pub tcp_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
|
||||||
|
let host = args.tcp_host.clone();
|
||||||
|
let port = args.tcp_port;
|
||||||
|
device.register_l2cap_channel_server(
|
||||||
|
args.psm,
|
||||||
|
move |_py, l2cap_channel| {
|
||||||
|
let channel_info = l2cap_channel
|
||||||
|
.debug_string()
|
||||||
|
.unwrap_or_else(|e| format!("failed to get l2cap channel info ({e})"));
|
||||||
|
println!("{} {channel_info}", "*** L2CAP channel:".cyan());
|
||||||
|
|
||||||
|
let host = host.clone();
|
||||||
|
// Ensure Python event loop is available to l2cap `disconnect`
|
||||||
|
let _ = run_future_with_current_task_locals(proxy_data_between_l2cap_and_tcp(
|
||||||
|
l2cap_channel,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
args.max_credits,
|
||||||
|
args.mtu,
|
||||||
|
args.mps,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!("### Listening for CoC connection on PSM {}", args.psm).yellow()
|
||||||
|
);
|
||||||
|
|
||||||
|
device.on_connection(|_py, mut connection| {
|
||||||
|
let connection_info = connection
|
||||||
|
.debug_string()
|
||||||
|
.unwrap_or_else(|e| format!("failed to get connection info ({e})"));
|
||||||
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
"@@@ Bluetooth connection: ".green(),
|
||||||
|
connection_info,
|
||||||
|
);
|
||||||
|
connection.on_disconnection(|_py, reason| {
|
||||||
|
let disconnection_info = match HciConstant::error_name(reason) {
|
||||||
|
Ok(info_string) => info_string,
|
||||||
|
Err(py_err) => format!("failed to get disconnection error name ({})", py_err),
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
"@@@ Bluetooth disconnection: ".red(),
|
||||||
|
disconnection_info,
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
device.start_advertising(false).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_data_between_l2cap_and_tcp(
|
||||||
|
mut l2cap_channel: LeConnectionOrientedChannel,
|
||||||
|
tcp_host: String,
|
||||||
|
tcp_port: u16,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
let (l2cap_to_tcp_tx, mut l2cap_to_tcp_rx) = mpsc::channel::<BridgeData>(10);
|
||||||
|
|
||||||
|
// Set callback (`set_sink`) for when l2cap data is received.
|
||||||
|
let l2cap_to_tcp_tx_clone = l2cap_to_tcp_tx.clone();
|
||||||
|
l2cap_channel
|
||||||
|
.set_sink(move |_py, sdu| {
|
||||||
|
block_on(l2cap_to_tcp_tx_clone.send(BridgeData::Data(sdu.into())))
|
||||||
|
.expect("failed to channel data to tcp");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("failed to set sink for l2cap connection");
|
||||||
|
|
||||||
|
// Set l2cap callback for when the channel is closed.
|
||||||
|
l2cap_channel
|
||||||
|
.on_close(move |_py| {
|
||||||
|
println!("{}", "*** L2CAP channel closed".red());
|
||||||
|
block_on(l2cap_to_tcp_tx.send(BridgeData::CloseSignal))
|
||||||
|
.expect("failed to channel close signal to tcp");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("failed to set on_close callback for l2cap channel");
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!("### Connecting to TCP {tcp_host}:{tcp_port}...").yellow()
|
||||||
|
);
|
||||||
|
|
||||||
|
let l2cap_channel = Arc::new(Mutex::new(Some(l2cap_channel)));
|
||||||
|
let tcp_stream = match TcpStream::connect(format!("{tcp_host}:{tcp_port}")).await {
|
||||||
|
Ok(stream) => {
|
||||||
|
println!("{}", "### Connected".green());
|
||||||
|
Some(stream)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
println!("{}", format!("!!! Connection failed: {err}").red());
|
||||||
|
if let Some(mut channel) = l2cap_channel.lock().await.take() {
|
||||||
|
// Bumble might enter an invalid state if disconnection request is received from
|
||||||
|
// l2cap client before receiving a disconnection response from the same client,
|
||||||
|
// blocking this async call from returning.
|
||||||
|
// See: https://github.com/google/bumble/issues/257
|
||||||
|
select! {
|
||||||
|
res = channel.disconnect() => {
|
||||||
|
let _ = res.map_err(|e| eprintln!("Failed to call disconnect on l2cap channel: {e}"));
|
||||||
|
},
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(1)) => eprintln!("Timed out while calling disconnect on l2cap channel."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match tcp_stream {
|
||||||
|
None => {
|
||||||
|
while let Some(bridge_data) = l2cap_to_tcp_rx.recv().await {
|
||||||
|
match bridge_data {
|
||||||
|
BridgeData::Data(sdu) => {
|
||||||
|
println!("{}", format!("<<< [L2CAP SDU]: {} bytes", sdu.len()).cyan());
|
||||||
|
println!("{}", "!!! TCP socket not open, dropping".red())
|
||||||
|
}
|
||||||
|
BridgeData::CloseSignal => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(tcp_stream) => {
|
||||||
|
let (tcp_reader, tcp_writer) = tcp_stream.into_split();
|
||||||
|
|
||||||
|
// Do tcp stuff when something happens on the l2cap channel.
|
||||||
|
let handle_l2cap_data_future =
|
||||||
|
proxy_l2cap_rx_to_tcp_tx(l2cap_to_tcp_rx, tcp_writer, l2cap_channel.clone());
|
||||||
|
|
||||||
|
// Do l2cap stuff when something happens on tcp.
|
||||||
|
let handle_tcp_data_future =
|
||||||
|
proxy_tcp_rx_to_l2cap_tx(tcp_reader, l2cap_channel.clone(), false);
|
||||||
|
|
||||||
|
let (handle_l2cap_result, handle_tcp_result) =
|
||||||
|
join!(handle_l2cap_data_future, handle_tcp_data_future);
|
||||||
|
|
||||||
|
if let Err(e) = handle_l2cap_result {
|
||||||
|
println!("!!! Error: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = handle_tcp_result {
|
||||||
|
println!("!!! Error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Python::with_gil(|_| {
|
||||||
|
// Must hold GIL at least once while/after dropping for Python heap object to ensure
|
||||||
|
// de-allocation.
|
||||||
|
drop(l2cap_channel);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
19
rust/src/cli/mod.rs
Normal file
19
rust/src/cli/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
pub(crate) mod firmware;
|
||||||
|
|
||||||
|
pub(crate) mod usb;
|
||||||
|
|
||||||
|
pub(crate) mod l2cap;
|
||||||
@@ -23,7 +23,6 @@
|
|||||||
//! whether it is a Bluetooth device that uses a non-standard Class, or some other
|
//! whether it is a Bluetooth device that uses a non-standard Class, or some other
|
||||||
//! type of device (there's no way to tell).
|
//! type of device (there's no way to tell).
|
||||||
|
|
||||||
use clap::Parser as _;
|
|
||||||
use itertools::Itertools as _;
|
use itertools::Itertools as _;
|
||||||
use owo_colors::{OwoColorize, Style};
|
use owo_colors::{OwoColorize, Style};
|
||||||
use rusb::{Device, DeviceDescriptor, Direction, TransferType, UsbContext};
|
use rusb::{Device, DeviceDescriptor, Direction, TransferType, UsbContext};
|
||||||
@@ -31,15 +30,12 @@ use std::{
|
|||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
const USB_DEVICE_CLASS_DEVICE: u8 = 0x00;
|
const USB_DEVICE_CLASS_DEVICE: u8 = 0x00;
|
||||||
const USB_DEVICE_CLASS_WIRELESS_CONTROLLER: u8 = 0xE0;
|
const USB_DEVICE_CLASS_WIRELESS_CONTROLLER: u8 = 0xE0;
|
||||||
const USB_DEVICE_SUBCLASS_RF_CONTROLLER: u8 = 0x01;
|
const USB_DEVICE_SUBCLASS_RF_CONTROLLER: u8 = 0x01;
|
||||||
const USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER: u8 = 0x01;
|
const USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER: u8 = 0x01;
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
pub(crate) fn probe(verbose: bool) -> anyhow::Result<()> {
|
||||||
let cli = Cli::parse();
|
|
||||||
|
|
||||||
let mut bt_dev_count = 0;
|
let mut bt_dev_count = 0;
|
||||||
let mut device_serials_by_id: HashMap<(u16, u16), HashSet<String>> = HashMap::new();
|
let mut device_serials_by_id: HashMap<(u16, u16), HashSet<String>> = HashMap::new();
|
||||||
for device in rusb::devices()?.iter() {
|
for device in rusb::devices()?.iter() {
|
||||||
@@ -159,7 +155,7 @@ fn main() -> anyhow::Result<()> {
|
|||||||
println!("{:26}{}", " Product:".green(), p);
|
println!("{:26}{}", " Product:".green(), p);
|
||||||
}
|
}
|
||||||
|
|
||||||
if cli.verbose {
|
if verbose {
|
||||||
print_device_details(&device, &device_desc)?;
|
print_device_details(&device, &device_desc)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,11 +328,3 @@ impl From<&DeviceDescriptor> for ClassInfo {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(clap::Parser)]
|
|
||||||
#[command(author, version, about, long_about = None)]
|
|
||||||
struct Cli {
|
|
||||||
/// Show additional info for each USB device
|
|
||||||
#[arg(long, default_value_t = false)]
|
|
||||||
verbose: bool,
|
|
||||||
}
|
|
||||||
17
rust/src/internal/drivers/mod.rs
Normal file
17
rust/src/internal/drivers/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Device drivers
|
||||||
|
|
||||||
|
pub(crate) mod rtk;
|
||||||
253
rust/src/internal/drivers/rtk.rs
Normal file
253
rust/src/internal/drivers/rtk.rs
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Drivers for Realtek controllers
|
||||||
|
|
||||||
|
use nom::{bytes, combinator, error, multi, number, sequence};
|
||||||
|
|
||||||
|
/// Realtek firmware file contents
|
||||||
|
pub struct Firmware {
|
||||||
|
version: u32,
|
||||||
|
project_id: u8,
|
||||||
|
patches: Vec<Patch>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Firmware {
|
||||||
|
/// Parse a `*_fw.bin` file
|
||||||
|
pub fn parse(input: &[u8]) -> Result<Self, nom::Err<error::Error<&[u8]>>> {
|
||||||
|
let extension_sig = [0x51, 0x04, 0xFD, 0x77];
|
||||||
|
|
||||||
|
let (_rem, (_tag, fw_version, patch_count, payload)) =
|
||||||
|
combinator::all_consuming(combinator::map_parser(
|
||||||
|
// ignore the sig suffix
|
||||||
|
sequence::terminated(
|
||||||
|
bytes::complete::take(
|
||||||
|
// underflow will show up as parse failure
|
||||||
|
input.len().saturating_sub(extension_sig.len()),
|
||||||
|
),
|
||||||
|
bytes::complete::tag(extension_sig.as_slice()),
|
||||||
|
),
|
||||||
|
sequence::tuple((
|
||||||
|
bytes::complete::tag(b"Realtech"),
|
||||||
|
// version
|
||||||
|
number::complete::le_u32,
|
||||||
|
// patch count
|
||||||
|
combinator::map(number::complete::le_u16, |c| c as usize),
|
||||||
|
// everything else except suffix
|
||||||
|
combinator::rest,
|
||||||
|
)),
|
||||||
|
))(input)?;
|
||||||
|
|
||||||
|
// ignore remaining input, since patch offsets are relative to the complete input
|
||||||
|
let (_rem, (chip_ids, patch_lengths, patch_offsets)) = sequence::tuple((
|
||||||
|
// chip id
|
||||||
|
multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
|
||||||
|
// patch length
|
||||||
|
multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
|
||||||
|
// patch offset
|
||||||
|
multi::many_m_n(patch_count, patch_count, number::complete::le_u32),
|
||||||
|
))(payload)?;
|
||||||
|
|
||||||
|
let patches = chip_ids
|
||||||
|
.into_iter()
|
||||||
|
.zip(patch_lengths.into_iter())
|
||||||
|
.zip(patch_offsets.into_iter())
|
||||||
|
.map(|((chip_id, patch_length), patch_offset)| {
|
||||||
|
combinator::map(
|
||||||
|
sequence::preceded(
|
||||||
|
bytes::complete::take(patch_offset),
|
||||||
|
// ignore trailing 4-byte suffix
|
||||||
|
sequence::terminated(
|
||||||
|
// patch including svn version, but not suffix
|
||||||
|
combinator::consumed(sequence::preceded(
|
||||||
|
// patch before svn version or version suffix
|
||||||
|
// prefix length underflow will show up as parse failure
|
||||||
|
bytes::complete::take(patch_length.saturating_sub(8)),
|
||||||
|
// svn version
|
||||||
|
number::complete::le_u32,
|
||||||
|
)),
|
||||||
|
// dummy suffix, overwritten with firmware version
|
||||||
|
bytes::complete::take(4_usize),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|(patch_contents_before_version, svn_version): (&[u8], u32)| {
|
||||||
|
let mut contents = patch_contents_before_version.to_vec();
|
||||||
|
// replace what would have been the trailing dummy suffix with fw version
|
||||||
|
contents.extend_from_slice(&fw_version.to_le_bytes());
|
||||||
|
|
||||||
|
Patch {
|
||||||
|
contents,
|
||||||
|
svn_version,
|
||||||
|
chip_id,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)(input)
|
||||||
|
.map(|(_rem, output)| output)
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
// look for project id from the end
|
||||||
|
let mut offset = payload.len();
|
||||||
|
let mut project_id: Option<u8> = None;
|
||||||
|
while offset >= 2 {
|
||||||
|
// Won't panic, since offset >= 2
|
||||||
|
let chunk = &payload[offset - 2..offset];
|
||||||
|
let length: usize = chunk[0].into();
|
||||||
|
let opcode = chunk[1];
|
||||||
|
offset -= 2;
|
||||||
|
|
||||||
|
if opcode == 0xFF {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if length == 0 {
|
||||||
|
// report what nom likely would have done, if nom was good at parsing backwards
|
||||||
|
return Err(nom::Err::Error(error::Error::new(
|
||||||
|
chunk,
|
||||||
|
error::ErrorKind::Verify,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if opcode == 0 && length == 1 {
|
||||||
|
project_id = offset
|
||||||
|
.checked_sub(1)
|
||||||
|
.and_then(|index| payload.get(index))
|
||||||
|
.copied();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset -= length;
|
||||||
|
}
|
||||||
|
|
||||||
|
match project_id {
|
||||||
|
Some(project_id) => Ok(Firmware {
|
||||||
|
project_id,
|
||||||
|
version: fw_version,
|
||||||
|
patches,
|
||||||
|
}),
|
||||||
|
None => {
|
||||||
|
// we ran out of file without finding a project id
|
||||||
|
Err(nom::Err::Error(error::Error::new(
|
||||||
|
payload,
|
||||||
|
error::ErrorKind::Eof,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Patch version
|
||||||
|
pub fn version(&self) -> u32 {
|
||||||
|
self.version
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project id
|
||||||
|
pub fn project_id(&self) -> u8 {
|
||||||
|
self.project_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Patches
|
||||||
|
pub fn patches(&self) -> &[Patch] {
|
||||||
|
&self.patches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Patch in a [Firmware}
|
||||||
|
pub struct Patch {
|
||||||
|
chip_id: u16,
|
||||||
|
contents: Vec<u8>,
|
||||||
|
svn_version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Patch {
|
||||||
|
/// Chip id
|
||||||
|
pub fn chip_id(&self) -> u16 {
|
||||||
|
self.chip_id
|
||||||
|
}
|
||||||
|
/// Contents of the patch, including the 4-byte firmware version suffix
|
||||||
|
pub fn contents(&self) -> &[u8] {
|
||||||
|
&self.contents
|
||||||
|
}
|
||||||
|
/// SVN version
|
||||||
|
pub fn svn_version(&self) -> u32 {
|
||||||
|
self.svn_version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use std::{fs, io, path};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_firmware_rtl8723b() -> anyhow::Result<()> {
|
||||||
|
let fw = Firmware::parse(&firmware_contents("rtl8723b_fw_structure.bin")?)
|
||||||
|
.map_err(|e| anyhow!("{:?}", e))?;
|
||||||
|
|
||||||
|
let fw_version = 0x0E2F9F73;
|
||||||
|
assert_eq!(fw_version, fw.version());
|
||||||
|
assert_eq!(0x0001, fw.project_id());
|
||||||
|
assert_eq!(
|
||||||
|
vec![(0x0001, 0x00002BBF, 22368,), (0x0002, 0x00002BBF, 22496,),],
|
||||||
|
patch_summaries(fw, fw_version)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_firmware_rtl8761bu() -> anyhow::Result<()> {
|
||||||
|
let fw = Firmware::parse(&firmware_contents("rtl8761bu_fw_structure.bin")?)
|
||||||
|
.map_err(|e| anyhow!("{:?}", e))?;
|
||||||
|
|
||||||
|
let fw_version = 0xDFC6D922;
|
||||||
|
assert_eq!(fw_version, fw.version());
|
||||||
|
assert_eq!(0x000E, fw.project_id());
|
||||||
|
assert_eq!(
|
||||||
|
vec![(0x0001, 0x00005060, 14048,), (0x0002, 0xD6D525A4, 30204,),],
|
||||||
|
patch_summaries(fw, fw_version)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn firmware_contents(filename: &str) -> io::Result<Vec<u8>> {
|
||||||
|
fs::read(
|
||||||
|
path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("resources/test/firmware/realtek")
|
||||||
|
.join(filename),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a tuple of (chip id, svn version, contents len, contents sha256)
|
||||||
|
fn patch_summaries(fw: Firmware, fw_version: u32) -> Vec<(u16, u32, usize)> {
|
||||||
|
fw.patches()
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
let contents = p.contents();
|
||||||
|
let mut dummy_contents = dummy_contents(contents.len());
|
||||||
|
dummy_contents.extend_from_slice(&p.svn_version().to_le_bytes());
|
||||||
|
dummy_contents.extend_from_slice(&fw_version.to_le_bytes());
|
||||||
|
assert_eq!(&dummy_contents, contents);
|
||||||
|
(p.chip_id(), p.svn_version(), contents.len())
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dummy_contents(len: usize) -> Vec<u8> {
|
||||||
|
let mut vec = (len as u32).to_le_bytes().as_slice().repeat(len / 4 + 1);
|
||||||
|
assert!(vec.len() >= len);
|
||||||
|
// leave room for svn version and firmware version
|
||||||
|
vec.truncate(len - 8);
|
||||||
|
vec
|
||||||
|
}
|
||||||
|
}
|
||||||
20
rust/src/internal/mod.rs
Normal file
20
rust/src/internal/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! It's not clear where to put Rust code that isn't simply a wrapper around Python. Until we have
|
||||||
|
//! a good answer for what to do there, the idea is to put it in this (non-public) module, and
|
||||||
|
//! `pub use` it into the relevant areas of the `wrapper` module so that it's still easy for users
|
||||||
|
//! to discover.
|
||||||
|
|
||||||
|
pub(crate) mod drivers;
|
||||||
@@ -29,3 +29,5 @@
|
|||||||
pub mod wrapper;
|
pub mod wrapper;
|
||||||
|
|
||||||
pub mod adv;
|
pub mod adv;
|
||||||
|
|
||||||
|
pub(crate) mod internal;
|
||||||
|
|||||||
271
rust/src/main.rs
Normal file
271
rust/src/main.rs
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! CLI tools for Bumble
|
||||||
|
|
||||||
|
#![deny(missing_docs, unsafe_code)]
|
||||||
|
|
||||||
|
use bumble::wrapper::logging::{bumble_env_logging_level, py_logging_basic_config};
|
||||||
|
use clap::Parser as _;
|
||||||
|
use pyo3::PyResult;
|
||||||
|
use std::{fmt, path};
|
||||||
|
|
||||||
|
mod cli;
|
||||||
|
|
||||||
|
#[pyo3_asyncio::tokio::main]
|
||||||
|
async fn main() -> PyResult<()> {
|
||||||
|
env_logger::builder()
|
||||||
|
.filter_level(log::LevelFilter::Info)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
py_logging_basic_config(bumble_env_logging_level("INFO"))?;
|
||||||
|
|
||||||
|
let cli: Cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.subcommand {
|
||||||
|
Subcommand::Firmware { subcommand: fw } => match fw {
|
||||||
|
Firmware::Realtek { subcommand: rtk } => match rtk {
|
||||||
|
Realtek::Download(dl) => {
|
||||||
|
cli::firmware::rtk::download(dl).await?;
|
||||||
|
}
|
||||||
|
Realtek::Drop { transport } => cli::firmware::rtk::drop(&transport).await?,
|
||||||
|
Realtek::Info { transport, force } => {
|
||||||
|
cli::firmware::rtk::info(&transport, force).await?;
|
||||||
|
}
|
||||||
|
Realtek::Load { transport, force } => {
|
||||||
|
cli::firmware::rtk::load(&transport, force).await?
|
||||||
|
}
|
||||||
|
Realtek::Parse { firmware_path } => cli::firmware::rtk::parse(&firmware_path)?,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Subcommand::L2cap {
|
||||||
|
subcommand,
|
||||||
|
device_config,
|
||||||
|
transport,
|
||||||
|
psm,
|
||||||
|
l2cap_coc_max_credits,
|
||||||
|
l2cap_coc_mtu,
|
||||||
|
l2cap_coc_mps,
|
||||||
|
} => {
|
||||||
|
cli::l2cap::run(
|
||||||
|
subcommand,
|
||||||
|
device_config,
|
||||||
|
transport,
|
||||||
|
psm,
|
||||||
|
l2cap_coc_max_credits,
|
||||||
|
l2cap_coc_mtu,
|
||||||
|
l2cap_coc_mps,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
Subcommand::Usb { subcommand } => match subcommand {
|
||||||
|
Usb::Probe(probe) => cli::usb::probe(probe.verbose)?,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
struct Cli {
|
||||||
|
#[clap(subcommand)]
|
||||||
|
subcommand: Subcommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Subcommand, Debug, Clone)]
|
||||||
|
enum Subcommand {
|
||||||
|
/// Manage device firmware
|
||||||
|
Firmware {
|
||||||
|
#[clap(subcommand)]
|
||||||
|
subcommand: Firmware,
|
||||||
|
},
|
||||||
|
/// L2cap client/server operations
|
||||||
|
L2cap {
|
||||||
|
#[command(subcommand)]
|
||||||
|
subcommand: L2cap,
|
||||||
|
|
||||||
|
/// Device configuration file.
|
||||||
|
///
|
||||||
|
/// See, for instance, `examples/device1.json` in the Python project.
|
||||||
|
#[arg(long)]
|
||||||
|
device_config: path::PathBuf,
|
||||||
|
/// Bumble transport spec.
|
||||||
|
///
|
||||||
|
/// <https://google.github.io/bumble/transports/index.html>
|
||||||
|
#[arg(long)]
|
||||||
|
transport: String,
|
||||||
|
|
||||||
|
/// PSM for L2CAP Connection-oriented Channel.
|
||||||
|
///
|
||||||
|
/// Must be in the range [0, 65535].
|
||||||
|
#[arg(long)]
|
||||||
|
psm: u16,
|
||||||
|
|
||||||
|
/// Maximum L2CAP CoC Credits. When not specified, lets Bumble set the default.
|
||||||
|
///
|
||||||
|
/// Must be in the range [1, 65535].
|
||||||
|
#[arg(long, value_parser = clap::value_parser!(u16).range(1..))]
|
||||||
|
l2cap_coc_max_credits: Option<u16>,
|
||||||
|
|
||||||
|
/// L2CAP CoC MTU. When not specified, lets Bumble set the default.
|
||||||
|
///
|
||||||
|
/// Must be in the range [23, 65535].
|
||||||
|
#[arg(long, value_parser = clap::value_parser!(u16).range(23..))]
|
||||||
|
l2cap_coc_mtu: Option<u16>,
|
||||||
|
|
||||||
|
/// L2CAP CoC MPS. When not specified, lets Bumble set the default.
|
||||||
|
///
|
||||||
|
/// Must be in the range [23, 65535].
|
||||||
|
#[arg(long, value_parser = clap::value_parser!(u16).range(23..))]
|
||||||
|
l2cap_coc_mps: Option<u16>,
|
||||||
|
},
|
||||||
|
/// USB operations
|
||||||
|
Usb {
|
||||||
|
#[clap(subcommand)]
|
||||||
|
subcommand: Usb,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Subcommand, Debug, Clone)]
|
||||||
|
enum Firmware {
|
||||||
|
/// Manage Realtek chipset firmware
|
||||||
|
Realtek {
|
||||||
|
#[clap(subcommand)]
|
||||||
|
subcommand: Realtek,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Subcommand, Debug, Clone)]
|
||||||
|
|
||||||
|
enum Realtek {
|
||||||
|
/// Download Realtek firmware
|
||||||
|
Download(Download),
|
||||||
|
/// Drop firmware from a USB device
|
||||||
|
Drop {
|
||||||
|
/// Bumble transport spec. Must be for a USB device.
|
||||||
|
///
|
||||||
|
/// <https://google.github.io/bumble/transports/index.html>
|
||||||
|
#[arg(long)]
|
||||||
|
transport: String,
|
||||||
|
},
|
||||||
|
/// Show driver info for a USB device
|
||||||
|
Info {
|
||||||
|
/// Bumble transport spec. Must be for a USB device.
|
||||||
|
///
|
||||||
|
/// <https://google.github.io/bumble/transports/index.html>
|
||||||
|
#[arg(long)]
|
||||||
|
transport: String,
|
||||||
|
/// Try to resolve driver info even if USB info is not available, or if the USB
|
||||||
|
/// (vendor,product) tuple is not in the list of known compatible RTK USB dongles.
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
force: bool,
|
||||||
|
},
|
||||||
|
/// Load firmware onto a USB device
|
||||||
|
Load {
|
||||||
|
/// Bumble transport spec. Must be for a USB device.
|
||||||
|
///
|
||||||
|
/// <https://google.github.io/bumble/transports/index.html>
|
||||||
|
#[arg(long)]
|
||||||
|
transport: String,
|
||||||
|
/// Load firmware even if the USB info doesn't match.
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
force: bool,
|
||||||
|
},
|
||||||
|
/// Parse a firmware file
|
||||||
|
Parse {
|
||||||
|
/// Firmware file to parse
|
||||||
|
firmware_path: path::PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Args, Debug, Clone)]
|
||||||
|
struct Download {
|
||||||
|
/// Directory to download to. Defaults to an OS-specific path specific to the Bumble tool.
|
||||||
|
#[arg(long)]
|
||||||
|
output_dir: Option<path::PathBuf>,
|
||||||
|
/// Source to download from
|
||||||
|
#[arg(long, default_value_t = Source::LinuxKernel)]
|
||||||
|
source: Source,
|
||||||
|
/// Only download a single image
|
||||||
|
#[arg(long, value_name = "base name")]
|
||||||
|
single: Option<String>,
|
||||||
|
/// Overwrite existing files
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
overwrite: bool,
|
||||||
|
/// Don't print the parse results for the downloaded file names
|
||||||
|
#[arg(long)]
|
||||||
|
no_parse: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, clap::ValueEnum)]
|
||||||
|
enum Source {
|
||||||
|
LinuxKernel,
|
||||||
|
RealtekOpensource,
|
||||||
|
LinuxFromScratch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Source {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Source::LinuxKernel => write!(f, "linux-kernel"),
|
||||||
|
Source::RealtekOpensource => write!(f, "realtek-opensource"),
|
||||||
|
Source::LinuxFromScratch => write!(f, "linux-from-scratch"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Subcommand, Debug, Clone)]
|
||||||
|
enum L2cap {
|
||||||
|
/// Starts an L2CAP server
|
||||||
|
Server {
|
||||||
|
/// TCP host that the l2cap server will connect to.
|
||||||
|
/// Data is bridged like so:
|
||||||
|
/// TCP server <-> (TCP client / **L2CAP server**) <-> (L2CAP client / TCP server) <-> TCP client
|
||||||
|
#[arg(long, default_value = "localhost")]
|
||||||
|
tcp_host: String,
|
||||||
|
/// TCP port that the server will connect to.
|
||||||
|
///
|
||||||
|
/// Must be in the range [1, 65535].
|
||||||
|
#[arg(long, default_value_t = 9544)]
|
||||||
|
tcp_port: u16,
|
||||||
|
},
|
||||||
|
/// Starts an L2CAP client
|
||||||
|
Client {
|
||||||
|
/// L2cap server address that this l2cap client will connect to.
|
||||||
|
bluetooth_address: String,
|
||||||
|
/// TCP host that the l2cap client will bind to and listen for incoming TCP connections.
|
||||||
|
/// Data is bridged like so:
|
||||||
|
/// TCP client <-> (TCP server / **L2CAP client**) <-> (L2CAP server / TCP client) <-> TCP server
|
||||||
|
#[arg(long, default_value = "localhost")]
|
||||||
|
tcp_host: String,
|
||||||
|
/// TCP port that the client will connect to.
|
||||||
|
///
|
||||||
|
/// Must be in the range [1, 65535].
|
||||||
|
#[arg(long, default_value_t = 9543)]
|
||||||
|
tcp_port: u16,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Subcommand, Debug, Clone)]
|
||||||
|
enum Usb {
|
||||||
|
/// Probe the USB bus for Bluetooth devices
|
||||||
|
Probe(Probe),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Args, Debug, Clone)]
|
||||||
|
struct Probe {
|
||||||
|
/// Show additional info for each USB device
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
verbose: bool,
|
||||||
|
}
|
||||||
@@ -19,13 +19,19 @@ use crate::{
|
|||||||
wrapper::{
|
wrapper::{
|
||||||
core::AdvertisingData,
|
core::AdvertisingData,
|
||||||
gatt_client::{ProfileServiceProxy, ServiceProxy},
|
gatt_client::{ProfileServiceProxy, ServiceProxy},
|
||||||
hci::Address,
|
hci::{Address, HciErrorCode},
|
||||||
|
host::Host,
|
||||||
|
l2cap::LeConnectionOrientedChannel,
|
||||||
transport::{Sink, Source},
|
transport::{Sink, Source},
|
||||||
ClosureCallback,
|
ClosureCallback, PyDictExt, PyObjectExt,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use pyo3::types::PyDict;
|
use pyo3::{
|
||||||
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject};
|
intern,
|
||||||
|
types::{PyDict, PyModule},
|
||||||
|
IntoPy, PyObject, PyResult, Python, ToPyObject,
|
||||||
|
};
|
||||||
|
use pyo3_asyncio::tokio::into_future;
|
||||||
use std::path;
|
use std::path;
|
||||||
|
|
||||||
/// A device that can send/receive HCI frames.
|
/// A device that can send/receive HCI frames.
|
||||||
@@ -65,7 +71,7 @@ impl Device {
|
|||||||
Python::with_gil(|py| {
|
Python::with_gil(|py| {
|
||||||
self.0
|
self.0
|
||||||
.call_method0(py, intern!(py, "power_on"))
|
.call_method0(py, intern!(py, "power_on"))
|
||||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||||
})?
|
})?
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
@@ -76,12 +82,28 @@ impl Device {
|
|||||||
Python::with_gil(|py| {
|
Python::with_gil(|py| {
|
||||||
self.0
|
self.0
|
||||||
.call_method1(py, intern!(py, "connect"), (peer_addr,))
|
.call_method1(py, intern!(py, "connect"), (peer_addr,))
|
||||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||||
})?
|
})?
|
||||||
.await
|
.await
|
||||||
.map(Connection)
|
.map(Connection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register a callback to be called for each incoming connection.
|
||||||
|
pub fn on_connection(
|
||||||
|
&mut self,
|
||||||
|
callback: impl Fn(Python, Connection) -> PyResult<()> + Send + 'static,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
let boxed = ClosureCallback::new(move |py, args, _kwargs| {
|
||||||
|
callback(py, Connection(args.get_item(0)?.into()))
|
||||||
|
});
|
||||||
|
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method1(py, intern!(py, "add_listener"), ("connection", boxed))
|
||||||
|
})
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
/// Start scanning
|
/// Start scanning
|
||||||
pub async fn start_scanning(&self, filter_duplicates: bool) -> PyResult<()> {
|
pub async fn start_scanning(&self, filter_duplicates: bool) -> PyResult<()> {
|
||||||
Python::with_gil(|py| {
|
Python::with_gil(|py| {
|
||||||
@@ -89,7 +111,7 @@ impl Device {
|
|||||||
kwargs.set_item("filter_duplicates", filter_duplicates)?;
|
kwargs.set_item("filter_duplicates", filter_duplicates)?;
|
||||||
self.0
|
self.0
|
||||||
.call_method(py, intern!(py, "start_scanning"), (), Some(kwargs))
|
.call_method(py, intern!(py, "start_scanning"), (), Some(kwargs))
|
||||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||||
})?
|
})?
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
@@ -123,6 +145,15 @@ impl Device {
|
|||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the host used by the device, if any
|
||||||
|
pub fn host(&mut self) -> PyResult<Option<Host>> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.getattr(py, intern!(py, "host"))
|
||||||
|
.map(|obj| obj.into_option(Host::from))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Start advertising the data set with [Device.set_advertisement].
|
/// Start advertising the data set with [Device.set_advertisement].
|
||||||
pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> {
|
pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> {
|
||||||
Python::with_gil(|py| {
|
Python::with_gil(|py| {
|
||||||
@@ -131,7 +162,7 @@ impl Device {
|
|||||||
|
|
||||||
self.0
|
self.0
|
||||||
.call_method(py, intern!(py, "start_advertising"), (), Some(kwargs))
|
.call_method(py, intern!(py, "start_advertising"), (), Some(kwargs))
|
||||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||||
})?
|
})?
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
@@ -142,16 +173,114 @@ impl Device {
|
|||||||
Python::with_gil(|py| {
|
Python::with_gil(|py| {
|
||||||
self.0
|
self.0
|
||||||
.call_method0(py, intern!(py, "stop_advertising"))
|
.call_method0(py, intern!(py, "stop_advertising"))
|
||||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||||
})?
|
})?
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registers an L2CAP connection oriented channel server. When a client connects to the server,
|
||||||
|
/// the `server` callback is passed a handle to the established channel. When optional arguments
|
||||||
|
/// are not specified, the Python module specifies the defaults.
|
||||||
|
pub fn register_l2cap_channel_server(
|
||||||
|
&mut self,
|
||||||
|
psm: u16,
|
||||||
|
server: impl Fn(Python, LeConnectionOrientedChannel) -> PyResult<()> + Send + 'static,
|
||||||
|
max_credits: Option<u16>,
|
||||||
|
mtu: Option<u16>,
|
||||||
|
mps: Option<u16>,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
let boxed = ClosureCallback::new(move |py, args, _kwargs| {
|
||||||
|
server(
|
||||||
|
py,
|
||||||
|
LeConnectionOrientedChannel::from(args.get_item(0)?.into()),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let kwargs = PyDict::new(py);
|
||||||
|
kwargs.set_item("psm", psm)?;
|
||||||
|
kwargs.set_item("server", boxed.into_py(py))?;
|
||||||
|
kwargs.set_opt_item("max_credits", max_credits)?;
|
||||||
|
kwargs.set_opt_item("mtu", mtu)?;
|
||||||
|
kwargs.set_opt_item("mps", mps)?;
|
||||||
|
self.0.call_method(
|
||||||
|
py,
|
||||||
|
intern!(py, "register_l2cap_channel_server"),
|
||||||
|
(),
|
||||||
|
Some(kwargs),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A connection to a remote device.
|
/// A connection to a remote device.
|
||||||
pub struct Connection(PyObject);
|
pub struct Connection(PyObject);
|
||||||
|
|
||||||
|
impl Connection {
|
||||||
|
/// Open an L2CAP channel using this connection. When optional arguments are not specified, the
|
||||||
|
/// Python module specifies the defaults.
|
||||||
|
pub async fn open_l2cap_channel(
|
||||||
|
&mut self,
|
||||||
|
psm: u16,
|
||||||
|
max_credits: Option<u16>,
|
||||||
|
mtu: Option<u16>,
|
||||||
|
mps: Option<u16>,
|
||||||
|
) -> PyResult<LeConnectionOrientedChannel> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
let kwargs = PyDict::new(py);
|
||||||
|
kwargs.set_item("psm", psm)?;
|
||||||
|
kwargs.set_opt_item("max_credits", max_credits)?;
|
||||||
|
kwargs.set_opt_item("mtu", mtu)?;
|
||||||
|
kwargs.set_opt_item("mps", mps)?;
|
||||||
|
self.0
|
||||||
|
.call_method(py, intern!(py, "open_l2cap_channel"), (), Some(kwargs))
|
||||||
|
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(LeConnectionOrientedChannel::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disconnect from device with provided reason. When optional arguments are not specified, the
|
||||||
|
/// Python module specifies the defaults.
|
||||||
|
pub async fn disconnect(&mut self, reason: Option<HciErrorCode>) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
let kwargs = PyDict::new(py);
|
||||||
|
kwargs.set_opt_item("reason", reason)?;
|
||||||
|
self.0
|
||||||
|
.call_method(py, intern!(py, "disconnect"), (), Some(kwargs))
|
||||||
|
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a callback to be called on disconnection.
|
||||||
|
pub fn on_disconnection(
|
||||||
|
&mut self,
|
||||||
|
callback: impl Fn(Python, HciErrorCode) -> PyResult<()> + Send + 'static,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
let boxed = ClosureCallback::new(move |py, args, _kwargs| {
|
||||||
|
callback(py, args.get_item(0)?.extract()?)
|
||||||
|
});
|
||||||
|
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method1(py, intern!(py, "add_listener"), ("disconnection", boxed))
|
||||||
|
})
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns some information about the connection as a [String].
|
||||||
|
pub fn debug_string(&self) -> PyResult<String> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
let str_obj = self.0.call_method0(py, intern!(py, "__str__"))?;
|
||||||
|
str_obj.gil_ref(py).extract()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The other end of a connection
|
/// The other end of a connection
|
||||||
pub struct Peer(PyObject);
|
pub struct Peer(PyObject);
|
||||||
|
|
||||||
@@ -173,7 +302,7 @@ impl Peer {
|
|||||||
Python::with_gil(|py| {
|
Python::with_gil(|py| {
|
||||||
self.0
|
self.0
|
||||||
.call_method0(py, intern!(py, "discover_services"))
|
.call_method0(py, intern!(py, "discover_services"))
|
||||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||||
})?
|
})?
|
||||||
.await
|
.await
|
||||||
.and_then(|list| {
|
.and_then(|list| {
|
||||||
@@ -207,13 +336,7 @@ impl Peer {
|
|||||||
let class = module.getattr(P::PROXY_CLASS_NAME)?;
|
let class = module.getattr(P::PROXY_CLASS_NAME)?;
|
||||||
self.0
|
self.0
|
||||||
.call_method1(py, intern!(py, "create_service_proxy"), (class,))
|
.call_method1(py, intern!(py, "create_service_proxy"), (class,))
|
||||||
.map(|obj| {
|
.map(|obj| obj.into_option(P::wrap))
|
||||||
if obj.is_none(py) {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(P::wrap(obj))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
rust/src/wrapper/drivers/mod.rs
Normal file
17
rust/src/wrapper/drivers/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Device drivers
|
||||||
|
|
||||||
|
pub mod rtk;
|
||||||
141
rust/src/wrapper/drivers/rtk.rs
Normal file
141
rust/src/wrapper/drivers/rtk.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Drivers for Realtek controllers
|
||||||
|
|
||||||
|
use crate::wrapper::{host::Host, PyObjectExt};
|
||||||
|
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject};
|
||||||
|
use pyo3_asyncio::tokio::into_future;
|
||||||
|
|
||||||
|
pub use crate::internal::drivers::rtk::{Firmware, Patch};
|
||||||
|
|
||||||
|
/// Driver for a Realtek controller
|
||||||
|
pub struct Driver(PyObject);
|
||||||
|
|
||||||
|
impl Driver {
|
||||||
|
/// Locate the driver for the provided host.
|
||||||
|
pub async fn for_host(host: &Host, force: bool) -> PyResult<Option<Self>> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
|
||||||
|
.getattr(intern!(py, "Driver"))?
|
||||||
|
.call_method1(intern!(py, "for_host"), (&host.obj, force))
|
||||||
|
.and_then(into_future)
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(|obj| obj.into_option(Self))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the host has a known driver.
|
||||||
|
pub async fn check(host: &Host) -> PyResult<bool> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
|
||||||
|
.getattr(intern!(py, "Driver"))?
|
||||||
|
.call_method1(intern!(py, "check"), (&host.obj,))
|
||||||
|
.and_then(|obj| obj.extract::<bool>())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the [DriverInfo] for the host, if one matches
|
||||||
|
pub async fn driver_info_for_host(host: &Host) -> PyResult<Option<DriverInfo>> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
|
||||||
|
.getattr(intern!(py, "Driver"))?
|
||||||
|
.call_method1(intern!(py, "driver_info_for_host"), (&host.obj,))
|
||||||
|
.and_then(into_future)
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(|obj| obj.into_option(DriverInfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a command to the device to drop firmware
|
||||||
|
pub async fn drop_firmware(host: &mut Host) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
|
||||||
|
.getattr(intern!(py, "Driver"))?
|
||||||
|
.call_method1(intern!(py, "drop_firmware"), (&host.obj,))
|
||||||
|
.and_then(into_future)
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load firmware onto the device.
|
||||||
|
pub async fn download_firmware(&mut self) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method0(py, intern!(py, "download_firmware"))
|
||||||
|
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata about a known driver & applicable device
|
||||||
|
pub struct DriverInfo(PyObject);
|
||||||
|
|
||||||
|
impl DriverInfo {
|
||||||
|
/// Returns a list of all drivers that Bumble knows how to handle.
|
||||||
|
pub fn all_drivers() -> PyResult<Vec<DriverInfo>> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
|
||||||
|
.getattr(intern!(py, "Driver"))?
|
||||||
|
.getattr(intern!(py, "DRIVER_INFOS"))?
|
||||||
|
.iter()?
|
||||||
|
.map(|r| r.map(|h| DriverInfo(h.to_object(py))))
|
||||||
|
.collect::<PyResult<Vec<_>>>()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The firmware file name to load from the filesystem, e.g. `foo_fw.bin`.
|
||||||
|
pub fn firmware_name(&self) -> PyResult<String> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.getattr(py, intern!(py, "fw_name"))?
|
||||||
|
.as_ref(py)
|
||||||
|
.extract::<String>()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The config file name, if any, to load from the filesystem, e.g. `foo_config.bin`.
|
||||||
|
pub fn config_name(&self) -> PyResult<Option<String>> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
let obj = self.0.getattr(py, intern!(py, "config_name"))?;
|
||||||
|
let handle = obj.as_ref(py);
|
||||||
|
|
||||||
|
if handle.is_none() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
handle
|
||||||
|
.extract::<String>()
|
||||||
|
.map(|s| if s.is_empty() { None } else { Some(s) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether or not config is required.
|
||||||
|
pub fn config_needed(&self) -> PyResult<bool> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.getattr(py, intern!(py, "config_needed"))?
|
||||||
|
.as_ref(py)
|
||||||
|
.extract::<bool>()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ROM id
|
||||||
|
pub fn rom(&self) -> PyResult<u32> {
|
||||||
|
Python::with_gil(|py| self.0.getattr(py, intern!(py, "rom"))?.as_ref(py).extract())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,40 @@
|
|||||||
//! HCI
|
//! HCI
|
||||||
|
|
||||||
use itertools::Itertools as _;
|
use itertools::Itertools as _;
|
||||||
use pyo3::{exceptions::PyException, intern, types::PyModule, PyErr, PyObject, PyResult, Python};
|
use pyo3::{
|
||||||
|
exceptions::PyException, intern, types::PyModule, FromPyObject, PyAny, PyErr, PyObject,
|
||||||
|
PyResult, Python, ToPyObject,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// HCI error code.
|
||||||
|
pub struct HciErrorCode(u8);
|
||||||
|
|
||||||
|
impl<'source> FromPyObject<'source> for HciErrorCode {
|
||||||
|
fn extract(ob: &'source PyAny) -> PyResult<Self> {
|
||||||
|
Ok(HciErrorCode(ob.extract()?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToPyObject for HciErrorCode {
|
||||||
|
fn to_object(&self, py: Python<'_>) -> PyObject {
|
||||||
|
self.0.to_object(py)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provides helpers for interacting with HCI
|
||||||
|
pub struct HciConstant;
|
||||||
|
|
||||||
|
impl HciConstant {
|
||||||
|
/// Human-readable error name
|
||||||
|
pub fn error_name(status: HciErrorCode) -> PyResult<String> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
PyModule::import(py, intern!(py, "bumble.hci"))?
|
||||||
|
.getattr(intern!(py, "HCI_Constant"))?
|
||||||
|
.call_method1(intern!(py, "error_name"), (status.0,))?
|
||||||
|
.extract()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A Bluetooth address
|
/// A Bluetooth address
|
||||||
pub struct Address(pub(crate) PyObject);
|
pub struct Address(pub(crate) PyObject);
|
||||||
|
|||||||
71
rust/src/wrapper/host.rs
Normal file
71
rust/src/wrapper/host.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! Host-side types
|
||||||
|
|
||||||
|
use crate::wrapper::transport::{Sink, Source};
|
||||||
|
use pyo3::{intern, prelude::PyModule, types::PyDict, PyObject, PyResult, Python};
|
||||||
|
|
||||||
|
/// Host HCI commands
|
||||||
|
pub struct Host {
|
||||||
|
pub(crate) obj: PyObject,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Host {
|
||||||
|
/// Create a Host that wraps the provided obj
|
||||||
|
pub(crate) fn from(obj: PyObject) -> Self {
|
||||||
|
Self { obj }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new Host
|
||||||
|
pub fn new(source: Source, sink: Sink) -> PyResult<Self> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
PyModule::import(py, intern!(py, "bumble.host"))?
|
||||||
|
.getattr(intern!(py, "Host"))?
|
||||||
|
.call((source.0, sink.0), None)
|
||||||
|
.map(|any| Self { obj: any.into() })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a reset command and perform other reset tasks.
|
||||||
|
pub async fn reset(&mut self, driver_factory: DriverFactory) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
let kwargs = match driver_factory {
|
||||||
|
DriverFactory::None => {
|
||||||
|
let kw = PyDict::new(py);
|
||||||
|
kw.set_item("driver_factory", py.None())?;
|
||||||
|
Some(kw)
|
||||||
|
}
|
||||||
|
DriverFactory::Auto => {
|
||||||
|
// leave the default in place
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.obj
|
||||||
|
.call_method(py, intern!(py, "reset"), (), kwargs)
|
||||||
|
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Driver factory to use when initializing a host
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum DriverFactory {
|
||||||
|
/// Do not load drivers
|
||||||
|
None,
|
||||||
|
/// Load appropriate driver, if any is found
|
||||||
|
Auto,
|
||||||
|
}
|
||||||
92
rust/src/wrapper/l2cap.rs
Normal file
92
rust/src/wrapper/l2cap.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! L2CAP
|
||||||
|
|
||||||
|
use crate::wrapper::{ClosureCallback, PyObjectExt};
|
||||||
|
use pyo3::{intern, PyObject, PyResult, Python};
|
||||||
|
|
||||||
|
/// L2CAP connection-oriented channel
|
||||||
|
pub struct LeConnectionOrientedChannel(PyObject);
|
||||||
|
|
||||||
|
impl LeConnectionOrientedChannel {
|
||||||
|
/// Create a LeConnectionOrientedChannel that wraps the provided obj.
|
||||||
|
pub(crate) fn from(obj: PyObject) -> Self {
|
||||||
|
Self(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues data to be automatically sent across this channel.
|
||||||
|
pub fn write(&mut self, data: &[u8]) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| self.0.call_method1(py, intern!(py, "write"), (data,))).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for queued data to be sent on this channel.
|
||||||
|
pub async fn drain(&mut self) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method0(py, intern!(py, "drain"))
|
||||||
|
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a callback to be called when the channel is closed.
|
||||||
|
pub fn on_close(
|
||||||
|
&mut self,
|
||||||
|
callback: impl Fn(Python) -> PyResult<()> + Send + 'static,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
let boxed = ClosureCallback::new(move |py, _args, _kwargs| callback(py));
|
||||||
|
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method1(py, intern!(py, "add_listener"), ("close", boxed))
|
||||||
|
})
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a callback to be called when the channel receives data.
|
||||||
|
pub fn set_sink(
|
||||||
|
&mut self,
|
||||||
|
callback: impl Fn(Python, &[u8]) -> PyResult<()> + Send + 'static,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
let boxed = ClosureCallback::new(move |py, args, _kwargs| {
|
||||||
|
callback(py, args.get_item(0)?.extract()?)
|
||||||
|
});
|
||||||
|
Python::with_gil(|py| self.0.setattr(py, intern!(py, "sink"), boxed)).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disconnect the l2cap channel.
|
||||||
|
/// Must be called from a thread with a Python event loop, which should be true on
|
||||||
|
/// `tokio::main` and `async_std::main`.
|
||||||
|
///
|
||||||
|
/// For more info, see https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars.
|
||||||
|
pub async fn disconnect(&mut self) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method0(py, intern!(py, "disconnect"))
|
||||||
|
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns some information about the channel as a [String].
|
||||||
|
pub fn debug_string(&self) -> PyResult<String> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
let str_obj = self.0.call_method0(py, intern!(py, "__str__"))?;
|
||||||
|
str_obj.gil_ref(py).extract()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,14 +31,17 @@ pub use pyo3_asyncio;
|
|||||||
pub mod assigned_numbers;
|
pub mod assigned_numbers;
|
||||||
pub mod core;
|
pub mod core;
|
||||||
pub mod device;
|
pub mod device;
|
||||||
|
pub mod drivers;
|
||||||
pub mod gatt_client;
|
pub mod gatt_client;
|
||||||
pub mod hci;
|
pub mod hci;
|
||||||
|
pub mod host;
|
||||||
|
pub mod l2cap;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
|
||||||
/// Convenience extensions to [PyObject]
|
/// Convenience extensions to [PyObject]
|
||||||
pub trait PyObjectExt {
|
pub trait PyObjectExt: Sized {
|
||||||
/// Get a GIL-bound reference
|
/// Get a GIL-bound reference
|
||||||
fn gil_ref<'py>(&'py self, py: Python<'py>) -> &'py PyAny;
|
fn gil_ref<'py>(&'py self, py: Python<'py>) -> &'py PyAny;
|
||||||
|
|
||||||
@@ -49,6 +52,17 @@ pub trait PyObjectExt {
|
|||||||
{
|
{
|
||||||
Python::with_gil(|py| self.gil_ref(py).extract::<T>())
|
Python::with_gil(|py| self.gil_ref(py).extract::<T>())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If the Python object is a Python `None`, return a Rust `None`, otherwise `Some` with the mapped type
|
||||||
|
fn into_option<T>(self, map_obj: impl Fn(Self) -> T) -> Option<T> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
if self.gil_ref(py).is_none() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(map_obj(self))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PyObjectExt for PyObject {
|
impl PyObjectExt for PyObject {
|
||||||
@@ -57,6 +71,21 @@ impl PyObjectExt for PyObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convenience extensions to [PyDict]
|
||||||
|
pub trait PyDictExt {
|
||||||
|
/// Set item in dict only if value is Some, otherwise do nothing.
|
||||||
|
fn set_opt_item<K: ToPyObject, V: ToPyObject>(&self, key: K, value: Option<V>) -> PyResult<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PyDictExt for PyDict {
|
||||||
|
fn set_opt_item<K: ToPyObject, V: ToPyObject>(&self, key: K, value: Option<V>) -> PyResult<()> {
|
||||||
|
if let Some(value) = value {
|
||||||
|
self.set_item(key, value)?
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Wrapper to make Rust closures ([Fn] implementations) callable from Python.
|
/// Wrapper to make Rust closures ([Fn] implementations) callable from Python.
|
||||||
///
|
///
|
||||||
/// The Python callable form returns a Python `None`.
|
/// The Python callable form returns a Python `None`.
|
||||||
|
|||||||
@@ -14,7 +14,10 @@
|
|||||||
|
|
||||||
//! GATT profiles
|
//! GATT profiles
|
||||||
|
|
||||||
use crate::wrapper::gatt_client::{CharacteristicProxy, ProfileServiceProxy};
|
use crate::wrapper::{
|
||||||
|
gatt_client::{CharacteristicProxy, ProfileServiceProxy},
|
||||||
|
PyObjectExt,
|
||||||
|
};
|
||||||
use pyo3::{intern, PyObject, PyResult, Python};
|
use pyo3::{intern, PyObject, PyResult, Python};
|
||||||
|
|
||||||
/// Exposes the battery GATT service
|
/// Exposes the battery GATT service
|
||||||
@@ -26,13 +29,7 @@ impl BatteryServiceProxy {
|
|||||||
Python::with_gil(|py| {
|
Python::with_gil(|py| {
|
||||||
self.0
|
self.0
|
||||||
.getattr(py, intern!(py, "battery_level"))
|
.getattr(py, intern!(py, "battery_level"))
|
||||||
.map(|level| {
|
.map(|level| level.into_option(CharacteristicProxy))
|
||||||
if level.is_none(py) {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(CharacteristicProxy(level))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ install_requires =
|
|||||||
humanize >= 4.6.0; platform_system!='Emscripten'
|
humanize >= 4.6.0; platform_system!='Emscripten'
|
||||||
libusb1 >= 2.0.1; platform_system!='Emscripten'
|
libusb1 >= 2.0.1; platform_system!='Emscripten'
|
||||||
libusb-package == 1.0.26.1; platform_system!='Emscripten'
|
libusb-package == 1.0.26.1; platform_system!='Emscripten'
|
||||||
|
platformdirs == 3.10.0; platform_system!='Emscripten'
|
||||||
prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
|
prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
|
||||||
prettytable >= 3.6.0; platform_system!='Emscripten'
|
prettytable >= 3.6.0; platform_system!='Emscripten'
|
||||||
protobuf >= 3.12.4; platform_system!='Emscripten'
|
protobuf >= 3.12.4; platform_system!='Emscripten'
|
||||||
|
|||||||
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 asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import pathlib
|
||||||
|
import pytest
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -83,12 +85,22 @@ JSON3 = """
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def test_basic():
|
@pytest.fixture
|
||||||
with tempfile.NamedTemporaryFile(mode="r+", encoding='utf-8') as file:
|
def temporary_file():
|
||||||
keystore = JsonKeyStore('my_namespace', file.name)
|
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.write("{}")
|
||||||
file.flush()
|
file.flush()
|
||||||
|
|
||||||
|
keystore = JsonKeyStore('my_namespace', temporary_file)
|
||||||
|
|
||||||
keys = await keystore.get_all()
|
keys = await keystore.get_all()
|
||||||
assert len(keys) == 0
|
assert len(keys) == 0
|
||||||
|
|
||||||
@@ -105,7 +117,6 @@ async def test_basic():
|
|||||||
assert foo.ltk is not None
|
assert foo.ltk is not None
|
||||||
assert foo.ltk.value == ltk
|
assert foo.ltk.value == ltk
|
||||||
|
|
||||||
file.flush()
|
|
||||||
with open(file.name, "r", encoding="utf-8") as json_file:
|
with open(file.name, "r", encoding="utf-8") as json_file:
|
||||||
json_data = json.load(json_file)
|
json_data = json.load(json_file)
|
||||||
assert 'my_namespace' in json_data
|
assert 'my_namespace' in json_data
|
||||||
@@ -114,51 +125,50 @@ async def test_basic():
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def test_parsing():
|
async def test_parsing(temporary_file):
|
||||||
with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
|
with open(temporary_file, mode='w', encoding='utf-8') as file:
|
||||||
keystore = JsonKeyStore('my_namespace', file.name)
|
|
||||||
file.write(JSON1)
|
file.write(JSON1)
|
||||||
file.flush()
|
file.flush()
|
||||||
|
|
||||||
|
keystore = JsonKeyStore('my_namespace', file.name)
|
||||||
foo = await keystore.get('14:7D:DA:4E:53:A8/P')
|
foo = await keystore.get('14:7D:DA:4E:53:A8/P')
|
||||||
assert foo is not None
|
assert foo is not None
|
||||||
assert foo.ltk.value == bytes.fromhex('d1897ee10016eb1a08e4e037fd54c683')
|
assert foo.ltk.value == bytes.fromhex('d1897ee10016eb1a08e4e037fd54c683')
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def test_default_namespace():
|
async def test_default_namespace(temporary_file):
|
||||||
with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
|
with open(temporary_file, mode='w', encoding='utf-8') as file:
|
||||||
keystore = JsonKeyStore(None, file.name)
|
|
||||||
file.write(JSON1)
|
file.write(JSON1)
|
||||||
file.flush()
|
file.flush()
|
||||||
|
|
||||||
|
keystore = JsonKeyStore(None, file.name)
|
||||||
all_keys = await keystore.get_all()
|
all_keys = await keystore.get_all()
|
||||||
assert len(all_keys) == 1
|
assert len(all_keys) == 1
|
||||||
name, keys = all_keys[0]
|
name, keys = all_keys[0]
|
||||||
assert name == '14:7D:DA:4E:53:A8/P'
|
assert name == '14:7D:DA:4E:53:A8/P'
|
||||||
assert keys.irk.value == bytes.fromhex('e7b2543b206e4e46b44f9e51dad22bd1')
|
assert keys.irk.value == bytes.fromhex('e7b2543b206e4e46b44f9e51dad22bd1')
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
|
with open(temporary_file, mode='w', encoding='utf-8') as file:
|
||||||
keystore = JsonKeyStore(None, file.name)
|
|
||||||
file.write(JSON2)
|
file.write(JSON2)
|
||||||
file.flush()
|
file.flush()
|
||||||
|
|
||||||
|
keystore = JsonKeyStore(None, file.name)
|
||||||
keys = PairingKeys()
|
keys = PairingKeys()
|
||||||
ltk = bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
|
ltk = bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
|
||||||
keys.ltk = PairingKeys.Key(ltk)
|
keys.ltk = PairingKeys.Key(ltk)
|
||||||
await keystore.update('foo', keys)
|
await keystore.update('foo', keys)
|
||||||
file.flush()
|
|
||||||
with open(file.name, "r", encoding="utf-8") as json_file:
|
with open(file.name, "r", encoding="utf-8") as json_file:
|
||||||
json_data = json.load(json_file)
|
json_data = json.load(json_file)
|
||||||
assert '__DEFAULT__' in json_data
|
assert '__DEFAULT__' in json_data
|
||||||
assert 'foo' in json_data['__DEFAULT__']
|
assert 'foo' in json_data['__DEFAULT__']
|
||||||
assert 'ltk' in json_data['__DEFAULT__']['foo']
|
assert 'ltk' in json_data['__DEFAULT__']['foo']
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
|
with open(temporary_file, mode='w', encoding='utf-8') as file:
|
||||||
keystore = JsonKeyStore(None, file.name)
|
|
||||||
file.write(JSON3)
|
file.write(JSON3)
|
||||||
file.flush()
|
file.flush()
|
||||||
|
|
||||||
|
keystore = JsonKeyStore(None, file.name)
|
||||||
all_keys = await keystore.get_all()
|
all_keys = await keystore.get_all()
|
||||||
assert len(all_keys) == 1
|
assert len(all_keys) == 1
|
||||||
name, keys = all_keys[0]
|
name, keys = all_keys[0]
|
||||||
|
|||||||
@@ -67,8 +67,9 @@ def download_file(base_url, name, remove_suffix):
|
|||||||
@click.command
|
@click.command
|
||||||
@click.option(
|
@click.option(
|
||||||
"--output-dir",
|
"--output-dir",
|
||||||
default=".",
|
default="",
|
||||||
help="Output directory where the files will be saved",
|
help="Output directory where the files will be saved. Defaults to the OS-specific"
|
||||||
|
"app data dir, which the driver will check when trying to find firmware",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
@@ -84,6 +85,9 @@ def main(output_dir, source, single, force, parse):
|
|||||||
"""Download RTK firmware images and configs."""
|
"""Download RTK firmware images and configs."""
|
||||||
|
|
||||||
# Check that the output dir exists
|
# Check that the output dir exists
|
||||||
|
if output_dir == '':
|
||||||
|
output_dir = rtk.rtk_firmware_dir()
|
||||||
|
else:
|
||||||
output_dir = pathlib.Path(output_dir)
|
output_dir = pathlib.Path(output_dir)
|
||||||
if not output_dir.is_dir():
|
if not output_dir.is_dir():
|
||||||
print("Output dir does not exist or is not a directory")
|
print("Output dir does not exist or is not a directory")
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ async def do_load(usb_transport, force):
|
|||||||
# Get the driver.
|
# Get the driver.
|
||||||
driver = await rtk.Driver.for_host(host, force)
|
driver = await rtk.Driver.for_host(host, force)
|
||||||
if driver is None:
|
if driver is None:
|
||||||
if not force:
|
|
||||||
print("Firmware already loaded or no supported driver for this device.")
|
print("Firmware already loaded or no supported driver for this device.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user