forked from auracaster/bumble_mirror
Compare commits
15 Commits
v0.0.193
...
packageFil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2c46e9ace | ||
|
|
32a41a815d | ||
|
|
df5fc2ddfe | ||
|
|
79122313a6 | ||
|
|
d7d03e2e92 | ||
|
|
ea493480a9 | ||
|
|
658f641a53 | ||
|
|
00edd1fbf8 | ||
|
|
999d7b07e1 | ||
|
|
2e3aeb8648 | ||
|
|
f910a696ad | ||
|
|
e1d10bc482 | ||
|
|
dea907be86 | ||
|
|
f5baf51132 | ||
|
|
f2dc8bd84e |
407
apps/auracast.py
Normal file
407
apps/auracast.py
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
# 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
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import cast, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
import click
|
||||||
|
import pyee
|
||||||
|
|
||||||
|
from bumble.colors import color
|
||||||
|
import bumble.company_ids
|
||||||
|
import bumble.core
|
||||||
|
import bumble.device
|
||||||
|
import bumble.gatt
|
||||||
|
import bumble.hci
|
||||||
|
import bumble.profiles.bap
|
||||||
|
import bumble.profiles.pbp
|
||||||
|
import bumble.transport
|
||||||
|
import bumble.utils
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
AURACAST_DEFAULT_DEVICE_NAME = "Bumble Auracast"
|
||||||
|
AURACAST_DEFAULT_DEVICE_ADDRESS = bumble.hci.Address("F0:F1:F2:F3:F4:F5")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Discover Broadcasts
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class BroadcastDiscoverer:
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class Broadcast(pyee.EventEmitter):
|
||||||
|
name: str
|
||||||
|
sync: bumble.device.PeriodicAdvertisingSync
|
||||||
|
rssi: int = 0
|
||||||
|
public_broadcast_announcement: Optional[
|
||||||
|
bumble.profiles.pbp.PublicBroadcastAnnouncement
|
||||||
|
] = None
|
||||||
|
broadcast_audio_announcement: Optional[
|
||||||
|
bumble.profiles.bap.BroadcastAudioAnnouncement
|
||||||
|
] = None
|
||||||
|
basic_audio_announcement: Optional[
|
||||||
|
bumble.profiles.bap.BasicAudioAnnouncement
|
||||||
|
] = None
|
||||||
|
appearance: Optional[bumble.core.Appearance] = None
|
||||||
|
biginfo: Optional[bumble.device.BIGInfoAdvertisement] = None
|
||||||
|
manufacturer_data: Optional[Tuple[str, bytes]] = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.sync.on('establishment', self.on_sync_establishment)
|
||||||
|
self.sync.on('loss', self.on_sync_loss)
|
||||||
|
self.sync.on('periodic_advertisement', self.on_periodic_advertisement)
|
||||||
|
self.sync.on('biginfo_advertisement', self.on_biginfo_advertisement)
|
||||||
|
|
||||||
|
self.establishment_timeout_task = asyncio.create_task(
|
||||||
|
self.wait_for_establishment()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def wait_for_establishment(self) -> None:
|
||||||
|
await asyncio.sleep(5.0)
|
||||||
|
if self.sync.state == bumble.device.PeriodicAdvertisingSync.State.PENDING:
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
'!!! Periodic advertisement sync not established in time, '
|
||||||
|
'canceling',
|
||||||
|
'red',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.sync.terminate()
|
||||||
|
|
||||||
|
def update(self, advertisement: bumble.device.Advertisement) -> None:
|
||||||
|
self.rssi = advertisement.rssi
|
||||||
|
for service_data in advertisement.data.get_all(
|
||||||
|
bumble.core.AdvertisingData.SERVICE_DATA
|
||||||
|
):
|
||||||
|
assert isinstance(service_data, tuple)
|
||||||
|
service_uuid, data = service_data
|
||||||
|
assert isinstance(data, bytes)
|
||||||
|
|
||||||
|
if (
|
||||||
|
service_uuid
|
||||||
|
== bumble.gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE
|
||||||
|
):
|
||||||
|
self.public_broadcast_announcement = (
|
||||||
|
bumble.profiles.pbp.PublicBroadcastAnnouncement.from_bytes(data)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (
|
||||||
|
service_uuid
|
||||||
|
== bumble.gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
|
||||||
|
):
|
||||||
|
self.broadcast_audio_announcement = (
|
||||||
|
bumble.profiles.bap.BroadcastAudioAnnouncement.from_bytes(data)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.appearance = advertisement.data.get( # type: ignore[assignment]
|
||||||
|
bumble.core.AdvertisingData.APPEARANCE
|
||||||
|
)
|
||||||
|
|
||||||
|
if manufacturer_data := advertisement.data.get(
|
||||||
|
bumble.core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA
|
||||||
|
):
|
||||||
|
assert isinstance(manufacturer_data, tuple)
|
||||||
|
company_id = cast(int, manufacturer_data[0])
|
||||||
|
data = cast(bytes, manufacturer_data[1])
|
||||||
|
self.manufacturer_data = (
|
||||||
|
bumble.company_ids.COMPANY_IDENTIFIERS.get(
|
||||||
|
company_id, f'0x{company_id:04X}'
|
||||||
|
),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
|
||||||
|
def print(self) -> None:
|
||||||
|
print(
|
||||||
|
color('Broadcast:', 'yellow'),
|
||||||
|
self.sync.advertiser_address,
|
||||||
|
color(self.sync.state.name, 'green'),
|
||||||
|
)
|
||||||
|
print(f' {color("Name", "cyan")}: {self.name}')
|
||||||
|
if self.appearance:
|
||||||
|
print(f' {color("Appearance", "cyan")}: {str(self.appearance)}')
|
||||||
|
print(f' {color("RSSI", "cyan")}: {self.rssi}')
|
||||||
|
print(f' {color("SID", "cyan")}: {self.sync.sid}')
|
||||||
|
|
||||||
|
if self.manufacturer_data:
|
||||||
|
print(
|
||||||
|
f' {color("Manufacturer Data", "cyan")}: '
|
||||||
|
f'{self.manufacturer_data[0]} -> {self.manufacturer_data[1].hex()}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.broadcast_audio_announcement:
|
||||||
|
print(
|
||||||
|
f' {color("Broadcast ID", "cyan")}: '
|
||||||
|
f'{self.broadcast_audio_announcement.broadcast_id}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.public_broadcast_announcement:
|
||||||
|
print(
|
||||||
|
f' {color("Features", "cyan")}: '
|
||||||
|
f'{self.public_broadcast_announcement.features}'
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f' {color("Metadata", "cyan")}: '
|
||||||
|
f'{self.public_broadcast_announcement.metadata}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.basic_audio_announcement:
|
||||||
|
print(color(' Audio:', 'cyan'))
|
||||||
|
print(
|
||||||
|
color(' Presentation Delay:', 'magenta'),
|
||||||
|
self.basic_audio_announcement.presentation_delay,
|
||||||
|
)
|
||||||
|
for subgroup in self.basic_audio_announcement.subgroups:
|
||||||
|
print(color(' Subgroup:', 'magenta'))
|
||||||
|
print(color(' Codec ID:', 'yellow'))
|
||||||
|
print(
|
||||||
|
color(' Coding Format: ', 'green'),
|
||||||
|
subgroup.codec_id.coding_format.name,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' Company ID: ', 'green'),
|
||||||
|
subgroup.codec_id.company_id,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' Vendor Specific Codec ID:', 'green'),
|
||||||
|
subgroup.codec_id.vendor_specific_codec_id,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' Codec Config:', 'yellow'),
|
||||||
|
subgroup.codec_specific_configuration,
|
||||||
|
)
|
||||||
|
print(color(' Metadata: ', 'yellow'), subgroup.metadata)
|
||||||
|
|
||||||
|
for bis in subgroup.bis:
|
||||||
|
print(color(f' BIS [{bis.index}]:', 'yellow'))
|
||||||
|
print(
|
||||||
|
color(' Codec Config:', 'green'),
|
||||||
|
bis.codec_specific_configuration,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.biginfo:
|
||||||
|
print(color(' BIG:', 'cyan'))
|
||||||
|
print(
|
||||||
|
color(' Number of BIS:', 'magenta'),
|
||||||
|
self.biginfo.num_bis,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' PHY: ', 'magenta'),
|
||||||
|
self.biginfo.phy.name,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' Framed: ', 'magenta'),
|
||||||
|
self.biginfo.framed,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' Encrypted: ', 'magenta'),
|
||||||
|
self.biginfo.encrypted,
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_sync_establishment(self) -> None:
|
||||||
|
self.establishment_timeout_task.cancel()
|
||||||
|
self.emit('change')
|
||||||
|
|
||||||
|
def on_sync_loss(self) -> None:
|
||||||
|
self.basic_audio_announcement = None
|
||||||
|
self.biginfo = None
|
||||||
|
self.emit('change')
|
||||||
|
|
||||||
|
def on_periodic_advertisement(
|
||||||
|
self, advertisement: bumble.device.PeriodicAdvertisement
|
||||||
|
) -> None:
|
||||||
|
if advertisement.data is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
for service_data in advertisement.data.get_all(
|
||||||
|
bumble.core.AdvertisingData.SERVICE_DATA
|
||||||
|
):
|
||||||
|
assert isinstance(service_data, tuple)
|
||||||
|
service_uuid, data = service_data
|
||||||
|
assert isinstance(data, bytes)
|
||||||
|
|
||||||
|
if service_uuid == bumble.gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE:
|
||||||
|
self.basic_audio_announcement = (
|
||||||
|
bumble.profiles.bap.BasicAudioAnnouncement.from_bytes(data)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
self.emit('change')
|
||||||
|
|
||||||
|
def on_biginfo_advertisement(
|
||||||
|
self, advertisement: bumble.device.BIGInfoAdvertisement
|
||||||
|
) -> None:
|
||||||
|
self.biginfo = advertisement
|
||||||
|
self.emit('change')
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device: bumble.device.Device,
|
||||||
|
filter_duplicates: bool,
|
||||||
|
sync_timeout: float,
|
||||||
|
):
|
||||||
|
self.device = device
|
||||||
|
self.filter_duplicates = filter_duplicates
|
||||||
|
self.sync_timeout = sync_timeout
|
||||||
|
self.broadcasts: Dict[bumble.hci.Address, BroadcastDiscoverer.Broadcast] = {}
|
||||||
|
self.status_message = ''
|
||||||
|
device.on('advertisement', self.on_advertisement)
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
self.status_message = color('Scanning...', 'green')
|
||||||
|
await self.device.start_scanning(
|
||||||
|
active=False,
|
||||||
|
filter_duplicates=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
# Clear the screen from the top
|
||||||
|
print('\033[H')
|
||||||
|
print('\033[0J')
|
||||||
|
print('\033[H')
|
||||||
|
|
||||||
|
# Print the status message
|
||||||
|
print(self.status_message)
|
||||||
|
print("==========================================")
|
||||||
|
|
||||||
|
# Print all broadcasts
|
||||||
|
for broadcast in self.broadcasts.values():
|
||||||
|
broadcast.print()
|
||||||
|
print('------------------------------------------')
|
||||||
|
|
||||||
|
# Clear the screen to the bottom
|
||||||
|
print('\033[0J')
|
||||||
|
|
||||||
|
def on_advertisement(self, advertisement: bumble.device.Advertisement) -> None:
|
||||||
|
if (
|
||||||
|
broadcast_name := advertisement.data.get(
|
||||||
|
bumble.core.AdvertisingData.BROADCAST_NAME
|
||||||
|
)
|
||||||
|
) is None:
|
||||||
|
return
|
||||||
|
assert isinstance(broadcast_name, str)
|
||||||
|
|
||||||
|
if broadcast := self.broadcasts.get(advertisement.address):
|
||||||
|
broadcast.update(advertisement)
|
||||||
|
self.refresh()
|
||||||
|
return
|
||||||
|
|
||||||
|
bumble.utils.AsyncRunner.spawn(
|
||||||
|
self.on_new_broadcast(broadcast_name, advertisement)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_new_broadcast(
|
||||||
|
self, name: str, advertisement: bumble.device.Advertisement
|
||||||
|
) -> None:
|
||||||
|
periodic_advertising_sync = await self.device.create_periodic_advertising_sync(
|
||||||
|
advertiser_address=advertisement.address,
|
||||||
|
sid=advertisement.sid,
|
||||||
|
sync_timeout=self.sync_timeout,
|
||||||
|
filter_duplicates=self.filter_duplicates,
|
||||||
|
)
|
||||||
|
broadcast = self.Broadcast(
|
||||||
|
name,
|
||||||
|
periodic_advertising_sync,
|
||||||
|
)
|
||||||
|
broadcast.on('change', self.refresh)
|
||||||
|
broadcast.update(advertisement)
|
||||||
|
self.broadcasts[advertisement.address] = broadcast
|
||||||
|
periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
|
||||||
|
self.status_message = color(
|
||||||
|
f'+Found {len(self.broadcasts)} broadcasts', 'green'
|
||||||
|
)
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def on_broadcast_loss(self, broadcast: Broadcast) -> None:
|
||||||
|
del self.broadcasts[broadcast.sync.advertiser_address]
|
||||||
|
bumble.utils.AsyncRunner.spawn(broadcast.sync.terminate())
|
||||||
|
self.status_message = color(
|
||||||
|
f'-Found {len(self.broadcasts)} broadcasts', 'green'
|
||||||
|
)
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_discover_broadcasts(
|
||||||
|
filter_duplicates: bool, sync_timeout: float, transport: str
|
||||||
|
) -> None:
|
||||||
|
async with await bumble.transport.open_transport(transport) as (
|
||||||
|
hci_source,
|
||||||
|
hci_sink,
|
||||||
|
):
|
||||||
|
device = bumble.device.Device.with_hci(
|
||||||
|
AURACAST_DEFAULT_DEVICE_NAME,
|
||||||
|
AURACAST_DEFAULT_DEVICE_ADDRESS,
|
||||||
|
hci_source,
|
||||||
|
hci_sink,
|
||||||
|
)
|
||||||
|
await device.power_on()
|
||||||
|
discoverer = BroadcastDiscoverer(device, filter_duplicates, sync_timeout)
|
||||||
|
await discoverer.run()
|
||||||
|
await hci_source.terminated
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@click.group()
|
||||||
|
@click.pass_context
|
||||||
|
def auracast(
|
||||||
|
ctx,
|
||||||
|
):
|
||||||
|
ctx.ensure_object(dict)
|
||||||
|
|
||||||
|
|
||||||
|
@auracast.command('discover-broadcasts')
|
||||||
|
@click.option(
|
||||||
|
'--filter-duplicates', is_flag=True, default=False, help='Filter duplicates'
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--sync-timeout',
|
||||||
|
metavar='SYNC_TIMEOUT',
|
||||||
|
type=float,
|
||||||
|
default=5.0,
|
||||||
|
help='Sync timeout (in seconds)',
|
||||||
|
)
|
||||||
|
@click.argument('transport')
|
||||||
|
@click.pass_context
|
||||||
|
def discover_broadcasts(ctx, filter_duplicates, sync_timeout, transport):
|
||||||
|
"""Discover public broadcasts"""
|
||||||
|
asyncio.run(run_discover_broadcasts(filter_duplicates, sync_timeout, transport))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||||
|
auracast()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main() # pylint: disable=no-value-for-parameter
|
||||||
181
apps/bench.py
181
apps/bench.py
@@ -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())
|
||||||
|
|||||||
@@ -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)}')
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
511
apps/rfcomm_bridge.py
Normal file
511
apps/rfcomm_bridge.py
Normal file
@@ -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
|
||||||
804
bumble/core.py
804
bumble/core.py
@@ -16,11 +16,14 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import struct
|
import struct
|
||||||
from typing import List, Optional, Tuple, Union, cast, Dict
|
from typing import List, Optional, Tuple, Union, cast, Dict
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
from .company_ids import COMPANY_IDENTIFIERS
|
from bumble.company_ids import COMPANY_IDENTIFIERS
|
||||||
|
from bumble.utils import OpenIntEnum
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -692,11 +695,569 @@ class DeviceClass:
|
|||||||
return name_or_number(class_names, minor_device_class)
|
return name_or_number(class_names, minor_device_class)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Appearance
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Appearance:
|
||||||
|
class Category(OpenIntEnum):
|
||||||
|
UNKNOWN = 0x0000
|
||||||
|
PHONE = 0x0001
|
||||||
|
COMPUTER = 0x0002
|
||||||
|
WATCH = 0x0003
|
||||||
|
CLOCK = 0x0004
|
||||||
|
DISPLAY = 0x0005
|
||||||
|
REMOTE_CONTROL = 0x0006
|
||||||
|
EYE_GLASSES = 0x0007
|
||||||
|
TAG = 0x0008
|
||||||
|
KEYRING = 0x0009
|
||||||
|
MEDIA_PLAYER = 0x000A
|
||||||
|
BARCODE_SCANNER = 0x000B
|
||||||
|
THERMOMETER = 0x000C
|
||||||
|
HEART_RATE_SENSOR = 0x000D
|
||||||
|
BLOOD_PRESSURE = 0x000E
|
||||||
|
HUMAN_INTERFACE_DEVICE = 0x000F
|
||||||
|
GLUCOSE_METER = 0x0010
|
||||||
|
RUNNING_WALKING_SENSOR = 0x0011
|
||||||
|
CYCLING = 0x0012
|
||||||
|
CONTROL_DEVICE = 0x0013
|
||||||
|
NETWORK_DEVICE = 0x0014
|
||||||
|
SENSOR = 0x0015
|
||||||
|
LIGHT_FIXTURES = 0x0016
|
||||||
|
FAN = 0x0017
|
||||||
|
HVAC = 0x0018
|
||||||
|
AIR_CONDITIONING = 0x0019
|
||||||
|
HUMIDIFIER = 0x001A
|
||||||
|
HEATING = 0x001B
|
||||||
|
ACCESS_CONTROL = 0x001C
|
||||||
|
MOTORIZED_DEVICE = 0x001D
|
||||||
|
POWER_DEVICE = 0x001E
|
||||||
|
LIGHT_SOURCE = 0x001F
|
||||||
|
WINDOW_COVERING = 0x0020
|
||||||
|
AUDIO_SINK = 0x0021
|
||||||
|
AUDIO_SOURCE = 0x0022
|
||||||
|
MOTORIZED_VEHICLE = 0x0023
|
||||||
|
DOMESTIC_APPLIANCE = 0x0024
|
||||||
|
WEARABLE_AUDIO_DEVICE = 0x0025
|
||||||
|
AIRCRAFT = 0x0026
|
||||||
|
AV_EQUIPMENT = 0x0027
|
||||||
|
DISPLAY_EQUIPMENT = 0x0028
|
||||||
|
HEARING_AID = 0x0029
|
||||||
|
GAMING = 0x002A
|
||||||
|
SIGNAGE = 0x002B
|
||||||
|
PULSE_OXIMETER = 0x0031
|
||||||
|
WEIGHT_SCALE = 0x0032
|
||||||
|
PERSONAL_MOBILITY_DEVICE = 0x0033
|
||||||
|
CONTINUOUS_GLUCOSE_MONITOR = 0x0034
|
||||||
|
INSULIN_PUMP = 0x0035
|
||||||
|
MEDICATION_DELIVERY = 0x0036
|
||||||
|
SPIROMETER = 0x0037
|
||||||
|
OUTDOOR_SPORTS_ACTIVITY = 0x0051
|
||||||
|
|
||||||
|
class UnknownSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_UNKNOWN = 0x00
|
||||||
|
|
||||||
|
class PhoneSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_PHONE = 0x00
|
||||||
|
|
||||||
|
class ComputerSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_COMPUTER = 0x00
|
||||||
|
DESKTOP_WORKSTATION = 0x01
|
||||||
|
SERVER_CLASS_COMPUTER = 0x02
|
||||||
|
LAPTOP = 0x03
|
||||||
|
HANDHELD_PC_PDA = 0x04
|
||||||
|
PALM_SIZE_PC_PDA = 0x05
|
||||||
|
WEARABLE_COMPUTER = 0x06
|
||||||
|
TABLET = 0x07
|
||||||
|
DOCKING_STATION = 0x08
|
||||||
|
ALL_IN_ONE = 0x09
|
||||||
|
BLADE_SERVER = 0x0A
|
||||||
|
CONVERTIBLE = 0x0B
|
||||||
|
DETACHABLE = 0x0C
|
||||||
|
IOT_GATEWAY = 0x0D
|
||||||
|
MINI_PC = 0x0E
|
||||||
|
STICK_PC = 0x0F
|
||||||
|
|
||||||
|
class WatchSubcategory(OpenIntEnum):
|
||||||
|
GENENERIC_WATCH = 0x00
|
||||||
|
SPORTS_WATCH = 0x01
|
||||||
|
SMARTWATCH = 0x02
|
||||||
|
|
||||||
|
class ClockSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_CLOCK = 0x00
|
||||||
|
|
||||||
|
class DisplaySubcategory(OpenIntEnum):
|
||||||
|
GENERIC_DISPLAY = 0x00
|
||||||
|
|
||||||
|
class RemoteControlSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_REMOTE_CONTROL = 0x00
|
||||||
|
|
||||||
|
class EyeglassesSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_EYEGLASSES = 0x00
|
||||||
|
|
||||||
|
class TagSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_TAG = 0x00
|
||||||
|
|
||||||
|
class KeyringSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_KEYRING = 0x00
|
||||||
|
|
||||||
|
class MediaPlayerSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_MEDIA_PLAYER = 0x00
|
||||||
|
|
||||||
|
class BarcodeScannerSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_BARCODE_SCANNER = 0x00
|
||||||
|
|
||||||
|
class ThermometerSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_THERMOMETER = 0x00
|
||||||
|
EAR_THERMOMETER = 0x01
|
||||||
|
|
||||||
|
class HeartRateSensorSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_HEART_RATE_SENSOR = 0x00
|
||||||
|
HEART_RATE_BELT = 0x01
|
||||||
|
|
||||||
|
class BloodPressureSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_BLOOD_PRESSURE = 0x00
|
||||||
|
ARM_BLOOD_PRESSURE = 0x01
|
||||||
|
WRIST_BLOOD_PRESSURE = 0x02
|
||||||
|
|
||||||
|
class HumanInterfaceDeviceSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_HUMAN_INTERFACE_DEVICE = 0x00
|
||||||
|
KEYBOARD = 0x01
|
||||||
|
MOUSE = 0x02
|
||||||
|
JOYSTICK = 0x03
|
||||||
|
GAMEPAD = 0x04
|
||||||
|
DIGITIZER_TABLET = 0x05
|
||||||
|
CARD_READER = 0x06
|
||||||
|
DIGITAL_PEN = 0x07
|
||||||
|
BARCODE_SCANNER = 0x08
|
||||||
|
TOUCHPAD = 0x09
|
||||||
|
PRESENTATION_REMOTE = 0x0A
|
||||||
|
|
||||||
|
class GlucoseMeterSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_GLUCOSE_METER = 0x00
|
||||||
|
|
||||||
|
class RunningWalkingSensorSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_RUNNING_WALKING_SENSOR = 0x00
|
||||||
|
IN_SHOE_RUNNING_WALKING_SENSOR = 0x01
|
||||||
|
ON_SHOW_RUNNING_WALKING_SENSOR = 0x02
|
||||||
|
ON_HIP_RUNNING_WALKING_SENSOR = 0x03
|
||||||
|
|
||||||
|
class CyclingSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_CYCLING = 0x00
|
||||||
|
CYCLING_COMPUTER = 0x01
|
||||||
|
SPEED_SENSOR = 0x02
|
||||||
|
CADENCE_SENSOR = 0x03
|
||||||
|
POWER_SENSOR = 0x04
|
||||||
|
SPEED_AND_CADENCE_SENSOR = 0x05
|
||||||
|
|
||||||
|
class ControlDeviceSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_CONTROL_DEVICE = 0x00
|
||||||
|
SWITCH = 0x01
|
||||||
|
MULTI_SWITCH = 0x02
|
||||||
|
BUTTON = 0x03
|
||||||
|
SLIDER = 0x04
|
||||||
|
ROTARY_SWITCH = 0x05
|
||||||
|
TOUCH_PANEL = 0x06
|
||||||
|
SINGLE_SWITCH = 0x07
|
||||||
|
DOUBLE_SWITCH = 0x08
|
||||||
|
TRIPLE_SWITCH = 0x09
|
||||||
|
BATTERY_SWITCH = 0x0A
|
||||||
|
ENERGY_HARVESTING_SWITCH = 0x0B
|
||||||
|
PUSH_BUTTON = 0x0C
|
||||||
|
|
||||||
|
class NetworkDeviceSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_NETWORK_DEVICE = 0x00
|
||||||
|
ACCESS_POINT = 0x01
|
||||||
|
MESH_DEVICE = 0x02
|
||||||
|
MESH_NETWORK_PROXY = 0x03
|
||||||
|
|
||||||
|
class SensorSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_SENSOR = 0x00
|
||||||
|
MOTION_SENSOR = 0x01
|
||||||
|
AIR_QUALITY_SENSOR = 0x02
|
||||||
|
TEMPERATURE_SENSOR = 0x03
|
||||||
|
HUMIDITY_SENSOR = 0x04
|
||||||
|
LEAK_SENSOR = 0x05
|
||||||
|
SMOKE_SENSOR = 0x06
|
||||||
|
OCCUPANCY_SENSOR = 0x07
|
||||||
|
CONTACT_SENSOR = 0x08
|
||||||
|
CARBON_MONOXIDE_SENSOR = 0x09
|
||||||
|
CARBON_DIOXIDE_SENSOR = 0x0A
|
||||||
|
AMBIENT_LIGHT_SENSOR = 0x0B
|
||||||
|
ENERGY_SENSOR = 0x0C
|
||||||
|
COLOR_LIGHT_SENSOR = 0x0D
|
||||||
|
RAIN_SENSOR = 0x0E
|
||||||
|
FIRE_SENSOR = 0x0F
|
||||||
|
WIND_SENSOR = 0x10
|
||||||
|
PROXIMITY_SENSOR = 0x11
|
||||||
|
MULTI_SENSOR = 0x12
|
||||||
|
FLUSH_MOUNTED_SENSOR = 0x13
|
||||||
|
CEILING_MOUNTED_SENSOR = 0x14
|
||||||
|
WALL_MOUNTED_SENSOR = 0x15
|
||||||
|
MULTISENSOR = 0x16
|
||||||
|
ENERGY_METER = 0x17
|
||||||
|
FLAME_DETECTOR = 0x18
|
||||||
|
VEHICLE_TIRE_PRESSURE_SENSOR = 0x19
|
||||||
|
|
||||||
|
class LightFixturesSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_LIGHT_FIXTURES = 0x00
|
||||||
|
WALL_LIGHT = 0x01
|
||||||
|
CEILING_LIGHT = 0x02
|
||||||
|
FLOOR_LIGHT = 0x03
|
||||||
|
CABINET_LIGHT = 0x04
|
||||||
|
DESK_LIGHT = 0x05
|
||||||
|
TROFFER_LIGHT = 0x06
|
||||||
|
PENDANT_LIGHT = 0x07
|
||||||
|
IN_GROUND_LIGHT = 0x08
|
||||||
|
FLOOD_LIGHT = 0x09
|
||||||
|
UNDERWATER_LIGHT = 0x0A
|
||||||
|
BOLLARD_WITH_LIGHT = 0x0B
|
||||||
|
PATHWAY_LIGHT = 0x0C
|
||||||
|
GARDEN_LIGHT = 0x0D
|
||||||
|
POLE_TOP_LIGHT = 0x0E
|
||||||
|
SPOTLIGHT = 0x0F
|
||||||
|
LINEAR_LIGHT = 0x10
|
||||||
|
STREET_LIGHT = 0x11
|
||||||
|
SHELVES_LIGHT = 0x12
|
||||||
|
BAY_LIGHT = 0x013
|
||||||
|
EMERGENCY_EXIT_LIGHT = 0x14
|
||||||
|
LIGHT_CONTROLLER = 0x15
|
||||||
|
LIGHT_DRIVER = 0x16
|
||||||
|
BULB = 0x17
|
||||||
|
LOW_BAY_LIGHT = 0x18
|
||||||
|
HIGH_BAY_LIGHT = 0x19
|
||||||
|
|
||||||
|
class FanSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_FAN = 0x00
|
||||||
|
CEILING_FAN = 0x01
|
||||||
|
AXIAL_FAN = 0x02
|
||||||
|
EXHAUST_FAN = 0x03
|
||||||
|
PEDESTAL_FAN = 0x04
|
||||||
|
DESK_FAN = 0x05
|
||||||
|
WALL_FAN = 0x06
|
||||||
|
|
||||||
|
class HvacSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_HVAC = 0x00
|
||||||
|
THERMOSTAT = 0x01
|
||||||
|
HUMIDIFIER = 0x02
|
||||||
|
DEHUMIDIFIER = 0x03
|
||||||
|
HEATER = 0x04
|
||||||
|
RADIATOR = 0x05
|
||||||
|
BOILER = 0x06
|
||||||
|
HEAT_PUMP = 0x07
|
||||||
|
INFRARED_HEATER = 0x08
|
||||||
|
RADIANT_PANEL_HEATER = 0x09
|
||||||
|
FAN_HEATER = 0x0A
|
||||||
|
AIR_CURTAIN = 0x0B
|
||||||
|
|
||||||
|
class AirConditioningSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_AIR_CONDITIONING = 0x00
|
||||||
|
|
||||||
|
class HumidifierSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_HUMIDIFIER = 0x00
|
||||||
|
|
||||||
|
class HeatingSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_HEATING = 0x00
|
||||||
|
RADIATOR = 0x01
|
||||||
|
BOILER = 0x02
|
||||||
|
HEAT_PUMP = 0x03
|
||||||
|
INFRARED_HEATER = 0x04
|
||||||
|
RADIANT_PANEL_HEATER = 0x05
|
||||||
|
FAN_HEATER = 0x06
|
||||||
|
AIR_CURTAIN = 0x07
|
||||||
|
|
||||||
|
class AccessControlSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_ACCESS_CONTROL = 0x00
|
||||||
|
ACCESS_DOOR = 0x01
|
||||||
|
GARAGE_DOOR = 0x02
|
||||||
|
EMERGENCY_EXIT_DOOR = 0x03
|
||||||
|
ACCESS_LOCK = 0x04
|
||||||
|
ELEVATOR = 0x05
|
||||||
|
WINDOW = 0x06
|
||||||
|
ENTRANCE_GATE = 0x07
|
||||||
|
DOOR_LOCK = 0x08
|
||||||
|
LOCKER = 0x09
|
||||||
|
|
||||||
|
class MotorizedDeviceSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_MOTORIZED_DEVICE = 0x00
|
||||||
|
MOTORIZED_GATE = 0x01
|
||||||
|
AWNING = 0x02
|
||||||
|
BLINDS_OR_SHADES = 0x03
|
||||||
|
CURTAINS = 0x04
|
||||||
|
SCREEN = 0x05
|
||||||
|
|
||||||
|
class PowerDeviceSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_POWER_DEVICE = 0x00
|
||||||
|
POWER_OUTLET = 0x01
|
||||||
|
POWER_STRIP = 0x02
|
||||||
|
PLUG = 0x03
|
||||||
|
POWER_SUPPLY = 0x04
|
||||||
|
LED_DRIVER = 0x05
|
||||||
|
FLUORESCENT_LAMP_GEAR = 0x06
|
||||||
|
HID_LAMP_GEAR = 0x07
|
||||||
|
CHARGE_CASE = 0x08
|
||||||
|
POWER_BANK = 0x09
|
||||||
|
|
||||||
|
class LightSourceSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_LIGHT_SOURCE = 0x00
|
||||||
|
INCANDESCENT_LIGHT_BULB = 0x01
|
||||||
|
LED_LAMP = 0x02
|
||||||
|
HID_LAMP = 0x03
|
||||||
|
FLUORESCENT_LAMP = 0x04
|
||||||
|
LED_ARRAY = 0x05
|
||||||
|
MULTI_COLOR_LED_ARRAY = 0x06
|
||||||
|
LOW_VOLTAGE_HALOGEN = 0x07
|
||||||
|
ORGANIC_LIGHT_EMITTING_DIODE = 0x08
|
||||||
|
|
||||||
|
class WindowCoveringSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_WINDOW_COVERING = 0x00
|
||||||
|
WINDOW_SHADES = 0x01
|
||||||
|
WINDOW_BLINDS = 0x02
|
||||||
|
WINDOW_AWNING = 0x03
|
||||||
|
WINDOW_CURTAIN = 0x04
|
||||||
|
EXTERIOR_SHUTTER = 0x05
|
||||||
|
EXTERIOR_SCREEN = 0x06
|
||||||
|
|
||||||
|
class AudioSinkSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_AUDIO_SINK = 0x00
|
||||||
|
STANDALONE_SPEAKER = 0x01
|
||||||
|
SOUNDBAR = 0x02
|
||||||
|
BOOKSHELF_SPEAKER = 0x03
|
||||||
|
STANDMOUNTED_SPEAKER = 0x04
|
||||||
|
SPEAKERPHONE = 0x05
|
||||||
|
|
||||||
|
class AudioSourceSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_AUDIO_SOURCE = 0x00
|
||||||
|
MICROPHONE = 0x01
|
||||||
|
ALARM = 0x02
|
||||||
|
BELL = 0x03
|
||||||
|
HORN = 0x04
|
||||||
|
BROADCASTING_DEVICE = 0x05
|
||||||
|
SERVICE_DESK = 0x06
|
||||||
|
KIOSK = 0x07
|
||||||
|
BROADCASTING_ROOM = 0x08
|
||||||
|
AUDITORIUM = 0x09
|
||||||
|
|
||||||
|
class MotorizedVehicleSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_MOTORIZED_VEHICLE = 0x00
|
||||||
|
CAR = 0x01
|
||||||
|
LARGE_GOODS_VEHICLE = 0x02
|
||||||
|
TWO_WHEELED_VEHICLE = 0x03
|
||||||
|
MOTORBIKE = 0x04
|
||||||
|
SCOOTER = 0x05
|
||||||
|
MOPED = 0x06
|
||||||
|
THREE_WHEELED_VEHICLE = 0x07
|
||||||
|
LIGHT_VEHICLE = 0x08
|
||||||
|
QUAD_BIKE = 0x09
|
||||||
|
MINIBUS = 0x0A
|
||||||
|
BUS = 0x0B
|
||||||
|
TROLLEY = 0x0C
|
||||||
|
AGRICULTURAL_VEHICLE = 0x0D
|
||||||
|
CAMPER_CARAVAN = 0x0E
|
||||||
|
RECREATIONAL_VEHICLE_MOTOR_HOME = 0x0F
|
||||||
|
|
||||||
|
class DomesticApplianceSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_DOMESTIC_APPLIANCE = 0x00
|
||||||
|
REFRIGERATOR = 0x01
|
||||||
|
FREEZER = 0x02
|
||||||
|
OVEN = 0x03
|
||||||
|
MICROWAVE = 0x04
|
||||||
|
TOASTER = 0x05
|
||||||
|
WASHING_MACHINE = 0x06
|
||||||
|
DRYER = 0x07
|
||||||
|
COFFEE_MAKER = 0x08
|
||||||
|
CLOTHES_IRON = 0x09
|
||||||
|
CURLING_IRON = 0x0A
|
||||||
|
HAIR_DRYER = 0x0B
|
||||||
|
VACUUM_CLEANER = 0x0C
|
||||||
|
ROBOTIC_VACUUM_CLEANER = 0x0D
|
||||||
|
RICE_COOKER = 0x0E
|
||||||
|
CLOTHES_STEAMER = 0x0F
|
||||||
|
|
||||||
|
class WearableAudioDeviceSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_WEARABLE_AUDIO_DEVICE = 0x00
|
||||||
|
EARBUD = 0x01
|
||||||
|
HEADSET = 0x02
|
||||||
|
HEADPHONES = 0x03
|
||||||
|
NECK_BAND = 0x04
|
||||||
|
|
||||||
|
class AircraftSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_AIRCRAFT = 0x00
|
||||||
|
LIGHT_AIRCRAFT = 0x01
|
||||||
|
MICROLIGHT = 0x02
|
||||||
|
PARAGLIDER = 0x03
|
||||||
|
LARGE_PASSENGER_AIRCRAFT = 0x04
|
||||||
|
|
||||||
|
class AvEquipmentSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_AV_EQUIPMENT = 0x00
|
||||||
|
AMPLIFIER = 0x01
|
||||||
|
RECEIVER = 0x02
|
||||||
|
RADIO = 0x03
|
||||||
|
TUNER = 0x04
|
||||||
|
TURNTABLE = 0x05
|
||||||
|
CD_PLAYER = 0x06
|
||||||
|
DVD_PLAYER = 0x07
|
||||||
|
BLUERAY_PLAYER = 0x08
|
||||||
|
OPTICAL_DISC_PLAYER = 0x09
|
||||||
|
SET_TOP_BOX = 0x0A
|
||||||
|
|
||||||
|
class DisplayEquipmentSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_DISPLAY_EQUIPMENT = 0x00
|
||||||
|
TELEVISION = 0x01
|
||||||
|
MONITOR = 0x02
|
||||||
|
PROJECTOR = 0x03
|
||||||
|
|
||||||
|
class HearingAidSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_HEARING_AID = 0x00
|
||||||
|
IN_EAR_HEARING_AID = 0x01
|
||||||
|
BEHIND_EAR_HEARING_AID = 0x02
|
||||||
|
COCHLEAR_IMPLANT = 0x03
|
||||||
|
|
||||||
|
class GamingSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_GAMING = 0x00
|
||||||
|
HOME_VIDEO_GAME_CONSOLE = 0x01
|
||||||
|
PORTABLE_HANDHELD_CONSOLE = 0x02
|
||||||
|
|
||||||
|
class SignageSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_SIGNAGE = 0x00
|
||||||
|
DIGITAL_SIGNAGE = 0x01
|
||||||
|
ELECTRONIC_LABEL = 0x02
|
||||||
|
|
||||||
|
class PulseOximeterSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_PULSE_OXIMETER = 0x00
|
||||||
|
FINGERTIP_PULSE_OXIMETER = 0x01
|
||||||
|
WRIST_WORN_PULSE_OXIMETER = 0x02
|
||||||
|
|
||||||
|
class WeightScaleSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_WEIGHT_SCALE = 0x00
|
||||||
|
|
||||||
|
class PersonalMobilityDeviceSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_PERSONAL_MOBILITY_DEVICE = 0x00
|
||||||
|
POWERED_WHEELCHAIR = 0x01
|
||||||
|
MOBILITY_SCOOTER = 0x02
|
||||||
|
|
||||||
|
class ContinuousGlucoseMonitorSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_CONTINUOUS_GLUCOSE_MONITOR = 0x00
|
||||||
|
|
||||||
|
class InsulinPumpSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_INSULIN_PUMP = 0x00
|
||||||
|
INSULIN_PUMP_DURABLE_PUMP = 0x01
|
||||||
|
INSULIN_PUMP_PATCH_PUMP = 0x02
|
||||||
|
INSULIN_PEN = 0x03
|
||||||
|
|
||||||
|
class MedicationDeliverySubcategory(OpenIntEnum):
|
||||||
|
GENERIC_MEDICATION_DELIVERY = 0x00
|
||||||
|
|
||||||
|
class SpirometerSubcategory(OpenIntEnum):
|
||||||
|
GENERIC_SPIROMETER = 0x00
|
||||||
|
HANDHELD_SPIROMETER = 0x01
|
||||||
|
|
||||||
|
class OutdoorSportsActivitySubcategory(OpenIntEnum):
|
||||||
|
GENERIC_OUTDOOR_SPORTS_ACTIVITY = 0x00
|
||||||
|
LOCATION_DISPLAY = 0x01
|
||||||
|
LOCATION_AND_NAVIGATION_DISPLAY = 0x02
|
||||||
|
LOCATION_POD = 0x03
|
||||||
|
LOCATION_AND_NAVIGATION_POD = 0x04
|
||||||
|
|
||||||
|
class _OpenSubcategory(OpenIntEnum):
|
||||||
|
GENERIC = 0x00
|
||||||
|
|
||||||
|
SUBCATEGORY_CLASSES = {
|
||||||
|
Category.UNKNOWN: UnknownSubcategory,
|
||||||
|
Category.PHONE: PhoneSubcategory,
|
||||||
|
Category.COMPUTER: ComputerSubcategory,
|
||||||
|
Category.WATCH: WatchSubcategory,
|
||||||
|
Category.CLOCK: ClockSubcategory,
|
||||||
|
Category.DISPLAY: DisplaySubcategory,
|
||||||
|
Category.REMOTE_CONTROL: RemoteControlSubcategory,
|
||||||
|
Category.EYE_GLASSES: EyeglassesSubcategory,
|
||||||
|
Category.TAG: TagSubcategory,
|
||||||
|
Category.KEYRING: KeyringSubcategory,
|
||||||
|
Category.MEDIA_PLAYER: MediaPlayerSubcategory,
|
||||||
|
Category.BARCODE_SCANNER: BarcodeScannerSubcategory,
|
||||||
|
Category.THERMOMETER: ThermometerSubcategory,
|
||||||
|
Category.HEART_RATE_SENSOR: HeartRateSensorSubcategory,
|
||||||
|
Category.BLOOD_PRESSURE: BloodPressureSubcategory,
|
||||||
|
Category.HUMAN_INTERFACE_DEVICE: HumanInterfaceDeviceSubcategory,
|
||||||
|
Category.GLUCOSE_METER: GlucoseMeterSubcategory,
|
||||||
|
Category.RUNNING_WALKING_SENSOR: RunningWalkingSensorSubcategory,
|
||||||
|
Category.CYCLING: CyclingSubcategory,
|
||||||
|
Category.CONTROL_DEVICE: ControlDeviceSubcategory,
|
||||||
|
Category.NETWORK_DEVICE: NetworkDeviceSubcategory,
|
||||||
|
Category.SENSOR: SensorSubcategory,
|
||||||
|
Category.LIGHT_FIXTURES: LightFixturesSubcategory,
|
||||||
|
Category.FAN: FanSubcategory,
|
||||||
|
Category.HVAC: HvacSubcategory,
|
||||||
|
Category.AIR_CONDITIONING: AirConditioningSubcategory,
|
||||||
|
Category.HUMIDIFIER: HumidifierSubcategory,
|
||||||
|
Category.HEATING: HeatingSubcategory,
|
||||||
|
Category.ACCESS_CONTROL: AccessControlSubcategory,
|
||||||
|
Category.MOTORIZED_DEVICE: MotorizedDeviceSubcategory,
|
||||||
|
Category.POWER_DEVICE: PowerDeviceSubcategory,
|
||||||
|
Category.LIGHT_SOURCE: LightSourceSubcategory,
|
||||||
|
Category.WINDOW_COVERING: WindowCoveringSubcategory,
|
||||||
|
Category.AUDIO_SINK: AudioSinkSubcategory,
|
||||||
|
Category.AUDIO_SOURCE: AudioSourceSubcategory,
|
||||||
|
Category.MOTORIZED_VEHICLE: MotorizedVehicleSubcategory,
|
||||||
|
Category.DOMESTIC_APPLIANCE: DomesticApplianceSubcategory,
|
||||||
|
Category.WEARABLE_AUDIO_DEVICE: WearableAudioDeviceSubcategory,
|
||||||
|
Category.AIRCRAFT: AircraftSubcategory,
|
||||||
|
Category.AV_EQUIPMENT: AvEquipmentSubcategory,
|
||||||
|
Category.DISPLAY_EQUIPMENT: DisplayEquipmentSubcategory,
|
||||||
|
Category.HEARING_AID: HearingAidSubcategory,
|
||||||
|
Category.GAMING: GamingSubcategory,
|
||||||
|
Category.SIGNAGE: SignageSubcategory,
|
||||||
|
Category.PULSE_OXIMETER: PulseOximeterSubcategory,
|
||||||
|
Category.WEIGHT_SCALE: WeightScaleSubcategory,
|
||||||
|
Category.PERSONAL_MOBILITY_DEVICE: PersonalMobilityDeviceSubcategory,
|
||||||
|
Category.CONTINUOUS_GLUCOSE_MONITOR: ContinuousGlucoseMonitorSubcategory,
|
||||||
|
Category.INSULIN_PUMP: InsulinPumpSubcategory,
|
||||||
|
Category.MEDICATION_DELIVERY: MedicationDeliverySubcategory,
|
||||||
|
Category.SPIROMETER: SpirometerSubcategory,
|
||||||
|
Category.OUTDOOR_SPORTS_ACTIVITY: OutdoorSportsActivitySubcategory,
|
||||||
|
}
|
||||||
|
|
||||||
|
category: Category
|
||||||
|
subcategory: enum.IntEnum
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_int(cls, appearance: int) -> Self:
|
||||||
|
category = cls.Category(appearance >> 6)
|
||||||
|
return cls(category, appearance & 0x3F)
|
||||||
|
|
||||||
|
def __init__(self, category: Category, subcategory: int) -> None:
|
||||||
|
self.category = category
|
||||||
|
if subcategory_class := self.SUBCATEGORY_CLASSES.get(category):
|
||||||
|
self.subcategory = subcategory_class(subcategory)
|
||||||
|
else:
|
||||||
|
self.subcategory = self._OpenSubcategory(subcategory)
|
||||||
|
|
||||||
|
def __int__(self) -> int:
|
||||||
|
return self.category << 6 | self.subcategory
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
'Appearance('
|
||||||
|
f'category={self.category.name}, '
|
||||||
|
f'subcategory={self.subcategory.name}'
|
||||||
|
')'
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f'{self.category.name}/{self.subcategory.name}'
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Advertising Data
|
# Advertising Data
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
AdvertisingObject = Union[
|
AdvertisingDataObject = Union[
|
||||||
List[UUID], Tuple[UUID, bytes], bytes, str, int, Tuple[int, int], Tuple[int, bytes]
|
List[UUID],
|
||||||
|
Tuple[UUID, bytes],
|
||||||
|
bytes,
|
||||||
|
str,
|
||||||
|
int,
|
||||||
|
Tuple[int, int],
|
||||||
|
Tuple[int, bytes],
|
||||||
|
Appearance,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -704,109 +1265,115 @@ class AdvertisingData:
|
|||||||
# fmt: off
|
# fmt: off
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
|
|
||||||
# This list is only partial, it still needs to be filled in from the spec
|
FLAGS = 0x01
|
||||||
FLAGS = 0x01
|
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x02
|
||||||
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x02
|
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x03
|
||||||
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x03
|
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x04
|
||||||
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x04
|
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x05
|
||||||
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x05
|
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x06
|
||||||
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x06
|
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x07
|
||||||
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x07
|
SHORTENED_LOCAL_NAME = 0x08
|
||||||
SHORTENED_LOCAL_NAME = 0x08
|
COMPLETE_LOCAL_NAME = 0x09
|
||||||
COMPLETE_LOCAL_NAME = 0x09
|
TX_POWER_LEVEL = 0x0A
|
||||||
TX_POWER_LEVEL = 0x0A
|
CLASS_OF_DEVICE = 0x0D
|
||||||
CLASS_OF_DEVICE = 0x0D
|
SIMPLE_PAIRING_HASH_C = 0x0E
|
||||||
SIMPLE_PAIRING_HASH_C = 0x0E
|
SIMPLE_PAIRING_HASH_C_192 = 0x0E
|
||||||
SIMPLE_PAIRING_HASH_C_192 = 0x0E
|
SIMPLE_PAIRING_RANDOMIZER_R = 0x0F
|
||||||
SIMPLE_PAIRING_RANDOMIZER_R = 0x0F
|
SIMPLE_PAIRING_RANDOMIZER_R_192 = 0x0F
|
||||||
SIMPLE_PAIRING_RANDOMIZER_R_192 = 0x0F
|
DEVICE_ID = 0x10
|
||||||
DEVICE_ID = 0x10
|
SECURITY_MANAGER_TK_VALUE = 0x10
|
||||||
SECURITY_MANAGER_TK_VALUE = 0x10
|
SECURITY_MANAGER_OUT_OF_BAND_FLAGS = 0x11
|
||||||
SECURITY_MANAGER_OUT_OF_BAND_FLAGS = 0x11
|
PERIPHERAL_CONNECTION_INTERVAL_RANGE = 0x12
|
||||||
PERIPHERAL_CONNECTION_INTERVAL_RANGE = 0x12
|
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS = 0x14
|
||||||
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS = 0x14
|
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS = 0x15
|
||||||
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS = 0x15
|
SERVICE_DATA = 0x16
|
||||||
SERVICE_DATA = 0x16
|
SERVICE_DATA_16_BIT_UUID = 0x16
|
||||||
SERVICE_DATA_16_BIT_UUID = 0x16
|
PUBLIC_TARGET_ADDRESS = 0x17
|
||||||
PUBLIC_TARGET_ADDRESS = 0x17
|
RANDOM_TARGET_ADDRESS = 0x18
|
||||||
RANDOM_TARGET_ADDRESS = 0x18
|
APPEARANCE = 0x19
|
||||||
APPEARANCE = 0x19
|
ADVERTISING_INTERVAL = 0x1A
|
||||||
ADVERTISING_INTERVAL = 0x1A
|
LE_BLUETOOTH_DEVICE_ADDRESS = 0x1B
|
||||||
LE_BLUETOOTH_DEVICE_ADDRESS = 0x1B
|
LE_ROLE = 0x1C
|
||||||
LE_ROLE = 0x1C
|
SIMPLE_PAIRING_HASH_C_256 = 0x1D
|
||||||
SIMPLE_PAIRING_HASH_C_256 = 0x1D
|
SIMPLE_PAIRING_RANDOMIZER_R_256 = 0x1E
|
||||||
SIMPLE_PAIRING_RANDOMIZER_R_256 = 0x1E
|
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS = 0x1F
|
||||||
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS = 0x1F
|
SERVICE_DATA_32_BIT_UUID = 0x20
|
||||||
SERVICE_DATA_32_BIT_UUID = 0x20
|
SERVICE_DATA_128_BIT_UUID = 0x21
|
||||||
SERVICE_DATA_128_BIT_UUID = 0x21
|
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE = 0x22
|
||||||
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE = 0x22
|
LE_SECURE_CONNECTIONS_RANDOM_VALUE = 0x23
|
||||||
LE_SECURE_CONNECTIONS_RANDOM_VALUE = 0x23
|
URI = 0x24
|
||||||
URI = 0x24
|
INDOOR_POSITIONING = 0x25
|
||||||
INDOOR_POSITIONING = 0x25
|
TRANSPORT_DISCOVERY_DATA = 0x26
|
||||||
TRANSPORT_DISCOVERY_DATA = 0x26
|
LE_SUPPORTED_FEATURES = 0x27
|
||||||
LE_SUPPORTED_FEATURES = 0x27
|
CHANNEL_MAP_UPDATE_INDICATION = 0x28
|
||||||
CHANNEL_MAP_UPDATE_INDICATION = 0x28
|
PB_ADV = 0x29
|
||||||
PB_ADV = 0x29
|
MESH_MESSAGE = 0x2A
|
||||||
MESH_MESSAGE = 0x2A
|
MESH_BEACON = 0x2B
|
||||||
MESH_BEACON = 0x2B
|
BIGINFO = 0x2C
|
||||||
BIGINFO = 0x2C
|
BROADCAST_CODE = 0x2D
|
||||||
BROADCAST_CODE = 0x2D
|
RESOLVABLE_SET_IDENTIFIER = 0x2E
|
||||||
RESOLVABLE_SET_IDENTIFIER = 0x2E
|
ADVERTISING_INTERVAL_LONG = 0x2F
|
||||||
ADVERTISING_INTERVAL_LONG = 0x2F
|
BROADCAST_NAME = 0x30
|
||||||
THREE_D_INFORMATION_DATA = 0x3D
|
ENCRYPTED_ADVERTISING_DATA = 0X31
|
||||||
MANUFACTURER_SPECIFIC_DATA = 0xFF
|
PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION = 0X32
|
||||||
|
ELECTRONIC_SHELF_LABEL = 0X34
|
||||||
|
THREE_D_INFORMATION_DATA = 0x3D
|
||||||
|
MANUFACTURER_SPECIFIC_DATA = 0xFF
|
||||||
|
|
||||||
AD_TYPE_NAMES = {
|
AD_TYPE_NAMES = {
|
||||||
FLAGS: 'FLAGS',
|
FLAGS: 'FLAGS',
|
||||||
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
|
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
|
||||||
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
|
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
|
||||||
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
|
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
|
||||||
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
|
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
|
||||||
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
|
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
|
||||||
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
|
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
|
||||||
SHORTENED_LOCAL_NAME: 'SHORTENED_LOCAL_NAME',
|
SHORTENED_LOCAL_NAME: 'SHORTENED_LOCAL_NAME',
|
||||||
COMPLETE_LOCAL_NAME: 'COMPLETE_LOCAL_NAME',
|
COMPLETE_LOCAL_NAME: 'COMPLETE_LOCAL_NAME',
|
||||||
TX_POWER_LEVEL: 'TX_POWER_LEVEL',
|
TX_POWER_LEVEL: 'TX_POWER_LEVEL',
|
||||||
CLASS_OF_DEVICE: 'CLASS_OF_DEVICE',
|
CLASS_OF_DEVICE: 'CLASS_OF_DEVICE',
|
||||||
SIMPLE_PAIRING_HASH_C: 'SIMPLE_PAIRING_HASH_C',
|
SIMPLE_PAIRING_HASH_C: 'SIMPLE_PAIRING_HASH_C',
|
||||||
SIMPLE_PAIRING_HASH_C_192: 'SIMPLE_PAIRING_HASH_C_192',
|
SIMPLE_PAIRING_HASH_C_192: 'SIMPLE_PAIRING_HASH_C_192',
|
||||||
SIMPLE_PAIRING_RANDOMIZER_R: 'SIMPLE_PAIRING_RANDOMIZER_R',
|
SIMPLE_PAIRING_RANDOMIZER_R: 'SIMPLE_PAIRING_RANDOMIZER_R',
|
||||||
SIMPLE_PAIRING_RANDOMIZER_R_192: 'SIMPLE_PAIRING_RANDOMIZER_R_192',
|
SIMPLE_PAIRING_RANDOMIZER_R_192: 'SIMPLE_PAIRING_RANDOMIZER_R_192',
|
||||||
DEVICE_ID: 'DEVICE_ID',
|
DEVICE_ID: 'DEVICE_ID',
|
||||||
SECURITY_MANAGER_TK_VALUE: 'SECURITY_MANAGER_TK_VALUE',
|
SECURITY_MANAGER_TK_VALUE: 'SECURITY_MANAGER_TK_VALUE',
|
||||||
SECURITY_MANAGER_OUT_OF_BAND_FLAGS: 'SECURITY_MANAGER_OUT_OF_BAND_FLAGS',
|
SECURITY_MANAGER_OUT_OF_BAND_FLAGS: 'SECURITY_MANAGER_OUT_OF_BAND_FLAGS',
|
||||||
PERIPHERAL_CONNECTION_INTERVAL_RANGE: 'PERIPHERAL_CONNECTION_INTERVAL_RANGE',
|
PERIPHERAL_CONNECTION_INTERVAL_RANGE: 'PERIPHERAL_CONNECTION_INTERVAL_RANGE',
|
||||||
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS',
|
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS',
|
||||||
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS',
|
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS',
|
||||||
SERVICE_DATA: 'SERVICE_DATA',
|
SERVICE_DATA_16_BIT_UUID: 'SERVICE_DATA_16_BIT_UUID',
|
||||||
SERVICE_DATA_16_BIT_UUID: 'SERVICE_DATA_16_BIT_UUID',
|
PUBLIC_TARGET_ADDRESS: 'PUBLIC_TARGET_ADDRESS',
|
||||||
PUBLIC_TARGET_ADDRESS: 'PUBLIC_TARGET_ADDRESS',
|
RANDOM_TARGET_ADDRESS: 'RANDOM_TARGET_ADDRESS',
|
||||||
RANDOM_TARGET_ADDRESS: 'RANDOM_TARGET_ADDRESS',
|
APPEARANCE: 'APPEARANCE',
|
||||||
APPEARANCE: 'APPEARANCE',
|
ADVERTISING_INTERVAL: 'ADVERTISING_INTERVAL',
|
||||||
ADVERTISING_INTERVAL: 'ADVERTISING_INTERVAL',
|
LE_BLUETOOTH_DEVICE_ADDRESS: 'LE_BLUETOOTH_DEVICE_ADDRESS',
|
||||||
LE_BLUETOOTH_DEVICE_ADDRESS: 'LE_BLUETOOTH_DEVICE_ADDRESS',
|
LE_ROLE: 'LE_ROLE',
|
||||||
LE_ROLE: 'LE_ROLE',
|
SIMPLE_PAIRING_HASH_C_256: 'SIMPLE_PAIRING_HASH_C_256',
|
||||||
SIMPLE_PAIRING_HASH_C_256: 'SIMPLE_PAIRING_HASH_C_256',
|
SIMPLE_PAIRING_RANDOMIZER_R_256: 'SIMPLE_PAIRING_RANDOMIZER_R_256',
|
||||||
SIMPLE_PAIRING_RANDOMIZER_R_256: 'SIMPLE_PAIRING_RANDOMIZER_R_256',
|
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS',
|
||||||
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS',
|
SERVICE_DATA_32_BIT_UUID: 'SERVICE_DATA_32_BIT_UUID',
|
||||||
SERVICE_DATA_32_BIT_UUID: 'SERVICE_DATA_32_BIT_UUID',
|
SERVICE_DATA_128_BIT_UUID: 'SERVICE_DATA_128_BIT_UUID',
|
||||||
SERVICE_DATA_128_BIT_UUID: 'SERVICE_DATA_128_BIT_UUID',
|
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE: 'LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE',
|
||||||
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE: 'LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE',
|
LE_SECURE_CONNECTIONS_RANDOM_VALUE: 'LE_SECURE_CONNECTIONS_RANDOM_VALUE',
|
||||||
LE_SECURE_CONNECTIONS_RANDOM_VALUE: 'LE_SECURE_CONNECTIONS_RANDOM_VALUE',
|
URI: 'URI',
|
||||||
URI: 'URI',
|
INDOOR_POSITIONING: 'INDOOR_POSITIONING',
|
||||||
INDOOR_POSITIONING: 'INDOOR_POSITIONING',
|
TRANSPORT_DISCOVERY_DATA: 'TRANSPORT_DISCOVERY_DATA',
|
||||||
TRANSPORT_DISCOVERY_DATA: 'TRANSPORT_DISCOVERY_DATA',
|
LE_SUPPORTED_FEATURES: 'LE_SUPPORTED_FEATURES',
|
||||||
LE_SUPPORTED_FEATURES: 'LE_SUPPORTED_FEATURES',
|
CHANNEL_MAP_UPDATE_INDICATION: 'CHANNEL_MAP_UPDATE_INDICATION',
|
||||||
CHANNEL_MAP_UPDATE_INDICATION: 'CHANNEL_MAP_UPDATE_INDICATION',
|
PB_ADV: 'PB_ADV',
|
||||||
PB_ADV: 'PB_ADV',
|
MESH_MESSAGE: 'MESH_MESSAGE',
|
||||||
MESH_MESSAGE: 'MESH_MESSAGE',
|
MESH_BEACON: 'MESH_BEACON',
|
||||||
MESH_BEACON: 'MESH_BEACON',
|
BIGINFO: 'BIGINFO',
|
||||||
BIGINFO: 'BIGINFO',
|
BROADCAST_CODE: 'BROADCAST_CODE',
|
||||||
BROADCAST_CODE: 'BROADCAST_CODE',
|
RESOLVABLE_SET_IDENTIFIER: 'RESOLVABLE_SET_IDENTIFIER',
|
||||||
RESOLVABLE_SET_IDENTIFIER: 'RESOLVABLE_SET_IDENTIFIER',
|
ADVERTISING_INTERVAL_LONG: 'ADVERTISING_INTERVAL_LONG',
|
||||||
ADVERTISING_INTERVAL_LONG: 'ADVERTISING_INTERVAL_LONG',
|
BROADCAST_NAME: 'BROADCAST_NAME',
|
||||||
THREE_D_INFORMATION_DATA: 'THREE_D_INFORMATION_DATA',
|
ENCRYPTED_ADVERTISING_DATA: 'ENCRYPTED_ADVERTISING_DATA',
|
||||||
MANUFACTURER_SPECIFIC_DATA: 'MANUFACTURER_SPECIFIC_DATA'
|
PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION: 'PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION',
|
||||||
|
ELECTRONIC_SHELF_LABEL: 'ELECTRONIC_SHELF_LABEL',
|
||||||
|
THREE_D_INFORMATION_DATA: 'THREE_D_INFORMATION_DATA',
|
||||||
|
MANUFACTURER_SPECIFIC_DATA: 'MANUFACTURER_SPECIFIC_DATA'
|
||||||
}
|
}
|
||||||
|
|
||||||
LE_LIMITED_DISCOVERABLE_MODE_FLAG = 0x01
|
LE_LIMITED_DISCOVERABLE_MODE_FLAG = 0x01
|
||||||
@@ -915,7 +1482,11 @@ class AdvertisingData:
|
|||||||
ad_data_str = f'company={company_name}, data={ad_data[2:].hex()}'
|
ad_data_str = f'company={company_name}, data={ad_data[2:].hex()}'
|
||||||
elif ad_type == AdvertisingData.APPEARANCE:
|
elif ad_type == AdvertisingData.APPEARANCE:
|
||||||
ad_type_str = 'Appearance'
|
ad_type_str = 'Appearance'
|
||||||
ad_data_str = ad_data.hex()
|
appearance = Appearance.from_int(struct.unpack_from('<H', ad_data, 0)[0])
|
||||||
|
ad_data_str = str(appearance)
|
||||||
|
elif ad_type == AdvertisingData.BROADCAST_NAME:
|
||||||
|
ad_type_str = 'Broadcast Name'
|
||||||
|
ad_data_str = ad_data.decode('utf-8')
|
||||||
else:
|
else:
|
||||||
ad_type_str = AdvertisingData.AD_TYPE_NAMES.get(ad_type, f'0x{ad_type:02X}')
|
ad_type_str = AdvertisingData.AD_TYPE_NAMES.get(ad_type, f'0x{ad_type:02X}')
|
||||||
ad_data_str = ad_data.hex()
|
ad_data_str = ad_data.hex()
|
||||||
@@ -924,7 +1495,7 @@ class AdvertisingData:
|
|||||||
|
|
||||||
# pylint: disable=too-many-return-statements
|
# pylint: disable=too-many-return-statements
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def ad_data_to_object(ad_type: int, ad_data: bytes) -> AdvertisingObject:
|
def ad_data_to_object(ad_type: int, ad_data: bytes) -> AdvertisingDataObject:
|
||||||
if ad_type in (
|
if ad_type in (
|
||||||
AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||||
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||||
@@ -959,16 +1530,14 @@ class AdvertisingData:
|
|||||||
AdvertisingData.SHORTENED_LOCAL_NAME,
|
AdvertisingData.SHORTENED_LOCAL_NAME,
|
||||||
AdvertisingData.COMPLETE_LOCAL_NAME,
|
AdvertisingData.COMPLETE_LOCAL_NAME,
|
||||||
AdvertisingData.URI,
|
AdvertisingData.URI,
|
||||||
|
AdvertisingData.BROADCAST_NAME,
|
||||||
):
|
):
|
||||||
return ad_data.decode("utf-8")
|
return ad_data.decode("utf-8")
|
||||||
|
|
||||||
if ad_type in (AdvertisingData.TX_POWER_LEVEL, AdvertisingData.FLAGS):
|
if ad_type in (AdvertisingData.TX_POWER_LEVEL, AdvertisingData.FLAGS):
|
||||||
return cast(int, struct.unpack('B', ad_data)[0])
|
return cast(int, struct.unpack('B', ad_data)[0])
|
||||||
|
|
||||||
if ad_type in (
|
if ad_type in (AdvertisingData.ADVERTISING_INTERVAL,):
|
||||||
AdvertisingData.APPEARANCE,
|
|
||||||
AdvertisingData.ADVERTISING_INTERVAL,
|
|
||||||
):
|
|
||||||
return cast(int, struct.unpack('<H', ad_data)[0])
|
return cast(int, struct.unpack('<H', ad_data)[0])
|
||||||
|
|
||||||
if ad_type == AdvertisingData.CLASS_OF_DEVICE:
|
if ad_type == AdvertisingData.CLASS_OF_DEVICE:
|
||||||
@@ -980,6 +1549,11 @@ class AdvertisingData:
|
|||||||
if ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
|
if ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
|
||||||
return (cast(int, struct.unpack_from('<H', ad_data, 0)[0]), ad_data[2:])
|
return (cast(int, struct.unpack_from('<H', ad_data, 0)[0]), ad_data[2:])
|
||||||
|
|
||||||
|
if ad_type == AdvertisingData.APPEARANCE:
|
||||||
|
return Appearance.from_int(
|
||||||
|
cast(int, struct.unpack_from('<H', ad_data, 0)[0])
|
||||||
|
)
|
||||||
|
|
||||||
return ad_data
|
return ad_data
|
||||||
|
|
||||||
def append(self, data: bytes) -> None:
|
def append(self, data: bytes) -> None:
|
||||||
@@ -993,27 +1567,27 @@ class AdvertisingData:
|
|||||||
self.ad_structures.append((ad_type, ad_data))
|
self.ad_structures.append((ad_type, ad_data))
|
||||||
offset += length
|
offset += length
|
||||||
|
|
||||||
def get_all(self, type_id: int, raw: bool = False) -> List[AdvertisingObject]:
|
def get_all(self, type_id: int, raw: bool = False) -> List[AdvertisingDataObject]:
|
||||||
'''
|
'''
|
||||||
Get Advertising Data Structure(s) with a given type
|
Get Advertising Data Structure(s) with a given type
|
||||||
|
|
||||||
Returns a (possibly empty) list of matches.
|
Returns a (possibly empty) list of matches.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def process_ad_data(ad_data: bytes) -> AdvertisingObject:
|
def process_ad_data(ad_data: bytes) -> AdvertisingDataObject:
|
||||||
return ad_data if raw else self.ad_data_to_object(type_id, ad_data)
|
return ad_data if raw else self.ad_data_to_object(type_id, ad_data)
|
||||||
|
|
||||||
return [process_ad_data(ad[1]) for ad in self.ad_structures if ad[0] == type_id]
|
return [process_ad_data(ad[1]) for ad in self.ad_structures if ad[0] == type_id]
|
||||||
|
|
||||||
def get(self, type_id: int, raw: bool = False) -> Optional[AdvertisingObject]:
|
def get(self, type_id: int, raw: bool = False) -> Optional[AdvertisingDataObject]:
|
||||||
'''
|
'''
|
||||||
Get Advertising Data Structure(s) with a given type
|
Get Advertising Data Structure(s) with a given type
|
||||||
|
|
||||||
Returns the first entry, or None if no structure matches.
|
Returns the first entry, or None if no structure matches.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
all = self.get_all(type_id, raw=raw)
|
all_objects = self.get_all(type_id, raw=raw)
|
||||||
return all[0] if all else None
|
return all_objects[0] if all_objects else None
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self):
|
||||||
return b''.join(
|
return b''.join(
|
||||||
|
|||||||
453
bumble/device.py
453
bumble/device.py
@@ -16,22 +16,21 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from enum import IntEnum
|
|
||||||
import copy
|
|
||||||
import functools
|
|
||||||
import json
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
from collections.abc import Iterable
|
||||||
import secrets
|
|
||||||
import sys
|
|
||||||
from contextlib import (
|
from contextlib import (
|
||||||
asynccontextmanager,
|
asynccontextmanager,
|
||||||
AsyncExitStack,
|
AsyncExitStack,
|
||||||
closing,
|
closing,
|
||||||
AbstractAsyncContextManager,
|
|
||||||
)
|
)
|
||||||
|
import copy
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from collections.abc import Iterable
|
from enum import Enum, IntEnum
|
||||||
|
import functools
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
@@ -81,6 +80,7 @@ from .hci import (
|
|||||||
HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS,
|
HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS,
|
||||||
HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS,
|
HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS,
|
||||||
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
||||||
|
HCI_OPERATION_CANCELLED_BY_HOST_ERROR,
|
||||||
HCI_R2_PAGE_SCAN_REPETITION_MODE,
|
HCI_R2_PAGE_SCAN_REPETITION_MODE,
|
||||||
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
@@ -102,11 +102,16 @@ from .hci import (
|
|||||||
HCI_LE_Accept_CIS_Request_Command,
|
HCI_LE_Accept_CIS_Request_Command,
|
||||||
HCI_LE_Add_Device_To_Resolving_List_Command,
|
HCI_LE_Add_Device_To_Resolving_List_Command,
|
||||||
HCI_LE_Advertising_Report_Event,
|
HCI_LE_Advertising_Report_Event,
|
||||||
|
HCI_LE_BIGInfo_Advertising_Report_Event,
|
||||||
HCI_LE_Clear_Resolving_List_Command,
|
HCI_LE_Clear_Resolving_List_Command,
|
||||||
HCI_LE_Connection_Update_Command,
|
HCI_LE_Connection_Update_Command,
|
||||||
HCI_LE_Create_Connection_Cancel_Command,
|
HCI_LE_Create_Connection_Cancel_Command,
|
||||||
HCI_LE_Create_Connection_Command,
|
HCI_LE_Create_Connection_Command,
|
||||||
HCI_LE_Create_CIS_Command,
|
HCI_LE_Create_CIS_Command,
|
||||||
|
HCI_LE_Periodic_Advertising_Create_Sync_Command,
|
||||||
|
HCI_LE_Periodic_Advertising_Create_Sync_Cancel_Command,
|
||||||
|
HCI_LE_Periodic_Advertising_Report_Event,
|
||||||
|
HCI_LE_Periodic_Advertising_Terminate_Sync_Command,
|
||||||
HCI_LE_Enable_Encryption_Command,
|
HCI_LE_Enable_Encryption_Command,
|
||||||
HCI_LE_Extended_Advertising_Report_Event,
|
HCI_LE_Extended_Advertising_Report_Event,
|
||||||
HCI_LE_Extended_Create_Connection_Command,
|
HCI_LE_Extended_Create_Connection_Command,
|
||||||
@@ -248,6 +253,8 @@ DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS = l2cap.L2CAP_LE_CREDIT_BASED_CONN
|
|||||||
DEVICE_DEFAULT_ADVERTISING_TX_POWER = (
|
DEVICE_DEFAULT_ADVERTISING_TX_POWER = (
|
||||||
HCI_LE_Set_Extended_Advertising_Parameters_Command.TX_POWER_NO_PREFERENCE
|
HCI_LE_Set_Extended_Advertising_Parameters_Command.TX_POWER_NO_PREFERENCE
|
||||||
)
|
)
|
||||||
|
DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_SKIP = 0
|
||||||
|
DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_TIMEOUT = 5.0
|
||||||
|
|
||||||
# fmt: on
|
# fmt: on
|
||||||
# pylint: enable=line-too-long
|
# pylint: enable=line-too-long
|
||||||
@@ -552,6 +559,70 @@ class AdvertisingEventProperties:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
|
class PeriodicAdvertisement:
|
||||||
|
address: Address
|
||||||
|
sid: int
|
||||||
|
tx_power: int = (
|
||||||
|
HCI_LE_Periodic_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
|
||||||
|
)
|
||||||
|
rssi: int = HCI_LE_Periodic_Advertising_Report_Event.RSSI_NOT_AVAILABLE
|
||||||
|
is_truncated: bool = False
|
||||||
|
data_bytes: bytes = b''
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
TX_POWER_NOT_AVAILABLE: ClassVar[int] = (
|
||||||
|
HCI_LE_Periodic_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
|
||||||
|
)
|
||||||
|
RSSI_NOT_AVAILABLE: ClassVar[int] = (
|
||||||
|
HCI_LE_Periodic_Advertising_Report_Event.RSSI_NOT_AVAILABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self.data = (
|
||||||
|
None if self.is_truncated else AdvertisingData.from_bytes(self.data_bytes)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
|
class BIGInfoAdvertisement:
|
||||||
|
address: Address
|
||||||
|
sid: int
|
||||||
|
num_bis: int
|
||||||
|
nse: int
|
||||||
|
iso_interval: int
|
||||||
|
bn: int
|
||||||
|
pto: int
|
||||||
|
irc: int
|
||||||
|
max_pdu: int
|
||||||
|
sdu_interval: int
|
||||||
|
max_sdu: int
|
||||||
|
phy: Phy
|
||||||
|
framed: bool
|
||||||
|
encrypted: bool
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_report(cls, address: Address, sid: int, report) -> Self:
|
||||||
|
return cls(
|
||||||
|
address,
|
||||||
|
sid,
|
||||||
|
report.num_bis,
|
||||||
|
report.nse,
|
||||||
|
report.iso_interval,
|
||||||
|
report.bn,
|
||||||
|
report.pto,
|
||||||
|
report.irc,
|
||||||
|
report.max_pdu,
|
||||||
|
report.sdu_interval,
|
||||||
|
report.max_sdu,
|
||||||
|
Phy(report.phy),
|
||||||
|
report.framing != 0,
|
||||||
|
report.encryption != 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# TODO: replace with typing.TypeAlias when the code base is all Python >= 3.10
|
# TODO: replace with typing.TypeAlias when the code base is all Python >= 3.10
|
||||||
AdvertisingChannelMap = HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap
|
AdvertisingChannelMap = HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap
|
||||||
@@ -795,6 +866,201 @@ class AdvertisingSet(EventEmitter):
|
|||||||
self.emit('termination', status)
|
self.emit('termination', status)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class PeriodicAdvertisingSync(EventEmitter):
|
||||||
|
class State(Enum):
|
||||||
|
INIT = 0
|
||||||
|
PENDING = 1
|
||||||
|
ESTABLISHED = 2
|
||||||
|
CANCELLED = 3
|
||||||
|
ERROR = 4
|
||||||
|
LOST = 5
|
||||||
|
TERMINATED = 6
|
||||||
|
|
||||||
|
_state: State
|
||||||
|
sync_handle: Optional[int]
|
||||||
|
advertiser_address: Address
|
||||||
|
sid: int
|
||||||
|
skip: int
|
||||||
|
sync_timeout: float # Sync timeout, in seconds
|
||||||
|
filter_duplicates: bool
|
||||||
|
status: int
|
||||||
|
advertiser_phy: int
|
||||||
|
periodic_advertising_interval: int
|
||||||
|
advertiser_clock_accuracy: int
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device: Device,
|
||||||
|
advertiser_address: Address,
|
||||||
|
sid: int,
|
||||||
|
skip: int,
|
||||||
|
sync_timeout: float,
|
||||||
|
filter_duplicates: bool,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._state = self.State.INIT
|
||||||
|
self.sync_handle = None
|
||||||
|
self.device = device
|
||||||
|
self.advertiser_address = advertiser_address
|
||||||
|
self.sid = sid
|
||||||
|
self.skip = skip
|
||||||
|
self.sync_timeout = sync_timeout
|
||||||
|
self.filter_duplicates = filter_duplicates
|
||||||
|
self.status = HCI_SUCCESS
|
||||||
|
self.advertiser_phy = 0
|
||||||
|
self.periodic_advertising_interval = 0
|
||||||
|
self.advertiser_clock_accuracy = 0
|
||||||
|
self.data_accumulator = b''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> State:
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
@state.setter
|
||||||
|
def state(self, state: State) -> None:
|
||||||
|
logger.debug(f'{self} -> {state.name}')
|
||||||
|
self._state = state
|
||||||
|
self.emit('state_change')
|
||||||
|
|
||||||
|
async def establish(self) -> None:
|
||||||
|
if self.state != self.State.INIT:
|
||||||
|
raise InvalidStateError('sync not in init state')
|
||||||
|
|
||||||
|
options = HCI_LE_Periodic_Advertising_Create_Sync_Command.Options(0)
|
||||||
|
if self.filter_duplicates:
|
||||||
|
options |= (
|
||||||
|
HCI_LE_Periodic_Advertising_Create_Sync_Command.Options.DUPLICATE_FILTERING_INITIALLY_ENABLED
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await self.device.send_command(
|
||||||
|
HCI_LE_Periodic_Advertising_Create_Sync_Command(
|
||||||
|
options=options,
|
||||||
|
advertising_sid=self.sid,
|
||||||
|
advertiser_address_type=self.advertiser_address.address_type,
|
||||||
|
advertiser_address=self.advertiser_address,
|
||||||
|
skip=self.skip,
|
||||||
|
sync_timeout=int(self.sync_timeout * 100),
|
||||||
|
sync_cte_type=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if response.status != HCI_Command_Status_Event.PENDING:
|
||||||
|
raise HCI_StatusError(response)
|
||||||
|
|
||||||
|
self.state = self.State.PENDING
|
||||||
|
|
||||||
|
async def terminate(self) -> None:
|
||||||
|
if self.state in (self.State.INIT, self.State.CANCELLED, self.State.TERMINATED):
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.state == self.State.PENDING:
|
||||||
|
self.state = self.State.CANCELLED
|
||||||
|
response = await self.device.send_command(
|
||||||
|
HCI_LE_Periodic_Advertising_Create_Sync_Cancel_Command(),
|
||||||
|
)
|
||||||
|
if response.status == HCI_SUCCESS:
|
||||||
|
if self in self.device.periodic_advertising_syncs:
|
||||||
|
self.device.periodic_advertising_syncs.remove(self)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.state in (self.State.ESTABLISHED, self.State.ERROR, self.State.LOST):
|
||||||
|
self.state = self.State.TERMINATED
|
||||||
|
await self.device.send_command(
|
||||||
|
HCI_LE_Periodic_Advertising_Terminate_Sync_Command(
|
||||||
|
sync_handle=self.sync_handle
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.device.periodic_advertising_syncs.remove(self)
|
||||||
|
|
||||||
|
def on_establishment(
|
||||||
|
self,
|
||||||
|
status,
|
||||||
|
sync_handle,
|
||||||
|
advertiser_phy,
|
||||||
|
periodic_advertising_interval,
|
||||||
|
advertiser_clock_accuracy,
|
||||||
|
) -> None:
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
if self.state == self.State.CANCELLED:
|
||||||
|
# Somehow, we receive an established event after trying to cancel, most
|
||||||
|
# likely because the cancel command was sent too late, when the sync was
|
||||||
|
# already established, but before the established event was sent.
|
||||||
|
# We need to automatically terminate.
|
||||||
|
logger.debug(
|
||||||
|
"received established event for cancelled sync, will terminate"
|
||||||
|
)
|
||||||
|
self.state = self.State.ESTABLISHED
|
||||||
|
AsyncRunner.spawn(self.terminate())
|
||||||
|
return
|
||||||
|
|
||||||
|
if status == HCI_SUCCESS:
|
||||||
|
self.sync_handle = sync_handle
|
||||||
|
self.advertiser_phy = advertiser_phy
|
||||||
|
self.periodic_advertising_interval = periodic_advertising_interval
|
||||||
|
self.advertiser_clock_accuracy = advertiser_clock_accuracy
|
||||||
|
self.state = self.State.ESTABLISHED
|
||||||
|
self.emit('establishment')
|
||||||
|
return
|
||||||
|
|
||||||
|
# We don't need to keep a reference anymore
|
||||||
|
if self in self.device.periodic_advertising_syncs:
|
||||||
|
self.device.periodic_advertising_syncs.remove(self)
|
||||||
|
|
||||||
|
if status == HCI_OPERATION_CANCELLED_BY_HOST_ERROR:
|
||||||
|
self.state = self.State.CANCELLED
|
||||||
|
self.emit('cancellation')
|
||||||
|
return
|
||||||
|
|
||||||
|
self.state = self.State.ERROR
|
||||||
|
self.emit('error')
|
||||||
|
|
||||||
|
def on_loss(self):
|
||||||
|
self.state = self.State.LOST
|
||||||
|
self.emit('loss')
|
||||||
|
|
||||||
|
def on_periodic_advertising_report(self, report) -> None:
|
||||||
|
self.data_accumulator += report.data
|
||||||
|
if (
|
||||||
|
report.data_status
|
||||||
|
== HCI_LE_Periodic_Advertising_Report_Event.DataStatus.DATA_INCOMPLETE_MORE_TO_COME
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
self.emit(
|
||||||
|
'periodic_advertisement',
|
||||||
|
PeriodicAdvertisement(
|
||||||
|
self.advertiser_address,
|
||||||
|
self.sid,
|
||||||
|
report.tx_power,
|
||||||
|
report.rssi,
|
||||||
|
is_truncated=(
|
||||||
|
report.data_status
|
||||||
|
== HCI_LE_Periodic_Advertising_Report_Event.DataStatus.DATA_INCOMPLETE_TRUNCATED_NO_MORE_TO_COME
|
||||||
|
),
|
||||||
|
data_bytes=self.data_accumulator,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.data_accumulator = b''
|
||||||
|
|
||||||
|
def on_biginfo_advertising_report(self, report) -> None:
|
||||||
|
self.emit(
|
||||||
|
'biginfo_advertisement',
|
||||||
|
BIGInfoAdvertisement.from_report(self.advertiser_address, self.sid, report),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return (
|
||||||
|
'PeriodicAdvertisingSync('
|
||||||
|
f'state={self.state.name}, '
|
||||||
|
f'sync_handle={self.sync_handle}, '
|
||||||
|
f'sid={self.sid}, '
|
||||||
|
f'skip={self.skip}, '
|
||||||
|
f'filter_duplicates={self.filter_duplicates}'
|
||||||
|
')'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class LePhyOptions:
|
class LePhyOptions:
|
||||||
# Coded PHY preference
|
# Coded PHY preference
|
||||||
@@ -1409,6 +1675,20 @@ def try_with_connection_from_address(function):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# Decorator that converts the first argument from a sync handle to a periodic
|
||||||
|
# advertising sync object
|
||||||
|
def with_periodic_advertising_sync_from_handle(function):
|
||||||
|
@functools.wraps(function)
|
||||||
|
def wrapper(self, sync_handle, *args, **kwargs):
|
||||||
|
if (sync := self.lookup_periodic_advertising_sync(sync_handle)) is None:
|
||||||
|
raise ValueError(
|
||||||
|
f'no periodic advertising sync for handle: 0x{sync_handle:04x}'
|
||||||
|
)
|
||||||
|
return function(self, sync, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
# Decorator that adds a method to the list of event handlers for host events.
|
# Decorator that adds a method to the list of event handlers for host events.
|
||||||
# This assumes that the method name starts with `on_`
|
# This assumes that the method name starts with `on_`
|
||||||
def host_event_handler(function):
|
def host_event_handler(function):
|
||||||
@@ -1439,6 +1719,7 @@ class Device(CompositeEventEmitter):
|
|||||||
Address, List[asyncio.Future[Union[Connection, Tuple[Address, int, int]]]]
|
Address, List[asyncio.Future[Union[Connection, Tuple[Address, int, int]]]]
|
||||||
]
|
]
|
||||||
advertisement_accumulators: Dict[Address, AdvertisementDataAccumulator]
|
advertisement_accumulators: Dict[Address, AdvertisementDataAccumulator]
|
||||||
|
periodic_advertising_syncs: List[PeriodicAdvertisingSync]
|
||||||
config: DeviceConfiguration
|
config: DeviceConfiguration
|
||||||
legacy_advertiser: Optional[LegacyAdvertiser]
|
legacy_advertiser: Optional[LegacyAdvertiser]
|
||||||
sco_links: Dict[int, ScoLink]
|
sco_links: Dict[int, ScoLink]
|
||||||
@@ -1524,6 +1805,7 @@ class Device(CompositeEventEmitter):
|
|||||||
[l2cap.L2CAP_Information_Request.EXTENDED_FEATURE_FIXED_CHANNELS]
|
[l2cap.L2CAP_Information_Request.EXTENDED_FEATURE_FIXED_CHANNELS]
|
||||||
)
|
)
|
||||||
self.advertisement_accumulators = {} # Accumulators, by address
|
self.advertisement_accumulators = {} # Accumulators, by address
|
||||||
|
self.periodic_advertising_syncs = []
|
||||||
self.scanning = False
|
self.scanning = False
|
||||||
self.scanning_is_passive = False
|
self.scanning_is_passive = False
|
||||||
self.discovering = False
|
self.discovering = False
|
||||||
@@ -1574,6 +1856,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
|
||||||
@@ -1706,6 +1989,18 @@ class Device(CompositeEventEmitter):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def lookup_periodic_advertising_sync(
|
||||||
|
self, sync_handle: int
|
||||||
|
) -> Optional[PeriodicAdvertisingSync]:
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
sync
|
||||||
|
for sync in self.periodic_advertising_syncs
|
||||||
|
if sync.sync_handle == sync_handle
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
@deprecated("Please use create_l2cap_server()")
|
@deprecated("Please use create_l2cap_server()")
|
||||||
def register_l2cap_server(self, psm, server) -> int:
|
def register_l2cap_server(self, psm, server) -> int:
|
||||||
return self.l2cap_channel_manager.register_server(psm, server)
|
return self.l2cap_channel_manager.register_server(psm, server)
|
||||||
@@ -2368,6 +2663,116 @@ class Device(CompositeEventEmitter):
|
|||||||
if advertisement := accumulator.update(report):
|
if advertisement := accumulator.update(report):
|
||||||
self.emit('advertisement', advertisement)
|
self.emit('advertisement', advertisement)
|
||||||
|
|
||||||
|
async def create_periodic_advertising_sync(
|
||||||
|
self,
|
||||||
|
advertiser_address: Address,
|
||||||
|
sid: int,
|
||||||
|
skip: int = DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_SKIP,
|
||||||
|
sync_timeout: float = DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_TIMEOUT,
|
||||||
|
filter_duplicates: bool = False,
|
||||||
|
) -> PeriodicAdvertisingSync:
|
||||||
|
# Check that there isn't already an equivalent entry
|
||||||
|
if any(
|
||||||
|
sync.advertiser_address == advertiser_address and sync.sid == sid
|
||||||
|
for sync in self.periodic_advertising_syncs
|
||||||
|
):
|
||||||
|
raise ValueError("equivalent entry already created")
|
||||||
|
|
||||||
|
# Create a new entry
|
||||||
|
sync = PeriodicAdvertisingSync(
|
||||||
|
device=self,
|
||||||
|
advertiser_address=advertiser_address,
|
||||||
|
sid=sid,
|
||||||
|
skip=skip,
|
||||||
|
sync_timeout=sync_timeout,
|
||||||
|
filter_duplicates=filter_duplicates,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.periodic_advertising_syncs.append(sync)
|
||||||
|
|
||||||
|
# Check if any sync should be started
|
||||||
|
await self._update_periodic_advertising_syncs()
|
||||||
|
|
||||||
|
return sync
|
||||||
|
|
||||||
|
async def _update_periodic_advertising_syncs(self) -> None:
|
||||||
|
# Check if there's already a pending sync
|
||||||
|
if any(
|
||||||
|
sync.state == PeriodicAdvertisingSync.State.PENDING
|
||||||
|
for sync in self.periodic_advertising_syncs
|
||||||
|
):
|
||||||
|
logger.debug("at least one sync pending, nothing to update yet")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Start the next sync that's waiting to be started
|
||||||
|
if ready := next(
|
||||||
|
(
|
||||||
|
sync
|
||||||
|
for sync in self.periodic_advertising_syncs
|
||||||
|
if sync.state == PeriodicAdvertisingSync.State.INIT
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
):
|
||||||
|
await ready.establish()
|
||||||
|
return
|
||||||
|
|
||||||
|
@host_event_handler
|
||||||
|
def on_periodic_advertising_sync_establishment(
|
||||||
|
self,
|
||||||
|
status: int,
|
||||||
|
sync_handle: int,
|
||||||
|
advertising_sid: int,
|
||||||
|
advertiser_address: Address,
|
||||||
|
advertiser_phy: int,
|
||||||
|
periodic_advertising_interval: int,
|
||||||
|
advertiser_clock_accuracy: int,
|
||||||
|
) -> None:
|
||||||
|
for periodic_advertising_sync in self.periodic_advertising_syncs:
|
||||||
|
if (
|
||||||
|
periodic_advertising_sync.advertiser_address == advertiser_address
|
||||||
|
and periodic_advertising_sync.sid == advertising_sid
|
||||||
|
):
|
||||||
|
periodic_advertising_sync.on_establishment(
|
||||||
|
status,
|
||||||
|
sync_handle,
|
||||||
|
advertiser_phy,
|
||||||
|
periodic_advertising_interval,
|
||||||
|
advertiser_clock_accuracy,
|
||||||
|
)
|
||||||
|
|
||||||
|
AsyncRunner.spawn(self._update_periodic_advertising_syncs())
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"periodic advertising sync establishment for unknown address/sid"
|
||||||
|
)
|
||||||
|
|
||||||
|
@host_event_handler
|
||||||
|
@with_periodic_advertising_sync_from_handle
|
||||||
|
def on_periodic_advertising_sync_loss(
|
||||||
|
self, periodic_advertising_sync: PeriodicAdvertisingSync
|
||||||
|
):
|
||||||
|
periodic_advertising_sync.on_loss()
|
||||||
|
|
||||||
|
@host_event_handler
|
||||||
|
@with_periodic_advertising_sync_from_handle
|
||||||
|
def on_periodic_advertising_report(
|
||||||
|
self,
|
||||||
|
periodic_advertising_sync: PeriodicAdvertisingSync,
|
||||||
|
report: HCI_LE_Periodic_Advertising_Report_Event,
|
||||||
|
):
|
||||||
|
periodic_advertising_sync.on_periodic_advertising_report(report)
|
||||||
|
|
||||||
|
@host_event_handler
|
||||||
|
@with_periodic_advertising_sync_from_handle
|
||||||
|
def on_biginfo_advertising_report(
|
||||||
|
self,
|
||||||
|
periodic_advertising_sync: PeriodicAdvertisingSync,
|
||||||
|
report: HCI_LE_BIGInfo_Advertising_Report_Event,
|
||||||
|
):
|
||||||
|
periodic_advertising_sync.on_biginfo_advertising_report(report)
|
||||||
|
|
||||||
async def start_discovery(self, auto_restart: bool = True) -> None:
|
async def start_discovery(self, auto_restart: bool = True) -> None:
|
||||||
await self.send_command(
|
await self.send_command(
|
||||||
HCI_Write_Inquiry_Mode_Command(inquiry_mode=HCI_EXTENDED_INQUIRY_MODE),
|
HCI_Write_Inquiry_Mode_Command(inquiry_mode=HCI_EXTENDED_INQUIRY_MODE),
|
||||||
@@ -3605,14 +4010,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
|
||||||
)
|
)
|
||||||
@@ -3743,6 +4162,16 @@ class Device(CompositeEventEmitter):
|
|||||||
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):
|
||||||
|
|||||||
262
bumble/hci.py
262
bumble/hci.py
@@ -26,8 +26,8 @@ import struct
|
|||||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Type, Union, ClassVar
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Type, Union, ClassVar
|
||||||
|
|
||||||
from bumble import crypto
|
from bumble import crypto
|
||||||
from .colors import color
|
from bumble.colors import color
|
||||||
from .core import (
|
from bumble.core import (
|
||||||
BT_BR_EDR_TRANSPORT,
|
BT_BR_EDR_TRANSPORT,
|
||||||
AdvertisingData,
|
AdvertisingData,
|
||||||
DeviceClass,
|
DeviceClass,
|
||||||
@@ -36,6 +36,7 @@ from .core import (
|
|||||||
name_or_number,
|
name_or_number,
|
||||||
padded_bytes,
|
padded_bytes,
|
||||||
)
|
)
|
||||||
|
from bumble.utils import OpenIntEnum
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -1104,7 +1105,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
|
||||||
@@ -1380,7 +1381,7 @@ class LmpFeatureMask(enum.IntFlag):
|
|||||||
STATUS_SPEC = {'size': 1, 'mapper': lambda x: HCI_Constant.status_name(x)}
|
STATUS_SPEC = {'size': 1, 'mapper': lambda x: HCI_Constant.status_name(x)}
|
||||||
|
|
||||||
|
|
||||||
class CodecID(enum.IntEnum):
|
class CodecID(OpenIntEnum):
|
||||||
# fmt: off
|
# fmt: off
|
||||||
U_LOG = 0x00
|
U_LOG = 0x00
|
||||||
A_LOG = 0x01
|
A_LOG = 0x01
|
||||||
@@ -1967,6 +1968,9 @@ class Address:
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.to_string()
|
return self.to_string()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'Address({self.to_string(False)}/{self.address_type_name(self.address_type)})'
|
||||||
|
|
||||||
|
|
||||||
# Predefined address values
|
# Predefined address values
|
||||||
Address.NIL = Address(b"\xff\xff\xff\xff\xff\xff", Address.PUBLIC_DEVICE_ADDRESS)
|
Address.NIL = Address(b"\xff\xff\xff\xff\xff\xff", Address.PUBLIC_DEVICE_ADDRESS)
|
||||||
@@ -4452,6 +4456,80 @@ class HCI_LE_Extended_Create_Connection_Command(HCI_Command):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
'options',
|
||||||
|
{
|
||||||
|
'size': 1,
|
||||||
|
'mapper': lambda x: HCI_LE_Periodic_Advertising_Create_Sync_Command.Options(
|
||||||
|
x
|
||||||
|
).name,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
('advertising_sid', 1),
|
||||||
|
('advertiser_address_type', Address.ADDRESS_TYPE_SPEC),
|
||||||
|
('advertiser_address', Address.parse_address_preceded_by_type),
|
||||||
|
('skip', 2),
|
||||||
|
('sync_timeout', 2),
|
||||||
|
(
|
||||||
|
'sync_cte_type',
|
||||||
|
{
|
||||||
|
'size': 1,
|
||||||
|
'mapper': lambda x: HCI_LE_Periodic_Advertising_Create_Sync_Command.CteType(
|
||||||
|
x
|
||||||
|
).name,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class HCI_LE_Periodic_Advertising_Create_Sync_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.67 LE Periodic Advertising Create Sync command
|
||||||
|
'''
|
||||||
|
|
||||||
|
class Options(enum.IntFlag):
|
||||||
|
USE_PERIODIC_ADVERTISER_LIST = 1 << 0
|
||||||
|
REPORTING_INITIALLY_DISABLED = 1 << 1
|
||||||
|
DUPLICATE_FILTERING_INITIALLY_ENABLED = 1 << 2
|
||||||
|
|
||||||
|
class CteType(enum.IntFlag):
|
||||||
|
DO_NOT_SYNC_TO_PACKETS_WITH_AN_AOA_CONSTANT_TONE_EXTENSION = 1 << 0
|
||||||
|
DO_NOT_SYNC_TO_PACKETS_WITH_AN_AOD_CONSTANT_TONE_EXTENSION_1US = 1 << 1
|
||||||
|
DO_NOT_SYNC_TO_PACKETS_WITH_AN_AOD_CONSTANT_TONE_EXTENSION_2US = 1 << 2
|
||||||
|
DO_NOT_SYNC_TO_PACKETS_WITH_A_TYPE_3_CONSTANT_TONE_EXTENSION = 1 << 3
|
||||||
|
DO_NOT_SYNC_TO_PACKETS_WITHOUT_A_CONSTANT_TONE_EXTENSION = 1 << 4
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command()
|
||||||
|
class HCI_LE_Periodic_Advertising_Create_Sync_Cancel_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.68 LE Periodic Advertising Create Sync Cancel Command
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command([('sync_handle', 2)])
|
||||||
|
class HCI_LE_Periodic_Advertising_Terminate_Sync_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.69 LE Periodic Advertising Terminate Sync Command
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command([('sync_handle', 2), ('enable', 1)])
|
||||||
|
class HCI_LE_Set_Periodic_Advertising_Receive_Enable_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.88 LE Set Periodic Advertising Receive Enable Command
|
||||||
|
'''
|
||||||
|
|
||||||
|
class Enable(enum.IntFlag):
|
||||||
|
REPORTING_ENABLED = 1 << 0
|
||||||
|
DUPLICATE_FILTERING_ENABLED = 1 << 1
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command(
|
@HCI_Command.command(
|
||||||
[
|
[
|
||||||
@@ -4487,14 +4565,6 @@ class HCI_LE_Set_Privacy_Mode_Command(HCI_Command):
|
|||||||
return name_or_number(cls.PRIVACY_MODE_NAMES, privacy_mode)
|
return name_or_number(cls.PRIVACY_MODE_NAMES, privacy_mode)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
@HCI_Command.command([('bit_number', 1), ('bit_value', 1)])
|
|
||||||
class HCI_LE_Set_Host_Feature_Command(HCI_Command):
|
|
||||||
'''
|
|
||||||
See Bluetooth spec @ 7.8.115 LE Set Host Feature Command
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command(
|
@HCI_Command.command(
|
||||||
fields=[
|
fields=[
|
||||||
@@ -4655,6 +4725,14 @@ class HCI_LE_Remove_ISO_Data_Path_Command(HCI_Command):
|
|||||||
data_path_direction: int
|
data_path_direction: int
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command([('bit_number', 1), ('bit_value', 1)])
|
||||||
|
class HCI_LE_Set_Host_Feature_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.115 LE Set Host Feature Command
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# HCI Events
|
# HCI Events
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -5271,6 +5349,142 @@ HCI_LE_Meta_Event.subevent_classes[HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_LE_Meta_Event.event(
|
||||||
|
[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('sync_handle', 2),
|
||||||
|
('advertising_sid', 1),
|
||||||
|
('advertiser_address_type', Address.ADDRESS_TYPE_SPEC),
|
||||||
|
('advertiser_address', Address.parse_address_preceded_by_type),
|
||||||
|
('advertiser_phy', {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
|
||||||
|
('periodic_advertising_interval', 2),
|
||||||
|
('advertiser_clock_accuracy', 1),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class HCI_LE_Periodic_Advertising_Sync_Established_Event(HCI_LE_Meta_Event):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.7.65.14 LE Periodic Advertising Sync Established Event
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_LE_Meta_Event.event(
|
||||||
|
[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('sync_handle', 2),
|
||||||
|
('advertising_sid', 1),
|
||||||
|
('advertiser_address_type', Address.ADDRESS_TYPE_SPEC),
|
||||||
|
('advertiser_address', Address.parse_address_preceded_by_type),
|
||||||
|
('advertiser_phy', {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
|
||||||
|
('periodic_advertising_interval', 2),
|
||||||
|
('advertiser_clock_accuracy', 1),
|
||||||
|
('num_subevents', 1),
|
||||||
|
('subevent_interval', 1),
|
||||||
|
('response_slot_delay', 1),
|
||||||
|
('response_slot_spacing', 1),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class HCI_LE_Periodic_Advertising_Sync_Established_V2_Event(HCI_LE_Meta_Event):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.7.65.14 LE Periodic Advertising Sync Established Event
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_LE_Meta_Event.event(
|
||||||
|
[
|
||||||
|
('sync_handle', 2),
|
||||||
|
('tx_power', -1),
|
||||||
|
('rssi', -1),
|
||||||
|
(
|
||||||
|
'cte_type',
|
||||||
|
{
|
||||||
|
'size': 1,
|
||||||
|
'mapper': lambda x: HCI_LE_Periodic_Advertising_Report_Event.CteType(
|
||||||
|
x
|
||||||
|
).name,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'data_status',
|
||||||
|
{
|
||||||
|
'size': 1,
|
||||||
|
'mapper': lambda x: HCI_LE_Periodic_Advertising_Report_Event.DataStatus(
|
||||||
|
x
|
||||||
|
).name,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
('data', 'v'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class HCI_LE_Periodic_Advertising_Report_Event(HCI_LE_Meta_Event):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.7.65.15 LE Periodic Advertising Report Event
|
||||||
|
'''
|
||||||
|
|
||||||
|
TX_POWER_INFORMATION_NOT_AVAILABLE = 0x7F
|
||||||
|
RSSI_NOT_AVAILABLE = 0x7F
|
||||||
|
|
||||||
|
class CteType(OpenIntEnum):
|
||||||
|
AOA_CONSTANT_TONE_EXTENSION = 0x00
|
||||||
|
AOD_CONSTANT_TONE_EXTENSION_1US = 0x01
|
||||||
|
AOD_CONSTANT_TONE_EXTENSION_2US = 0x02
|
||||||
|
NO_CONSTANT_TONE_EXTENSION = 0xFF
|
||||||
|
|
||||||
|
class DataStatus(OpenIntEnum):
|
||||||
|
DATA_COMPLETE = 0x00
|
||||||
|
DATA_INCOMPLETE_MORE_TO_COME = 0x01
|
||||||
|
DATA_INCOMPLETE_TRUNCATED_NO_MORE_TO_COME = 0x02
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_LE_Meta_Event.event(
|
||||||
|
[
|
||||||
|
('sync_handle', 2),
|
||||||
|
('tx_power', -1),
|
||||||
|
('rssi', -1),
|
||||||
|
(
|
||||||
|
'cte_type',
|
||||||
|
{
|
||||||
|
'size': 1,
|
||||||
|
'mapper': lambda x: HCI_LE_Periodic_Advertising_Report_Event.CteType(
|
||||||
|
x
|
||||||
|
).name,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
('periodic_event_counter', 2),
|
||||||
|
('subevent', 1),
|
||||||
|
(
|
||||||
|
'data_status',
|
||||||
|
{
|
||||||
|
'size': 1,
|
||||||
|
'mapper': lambda x: HCI_LE_Periodic_Advertising_Report_Event.DataStatus(
|
||||||
|
x
|
||||||
|
).name,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
('data', 'v'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class HCI_LE_Periodic_Advertising_Report_V2_Event(HCI_LE_Meta_Event):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.7.65.15 LE Periodic Advertising Report Event
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_LE_Meta_Event.event(
|
||||||
|
[
|
||||||
|
('sync_handle', 2),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class HCI_LE_Periodic_Advertising_Sync_Lost_Event(HCI_LE_Meta_Event):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.7.65.16 LE Periodic Advertising Sync Lost Event
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_LE_Meta_Event.event(
|
@HCI_LE_Meta_Event.event(
|
||||||
[
|
[
|
||||||
@@ -5336,6 +5550,30 @@ class HCI_LE_CIS_Request_Event(HCI_LE_Meta_Event):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_LE_Meta_Event.event(
|
||||||
|
[
|
||||||
|
('sync_handle', 2),
|
||||||
|
('num_bis', 1),
|
||||||
|
('nse', 1),
|
||||||
|
('iso_interval', 2),
|
||||||
|
('bn', 1),
|
||||||
|
('pto', 1),
|
||||||
|
('irc', 1),
|
||||||
|
('max_pdu', 2),
|
||||||
|
('sdu_interval', 3),
|
||||||
|
('max_sdu', 2),
|
||||||
|
('phy', {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
|
||||||
|
('framing', 1),
|
||||||
|
('encryption', 1),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class HCI_LE_BIGInfo_Advertising_Report_Event(HCI_LE_Meta_Event):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.7.65.34 LE BIGInfo Advertising Report Event
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Event.event([('status', STATUS_SPEC)])
|
@HCI_Event.event([('status', STATUS_SPEC)])
|
||||||
class HCI_Inquiry_Complete_Event(HCI_Event):
|
class HCI_Inquiry_Complete_Event(HCI_Event):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -905,6 +909,27 @@ class Host(AbortableEventEmitter):
|
|||||||
event.num_completed_extended_advertising_events,
|
event.num_completed_extended_advertising_events,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_hci_le_periodic_advertising_sync_established_event(self, event):
|
||||||
|
self.emit(
|
||||||
|
'periodic_advertising_sync_establishment',
|
||||||
|
event.status,
|
||||||
|
event.sync_handle,
|
||||||
|
event.advertising_sid,
|
||||||
|
event.advertiser_address,
|
||||||
|
event.advertiser_phy,
|
||||||
|
event.periodic_advertising_interval,
|
||||||
|
event.advertiser_clock_accuracy,
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_hci_le_periodic_advertising_sync_lost_event(self, event):
|
||||||
|
self.emit('periodic_advertising_sync_loss', event.sync_handle)
|
||||||
|
|
||||||
|
def on_hci_le_periodic_advertising_report_event(self, event):
|
||||||
|
self.emit('periodic_advertising_report', event.sync_handle, event)
|
||||||
|
|
||||||
|
def on_hci_le_biginfo_advertising_report_event(self, event):
|
||||||
|
self.emit('biginfo_advertising_report', event.sync_handle, event)
|
||||||
|
|
||||||
def on_hci_le_cis_request_event(self, event):
|
def on_hci_le_cis_request_event(self, event):
|
||||||
self.emit(
|
self.emit(
|
||||||
'cis_request',
|
'cis_request',
|
||||||
|
|||||||
@@ -70,6 +70,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
|
||||||
|
|
||||||
@@ -832,7 +833,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
|
||||||
|
|
||||||
@@ -2225,7 +2228,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
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from bumble.core import (
|
|||||||
BT_PERIPHERAL_ROLE,
|
BT_PERIPHERAL_ROLE,
|
||||||
UUID,
|
UUID,
|
||||||
AdvertisingData,
|
AdvertisingData,
|
||||||
|
Appearance,
|
||||||
ConnectionError,
|
ConnectionError,
|
||||||
)
|
)
|
||||||
from bumble.device import (
|
from bumble.device import (
|
||||||
@@ -988,8 +989,8 @@ class HostService(HostServicer):
|
|||||||
dt.random_target_addresses.extend(
|
dt.random_target_addresses.extend(
|
||||||
[data[i * 6 :: i * 6 + 6] for i in range(int(len(data) / 6))]
|
[data[i * 6 :: i * 6 + 6] for i in range(int(len(data) / 6))]
|
||||||
)
|
)
|
||||||
if i := cast(int, ad.get(AdvertisingData.APPEARANCE)):
|
if appearance := cast(Appearance, ad.get(AdvertisingData.APPEARANCE)):
|
||||||
dt.appearance = i
|
dt.appearance = int(appearance)
|
||||||
if i := cast(int, ad.get(AdvertisingData.ADVERTISING_INTERVAL)):
|
if i := cast(int, ad.get(AdvertisingData.ADVERTISING_INTERVAL)):
|
||||||
dt.advertising_interval = i
|
dt.advertising_interval = i
|
||||||
if s := cast(str, ad.get(AdvertisingData.URI)):
|
if s := cast(str, ad.get(AdvertisingData.URI)):
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import struct
|
|||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, List, Union, Type, Dict, Any, Tuple
|
from typing import Optional, List, Union, Type, Dict, Any, Tuple
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
from bumble import core
|
from bumble import core
|
||||||
from bumble import colors
|
from bumble import colors
|
||||||
@@ -32,6 +33,8 @@ from bumble import device
|
|||||||
from bumble import hci
|
from bumble import hci
|
||||||
from bumble import gatt
|
from bumble import gatt
|
||||||
from bumble import gatt_client
|
from bumble import gatt_client
|
||||||
|
from bumble import utils
|
||||||
|
from bumble.profiles import le_audio
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -115,7 +118,7 @@ class ContextType(enum.IntFlag):
|
|||||||
EMERGENCY_ALARM = 0x0800
|
EMERGENCY_ALARM = 0x0800
|
||||||
|
|
||||||
|
|
||||||
class SamplingFrequency(enum.IntEnum):
|
class SamplingFrequency(utils.OpenIntEnum):
|
||||||
'''Bluetooth Assigned Numbers, Section 6.12.5.1 - Sampling Frequency'''
|
'''Bluetooth Assigned Numbers, Section 6.12.5.1 - Sampling Frequency'''
|
||||||
|
|
||||||
# fmt: off
|
# fmt: off
|
||||||
@@ -240,7 +243,7 @@ class SupportedFrameDuration(enum.IntFlag):
|
|||||||
DURATION_10000_US_PREFERRED = 0b0010
|
DURATION_10000_US_PREFERRED = 0b0010
|
||||||
|
|
||||||
|
|
||||||
class AnnouncementType(enum.IntEnum):
|
class AnnouncementType(utils.OpenIntEnum):
|
||||||
'''Basic Audio Profile, 3.5.3. Additional Audio Stream Control Service requirements'''
|
'''Basic Audio Profile, 3.5.3. Additional Audio Stream Control Service requirements'''
|
||||||
|
|
||||||
# fmt: off
|
# fmt: off
|
||||||
@@ -613,7 +616,7 @@ class CodecSpecificConfiguration:
|
|||||||
* Basic Audio Profile, 4.3.2 - Codec_Specific_Capabilities LTV requirements
|
* Basic Audio Profile, 4.3.2 - Codec_Specific_Capabilities LTV requirements
|
||||||
'''
|
'''
|
||||||
|
|
||||||
class Type(enum.IntEnum):
|
class Type(utils.OpenIntEnum):
|
||||||
# fmt: off
|
# fmt: off
|
||||||
SAMPLING_FREQUENCY = 0x01
|
SAMPLING_FREQUENCY = 0x01
|
||||||
FRAME_DURATION = 0x02
|
FRAME_DURATION = 0x02
|
||||||
@@ -725,6 +728,99 @@ class PacRecord:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class BroadcastAudioAnnouncement:
|
||||||
|
broadcast_id: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> Self:
|
||||||
|
return cls(int.from_bytes(data[:3], 'little'))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class BasicAudioAnnouncement:
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class BIS:
|
||||||
|
index: int
|
||||||
|
codec_specific_configuration: CodecSpecificConfiguration
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class CodecInfo:
|
||||||
|
coding_format: hci.CodecID
|
||||||
|
company_id: int
|
||||||
|
vendor_specific_codec_id: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> Self:
|
||||||
|
coding_format = hci.CodecID(data[0])
|
||||||
|
company_id = int.from_bytes(data[1:3], 'little')
|
||||||
|
vendor_specific_codec_id = int.from_bytes(data[3:5], 'little')
|
||||||
|
return cls(coding_format, company_id, vendor_specific_codec_id)
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class Subgroup:
|
||||||
|
codec_id: BasicAudioAnnouncement.CodecInfo
|
||||||
|
codec_specific_configuration: CodecSpecificConfiguration
|
||||||
|
metadata: le_audio.Metadata
|
||||||
|
bis: List[BasicAudioAnnouncement.BIS]
|
||||||
|
|
||||||
|
presentation_delay: int
|
||||||
|
subgroups: List[BasicAudioAnnouncement.Subgroup]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> Self:
|
||||||
|
presentation_delay = int.from_bytes(data[:3], 'little')
|
||||||
|
subgroups = []
|
||||||
|
offset = 4
|
||||||
|
for _ in range(data[3]):
|
||||||
|
num_bis = data[offset]
|
||||||
|
offset += 1
|
||||||
|
codec_id = cls.CodecInfo.from_bytes(data[offset : offset + 5])
|
||||||
|
offset += 5
|
||||||
|
codec_specific_configuration_length = data[offset]
|
||||||
|
offset += 1
|
||||||
|
codec_specific_configuration = data[
|
||||||
|
offset : offset + codec_specific_configuration_length
|
||||||
|
]
|
||||||
|
offset += codec_specific_configuration_length
|
||||||
|
metadata_length = data[offset]
|
||||||
|
offset += 1
|
||||||
|
metadata = le_audio.Metadata.from_bytes(
|
||||||
|
data[offset : offset + metadata_length]
|
||||||
|
)
|
||||||
|
offset += metadata_length
|
||||||
|
|
||||||
|
bis = []
|
||||||
|
for _ in range(num_bis):
|
||||||
|
bis_index = data[offset]
|
||||||
|
offset += 1
|
||||||
|
bis_codec_specific_configuration_length = data[offset]
|
||||||
|
offset += 1
|
||||||
|
bis_codec_specific_configuration = data[
|
||||||
|
offset : offset + bis_codec_specific_configuration_length
|
||||||
|
]
|
||||||
|
offset += bis_codec_specific_configuration_length
|
||||||
|
bis.append(
|
||||||
|
cls.BIS(
|
||||||
|
bis_index,
|
||||||
|
CodecSpecificConfiguration.from_bytes(
|
||||||
|
bis_codec_specific_configuration
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
subgroups.append(
|
||||||
|
cls.Subgroup(
|
||||||
|
codec_id,
|
||||||
|
CodecSpecificConfiguration.from_bytes(codec_specific_configuration),
|
||||||
|
metadata,
|
||||||
|
bis,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(presentation_delay, subgroups)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Server
|
# Server
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -744,9 +840,9 @@ class PublishedAudioCapabilitiesService(gatt.TemplateService):
|
|||||||
supported_sink_context: ContextType,
|
supported_sink_context: ContextType,
|
||||||
available_source_context: ContextType,
|
available_source_context: ContextType,
|
||||||
available_sink_context: ContextType,
|
available_sink_context: ContextType,
|
||||||
sink_pac: Sequence[PacRecord] = [],
|
sink_pac: Sequence[PacRecord] = (),
|
||||||
sink_audio_locations: Optional[AudioLocation] = None,
|
sink_audio_locations: Optional[AudioLocation] = None,
|
||||||
source_pac: Sequence[PacRecord] = [],
|
source_pac: Sequence[PacRecord] = (),
|
||||||
source_audio_locations: Optional[AudioLocation] = None,
|
source_audio_locations: Optional[AudioLocation] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
characteristics = []
|
characteristics = []
|
||||||
|
|||||||
49
bumble/profiles/le_audio.py
Normal file
49
bumble/profiles/le_audio.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# 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
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
import dataclasses
|
||||||
|
from typing import List
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Classes
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class Metadata:
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class Entry:
|
||||||
|
tag: int
|
||||||
|
data: bytes
|
||||||
|
|
||||||
|
entries: List[Entry]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> Self:
|
||||||
|
entries = []
|
||||||
|
offset = 0
|
||||||
|
length = len(data)
|
||||||
|
while length >= 2:
|
||||||
|
entry_length = data[offset]
|
||||||
|
entry_tag = data[offset + 1]
|
||||||
|
entry_data = data[offset + 2 : offset + 2 + entry_length - 1]
|
||||||
|
entries.append(cls.Entry(entry_tag, entry_data))
|
||||||
|
length -= entry_length
|
||||||
|
offset += entry_length
|
||||||
|
|
||||||
|
return cls(entries)
|
||||||
46
bumble/profiles/pbp.py
Normal file
46
bumble/profiles/pbp.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# 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
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
import dataclasses
|
||||||
|
import enum
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
from bumble.profiles import le_audio
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Classes
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class PublicBroadcastAnnouncement:
|
||||||
|
class Features(enum.IntFlag):
|
||||||
|
ENCRYPTED = 1 << 0
|
||||||
|
STANDARD_QUALITY_CONFIGURATION = 1 << 1
|
||||||
|
HIGH_QUALITY_CONFIGURATION = 1 << 2
|
||||||
|
|
||||||
|
features: Features
|
||||||
|
metadata: le_audio.Metadata
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> Self:
|
||||||
|
features = cls.Features(data[0])
|
||||||
|
metadata_length = data[1]
|
||||||
|
metadata_ltv = data[1 : 1 + metadata_length]
|
||||||
|
return cls(
|
||||||
|
features=features, metadata=le_audio.Metadata.from_bytes(metadata_ltv)
|
||||||
|
)
|
||||||
219
bumble/rfcomm.py
219
bumble/rfcomm.py
@@ -106,9 +106,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
|
||||||
@@ -365,12 +367,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 +384,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 +398,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 +448,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 +530,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 +629,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 +651,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 +661,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
|
||||||
|
|
||||||
@@ -689,8 +722,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 +764,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 +776,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 +848,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 +886,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 +913,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 +961,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 +969,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 +991,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 +1074,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 +1088,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 +1114,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 +1133,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:
|
||||||
|
|||||||
@@ -997,7 +997,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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
21
tasks.py
21
tasks.py
@@ -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")
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from bumble.core import AdvertisingData, UUID, get_dict_key_by_value
|
from enum import IntEnum
|
||||||
|
|
||||||
|
from bumble.core import AdvertisingData, Appearance, UUID, get_dict_key_by_value
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -66,8 +68,35 @@ def test_uuid_to_hex_str() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_appearance() -> None:
|
||||||
|
a = Appearance(Appearance.Category.COMPUTER, Appearance.ComputerSubcategory.LAPTOP)
|
||||||
|
assert str(a) == 'COMPUTER/LAPTOP'
|
||||||
|
assert int(a) == 0x0083
|
||||||
|
|
||||||
|
a = Appearance(Appearance.Category.HUMAN_INTERFACE_DEVICE, 0x77)
|
||||||
|
assert str(a) == 'HUMAN_INTERFACE_DEVICE/HumanInterfaceDeviceSubcategory[119]'
|
||||||
|
assert int(a) == 0x03C0 | 0x77
|
||||||
|
|
||||||
|
a = Appearance.from_int(0x0381)
|
||||||
|
assert a.category == Appearance.Category.BLOOD_PRESSURE
|
||||||
|
assert a.subcategory == Appearance.BloodPressureSubcategory.ARM_BLOOD_PRESSURE
|
||||||
|
assert int(a) == 0x381
|
||||||
|
|
||||||
|
a = Appearance.from_int(0x038A)
|
||||||
|
assert a.category == Appearance.Category.BLOOD_PRESSURE
|
||||||
|
assert a.subcategory == 0x0A
|
||||||
|
assert int(a) == 0x038A
|
||||||
|
|
||||||
|
a = Appearance.from_int(0x3333)
|
||||||
|
assert a.category == 0xCC
|
||||||
|
assert a.subcategory == 0x33
|
||||||
|
assert int(a) == 0x3333
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
test_ad_data()
|
test_ad_data()
|
||||||
test_get_dict_key_by_value()
|
test_get_dict_key_by_value()
|
||||||
test_uuid_to_hex_str()
|
test_uuid_to_hex_str()
|
||||||
|
test_appearance()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
3
web/.gitignore
vendored
Normal file
3
web/.gitignore
vendored
Normal file
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user