forked from auracaster/bumble_mirror
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a51166af7 | |||
| 5aae44b610 | |||
| e3ea167827 | |||
| eec145e095 | |||
| 87fa02d6e5 | |||
| ad94c1e1f3 | |||
| 546a0bce8d | |||
| cb7ca44a1c | |||
| 4081b93407 | |||
| 26203ebaad | |||
| 3389e3e1ed | |||
| 7e1f01c01e | |||
| 613e15548a | |||
| e09c91df8e | |||
| df206667b6 | |||
| 0f19dd5263 | |||
| b98e4937f3 | |||
| c2c46e9ace | |||
| 27791cf218 | |||
| 32a41a815d | |||
| df5fc2ddfe | |||
| 79122313a6 | |||
| d7d03e2e92 | |||
| ea493480a9 | |||
| f8a2d4f0e0 | |||
| 00edd1fbf8 | |||
| 999d7b07e1 | |||
| 2e3aeb8648 | |||
| f910a696ad | |||
| e1d10bc482 | |||
| 181467f11b | |||
| 394137b6f7 | |||
| f5baf51132 | |||
| f2dc8bd84e |
@@ -0,0 +1,30 @@
|
|||||||
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||||
|
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||||
|
{
|
||||||
|
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/universal:2",
|
||||||
|
|
||||||
|
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||||
|
// "features": {},
|
||||||
|
|
||||||
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
|
// "forwardPorts": [],
|
||||||
|
|
||||||
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
|
"postCreateCommand":
|
||||||
|
"python -m pip install '.[build,test,development,documentation]'",
|
||||||
|
|
||||||
|
// Configure tool-specific properties.
|
||||||
|
"customizations": {
|
||||||
|
// Configure properties specific to VS Code.
|
||||||
|
"vscode": {
|
||||||
|
// Add the IDs of extensions you want installed when the container is created.
|
||||||
|
"extensions": [
|
||||||
|
"ms-python.python"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||||
|
// "remoteUser": "root"
|
||||||
|
}
|
||||||
+146
-35
@@ -40,6 +40,8 @@ from bumble.hci import (
|
|||||||
HCI_LE_1M_PHY,
|
HCI_LE_1M_PHY,
|
||||||
HCI_LE_2M_PHY,
|
HCI_LE_2M_PHY,
|
||||||
HCI_LE_CODED_PHY,
|
HCI_LE_CODED_PHY,
|
||||||
|
HCI_CENTRAL_ROLE,
|
||||||
|
HCI_PERIPHERAL_ROLE,
|
||||||
HCI_Constant,
|
HCI_Constant,
|
||||||
HCI_Error,
|
HCI_Error,
|
||||||
HCI_StatusError,
|
HCI_StatusError,
|
||||||
@@ -57,6 +59,7 @@ from bumble.transport import open_transport_or_link
|
|||||||
import bumble.rfcomm
|
import bumble.rfcomm
|
||||||
import bumble.core
|
import bumble.core
|
||||||
from bumble.utils import AsyncRunner
|
from bumble.utils import AsyncRunner
|
||||||
|
from bumble.pairing import PairingConfig
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -128,40 +131,34 @@ def le_phy_name(phy_id):
|
|||||||
|
|
||||||
|
|
||||||
def print_connection(connection):
|
def print_connection(connection):
|
||||||
|
params = []
|
||||||
if connection.transport == BT_LE_TRANSPORT:
|
if connection.transport == BT_LE_TRANSPORT:
|
||||||
phy_state = (
|
params.append(
|
||||||
'PHY='
|
'PHY='
|
||||||
f'TX:{le_phy_name(connection.phy.tx_phy)}/'
|
f'TX:{le_phy_name(connection.phy.tx_phy)}/'
|
||||||
f'RX:{le_phy_name(connection.phy.rx_phy)}'
|
f'RX:{le_phy_name(connection.phy.rx_phy)}'
|
||||||
)
|
)
|
||||||
|
|
||||||
data_length = (
|
params.append(
|
||||||
'DL=('
|
'DL=('
|
||||||
f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
|
f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
|
||||||
f'RX:{connection.data_length[2]}/{connection.data_length[3]}'
|
f'RX:{connection.data_length[2]}/{connection.data_length[3]}'
|
||||||
')'
|
')'
|
||||||
)
|
)
|
||||||
connection_parameters = (
|
|
||||||
|
params.append(
|
||||||
'Parameters='
|
'Parameters='
|
||||||
f'{connection.parameters.connection_interval * 1.25:.2f}/'
|
f'{connection.parameters.connection_interval * 1.25:.2f}/'
|
||||||
f'{connection.parameters.peripheral_latency}/'
|
f'{connection.parameters.peripheral_latency}/'
|
||||||
f'{connection.parameters.supervision_timeout * 10} '
|
f'{connection.parameters.supervision_timeout * 10} '
|
||||||
)
|
)
|
||||||
|
|
||||||
|
params.append(f'MTU={connection.att_mtu}')
|
||||||
|
|
||||||
else:
|
else:
|
||||||
phy_state = ''
|
params.append(f'Role={HCI_Constant.role_name(connection.role)}')
|
||||||
data_length = ''
|
|
||||||
connection_parameters = ''
|
|
||||||
|
|
||||||
mtu = connection.att_mtu
|
logging.info(color('@@@ Connection: ', 'yellow') + ' '.join(params))
|
||||||
|
|
||||||
logging.info(
|
|
||||||
f'{color("@@@ Connection:", "yellow")} '
|
|
||||||
f'{connection_parameters} '
|
|
||||||
f'{data_length} '
|
|
||||||
f'{phy_state} '
|
|
||||||
f'MTU={mtu}'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def make_sdp_records(channel):
|
def make_sdp_records(channel):
|
||||||
@@ -214,6 +211,17 @@ def log_stats(title, stats):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def switch_roles(connection, role):
|
||||||
|
target_role = HCI_CENTRAL_ROLE if role == "central" else HCI_PERIPHERAL_ROLE
|
||||||
|
if connection.role != target_role:
|
||||||
|
logging.info(f'{color("### Switching roles to:", "cyan")} {role}')
|
||||||
|
try:
|
||||||
|
await connection.switch_role(target_role)
|
||||||
|
logging.info(color('### Role switch complete', 'cyan'))
|
||||||
|
except HCI_Error as error:
|
||||||
|
logging.info(f'{color("### Role switch failed:", "red")} {error}')
|
||||||
|
|
||||||
|
|
||||||
class PacketType(enum.IntEnum):
|
class PacketType(enum.IntEnum):
|
||||||
RESET = 0
|
RESET = 0
|
||||||
SEQUENCE = 1
|
SEQUENCE = 1
|
||||||
@@ -899,14 +907,26 @@ class L2capServer(StreamedPacketIO):
|
|||||||
# RfcommClient
|
# RfcommClient
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class RfcommClient(StreamedPacketIO):
|
class RfcommClient(StreamedPacketIO):
|
||||||
def __init__(self, device, channel, uuid, l2cap_mtu, max_frame_size, window_size):
|
def __init__(
|
||||||
|
self,
|
||||||
|
device,
|
||||||
|
channel,
|
||||||
|
uuid,
|
||||||
|
l2cap_mtu,
|
||||||
|
max_frame_size,
|
||||||
|
initial_credits,
|
||||||
|
max_credits,
|
||||||
|
credits_threshold,
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.device = device
|
self.device = device
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.uuid = uuid
|
self.uuid = uuid
|
||||||
self.l2cap_mtu = l2cap_mtu
|
self.l2cap_mtu = l2cap_mtu
|
||||||
self.max_frame_size = max_frame_size
|
self.max_frame_size = max_frame_size
|
||||||
self.window_size = window_size
|
self.initial_credits = initial_credits
|
||||||
|
self.max_credits = max_credits
|
||||||
|
self.credits_threshold = credits_threshold
|
||||||
self.rfcomm_session = None
|
self.rfcomm_session = None
|
||||||
self.ready = asyncio.Event()
|
self.ready = asyncio.Event()
|
||||||
|
|
||||||
@@ -940,12 +960,17 @@ class RfcommClient(StreamedPacketIO):
|
|||||||
logging.info(color(f'### Opening session for channel {channel}...', 'yellow'))
|
logging.info(color(f'### Opening session for channel {channel}...', 'yellow'))
|
||||||
try:
|
try:
|
||||||
dlc_options = {}
|
dlc_options = {}
|
||||||
if self.max_frame_size:
|
if self.max_frame_size is not None:
|
||||||
dlc_options['max_frame_size'] = self.max_frame_size
|
dlc_options['max_frame_size'] = self.max_frame_size
|
||||||
if self.window_size:
|
if self.initial_credits is not None:
|
||||||
dlc_options['window_size'] = self.window_size
|
dlc_options['initial_credits'] = self.initial_credits
|
||||||
rfcomm_session = await rfcomm_mux.open_dlc(channel, **dlc_options)
|
rfcomm_session = await rfcomm_mux.open_dlc(channel, **dlc_options)
|
||||||
logging.info(color(f'### Session open: {rfcomm_session}', 'yellow'))
|
logging.info(color(f'### Session open: {rfcomm_session}', 'yellow'))
|
||||||
|
if self.max_credits is not None:
|
||||||
|
rfcomm_session.rx_max_credits = self.max_credits
|
||||||
|
if self.credits_threshold is not None:
|
||||||
|
rfcomm_session.rx_credits_threshold = self.credits_threshold
|
||||||
|
|
||||||
except bumble.core.ConnectionError as error:
|
except bumble.core.ConnectionError as error:
|
||||||
logging.info(color(f'!!! Session open failed: {error}', 'red'))
|
logging.info(color(f'!!! Session open failed: {error}', 'red'))
|
||||||
await rfcomm_mux.disconnect()
|
await rfcomm_mux.disconnect()
|
||||||
@@ -969,8 +994,19 @@ class RfcommClient(StreamedPacketIO):
|
|||||||
# RfcommServer
|
# RfcommServer
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class RfcommServer(StreamedPacketIO):
|
class RfcommServer(StreamedPacketIO):
|
||||||
def __init__(self, device, channel, l2cap_mtu):
|
def __init__(
|
||||||
|
self,
|
||||||
|
device,
|
||||||
|
channel,
|
||||||
|
l2cap_mtu,
|
||||||
|
max_frame_size,
|
||||||
|
initial_credits,
|
||||||
|
max_credits,
|
||||||
|
credits_threshold,
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self.max_credits = max_credits
|
||||||
|
self.credits_threshold = credits_threshold
|
||||||
self.dlc = None
|
self.dlc = None
|
||||||
self.ready = asyncio.Event()
|
self.ready = asyncio.Event()
|
||||||
|
|
||||||
@@ -981,7 +1017,12 @@ class RfcommServer(StreamedPacketIO):
|
|||||||
rfcomm_server = bumble.rfcomm.Server(device, **server_options)
|
rfcomm_server = bumble.rfcomm.Server(device, **server_options)
|
||||||
|
|
||||||
# Listen for incoming DLC connections
|
# Listen for incoming DLC connections
|
||||||
channel_number = rfcomm_server.listen(self.on_dlc, channel)
|
dlc_options = {}
|
||||||
|
if max_frame_size is not None:
|
||||||
|
dlc_options['max_frame_size'] = max_frame_size
|
||||||
|
if initial_credits is not None:
|
||||||
|
dlc_options['initial_credits'] = initial_credits
|
||||||
|
channel_number = rfcomm_server.listen(self.on_dlc, channel, **dlc_options)
|
||||||
|
|
||||||
# Setup the SDP to advertise this channel
|
# Setup the SDP to advertise this channel
|
||||||
device.sdp_service_records = make_sdp_records(channel_number)
|
device.sdp_service_records = make_sdp_records(channel_number)
|
||||||
@@ -1001,9 +1042,17 @@ class RfcommServer(StreamedPacketIO):
|
|||||||
|
|
||||||
def on_dlc(self, dlc):
|
def on_dlc(self, dlc):
|
||||||
logging.info(color(f'*** DLC connected: {dlc}', 'blue'))
|
logging.info(color(f'*** DLC connected: {dlc}', 'blue'))
|
||||||
|
if self.credits_threshold is not None:
|
||||||
|
dlc.rx_threshold = self.credits_threshold
|
||||||
|
if self.max_credits is not None:
|
||||||
|
dlc.rx_max_credits = self.max_credits
|
||||||
dlc.sink = self.on_packet
|
dlc.sink = self.on_packet
|
||||||
self.io_sink = dlc.write
|
self.io_sink = dlc.write
|
||||||
self.dlc = dlc
|
self.dlc = dlc
|
||||||
|
if self.max_credits is not None:
|
||||||
|
dlc.rx_max_credits = self.max_credits
|
||||||
|
if self.credits_threshold is not None:
|
||||||
|
dlc.rx_credits_threshold = self.credits_threshold
|
||||||
|
|
||||||
async def drain(self):
|
async def drain(self):
|
||||||
assert self.dlc
|
assert self.dlc
|
||||||
@@ -1026,6 +1075,7 @@ class Central(Connection.Listener):
|
|||||||
authenticate,
|
authenticate,
|
||||||
encrypt,
|
encrypt,
|
||||||
extended_data_length,
|
extended_data_length,
|
||||||
|
role_switch,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
@@ -1036,6 +1086,7 @@ class Central(Connection.Listener):
|
|||||||
self.authenticate = authenticate
|
self.authenticate = authenticate
|
||||||
self.encrypt = encrypt or authenticate
|
self.encrypt = encrypt or authenticate
|
||||||
self.extended_data_length = extended_data_length
|
self.extended_data_length = extended_data_length
|
||||||
|
self.role_switch = role_switch
|
||||||
self.device = None
|
self.device = None
|
||||||
self.connection = None
|
self.connection = None
|
||||||
|
|
||||||
@@ -1086,6 +1137,11 @@ class Central(Connection.Listener):
|
|||||||
role = self.role_factory(mode)
|
role = self.role_factory(mode)
|
||||||
self.device.classic_enabled = self.classic
|
self.device.classic_enabled = self.classic
|
||||||
|
|
||||||
|
# Set up a pairing config factory with minimal requirements.
|
||||||
|
self.device.pairing_config_factory = lambda _: PairingConfig(
|
||||||
|
sc=False, mitm=False, bonding=False
|
||||||
|
)
|
||||||
|
|
||||||
await self.device.power_on()
|
await self.device.power_on()
|
||||||
|
|
||||||
if self.classic:
|
if self.classic:
|
||||||
@@ -1114,6 +1170,10 @@ class Central(Connection.Listener):
|
|||||||
self.connection.listener = self
|
self.connection.listener = self
|
||||||
print_connection(self.connection)
|
print_connection(self.connection)
|
||||||
|
|
||||||
|
# Switch roles if needed.
|
||||||
|
if self.role_switch:
|
||||||
|
await switch_roles(self.connection, self.role_switch)
|
||||||
|
|
||||||
# Wait a bit after the connection, some controllers aren't very good when
|
# Wait a bit after the connection, some controllers aren't very good when
|
||||||
# we start sending data right away while some connection parameters are
|
# we start sending data right away while some connection parameters are
|
||||||
# updated post connection
|
# updated post connection
|
||||||
@@ -1175,20 +1235,30 @@ class Central(Connection.Listener):
|
|||||||
def on_connection_data_length_change(self):
|
def on_connection_data_length_change(self):
|
||||||
print_connection(self.connection)
|
print_connection(self.connection)
|
||||||
|
|
||||||
|
def on_role_change(self):
|
||||||
|
print_connection(self.connection)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Peripheral
|
# Peripheral
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Peripheral(Device.Listener, Connection.Listener):
|
class Peripheral(Device.Listener, Connection.Listener):
|
||||||
def __init__(
|
def __init__(
|
||||||
self, transport, classic, extended_data_length, role_factory, mode_factory
|
self,
|
||||||
|
transport,
|
||||||
|
role_factory,
|
||||||
|
mode_factory,
|
||||||
|
classic,
|
||||||
|
extended_data_length,
|
||||||
|
role_switch,
|
||||||
):
|
):
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
self.classic = classic
|
self.classic = classic
|
||||||
self.extended_data_length = extended_data_length
|
|
||||||
self.role_factory = role_factory
|
self.role_factory = role_factory
|
||||||
self.role = None
|
|
||||||
self.mode_factory = mode_factory
|
self.mode_factory = mode_factory
|
||||||
|
self.extended_data_length = extended_data_length
|
||||||
|
self.role_switch = role_switch
|
||||||
|
self.role = None
|
||||||
self.mode = None
|
self.mode = None
|
||||||
self.device = None
|
self.device = None
|
||||||
self.connection = None
|
self.connection = None
|
||||||
@@ -1211,6 +1281,11 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
self.role = self.role_factory(self.mode)
|
self.role = self.role_factory(self.mode)
|
||||||
self.device.classic_enabled = self.classic
|
self.device.classic_enabled = self.classic
|
||||||
|
|
||||||
|
# Set up a pairing config factory with minimal requirements.
|
||||||
|
self.device.pairing_config_factory = lambda _: PairingConfig(
|
||||||
|
sc=False, mitm=False, bonding=False
|
||||||
|
)
|
||||||
|
|
||||||
await self.device.power_on()
|
await self.device.power_on()
|
||||||
|
|
||||||
if self.classic:
|
if self.classic:
|
||||||
@@ -1237,6 +1312,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
|
|
||||||
await self.connected.wait()
|
await self.connected.wait()
|
||||||
logging.info(color('### Connected', 'cyan'))
|
logging.info(color('### Connected', 'cyan'))
|
||||||
|
print_connection(self.connection)
|
||||||
|
|
||||||
await self.mode.on_connection(self.connection)
|
await self.mode.on_connection(self.connection)
|
||||||
await self.role.run()
|
await self.role.run()
|
||||||
@@ -1253,7 +1329,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
AsyncRunner.spawn(self.device.set_connectable(False))
|
AsyncRunner.spawn(self.device.set_connectable(False))
|
||||||
|
|
||||||
# Request a new data length if needed
|
# Request a new data length if needed
|
||||||
if self.extended_data_length:
|
if not self.classic and self.extended_data_length:
|
||||||
logging.info("+++ Requesting extended data length")
|
logging.info("+++ Requesting extended data length")
|
||||||
AsyncRunner.spawn(
|
AsyncRunner.spawn(
|
||||||
connection.set_data_length(
|
connection.set_data_length(
|
||||||
@@ -1261,6 +1337,10 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Switch roles if needed.
|
||||||
|
if self.role_switch:
|
||||||
|
AsyncRunner.spawn(switch_roles(connection, self.role_switch))
|
||||||
|
|
||||||
def on_disconnection(self, reason):
|
def on_disconnection(self, reason):
|
||||||
logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
|
logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
|
||||||
self.connection = None
|
self.connection = None
|
||||||
@@ -1282,6 +1362,9 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
def on_connection_data_length_change(self):
|
def on_connection_data_length_change(self):
|
||||||
print_connection(self.connection)
|
print_connection(self.connection)
|
||||||
|
|
||||||
|
def on_role_change(self):
|
||||||
|
print_connection(self.connection)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def create_mode_factory(ctx, default_mode):
|
def create_mode_factory(ctx, default_mode):
|
||||||
@@ -1321,7 +1404,9 @@ def create_mode_factory(ctx, default_mode):
|
|||||||
uuid=ctx.obj['rfcomm_uuid'],
|
uuid=ctx.obj['rfcomm_uuid'],
|
||||||
l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
|
l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
|
||||||
max_frame_size=ctx.obj['rfcomm_max_frame_size'],
|
max_frame_size=ctx.obj['rfcomm_max_frame_size'],
|
||||||
window_size=ctx.obj['rfcomm_window_size'],
|
initial_credits=ctx.obj['rfcomm_initial_credits'],
|
||||||
|
max_credits=ctx.obj['rfcomm_max_credits'],
|
||||||
|
credits_threshold=ctx.obj['rfcomm_credits_threshold'],
|
||||||
)
|
)
|
||||||
|
|
||||||
if mode == 'rfcomm-server':
|
if mode == 'rfcomm-server':
|
||||||
@@ -1329,6 +1414,10 @@ def create_mode_factory(ctx, default_mode):
|
|||||||
device,
|
device,
|
||||||
channel=ctx.obj['rfcomm_channel'],
|
channel=ctx.obj['rfcomm_channel'],
|
||||||
l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
|
l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
|
||||||
|
max_frame_size=ctx.obj['rfcomm_max_frame_size'],
|
||||||
|
initial_credits=ctx.obj['rfcomm_initial_credits'],
|
||||||
|
max_credits=ctx.obj['rfcomm_max_credits'],
|
||||||
|
credits_threshold=ctx.obj['rfcomm_credits_threshold'],
|
||||||
)
|
)
|
||||||
|
|
||||||
raise ValueError('invalid mode')
|
raise ValueError('invalid mode')
|
||||||
@@ -1405,6 +1494,11 @@ def create_role_factory(ctx, default_role):
|
|||||||
'--extended-data-length',
|
'--extended-data-length',
|
||||||
help='Request a data length upon connection, specified as tx_octets/tx_time',
|
help='Request a data length upon connection, specified as tx_octets/tx_time',
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
'--role-switch',
|
||||||
|
type=click.Choice(['central', 'peripheral']),
|
||||||
|
help='Request role switch upon connection (central or peripheral)',
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--rfcomm-channel',
|
'--rfcomm-channel',
|
||||||
type=int,
|
type=int,
|
||||||
@@ -1427,9 +1521,19 @@ def create_role_factory(ctx, default_role):
|
|||||||
help='RFComm maximum frame size',
|
help='RFComm maximum frame size',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--rfcomm-window-size',
|
'--rfcomm-initial-credits',
|
||||||
type=int,
|
type=int,
|
||||||
help='RFComm window size',
|
help='RFComm initial credits',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--rfcomm-max-credits',
|
||||||
|
type=int,
|
||||||
|
help='RFComm max credits',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--rfcomm-credits-threshold',
|
||||||
|
type=int,
|
||||||
|
help='RFComm credits threshold',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--l2cap-psm',
|
'--l2cap-psm',
|
||||||
@@ -1459,7 +1563,7 @@ def create_role_factory(ctx, default_role):
|
|||||||
'--packet-size',
|
'--packet-size',
|
||||||
'-s',
|
'-s',
|
||||||
metavar='SIZE',
|
metavar='SIZE',
|
||||||
type=click.IntRange(8, 4096),
|
type=click.IntRange(8, 8192),
|
||||||
default=500,
|
default=500,
|
||||||
help='Packet size (client or ping role)',
|
help='Packet size (client or ping role)',
|
||||||
)
|
)
|
||||||
@@ -1519,6 +1623,7 @@ def bench(
|
|||||||
mode,
|
mode,
|
||||||
att_mtu,
|
att_mtu,
|
||||||
extended_data_length,
|
extended_data_length,
|
||||||
|
role_switch,
|
||||||
packet_size,
|
packet_size,
|
||||||
packet_count,
|
packet_count,
|
||||||
start_delay,
|
start_delay,
|
||||||
@@ -1530,7 +1635,9 @@ def bench(
|
|||||||
rfcomm_uuid,
|
rfcomm_uuid,
|
||||||
rfcomm_l2cap_mtu,
|
rfcomm_l2cap_mtu,
|
||||||
rfcomm_max_frame_size,
|
rfcomm_max_frame_size,
|
||||||
rfcomm_window_size,
|
rfcomm_initial_credits,
|
||||||
|
rfcomm_max_credits,
|
||||||
|
rfcomm_credits_threshold,
|
||||||
l2cap_psm,
|
l2cap_psm,
|
||||||
l2cap_mtu,
|
l2cap_mtu,
|
||||||
l2cap_mps,
|
l2cap_mps,
|
||||||
@@ -1545,7 +1652,9 @@ def bench(
|
|||||||
ctx.obj['rfcomm_uuid'] = rfcomm_uuid
|
ctx.obj['rfcomm_uuid'] = rfcomm_uuid
|
||||||
ctx.obj['rfcomm_l2cap_mtu'] = rfcomm_l2cap_mtu
|
ctx.obj['rfcomm_l2cap_mtu'] = rfcomm_l2cap_mtu
|
||||||
ctx.obj['rfcomm_max_frame_size'] = rfcomm_max_frame_size
|
ctx.obj['rfcomm_max_frame_size'] = rfcomm_max_frame_size
|
||||||
ctx.obj['rfcomm_window_size'] = rfcomm_window_size
|
ctx.obj['rfcomm_initial_credits'] = rfcomm_initial_credits
|
||||||
|
ctx.obj['rfcomm_max_credits'] = rfcomm_max_credits
|
||||||
|
ctx.obj['rfcomm_credits_threshold'] = rfcomm_credits_threshold
|
||||||
ctx.obj['l2cap_psm'] = l2cap_psm
|
ctx.obj['l2cap_psm'] = l2cap_psm
|
||||||
ctx.obj['l2cap_mtu'] = l2cap_mtu
|
ctx.obj['l2cap_mtu'] = l2cap_mtu
|
||||||
ctx.obj['l2cap_mps'] = l2cap_mps
|
ctx.obj['l2cap_mps'] = l2cap_mps
|
||||||
@@ -1557,12 +1666,12 @@ def bench(
|
|||||||
ctx.obj['repeat_delay'] = repeat_delay
|
ctx.obj['repeat_delay'] = repeat_delay
|
||||||
ctx.obj['pace'] = pace
|
ctx.obj['pace'] = pace
|
||||||
ctx.obj['linger'] = linger
|
ctx.obj['linger'] = linger
|
||||||
|
|
||||||
ctx.obj['extended_data_length'] = (
|
ctx.obj['extended_data_length'] = (
|
||||||
[int(x) for x in extended_data_length.split('/')]
|
[int(x) for x in extended_data_length.split('/')]
|
||||||
if extended_data_length
|
if extended_data_length
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
ctx.obj['role_switch'] = role_switch
|
||||||
ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server')
|
ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server')
|
||||||
|
|
||||||
|
|
||||||
@@ -1606,6 +1715,7 @@ def central(
|
|||||||
authenticate,
|
authenticate,
|
||||||
encrypt or authenticate,
|
encrypt or authenticate,
|
||||||
ctx.obj['extended_data_length'],
|
ctx.obj['extended_data_length'],
|
||||||
|
ctx.obj['role_switch'],
|
||||||
).run()
|
).run()
|
||||||
|
|
||||||
asyncio.run(run_central())
|
asyncio.run(run_central())
|
||||||
@@ -1622,10 +1732,11 @@ def peripheral(ctx, transport):
|
|||||||
async def run_peripheral():
|
async def run_peripheral():
|
||||||
await Peripheral(
|
await Peripheral(
|
||||||
transport,
|
transport,
|
||||||
ctx.obj['classic'],
|
|
||||||
ctx.obj['extended_data_length'],
|
|
||||||
role_factory,
|
role_factory,
|
||||||
mode_factory,
|
mode_factory,
|
||||||
|
ctx.obj['classic'],
|
||||||
|
ctx.obj['extended_data_length'],
|
||||||
|
ctx.obj['role_switch'],
|
||||||
).run()
|
).run()
|
||||||
|
|
||||||
asyncio.run(run_peripheral())
|
asyncio.run(run_peripheral())
|
||||||
|
|||||||
+5
-20
@@ -63,6 +63,7 @@ from bumble.transport import open_transport_or_link
|
|||||||
from bumble.gatt import Characteristic, Service, CharacteristicDeclaration, Descriptor
|
from bumble.gatt import Characteristic, Service, CharacteristicDeclaration, Descriptor
|
||||||
from bumble.gatt_client import CharacteristicProxy
|
from bumble.gatt_client import CharacteristicProxy
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
|
Address,
|
||||||
HCI_Constant,
|
HCI_Constant,
|
||||||
HCI_LE_1M_PHY,
|
HCI_LE_1M_PHY,
|
||||||
HCI_LE_2M_PHY,
|
HCI_LE_2M_PHY,
|
||||||
@@ -289,11 +290,7 @@ class ConsoleApp:
|
|||||||
device_config, hci_source, hci_sink
|
device_config, hci_source, hci_sink
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
random_address = (
|
random_address = Address.generate_static_address()
|
||||||
f"{random.randint(192,255):02X}" # address is static random
|
|
||||||
)
|
|
||||||
for random_byte in random.sample(range(255), 5):
|
|
||||||
random_address += f":{random_byte:02X}"
|
|
||||||
self.append_to_log(f"Setting random address: {random_address}")
|
self.append_to_log(f"Setting random address: {random_address}")
|
||||||
self.device = Device.with_hci(
|
self.device = Device.with_hci(
|
||||||
'Bumble', random_address, hci_source, hci_sink
|
'Bumble', random_address, hci_source, hci_sink
|
||||||
@@ -503,21 +500,9 @@ class ConsoleApp:
|
|||||||
self.show_error('not connected')
|
self.show_error('not connected')
|
||||||
return
|
return
|
||||||
|
|
||||||
# Discover all services, characteristics and descriptors
|
self.append_to_output('Service Discovery starting...')
|
||||||
self.append_to_output('discovering services...')
|
await self.connected_peer.discover_all()
|
||||||
await self.connected_peer.discover_services()
|
self.append_to_output('Service Discovery done!')
|
||||||
self.append_to_output(
|
|
||||||
f'found {len(self.connected_peer.services)} services,'
|
|
||||||
' discovering characteristics...'
|
|
||||||
)
|
|
||||||
await self.connected_peer.discover_characteristics()
|
|
||||||
self.append_to_output('found characteristics, discovering descriptors...')
|
|
||||||
for service in self.connected_peer.services:
|
|
||||||
for characteristic in service.characteristics:
|
|
||||||
await self.connected_peer.discover_descriptors(characteristic)
|
|
||||||
self.append_to_output('discovery completed')
|
|
||||||
|
|
||||||
self.show_remote_services(self.connected_peer.services)
|
|
||||||
|
|
||||||
async def discover_attributes(self):
|
async def discover_attributes(self):
|
||||||
if not self.connected_peer:
|
if not self.connected_peer:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from bumble.colors import color
|
|||||||
from bumble.core import name_or_number
|
from bumble.core import name_or_number
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
map_null_terminated_utf8_string,
|
map_null_terminated_utf8_string,
|
||||||
LeFeatureMask,
|
LeFeature,
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
HCI_VERSION_NAMES,
|
HCI_VERSION_NAMES,
|
||||||
LMP_VERSION_NAMES,
|
LMP_VERSION_NAMES,
|
||||||
@@ -140,7 +140,7 @@ async def get_le_info(host: Host) -> None:
|
|||||||
|
|
||||||
print(color('LE Features:', 'yellow'))
|
print(color('LE Features:', 'yellow'))
|
||||||
for feature in host.supported_le_features:
|
for feature in host.supported_le_features:
|
||||||
print(LeFeatureMask(feature).name)
|
print(f' {LeFeature(feature).name}')
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -224,7 +224,7 @@ async def async_main(latency_probes, transport):
|
|||||||
print()
|
print()
|
||||||
print(color('Supported Commands:', 'yellow'))
|
print(color('Supported Commands:', 'yellow'))
|
||||||
for command in host.supported_commands:
|
for command in host.supported_commands:
|
||||||
print(' ', HCI_Command.command_name(command))
|
print(f' {HCI_Command.command_name(command)}')
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
# Copyright 2024 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 time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from bumble.colors import color
|
||||||
|
from bumble.device import Device, DeviceConfiguration, Connection
|
||||||
|
from bumble import core
|
||||||
|
from bumble import hci
|
||||||
|
from bumble import rfcomm
|
||||||
|
from bumble import transport
|
||||||
|
from bumble import utils
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
DEFAULT_RFCOMM_UUID = "E6D55659-C8B4-4B85-96BB-B1143AF6D3AE"
|
||||||
|
DEFAULT_MTU = 4096
|
||||||
|
DEFAULT_CLIENT_TCP_PORT = 9544
|
||||||
|
DEFAULT_SERVER_TCP_PORT = 9545
|
||||||
|
|
||||||
|
TRACE_MAX_SIZE = 48
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Tracer:
|
||||||
|
"""
|
||||||
|
Trace data buffers transmitted from one endpoint to another, with stats.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, channel_name: str) -> None:
|
||||||
|
self.channel_name = channel_name
|
||||||
|
self.last_ts: float = 0.0
|
||||||
|
|
||||||
|
def trace_data(self, data: bytes) -> None:
|
||||||
|
now = time.time()
|
||||||
|
elapsed_s = now - self.last_ts if self.last_ts else 0
|
||||||
|
elapsed_ms = int(elapsed_s * 1000)
|
||||||
|
instant_throughput_kbps = ((len(data) / elapsed_s) / 1000) if elapsed_s else 0.0
|
||||||
|
|
||||||
|
hex_str = data[:TRACE_MAX_SIZE].hex() + (
|
||||||
|
"..." if len(data) > TRACE_MAX_SIZE else ""
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[{self.channel_name}] {len(data):4} bytes "
|
||||||
|
f"(+{elapsed_ms:4}ms, {instant_throughput_kbps: 7.2f}kB/s) "
|
||||||
|
f" {hex_str}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.last_ts = now
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class ServerBridge:
|
||||||
|
"""
|
||||||
|
RFCOMM server bridge: waits for a peer to connect an RFCOMM channel.
|
||||||
|
The RFCOMM channel may be associated with a UUID published in an SDP service
|
||||||
|
description, or simply be on a system-assigned channel number.
|
||||||
|
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 RFCOMM channel is closed, the bridge disconnects the TCP socket
|
||||||
|
and waits for a new channel to be connected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
READ_CHUNK_SIZE = 4096
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, channel: int, uuid: str, trace: bool, tcp_host: str, tcp_port: int
|
||||||
|
) -> None:
|
||||||
|
self.device: Optional[Device] = None
|
||||||
|
self.channel = channel
|
||||||
|
self.uuid = uuid
|
||||||
|
self.tcp_host = tcp_host
|
||||||
|
self.tcp_port = tcp_port
|
||||||
|
self.rfcomm_channel: Optional[rfcomm.DLC] = None
|
||||||
|
self.tcp_tracer: Optional[Tracer]
|
||||||
|
self.rfcomm_tracer: Optional[Tracer]
|
||||||
|
|
||||||
|
if trace:
|
||||||
|
self.tcp_tracer = Tracer(color("RFCOMM->TCP", "cyan"))
|
||||||
|
self.rfcomm_tracer = Tracer(color("TCP->RFCOMM", "magenta"))
|
||||||
|
else:
|
||||||
|
self.rfcomm_tracer = None
|
||||||
|
self.tcp_tracer = None
|
||||||
|
|
||||||
|
async def start(self, device: Device) -> None:
|
||||||
|
self.device = device
|
||||||
|
|
||||||
|
# Create and register a server
|
||||||
|
rfcomm_server = rfcomm.Server(self.device)
|
||||||
|
|
||||||
|
# Listen for incoming DLC connections
|
||||||
|
self.channel = rfcomm_server.listen(self.on_rfcomm_channel, self.channel)
|
||||||
|
|
||||||
|
# Setup the SDP to advertise this channel
|
||||||
|
service_record_handle = 0x00010001
|
||||||
|
self.device.sdp_service_records = {
|
||||||
|
service_record_handle: rfcomm.make_service_sdp_records(
|
||||||
|
service_record_handle, self.channel, core.UUID(self.uuid)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# We're ready for a connection
|
||||||
|
self.device.on("connection", self.on_connection)
|
||||||
|
await self.set_available(True)
|
||||||
|
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
(
|
||||||
|
f"### Listening for RFCOMM connection on {device.public_address}, "
|
||||||
|
f"channel {self.channel}"
|
||||||
|
),
|
||||||
|
"yellow",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_available(self, available: bool):
|
||||||
|
# Become discoverable and connectable
|
||||||
|
assert self.device
|
||||||
|
await self.device.set_connectable(available)
|
||||||
|
await self.device.set_discoverable(available)
|
||||||
|
|
||||||
|
def on_connection(self, connection):
|
||||||
|
print(color(f"@@@ Bluetooth connection: {connection}", "blue"))
|
||||||
|
connection.on("disconnection", self.on_disconnection)
|
||||||
|
|
||||||
|
# Don't accept new connections until we're disconnected
|
||||||
|
utils.AsyncRunner.spawn(self.set_available(False))
|
||||||
|
|
||||||
|
def on_disconnection(self, reason: int):
|
||||||
|
print(
|
||||||
|
color("@@@ Bluetooth disconnection:", "red"),
|
||||||
|
hci.HCI_Constant.error_name(reason),
|
||||||
|
)
|
||||||
|
|
||||||
|
# We're ready for a new connection
|
||||||
|
utils.AsyncRunner.spawn(self.set_available(True))
|
||||||
|
|
||||||
|
# Called when an RFCOMM channel is established
|
||||||
|
@utils.AsyncRunner.run_in_task()
|
||||||
|
async def on_rfcomm_channel(self, rfcomm_channel):
|
||||||
|
print(color("*** RFCOMM channel:", "cyan"), rfcomm_channel)
|
||||||
|
|
||||||
|
# Connect to the TCP server
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f"### Connecting to TCP {self.tcp_host}:{self.tcp_port}",
|
||||||
|
"yellow",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.open_connection(self.tcp_host, self.tcp_port)
|
||||||
|
except OSError:
|
||||||
|
print(color("!!! Connection failed", "red"))
|
||||||
|
await rfcomm_channel.disconnect()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pipe data from RFCOMM to TCP
|
||||||
|
def on_rfcomm_channel_closed():
|
||||||
|
print(color("*** RFCOMM channel closed", "cyan"))
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
def write_rfcomm_data(data):
|
||||||
|
if self.rfcomm_tracer:
|
||||||
|
self.rfcomm_tracer.trace_data(data)
|
||||||
|
|
||||||
|
writer.write(data)
|
||||||
|
|
||||||
|
rfcomm_channel.sink = write_rfcomm_data
|
||||||
|
rfcomm_channel.on("close", on_rfcomm_channel_closed)
|
||||||
|
|
||||||
|
# Pipe data from TCP to RFCOMM
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = await reader.read(self.READ_CHUNK_SIZE)
|
||||||
|
|
||||||
|
if len(data) == 0:
|
||||||
|
print(color("### TCP end of stream", "yellow"))
|
||||||
|
if rfcomm_channel.state == rfcomm.DLC.State.CONNECTED:
|
||||||
|
await rfcomm_channel.disconnect()
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.tcp_tracer:
|
||||||
|
self.tcp_tracer.trace_data(data)
|
||||||
|
|
||||||
|
rfcomm_channel.write(data)
|
||||||
|
await rfcomm_channel.drain()
|
||||||
|
except Exception as error:
|
||||||
|
print(f"!!! Exception: {error}")
|
||||||
|
break
|
||||||
|
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
print(color("~~~ Bye bye", "magenta"))
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class ClientBridge:
|
||||||
|
"""
|
||||||
|
RFCOMM client bridge: connects to a BR/EDR device, then waits for an inbound
|
||||||
|
TCP connection on a specified port number. When a TCP client connects, an
|
||||||
|
RFCOMM connection to the device is established, and the data is bridged in both
|
||||||
|
directions, with flow control.
|
||||||
|
When the TCP connection is closed by the client, the RFCOMM channel is
|
||||||
|
disconnected, but the connection to the device remains, ready for a new TCP client
|
||||||
|
to connect.
|
||||||
|
"""
|
||||||
|
|
||||||
|
READ_CHUNK_SIZE = 4096
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
channel: int,
|
||||||
|
uuid: str,
|
||||||
|
trace: bool,
|
||||||
|
address: str,
|
||||||
|
tcp_host: str,
|
||||||
|
tcp_port: int,
|
||||||
|
encrypt: bool,
|
||||||
|
):
|
||||||
|
self.channel = channel
|
||||||
|
self.uuid = uuid
|
||||||
|
self.trace = trace
|
||||||
|
self.address = address
|
||||||
|
self.tcp_host = tcp_host
|
||||||
|
self.tcp_port = tcp_port
|
||||||
|
self.encrypt = encrypt
|
||||||
|
self.device: Optional[Device] = None
|
||||||
|
self.connection: Optional[Connection] = None
|
||||||
|
self.rfcomm_client: Optional[rfcomm.Client]
|
||||||
|
self.rfcomm_mux: Optional[rfcomm.Multiplexer]
|
||||||
|
self.tcp_connected: bool = False
|
||||||
|
|
||||||
|
self.tcp_tracer: Optional[Tracer]
|
||||||
|
self.rfcomm_tracer: Optional[Tracer]
|
||||||
|
|
||||||
|
if trace:
|
||||||
|
self.tcp_tracer = Tracer(color("RFCOMM->TCP", "cyan"))
|
||||||
|
self.rfcomm_tracer = Tracer(color("TCP->RFCOMM", "magenta"))
|
||||||
|
else:
|
||||||
|
self.rfcomm_tracer = None
|
||||||
|
self.tcp_tracer = None
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
if self.connection:
|
||||||
|
return
|
||||||
|
|
||||||
|
print(color(f"@@@ Connecting to Bluetooth {self.address}", "blue"))
|
||||||
|
assert self.device
|
||||||
|
self.connection = await self.device.connect(
|
||||||
|
self.address, transport=core.BT_BR_EDR_TRANSPORT
|
||||||
|
)
|
||||||
|
print(color(f"@@@ Bluetooth connection: {self.connection}", "blue"))
|
||||||
|
self.connection.on("disconnection", self.on_disconnection)
|
||||||
|
|
||||||
|
if self.encrypt:
|
||||||
|
print(color("@@@ Encrypting Bluetooth connection", "blue"))
|
||||||
|
await self.connection.encrypt()
|
||||||
|
print(color("@@@ Bluetooth connection encrypted", "blue"))
|
||||||
|
|
||||||
|
self.rfcomm_client = rfcomm.Client(self.connection)
|
||||||
|
try:
|
||||||
|
self.rfcomm_mux = await self.rfcomm_client.start()
|
||||||
|
except BaseException as e:
|
||||||
|
print(color("!!! Failed to setup RFCOMM connection", "red"), e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def start(self, device: Device) -> None:
|
||||||
|
self.device = device
|
||||||
|
await device.set_connectable(False)
|
||||||
|
await device.set_discoverable(False)
|
||||||
|
|
||||||
|
# Called when a TCP connection is established
|
||||||
|
async def on_tcp_connection(reader, writer):
|
||||||
|
print(color("<<< TCP connection", "magenta"))
|
||||||
|
if self.tcp_connected:
|
||||||
|
print(
|
||||||
|
color("!!! TCP connection already active, rejecting new one", "red")
|
||||||
|
)
|
||||||
|
writer.close()
|
||||||
|
return
|
||||||
|
self.tcp_connected = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.pipe(reader, writer)
|
||||||
|
except BaseException as error:
|
||||||
|
print(color("!!! Exception while piping data:", "red"), error)
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
self.tcp_connected = False
|
||||||
|
|
||||||
|
await asyncio.start_server(
|
||||||
|
on_tcp_connection,
|
||||||
|
host=self.tcp_host if self.tcp_host != "_" else None,
|
||||||
|
port=self.tcp_port,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f"### Listening for TCP connections on port {self.tcp_port}", "magenta"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def pipe(
|
||||||
|
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||||||
|
) -> None:
|
||||||
|
# Resolve the channel number from the UUID if needed
|
||||||
|
if self.channel == 0:
|
||||||
|
await self.connect()
|
||||||
|
assert self.connection
|
||||||
|
channel = await rfcomm.find_rfcomm_channel_with_uuid(
|
||||||
|
self.connection, self.uuid
|
||||||
|
)
|
||||||
|
if channel:
|
||||||
|
print(color(f"### Found RFCOMM channel {channel}", "yellow"))
|
||||||
|
else:
|
||||||
|
print(color(f"!!! RFCOMM channel with UUID {self.uuid} not found"))
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
channel = self.channel
|
||||||
|
|
||||||
|
# Connect a new RFCOMM channel
|
||||||
|
await self.connect()
|
||||||
|
assert self.rfcomm_mux
|
||||||
|
print(color(f"*** Opening RFCOMM channel {channel}", "green"))
|
||||||
|
try:
|
||||||
|
rfcomm_channel = await self.rfcomm_mux.open_dlc(channel)
|
||||||
|
print(color(f"*** RFCOMM channel open: {rfcomm_channel}", "green"))
|
||||||
|
except Exception as error:
|
||||||
|
print(color(f"!!! RFCOMM open failed: {error}", "red"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pipe data from RFCOMM to TCP
|
||||||
|
def on_rfcomm_channel_closed():
|
||||||
|
print(color("*** RFCOMM channel closed", "green"))
|
||||||
|
|
||||||
|
def write_rfcomm_data(data):
|
||||||
|
if self.trace:
|
||||||
|
self.rfcomm_tracer.trace_data(data)
|
||||||
|
|
||||||
|
writer.write(data)
|
||||||
|
|
||||||
|
rfcomm_channel.on("close", on_rfcomm_channel_closed)
|
||||||
|
rfcomm_channel.sink = write_rfcomm_data
|
||||||
|
|
||||||
|
# Pipe data from TCP to RFCOMM
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = await reader.read(self.READ_CHUNK_SIZE)
|
||||||
|
|
||||||
|
if len(data) == 0:
|
||||||
|
print(color("### TCP end of stream", "yellow"))
|
||||||
|
if rfcomm_channel.state == rfcomm.DLC.State.CONNECTED:
|
||||||
|
await rfcomm_channel.disconnect()
|
||||||
|
self.tcp_connected = False
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.tcp_tracer:
|
||||||
|
self.tcp_tracer.trace_data(data)
|
||||||
|
|
||||||
|
rfcomm_channel.write(data)
|
||||||
|
await rfcomm_channel.drain()
|
||||||
|
except Exception as error:
|
||||||
|
print(f"!!! Exception: {error}")
|
||||||
|
break
|
||||||
|
|
||||||
|
print(color("~~~ Bye bye", "magenta"))
|
||||||
|
|
||||||
|
def on_disconnection(self, reason: int) -> None:
|
||||||
|
print(
|
||||||
|
color("@@@ Bluetooth disconnection:", "red"),
|
||||||
|
hci.HCI_Constant.error_name(reason),
|
||||||
|
)
|
||||||
|
self.connection = None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def run(device_config, hci_transport, bridge):
|
||||||
|
print("<<< connecting to HCI...")
|
||||||
|
async with await transport.open_transport_or_link(hci_transport) as (
|
||||||
|
hci_source,
|
||||||
|
hci_sink,
|
||||||
|
):
|
||||||
|
print("<<< connected")
|
||||||
|
|
||||||
|
if device_config:
|
||||||
|
device = Device.from_config_file_with_hci(
|
||||||
|
device_config, hci_source, hci_sink
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
device = Device.from_config_with_hci(
|
||||||
|
DeviceConfiguration(), hci_source, hci_sink
|
||||||
|
)
|
||||||
|
device.classic_enabled = True
|
||||||
|
|
||||||
|
# Let's go
|
||||||
|
await device.power_on()
|
||||||
|
try:
|
||||||
|
await bridge.start(device)
|
||||||
|
|
||||||
|
# Wait until the transport terminates
|
||||||
|
await hci_source.wait_for_termination()
|
||||||
|
except core.ConnectionError as error:
|
||||||
|
print(color(f"!!! Bluetooth connection failed: {error}", "red"))
|
||||||
|
except Exception as error:
|
||||||
|
print(f"Exception while running bridge: {error}")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@click.group()
|
||||||
|
@click.pass_context
|
||||||
|
@click.option(
|
||||||
|
"--device-config",
|
||||||
|
metavar="CONFIG_FILE",
|
||||||
|
help="Device configuration file",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--hci-transport", metavar="TRANSPORT_NAME", help="HCI transport", required=True
|
||||||
|
)
|
||||||
|
@click.option("--trace", is_flag=True, help="Trace bridged data to stdout")
|
||||||
|
@click.option(
|
||||||
|
"--channel",
|
||||||
|
metavar="CHANNEL_NUMER",
|
||||||
|
help="RFCOMM channel number",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--uuid",
|
||||||
|
metavar="UUID",
|
||||||
|
help="UUID for the RFCOMM channel",
|
||||||
|
default=DEFAULT_RFCOMM_UUID,
|
||||||
|
)
|
||||||
|
def cli(
|
||||||
|
context,
|
||||||
|
device_config,
|
||||||
|
hci_transport,
|
||||||
|
trace,
|
||||||
|
channel,
|
||||||
|
uuid,
|
||||||
|
):
|
||||||
|
context.ensure_object(dict)
|
||||||
|
context.obj["device_config"] = device_config
|
||||||
|
context.obj["hci_transport"] = hci_transport
|
||||||
|
context.obj["trace"] = trace
|
||||||
|
context.obj["channel"] = channel
|
||||||
|
context.obj["uuid"] = uuid
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@cli.command()
|
||||||
|
@click.pass_context
|
||||||
|
@click.option("--tcp-host", help="TCP host", default="localhost")
|
||||||
|
@click.option("--tcp-port", help="TCP port", default=DEFAULT_SERVER_TCP_PORT)
|
||||||
|
def server(context, tcp_host, tcp_port):
|
||||||
|
bridge = ServerBridge(
|
||||||
|
context.obj["channel"],
|
||||||
|
context.obj["uuid"],
|
||||||
|
context.obj["trace"],
|
||||||
|
tcp_host,
|
||||||
|
tcp_port,
|
||||||
|
)
|
||||||
|
asyncio.run(run(context.obj["device_config"], context.obj["hci_transport"], bridge))
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@cli.command()
|
||||||
|
@click.pass_context
|
||||||
|
@click.argument("bluetooth-address")
|
||||||
|
@click.option("--tcp-host", help="TCP host", default="_")
|
||||||
|
@click.option("--tcp-port", help="TCP port", default=DEFAULT_CLIENT_TCP_PORT)
|
||||||
|
@click.option("--encrypt", is_flag=True, help="Encrypt the connection")
|
||||||
|
def client(context, bluetooth_address, tcp_host, tcp_port, encrypt):
|
||||||
|
bridge = ClientBridge(
|
||||||
|
context.obj["channel"],
|
||||||
|
context.obj["uuid"],
|
||||||
|
context.obj["trace"],
|
||||||
|
bluetooth_address,
|
||||||
|
tcp_host,
|
||||||
|
tcp_port,
|
||||||
|
encrypt,
|
||||||
|
)
|
||||||
|
asyncio.run(run(context.obj["device_config"], context.obj["hci_transport"], bridge))
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logging.basicConfig(level=os.environ.get("BUMBLE_LOGLEVEL", "WARNING").upper())
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli(obj={}) # pylint: disable=no-value-for-parameter
|
||||||
+12
-6
@@ -14,13 +14,19 @@
|
|||||||
|
|
||||||
from typing import List, Union
|
from typing import List, Union
|
||||||
|
|
||||||
|
from bumble import core
|
||||||
|
|
||||||
|
|
||||||
|
class AtParsingError(core.InvalidPacketError):
|
||||||
|
"""Error raised when parsing AT commands fails."""
|
||||||
|
|
||||||
|
|
||||||
def tokenize_parameters(buffer: bytes) -> List[bytes]:
|
def tokenize_parameters(buffer: bytes) -> List[bytes]:
|
||||||
"""Split input parameters into tokens.
|
"""Split input parameters into tokens.
|
||||||
Removes space characters outside of double quote blocks:
|
Removes space characters outside of double quote blocks:
|
||||||
T-rec-V-25 - 5.2.1 Command line general format: "Space characters (IA5 2/0)
|
T-rec-V-25 - 5.2.1 Command line general format: "Space characters (IA5 2/0)
|
||||||
are ignored [..], unless they are embedded in numeric or string constants"
|
are ignored [..], unless they are embedded in numeric or string constants"
|
||||||
Raises ValueError in case of invalid input string."""
|
Raises AtParsingError in case of invalid input string."""
|
||||||
|
|
||||||
tokens = []
|
tokens = []
|
||||||
in_quotes = False
|
in_quotes = False
|
||||||
@@ -43,11 +49,11 @@ def tokenize_parameters(buffer: bytes) -> List[bytes]:
|
|||||||
token = bytearray()
|
token = bytearray()
|
||||||
elif char == b'(':
|
elif char == b'(':
|
||||||
if len(token) > 0:
|
if len(token) > 0:
|
||||||
raise ValueError("open_paren following regular character")
|
raise AtParsingError("open_paren following regular character")
|
||||||
tokens.append(char)
|
tokens.append(char)
|
||||||
elif char == b'"':
|
elif char == b'"':
|
||||||
if len(token) > 0:
|
if len(token) > 0:
|
||||||
raise ValueError("quote following regular character")
|
raise AtParsingError("quote following regular character")
|
||||||
in_quotes = True
|
in_quotes = True
|
||||||
token.extend(char)
|
token.extend(char)
|
||||||
else:
|
else:
|
||||||
@@ -59,7 +65,7 @@ def tokenize_parameters(buffer: bytes) -> List[bytes]:
|
|||||||
|
|
||||||
def parse_parameters(buffer: bytes) -> List[Union[bytes, list]]:
|
def parse_parameters(buffer: bytes) -> List[Union[bytes, list]]:
|
||||||
"""Parse the parameters using the comma and parenthesis separators.
|
"""Parse the parameters using the comma and parenthesis separators.
|
||||||
Raises ValueError in case of invalid input string."""
|
Raises AtParsingError in case of invalid input string."""
|
||||||
|
|
||||||
tokens = tokenize_parameters(buffer)
|
tokens = tokenize_parameters(buffer)
|
||||||
accumulator: List[list] = [[]]
|
accumulator: List[list] = [[]]
|
||||||
@@ -73,7 +79,7 @@ def parse_parameters(buffer: bytes) -> List[Union[bytes, list]]:
|
|||||||
accumulator.append([])
|
accumulator.append([])
|
||||||
elif token == b')':
|
elif token == b')':
|
||||||
if len(accumulator) < 2:
|
if len(accumulator) < 2:
|
||||||
raise ValueError("close_paren without matching open_paren")
|
raise AtParsingError("close_paren without matching open_paren")
|
||||||
accumulator[-1].append(current)
|
accumulator[-1].append(current)
|
||||||
current = accumulator.pop()
|
current = accumulator.pop()
|
||||||
else:
|
else:
|
||||||
@@ -81,5 +87,5 @@ def parse_parameters(buffer: bytes) -> List[Union[bytes, list]]:
|
|||||||
|
|
||||||
accumulator[-1].append(current)
|
accumulator[-1].append(current)
|
||||||
if len(accumulator) > 1:
|
if len(accumulator) > 1:
|
||||||
raise ValueError("missing close_paren")
|
raise AtParsingError("missing close_paren")
|
||||||
return accumulator[0]
|
return accumulator[0]
|
||||||
|
|||||||
+8
-5
@@ -20,6 +20,7 @@ import enum
|
|||||||
import struct
|
import struct
|
||||||
from typing import Dict, Type, Union, Tuple
|
from typing import Dict, Type, Union, Tuple
|
||||||
|
|
||||||
|
from bumble import core
|
||||||
from bumble.utils import OpenIntEnum
|
from bumble.utils import OpenIntEnum
|
||||||
|
|
||||||
|
|
||||||
@@ -88,7 +89,9 @@ class Frame:
|
|||||||
short_name = subclass.__name__.replace("ResponseFrame", "")
|
short_name = subclass.__name__.replace("ResponseFrame", "")
|
||||||
category_class = ResponseFrame
|
category_class = ResponseFrame
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"invalid subclass name {subclass.__name__}")
|
raise core.InvalidArgumentError(
|
||||||
|
f"invalid subclass name {subclass.__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
uppercase_indexes = [
|
uppercase_indexes = [
|
||||||
i for i in range(len(short_name)) if short_name[i].isupper()
|
i for i in range(len(short_name)) if short_name[i].isupper()
|
||||||
@@ -106,7 +109,7 @@ class Frame:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(data: bytes) -> Frame:
|
def from_bytes(data: bytes) -> Frame:
|
||||||
if data[0] >> 4 != 0:
|
if data[0] >> 4 != 0:
|
||||||
raise ValueError("first 4 bits must be 0s")
|
raise core.InvalidPacketError("first 4 bits must be 0s")
|
||||||
|
|
||||||
ctype_or_response = data[0] & 0xF
|
ctype_or_response = data[0] & 0xF
|
||||||
subunit_type = Frame.SubunitType(data[1] >> 3)
|
subunit_type = Frame.SubunitType(data[1] >> 3)
|
||||||
@@ -122,7 +125,7 @@ class Frame:
|
|||||||
# Extended to the next byte
|
# Extended to the next byte
|
||||||
extension = data[2]
|
extension = data[2]
|
||||||
if extension == 0:
|
if extension == 0:
|
||||||
raise ValueError("extended subunit ID value reserved")
|
raise core.InvalidPacketError("extended subunit ID value reserved")
|
||||||
if extension == 0xFF:
|
if extension == 0xFF:
|
||||||
subunit_id = 5 + 254 + data[3]
|
subunit_id = 5 + 254 + data[3]
|
||||||
opcode_offset = 4
|
opcode_offset = 4
|
||||||
@@ -131,7 +134,7 @@ class Frame:
|
|||||||
opcode_offset = 3
|
opcode_offset = 3
|
||||||
|
|
||||||
elif subunit_id == 6:
|
elif subunit_id == 6:
|
||||||
raise ValueError("reserved subunit ID")
|
raise core.InvalidPacketError("reserved subunit ID")
|
||||||
|
|
||||||
opcode = Frame.OperationCode(data[opcode_offset])
|
opcode = Frame.OperationCode(data[opcode_offset])
|
||||||
operands = data[opcode_offset + 1 :]
|
operands = data[opcode_offset + 1 :]
|
||||||
@@ -448,7 +451,7 @@ class PassThroughFrame:
|
|||||||
operation_data: bytes,
|
operation_data: bytes,
|
||||||
) -> None:
|
) -> None:
|
||||||
if len(operation_data) > 255:
|
if len(operation_data) > 255:
|
||||||
raise ValueError("operation data must be <= 255 bytes")
|
raise core.InvalidArgumentError("operation data must be <= 255 bytes")
|
||||||
self.state_flag = state_flag
|
self.state_flag = state_flag
|
||||||
self.operation_id = operation_id
|
self.operation_id = operation_id
|
||||||
self.operation_data = operation_data
|
self.operation_data = operation_data
|
||||||
|
|||||||
+3
-2
@@ -23,6 +23,7 @@ from typing import Callable, cast, Dict, Optional
|
|||||||
|
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble import avc
|
from bumble import avc
|
||||||
|
from bumble import core
|
||||||
from bumble import l2cap
|
from bumble import l2cap
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -275,7 +276,7 @@ class Protocol:
|
|||||||
self, pid: int, handler: Protocol.CommandHandler
|
self, pid: int, handler: Protocol.CommandHandler
|
||||||
) -> None:
|
) -> None:
|
||||||
if pid not in self.command_handlers or self.command_handlers[pid] != handler:
|
if pid not in self.command_handlers or self.command_handlers[pid] != handler:
|
||||||
raise ValueError("command handler not registered")
|
raise core.InvalidArgumentError("command handler not registered")
|
||||||
del self.command_handlers[pid]
|
del self.command_handlers[pid]
|
||||||
|
|
||||||
def register_response_handler(
|
def register_response_handler(
|
||||||
@@ -287,5 +288,5 @@ class Protocol:
|
|||||||
self, pid: int, handler: Protocol.ResponseHandler
|
self, pid: int, handler: Protocol.ResponseHandler
|
||||||
) -> None:
|
) -> None:
|
||||||
if pid not in self.response_handlers or self.response_handlers[pid] != handler:
|
if pid not in self.response_handlers or self.response_handlers[pid] != handler:
|
||||||
raise ValueError("response handler not registered")
|
raise core.InvalidArgumentError("response handler not registered")
|
||||||
del self.response_handlers[pid]
|
del self.response_handlers[pid]
|
||||||
|
|||||||
+5
-1
@@ -43,6 +43,7 @@ from .core import (
|
|||||||
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
|
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
|
||||||
InvalidStateError,
|
InvalidStateError,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
|
InvalidArgumentError,
|
||||||
name_or_number,
|
name_or_number,
|
||||||
)
|
)
|
||||||
from .a2dp import (
|
from .a2dp import (
|
||||||
@@ -700,7 +701,7 @@ class Message: # pylint:disable=attribute-defined-outside-init
|
|||||||
signal_identifier_str = name[:-7]
|
signal_identifier_str = name[:-7]
|
||||||
message_type = Message.MessageType.RESPONSE_REJECT
|
message_type = Message.MessageType.RESPONSE_REJECT
|
||||||
else:
|
else:
|
||||||
raise ValueError('invalid class name')
|
raise InvalidArgumentError('invalid class name')
|
||||||
|
|
||||||
subclass.message_type = message_type
|
subclass.message_type = message_type
|
||||||
|
|
||||||
@@ -2162,6 +2163,9 @@ class LocalStreamEndPoint(StreamEndPoint, EventEmitter):
|
|||||||
def on_abort_command(self):
|
def on_abort_command(self):
|
||||||
self.emit('abort')
|
self.emit('abort')
|
||||||
|
|
||||||
|
def on_delayreport_command(self, delay: int):
|
||||||
|
self.emit('delay_report', delay)
|
||||||
|
|
||||||
def on_rtp_channel_open(self):
|
def on_rtp_channel_open(self):
|
||||||
self.emit('rtp_channel_open')
|
self.emit('rtp_channel_open')
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -55,6 +55,7 @@ from bumble.sdp import (
|
|||||||
)
|
)
|
||||||
from bumble.utils import AsyncRunner, OpenIntEnum
|
from bumble.utils import AsyncRunner, OpenIntEnum
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
|
InvalidArgumentError,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
BT_L2CAP_PROTOCOL_ID,
|
BT_L2CAP_PROTOCOL_ID,
|
||||||
BT_AVCTP_PROTOCOL_ID,
|
BT_AVCTP_PROTOCOL_ID,
|
||||||
@@ -1411,7 +1412,7 @@ class Protocol(pyee.EventEmitter):
|
|||||||
def notify_track_changed(self, identifier: bytes) -> None:
|
def notify_track_changed(self, identifier: bytes) -> None:
|
||||||
"""Notify the connected peer of a Track change."""
|
"""Notify the connected peer of a Track change."""
|
||||||
if len(identifier) != 8:
|
if len(identifier) != 8:
|
||||||
raise ValueError("identifier must be 8 bytes")
|
raise InvalidArgumentError("identifier must be 8 bytes")
|
||||||
self.notify_event(TrackChangedEvent(identifier))
|
self.notify_event(TrackChangedEvent(identifier))
|
||||||
|
|
||||||
def notify_playback_position_changed(self, position: int) -> None:
|
def notify_playback_position_changed(self, position: int) -> None:
|
||||||
|
|||||||
+17
-13
@@ -18,6 +18,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from bumble import core
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class BitReader:
|
class BitReader:
|
||||||
@@ -40,7 +42,7 @@ class BitReader:
|
|||||||
""" "Read up to 32 bits."""
|
""" "Read up to 32 bits."""
|
||||||
|
|
||||||
if bits > 32:
|
if bits > 32:
|
||||||
raise ValueError('maximum read size is 32')
|
raise core.InvalidArgumentError('maximum read size is 32')
|
||||||
|
|
||||||
if self.bits_cached >= bits:
|
if self.bits_cached >= bits:
|
||||||
# We have enough bits.
|
# We have enough bits.
|
||||||
@@ -53,7 +55,7 @@ class BitReader:
|
|||||||
feed_size = len(feed_bytes)
|
feed_size = len(feed_bytes)
|
||||||
feed_int = int.from_bytes(feed_bytes, byteorder='big')
|
feed_int = int.from_bytes(feed_bytes, byteorder='big')
|
||||||
if 8 * feed_size + self.bits_cached < bits:
|
if 8 * feed_size + self.bits_cached < bits:
|
||||||
raise ValueError('trying to read past the data')
|
raise core.InvalidArgumentError('trying to read past the data')
|
||||||
self.byte_position += feed_size
|
self.byte_position += feed_size
|
||||||
|
|
||||||
# Combine the new cache and the old cache
|
# Combine the new cache and the old cache
|
||||||
@@ -68,7 +70,7 @@ class BitReader:
|
|||||||
|
|
||||||
def read_bytes(self, count: int):
|
def read_bytes(self, count: int):
|
||||||
if self.bit_position + 8 * count > 8 * len(self.data):
|
if self.bit_position + 8 * count > 8 * len(self.data):
|
||||||
raise ValueError('not enough data')
|
raise core.InvalidArgumentError('not enough data')
|
||||||
|
|
||||||
if self.bit_position % 8:
|
if self.bit_position % 8:
|
||||||
# Not byte aligned
|
# Not byte aligned
|
||||||
@@ -113,7 +115,7 @@ class AacAudioRtpPacket:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def program_config_element(reader: BitReader):
|
def program_config_element(reader: BitReader):
|
||||||
raise ValueError('program_config_element not supported')
|
raise core.InvalidPacketError('program_config_element not supported')
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GASpecificConfig:
|
class GASpecificConfig:
|
||||||
@@ -140,7 +142,7 @@ class AacAudioRtpPacket:
|
|||||||
aac_spectral_data_resilience_flags = reader.read(1)
|
aac_spectral_data_resilience_flags = reader.read(1)
|
||||||
extension_flag_3 = reader.read(1)
|
extension_flag_3 = reader.read(1)
|
||||||
if extension_flag_3 == 1:
|
if extension_flag_3 == 1:
|
||||||
raise ValueError('extensionFlag3 == 1 not supported')
|
raise core.InvalidPacketError('extensionFlag3 == 1 not supported')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def audio_object_type(reader: BitReader):
|
def audio_object_type(reader: BitReader):
|
||||||
@@ -216,7 +218,7 @@ class AacAudioRtpPacket:
|
|||||||
reader, self.channel_configuration, self.audio_object_type
|
reader, self.channel_configuration, self.audio_object_type
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise core.InvalidPacketError(
|
||||||
f'audioObjectType {self.audio_object_type} not supported'
|
f'audioObjectType {self.audio_object_type} not supported'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -260,7 +262,7 @@ class AacAudioRtpPacket:
|
|||||||
else:
|
else:
|
||||||
audio_mux_version_a = 0
|
audio_mux_version_a = 0
|
||||||
if audio_mux_version_a != 0:
|
if audio_mux_version_a != 0:
|
||||||
raise ValueError('audioMuxVersionA != 0 not supported')
|
raise core.InvalidPacketError('audioMuxVersionA != 0 not supported')
|
||||||
if audio_mux_version == 1:
|
if audio_mux_version == 1:
|
||||||
tara_buffer_fullness = AacAudioRtpPacket.latm_value(reader)
|
tara_buffer_fullness = AacAudioRtpPacket.latm_value(reader)
|
||||||
stream_cnt = 0
|
stream_cnt = 0
|
||||||
@@ -268,10 +270,10 @@ class AacAudioRtpPacket:
|
|||||||
num_sub_frames = reader.read(6)
|
num_sub_frames = reader.read(6)
|
||||||
num_program = reader.read(4)
|
num_program = reader.read(4)
|
||||||
if num_program != 0:
|
if num_program != 0:
|
||||||
raise ValueError('num_program != 0 not supported')
|
raise core.InvalidPacketError('num_program != 0 not supported')
|
||||||
num_layer = reader.read(3)
|
num_layer = reader.read(3)
|
||||||
if num_layer != 0:
|
if num_layer != 0:
|
||||||
raise ValueError('num_layer != 0 not supported')
|
raise core.InvalidPacketError('num_layer != 0 not supported')
|
||||||
if audio_mux_version == 0:
|
if audio_mux_version == 0:
|
||||||
self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
|
self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
|
||||||
reader
|
reader
|
||||||
@@ -284,7 +286,7 @@ class AacAudioRtpPacket:
|
|||||||
)
|
)
|
||||||
audio_specific_config_len = reader.bit_position - marker
|
audio_specific_config_len = reader.bit_position - marker
|
||||||
if asc_len < audio_specific_config_len:
|
if asc_len < audio_specific_config_len:
|
||||||
raise ValueError('audio_specific_config_len > asc_len')
|
raise core.InvalidPacketError('audio_specific_config_len > asc_len')
|
||||||
asc_len -= audio_specific_config_len
|
asc_len -= audio_specific_config_len
|
||||||
reader.skip(asc_len)
|
reader.skip(asc_len)
|
||||||
frame_length_type = reader.read(3)
|
frame_length_type = reader.read(3)
|
||||||
@@ -293,7 +295,9 @@ class AacAudioRtpPacket:
|
|||||||
elif frame_length_type == 1:
|
elif frame_length_type == 1:
|
||||||
frame_length = reader.read(9)
|
frame_length = reader.read(9)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f'frame_length_type {frame_length_type} not supported')
|
raise core.InvalidPacketError(
|
||||||
|
f'frame_length_type {frame_length_type} not supported'
|
||||||
|
)
|
||||||
|
|
||||||
self.other_data_present = reader.read(1)
|
self.other_data_present = reader.read(1)
|
||||||
if self.other_data_present:
|
if self.other_data_present:
|
||||||
@@ -318,12 +322,12 @@ class AacAudioRtpPacket:
|
|||||||
|
|
||||||
def __init__(self, reader: BitReader, mux_config_present: int):
|
def __init__(self, reader: BitReader, mux_config_present: int):
|
||||||
if mux_config_present == 0:
|
if mux_config_present == 0:
|
||||||
raise ValueError('muxConfigPresent == 0 not supported')
|
raise core.InvalidPacketError('muxConfigPresent == 0 not supported')
|
||||||
|
|
||||||
# AudioMuxElement - ISO/EIC 14496-3 Table 1.41
|
# AudioMuxElement - ISO/EIC 14496-3 Table 1.41
|
||||||
use_same_stream_mux = reader.read(1)
|
use_same_stream_mux = reader.read(1)
|
||||||
if use_same_stream_mux:
|
if use_same_stream_mux:
|
||||||
raise ValueError('useSameStreamMux == 1 not supported')
|
raise core.InvalidPacketError('useSameStreamMux == 1 not supported')
|
||||||
self.stream_mux_config = AacAudioRtpPacket.StreamMuxConfig(reader)
|
self.stream_mux_config = AacAudioRtpPacket.StreamMuxConfig(reader)
|
||||||
|
|
||||||
# We only support:
|
# We only support:
|
||||||
|
|||||||
+6
-2
@@ -16,6 +16,10 @@ from functools import partial
|
|||||||
from typing import List, Optional, Union
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
|
||||||
|
class ColorError(ValueError):
|
||||||
|
"""Error raised when a color spec is invalid."""
|
||||||
|
|
||||||
|
|
||||||
# ANSI color names. There is also a "default"
|
# ANSI color names. There is also a "default"
|
||||||
COLORS = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white')
|
COLORS = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white')
|
||||||
|
|
||||||
@@ -52,7 +56,7 @@ def _color_code(spec: ColorSpec, base: int) -> str:
|
|||||||
elif isinstance(spec, int) and 0 <= spec <= 255:
|
elif isinstance(spec, int) and 0 <= spec <= 255:
|
||||||
return _join(base + 8, 5, spec)
|
return _join(base + 8, 5, spec)
|
||||||
else:
|
else:
|
||||||
raise ValueError('Invalid color spec "%s"' % spec)
|
raise ColorError('Invalid color spec "%s"' % spec)
|
||||||
|
|
||||||
|
|
||||||
def color(
|
def color(
|
||||||
@@ -72,7 +76,7 @@ def color(
|
|||||||
if style_part in STYLES:
|
if style_part in STYLES:
|
||||||
codes.append(STYLES.index(style_part))
|
codes.append(STYLES.index(style_part))
|
||||||
else:
|
else:
|
||||||
raise ValueError('Invalid style "%s"' % style_part)
|
raise ColorError('Invalid style "%s"' % style_part)
|
||||||
|
|
||||||
if codes:
|
if codes:
|
||||||
return '\x1b[{0}m{1}\x1b[0m'.format(_join(*codes), s)
|
return '\x1b[{0}m{1}\x1b[0m'.format(_join(*codes), s)
|
||||||
|
|||||||
+33
-7
@@ -79,7 +79,13 @@ def get_dict_key_by_value(dictionary, value):
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Exceptions
|
# Exceptions
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class BaseError(Exception):
|
|
||||||
|
|
||||||
|
class BaseBumbleError(Exception):
|
||||||
|
"""Base Error raised by Bumble."""
|
||||||
|
|
||||||
|
|
||||||
|
class BaseError(BaseBumbleError):
|
||||||
"""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__(
|
def __init__(
|
||||||
@@ -118,18 +124,38 @@ class ProtocolError(BaseError):
|
|||||||
"""Protocol Error"""
|
"""Protocol Error"""
|
||||||
|
|
||||||
|
|
||||||
class TimeoutError(Exception): # pylint: disable=redefined-builtin
|
class TimeoutError(BaseBumbleError): # pylint: disable=redefined-builtin
|
||||||
"""Timeout Error"""
|
"""Timeout Error"""
|
||||||
|
|
||||||
|
|
||||||
class CommandTimeoutError(Exception):
|
class CommandTimeoutError(BaseBumbleError):
|
||||||
"""Command Timeout Error"""
|
"""Command Timeout Error"""
|
||||||
|
|
||||||
|
|
||||||
class InvalidStateError(Exception):
|
class InvalidStateError(BaseBumbleError):
|
||||||
"""Invalid State Error"""
|
"""Invalid State Error"""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidArgumentError(BaseBumbleError, ValueError):
|
||||||
|
"""Invalid Argument Error"""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPacketError(BaseBumbleError, ValueError):
|
||||||
|
"""Invalid Packet Error"""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidOperationError(BaseBumbleError, RuntimeError):
|
||||||
|
"""Invalid Operation Error"""
|
||||||
|
|
||||||
|
|
||||||
|
class OutOfResourcesError(BaseBumbleError, RuntimeError):
|
||||||
|
"""Out of Resources Error"""
|
||||||
|
|
||||||
|
|
||||||
|
class UnreachableError(BaseBumbleError):
|
||||||
|
"""The code path raising this error should be unreachable."""
|
||||||
|
|
||||||
|
|
||||||
class ConnectionError(BaseError): # pylint: disable=redefined-builtin
|
class ConnectionError(BaseError): # pylint: disable=redefined-builtin
|
||||||
"""Connection Error"""
|
"""Connection Error"""
|
||||||
|
|
||||||
@@ -188,12 +214,12 @@ class UUID:
|
|||||||
or uuid_str_or_int[18] != '-'
|
or uuid_str_or_int[18] != '-'
|
||||||
or uuid_str_or_int[23] != '-'
|
or uuid_str_or_int[23] != '-'
|
||||||
):
|
):
|
||||||
raise ValueError('invalid UUID format')
|
raise InvalidArgumentError('invalid UUID format')
|
||||||
uuid_str = uuid_str_or_int.replace('-', '')
|
uuid_str = uuid_str_or_int.replace('-', '')
|
||||||
else:
|
else:
|
||||||
uuid_str = uuid_str_or_int
|
uuid_str = uuid_str_or_int
|
||||||
if len(uuid_str) != 32 and len(uuid_str) != 8 and len(uuid_str) != 4:
|
if len(uuid_str) != 32 and len(uuid_str) != 8 and len(uuid_str) != 4:
|
||||||
raise ValueError(f"invalid UUID format: {uuid_str}")
|
raise InvalidArgumentError(f"invalid UUID format: {uuid_str}")
|
||||||
self.uuid_bytes = bytes(reversed(bytes.fromhex(uuid_str)))
|
self.uuid_bytes = bytes(reversed(bytes.fromhex(uuid_str)))
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
||||||
@@ -218,7 +244,7 @@ class UUID:
|
|||||||
|
|
||||||
return self.register()
|
return self.register()
|
||||||
|
|
||||||
raise ValueError('only 2, 4 and 16 bytes are allowed')
|
raise InvalidArgumentError('only 2, 4 and 16 bytes are allowed')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_16_bits(cls, uuid_16: int, name: Optional[str] = None) -> UUID:
|
def from_16_bits(cls, uuid_16: int, name: Optional[str] = None) -> UUID:
|
||||||
|
|||||||
+98
-42
@@ -27,6 +27,7 @@ import copy
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum, IntEnum
|
from enum import Enum, IntEnum
|
||||||
import functools
|
import functools
|
||||||
|
import itertools
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
@@ -178,10 +179,15 @@ from .core import (
|
|||||||
BT_LE_TRANSPORT,
|
BT_LE_TRANSPORT,
|
||||||
BT_PERIPHERAL_ROLE,
|
BT_PERIPHERAL_ROLE,
|
||||||
AdvertisingData,
|
AdvertisingData,
|
||||||
|
BaseBumbleError,
|
||||||
ConnectionParameterUpdateError,
|
ConnectionParameterUpdateError,
|
||||||
CommandTimeoutError,
|
CommandTimeoutError,
|
||||||
ConnectionPHY,
|
ConnectionPHY,
|
||||||
|
InvalidArgumentError,
|
||||||
|
InvalidOperationError,
|
||||||
InvalidStateError,
|
InvalidStateError,
|
||||||
|
OutOfResourcesError,
|
||||||
|
UnreachableError,
|
||||||
)
|
)
|
||||||
from .utils import (
|
from .utils import (
|
||||||
AsyncRunner,
|
AsyncRunner,
|
||||||
@@ -266,6 +272,8 @@ DEVICE_MAX_HIGH_DUTY_CYCLE_CONNECTABLE_DIRECTED_ADVERTISING_DURATION = 1.28
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Classes
|
# Classes
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
class ObjectLookupError(BaseBumbleError):
|
||||||
|
"""Error raised when failed to lookup an object."""
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -1133,6 +1141,15 @@ class Peer:
|
|||||||
async def discover_attributes(self) -> List[gatt_client.AttributeProxy]:
|
async def discover_attributes(self) -> List[gatt_client.AttributeProxy]:
|
||||||
return await self.gatt_client.discover_attributes()
|
return await self.gatt_client.discover_attributes()
|
||||||
|
|
||||||
|
async def discover_all(self):
|
||||||
|
await self.discover_services()
|
||||||
|
for service in self.services:
|
||||||
|
await self.discover_characteristics(service=service)
|
||||||
|
|
||||||
|
for service in self.services:
|
||||||
|
for characteristic in service.characteristics:
|
||||||
|
await self.discover_descriptors(characteristic=characteristic)
|
||||||
|
|
||||||
async def subscribe(
|
async def subscribe(
|
||||||
self,
|
self,
|
||||||
characteristic: gatt_client.CharacteristicProxy,
|
characteristic: gatt_client.CharacteristicProxy,
|
||||||
@@ -1172,8 +1189,20 @@ class Peer:
|
|||||||
return self.gatt_client.get_services_by_uuid(uuid)
|
return self.gatt_client.get_services_by_uuid(uuid)
|
||||||
|
|
||||||
def get_characteristics_by_uuid(
|
def get_characteristics_by_uuid(
|
||||||
self, uuid: core.UUID, service: Optional[gatt_client.ServiceProxy] = None
|
self,
|
||||||
|
uuid: core.UUID,
|
||||||
|
service: Optional[Union[gatt_client.ServiceProxy, core.UUID]] = None,
|
||||||
) -> List[gatt_client.CharacteristicProxy]:
|
) -> List[gatt_client.CharacteristicProxy]:
|
||||||
|
if isinstance(service, core.UUID):
|
||||||
|
return list(
|
||||||
|
itertools.chain(
|
||||||
|
*[
|
||||||
|
self.get_characteristics_by_uuid(uuid, s)
|
||||||
|
for s in self.get_services_by_uuid(service)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return self.gatt_client.get_characteristics_by_uuid(uuid, service)
|
return self.gatt_client.get_characteristics_by_uuid(uuid, service)
|
||||||
|
|
||||||
def create_service_proxy(self, proxy_class: Type[_PROXY_CLASS]) -> _PROXY_CLASS:
|
def create_service_proxy(self, proxy_class: Type[_PROXY_CLASS]) -> _PROXY_CLASS:
|
||||||
@@ -1640,7 +1669,9 @@ def with_connection_from_handle(function):
|
|||||||
@functools.wraps(function)
|
@functools.wraps(function)
|
||||||
def wrapper(self, connection_handle, *args, **kwargs):
|
def wrapper(self, connection_handle, *args, **kwargs):
|
||||||
if (connection := self.lookup_connection(connection_handle)) is None:
|
if (connection := self.lookup_connection(connection_handle)) is None:
|
||||||
raise ValueError(f'no connection for handle: 0x{connection_handle:04x}')
|
raise ObjectLookupError(
|
||||||
|
f'no connection for handle: 0x{connection_handle:04x}'
|
||||||
|
)
|
||||||
return function(self, connection, *args, **kwargs)
|
return function(self, connection, *args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -1655,7 +1686,7 @@ def with_connection_from_address(function):
|
|||||||
for connection in self.connections.values():
|
for connection in self.connections.values():
|
||||||
if connection.peer_address == address:
|
if connection.peer_address == address:
|
||||||
return function(self, connection, *args, **kwargs)
|
return function(self, connection, *args, **kwargs)
|
||||||
raise ValueError('no connection for address')
|
raise ObjectLookupError('no connection for address')
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
@@ -1856,6 +1887,7 @@ class Device(CompositeEventEmitter):
|
|||||||
|
|
||||||
# Extended advertising.
|
# Extended advertising.
|
||||||
self.extended_advertising_sets: Dict[int, AdvertisingSet] = {}
|
self.extended_advertising_sets: Dict[int, AdvertisingSet] = {}
|
||||||
|
self.connecting_extended_advertising_sets: Dict[int, AdvertisingSet] = {}
|
||||||
|
|
||||||
# Legacy advertising.
|
# Legacy advertising.
|
||||||
# The advertising and scan response data, as well as the advertising interval
|
# The advertising and scan response data, as well as the advertising interval
|
||||||
@@ -2092,7 +2124,7 @@ class Device(CompositeEventEmitter):
|
|||||||
spec=spec,
|
spec=spec,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f'Unexpected mode {spec}')
|
raise InvalidArgumentError(f'Unexpected mode {spec}')
|
||||||
|
|
||||||
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
||||||
self.host.send_l2cap_pdu(connection_handle, cid, pdu)
|
self.host.send_l2cap_pdu(connection_handle, cid, pdu)
|
||||||
@@ -2253,7 +2285,7 @@ class Device(CompositeEventEmitter):
|
|||||||
def supports_le_features(self, feature: LeFeatureMask) -> bool:
|
def supports_le_features(self, feature: LeFeatureMask) -> bool:
|
||||||
return self.host.supports_le_features(feature)
|
return self.host.supports_le_features(feature)
|
||||||
|
|
||||||
def supports_le_phy(self, phy):
|
def supports_le_phy(self, phy: int) -> bool:
|
||||||
if phy == HCI_LE_1M_PHY:
|
if phy == HCI_LE_1M_PHY:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -2262,7 +2294,7 @@ class Device(CompositeEventEmitter):
|
|||||||
HCI_LE_CODED_PHY: LeFeatureMask.LE_CODED_PHY,
|
HCI_LE_CODED_PHY: LeFeatureMask.LE_CODED_PHY,
|
||||||
}
|
}
|
||||||
if phy not in feature_map:
|
if phy not in feature_map:
|
||||||
raise ValueError('invalid PHY')
|
raise InvalidArgumentError('invalid PHY')
|
||||||
|
|
||||||
return self.supports_le_features(feature_map[phy])
|
return self.supports_le_features(feature_map[phy])
|
||||||
|
|
||||||
@@ -2322,7 +2354,7 @@ class Device(CompositeEventEmitter):
|
|||||||
# Decide what peer address to use
|
# Decide what peer address to use
|
||||||
if advertising_type.is_directed:
|
if advertising_type.is_directed:
|
||||||
if target is None:
|
if target is None:
|
||||||
raise ValueError('directed advertising requires a target')
|
raise InvalidArgumentError('directed advertising requires a target')
|
||||||
peer_address = target
|
peer_address = target
|
||||||
else:
|
else:
|
||||||
peer_address = Address.ANY
|
peer_address = Address.ANY
|
||||||
@@ -2429,7 +2461,7 @@ class Device(CompositeEventEmitter):
|
|||||||
and advertising_data
|
and advertising_data
|
||||||
and scan_response_data
|
and scan_response_data
|
||||||
):
|
):
|
||||||
raise ValueError(
|
raise InvalidArgumentError(
|
||||||
"Extended advertisements can't have both data and scan \
|
"Extended advertisements can't have both data and scan \
|
||||||
response data"
|
response data"
|
||||||
)
|
)
|
||||||
@@ -2445,7 +2477,9 @@ class Device(CompositeEventEmitter):
|
|||||||
if handle not in self.extended_advertising_sets
|
if handle not in self.extended_advertising_sets
|
||||||
)
|
)
|
||||||
except StopIteration as exc:
|
except StopIteration as exc:
|
||||||
raise RuntimeError("all valid advertising handles already in use") from exc
|
raise OutOfResourcesError(
|
||||||
|
"all valid advertising handles already in use"
|
||||||
|
) from exc
|
||||||
|
|
||||||
# Use the device's random address if a random address is needed but none was
|
# Use the device's random address if a random address is needed but none was
|
||||||
# provided.
|
# provided.
|
||||||
@@ -2544,14 +2578,14 @@ class Device(CompositeEventEmitter):
|
|||||||
) -> None:
|
) -> None:
|
||||||
# Check that the arguments are legal
|
# Check that the arguments are legal
|
||||||
if scan_interval < scan_window:
|
if scan_interval < scan_window:
|
||||||
raise ValueError('scan_interval must be >= scan_window')
|
raise InvalidArgumentError('scan_interval must be >= scan_window')
|
||||||
if (
|
if (
|
||||||
scan_interval < DEVICE_MIN_SCAN_INTERVAL
|
scan_interval < DEVICE_MIN_SCAN_INTERVAL
|
||||||
or scan_interval > DEVICE_MAX_SCAN_INTERVAL
|
or scan_interval > DEVICE_MAX_SCAN_INTERVAL
|
||||||
):
|
):
|
||||||
raise ValueError('scan_interval out of range')
|
raise InvalidArgumentError('scan_interval out of range')
|
||||||
if scan_window < DEVICE_MIN_SCAN_WINDOW or scan_window > DEVICE_MAX_SCAN_WINDOW:
|
if scan_window < DEVICE_MIN_SCAN_WINDOW or scan_window > DEVICE_MAX_SCAN_WINDOW:
|
||||||
raise ValueError('scan_interval out of range')
|
raise InvalidArgumentError('scan_interval out of range')
|
||||||
|
|
||||||
# Reset the accumulators
|
# Reset the accumulators
|
||||||
self.advertisement_accumulators = {}
|
self.advertisement_accumulators = {}
|
||||||
@@ -2579,7 +2613,7 @@ class Device(CompositeEventEmitter):
|
|||||||
scanning_phy_count += 1
|
scanning_phy_count += 1
|
||||||
|
|
||||||
if scanning_phy_count == 0:
|
if scanning_phy_count == 0:
|
||||||
raise ValueError('at least one scanning PHY must be enabled')
|
raise InvalidArgumentError('at least one scanning PHY must be enabled')
|
||||||
|
|
||||||
await self.send_command(
|
await self.send_command(
|
||||||
HCI_LE_Set_Extended_Scan_Parameters_Command(
|
HCI_LE_Set_Extended_Scan_Parameters_Command(
|
||||||
@@ -2883,7 +2917,7 @@ class Device(CompositeEventEmitter):
|
|||||||
|
|
||||||
# Check parameters
|
# Check parameters
|
||||||
if transport not in (BT_LE_TRANSPORT, BT_BR_EDR_TRANSPORT):
|
if transport not in (BT_LE_TRANSPORT, BT_BR_EDR_TRANSPORT):
|
||||||
raise ValueError('invalid transport')
|
raise InvalidArgumentError('invalid transport')
|
||||||
|
|
||||||
# Adjust the transport automatically if we need to
|
# Adjust the transport automatically if we need to
|
||||||
if transport == BT_LE_TRANSPORT and not self.le_enabled:
|
if transport == BT_LE_TRANSPORT and not self.le_enabled:
|
||||||
@@ -2900,7 +2934,7 @@ class Device(CompositeEventEmitter):
|
|||||||
peer_address = Address.from_string_for_transport(
|
peer_address = Address.from_string_for_transport(
|
||||||
peer_address, transport
|
peer_address, transport
|
||||||
)
|
)
|
||||||
except ValueError:
|
except InvalidArgumentError:
|
||||||
# If the address is not parsable, assume it is a name instead
|
# If the address is not parsable, assume it is a name instead
|
||||||
logger.debug('looking for peer by name')
|
logger.debug('looking for peer by name')
|
||||||
peer_address = await self.find_peer_by_name(
|
peer_address = await self.find_peer_by_name(
|
||||||
@@ -2912,7 +2946,7 @@ class Device(CompositeEventEmitter):
|
|||||||
transport == BT_BR_EDR_TRANSPORT
|
transport == BT_BR_EDR_TRANSPORT
|
||||||
and peer_address.address_type != Address.PUBLIC_DEVICE_ADDRESS
|
and peer_address.address_type != Address.PUBLIC_DEVICE_ADDRESS
|
||||||
):
|
):
|
||||||
raise ValueError('BR/EDR addresses must be PUBLIC')
|
raise InvalidArgumentError('BR/EDR addresses must be PUBLIC')
|
||||||
|
|
||||||
assert isinstance(peer_address, Address)
|
assert isinstance(peer_address, Address)
|
||||||
|
|
||||||
@@ -2963,7 +2997,7 @@ class Device(CompositeEventEmitter):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not phys:
|
if not phys:
|
||||||
raise ValueError('at least one supported PHY needed')
|
raise InvalidArgumentError('at least one supported PHY needed')
|
||||||
|
|
||||||
phy_count = len(phys)
|
phy_count = len(phys)
|
||||||
initiating_phys = phy_list_to_bits(phys)
|
initiating_phys = phy_list_to_bits(phys)
|
||||||
@@ -3035,7 +3069,7 @@ class Device(CompositeEventEmitter):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if HCI_LE_1M_PHY not in connection_parameters_preferences:
|
if HCI_LE_1M_PHY not in connection_parameters_preferences:
|
||||||
raise ValueError('1M PHY preferences required')
|
raise InvalidArgumentError('1M PHY preferences required')
|
||||||
|
|
||||||
prefs = connection_parameters_preferences[HCI_LE_1M_PHY]
|
prefs = connection_parameters_preferences[HCI_LE_1M_PHY]
|
||||||
result = await self.send_command(
|
result = await self.send_command(
|
||||||
@@ -3135,7 +3169,7 @@ class Device(CompositeEventEmitter):
|
|||||||
if isinstance(peer_address, str):
|
if isinstance(peer_address, str):
|
||||||
try:
|
try:
|
||||||
peer_address = Address(peer_address)
|
peer_address = Address(peer_address)
|
||||||
except ValueError:
|
except InvalidArgumentError:
|
||||||
# If the address is not parsable, assume it is a name instead
|
# If the address is not parsable, assume it is a name instead
|
||||||
logger.debug('looking for peer by name')
|
logger.debug('looking for peer by name')
|
||||||
peer_address = await self.find_peer_by_name(
|
peer_address = await self.find_peer_by_name(
|
||||||
@@ -3145,7 +3179,7 @@ class Device(CompositeEventEmitter):
|
|||||||
assert isinstance(peer_address, Address)
|
assert isinstance(peer_address, Address)
|
||||||
|
|
||||||
if peer_address == Address.NIL:
|
if peer_address == Address.NIL:
|
||||||
raise ValueError('accept on nil address')
|
raise InvalidArgumentError('accept on nil address')
|
||||||
|
|
||||||
# Create a future so that we can wait for the request
|
# Create a future so that we can wait for the request
|
||||||
pending_request_fut = asyncio.get_running_loop().create_future()
|
pending_request_fut = asyncio.get_running_loop().create_future()
|
||||||
@@ -3258,7 +3292,7 @@ class Device(CompositeEventEmitter):
|
|||||||
if isinstance(peer_address, str):
|
if isinstance(peer_address, str):
|
||||||
try:
|
try:
|
||||||
peer_address = Address(peer_address)
|
peer_address = Address(peer_address)
|
||||||
except ValueError:
|
except InvalidArgumentError:
|
||||||
# If the address is not parsable, assume it is a name instead
|
# If the address is not parsable, assume it is a name instead
|
||||||
logger.debug('looking for peer by name')
|
logger.debug('looking for peer by name')
|
||||||
peer_address = await self.find_peer_by_name(
|
peer_address = await self.find_peer_by_name(
|
||||||
@@ -3301,10 +3335,10 @@ class Device(CompositeEventEmitter):
|
|||||||
|
|
||||||
async def set_data_length(self, connection, tx_octets, tx_time) -> None:
|
async def set_data_length(self, connection, tx_octets, tx_time) -> None:
|
||||||
if tx_octets < 0x001B or tx_octets > 0x00FB:
|
if tx_octets < 0x001B or tx_octets > 0x00FB:
|
||||||
raise ValueError('tx_octets must be between 0x001B and 0x00FB')
|
raise InvalidArgumentError('tx_octets must be between 0x001B and 0x00FB')
|
||||||
|
|
||||||
if tx_time < 0x0148 or tx_time > 0x4290:
|
if tx_time < 0x0148 or tx_time > 0x4290:
|
||||||
raise ValueError('tx_time must be between 0x0148 and 0x4290')
|
raise InvalidArgumentError('tx_time must be between 0x0148 and 0x4290')
|
||||||
|
|
||||||
return await self.send_command(
|
return await self.send_command(
|
||||||
HCI_LE_Set_Data_Length_Command(
|
HCI_LE_Set_Data_Length_Command(
|
||||||
@@ -3579,7 +3613,7 @@ class Device(CompositeEventEmitter):
|
|||||||
|
|
||||||
async def encrypt(self, connection, enable=True):
|
async def encrypt(self, connection, enable=True):
|
||||||
if not enable and connection.transport == BT_LE_TRANSPORT:
|
if not enable and connection.transport == BT_LE_TRANSPORT:
|
||||||
raise ValueError('`enable` parameter is classic only.')
|
raise InvalidArgumentError('`enable` parameter is classic only.')
|
||||||
|
|
||||||
# Set up event handlers
|
# Set up event handlers
|
||||||
pending_encryption = asyncio.get_running_loop().create_future()
|
pending_encryption = asyncio.get_running_loop().create_future()
|
||||||
@@ -3598,11 +3632,11 @@ class Device(CompositeEventEmitter):
|
|||||||
if connection.transport == BT_LE_TRANSPORT:
|
if connection.transport == BT_LE_TRANSPORT:
|
||||||
# Look for a key in the key store
|
# Look for a key in the key store
|
||||||
if self.keystore is None:
|
if self.keystore is None:
|
||||||
raise RuntimeError('no key store')
|
raise InvalidOperationError('no key store')
|
||||||
|
|
||||||
keys = await self.keystore.get(str(connection.peer_address))
|
keys = await self.keystore.get(str(connection.peer_address))
|
||||||
if keys is None:
|
if keys is None:
|
||||||
raise RuntimeError('keys not found in key store')
|
raise InvalidOperationError('keys not found in key store')
|
||||||
|
|
||||||
if keys.ltk is not None:
|
if keys.ltk is not None:
|
||||||
ltk = keys.ltk.value
|
ltk = keys.ltk.value
|
||||||
@@ -3613,7 +3647,7 @@ class Device(CompositeEventEmitter):
|
|||||||
rand = keys.ltk_central.rand
|
rand = keys.ltk_central.rand
|
||||||
ediv = keys.ltk_central.ediv
|
ediv = keys.ltk_central.ediv
|
||||||
else:
|
else:
|
||||||
raise RuntimeError('no LTK found for peer')
|
raise InvalidOperationError('no LTK found for peer')
|
||||||
|
|
||||||
if connection.role != HCI_CENTRAL_ROLE:
|
if connection.role != HCI_CENTRAL_ROLE:
|
||||||
raise InvalidStateError('only centrals can start encryption')
|
raise InvalidStateError('only centrals can start encryption')
|
||||||
@@ -3888,7 +3922,7 @@ class Device(CompositeEventEmitter):
|
|||||||
return cis_link
|
return cis_link
|
||||||
|
|
||||||
# Mypy believes this is reachable when context is an ExitStack.
|
# Mypy believes this is reachable when context is an ExitStack.
|
||||||
raise InvalidStateError('Unreachable')
|
raise UnreachableError()
|
||||||
|
|
||||||
# [LE only]
|
# [LE only]
|
||||||
@experimental('Only for testing.')
|
@experimental('Only for testing.')
|
||||||
@@ -4009,14 +4043,28 @@ class Device(CompositeEventEmitter):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not (connection := self.lookup_connection(connection_handle)):
|
if connection := self.lookup_connection(connection_handle):
|
||||||
logger.warning(f'no connection for handle 0x{connection_handle:04x}')
|
# We have already received the connection complete event.
|
||||||
|
self._complete_le_extended_advertising_connection(
|
||||||
|
connection, advertising_set
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Associate the connection handle with the advertising set, the connection
|
||||||
|
# will complete later.
|
||||||
|
logger.debug(
|
||||||
|
f'the connection with handle {connection_handle:04X} will complete later'
|
||||||
|
)
|
||||||
|
self.connecting_extended_advertising_sets[connection_handle] = advertising_set
|
||||||
|
|
||||||
|
def _complete_le_extended_advertising_connection(
|
||||||
|
self, connection: Connection, advertising_set: AdvertisingSet
|
||||||
|
) -> None:
|
||||||
# Update the connection address.
|
# Update the connection address.
|
||||||
connection.self_address = (
|
connection.self_address = (
|
||||||
advertising_set.random_address
|
advertising_set.random_address
|
||||||
if advertising_set.advertising_parameters.own_address_type
|
if advertising_set.random_address is not None
|
||||||
|
and advertising_set.advertising_parameters.own_address_type
|
||||||
in (OwnAddressType.RANDOM, OwnAddressType.RESOLVABLE_OR_RANDOM)
|
in (OwnAddressType.RANDOM, OwnAddressType.RESOLVABLE_OR_RANDOM)
|
||||||
else self.public_address
|
else self.public_address
|
||||||
)
|
)
|
||||||
@@ -4105,7 +4153,6 @@ class Device(CompositeEventEmitter):
|
|||||||
# We were connected via a legacy advertisement.
|
# We were connected via a legacy advertisement.
|
||||||
if self.legacy_advertiser:
|
if self.legacy_advertiser:
|
||||||
own_address_type = self.legacy_advertiser.own_address_type
|
own_address_type = self.legacy_advertiser.own_address_type
|
||||||
self.legacy_advertiser = None
|
|
||||||
else:
|
else:
|
||||||
# This should not happen, but just in case, pick a default.
|
# This should not happen, but just in case, pick a default.
|
||||||
logger.warning("connection without an advertiser")
|
logger.warning("connection without an advertiser")
|
||||||
@@ -4136,19 +4183,28 @@ class Device(CompositeEventEmitter):
|
|||||||
)
|
)
|
||||||
self.connections[connection_handle] = connection
|
self.connections[connection_handle] = connection
|
||||||
|
|
||||||
if (
|
if role == HCI_PERIPHERAL_ROLE and self.legacy_advertiser:
|
||||||
role == HCI_PERIPHERAL_ROLE
|
if self.legacy_advertiser.auto_restart:
|
||||||
and self.legacy_advertiser
|
connection.once(
|
||||||
and self.legacy_advertiser.auto_restart
|
'disconnection',
|
||||||
):
|
lambda _: self.abort_on('flush', self.legacy_advertiser.start()),
|
||||||
connection.once(
|
)
|
||||||
'disconnection',
|
else:
|
||||||
lambda _: self.abort_on('flush', self.legacy_advertiser.start()),
|
self.legacy_advertiser = None
|
||||||
)
|
|
||||||
|
|
||||||
if role == HCI_CENTRAL_ROLE or not self.supports_le_extended_advertising:
|
if role == HCI_CENTRAL_ROLE or not self.supports_le_extended_advertising:
|
||||||
# We can emit now, we have all the info we need
|
# We can emit now, we have all the info we need
|
||||||
self._emit_le_connection(connection)
|
self._emit_le_connection(connection)
|
||||||
|
return
|
||||||
|
|
||||||
|
if role == HCI_PERIPHERAL_ROLE and self.supports_le_extended_advertising:
|
||||||
|
if advertising_set := self.connecting_extended_advertising_sets.pop(
|
||||||
|
connection_handle, None
|
||||||
|
):
|
||||||
|
# We have already received the advertising set termination event.
|
||||||
|
self._complete_le_extended_advertising_connection(
|
||||||
|
connection, advertising_set
|
||||||
|
)
|
||||||
|
|
||||||
@host_event_handler
|
@host_event_handler
|
||||||
def on_connection_failure(self, transport, peer_address, error_code):
|
def on_connection_failure(self, transport, peer_address, error_code):
|
||||||
@@ -4354,7 +4410,7 @@ class Device(CompositeEventEmitter):
|
|||||||
return await pairing_config.delegate.confirm(auto=True)
|
return await pairing_config.delegate.confirm(auto=True)
|
||||||
|
|
||||||
async def na() -> bool:
|
async def na() -> bool:
|
||||||
assert False, "N/A: unreachable"
|
raise UnreachableError()
|
||||||
|
|
||||||
# See Bluetooth spec @ Vol 3, Part C 5.2.2.6
|
# See Bluetooth spec @ Vol 3, Part C 5.2.2.6
|
||||||
methods = {
|
methods = {
|
||||||
|
|||||||
+13
-8
@@ -33,6 +33,7 @@ from typing import Tuple
|
|||||||
import weakref
|
import weakref
|
||||||
|
|
||||||
|
|
||||||
|
from bumble import core
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
hci_vendor_command_op_code,
|
hci_vendor_command_op_code,
|
||||||
STATUS_SPEC,
|
STATUS_SPEC,
|
||||||
@@ -49,6 +50,10 @@ from bumble.drivers import common
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RtkFirmwareError(core.BaseBumbleError):
|
||||||
|
"""Error raised when RTK firmware initialization fails."""
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -208,15 +213,15 @@ class Firmware:
|
|||||||
extension_sig = bytes([0x51, 0x04, 0xFD, 0x77])
|
extension_sig = bytes([0x51, 0x04, 0xFD, 0x77])
|
||||||
|
|
||||||
if not firmware.startswith(RTK_EPATCH_SIGNATURE):
|
if not firmware.startswith(RTK_EPATCH_SIGNATURE):
|
||||||
raise ValueError("Firmware does not start with epatch signature")
|
raise RtkFirmwareError("Firmware does not start with epatch signature")
|
||||||
|
|
||||||
if not firmware.endswith(extension_sig):
|
if not firmware.endswith(extension_sig):
|
||||||
raise ValueError("Firmware does not end with extension sig")
|
raise RtkFirmwareError("Firmware does not end with extension sig")
|
||||||
|
|
||||||
# The firmware should start with a 14 byte header.
|
# The firmware should start with a 14 byte header.
|
||||||
epatch_header_size = 14
|
epatch_header_size = 14
|
||||||
if len(firmware) < epatch_header_size:
|
if len(firmware) < epatch_header_size:
|
||||||
raise ValueError("Firmware too short")
|
raise RtkFirmwareError("Firmware too short")
|
||||||
|
|
||||||
# Look for the "project ID", starting from the end.
|
# Look for the "project ID", starting from the end.
|
||||||
offset = len(firmware) - len(extension_sig)
|
offset = len(firmware) - len(extension_sig)
|
||||||
@@ -230,7 +235,7 @@ class Firmware:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if length == 0:
|
if length == 0:
|
||||||
raise ValueError("Invalid 0-length instruction")
|
raise RtkFirmwareError("Invalid 0-length instruction")
|
||||||
|
|
||||||
if opcode == 0 and length == 1:
|
if opcode == 0 and length == 1:
|
||||||
project_id = firmware[offset - 1]
|
project_id = firmware[offset - 1]
|
||||||
@@ -239,7 +244,7 @@ class Firmware:
|
|||||||
offset -= length
|
offset -= length
|
||||||
|
|
||||||
if project_id < 0:
|
if project_id < 0:
|
||||||
raise ValueError("Project ID not found")
|
raise RtkFirmwareError("Project ID not found")
|
||||||
|
|
||||||
self.project_id = project_id
|
self.project_id = project_id
|
||||||
|
|
||||||
@@ -252,7 +257,7 @@ class Firmware:
|
|||||||
# <PatchLength_1><PatchLength_2>...<PatchLength_N> (16 bits each)
|
# <PatchLength_1><PatchLength_2>...<PatchLength_N> (16 bits each)
|
||||||
# <PatchOffset_1><PatchOffset_2>...<PatchOffset_N> (32 bits each)
|
# <PatchOffset_1><PatchOffset_2>...<PatchOffset_N> (32 bits each)
|
||||||
if epatch_header_size + 8 * num_patches > len(firmware):
|
if epatch_header_size + 8 * num_patches > len(firmware):
|
||||||
raise ValueError("Firmware too short")
|
raise RtkFirmwareError("Firmware too short")
|
||||||
chip_id_table_offset = epatch_header_size
|
chip_id_table_offset = epatch_header_size
|
||||||
patch_length_table_offset = chip_id_table_offset + 2 * num_patches
|
patch_length_table_offset = chip_id_table_offset + 2 * num_patches
|
||||||
patch_offset_table_offset = chip_id_table_offset + 4 * num_patches
|
patch_offset_table_offset = chip_id_table_offset + 4 * num_patches
|
||||||
@@ -266,7 +271,7 @@ class Firmware:
|
|||||||
"<I", firmware, patch_offset_table_offset + 4 * patch_index
|
"<I", firmware, patch_offset_table_offset + 4 * patch_index
|
||||||
)
|
)
|
||||||
if patch_offset + patch_length > len(firmware):
|
if patch_offset + patch_length > len(firmware):
|
||||||
raise ValueError("Firmware too short")
|
raise RtkFirmwareError("Firmware too short")
|
||||||
|
|
||||||
# Get the SVN version for the patch
|
# Get the SVN version for the patch
|
||||||
(svn_version,) = struct.unpack_from(
|
(svn_version,) = struct.unpack_from(
|
||||||
@@ -645,7 +650,7 @@ class Driver(common.Driver):
|
|||||||
):
|
):
|
||||||
return await self.download_for_rtl8723b()
|
return await self.download_for_rtl8723b()
|
||||||
|
|
||||||
raise ValueError("ROM not supported")
|
raise RtkFirmwareError("ROM not supported")
|
||||||
|
|
||||||
async def init_controller(self):
|
async def init_controller(self):
|
||||||
await self.download_firmware()
|
await self.download_firmware()
|
||||||
|
|||||||
@@ -331,9 +331,9 @@ class Client:
|
|||||||
async def request_mtu(self, mtu: int) -> int:
|
async def request_mtu(self, mtu: int) -> int:
|
||||||
# Check the range
|
# Check the range
|
||||||
if mtu < ATT_DEFAULT_MTU:
|
if mtu < ATT_DEFAULT_MTU:
|
||||||
raise ValueError(f'MTU must be >= {ATT_DEFAULT_MTU}')
|
raise core.InvalidArgumentError(f'MTU must be >= {ATT_DEFAULT_MTU}')
|
||||||
if mtu > 0xFFFF:
|
if mtu > 0xFFFF:
|
||||||
raise ValueError('MTU must be <= 0xFFFF')
|
raise core.InvalidArgumentError('MTU must be <= 0xFFFF')
|
||||||
|
|
||||||
# We can only send one request per connection
|
# We can only send one request per connection
|
||||||
if self.mtu_exchange_done:
|
if self.mtu_exchange_done:
|
||||||
|
|||||||
+15
-11
@@ -31,6 +31,8 @@ from bumble.core import (
|
|||||||
BT_BR_EDR_TRANSPORT,
|
BT_BR_EDR_TRANSPORT,
|
||||||
AdvertisingData,
|
AdvertisingData,
|
||||||
DeviceClass,
|
DeviceClass,
|
||||||
|
InvalidArgumentError,
|
||||||
|
InvalidPacketError,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
bit_flags_to_strings,
|
bit_flags_to_strings,
|
||||||
name_or_number,
|
name_or_number,
|
||||||
@@ -92,14 +94,14 @@ def map_class_of_device(class_of_device):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def phy_list_to_bits(phys):
|
def phy_list_to_bits(phys: Optional[Iterable[int]]) -> int:
|
||||||
if phys is None:
|
if phys is None:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
phy_bits = 0
|
phy_bits = 0
|
||||||
for phy in phys:
|
for phy in phys:
|
||||||
if phy not in HCI_LE_PHY_TYPE_TO_BIT:
|
if phy not in HCI_LE_PHY_TYPE_TO_BIT:
|
||||||
raise ValueError('invalid PHY')
|
raise InvalidArgumentError('invalid PHY')
|
||||||
phy_bits |= 1 << HCI_LE_PHY_TYPE_TO_BIT[phy]
|
phy_bits |= 1 << HCI_LE_PHY_TYPE_TO_BIT[phy]
|
||||||
return phy_bits
|
return phy_bits
|
||||||
|
|
||||||
@@ -1105,7 +1107,7 @@ HCI_SUPPORTED_COMMANDS_MASKS = {
|
|||||||
|
|
||||||
# LE Supported Features
|
# LE Supported Features
|
||||||
# See Bluetooth spec @ Vol 6, Part B, 4.6 FEATURE SUPPORT
|
# See Bluetooth spec @ Vol 6, Part B, 4.6 FEATURE SUPPORT
|
||||||
class LeFeature(enum.IntEnum):
|
class LeFeature(OpenIntEnum):
|
||||||
LE_ENCRYPTION = 0
|
LE_ENCRYPTION = 0
|
||||||
CONNECTION_PARAMETERS_REQUEST_PROCEDURE = 1
|
CONNECTION_PARAMETERS_REQUEST_PROCEDURE = 1
|
||||||
EXTENDED_REJECT_INDICATION = 2
|
EXTENDED_REJECT_INDICATION = 2
|
||||||
@@ -1553,7 +1555,7 @@ class HCI_Object:
|
|||||||
new_offset, field_value = field_type(data, offset)
|
new_offset, field_value = field_type(data, offset)
|
||||||
return (field_value, new_offset - offset)
|
return (field_value, new_offset - offset)
|
||||||
|
|
||||||
raise ValueError(f'unknown field type {field_type}')
|
raise InvalidArgumentError(f'unknown field type {field_type}')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def dict_from_bytes(data, offset, fields):
|
def dict_from_bytes(data, offset, fields):
|
||||||
@@ -1622,7 +1624,7 @@ class HCI_Object:
|
|||||||
if 0 <= field_value <= 255:
|
if 0 <= field_value <= 255:
|
||||||
field_bytes = bytes([field_value])
|
field_bytes = bytes([field_value])
|
||||||
else:
|
else:
|
||||||
raise ValueError('value too large for *-typed field')
|
raise InvalidArgumentError('value too large for *-typed field')
|
||||||
else:
|
else:
|
||||||
field_bytes = bytes(field_value)
|
field_bytes = bytes(field_value)
|
||||||
elif field_type == 'v':
|
elif field_type == 'v':
|
||||||
@@ -1641,7 +1643,9 @@ class HCI_Object:
|
|||||||
elif len(field_bytes) > field_type:
|
elif len(field_bytes) > field_type:
|
||||||
field_bytes = field_bytes[:field_type]
|
field_bytes = field_bytes[:field_type]
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"don't know how to serialize type {type(field_value)}")
|
raise InvalidArgumentError(
|
||||||
|
f"don't know how to serialize type {type(field_value)}"
|
||||||
|
)
|
||||||
|
|
||||||
return field_bytes
|
return field_bytes
|
||||||
|
|
||||||
@@ -1905,7 +1909,7 @@ class Address:
|
|||||||
self.address_bytes = bytes(reversed(bytes.fromhex(address)))
|
self.address_bytes = bytes(reversed(bytes.fromhex(address)))
|
||||||
|
|
||||||
if len(self.address_bytes) != 6:
|
if len(self.address_bytes) != 6:
|
||||||
raise ValueError('invalid address length')
|
raise InvalidArgumentError('invalid address length')
|
||||||
|
|
||||||
self.address_type = address_type
|
self.address_type = address_type
|
||||||
|
|
||||||
@@ -2108,7 +2112,7 @@ class HCI_Command(HCI_Packet):
|
|||||||
op_code, length = struct.unpack_from('<HB', packet, 1)
|
op_code, length = struct.unpack_from('<HB', packet, 1)
|
||||||
parameters = packet[4:]
|
parameters = packet[4:]
|
||||||
if len(parameters) != length:
|
if len(parameters) != length:
|
||||||
raise ValueError('invalid packet length')
|
raise InvalidPacketError('invalid packet length')
|
||||||
|
|
||||||
# Look for a registered class
|
# Look for a registered class
|
||||||
cls = HCI_Command.command_classes.get(op_code)
|
cls = HCI_Command.command_classes.get(op_code)
|
||||||
@@ -4807,7 +4811,7 @@ class HCI_Event(HCI_Packet):
|
|||||||
length = packet[2]
|
length = packet[2]
|
||||||
parameters = packet[3:]
|
parameters = packet[3:]
|
||||||
if len(parameters) != length:
|
if len(parameters) != length:
|
||||||
raise ValueError('invalid packet length')
|
raise InvalidPacketError('invalid packet length')
|
||||||
|
|
||||||
cls: Any
|
cls: Any
|
||||||
if event_code == HCI_LE_META_EVENT:
|
if event_code == HCI_LE_META_EVENT:
|
||||||
@@ -6342,7 +6346,7 @@ class HCI_AclDataPacket(HCI_Packet):
|
|||||||
bc_flag = (h >> 14) & 3
|
bc_flag = (h >> 14) & 3
|
||||||
data = packet[5:]
|
data = packet[5:]
|
||||||
if len(data) != data_total_length:
|
if len(data) != data_total_length:
|
||||||
raise ValueError('invalid packet length')
|
raise InvalidPacketError('invalid packet length')
|
||||||
return HCI_AclDataPacket(
|
return HCI_AclDataPacket(
|
||||||
connection_handle, pb_flag, bc_flag, data_total_length, data
|
connection_handle, pb_flag, bc_flag, data_total_length, data
|
||||||
)
|
)
|
||||||
@@ -6390,7 +6394,7 @@ class HCI_SynchronousDataPacket(HCI_Packet):
|
|||||||
packet_status = (h >> 12) & 0b11
|
packet_status = (h >> 12) & 0b11
|
||||||
data = packet[4:]
|
data = packet[4:]
|
||||||
if len(data) != data_total_length:
|
if len(data) != data_total_length:
|
||||||
raise ValueError(
|
raise InvalidPacketError(
|
||||||
f'invalid packet length {len(data)} != {data_total_length}'
|
f'invalid packet length {len(data)} != {data_total_length}'
|
||||||
)
|
)
|
||||||
return HCI_SynchronousDataPacket(
|
return HCI_SynchronousDataPacket(
|
||||||
|
|||||||
@@ -787,6 +787,10 @@ class Host(AbortableEventEmitter):
|
|||||||
# Just use the same implementation as for the non-enhanced event for now
|
# Just use the same implementation as for the non-enhanced event for now
|
||||||
self.on_hci_le_connection_complete_event(event)
|
self.on_hci_le_connection_complete_event(event)
|
||||||
|
|
||||||
|
def on_hci_le_enhanced_connection_complete_v2_event(self, event):
|
||||||
|
# Just use the same implementation as for the v1 event for now
|
||||||
|
self.on_hci_le_enhanced_connection_complete_event(event)
|
||||||
|
|
||||||
def on_hci_connection_complete_event(self, event):
|
def on_hci_connection_complete_event(self, event):
|
||||||
if event.status == hci.HCI_SUCCESS:
|
if event.status == hci.HCI_SUCCESS:
|
||||||
# Create/update the connection
|
# Create/update the connection
|
||||||
|
|||||||
+29
-19
@@ -41,7 +41,14 @@ from typing import (
|
|||||||
|
|
||||||
from .utils import deprecated
|
from .utils import deprecated
|
||||||
from .colors import color
|
from .colors import color
|
||||||
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
|
from .core import (
|
||||||
|
BT_CENTRAL_ROLE,
|
||||||
|
InvalidStateError,
|
||||||
|
InvalidArgumentError,
|
||||||
|
InvalidPacketError,
|
||||||
|
OutOfResourcesError,
|
||||||
|
ProtocolError,
|
||||||
|
)
|
||||||
from .hci import (
|
from .hci import (
|
||||||
HCI_LE_Connection_Update_Command,
|
HCI_LE_Connection_Update_Command,
|
||||||
HCI_Object,
|
HCI_Object,
|
||||||
@@ -70,6 +77,7 @@ L2CAP_LE_SIGNALING_CID = 0x05
|
|||||||
|
|
||||||
L2CAP_MIN_LE_MTU = 23
|
L2CAP_MIN_LE_MTU = 23
|
||||||
L2CAP_MIN_BR_EDR_MTU = 48
|
L2CAP_MIN_BR_EDR_MTU = 48
|
||||||
|
L2CAP_MAX_BR_EDR_MTU = 65535
|
||||||
|
|
||||||
L2CAP_DEFAULT_MTU = 2048 # Default value for the MTU we are willing to accept
|
L2CAP_DEFAULT_MTU = 2048 # Default value for the MTU we are willing to accept
|
||||||
|
|
||||||
@@ -188,17 +196,17 @@ class LeCreditBasedChannelSpec:
|
|||||||
self.max_credits < 1
|
self.max_credits < 1
|
||||||
or self.max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
|
or self.max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
|
||||||
):
|
):
|
||||||
raise ValueError('max credits out of range')
|
raise InvalidArgumentError('max credits out of range')
|
||||||
if (
|
if (
|
||||||
self.mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU
|
self.mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU
|
||||||
or self.mtu > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MTU
|
or self.mtu > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MTU
|
||||||
):
|
):
|
||||||
raise ValueError('MTU out of range')
|
raise InvalidArgumentError('MTU out of range')
|
||||||
if (
|
if (
|
||||||
self.mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
|
self.mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
|
||||||
or self.mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
|
or self.mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
|
||||||
):
|
):
|
||||||
raise ValueError('MPS out of range')
|
raise InvalidArgumentError('MPS out of range')
|
||||||
|
|
||||||
|
|
||||||
class L2CAP_PDU:
|
class L2CAP_PDU:
|
||||||
@@ -210,7 +218,7 @@ class L2CAP_PDU:
|
|||||||
def from_bytes(data: bytes) -> L2CAP_PDU:
|
def from_bytes(data: bytes) -> L2CAP_PDU:
|
||||||
# Check parameters
|
# Check parameters
|
||||||
if len(data) < 4:
|
if len(data) < 4:
|
||||||
raise ValueError('not enough data for L2CAP header')
|
raise InvalidPacketError('not enough data for L2CAP header')
|
||||||
|
|
||||||
_, l2cap_pdu_cid = struct.unpack_from('<HH', data, 0)
|
_, l2cap_pdu_cid = struct.unpack_from('<HH', data, 0)
|
||||||
l2cap_pdu_payload = data[4:]
|
l2cap_pdu_payload = data[4:]
|
||||||
@@ -815,7 +823,7 @@ class ClassicChannel(EventEmitter):
|
|||||||
|
|
||||||
# Check that we can start a new connection
|
# Check that we can start a new connection
|
||||||
if self.connection_result:
|
if self.connection_result:
|
||||||
raise RuntimeError('connection already pending')
|
raise InvalidStateError('connection already pending')
|
||||||
|
|
||||||
self._change_state(self.State.WAIT_CONNECT_RSP)
|
self._change_state(self.State.WAIT_CONNECT_RSP)
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
@@ -832,7 +840,9 @@ class ClassicChannel(EventEmitter):
|
|||||||
|
|
||||||
# Wait for the connection to succeed or fail
|
# Wait for the connection to succeed or fail
|
||||||
try:
|
try:
|
||||||
return await self.connection_result
|
return await self.connection.abort_on(
|
||||||
|
'disconnection', self.connection_result
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
|
|
||||||
@@ -1126,7 +1136,7 @@ class LeCreditBasedChannel(EventEmitter):
|
|||||||
# Check that we can start a new connection
|
# Check that we can start a new connection
|
||||||
identifier = self.manager.next_identifier(self.connection)
|
identifier = self.manager.next_identifier(self.connection)
|
||||||
if identifier in self.manager.le_coc_requests:
|
if identifier in self.manager.le_coc_requests:
|
||||||
raise RuntimeError('too many concurrent connection requests')
|
raise InvalidStateError('too many concurrent connection requests')
|
||||||
|
|
||||||
self._change_state(self.State.CONNECTING)
|
self._change_state(self.State.CONNECTING)
|
||||||
request = L2CAP_LE_Credit_Based_Connection_Request(
|
request = L2CAP_LE_Credit_Based_Connection_Request(
|
||||||
@@ -1513,7 +1523,7 @@ class ChannelManager:
|
|||||||
if cid not in channels:
|
if cid not in channels:
|
||||||
return cid
|
return cid
|
||||||
|
|
||||||
raise RuntimeError('no free CID available')
|
raise OutOfResourcesError('no free CID available')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_free_le_cid(channels: Iterable[int]) -> int:
|
def find_free_le_cid(channels: Iterable[int]) -> int:
|
||||||
@@ -1526,7 +1536,7 @@ class ChannelManager:
|
|||||||
if cid not in channels:
|
if cid not in channels:
|
||||||
return cid
|
return cid
|
||||||
|
|
||||||
raise RuntimeError('no free CID')
|
raise OutOfResourcesError('no free CID')
|
||||||
|
|
||||||
def next_identifier(self, connection: Connection) -> int:
|
def next_identifier(self, connection: Connection) -> int:
|
||||||
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
|
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
|
||||||
@@ -1573,15 +1583,15 @@ class ChannelManager:
|
|||||||
else:
|
else:
|
||||||
# Check that the PSM isn't already in use
|
# Check that the PSM isn't already in use
|
||||||
if spec.psm in self.servers:
|
if spec.psm in self.servers:
|
||||||
raise ValueError('PSM already in use')
|
raise InvalidArgumentError('PSM already in use')
|
||||||
|
|
||||||
# Check that the PSM is valid
|
# Check that the PSM is valid
|
||||||
if spec.psm % 2 == 0:
|
if spec.psm % 2 == 0:
|
||||||
raise ValueError('invalid PSM (not odd)')
|
raise InvalidArgumentError('invalid PSM (not odd)')
|
||||||
check = spec.psm >> 8
|
check = spec.psm >> 8
|
||||||
while check:
|
while check:
|
||||||
if check % 2 != 0:
|
if check % 2 != 0:
|
||||||
raise ValueError('invalid PSM')
|
raise InvalidArgumentError('invalid PSM')
|
||||||
check >>= 8
|
check >>= 8
|
||||||
|
|
||||||
self.servers[spec.psm] = ClassicChannelServer(self, spec.psm, handler, spec.mtu)
|
self.servers[spec.psm] = ClassicChannelServer(self, spec.psm, handler, spec.mtu)
|
||||||
@@ -1623,7 +1633,7 @@ class ChannelManager:
|
|||||||
else:
|
else:
|
||||||
# Check that the PSM isn't already in use
|
# Check that the PSM isn't already in use
|
||||||
if spec.psm in self.le_coc_servers:
|
if spec.psm in self.le_coc_servers:
|
||||||
raise ValueError('PSM already in use')
|
raise InvalidArgumentError('PSM already in use')
|
||||||
|
|
||||||
self.le_coc_servers[spec.psm] = LeCreditBasedChannelServer(
|
self.le_coc_servers[spec.psm] = LeCreditBasedChannelServer(
|
||||||
self,
|
self,
|
||||||
@@ -2151,10 +2161,10 @@ class ChannelManager:
|
|||||||
connection_channels = self.channels.setdefault(connection.handle, {})
|
connection_channels = self.channels.setdefault(connection.handle, {})
|
||||||
source_cid = self.find_free_le_cid(connection_channels)
|
source_cid = self.find_free_le_cid(connection_channels)
|
||||||
if source_cid is None: # Should never happen!
|
if source_cid is None: # Should never happen!
|
||||||
raise RuntimeError('all CIDs already in use')
|
raise OutOfResourcesError('all CIDs already in use')
|
||||||
|
|
||||||
if spec.psm is None:
|
if spec.psm is None:
|
||||||
raise ValueError('PSM cannot be None')
|
raise InvalidArgumentError('PSM cannot be None')
|
||||||
|
|
||||||
# Create the channel
|
# Create the channel
|
||||||
logger.debug(f'creating coc channel with cid={source_cid} for psm {spec.psm}')
|
logger.debug(f'creating coc channel with cid={source_cid} for psm {spec.psm}')
|
||||||
@@ -2203,10 +2213,10 @@ class ChannelManager:
|
|||||||
connection_channels = self.channels.setdefault(connection.handle, {})
|
connection_channels = self.channels.setdefault(connection.handle, {})
|
||||||
source_cid = self.find_free_br_edr_cid(connection_channels)
|
source_cid = self.find_free_br_edr_cid(connection_channels)
|
||||||
if source_cid is None: # Should never happen!
|
if source_cid is None: # Should never happen!
|
||||||
raise RuntimeError('all CIDs already in use')
|
raise OutOfResourcesError('all CIDs already in use')
|
||||||
|
|
||||||
if spec.psm is None:
|
if spec.psm is None:
|
||||||
raise ValueError('PSM cannot be None')
|
raise InvalidArgumentError('PSM cannot be None')
|
||||||
|
|
||||||
# Create the channel
|
# Create the channel
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -2225,7 +2235,7 @@ class ChannelManager:
|
|||||||
# Connect
|
# Connect
|
||||||
try:
|
try:
|
||||||
await channel.connect()
|
await channel.connect()
|
||||||
except Exception as e:
|
except BaseException as e:
|
||||||
del connection_channels[source_cid]
|
del connection_channels[source_cid]
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -19,7 +19,12 @@ import logging
|
|||||||
import asyncio
|
import asyncio
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
from bumble.core import BT_PERIPHERAL_ROLE, BT_BR_EDR_TRANSPORT, BT_LE_TRANSPORT
|
from bumble.core import (
|
||||||
|
BT_PERIPHERAL_ROLE,
|
||||||
|
BT_BR_EDR_TRANSPORT,
|
||||||
|
BT_LE_TRANSPORT,
|
||||||
|
InvalidStateError,
|
||||||
|
)
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
Address,
|
Address,
|
||||||
@@ -405,12 +410,12 @@ class RemoteLink:
|
|||||||
|
|
||||||
def add_controller(self, controller):
|
def add_controller(self, controller):
|
||||||
if self.controller:
|
if self.controller:
|
||||||
raise ValueError('controller already set')
|
raise InvalidStateError('controller already set')
|
||||||
self.controller = controller
|
self.controller = controller
|
||||||
|
|
||||||
def remove_controller(self, controller):
|
def remove_controller(self, controller):
|
||||||
if self.controller != controller:
|
if self.controller != controller:
|
||||||
raise ValueError('controller mismatch')
|
raise InvalidStateError('controller mismatch')
|
||||||
self.controller = None
|
self.controller = None
|
||||||
|
|
||||||
def get_pending_connection(self):
|
def get_pending_connection(self):
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
|||||||
set_member_rank: Optional[int] = None,
|
set_member_rank: Optional[int] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if len(set_identity_resolving_key) != SET_IDENTITY_RESOLVING_KEY_LENGTH:
|
if len(set_identity_resolving_key) != SET_IDENTITY_RESOLVING_KEY_LENGTH:
|
||||||
raise ValueError(
|
raise core.InvalidArgumentError(
|
||||||
f'Invalid SIRK length {len(set_identity_resolving_key)}, expected {SET_IDENTITY_RESOLVING_KEY_LENGTH}'
|
f'Invalid SIRK length {len(set_identity_resolving_key)}, expected {SET_IDENTITY_RESOLVING_KEY_LENGTH}'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
|||||||
key = await connection.device.get_link_key(connection.peer_address)
|
key = await connection.device.get_link_key(connection.peer_address)
|
||||||
|
|
||||||
if not key:
|
if not key:
|
||||||
raise RuntimeError('LTK or LinkKey is not present')
|
raise core.InvalidOperationError('LTK or LinkKey is not present')
|
||||||
|
|
||||||
sirk_bytes = sef(key, self.set_identity_resolving_key)
|
sirk_bytes = sef(key, self.set_identity_resolving_key)
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@ class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
|
|||||||
'''Reads SIRK and decrypts if encrypted.'''
|
'''Reads SIRK and decrypts if encrypted.'''
|
||||||
response = await self.set_identity_resolving_key.read_value()
|
response = await self.set_identity_resolving_key.read_value()
|
||||||
if len(response) != SET_IDENTITY_RESOLVING_KEY_LENGTH + 1:
|
if len(response) != SET_IDENTITY_RESOLVING_KEY_LENGTH + 1:
|
||||||
raise RuntimeError('Invalid SIRK value')
|
raise core.InvalidPacketError('Invalid SIRK value')
|
||||||
|
|
||||||
sirk_type = SirkType(response[0])
|
sirk_type = SirkType(response[0])
|
||||||
if sirk_type == SirkType.PLAINTEXT:
|
if sirk_type == SirkType.PLAINTEXT:
|
||||||
@@ -250,7 +250,7 @@ class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
|
|||||||
key = await device.get_link_key(connection.peer_address)
|
key = await device.get_link_key(connection.peer_address)
|
||||||
|
|
||||||
if not key:
|
if not key:
|
||||||
raise RuntimeError('LTK or LinkKey is not present')
|
raise core.InvalidOperationError('LTK or LinkKey is not present')
|
||||||
|
|
||||||
sirk = sef(key, response[1:])
|
sirk = sef(key, response[1:])
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
|
from bumble import core
|
||||||
from ..gatt_client import ProfileServiceProxy
|
from ..gatt_client import ProfileServiceProxy
|
||||||
from ..att import ATT_Error
|
from ..att import ATT_Error
|
||||||
from ..gatt import (
|
from ..gatt import (
|
||||||
@@ -59,17 +60,17 @@ class HeartRateService(TemplateService):
|
|||||||
rr_intervals=None,
|
rr_intervals=None,
|
||||||
):
|
):
|
||||||
if heart_rate < 0 or heart_rate > 0xFFFF:
|
if heart_rate < 0 or heart_rate > 0xFFFF:
|
||||||
raise ValueError('heart_rate out of range')
|
raise core.InvalidArgumentError('heart_rate out of range')
|
||||||
|
|
||||||
if energy_expended is not None and (
|
if energy_expended is not None and (
|
||||||
energy_expended < 0 or energy_expended > 0xFFFF
|
energy_expended < 0 or energy_expended > 0xFFFF
|
||||||
):
|
):
|
||||||
raise ValueError('energy_expended out of range')
|
raise core.InvalidArgumentError('energy_expended out of range')
|
||||||
|
|
||||||
if rr_intervals:
|
if rr_intervals:
|
||||||
for rr_interval in rr_intervals:
|
for rr_interval in rr_intervals:
|
||||||
if rr_interval < 0 or rr_interval * 1024 > 0xFFFF:
|
if rr_interval < 0 or rr_interval * 1024 > 0xFFFF:
|
||||||
raise ValueError('rr_intervals out of range')
|
raise core.InvalidArgumentError('rr_intervals out of range')
|
||||||
|
|
||||||
self.heart_rate = heart_rate
|
self.heart_rate = heart_rate
|
||||||
self.sensor_contact_detected = sensor_contact_detected
|
self.sensor_contact_detected = sensor_contact_detected
|
||||||
|
|||||||
+162
-63
@@ -36,7 +36,9 @@ from .core import (
|
|||||||
BT_RFCOMM_PROTOCOL_ID,
|
BT_RFCOMM_PROTOCOL_ID,
|
||||||
BT_BR_EDR_TRANSPORT,
|
BT_BR_EDR_TRANSPORT,
|
||||||
BT_L2CAP_PROTOCOL_ID,
|
BT_L2CAP_PROTOCOL_ID,
|
||||||
|
InvalidArgumentError,
|
||||||
InvalidStateError,
|
InvalidStateError,
|
||||||
|
InvalidPacketError,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -106,9 +108,11 @@ CRC_TABLE = bytes([
|
|||||||
0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF
|
0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF
|
||||||
])
|
])
|
||||||
|
|
||||||
RFCOMM_DEFAULT_L2CAP_MTU = 2048
|
RFCOMM_DEFAULT_L2CAP_MTU = 2048
|
||||||
RFCOMM_DEFAULT_WINDOW_SIZE = 7
|
RFCOMM_DEFAULT_INITIAL_CREDITS = 7
|
||||||
RFCOMM_DEFAULT_MAX_FRAME_SIZE = 2000
|
RFCOMM_DEFAULT_MAX_CREDITS = 32
|
||||||
|
RFCOMM_DEFAULT_CREDIT_THRESHOLD = RFCOMM_DEFAULT_MAX_CREDITS // 2
|
||||||
|
RFCOMM_DEFAULT_MAX_FRAME_SIZE = 2000
|
||||||
|
|
||||||
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1
|
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1
|
||||||
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
||||||
@@ -333,7 +337,7 @@ class RFCOMM_Frame:
|
|||||||
frame = RFCOMM_Frame(frame_type, c_r, dlci, p_f, information)
|
frame = RFCOMM_Frame(frame_type, c_r, dlci, p_f, information)
|
||||||
if frame.fcs != fcs:
|
if frame.fcs != fcs:
|
||||||
logger.warning(f'FCS mismatch: got {fcs:02X}, expected {frame.fcs:02X}')
|
logger.warning(f'FCS mismatch: got {fcs:02X}, expected {frame.fcs:02X}')
|
||||||
raise ValueError('fcs mismatch')
|
raise InvalidPacketError('fcs mismatch')
|
||||||
|
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
@@ -365,12 +369,12 @@ class RFCOMM_MCC_PN:
|
|||||||
ack_timer: int
|
ack_timer: int
|
||||||
max_frame_size: int
|
max_frame_size: int
|
||||||
max_retransmissions: int
|
max_retransmissions: int
|
||||||
window_size: int
|
initial_credits: int
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.window_size < 1 or self.window_size > 7:
|
if self.initial_credits < 1 or self.initial_credits > 7:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f'Error Recovery Window size {self.window_size} is out of range [1, 7].'
|
f'Initial credits {self.initial_credits} is out of range [1, 7].'
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -382,7 +386,7 @@ class RFCOMM_MCC_PN:
|
|||||||
ack_timer=data[3],
|
ack_timer=data[3],
|
||||||
max_frame_size=data[4] | data[5] << 8,
|
max_frame_size=data[4] | data[5] << 8,
|
||||||
max_retransmissions=data[6],
|
max_retransmissions=data[6],
|
||||||
window_size=data[7] & 0x07,
|
initial_credits=data[7] & 0x07,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __bytes__(self) -> bytes:
|
def __bytes__(self) -> bytes:
|
||||||
@@ -396,7 +400,7 @@ class RFCOMM_MCC_PN:
|
|||||||
(self.max_frame_size >> 8) & 0xFF,
|
(self.max_frame_size >> 8) & 0xFF,
|
||||||
self.max_retransmissions & 0xFF,
|
self.max_retransmissions & 0xFF,
|
||||||
# Only 3 bits are meaningful.
|
# Only 3 bits are meaningful.
|
||||||
self.window_size & 0x07,
|
self.initial_credits & 0x07,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -446,40 +450,43 @@ class DLC(EventEmitter):
|
|||||||
DISCONNECTED = 0x04
|
DISCONNECTED = 0x04
|
||||||
RESET = 0x05
|
RESET = 0x05
|
||||||
|
|
||||||
connection_result: Optional[asyncio.Future]
|
|
||||||
_sink: Optional[Callable[[bytes], None]]
|
|
||||||
_enqueued_rx_packets: collections.deque[bytes]
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
multiplexer: Multiplexer,
|
multiplexer: Multiplexer,
|
||||||
dlci: int,
|
dlci: int,
|
||||||
max_frame_size: int,
|
tx_max_frame_size: int,
|
||||||
window_size: int,
|
tx_initial_credits: int,
|
||||||
|
rx_max_frame_size: int,
|
||||||
|
rx_initial_credits: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.multiplexer = multiplexer
|
self.multiplexer = multiplexer
|
||||||
self.dlci = dlci
|
self.dlci = dlci
|
||||||
self.max_frame_size = max_frame_size
|
self.rx_max_frame_size = rx_max_frame_size
|
||||||
self.window_size = window_size
|
self.rx_initial_credits = rx_initial_credits
|
||||||
self.rx_credits = window_size
|
self.rx_max_credits = RFCOMM_DEFAULT_MAX_CREDITS
|
||||||
self.rx_threshold = window_size // 2
|
self.rx_credits = rx_initial_credits
|
||||||
self.tx_credits = window_size
|
self.rx_credits_threshold = RFCOMM_DEFAULT_CREDIT_THRESHOLD
|
||||||
|
self.tx_max_frame_size = tx_max_frame_size
|
||||||
|
self.tx_credits = tx_initial_credits
|
||||||
self.tx_buffer = b''
|
self.tx_buffer = b''
|
||||||
self.state = DLC.State.INIT
|
self.state = DLC.State.INIT
|
||||||
self.role = multiplexer.role
|
self.role = multiplexer.role
|
||||||
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
|
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
|
||||||
self.connection_result = None
|
self.connection_result: Optional[asyncio.Future] = None
|
||||||
|
self.disconnection_result: Optional[asyncio.Future] = None
|
||||||
self.drained = asyncio.Event()
|
self.drained = asyncio.Event()
|
||||||
self.drained.set()
|
self.drained.set()
|
||||||
# Queued packets when sink is not set.
|
# Queued packets when sink is not set.
|
||||||
self._enqueued_rx_packets = collections.deque(maxlen=DEFAULT_RX_QUEUE_SIZE)
|
self._enqueued_rx_packets: collections.deque[bytes] = collections.deque(
|
||||||
self._sink = None
|
maxlen=DEFAULT_RX_QUEUE_SIZE
|
||||||
|
)
|
||||||
|
self._sink: Optional[Callable[[bytes], None]] = None
|
||||||
|
|
||||||
# Compute the MTU
|
# Compute the MTU
|
||||||
max_overhead = 4 + 1 # header with 2-byte length + fcs
|
max_overhead = 4 + 1 # header with 2-byte length + fcs
|
||||||
self.mtu = min(
|
self.mtu = min(
|
||||||
max_frame_size, self.multiplexer.l2cap_channel.peer_mtu - max_overhead
|
tx_max_frame_size, self.multiplexer.l2cap_channel.peer_mtu - max_overhead
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -525,20 +532,35 @@ class DLC(EventEmitter):
|
|||||||
self.emit('open')
|
self.emit('open')
|
||||||
|
|
||||||
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
|
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
if self.state != DLC.State.CONNECTING:
|
if self.state == DLC.State.CONNECTING:
|
||||||
|
# Exchange the modem status with the peer
|
||||||
|
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
|
||||||
|
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
|
||||||
|
logger.debug(f'>>> MCC MSC Command: {msc}')
|
||||||
|
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||||
|
|
||||||
|
self.change_state(DLC.State.CONNECTED)
|
||||||
|
if self.connection_result:
|
||||||
|
self.connection_result.set_result(None)
|
||||||
|
self.connection_result = None
|
||||||
|
self.multiplexer.on_dlc_open_complete(self)
|
||||||
|
elif self.state == DLC.State.DISCONNECTING:
|
||||||
|
self.change_state(DLC.State.DISCONNECTED)
|
||||||
|
if self.disconnection_result:
|
||||||
|
self.disconnection_result.set_result(None)
|
||||||
|
self.disconnection_result = None
|
||||||
|
self.multiplexer.on_dlc_disconnection(self)
|
||||||
|
self.emit('close')
|
||||||
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
color('!!! received SABM when not in CONNECTING state', 'red')
|
color(
|
||||||
|
(
|
||||||
|
'!!! received UA frame when not in '
|
||||||
|
'CONNECTING or DISCONNECTING state'
|
||||||
|
),
|
||||||
|
'red',
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
# Exchange the modem status with the peer
|
|
||||||
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
|
|
||||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
|
|
||||||
logger.debug(f'>>> MCC MSC Command: {msc}')
|
|
||||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
|
||||||
|
|
||||||
self.change_state(DLC.State.CONNECTED)
|
|
||||||
self.multiplexer.on_dlc_open_complete(self)
|
|
||||||
|
|
||||||
def on_dm_frame(self, frame: RFCOMM_Frame) -> None:
|
def on_dm_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
# TODO: handle all states
|
# TODO: handle all states
|
||||||
@@ -609,6 +631,19 @@ class DLC(EventEmitter):
|
|||||||
self.connection_result = asyncio.get_running_loop().create_future()
|
self.connection_result = asyncio.get_running_loop().create_future()
|
||||||
self.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci))
|
self.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci))
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
if self.state != DLC.State.CONNECTED:
|
||||||
|
raise InvalidStateError('invalid state')
|
||||||
|
|
||||||
|
self.disconnection_result = asyncio.get_running_loop().create_future()
|
||||||
|
self.change_state(DLC.State.DISCONNECTING)
|
||||||
|
self.send_frame(
|
||||||
|
RFCOMM_Frame.disc(
|
||||||
|
c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0, dlci=self.dlci
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.disconnection_result
|
||||||
|
|
||||||
def accept(self) -> None:
|
def accept(self) -> None:
|
||||||
if self.state != DLC.State.INIT:
|
if self.state != DLC.State.INIT:
|
||||||
raise InvalidStateError('invalid state')
|
raise InvalidStateError('invalid state')
|
||||||
@@ -618,9 +653,9 @@ class DLC(EventEmitter):
|
|||||||
cl=0xE0,
|
cl=0xE0,
|
||||||
priority=7,
|
priority=7,
|
||||||
ack_timer=0,
|
ack_timer=0,
|
||||||
max_frame_size=self.max_frame_size,
|
max_frame_size=self.rx_max_frame_size,
|
||||||
max_retransmissions=0,
|
max_retransmissions=0,
|
||||||
window_size=self.window_size,
|
initial_credits=self.rx_initial_credits,
|
||||||
)
|
)
|
||||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=0, data=bytes(pn))
|
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=0, data=bytes(pn))
|
||||||
logger.debug(f'>>> PN Response: {pn}')
|
logger.debug(f'>>> PN Response: {pn}')
|
||||||
@@ -628,8 +663,8 @@ class DLC(EventEmitter):
|
|||||||
self.change_state(DLC.State.CONNECTING)
|
self.change_state(DLC.State.CONNECTING)
|
||||||
|
|
||||||
def rx_credits_needed(self) -> int:
|
def rx_credits_needed(self) -> int:
|
||||||
if self.rx_credits <= self.rx_threshold:
|
if self.rx_credits <= self.rx_credits_threshold:
|
||||||
return self.window_size - self.rx_credits
|
return self.rx_max_credits - self.rx_credits
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -680,7 +715,7 @@ class DLC(EventEmitter):
|
|||||||
# Automatically convert strings to bytes using UTF-8
|
# Automatically convert strings to bytes using UTF-8
|
||||||
data = data.encode('utf-8')
|
data = data.encode('utf-8')
|
||||||
else:
|
else:
|
||||||
raise ValueError('write only accept bytes or strings')
|
raise InvalidArgumentError('write only accept bytes or strings')
|
||||||
|
|
||||||
self.tx_buffer += data
|
self.tx_buffer += data
|
||||||
self.drained.clear()
|
self.drained.clear()
|
||||||
@@ -689,8 +724,28 @@ class DLC(EventEmitter):
|
|||||||
async def drain(self) -> None:
|
async def drain(self) -> None:
|
||||||
await self.drained.wait()
|
await self.drained.wait()
|
||||||
|
|
||||||
|
def abort(self) -> None:
|
||||||
|
logger.debug(f'aborting DLC: {self}')
|
||||||
|
if self.connection_result:
|
||||||
|
self.connection_result.cancel()
|
||||||
|
self.connection_result = None
|
||||||
|
if self.disconnection_result:
|
||||||
|
self.disconnection_result.cancel()
|
||||||
|
self.disconnection_result = None
|
||||||
|
self.change_state(DLC.State.RESET)
|
||||||
|
self.emit('close')
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f'DLC(dlci={self.dlci},state={self.state.name})'
|
return (
|
||||||
|
f'DLC(dlci={self.dlci}, '
|
||||||
|
f'state={self.state.name}, '
|
||||||
|
f'rx_max_frame_size={self.rx_max_frame_size}, '
|
||||||
|
f'rx_credits={self.rx_credits}, '
|
||||||
|
f'rx_max_credits={self.rx_max_credits}, '
|
||||||
|
f'tx_max_frame_size={self.tx_max_frame_size}, '
|
||||||
|
f'tx_credits={self.tx_credits}'
|
||||||
|
')'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -711,7 +766,7 @@ class Multiplexer(EventEmitter):
|
|||||||
connection_result: Optional[asyncio.Future]
|
connection_result: Optional[asyncio.Future]
|
||||||
disconnection_result: Optional[asyncio.Future]
|
disconnection_result: Optional[asyncio.Future]
|
||||||
open_result: Optional[asyncio.Future]
|
open_result: Optional[asyncio.Future]
|
||||||
acceptor: Optional[Callable[[int], bool]]
|
acceptor: Optional[Callable[[int], Optional[Tuple[int, int]]]]
|
||||||
dlcs: Dict[int, DLC]
|
dlcs: Dict[int, DLC]
|
||||||
|
|
||||||
def __init__(self, l2cap_channel: l2cap.ClassicChannel, role: Role) -> None:
|
def __init__(self, l2cap_channel: l2cap.ClassicChannel, role: Role) -> None:
|
||||||
@@ -723,11 +778,15 @@ class Multiplexer(EventEmitter):
|
|||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
self.disconnection_result = None
|
self.disconnection_result = None
|
||||||
self.open_result = None
|
self.open_result = None
|
||||||
|
self.open_pn: Optional[RFCOMM_MCC_PN] = None
|
||||||
|
self.open_rx_max_credits = 0
|
||||||
self.acceptor = None
|
self.acceptor = None
|
||||||
|
|
||||||
# Become a sink for the L2CAP channel
|
# Become a sink for the L2CAP channel
|
||||||
l2cap_channel.sink = self.on_pdu
|
l2cap_channel.sink = self.on_pdu
|
||||||
|
|
||||||
|
l2cap_channel.on('close', self.on_l2cap_channel_close)
|
||||||
|
|
||||||
def change_state(self, new_state: State) -> None:
|
def change_state(self, new_state: State) -> None:
|
||||||
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
|
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
|
||||||
self.state = new_state
|
self.state = new_state
|
||||||
@@ -791,6 +850,7 @@ class Multiplexer(EventEmitter):
|
|||||||
'rfcomm',
|
'rfcomm',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self.open_result = None
|
||||||
else:
|
else:
|
||||||
logger.warning(f'unexpected state for DM: {self}')
|
logger.warning(f'unexpected state for DM: {self}')
|
||||||
|
|
||||||
@@ -828,9 +888,16 @@ class Multiplexer(EventEmitter):
|
|||||||
else:
|
else:
|
||||||
if self.acceptor:
|
if self.acceptor:
|
||||||
channel_number = pn.dlci >> 1
|
channel_number = pn.dlci >> 1
|
||||||
if self.acceptor(channel_number):
|
if dlc_params := self.acceptor(channel_number):
|
||||||
# Create a new DLC
|
# Create a new DLC
|
||||||
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size)
|
dlc = DLC(
|
||||||
|
self,
|
||||||
|
dlci=pn.dlci,
|
||||||
|
tx_max_frame_size=pn.max_frame_size,
|
||||||
|
tx_initial_credits=pn.initial_credits,
|
||||||
|
rx_max_frame_size=dlc_params[0],
|
||||||
|
rx_initial_credits=dlc_params[1],
|
||||||
|
)
|
||||||
self.dlcs[pn.dlci] = dlc
|
self.dlcs[pn.dlci] = dlc
|
||||||
|
|
||||||
# Re-emit the handshake completion event
|
# Re-emit the handshake completion event
|
||||||
@@ -848,8 +915,17 @@ class Multiplexer(EventEmitter):
|
|||||||
# Response
|
# Response
|
||||||
logger.debug(f'>>> PN Response: {pn}')
|
logger.debug(f'>>> PN Response: {pn}')
|
||||||
if self.state == Multiplexer.State.OPENING:
|
if self.state == Multiplexer.State.OPENING:
|
||||||
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size)
|
assert self.open_pn
|
||||||
|
dlc = DLC(
|
||||||
|
self,
|
||||||
|
dlci=pn.dlci,
|
||||||
|
tx_max_frame_size=pn.max_frame_size,
|
||||||
|
tx_initial_credits=pn.initial_credits,
|
||||||
|
rx_max_frame_size=self.open_pn.max_frame_size,
|
||||||
|
rx_initial_credits=self.open_pn.initial_credits,
|
||||||
|
)
|
||||||
self.dlcs[pn.dlci] = dlc
|
self.dlcs[pn.dlci] = dlc
|
||||||
|
self.open_pn = None
|
||||||
dlc.connect()
|
dlc.connect()
|
||||||
else:
|
else:
|
||||||
logger.warning('ignoring PN response')
|
logger.warning('ignoring PN response')
|
||||||
@@ -887,7 +963,7 @@ class Multiplexer(EventEmitter):
|
|||||||
self,
|
self,
|
||||||
channel: int,
|
channel: int,
|
||||||
max_frame_size: int = RFCOMM_DEFAULT_MAX_FRAME_SIZE,
|
max_frame_size: int = RFCOMM_DEFAULT_MAX_FRAME_SIZE,
|
||||||
window_size: int = RFCOMM_DEFAULT_WINDOW_SIZE,
|
initial_credits: int = RFCOMM_DEFAULT_INITIAL_CREDITS,
|
||||||
) -> DLC:
|
) -> DLC:
|
||||||
if self.state != Multiplexer.State.CONNECTED:
|
if self.state != Multiplexer.State.CONNECTED:
|
||||||
if self.state == Multiplexer.State.OPENING:
|
if self.state == Multiplexer.State.OPENING:
|
||||||
@@ -895,17 +971,19 @@ class Multiplexer(EventEmitter):
|
|||||||
|
|
||||||
raise InvalidStateError('not connected')
|
raise InvalidStateError('not connected')
|
||||||
|
|
||||||
pn = RFCOMM_MCC_PN(
|
self.open_pn = RFCOMM_MCC_PN(
|
||||||
dlci=channel << 1,
|
dlci=channel << 1,
|
||||||
cl=0xF0,
|
cl=0xF0,
|
||||||
priority=7,
|
priority=7,
|
||||||
ack_timer=0,
|
ack_timer=0,
|
||||||
max_frame_size=max_frame_size,
|
max_frame_size=max_frame_size,
|
||||||
max_retransmissions=0,
|
max_retransmissions=0,
|
||||||
window_size=window_size,
|
initial_credits=initial_credits,
|
||||||
)
|
)
|
||||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=1, data=bytes(pn))
|
mcc = RFCOMM_Frame.make_mcc(
|
||||||
logger.debug(f'>>> Sending MCC: {pn}')
|
mcc_type=MccType.PN, c_r=1, data=bytes(self.open_pn)
|
||||||
|
)
|
||||||
|
logger.debug(f'>>> Sending MCC: {self.open_pn}')
|
||||||
self.open_result = asyncio.get_running_loop().create_future()
|
self.open_result = asyncio.get_running_loop().create_future()
|
||||||
self.change_state(Multiplexer.State.OPENING)
|
self.change_state(Multiplexer.State.OPENING)
|
||||||
self.send_frame(
|
self.send_frame(
|
||||||
@@ -915,15 +993,31 @@ class Multiplexer(EventEmitter):
|
|||||||
information=mcc,
|
information=mcc,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = await self.open_result
|
return await self.open_result
|
||||||
self.open_result = None
|
|
||||||
return result
|
|
||||||
|
|
||||||
def on_dlc_open_complete(self, dlc: DLC) -> None:
|
def on_dlc_open_complete(self, dlc: DLC) -> None:
|
||||||
logger.debug(f'DLC [{dlc.dlci}] open complete')
|
logger.debug(f'DLC [{dlc.dlci}] open complete')
|
||||||
|
|
||||||
self.change_state(Multiplexer.State.CONNECTED)
|
self.change_state(Multiplexer.State.CONNECTED)
|
||||||
|
|
||||||
if self.open_result:
|
if self.open_result:
|
||||||
self.open_result.set_result(dlc)
|
self.open_result.set_result(dlc)
|
||||||
|
self.open_result = None
|
||||||
|
|
||||||
|
def on_dlc_disconnection(self, dlc: DLC) -> None:
|
||||||
|
logger.debug(f'DLC [{dlc.dlci}] disconnection')
|
||||||
|
self.dlcs.pop(dlc.dlci, None)
|
||||||
|
|
||||||
|
def on_l2cap_channel_close(self) -> None:
|
||||||
|
logger.debug('L2CAP channel closed, cleaning up')
|
||||||
|
if self.open_result:
|
||||||
|
self.open_result.cancel()
|
||||||
|
self.open_result = None
|
||||||
|
if self.disconnection_result:
|
||||||
|
self.disconnection_result.cancel()
|
||||||
|
self.disconnection_result = None
|
||||||
|
for dlc in self.dlcs.values():
|
||||||
|
dlc.abort()
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f'Multiplexer(state={self.state.name})'
|
return f'Multiplexer(state={self.state.name})'
|
||||||
@@ -982,15 +1076,13 @@ class Client:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Server(EventEmitter):
|
class Server(EventEmitter):
|
||||||
acceptors: Dict[int, Callable[[DLC], None]]
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, device: Device, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
|
self, device: Device, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.device = device
|
self.device = device
|
||||||
self.multiplexer = None
|
self.acceptors: Dict[int, Callable[[DLC], None]] = {}
|
||||||
self.acceptors = {}
|
self.dlc_configs: Dict[int, Tuple[int, int]] = {}
|
||||||
|
|
||||||
# Register ourselves with the L2CAP channel manager
|
# Register ourselves with the L2CAP channel manager
|
||||||
self.l2cap_server = device.create_l2cap_server(
|
self.l2cap_server = device.create_l2cap_server(
|
||||||
@@ -998,7 +1090,13 @@ class Server(EventEmitter):
|
|||||||
handler=self.on_connection,
|
handler=self.on_connection,
|
||||||
)
|
)
|
||||||
|
|
||||||
def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int:
|
def listen(
|
||||||
|
self,
|
||||||
|
acceptor: Callable[[DLC], None],
|
||||||
|
channel: int = 0,
|
||||||
|
max_frame_size: int = RFCOMM_DEFAULT_MAX_FRAME_SIZE,
|
||||||
|
initial_credits: int = RFCOMM_DEFAULT_INITIAL_CREDITS,
|
||||||
|
) -> int:
|
||||||
if channel:
|
if channel:
|
||||||
if channel in self.acceptors:
|
if channel in self.acceptors:
|
||||||
# Busy
|
# Busy
|
||||||
@@ -1018,6 +1116,8 @@ class Server(EventEmitter):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
self.acceptors[channel] = acceptor
|
self.acceptors[channel] = acceptor
|
||||||
|
self.dlc_configs[channel] = (max_frame_size, initial_credits)
|
||||||
|
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
|
def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
|
||||||
@@ -1035,15 +1135,14 @@ class Server(EventEmitter):
|
|||||||
# Notify
|
# Notify
|
||||||
self.emit('start', multiplexer)
|
self.emit('start', multiplexer)
|
||||||
|
|
||||||
def accept_dlc(self, channel_number: int) -> bool:
|
def accept_dlc(self, channel_number: int) -> Optional[Tuple[int, int]]:
|
||||||
return channel_number in self.acceptors
|
return self.dlc_configs.get(channel_number)
|
||||||
|
|
||||||
def on_dlc(self, dlc: DLC) -> None:
|
def on_dlc(self, dlc: DLC) -> None:
|
||||||
logger.debug(f'@@@ new DLC connected: {dlc}')
|
logger.debug(f'@@@ new DLC connected: {dlc}')
|
||||||
|
|
||||||
# Let the acceptor know
|
# Let the acceptor know
|
||||||
acceptor = self.acceptors.get(dlc.dlci >> 1)
|
if acceptor := self.acceptors.get(dlc.dlci >> 1):
|
||||||
if acceptor:
|
|
||||||
acceptor(dlc)
|
acceptor(dlc)
|
||||||
|
|
||||||
def __enter__(self) -> Self:
|
def __enter__(self) -> Self:
|
||||||
|
|||||||
+14
-12
@@ -23,7 +23,7 @@ from typing_extensions import Self
|
|||||||
|
|
||||||
from . import core, l2cap
|
from . import core, l2cap
|
||||||
from .colors import color
|
from .colors import color
|
||||||
from .core import InvalidStateError
|
from .core import InvalidStateError, InvalidArgumentError, InvalidPacketError
|
||||||
from .hci import HCI_Object, name_or_number, key_with_value
|
from .hci import HCI_Object, name_or_number, key_with_value
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -189,7 +189,9 @@ class DataElement:
|
|||||||
self.bytes = None
|
self.bytes = None
|
||||||
if element_type in (DataElement.UNSIGNED_INTEGER, DataElement.SIGNED_INTEGER):
|
if element_type in (DataElement.UNSIGNED_INTEGER, DataElement.SIGNED_INTEGER):
|
||||||
if value_size is None:
|
if value_size is None:
|
||||||
raise ValueError('integer types must have a value size specified')
|
raise InvalidArgumentError(
|
||||||
|
'integer types must have a value size specified'
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def nil() -> DataElement:
|
def nil() -> DataElement:
|
||||||
@@ -265,7 +267,7 @@ class DataElement:
|
|||||||
if len(data) == 8:
|
if len(data) == 8:
|
||||||
return struct.unpack('>Q', data)[0]
|
return struct.unpack('>Q', data)[0]
|
||||||
|
|
||||||
raise ValueError(f'invalid integer length {len(data)}')
|
raise InvalidPacketError(f'invalid integer length {len(data)}')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def signed_integer_from_bytes(data):
|
def signed_integer_from_bytes(data):
|
||||||
@@ -281,7 +283,7 @@ class DataElement:
|
|||||||
if len(data) == 8:
|
if len(data) == 8:
|
||||||
return struct.unpack('>q', data)[0]
|
return struct.unpack('>q', data)[0]
|
||||||
|
|
||||||
raise ValueError(f'invalid integer length {len(data)}')
|
raise InvalidPacketError(f'invalid integer length {len(data)}')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list_from_bytes(data):
|
def list_from_bytes(data):
|
||||||
@@ -354,7 +356,7 @@ class DataElement:
|
|||||||
data = b''
|
data = b''
|
||||||
elif self.type == DataElement.UNSIGNED_INTEGER:
|
elif self.type == DataElement.UNSIGNED_INTEGER:
|
||||||
if self.value < 0:
|
if self.value < 0:
|
||||||
raise ValueError('UNSIGNED_INTEGER cannot be negative')
|
raise InvalidArgumentError('UNSIGNED_INTEGER cannot be negative')
|
||||||
|
|
||||||
if self.value_size == 1:
|
if self.value_size == 1:
|
||||||
data = struct.pack('B', self.value)
|
data = struct.pack('B', self.value)
|
||||||
@@ -365,7 +367,7 @@ class DataElement:
|
|||||||
elif self.value_size == 8:
|
elif self.value_size == 8:
|
||||||
data = struct.pack('>Q', self.value)
|
data = struct.pack('>Q', self.value)
|
||||||
else:
|
else:
|
||||||
raise ValueError('invalid value_size')
|
raise InvalidArgumentError('invalid value_size')
|
||||||
elif self.type == DataElement.SIGNED_INTEGER:
|
elif self.type == DataElement.SIGNED_INTEGER:
|
||||||
if self.value_size == 1:
|
if self.value_size == 1:
|
||||||
data = struct.pack('b', self.value)
|
data = struct.pack('b', self.value)
|
||||||
@@ -376,7 +378,7 @@ class DataElement:
|
|||||||
elif self.value_size == 8:
|
elif self.value_size == 8:
|
||||||
data = struct.pack('>q', self.value)
|
data = struct.pack('>q', self.value)
|
||||||
else:
|
else:
|
||||||
raise ValueError('invalid value_size')
|
raise InvalidArgumentError('invalid value_size')
|
||||||
elif self.type == DataElement.UUID:
|
elif self.type == DataElement.UUID:
|
||||||
data = bytes(reversed(bytes(self.value)))
|
data = bytes(reversed(bytes(self.value)))
|
||||||
elif self.type == DataElement.URL:
|
elif self.type == DataElement.URL:
|
||||||
@@ -392,7 +394,7 @@ class DataElement:
|
|||||||
size_bytes = b''
|
size_bytes = b''
|
||||||
if self.type == DataElement.NIL:
|
if self.type == DataElement.NIL:
|
||||||
if size != 0:
|
if size != 0:
|
||||||
raise ValueError('NIL must be empty')
|
raise InvalidArgumentError('NIL must be empty')
|
||||||
size_index = 0
|
size_index = 0
|
||||||
elif self.type in (
|
elif self.type in (
|
||||||
DataElement.UNSIGNED_INTEGER,
|
DataElement.UNSIGNED_INTEGER,
|
||||||
@@ -410,7 +412,7 @@ class DataElement:
|
|||||||
elif size == 16:
|
elif size == 16:
|
||||||
size_index = 4
|
size_index = 4
|
||||||
else:
|
else:
|
||||||
raise ValueError('invalid data size')
|
raise InvalidArgumentError('invalid data size')
|
||||||
elif self.type in (
|
elif self.type in (
|
||||||
DataElement.TEXT_STRING,
|
DataElement.TEXT_STRING,
|
||||||
DataElement.SEQUENCE,
|
DataElement.SEQUENCE,
|
||||||
@@ -427,10 +429,10 @@ class DataElement:
|
|||||||
size_index = 7
|
size_index = 7
|
||||||
size_bytes = struct.pack('>I', size)
|
size_bytes = struct.pack('>I', size)
|
||||||
else:
|
else:
|
||||||
raise ValueError('invalid data size')
|
raise InvalidArgumentError('invalid data size')
|
||||||
elif self.type == DataElement.BOOLEAN:
|
elif self.type == DataElement.BOOLEAN:
|
||||||
if size != 1:
|
if size != 1:
|
||||||
raise ValueError('boolean must be 1 byte')
|
raise InvalidArgumentError('boolean must be 1 byte')
|
||||||
size_index = 0
|
size_index = 0
|
||||||
|
|
||||||
self.bytes = bytes([self.type << 3 | size_index]) + size_bytes + data
|
self.bytes = bytes([self.type << 3 | size_index]) + size_bytes + data
|
||||||
@@ -997,7 +999,7 @@ class Server:
|
|||||||
try:
|
try:
|
||||||
handler(sdp_pdu)
|
handler(sdp_pdu)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(f'{color("!!! Exception in handler:", "red")} {error}')
|
logger.exception(f'{color("!!! Exception in handler:", "red")} {error}')
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=sdp_pdu.transaction_id,
|
transaction_id=sdp_pdu.transaction_id,
|
||||||
|
|||||||
+3
-2
@@ -55,6 +55,7 @@ from .core import (
|
|||||||
BT_CENTRAL_ROLE,
|
BT_CENTRAL_ROLE,
|
||||||
BT_LE_TRANSPORT,
|
BT_LE_TRANSPORT,
|
||||||
AdvertisingData,
|
AdvertisingData,
|
||||||
|
InvalidArgumentError,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
name_or_number,
|
name_or_number,
|
||||||
)
|
)
|
||||||
@@ -784,7 +785,7 @@ class Session:
|
|||||||
self.peer_oob_data = pairing_config.oob.peer_data
|
self.peer_oob_data = pairing_config.oob.peer_data
|
||||||
if pairing_config.sc:
|
if pairing_config.sc:
|
||||||
if pairing_config.oob.our_context is None:
|
if pairing_config.oob.our_context is None:
|
||||||
raise ValueError(
|
raise InvalidArgumentError(
|
||||||
"oob pairing config requires a context when sc is True"
|
"oob pairing config requires a context when sc is True"
|
||||||
)
|
)
|
||||||
self.r = pairing_config.oob.our_context.r
|
self.r = pairing_config.oob.our_context.r
|
||||||
@@ -793,7 +794,7 @@ class Session:
|
|||||||
self.tk = pairing_config.oob.legacy_context.tk
|
self.tk = pairing_config.oob.legacy_context.tk
|
||||||
else:
|
else:
|
||||||
if pairing_config.oob.legacy_context is None:
|
if pairing_config.oob.legacy_context is None:
|
||||||
raise ValueError(
|
raise InvalidArgumentError(
|
||||||
"oob pairing config requires a legacy context when sc is False"
|
"oob pairing config requires a legacy context when sc is False"
|
||||||
)
|
)
|
||||||
self.r = bytes(16)
|
self.r = bytes(16)
|
||||||
|
|||||||
+5
-4
@@ -23,6 +23,7 @@ import datetime
|
|||||||
from typing import BinaryIO, Generator
|
from typing import BinaryIO, Generator
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from bumble import core
|
||||||
from bumble.hci import HCI_COMMAND_PACKET, HCI_EVENT_PACKET
|
from bumble.hci import HCI_COMMAND_PACKET, HCI_EVENT_PACKET
|
||||||
|
|
||||||
|
|
||||||
@@ -138,13 +139,13 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
if ':' not in spec:
|
if ':' not in spec:
|
||||||
raise ValueError('snooper type prefix missing')
|
raise core.InvalidArgumentError('snooper type prefix missing')
|
||||||
|
|
||||||
snooper_type, snooper_args = spec.split(':', maxsplit=1)
|
snooper_type, snooper_args = spec.split(':', maxsplit=1)
|
||||||
|
|
||||||
if snooper_type == 'btsnoop':
|
if snooper_type == 'btsnoop':
|
||||||
if ':' not in snooper_args:
|
if ':' not in snooper_args:
|
||||||
raise ValueError('I/O type for btsnoop snooper type missing')
|
raise core.InvalidArgumentError('I/O type for btsnoop snooper type missing')
|
||||||
|
|
||||||
io_type, io_name = snooper_args.split(':', maxsplit=1)
|
io_type, io_name = snooper_args.split(':', maxsplit=1)
|
||||||
if io_type == 'file':
|
if io_type == 'file':
|
||||||
@@ -165,6 +166,6 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]:
|
|||||||
_SNOOPER_INSTANCE_COUNT -= 1
|
_SNOOPER_INSTANCE_COUNT -= 1
|
||||||
return
|
return
|
||||||
|
|
||||||
raise ValueError(f'I/O type {io_type} not supported')
|
raise core.InvalidArgumentError(f'I/O type {io_type} not supported')
|
||||||
|
|
||||||
raise ValueError(f'snooper type {snooper_type} not found')
|
raise core.InvalidArgumentError(f'snooper type {snooper_type} not found')
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .common import Transport, AsyncPipeSink, SnoopingTransport
|
from .common import Transport, AsyncPipeSink, SnoopingTransport, TransportSpecError
|
||||||
from ..snoop import create_snooper
|
from ..snoop import create_snooper
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -180,7 +180,7 @@ async def _open_transport(scheme: str, spec: Optional[str]) -> Transport:
|
|||||||
|
|
||||||
return await open_android_netsim_transport(spec)
|
return await open_android_netsim_transport(spec)
|
||||||
|
|
||||||
raise ValueError('unknown transport scheme')
|
raise TransportSpecError('unknown transport scheme')
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ import grpc.aio
|
|||||||
|
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink, Transport
|
from .common import (
|
||||||
|
PumpedTransport,
|
||||||
|
PumpedPacketSource,
|
||||||
|
PumpedPacketSink,
|
||||||
|
Transport,
|
||||||
|
TransportSpecError,
|
||||||
|
)
|
||||||
|
|
||||||
# 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
|
||||||
@@ -77,7 +83,7 @@ async def open_android_emulator_transport(spec: Optional[str]) -> Transport:
|
|||||||
elif ':' in param:
|
elif ':' in param:
|
||||||
server_host, server_port = param.split(':')
|
server_host, server_port = param.split(':')
|
||||||
else:
|
else:
|
||||||
raise ValueError('invalid parameter')
|
raise TransportSpecError('invalid parameter')
|
||||||
|
|
||||||
# Connect to the gRPC server
|
# Connect to the gRPC server
|
||||||
server_address = f'{server_host}:{server_port}'
|
server_address = f'{server_host}:{server_port}'
|
||||||
@@ -94,7 +100,7 @@ async def open_android_emulator_transport(spec: Optional[str]) -> Transport:
|
|||||||
service = VhciForwardingServiceStub(channel)
|
service = VhciForwardingServiceStub(channel)
|
||||||
hci_device = HciDevice(service.attachVhci())
|
hci_device = HciDevice(service.attachVhci())
|
||||||
else:
|
else:
|
||||||
raise ValueError('invalid mode')
|
raise TransportSpecError('invalid mode')
|
||||||
|
|
||||||
# Create the transport object
|
# Create the transport object
|
||||||
class EmulatorTransport(PumpedTransport):
|
class EmulatorTransport(PumpedTransport):
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ from .common import (
|
|||||||
PumpedPacketSource,
|
PumpedPacketSource,
|
||||||
PumpedPacketSink,
|
PumpedPacketSink,
|
||||||
Transport,
|
Transport,
|
||||||
|
TransportSpecError,
|
||||||
|
TransportInitError,
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=no-name-in-module
|
# pylint: disable=no-name-in-module
|
||||||
@@ -135,7 +137,7 @@ async def open_android_netsim_controller_transport(
|
|||||||
server_host: Optional[str], server_port: int, options: Dict[str, str]
|
server_host: Optional[str], server_port: int, options: Dict[str, str]
|
||||||
) -> Transport:
|
) -> Transport:
|
||||||
if not server_port:
|
if not server_port:
|
||||||
raise ValueError('invalid port')
|
raise TransportSpecError('invalid port')
|
||||||
if server_host == '_' or not server_host:
|
if server_host == '_' or not server_host:
|
||||||
server_host = 'localhost'
|
server_host = 'localhost'
|
||||||
|
|
||||||
@@ -288,7 +290,7 @@ async def open_android_netsim_host_transport_with_address(
|
|||||||
instance_number = 0 if options is None else int(options.get('instance', '0'))
|
instance_number = 0 if options is None else int(options.get('instance', '0'))
|
||||||
server_port = find_grpc_port(instance_number)
|
server_port = find_grpc_port(instance_number)
|
||||||
if not server_port:
|
if not server_port:
|
||||||
raise RuntimeError('gRPC server port not found')
|
raise TransportInitError('gRPC server port not found')
|
||||||
|
|
||||||
# Connect to the gRPC server
|
# Connect to the gRPC server
|
||||||
server_address = f'{server_host}:{server_port}'
|
server_address = f'{server_host}:{server_port}'
|
||||||
@@ -326,7 +328,7 @@ async def open_android_netsim_host_transport_with_channel(
|
|||||||
|
|
||||||
if response_type == 'error':
|
if response_type == 'error':
|
||||||
logger.warning(f'received error: {response.error}')
|
logger.warning(f'received error: {response.error}')
|
||||||
raise RuntimeError(response.error)
|
raise TransportInitError(response.error)
|
||||||
|
|
||||||
if response_type == 'hci_packet':
|
if response_type == 'hci_packet':
|
||||||
return (
|
return (
|
||||||
@@ -334,7 +336,7 @@ async def open_android_netsim_host_transport_with_channel(
|
|||||||
+ response.hci_packet.packet
|
+ response.hci_packet.packet
|
||||||
)
|
)
|
||||||
|
|
||||||
raise ValueError('unsupported response type')
|
raise TransportSpecError('unsupported response type')
|
||||||
|
|
||||||
async def write(self, packet):
|
async def write(self, packet):
|
||||||
await self.hci_device.write(
|
await self.hci_device.write(
|
||||||
@@ -429,7 +431,7 @@ async def open_android_netsim_transport(spec: Optional[str]) -> Transport:
|
|||||||
options: Dict[str, str] = {}
|
options: Dict[str, str] = {}
|
||||||
for param in params[params_offset:]:
|
for param in params[params_offset:]:
|
||||||
if '=' not in param:
|
if '=' not in param:
|
||||||
raise ValueError('invalid parameter, expected <name>=<value>')
|
raise TransportSpecError('invalid parameter, expected <name>=<value>')
|
||||||
option_name, option_value = param.split('=')
|
option_name, option_value = param.split('=')
|
||||||
options[option_name] = option_value
|
options[option_name] = option_value
|
||||||
|
|
||||||
@@ -440,7 +442,7 @@ async def open_android_netsim_transport(spec: Optional[str]) -> Transport:
|
|||||||
)
|
)
|
||||||
if mode == 'controller':
|
if mode == 'controller':
|
||||||
if host is None:
|
if host is None:
|
||||||
raise ValueError('<host>:<port> missing')
|
raise TransportSpecError('<host>:<port> missing')
|
||||||
return await open_android_netsim_controller_transport(host, port, options)
|
return await open_android_netsim_controller_transport(host, port, options)
|
||||||
|
|
||||||
raise ValueError('invalid mode option')
|
raise TransportSpecError('invalid mode option')
|
||||||
|
|||||||
+19
-10
@@ -23,6 +23,7 @@ import logging
|
|||||||
import io
|
import io
|
||||||
from typing import Any, ContextManager, Tuple, Optional, Protocol, Dict
|
from typing import Any, ContextManager, Tuple, Optional, Protocol, Dict
|
||||||
|
|
||||||
|
from bumble import core
|
||||||
from bumble import hci
|
from bumble import hci
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.snoop import Snooper
|
from bumble.snoop import Snooper
|
||||||
@@ -49,10 +50,16 @@ HCI_PACKET_INFO: Dict[int, Tuple[int, int, str]] = {
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Errors
|
# Errors
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class TransportLostError(Exception):
|
class TransportLostError(core.BaseBumbleError, RuntimeError):
|
||||||
"""
|
"""The Transport has been lost/disconnected."""
|
||||||
The Transport has been lost/disconnected.
|
|
||||||
"""
|
|
||||||
|
class TransportInitError(core.BaseBumbleError, RuntimeError):
|
||||||
|
"""Error raised when the transport cannot be initialized."""
|
||||||
|
|
||||||
|
|
||||||
|
class TransportSpecError(core.BaseBumbleError, ValueError):
|
||||||
|
"""Error raised when the transport spec is invalid."""
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -132,7 +139,9 @@ class PacketParser:
|
|||||||
packet_type
|
packet_type
|
||||||
) or self.extended_packet_info.get(packet_type)
|
) or self.extended_packet_info.get(packet_type)
|
||||||
if self.packet_info is None:
|
if self.packet_info is None:
|
||||||
raise ValueError(f'invalid packet type {packet_type}')
|
raise core.InvalidPacketError(
|
||||||
|
f'invalid packet type {packet_type}'
|
||||||
|
)
|
||||||
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:
|
||||||
@@ -178,19 +187,19 @@ 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[0]} found')
|
raise core.InvalidPacketError(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]
|
||||||
header = self.source.read(header_size)
|
header = self.source.read(header_size)
|
||||||
if len(header) != header_size:
|
if len(header) != header_size:
|
||||||
raise ValueError('packet too short')
|
raise core.InvalidPacketError('packet too short')
|
||||||
|
|
||||||
# Read the body
|
# Read the body
|
||||||
body_length = struct.unpack_from(packet_info[2], header, packet_info[1])[0]
|
body_length = struct.unpack_from(packet_info[2], header, packet_info[1])[0]
|
||||||
body = self.source.read(body_length)
|
body = self.source.read(body_length)
|
||||||
if len(body) != body_length:
|
if len(body) != body_length:
|
||||||
raise ValueError('packet too short')
|
raise core.InvalidPacketError('packet too short')
|
||||||
|
|
||||||
return packet_type + header + body
|
return packet_type + header + body
|
||||||
|
|
||||||
@@ -211,7 +220,7 @@ class AsyncPacketReader:
|
|||||||
# 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[0]} found')
|
raise core.InvalidPacketError(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]
|
||||||
@@ -420,7 +429,7 @@ class SnoopingTransport(Transport):
|
|||||||
return SnoopingTransport(
|
return SnoopingTransport(
|
||||||
transport, exit_stack.enter_context(snooper), exit_stack.pop_all().close
|
transport, exit_stack.enter_context(snooper), exit_stack.pop_all().close
|
||||||
)
|
)
|
||||||
raise RuntimeError('unexpected code path') # Satisfy the type checker
|
raise core.UnreachableError() # Satisfy the type checker
|
||||||
|
|
||||||
class Source:
|
class Source:
|
||||||
sink: TransportSink
|
sink: TransportSink
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from usb.core import USBError
|
|||||||
from usb.util import CTRL_TYPE_CLASS, CTRL_RECIPIENT_OTHER
|
from usb.util import CTRL_TYPE_CLASS, CTRL_RECIPIENT_OTHER
|
||||||
from usb.legacy import REQ_SET_FEATURE, REQ_CLEAR_FEATURE, CLASS_HUB
|
from usb.legacy import REQ_SET_FEATURE, REQ_CLEAR_FEATURE, CLASS_HUB
|
||||||
|
|
||||||
from .common import Transport, ParserSource
|
from .common import Transport, ParserSource, TransportInitError
|
||||||
from .. import hci
|
from .. import hci
|
||||||
from ..colors import color
|
from ..colors import color
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ async def open_pyusb_transport(spec: str) -> Transport:
|
|||||||
device = None
|
device = None
|
||||||
|
|
||||||
if device is None:
|
if device is None:
|
||||||
raise ValueError('device not found')
|
raise TransportInitError('device not found')
|
||||||
logger.debug(f'USB Device: {device}')
|
logger.debug(f'USB Device: {device}')
|
||||||
|
|
||||||
# Power Cycle the device
|
# Power Cycle the device
|
||||||
|
|||||||
+47
-42
@@ -15,19 +15,18 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import collections
|
|
||||||
import ctypes
|
import ctypes
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
import usb1
|
import usb1
|
||||||
|
|
||||||
from bumble.transport.common import Transport, ParserSource
|
from bumble.transport.common import Transport, ParserSource, TransportInitError
|
||||||
from bumble import hci
|
from bumble import hci
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.utils import AsyncRunner
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -115,13 +114,17 @@ async def open_usb_transport(spec: str) -> Transport:
|
|||||||
self.device = device
|
self.device = device
|
||||||
self.acl_out = acl_out
|
self.acl_out = acl_out
|
||||||
self.acl_out_transfer = device.getTransfer()
|
self.acl_out_transfer = device.getTransfer()
|
||||||
self.packets = collections.deque() # Queue of packets waiting to be sent
|
self.acl_out_transfer_ready = asyncio.Semaphore(1)
|
||||||
|
self.packets: asyncio.Queue[bytes] = (
|
||||||
|
asyncio.Queue()
|
||||||
|
) # Queue of packets waiting to be sent
|
||||||
self.loop = asyncio.get_running_loop()
|
self.loop = asyncio.get_running_loop()
|
||||||
|
self.queue_task = None
|
||||||
self.cancel_done = self.loop.create_future()
|
self.cancel_done = self.loop.create_future()
|
||||||
self.closed = False
|
self.closed = False
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
pass
|
self.queue_task = asyncio.create_task(self.process_queue())
|
||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet):
|
||||||
# Ignore packets if we're closed
|
# Ignore packets if we're closed
|
||||||
@@ -133,62 +136,64 @@ async def open_usb_transport(spec: str) -> Transport:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Queue the packet
|
# Queue the packet
|
||||||
self.packets.append(packet)
|
self.packets.put_nowait(packet)
|
||||||
if len(self.packets) == 1:
|
|
||||||
# The queue was previously empty, re-prime the pump
|
|
||||||
self.process_queue()
|
|
||||||
|
|
||||||
def transfer_callback(self, transfer):
|
def transfer_callback(self, transfer):
|
||||||
|
self.acl_out_transfer_ready.release()
|
||||||
status = transfer.getStatus()
|
status = transfer.getStatus()
|
||||||
|
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
if status == usb1.TRANSFER_COMPLETED:
|
if status == usb1.TRANSFER_CANCELLED:
|
||||||
self.loop.call_soon_threadsafe(self.on_packet_sent)
|
|
||||||
elif status == usb1.TRANSFER_CANCELLED:
|
|
||||||
self.loop.call_soon_threadsafe(self.cancel_done.set_result, None)
|
self.loop.call_soon_threadsafe(self.cancel_done.set_result, None)
|
||||||
else:
|
return
|
||||||
|
|
||||||
|
if status != usb1.TRANSFER_COMPLETED:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
color(f'!!! OUT transfer not completed: status={status}', 'red')
|
color(f'!!! OUT transfer not completed: status={status}', 'red')
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_packet_sent(self):
|
async def process_queue(self):
|
||||||
if self.packets:
|
while True:
|
||||||
self.packets.popleft()
|
# Wait for a packet to transfer.
|
||||||
self.process_queue()
|
packet = await self.packets.get()
|
||||||
|
|
||||||
def process_queue(self):
|
# Wait until we can start a transfer.
|
||||||
if len(self.packets) == 0:
|
await self.acl_out_transfer_ready.acquire()
|
||||||
return # Nothing to do
|
|
||||||
|
|
||||||
packet = self.packets[0]
|
# Transfer the packet.
|
||||||
packet_type = packet[0]
|
packet_type = packet[0]
|
||||||
if packet_type == hci.HCI_ACL_DATA_PACKET:
|
if packet_type == hci.HCI_ACL_DATA_PACKET:
|
||||||
self.acl_out_transfer.setBulk(
|
self.acl_out_transfer.setBulk(
|
||||||
self.acl_out, packet[1:], callback=self.transfer_callback
|
self.acl_out, packet[1:], callback=self.transfer_callback
|
||||||
)
|
)
|
||||||
self.acl_out_transfer.submit()
|
self.acl_out_transfer.submit()
|
||||||
elif packet_type == hci.HCI_COMMAND_PACKET:
|
elif packet_type == hci.HCI_COMMAND_PACKET:
|
||||||
self.acl_out_transfer.setControl(
|
self.acl_out_transfer.setControl(
|
||||||
USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS,
|
USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
packet[1:],
|
packet[1:],
|
||||||
callback=self.transfer_callback,
|
callback=self.transfer_callback,
|
||||||
)
|
)
|
||||||
self.acl_out_transfer.submit()
|
self.acl_out_transfer.submit()
|
||||||
else:
|
else:
|
||||||
logger.warning(color(f'unsupported packet type {packet_type}', 'red'))
|
logger.warning(
|
||||||
|
color(f'unsupported packet type {packet_type}', 'red')
|
||||||
|
)
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.closed = True
|
self.closed = True
|
||||||
|
if self.queue_task:
|
||||||
|
self.queue_task.cancel()
|
||||||
|
|
||||||
async def terminate(self):
|
async def terminate(self):
|
||||||
if not self.closed:
|
if not self.closed:
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
# Empty the packet queue so that we don't send any more data
|
# Empty the packet queue so that we don't send any more data
|
||||||
self.packets.clear()
|
while not self.packets.empty():
|
||||||
|
self.packets.get_nowait()
|
||||||
|
|
||||||
# If we have a transfer in flight, cancel it
|
# If we have a transfer in flight, cancel it
|
||||||
if self.acl_out_transfer.isSubmitted():
|
if self.acl_out_transfer.isSubmitted():
|
||||||
@@ -442,7 +447,7 @@ async def open_usb_transport(spec: str) -> Transport:
|
|||||||
|
|
||||||
if found is None:
|
if found is None:
|
||||||
context.close()
|
context.close()
|
||||||
raise ValueError('device not found')
|
raise TransportInitError('device not found')
|
||||||
|
|
||||||
logger.debug(f'USB Device: {found}')
|
logger.debug(f'USB Device: {found}')
|
||||||
|
|
||||||
@@ -507,7 +512,7 @@ async def open_usb_transport(spec: str) -> Transport:
|
|||||||
|
|
||||||
endpoints = find_endpoints(found)
|
endpoints = find_endpoints(found)
|
||||||
if endpoints is None:
|
if endpoints is None:
|
||||||
raise ValueError('no compatible interface found for device')
|
raise TransportInitError('no compatible interface found for device')
|
||||||
(configuration, interface, setting, acl_in, acl_out, events_in) = endpoints
|
(configuration, interface, setting, acl_in, acl_out, events_in) = endpoints
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'selected endpoints: configuration={configuration}, '
|
f'selected endpoints: configuration={configuration}, '
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -150,7 +150,8 @@ class AppViewModel : ViewModel() {
|
|||||||
} else if (senderPacketSizeSlider < 0.5F) {
|
} else if (senderPacketSizeSlider < 0.5F) {
|
||||||
512
|
512
|
||||||
} else if (senderPacketSizeSlider < 0.7F) {
|
} else if (senderPacketSizeSlider < 0.7F) {
|
||||||
1024
|
// 970 is a value that works well on Android.
|
||||||
|
970
|
||||||
} else if (senderPacketSizeSlider < 0.9F) {
|
} else if (senderPacketSizeSlider < 0.9F) {
|
||||||
2048
|
2048
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+7
-1
@@ -56,13 +56,19 @@ class SocketClient(private val viewModel: AppViewModel, private val socket: Blue
|
|||||||
|
|
||||||
thread {
|
thread {
|
||||||
socketDataSource.receive()
|
socketDataSource.receive()
|
||||||
|
socket.close()
|
||||||
|
sender.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.info("Startup delay: $DEFAULT_STARTUP_DELAY")
|
Log.info("Startup delay: $DEFAULT_STARTUP_DELAY")
|
||||||
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
||||||
Log.info("Starting to send")
|
Log.info("Starting to send")
|
||||||
|
|
||||||
sender.run()
|
try {
|
||||||
|
sender.run()
|
||||||
|
} catch (error: IOException) {
|
||||||
|
Log.info("run ended abruptly")
|
||||||
|
}
|
||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ console_scripts =
|
|||||||
bumble-gatt-dump = bumble.apps.gatt_dump:main
|
bumble-gatt-dump = bumble.apps.gatt_dump:main
|
||||||
bumble-hci-bridge = bumble.apps.hci_bridge:main
|
bumble-hci-bridge = bumble.apps.hci_bridge:main
|
||||||
bumble-l2cap-bridge = bumble.apps.l2cap_bridge:main
|
bumble-l2cap-bridge = bumble.apps.l2cap_bridge:main
|
||||||
|
bumble-rfcomm-bridge = bumble.apps.rfcomm_bridge:main
|
||||||
bumble-pair = bumble.apps.pair:main
|
bumble-pair = bumble.apps.pair:main
|
||||||
bumble-scan = bumble.apps.scan:main
|
bumble-scan = bumble.apps.scan:main
|
||||||
bumble-show = bumble.apps.show:main
|
bumble-show = bumble.apps.show:main
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ Invoke tasks
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import os
|
import os
|
||||||
|
import glob
|
||||||
|
import shutil
|
||||||
|
import urllib
|
||||||
|
from pathlib import Path
|
||||||
from invoke import task, call, Collection
|
from invoke import task, call, Collection
|
||||||
from invoke.exceptions import Exit, UnexpectedExit
|
from invoke.exceptions import Exit, UnexpectedExit
|
||||||
|
|
||||||
@@ -205,5 +208,21 @@ def serve(ctx, port=8000):
|
|||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@task
|
||||||
|
def web_build(ctx):
|
||||||
|
# Step 1: build the wheel
|
||||||
|
build(ctx)
|
||||||
|
# Step 2: Copy the wheel to the web folder, so the http server can access it
|
||||||
|
newest_wheel = Path(max(glob.glob('dist/*.whl'), key=lambda f: os.path.getmtime(f)))
|
||||||
|
shutil.copy(newest_wheel, Path('web/'))
|
||||||
|
# Step 3: Write wheel's name to web/packageFile
|
||||||
|
with open(Path('web', 'packageFile'), mode='w') as package_file:
|
||||||
|
package_file.write(str(Path('/') / newest_wheel.name))
|
||||||
|
# Step 4: Success!
|
||||||
|
print('Include ?packageFile=true in your URL!')
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
web_tasks.add_task(serve)
|
web_tasks.add_task(serve)
|
||||||
|
web_tasks.add_task(web_build, name="build")
|
||||||
|
|||||||
+36
-6
@@ -301,9 +301,7 @@ async def test_legacy_advertising_connection(own_address_type):
|
|||||||
else:
|
else:
|
||||||
assert device.lookup_connection(0x0001).self_address == device.random_address
|
assert device.lookup_connection(0x0001).self_address == device.random_address
|
||||||
|
|
||||||
# For unknown reason, read_phy() in on_connection() would be killed at the end of
|
await async_barrier()
|
||||||
# test, so we force scheduling here to avoid an warning.
|
|
||||||
await asyncio.sleep(0.0001)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -384,9 +382,41 @@ async def test_extended_advertising_connection(own_address_type):
|
|||||||
else:
|
else:
|
||||||
assert device.lookup_connection(0x0001).self_address == device.random_address
|
assert device.lookup_connection(0x0001).self_address == device.random_address
|
||||||
|
|
||||||
# For unknown reason, read_phy() in on_connection() would be killed at the end of
|
await async_barrier()
|
||||||
# test, so we force scheduling here to avoid an warning.
|
|
||||||
await asyncio.sleep(0.0001)
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'own_address_type,',
|
||||||
|
(OwnAddressType.PUBLIC, OwnAddressType.RANDOM),
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extended_advertising_connection_out_of_order(own_address_type):
|
||||||
|
device = Device(host=mock.AsyncMock(spec=Host))
|
||||||
|
peer_address = Address('F0:F1:F2:F3:F4:F5')
|
||||||
|
advertising_set = await device.create_advertising_set(
|
||||||
|
advertising_parameters=AdvertisingParameters(own_address_type=own_address_type)
|
||||||
|
)
|
||||||
|
device.on_advertising_set_termination(
|
||||||
|
HCI_SUCCESS,
|
||||||
|
advertising_set.advertising_handle,
|
||||||
|
0x0001,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
device.on_connection(
|
||||||
|
0x0001,
|
||||||
|
BT_LE_TRANSPORT,
|
||||||
|
peer_address,
|
||||||
|
BT_PERIPHERAL_ROLE,
|
||||||
|
ConnectionParameters(0, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
if own_address_type == OwnAddressType.PUBLIC:
|
||||||
|
assert device.lookup_connection(0x0001).self_address == device.public_address
|
||||||
|
else:
|
||||||
|
assert device.lookup_connection(0x0001).self_address == device.random_address
|
||||||
|
|
||||||
|
await async_barrier()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -879,6 +879,57 @@ async def test_unsubscribe():
|
|||||||
mock1.assert_called_once_with(ANY, False, False)
|
mock1.assert_called_once_with(ANY, False, False)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discover_all():
|
||||||
|
[client, server] = LinkedDevices().devices[:2]
|
||||||
|
|
||||||
|
characteristic1 = Characteristic(
|
||||||
|
'FDB159DB-036C-49E3-B3DB-6325AC750806',
|
||||||
|
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||||
|
Characteristic.READABLE,
|
||||||
|
bytes([1, 2, 3]),
|
||||||
|
)
|
||||||
|
|
||||||
|
descriptor1 = Descriptor('2902', 'READABLE,WRITEABLE')
|
||||||
|
descriptor2 = Descriptor('AAAA', 'READABLE,WRITEABLE')
|
||||||
|
characteristic2 = Characteristic(
|
||||||
|
'3234C4F4-3F34-4616-8935-45A50EE05DEB',
|
||||||
|
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||||
|
Characteristic.READABLE,
|
||||||
|
bytes([1, 2, 3]),
|
||||||
|
descriptors=[descriptor1, descriptor2],
|
||||||
|
)
|
||||||
|
|
||||||
|
service1 = Service(
|
||||||
|
'3A657F47-D34F-46B3-B1EC-698E29B6B829',
|
||||||
|
[characteristic1, characteristic2],
|
||||||
|
)
|
||||||
|
service2 = Service('1111', [])
|
||||||
|
server.add_services([service1, service2])
|
||||||
|
|
||||||
|
await client.power_on()
|
||||||
|
await server.power_on()
|
||||||
|
connection = await client.connect(server.random_address)
|
||||||
|
peer = Peer(connection)
|
||||||
|
|
||||||
|
await peer.discover_all()
|
||||||
|
assert len(peer.gatt_client.services) == 3
|
||||||
|
# service 1800 gets added automatically
|
||||||
|
assert peer.gatt_client.services[0].uuid == UUID('1800')
|
||||||
|
assert peer.gatt_client.services[1].uuid == service1.uuid
|
||||||
|
assert peer.gatt_client.services[2].uuid == service2.uuid
|
||||||
|
s = peer.get_services_by_uuid(service1.uuid)
|
||||||
|
assert len(s) == 1
|
||||||
|
assert len(s[0].characteristics) == 2
|
||||||
|
c = peer.get_characteristics_by_uuid(uuid=characteristic2.uuid, service=s[0])
|
||||||
|
assert len(c) == 1
|
||||||
|
assert len(c[0].descriptors) == 2
|
||||||
|
s = peer.get_services_by_uuid(service2.uuid)
|
||||||
|
assert len(s) == 1
|
||||||
|
assert len(s[0].characteristics) == 0
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mtu_exchange():
|
async def test_mtu_exchange():
|
||||||
@@ -1146,6 +1197,56 @@ def test_get_attribute_group():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_characteristics_by_uuid():
|
||||||
|
[client, server] = LinkedDevices().devices[:2]
|
||||||
|
|
||||||
|
characteristic1 = Characteristic(
|
||||||
|
'1234',
|
||||||
|
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||||
|
Characteristic.READABLE,
|
||||||
|
bytes([1, 2, 3]),
|
||||||
|
)
|
||||||
|
characteristic2 = Characteristic(
|
||||||
|
'5678',
|
||||||
|
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||||
|
Characteristic.READABLE,
|
||||||
|
bytes([1, 2, 3]),
|
||||||
|
)
|
||||||
|
service1 = Service(
|
||||||
|
'ABCD',
|
||||||
|
[characteristic1, characteristic2],
|
||||||
|
)
|
||||||
|
service2 = Service(
|
||||||
|
'FFFF',
|
||||||
|
[characteristic1],
|
||||||
|
)
|
||||||
|
|
||||||
|
server.add_services([service1, service2])
|
||||||
|
|
||||||
|
await client.power_on()
|
||||||
|
await server.power_on()
|
||||||
|
connection = await client.connect(server.random_address)
|
||||||
|
peer = Peer(connection)
|
||||||
|
|
||||||
|
await peer.discover_services()
|
||||||
|
await peer.discover_characteristics()
|
||||||
|
c = peer.get_characteristics_by_uuid(uuid=UUID('1234'))
|
||||||
|
assert len(c) == 2
|
||||||
|
assert isinstance(c[0], CharacteristicProxy)
|
||||||
|
c = peer.get_characteristics_by_uuid(uuid=UUID('1234'), service=UUID('ABCD'))
|
||||||
|
assert len(c) == 1
|
||||||
|
assert isinstance(c[0], CharacteristicProxy)
|
||||||
|
c = peer.get_characteristics_by_uuid(uuid=UUID('1234'), service=UUID('AAAA'))
|
||||||
|
assert len(c) == 0
|
||||||
|
|
||||||
|
s = peer.get_services_by_uuid(uuid=UUID('ABCD'))
|
||||||
|
assert len(s) == 1
|
||||||
|
c = peer.get_characteristics_by_uuid(uuid=UUID('1234'), service=s[0])
|
||||||
|
assert len(s) == 1
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ def test_frames():
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_basic_connection() -> None:
|
async def test_connection_and_disconnection() -> None:
|
||||||
devices = test_utils.TwoDevices()
|
devices = test_utils.TwoDevices()
|
||||||
await devices.setup_connection()
|
await devices.setup_connection()
|
||||||
|
|
||||||
@@ -83,6 +83,11 @@ async def test_basic_connection() -> None:
|
|||||||
dlcs[1].write(b'Lorem ipsum dolor sit amet')
|
dlcs[1].write(b'Lorem ipsum dolor sit amet')
|
||||||
assert await queues[0].get() == b'Lorem ipsum dolor sit amet'
|
assert await queues[0].get() == b'Lorem ipsum dolor sit amet'
|
||||||
|
|
||||||
|
closed = asyncio.Event()
|
||||||
|
dlcs[1].on('close', closed.set)
|
||||||
|
await dlcs[1].disconnect()
|
||||||
|
await closed.wait()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# files created by invoke web.build
|
||||||
|
*.whl
|
||||||
|
packageFile
|
||||||
@@ -24,9 +24,14 @@ controller using some other transport (ex: `python apps/hci_bridge.py ws-server:
|
|||||||
For HTTP, start an HTTP server with the `web` directory as its
|
For HTTP, start an HTTP server with the `web` directory as its
|
||||||
root. You can use the invoke task `inv web.serve` for convenience.
|
root. You can use the invoke task `inv web.serve` for convenience.
|
||||||
|
|
||||||
|
`inv web.build` will build the local copy of bumble and automatically copy the `.whl` file
|
||||||
|
to the web directory. To use this build, include the param `?packageFile=true` to the URL.
|
||||||
|
|
||||||
In a browser, open either `scanner/scanner.html` or `speaker/speaker.html`.
|
In a browser, open either `scanner/scanner.html` or `speaker/speaker.html`.
|
||||||
You can pass optional query parameters:
|
You can pass optional query parameters:
|
||||||
|
|
||||||
|
* `packageFile=true` will automatically use the bumble package built via the
|
||||||
|
`inv web.build` command.
|
||||||
* `package` may be set to point to a local build of Bumble (`.whl` files).
|
* `package` may be set to point to a local build of Bumble (`.whl` files).
|
||||||
The filename must be URL-encoded of course, and must be located under
|
The filename must be URL-encoded of course, and must be located under
|
||||||
the `web` directory (the HTTP server won't serve files not under its
|
the `web` directory (the HTTP server won't serve files not under its
|
||||||
@@ -46,3 +51,5 @@ Example:
|
|||||||
|
|
||||||
NOTE: to get a local build of the Bumble package, use `inv build`, the built `.whl` file can be found in the `dist` directory.
|
NOTE: to get a local build of the Bumble package, use `inv build`, the built `.whl` file can be found in the `dist` directory.
|
||||||
Make a copy of the built `.whl` file in the `web` directory.
|
Make a copy of the built `.whl` file in the `web` directory.
|
||||||
|
|
||||||
|
Tip: During web developement, disable caching. [Chrome](https://stackoverflow.com/a/7000899]) / [Firefiox](https://stackoverflow.com/a/289771)
|
||||||
+15
-3
@@ -75,7 +75,6 @@ export class Bumble extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load the Bumble module
|
// Load the Bumble module
|
||||||
bumblePackage ||= 'bumble';
|
|
||||||
console.log('Installing micropip');
|
console.log('Installing micropip');
|
||||||
this.log(`Installing ${bumblePackage}`)
|
this.log(`Installing ${bumblePackage}`)
|
||||||
await this.pyodide.loadPackage('micropip');
|
await this.pyodide.loadPackage('micropip');
|
||||||
@@ -166,6 +165,20 @@ export class Bumble extends EventTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getBumblePackage() {
|
||||||
|
const params = (new URL(document.location)).searchParams;
|
||||||
|
// First check the packageFile override param
|
||||||
|
if (params.has('packageFile')) {
|
||||||
|
return await (await fetch('/packageFile')).text()
|
||||||
|
}
|
||||||
|
// Then check the package override param
|
||||||
|
if (params.has('package')) {
|
||||||
|
return params.get('package')
|
||||||
|
}
|
||||||
|
// If no override params, default to the main package
|
||||||
|
return 'bumble'
|
||||||
|
}
|
||||||
|
|
||||||
export async function setupSimpleApp(appUrl, bumbleControls, log) {
|
export async function setupSimpleApp(appUrl, bumbleControls, log) {
|
||||||
// Load Bumble
|
// Load Bumble
|
||||||
log('Loading Bumble');
|
log('Loading Bumble');
|
||||||
@@ -173,8 +186,7 @@ export async function setupSimpleApp(appUrl, bumbleControls, log) {
|
|||||||
bumble.addEventListener('log', (event) => {
|
bumble.addEventListener('log', (event) => {
|
||||||
log(event.message);
|
log(event.message);
|
||||||
})
|
})
|
||||||
const params = (new URL(document.location)).searchParams;
|
await bumble.loadRuntime(await getBumblePackage());
|
||||||
await bumble.loadRuntime(params.get('package'));
|
|
||||||
|
|
||||||
log('Bumble is ready!')
|
log('Bumble is ready!')
|
||||||
const app = await bumble.loadApp(appUrl);
|
const app = await bumble.loadApp(appUrl);
|
||||||
|
|||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../docs/images/favicon.ico
|
||||||
Reference in New Issue
Block a user