mirror of
https://github.com/google/bumble.git
synced 2026-04-17 00:35:31 +00:00
Compare commits
43 Commits
v0.0.194
...
gbg/consol
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e4055bb6b | ||
|
|
4433184048 | ||
|
|
312fc8db36 | ||
|
|
615691ec81 | ||
|
|
ae8b83f294 | ||
|
|
4a8e21f4db | ||
|
|
3462e7c437 | ||
|
|
0f2e5239ad | ||
|
|
ee48cdc63f | ||
|
|
1c278bec93 | ||
|
|
6a51166af7 | ||
|
|
85d79fa914 | ||
|
|
142bdce94a | ||
|
|
881a5a64b5 | ||
|
|
5aae44b610 | ||
|
|
e3ea167827 | ||
|
|
eec145e095 | ||
|
|
87fa02d6e5 | ||
|
|
ad94c1e1f3 | ||
|
|
546a0bce8d | ||
|
|
cb7ca44a1c | ||
|
|
4081b93407 | ||
|
|
26203ebaad | ||
|
|
3389e3e1ed | ||
|
|
7e1f01c01e | ||
|
|
613e15548a | ||
|
|
e09c91df8e | ||
|
|
df206667b6 | ||
|
|
0f19dd5263 | ||
|
|
b98e4937f3 | ||
|
|
c2c46e9ace | ||
|
|
27791cf218 | ||
|
|
32a41a815d | ||
|
|
df5fc2ddfe | ||
|
|
79122313a6 | ||
|
|
d7d03e2e92 | ||
|
|
ea493480a9 | ||
|
|
658f641a53 | ||
|
|
f8a2d4f0e0 | ||
|
|
00edd1fbf8 | ||
|
|
999d7b07e1 | ||
|
|
2e3aeb8648 | ||
|
|
dea907be86 |
30
.devcontainer/devcontainer.json
Normal file
30
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,30 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||
{
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/universal:2",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand":
|
||||
"python -m pip install '.[build,test,development,documentation]'",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"ms-python.python"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
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
|
||||
100
apps/bench.py
100
apps/bench.py
@@ -40,6 +40,8 @@ from bumble.hci import (
|
||||
HCI_LE_1M_PHY,
|
||||
HCI_LE_2M_PHY,
|
||||
HCI_LE_CODED_PHY,
|
||||
HCI_CENTRAL_ROLE,
|
||||
HCI_PERIPHERAL_ROLE,
|
||||
HCI_Constant,
|
||||
HCI_Error,
|
||||
HCI_StatusError,
|
||||
@@ -57,6 +59,7 @@ from bumble.transport import open_transport_or_link
|
||||
import bumble.rfcomm
|
||||
import bumble.core
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.pairing import PairingConfig
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -128,40 +131,34 @@ def le_phy_name(phy_id):
|
||||
|
||||
|
||||
def print_connection(connection):
|
||||
params = []
|
||||
if connection.transport == BT_LE_TRANSPORT:
|
||||
phy_state = (
|
||||
params.append(
|
||||
'PHY='
|
||||
f'TX:{le_phy_name(connection.phy.tx_phy)}/'
|
||||
f'RX:{le_phy_name(connection.phy.rx_phy)}'
|
||||
)
|
||||
|
||||
data_length = (
|
||||
params.append(
|
||||
'DL=('
|
||||
f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
|
||||
f'RX:{connection.data_length[2]}/{connection.data_length[3]}'
|
||||
')'
|
||||
)
|
||||
connection_parameters = (
|
||||
|
||||
params.append(
|
||||
'Parameters='
|
||||
f'{connection.parameters.connection_interval * 1.25:.2f}/'
|
||||
f'{connection.parameters.peripheral_latency}/'
|
||||
f'{connection.parameters.supervision_timeout * 10} '
|
||||
)
|
||||
|
||||
params.append(f'MTU={connection.att_mtu}')
|
||||
|
||||
else:
|
||||
phy_state = ''
|
||||
data_length = ''
|
||||
connection_parameters = ''
|
||||
params.append(f'Role={HCI_Constant.role_name(connection.role)}')
|
||||
|
||||
mtu = connection.att_mtu
|
||||
|
||||
logging.info(
|
||||
f'{color("@@@ Connection:", "yellow")} '
|
||||
f'{connection_parameters} '
|
||||
f'{data_length} '
|
||||
f'{phy_state} '
|
||||
f'MTU={mtu}'
|
||||
)
|
||||
logging.info(color('@@@ Connection: ', 'yellow') + ' '.join(params))
|
||||
|
||||
|
||||
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):
|
||||
RESET = 0
|
||||
SEQUENCE = 1
|
||||
@@ -1034,6 +1042,10 @@ class RfcommServer(StreamedPacketIO):
|
||||
|
||||
def on_dlc(self, dlc):
|
||||
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
|
||||
self.io_sink = dlc.write
|
||||
self.dlc = dlc
|
||||
@@ -1063,6 +1075,7 @@ class Central(Connection.Listener):
|
||||
authenticate,
|
||||
encrypt,
|
||||
extended_data_length,
|
||||
role_switch,
|
||||
):
|
||||
super().__init__()
|
||||
self.transport = transport
|
||||
@@ -1073,6 +1086,7 @@ class Central(Connection.Listener):
|
||||
self.authenticate = authenticate
|
||||
self.encrypt = encrypt or authenticate
|
||||
self.extended_data_length = extended_data_length
|
||||
self.role_switch = role_switch
|
||||
self.device = None
|
||||
self.connection = None
|
||||
|
||||
@@ -1123,6 +1137,11 @@ class Central(Connection.Listener):
|
||||
role = self.role_factory(mode)
|
||||
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()
|
||||
|
||||
if self.classic:
|
||||
@@ -1151,6 +1170,10 @@ class Central(Connection.Listener):
|
||||
self.connection.listener = self
|
||||
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
|
||||
# we start sending data right away while some connection parameters are
|
||||
# updated post connection
|
||||
@@ -1212,20 +1235,30 @@ class Central(Connection.Listener):
|
||||
def on_connection_data_length_change(self):
|
||||
print_connection(self.connection)
|
||||
|
||||
def on_role_change(self):
|
||||
print_connection(self.connection)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Peripheral
|
||||
# -----------------------------------------------------------------------------
|
||||
class Peripheral(Device.Listener, Connection.Listener):
|
||||
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.classic = classic
|
||||
self.extended_data_length = extended_data_length
|
||||
self.role_factory = role_factory
|
||||
self.role = None
|
||||
self.mode_factory = mode_factory
|
||||
self.extended_data_length = extended_data_length
|
||||
self.role_switch = role_switch
|
||||
self.role = None
|
||||
self.mode = None
|
||||
self.device = None
|
||||
self.connection = None
|
||||
@@ -1248,6 +1281,11 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
self.role = self.role_factory(self.mode)
|
||||
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()
|
||||
|
||||
if self.classic:
|
||||
@@ -1274,6 +1312,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
|
||||
await self.connected.wait()
|
||||
logging.info(color('### Connected', 'cyan'))
|
||||
print_connection(self.connection)
|
||||
|
||||
await self.mode.on_connection(self.connection)
|
||||
await self.role.run()
|
||||
@@ -1290,7 +1329,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
AsyncRunner.spawn(self.device.set_connectable(False))
|
||||
|
||||
# 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")
|
||||
AsyncRunner.spawn(
|
||||
connection.set_data_length(
|
||||
@@ -1298,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):
|
||||
logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
|
||||
self.connection = None
|
||||
@@ -1319,6 +1362,9 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
def on_connection_data_length_change(self):
|
||||
print_connection(self.connection)
|
||||
|
||||
def on_role_change(self):
|
||||
print_connection(self.connection)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def create_mode_factory(ctx, default_mode):
|
||||
@@ -1448,6 +1494,11 @@ def create_role_factory(ctx, default_role):
|
||||
'--extended-data-length',
|
||||
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(
|
||||
'--rfcomm-channel',
|
||||
type=int,
|
||||
@@ -1512,7 +1563,7 @@ def create_role_factory(ctx, default_role):
|
||||
'--packet-size',
|
||||
'-s',
|
||||
metavar='SIZE',
|
||||
type=click.IntRange(8, 4096),
|
||||
type=click.IntRange(8, 8192),
|
||||
default=500,
|
||||
help='Packet size (client or ping role)',
|
||||
)
|
||||
@@ -1572,6 +1623,7 @@ def bench(
|
||||
mode,
|
||||
att_mtu,
|
||||
extended_data_length,
|
||||
role_switch,
|
||||
packet_size,
|
||||
packet_count,
|
||||
start_delay,
|
||||
@@ -1614,12 +1666,12 @@ def bench(
|
||||
ctx.obj['repeat_delay'] = repeat_delay
|
||||
ctx.obj['pace'] = pace
|
||||
ctx.obj['linger'] = linger
|
||||
|
||||
ctx.obj['extended_data_length'] = (
|
||||
[int(x) for x in extended_data_length.split('/')]
|
||||
if extended_data_length
|
||||
else None
|
||||
)
|
||||
ctx.obj['role_switch'] = role_switch
|
||||
ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server')
|
||||
|
||||
|
||||
@@ -1663,6 +1715,7 @@ def central(
|
||||
authenticate,
|
||||
encrypt or authenticate,
|
||||
ctx.obj['extended_data_length'],
|
||||
ctx.obj['role_switch'],
|
||||
).run()
|
||||
|
||||
asyncio.run(run_central())
|
||||
@@ -1679,10 +1732,11 @@ def peripheral(ctx, transport):
|
||||
async def run_peripheral():
|
||||
await Peripheral(
|
||||
transport,
|
||||
ctx.obj['classic'],
|
||||
ctx.obj['extended_data_length'],
|
||||
role_factory,
|
||||
mode_factory,
|
||||
ctx.obj['classic'],
|
||||
ctx.obj['extended_data_length'],
|
||||
ctx.obj['role_switch'],
|
||||
).run()
|
||||
|
||||
asyncio.run(run_peripheral())
|
||||
|
||||
@@ -63,6 +63,7 @@ from bumble.transport import open_transport_or_link
|
||||
from bumble.gatt import Characteristic, Service, CharacteristicDeclaration, Descriptor
|
||||
from bumble.gatt_client import CharacteristicProxy
|
||||
from bumble.hci import (
|
||||
Address,
|
||||
HCI_Constant,
|
||||
HCI_LE_1M_PHY,
|
||||
HCI_LE_2M_PHY,
|
||||
@@ -167,6 +168,7 @@ class ConsoleApp:
|
||||
'remote-services': None,
|
||||
'local-values': None,
|
||||
'remote-values': None,
|
||||
'remote-attributes': None,
|
||||
},
|
||||
'filter': {
|
||||
'address': None,
|
||||
@@ -215,6 +217,7 @@ class ConsoleApp:
|
||||
)
|
||||
self.local_values_text = FormattedTextControl()
|
||||
self.remote_values_text = FormattedTextControl()
|
||||
self.remote_attributes_text = FormattedTextControl()
|
||||
self.log_height = Dimension(min=7, weight=4)
|
||||
self.log_max_lines = 100
|
||||
self.log_lines = []
|
||||
@@ -241,6 +244,12 @@ class ConsoleApp:
|
||||
Frame(Window(self.remote_values_text), title='Remote Values'),
|
||||
filter=Condition(lambda: self.top_tab == 'remote-values'),
|
||||
),
|
||||
ConditionalContainer(
|
||||
Frame(
|
||||
Window(self.remote_attributes_text), title='Remote Attributes'
|
||||
),
|
||||
filter=Condition(lambda: self.top_tab == 'remote-attributes'),
|
||||
),
|
||||
ConditionalContainer(
|
||||
Frame(Window(self.log_text, height=self.log_height), title='Log'),
|
||||
filter=Condition(lambda: self.top_tab == 'log'),
|
||||
@@ -289,11 +298,7 @@ class ConsoleApp:
|
||||
device_config, hci_source, hci_sink
|
||||
)
|
||||
else:
|
||||
random_address = (
|
||||
f"{random.randint(192,255):02X}" # address is static random
|
||||
)
|
||||
for random_byte in random.sample(range(255), 5):
|
||||
random_address += f":{random_byte:02X}"
|
||||
random_address = Address.generate_static_address()
|
||||
self.append_to_log(f"Setting random address: {random_address}")
|
||||
self.device = Device.with_hci(
|
||||
'Bumble', random_address, hci_source, hci_sink
|
||||
@@ -503,19 +508,9 @@ class ConsoleApp:
|
||||
self.show_error('not connected')
|
||||
return
|
||||
|
||||
# Discover all services, characteristics and descriptors
|
||||
self.append_to_output('discovering services...')
|
||||
await self.connected_peer.discover_services()
|
||||
self.append_to_output(
|
||||
f'found {len(self.connected_peer.services)} services,'
|
||||
' discovering characteristics...'
|
||||
)
|
||||
await self.connected_peer.discover_characteristics()
|
||||
self.append_to_output('found characteristics, discovering descriptors...')
|
||||
for service in self.connected_peer.services:
|
||||
for characteristic in service.characteristics:
|
||||
await self.connected_peer.discover_descriptors(characteristic)
|
||||
self.append_to_output('discovery completed')
|
||||
self.append_to_output('Service Discovery starting...')
|
||||
await self.connected_peer.discover_all()
|
||||
self.append_to_output('Service Discovery done!')
|
||||
|
||||
self.show_remote_services(self.connected_peer.services)
|
||||
|
||||
@@ -529,7 +524,7 @@ class ConsoleApp:
|
||||
attributes = await self.connected_peer.discover_attributes()
|
||||
self.append_to_output(f'discovered {len(attributes)} attributes...')
|
||||
|
||||
self.show_attributes(attributes)
|
||||
await self.show_remote_attributes(attributes)
|
||||
|
||||
def find_remote_characteristic(self, param) -> Optional[CharacteristicProxy]:
|
||||
if not self.connected_peer:
|
||||
@@ -674,7 +669,6 @@ class ConsoleApp:
|
||||
connection_parameters_preferences=connection_parameters_preferences,
|
||||
timeout=DEFAULT_CONNECTION_TIMEOUT,
|
||||
)
|
||||
self.top_tab = 'services'
|
||||
except bumble.core.TimeoutError:
|
||||
self.show_error('connection timed out')
|
||||
|
||||
@@ -745,19 +739,20 @@ class ConsoleApp:
|
||||
'remote-services',
|
||||
'local-values',
|
||||
'remote-values',
|
||||
'remote-attributes',
|
||||
}:
|
||||
self.top_tab = params[0]
|
||||
self.ui.invalidate()
|
||||
|
||||
while self.top_tab == 'local-values':
|
||||
await self.do_show_local_values()
|
||||
await self.show_local_values()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
while self.top_tab == 'remote-values':
|
||||
await self.do_show_remote_values()
|
||||
await self.show_remote_values()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def do_show_local_values(self):
|
||||
async def show_local_values(self):
|
||||
prettytable = PrettyTable()
|
||||
field_names = ["Service", "Characteristic", "Descriptor"]
|
||||
|
||||
@@ -812,7 +807,7 @@ class ConsoleApp:
|
||||
self.local_values_text.text = prettytable.get_string()
|
||||
self.ui.invalidate()
|
||||
|
||||
async def do_show_remote_values(self):
|
||||
async def show_remote_values(self):
|
||||
prettytable = PrettyTable(
|
||||
field_names=[
|
||||
"Connection",
|
||||
@@ -846,6 +841,23 @@ class ConsoleApp:
|
||||
self.remote_values_text.text = prettytable.get_string()
|
||||
self.ui.invalidate()
|
||||
|
||||
async def show_remote_attributes(self, attributes):
|
||||
lines = []
|
||||
for attribute in attributes:
|
||||
lines.append(('ansimagenta', str(attribute) + "\n"))
|
||||
try:
|
||||
value = await attribute.read_value()
|
||||
lines.append(('ansicyan', value.hex() + "\n"))
|
||||
except bumble.core.ProtocolError as error:
|
||||
lines.append(("ansired", f"!!! Protocol Error ({error})\n"))
|
||||
except bumble.core.TimeoutError:
|
||||
lines.append(("ansired", "!!! Timeout\n"))
|
||||
except Exception as error:
|
||||
lines.append(("ansired", f"!!! Error ({error})\n"))
|
||||
|
||||
self.remote_attributes_text.text = lines
|
||||
self.ui.invalidate()
|
||||
|
||||
async def do_get_phy(self, _):
|
||||
if not self.connected_peer:
|
||||
self.show_error('not connected')
|
||||
|
||||
@@ -27,7 +27,7 @@ from bumble.colors import color
|
||||
from bumble.core import name_or_number
|
||||
from bumble.hci import (
|
||||
map_null_terminated_utf8_string,
|
||||
LeFeatureMask,
|
||||
LeFeature,
|
||||
HCI_SUCCESS,
|
||||
HCI_VERSION_NAMES,
|
||||
LMP_VERSION_NAMES,
|
||||
@@ -140,7 +140,7 @@ async def get_le_info(host: Host) -> None:
|
||||
|
||||
print(color('LE Features:', 'yellow'))
|
||||
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(color('Supported Commands:', 'yellow'))
|
||||
for command in host.supported_commands:
|
||||
print(' ', HCI_Command.command_name(command))
|
||||
print(f' {HCI_Command.command_name(command)}')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
18
bumble/at.py
18
bumble/at.py
@@ -14,13 +14,19 @@
|
||||
|
||||
from typing import List, Union
|
||||
|
||||
from bumble import core
|
||||
|
||||
|
||||
class AtParsingError(core.InvalidPacketError):
|
||||
"""Error raised when parsing AT commands fails."""
|
||||
|
||||
|
||||
def tokenize_parameters(buffer: bytes) -> List[bytes]:
|
||||
"""Split input parameters into tokens.
|
||||
Removes space characters outside of double quote blocks:
|
||||
T-rec-V-25 - 5.2.1 Command line general format: "Space characters (IA5 2/0)
|
||||
are ignored [..], unless they are embedded in numeric or string constants"
|
||||
Raises ValueError in case of invalid input string."""
|
||||
Raises AtParsingError in case of invalid input string."""
|
||||
|
||||
tokens = []
|
||||
in_quotes = False
|
||||
@@ -43,11 +49,11 @@ def tokenize_parameters(buffer: bytes) -> List[bytes]:
|
||||
token = bytearray()
|
||||
elif char == b'(':
|
||||
if len(token) > 0:
|
||||
raise ValueError("open_paren following regular character")
|
||||
raise AtParsingError("open_paren following regular character")
|
||||
tokens.append(char)
|
||||
elif char == b'"':
|
||||
if len(token) > 0:
|
||||
raise ValueError("quote following regular character")
|
||||
raise AtParsingError("quote following regular character")
|
||||
in_quotes = True
|
||||
token.extend(char)
|
||||
else:
|
||||
@@ -59,7 +65,7 @@ def tokenize_parameters(buffer: bytes) -> List[bytes]:
|
||||
|
||||
def parse_parameters(buffer: bytes) -> List[Union[bytes, list]]:
|
||||
"""Parse the parameters using the comma and parenthesis separators.
|
||||
Raises ValueError in case of invalid input string."""
|
||||
Raises AtParsingError in case of invalid input string."""
|
||||
|
||||
tokens = tokenize_parameters(buffer)
|
||||
accumulator: List[list] = [[]]
|
||||
@@ -73,7 +79,7 @@ def parse_parameters(buffer: bytes) -> List[Union[bytes, list]]:
|
||||
accumulator.append([])
|
||||
elif token == b')':
|
||||
if len(accumulator) < 2:
|
||||
raise ValueError("close_paren without matching open_paren")
|
||||
raise AtParsingError("close_paren without matching open_paren")
|
||||
accumulator[-1].append(current)
|
||||
current = accumulator.pop()
|
||||
else:
|
||||
@@ -81,5 +87,5 @@ def parse_parameters(buffer: bytes) -> List[Union[bytes, list]]:
|
||||
|
||||
accumulator[-1].append(current)
|
||||
if len(accumulator) > 1:
|
||||
raise ValueError("missing close_paren")
|
||||
raise AtParsingError("missing close_paren")
|
||||
return accumulator[0]
|
||||
|
||||
@@ -20,6 +20,7 @@ import enum
|
||||
import struct
|
||||
from typing import Dict, Type, Union, Tuple
|
||||
|
||||
from bumble import core
|
||||
from bumble.utils import OpenIntEnum
|
||||
|
||||
|
||||
@@ -88,7 +89,9 @@ class Frame:
|
||||
short_name = subclass.__name__.replace("ResponseFrame", "")
|
||||
category_class = ResponseFrame
|
||||
else:
|
||||
raise ValueError(f"invalid subclass name {subclass.__name__}")
|
||||
raise core.InvalidArgumentError(
|
||||
f"invalid subclass name {subclass.__name__}"
|
||||
)
|
||||
|
||||
uppercase_indexes = [
|
||||
i for i in range(len(short_name)) if short_name[i].isupper()
|
||||
@@ -106,7 +109,7 @@ class Frame:
|
||||
@staticmethod
|
||||
def from_bytes(data: bytes) -> Frame:
|
||||
if data[0] >> 4 != 0:
|
||||
raise ValueError("first 4 bits must be 0s")
|
||||
raise core.InvalidPacketError("first 4 bits must be 0s")
|
||||
|
||||
ctype_or_response = data[0] & 0xF
|
||||
subunit_type = Frame.SubunitType(data[1] >> 3)
|
||||
@@ -122,7 +125,7 @@ class Frame:
|
||||
# Extended to the next byte
|
||||
extension = data[2]
|
||||
if extension == 0:
|
||||
raise ValueError("extended subunit ID value reserved")
|
||||
raise core.InvalidPacketError("extended subunit ID value reserved")
|
||||
if extension == 0xFF:
|
||||
subunit_id = 5 + 254 + data[3]
|
||||
opcode_offset = 4
|
||||
@@ -131,7 +134,7 @@ class Frame:
|
||||
opcode_offset = 3
|
||||
|
||||
elif subunit_id == 6:
|
||||
raise ValueError("reserved subunit ID")
|
||||
raise core.InvalidPacketError("reserved subunit ID")
|
||||
|
||||
opcode = Frame.OperationCode(data[opcode_offset])
|
||||
operands = data[opcode_offset + 1 :]
|
||||
@@ -448,7 +451,7 @@ class PassThroughFrame:
|
||||
operation_data: bytes,
|
||||
) -> None:
|
||||
if len(operation_data) > 255:
|
||||
raise ValueError("operation data must be <= 255 bytes")
|
||||
raise core.InvalidArgumentError("operation data must be <= 255 bytes")
|
||||
self.state_flag = state_flag
|
||||
self.operation_id = operation_id
|
||||
self.operation_data = operation_data
|
||||
|
||||
@@ -23,6 +23,7 @@ from typing import Callable, cast, Dict, Optional
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble import avc
|
||||
from bumble import core
|
||||
from bumble import l2cap
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -275,7 +276,7 @@ class Protocol:
|
||||
self, pid: int, handler: Protocol.CommandHandler
|
||||
) -> None:
|
||||
if pid not in self.command_handlers or self.command_handlers[pid] != handler:
|
||||
raise ValueError("command handler not registered")
|
||||
raise core.InvalidArgumentError("command handler not registered")
|
||||
del self.command_handlers[pid]
|
||||
|
||||
def register_response_handler(
|
||||
@@ -287,5 +288,5 @@ class Protocol:
|
||||
self, pid: int, handler: Protocol.ResponseHandler
|
||||
) -> None:
|
||||
if pid not in self.response_handlers or self.response_handlers[pid] != handler:
|
||||
raise ValueError("response handler not registered")
|
||||
raise core.InvalidArgumentError("response handler not registered")
|
||||
del self.response_handlers[pid]
|
||||
|
||||
@@ -43,6 +43,7 @@ from .core import (
|
||||
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
|
||||
InvalidStateError,
|
||||
ProtocolError,
|
||||
InvalidArgumentError,
|
||||
name_or_number,
|
||||
)
|
||||
from .a2dp import (
|
||||
@@ -700,7 +701,7 @@ class Message: # pylint:disable=attribute-defined-outside-init
|
||||
signal_identifier_str = name[:-7]
|
||||
message_type = Message.MessageType.RESPONSE_REJECT
|
||||
else:
|
||||
raise ValueError('invalid class name')
|
||||
raise InvalidArgumentError('invalid class name')
|
||||
|
||||
subclass.message_type = message_type
|
||||
|
||||
@@ -2162,6 +2163,9 @@ class LocalStreamEndPoint(StreamEndPoint, EventEmitter):
|
||||
def on_abort_command(self):
|
||||
self.emit('abort')
|
||||
|
||||
def on_delayreport_command(self, delay: int):
|
||||
self.emit('delay_report', delay)
|
||||
|
||||
def on_rtp_channel_open(self):
|
||||
self.emit('rtp_channel_open')
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ from bumble.sdp import (
|
||||
)
|
||||
from bumble.utils import AsyncRunner, OpenIntEnum
|
||||
from bumble.core import (
|
||||
InvalidArgumentError,
|
||||
ProtocolError,
|
||||
BT_L2CAP_PROTOCOL_ID,
|
||||
BT_AVCTP_PROTOCOL_ID,
|
||||
@@ -1411,7 +1412,7 @@ class Protocol(pyee.EventEmitter):
|
||||
def notify_track_changed(self, identifier: bytes) -> None:
|
||||
"""Notify the connected peer of a Track change."""
|
||||
if len(identifier) != 8:
|
||||
raise ValueError("identifier must be 8 bytes")
|
||||
raise InvalidArgumentError("identifier must be 8 bytes")
|
||||
self.notify_event(TrackChangedEvent(identifier))
|
||||
|
||||
def notify_playback_position_changed(self, position: int) -> None:
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from bumble import core
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class BitReader:
|
||||
@@ -40,7 +42,7 @@ class BitReader:
|
||||
""" "Read up to 32 bits."""
|
||||
|
||||
if bits > 32:
|
||||
raise ValueError('maximum read size is 32')
|
||||
raise core.InvalidArgumentError('maximum read size is 32')
|
||||
|
||||
if self.bits_cached >= bits:
|
||||
# We have enough bits.
|
||||
@@ -53,7 +55,7 @@ class BitReader:
|
||||
feed_size = len(feed_bytes)
|
||||
feed_int = int.from_bytes(feed_bytes, byteorder='big')
|
||||
if 8 * feed_size + self.bits_cached < bits:
|
||||
raise ValueError('trying to read past the data')
|
||||
raise core.InvalidArgumentError('trying to read past the data')
|
||||
self.byte_position += feed_size
|
||||
|
||||
# Combine the new cache and the old cache
|
||||
@@ -68,7 +70,7 @@ class BitReader:
|
||||
|
||||
def read_bytes(self, count: int):
|
||||
if self.bit_position + 8 * count > 8 * len(self.data):
|
||||
raise ValueError('not enough data')
|
||||
raise core.InvalidArgumentError('not enough data')
|
||||
|
||||
if self.bit_position % 8:
|
||||
# Not byte aligned
|
||||
@@ -113,7 +115,7 @@ class AacAudioRtpPacket:
|
||||
|
||||
@staticmethod
|
||||
def program_config_element(reader: BitReader):
|
||||
raise ValueError('program_config_element not supported')
|
||||
raise core.InvalidPacketError('program_config_element not supported')
|
||||
|
||||
@dataclass
|
||||
class GASpecificConfig:
|
||||
@@ -140,7 +142,7 @@ class AacAudioRtpPacket:
|
||||
aac_spectral_data_resilience_flags = reader.read(1)
|
||||
extension_flag_3 = reader.read(1)
|
||||
if extension_flag_3 == 1:
|
||||
raise ValueError('extensionFlag3 == 1 not supported')
|
||||
raise core.InvalidPacketError('extensionFlag3 == 1 not supported')
|
||||
|
||||
@staticmethod
|
||||
def audio_object_type(reader: BitReader):
|
||||
@@ -216,7 +218,7 @@ class AacAudioRtpPacket:
|
||||
reader, self.channel_configuration, self.audio_object_type
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
raise core.InvalidPacketError(
|
||||
f'audioObjectType {self.audio_object_type} not supported'
|
||||
)
|
||||
|
||||
@@ -260,7 +262,7 @@ class AacAudioRtpPacket:
|
||||
else:
|
||||
audio_mux_version_a = 0
|
||||
if audio_mux_version_a != 0:
|
||||
raise ValueError('audioMuxVersionA != 0 not supported')
|
||||
raise core.InvalidPacketError('audioMuxVersionA != 0 not supported')
|
||||
if audio_mux_version == 1:
|
||||
tara_buffer_fullness = AacAudioRtpPacket.latm_value(reader)
|
||||
stream_cnt = 0
|
||||
@@ -268,10 +270,10 @@ class AacAudioRtpPacket:
|
||||
num_sub_frames = reader.read(6)
|
||||
num_program = reader.read(4)
|
||||
if num_program != 0:
|
||||
raise ValueError('num_program != 0 not supported')
|
||||
raise core.InvalidPacketError('num_program != 0 not supported')
|
||||
num_layer = reader.read(3)
|
||||
if num_layer != 0:
|
||||
raise ValueError('num_layer != 0 not supported')
|
||||
raise core.InvalidPacketError('num_layer != 0 not supported')
|
||||
if audio_mux_version == 0:
|
||||
self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
|
||||
reader
|
||||
@@ -284,7 +286,7 @@ class AacAudioRtpPacket:
|
||||
)
|
||||
audio_specific_config_len = reader.bit_position - marker
|
||||
if asc_len < audio_specific_config_len:
|
||||
raise ValueError('audio_specific_config_len > asc_len')
|
||||
raise core.InvalidPacketError('audio_specific_config_len > asc_len')
|
||||
asc_len -= audio_specific_config_len
|
||||
reader.skip(asc_len)
|
||||
frame_length_type = reader.read(3)
|
||||
@@ -293,7 +295,9 @@ class AacAudioRtpPacket:
|
||||
elif frame_length_type == 1:
|
||||
frame_length = reader.read(9)
|
||||
else:
|
||||
raise ValueError(f'frame_length_type {frame_length_type} not supported')
|
||||
raise core.InvalidPacketError(
|
||||
f'frame_length_type {frame_length_type} not supported'
|
||||
)
|
||||
|
||||
self.other_data_present = reader.read(1)
|
||||
if self.other_data_present:
|
||||
@@ -318,12 +322,12 @@ class AacAudioRtpPacket:
|
||||
|
||||
def __init__(self, reader: BitReader, mux_config_present: int):
|
||||
if mux_config_present == 0:
|
||||
raise ValueError('muxConfigPresent == 0 not supported')
|
||||
raise core.InvalidPacketError('muxConfigPresent == 0 not supported')
|
||||
|
||||
# AudioMuxElement - ISO/EIC 14496-3 Table 1.41
|
||||
use_same_stream_mux = reader.read(1)
|
||||
if use_same_stream_mux:
|
||||
raise ValueError('useSameStreamMux == 1 not supported')
|
||||
raise core.InvalidPacketError('useSameStreamMux == 1 not supported')
|
||||
self.stream_mux_config = AacAudioRtpPacket.StreamMuxConfig(reader)
|
||||
|
||||
# We only support:
|
||||
|
||||
@@ -16,6 +16,10 @@ from functools import partial
|
||||
from typing import List, Optional, Union
|
||||
|
||||
|
||||
class ColorError(ValueError):
|
||||
"""Error raised when a color spec is invalid."""
|
||||
|
||||
|
||||
# ANSI color names. There is also a "default"
|
||||
COLORS = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white')
|
||||
|
||||
@@ -52,7 +56,7 @@ def _color_code(spec: ColorSpec, base: int) -> str:
|
||||
elif isinstance(spec, int) and 0 <= spec <= 255:
|
||||
return _join(base + 8, 5, spec)
|
||||
else:
|
||||
raise ValueError('Invalid color spec "%s"' % spec)
|
||||
raise ColorError('Invalid color spec "%s"' % spec)
|
||||
|
||||
|
||||
def color(
|
||||
@@ -72,7 +76,7 @@ def color(
|
||||
if style_part in STYLES:
|
||||
codes.append(STYLES.index(style_part))
|
||||
else:
|
||||
raise ValueError('Invalid style "%s"' % style_part)
|
||||
raise ColorError('Invalid style "%s"' % style_part)
|
||||
|
||||
if codes:
|
||||
return '\x1b[{0}m{1}\x1b[0m'.format(_join(*codes), s)
|
||||
|
||||
844
bumble/core.py
844
bumble/core.py
@@ -16,11 +16,14 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import enum
|
||||
import struct
|
||||
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
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -76,7 +79,13 @@ def get_dict_key_by_value(dictionary, value):
|
||||
# -----------------------------------------------------------------------------
|
||||
# Exceptions
|
||||
# -----------------------------------------------------------------------------
|
||||
class BaseError(Exception):
|
||||
|
||||
|
||||
class BaseBumbleError(Exception):
|
||||
"""Base Error raised by Bumble."""
|
||||
|
||||
|
||||
class BaseError(BaseBumbleError):
|
||||
"""Base class for errors with an error code, error name and namespace"""
|
||||
|
||||
def __init__(
|
||||
@@ -115,18 +124,38 @@ class ProtocolError(BaseError):
|
||||
"""Protocol Error"""
|
||||
|
||||
|
||||
class TimeoutError(Exception): # pylint: disable=redefined-builtin
|
||||
class TimeoutError(BaseBumbleError): # pylint: disable=redefined-builtin
|
||||
"""Timeout Error"""
|
||||
|
||||
|
||||
class CommandTimeoutError(Exception):
|
||||
class CommandTimeoutError(BaseBumbleError):
|
||||
"""Command Timeout Error"""
|
||||
|
||||
|
||||
class InvalidStateError(Exception):
|
||||
class InvalidStateError(BaseBumbleError):
|
||||
"""Invalid State Error"""
|
||||
|
||||
|
||||
class InvalidArgumentError(BaseBumbleError, ValueError):
|
||||
"""Invalid Argument Error"""
|
||||
|
||||
|
||||
class InvalidPacketError(BaseBumbleError, ValueError):
|
||||
"""Invalid Packet Error"""
|
||||
|
||||
|
||||
class InvalidOperationError(BaseBumbleError, RuntimeError):
|
||||
"""Invalid Operation Error"""
|
||||
|
||||
|
||||
class OutOfResourcesError(BaseBumbleError, RuntimeError):
|
||||
"""Out of Resources Error"""
|
||||
|
||||
|
||||
class UnreachableError(BaseBumbleError):
|
||||
"""The code path raising this error should be unreachable."""
|
||||
|
||||
|
||||
class ConnectionError(BaseError): # pylint: disable=redefined-builtin
|
||||
"""Connection Error"""
|
||||
|
||||
@@ -185,12 +214,12 @@ class UUID:
|
||||
or uuid_str_or_int[18] != '-'
|
||||
or uuid_str_or_int[23] != '-'
|
||||
):
|
||||
raise ValueError('invalid UUID format')
|
||||
raise InvalidArgumentError('invalid UUID format')
|
||||
uuid_str = uuid_str_or_int.replace('-', '')
|
||||
else:
|
||||
uuid_str = uuid_str_or_int
|
||||
if len(uuid_str) != 32 and len(uuid_str) != 8 and len(uuid_str) != 4:
|
||||
raise ValueError(f"invalid UUID format: {uuid_str}")
|
||||
raise InvalidArgumentError(f"invalid UUID format: {uuid_str}")
|
||||
self.uuid_bytes = bytes(reversed(bytes.fromhex(uuid_str)))
|
||||
self.name = name
|
||||
|
||||
@@ -215,7 +244,7 @@ class UUID:
|
||||
|
||||
return self.register()
|
||||
|
||||
raise ValueError('only 2, 4 and 16 bytes are allowed')
|
||||
raise InvalidArgumentError('only 2, 4 and 16 bytes are allowed')
|
||||
|
||||
@classmethod
|
||||
def from_16_bits(cls, uuid_16: int, name: Optional[str] = None) -> UUID:
|
||||
@@ -692,11 +721,569 @@ class DeviceClass:
|
||||
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
|
||||
# -----------------------------------------------------------------------------
|
||||
AdvertisingObject = Union[
|
||||
List[UUID], Tuple[UUID, bytes], bytes, str, int, Tuple[int, int], Tuple[int, bytes]
|
||||
AdvertisingDataObject = Union[
|
||||
List[UUID],
|
||||
Tuple[UUID, bytes],
|
||||
bytes,
|
||||
str,
|
||||
int,
|
||||
Tuple[int, int],
|
||||
Tuple[int, bytes],
|
||||
Appearance,
|
||||
]
|
||||
|
||||
|
||||
@@ -704,109 +1291,115 @@ class AdvertisingData:
|
||||
# fmt: off
|
||||
# pylint: disable=line-too-long
|
||||
|
||||
# This list is only partial, it still needs to be filled in from the spec
|
||||
FLAGS = 0x01
|
||||
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x02
|
||||
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x03
|
||||
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x04
|
||||
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x05
|
||||
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x06
|
||||
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x07
|
||||
SHORTENED_LOCAL_NAME = 0x08
|
||||
COMPLETE_LOCAL_NAME = 0x09
|
||||
TX_POWER_LEVEL = 0x0A
|
||||
CLASS_OF_DEVICE = 0x0D
|
||||
SIMPLE_PAIRING_HASH_C = 0x0E
|
||||
SIMPLE_PAIRING_HASH_C_192 = 0x0E
|
||||
SIMPLE_PAIRING_RANDOMIZER_R = 0x0F
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_192 = 0x0F
|
||||
DEVICE_ID = 0x10
|
||||
SECURITY_MANAGER_TK_VALUE = 0x10
|
||||
SECURITY_MANAGER_OUT_OF_BAND_FLAGS = 0x11
|
||||
PERIPHERAL_CONNECTION_INTERVAL_RANGE = 0x12
|
||||
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS = 0x14
|
||||
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS = 0x15
|
||||
SERVICE_DATA = 0x16
|
||||
SERVICE_DATA_16_BIT_UUID = 0x16
|
||||
PUBLIC_TARGET_ADDRESS = 0x17
|
||||
RANDOM_TARGET_ADDRESS = 0x18
|
||||
APPEARANCE = 0x19
|
||||
ADVERTISING_INTERVAL = 0x1A
|
||||
LE_BLUETOOTH_DEVICE_ADDRESS = 0x1B
|
||||
LE_ROLE = 0x1C
|
||||
SIMPLE_PAIRING_HASH_C_256 = 0x1D
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_256 = 0x1E
|
||||
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS = 0x1F
|
||||
SERVICE_DATA_32_BIT_UUID = 0x20
|
||||
SERVICE_DATA_128_BIT_UUID = 0x21
|
||||
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE = 0x22
|
||||
LE_SECURE_CONNECTIONS_RANDOM_VALUE = 0x23
|
||||
URI = 0x24
|
||||
INDOOR_POSITIONING = 0x25
|
||||
TRANSPORT_DISCOVERY_DATA = 0x26
|
||||
LE_SUPPORTED_FEATURES = 0x27
|
||||
CHANNEL_MAP_UPDATE_INDICATION = 0x28
|
||||
PB_ADV = 0x29
|
||||
MESH_MESSAGE = 0x2A
|
||||
MESH_BEACON = 0x2B
|
||||
BIGINFO = 0x2C
|
||||
BROADCAST_CODE = 0x2D
|
||||
RESOLVABLE_SET_IDENTIFIER = 0x2E
|
||||
ADVERTISING_INTERVAL_LONG = 0x2F
|
||||
THREE_D_INFORMATION_DATA = 0x3D
|
||||
MANUFACTURER_SPECIFIC_DATA = 0xFF
|
||||
FLAGS = 0x01
|
||||
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x02
|
||||
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x03
|
||||
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x04
|
||||
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x05
|
||||
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x06
|
||||
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x07
|
||||
SHORTENED_LOCAL_NAME = 0x08
|
||||
COMPLETE_LOCAL_NAME = 0x09
|
||||
TX_POWER_LEVEL = 0x0A
|
||||
CLASS_OF_DEVICE = 0x0D
|
||||
SIMPLE_PAIRING_HASH_C = 0x0E
|
||||
SIMPLE_PAIRING_HASH_C_192 = 0x0E
|
||||
SIMPLE_PAIRING_RANDOMIZER_R = 0x0F
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_192 = 0x0F
|
||||
DEVICE_ID = 0x10
|
||||
SECURITY_MANAGER_TK_VALUE = 0x10
|
||||
SECURITY_MANAGER_OUT_OF_BAND_FLAGS = 0x11
|
||||
PERIPHERAL_CONNECTION_INTERVAL_RANGE = 0x12
|
||||
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS = 0x14
|
||||
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS = 0x15
|
||||
SERVICE_DATA = 0x16
|
||||
SERVICE_DATA_16_BIT_UUID = 0x16
|
||||
PUBLIC_TARGET_ADDRESS = 0x17
|
||||
RANDOM_TARGET_ADDRESS = 0x18
|
||||
APPEARANCE = 0x19
|
||||
ADVERTISING_INTERVAL = 0x1A
|
||||
LE_BLUETOOTH_DEVICE_ADDRESS = 0x1B
|
||||
LE_ROLE = 0x1C
|
||||
SIMPLE_PAIRING_HASH_C_256 = 0x1D
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_256 = 0x1E
|
||||
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS = 0x1F
|
||||
SERVICE_DATA_32_BIT_UUID = 0x20
|
||||
SERVICE_DATA_128_BIT_UUID = 0x21
|
||||
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE = 0x22
|
||||
LE_SECURE_CONNECTIONS_RANDOM_VALUE = 0x23
|
||||
URI = 0x24
|
||||
INDOOR_POSITIONING = 0x25
|
||||
TRANSPORT_DISCOVERY_DATA = 0x26
|
||||
LE_SUPPORTED_FEATURES = 0x27
|
||||
CHANNEL_MAP_UPDATE_INDICATION = 0x28
|
||||
PB_ADV = 0x29
|
||||
MESH_MESSAGE = 0x2A
|
||||
MESH_BEACON = 0x2B
|
||||
BIGINFO = 0x2C
|
||||
BROADCAST_CODE = 0x2D
|
||||
RESOLVABLE_SET_IDENTIFIER = 0x2E
|
||||
ADVERTISING_INTERVAL_LONG = 0x2F
|
||||
BROADCAST_NAME = 0x30
|
||||
ENCRYPTED_ADVERTISING_DATA = 0X31
|
||||
PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION = 0X32
|
||||
ELECTRONIC_SHELF_LABEL = 0X34
|
||||
THREE_D_INFORMATION_DATA = 0x3D
|
||||
MANUFACTURER_SPECIFIC_DATA = 0xFF
|
||||
|
||||
AD_TYPE_NAMES = {
|
||||
FLAGS: 'FLAGS',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
SHORTENED_LOCAL_NAME: 'SHORTENED_LOCAL_NAME',
|
||||
COMPLETE_LOCAL_NAME: 'COMPLETE_LOCAL_NAME',
|
||||
TX_POWER_LEVEL: 'TX_POWER_LEVEL',
|
||||
CLASS_OF_DEVICE: 'CLASS_OF_DEVICE',
|
||||
SIMPLE_PAIRING_HASH_C: 'SIMPLE_PAIRING_HASH_C',
|
||||
SIMPLE_PAIRING_HASH_C_192: 'SIMPLE_PAIRING_HASH_C_192',
|
||||
SIMPLE_PAIRING_RANDOMIZER_R: 'SIMPLE_PAIRING_RANDOMIZER_R',
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_192: 'SIMPLE_PAIRING_RANDOMIZER_R_192',
|
||||
DEVICE_ID: 'DEVICE_ID',
|
||||
SECURITY_MANAGER_TK_VALUE: 'SECURITY_MANAGER_TK_VALUE',
|
||||
SECURITY_MANAGER_OUT_OF_BAND_FLAGS: 'SECURITY_MANAGER_OUT_OF_BAND_FLAGS',
|
||||
PERIPHERAL_CONNECTION_INTERVAL_RANGE: 'PERIPHERAL_CONNECTION_INTERVAL_RANGE',
|
||||
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',
|
||||
SERVICE_DATA: 'SERVICE_DATA',
|
||||
SERVICE_DATA_16_BIT_UUID: 'SERVICE_DATA_16_BIT_UUID',
|
||||
PUBLIC_TARGET_ADDRESS: 'PUBLIC_TARGET_ADDRESS',
|
||||
RANDOM_TARGET_ADDRESS: 'RANDOM_TARGET_ADDRESS',
|
||||
APPEARANCE: 'APPEARANCE',
|
||||
ADVERTISING_INTERVAL: 'ADVERTISING_INTERVAL',
|
||||
LE_BLUETOOTH_DEVICE_ADDRESS: 'LE_BLUETOOTH_DEVICE_ADDRESS',
|
||||
LE_ROLE: 'LE_ROLE',
|
||||
SIMPLE_PAIRING_HASH_C_256: 'SIMPLE_PAIRING_HASH_C_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',
|
||||
SERVICE_DATA_32_BIT_UUID: 'SERVICE_DATA_32_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_RANDOM_VALUE: 'LE_SECURE_CONNECTIONS_RANDOM_VALUE',
|
||||
URI: 'URI',
|
||||
INDOOR_POSITIONING: 'INDOOR_POSITIONING',
|
||||
TRANSPORT_DISCOVERY_DATA: 'TRANSPORT_DISCOVERY_DATA',
|
||||
LE_SUPPORTED_FEATURES: 'LE_SUPPORTED_FEATURES',
|
||||
CHANNEL_MAP_UPDATE_INDICATION: 'CHANNEL_MAP_UPDATE_INDICATION',
|
||||
PB_ADV: 'PB_ADV',
|
||||
MESH_MESSAGE: 'MESH_MESSAGE',
|
||||
MESH_BEACON: 'MESH_BEACON',
|
||||
BIGINFO: 'BIGINFO',
|
||||
BROADCAST_CODE: 'BROADCAST_CODE',
|
||||
RESOLVABLE_SET_IDENTIFIER: 'RESOLVABLE_SET_IDENTIFIER',
|
||||
ADVERTISING_INTERVAL_LONG: 'ADVERTISING_INTERVAL_LONG',
|
||||
THREE_D_INFORMATION_DATA: 'THREE_D_INFORMATION_DATA',
|
||||
MANUFACTURER_SPECIFIC_DATA: 'MANUFACTURER_SPECIFIC_DATA'
|
||||
FLAGS: 'FLAGS',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
SHORTENED_LOCAL_NAME: 'SHORTENED_LOCAL_NAME',
|
||||
COMPLETE_LOCAL_NAME: 'COMPLETE_LOCAL_NAME',
|
||||
TX_POWER_LEVEL: 'TX_POWER_LEVEL',
|
||||
CLASS_OF_DEVICE: 'CLASS_OF_DEVICE',
|
||||
SIMPLE_PAIRING_HASH_C: 'SIMPLE_PAIRING_HASH_C',
|
||||
SIMPLE_PAIRING_HASH_C_192: 'SIMPLE_PAIRING_HASH_C_192',
|
||||
SIMPLE_PAIRING_RANDOMIZER_R: 'SIMPLE_PAIRING_RANDOMIZER_R',
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_192: 'SIMPLE_PAIRING_RANDOMIZER_R_192',
|
||||
DEVICE_ID: 'DEVICE_ID',
|
||||
SECURITY_MANAGER_TK_VALUE: 'SECURITY_MANAGER_TK_VALUE',
|
||||
SECURITY_MANAGER_OUT_OF_BAND_FLAGS: 'SECURITY_MANAGER_OUT_OF_BAND_FLAGS',
|
||||
PERIPHERAL_CONNECTION_INTERVAL_RANGE: 'PERIPHERAL_CONNECTION_INTERVAL_RANGE',
|
||||
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',
|
||||
SERVICE_DATA_16_BIT_UUID: 'SERVICE_DATA_16_BIT_UUID',
|
||||
PUBLIC_TARGET_ADDRESS: 'PUBLIC_TARGET_ADDRESS',
|
||||
RANDOM_TARGET_ADDRESS: 'RANDOM_TARGET_ADDRESS',
|
||||
APPEARANCE: 'APPEARANCE',
|
||||
ADVERTISING_INTERVAL: 'ADVERTISING_INTERVAL',
|
||||
LE_BLUETOOTH_DEVICE_ADDRESS: 'LE_BLUETOOTH_DEVICE_ADDRESS',
|
||||
LE_ROLE: 'LE_ROLE',
|
||||
SIMPLE_PAIRING_HASH_C_256: 'SIMPLE_PAIRING_HASH_C_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',
|
||||
SERVICE_DATA_32_BIT_UUID: 'SERVICE_DATA_32_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_RANDOM_VALUE: 'LE_SECURE_CONNECTIONS_RANDOM_VALUE',
|
||||
URI: 'URI',
|
||||
INDOOR_POSITIONING: 'INDOOR_POSITIONING',
|
||||
TRANSPORT_DISCOVERY_DATA: 'TRANSPORT_DISCOVERY_DATA',
|
||||
LE_SUPPORTED_FEATURES: 'LE_SUPPORTED_FEATURES',
|
||||
CHANNEL_MAP_UPDATE_INDICATION: 'CHANNEL_MAP_UPDATE_INDICATION',
|
||||
PB_ADV: 'PB_ADV',
|
||||
MESH_MESSAGE: 'MESH_MESSAGE',
|
||||
MESH_BEACON: 'MESH_BEACON',
|
||||
BIGINFO: 'BIGINFO',
|
||||
BROADCAST_CODE: 'BROADCAST_CODE',
|
||||
RESOLVABLE_SET_IDENTIFIER: 'RESOLVABLE_SET_IDENTIFIER',
|
||||
ADVERTISING_INTERVAL_LONG: 'ADVERTISING_INTERVAL_LONG',
|
||||
BROADCAST_NAME: 'BROADCAST_NAME',
|
||||
ENCRYPTED_ADVERTISING_DATA: 'ENCRYPTED_ADVERTISING_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
|
||||
@@ -915,7 +1508,11 @@ class AdvertisingData:
|
||||
ad_data_str = f'company={company_name}, data={ad_data[2:].hex()}'
|
||||
elif ad_type == AdvertisingData.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:
|
||||
ad_type_str = AdvertisingData.AD_TYPE_NAMES.get(ad_type, f'0x{ad_type:02X}')
|
||||
ad_data_str = ad_data.hex()
|
||||
@@ -924,7 +1521,7 @@ class AdvertisingData:
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
@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 (
|
||||
AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||
@@ -959,16 +1556,14 @@ class AdvertisingData:
|
||||
AdvertisingData.SHORTENED_LOCAL_NAME,
|
||||
AdvertisingData.COMPLETE_LOCAL_NAME,
|
||||
AdvertisingData.URI,
|
||||
AdvertisingData.BROADCAST_NAME,
|
||||
):
|
||||
return ad_data.decode("utf-8")
|
||||
|
||||
if ad_type in (AdvertisingData.TX_POWER_LEVEL, AdvertisingData.FLAGS):
|
||||
return cast(int, struct.unpack('B', ad_data)[0])
|
||||
|
||||
if ad_type in (
|
||||
AdvertisingData.APPEARANCE,
|
||||
AdvertisingData.ADVERTISING_INTERVAL,
|
||||
):
|
||||
if ad_type in (AdvertisingData.ADVERTISING_INTERVAL,):
|
||||
return cast(int, struct.unpack('<H', ad_data)[0])
|
||||
|
||||
if ad_type == AdvertisingData.CLASS_OF_DEVICE:
|
||||
@@ -980,6 +1575,11 @@ class AdvertisingData:
|
||||
if ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
|
||||
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
|
||||
|
||||
def append(self, data: bytes) -> None:
|
||||
@@ -993,27 +1593,27 @@ class AdvertisingData:
|
||||
self.ad_structures.append((ad_type, ad_data))
|
||||
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
|
||||
|
||||
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 [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
|
||||
|
||||
Returns the first entry, or None if no structure matches.
|
||||
'''
|
||||
|
||||
all = self.get_all(type_id, raw=raw)
|
||||
return all[0] if all else None
|
||||
all_objects = self.get_all(type_id, raw=raw)
|
||||
return all_objects[0] if all_objects else None
|
||||
|
||||
def __bytes__(self):
|
||||
return b''.join(
|
||||
|
||||
666
bumble/device.py
666
bumble/device.py
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@ from typing import Tuple
|
||||
import weakref
|
||||
|
||||
|
||||
from bumble import core
|
||||
from bumble.hci import (
|
||||
hci_vendor_command_op_code,
|
||||
STATUS_SPEC,
|
||||
@@ -49,6 +50,10 @@ from bumble.drivers import common
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RtkFirmwareError(core.BaseBumbleError):
|
||||
"""Error raised when RTK firmware initialization fails."""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Constants
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -208,15 +213,15 @@ class Firmware:
|
||||
extension_sig = bytes([0x51, 0x04, 0xFD, 0x77])
|
||||
|
||||
if not firmware.startswith(RTK_EPATCH_SIGNATURE):
|
||||
raise ValueError("Firmware does not start with epatch signature")
|
||||
raise RtkFirmwareError("Firmware does not start with epatch signature")
|
||||
|
||||
if not firmware.endswith(extension_sig):
|
||||
raise ValueError("Firmware does not end with extension sig")
|
||||
raise RtkFirmwareError("Firmware does not end with extension sig")
|
||||
|
||||
# The firmware should start with a 14 byte header.
|
||||
epatch_header_size = 14
|
||||
if len(firmware) < epatch_header_size:
|
||||
raise ValueError("Firmware too short")
|
||||
raise RtkFirmwareError("Firmware too short")
|
||||
|
||||
# Look for the "project ID", starting from the end.
|
||||
offset = len(firmware) - len(extension_sig)
|
||||
@@ -230,7 +235,7 @@ class Firmware:
|
||||
break
|
||||
|
||||
if length == 0:
|
||||
raise ValueError("Invalid 0-length instruction")
|
||||
raise RtkFirmwareError("Invalid 0-length instruction")
|
||||
|
||||
if opcode == 0 and length == 1:
|
||||
project_id = firmware[offset - 1]
|
||||
@@ -239,7 +244,7 @@ class Firmware:
|
||||
offset -= length
|
||||
|
||||
if project_id < 0:
|
||||
raise ValueError("Project ID not found")
|
||||
raise RtkFirmwareError("Project ID not found")
|
||||
|
||||
self.project_id = project_id
|
||||
|
||||
@@ -252,7 +257,7 @@ class Firmware:
|
||||
# <PatchLength_1><PatchLength_2>...<PatchLength_N> (16 bits each)
|
||||
# <PatchOffset_1><PatchOffset_2>...<PatchOffset_N> (32 bits each)
|
||||
if epatch_header_size + 8 * num_patches > len(firmware):
|
||||
raise ValueError("Firmware too short")
|
||||
raise RtkFirmwareError("Firmware too short")
|
||||
chip_id_table_offset = epatch_header_size
|
||||
patch_length_table_offset = chip_id_table_offset + 2 * num_patches
|
||||
patch_offset_table_offset = chip_id_table_offset + 4 * num_patches
|
||||
@@ -266,7 +271,7 @@ class Firmware:
|
||||
"<I", firmware, patch_offset_table_offset + 4 * patch_index
|
||||
)
|
||||
if patch_offset + patch_length > len(firmware):
|
||||
raise ValueError("Firmware too short")
|
||||
raise RtkFirmwareError("Firmware too short")
|
||||
|
||||
# Get the SVN version for the patch
|
||||
(svn_version,) = struct.unpack_from(
|
||||
@@ -645,7 +650,7 @@ class Driver(common.Driver):
|
||||
):
|
||||
return await self.download_for_rtl8723b()
|
||||
|
||||
raise ValueError("ROM not supported")
|
||||
raise RtkFirmwareError("ROM not supported")
|
||||
|
||||
async def init_controller(self):
|
||||
await self.download_firmware()
|
||||
|
||||
@@ -331,9 +331,9 @@ class Client:
|
||||
async def request_mtu(self, mtu: int) -> int:
|
||||
# Check the range
|
||||
if mtu < ATT_DEFAULT_MTU:
|
||||
raise ValueError(f'MTU must be >= {ATT_DEFAULT_MTU}')
|
||||
raise core.InvalidArgumentError(f'MTU must be >= {ATT_DEFAULT_MTU}')
|
||||
if mtu > 0xFFFF:
|
||||
raise ValueError('MTU must be <= 0xFFFF')
|
||||
raise core.InvalidArgumentError('MTU must be <= 0xFFFF')
|
||||
|
||||
# We can only send one request per connection
|
||||
if self.mtu_exchange_done:
|
||||
@@ -977,6 +977,7 @@ class Client:
|
||||
offset += len(part)
|
||||
|
||||
self.cache_value(attribute_handle, attribute_value)
|
||||
|
||||
# Return the value as bytes
|
||||
return attribute_value
|
||||
|
||||
|
||||
299
bumble/hci.py
299
bumble/hci.py
@@ -26,16 +26,19 @@ import struct
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Type, Union, ClassVar
|
||||
|
||||
from bumble import crypto
|
||||
from .colors import color
|
||||
from .core import (
|
||||
from bumble.colors import color
|
||||
from bumble.core import (
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
AdvertisingData,
|
||||
DeviceClass,
|
||||
InvalidArgumentError,
|
||||
InvalidPacketError,
|
||||
ProtocolError,
|
||||
bit_flags_to_strings,
|
||||
name_or_number,
|
||||
padded_bytes,
|
||||
)
|
||||
from bumble.utils import OpenIntEnum
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -91,14 +94,14 @@ def map_class_of_device(class_of_device):
|
||||
)
|
||||
|
||||
|
||||
def phy_list_to_bits(phys):
|
||||
def phy_list_to_bits(phys: Optional[Iterable[int]]) -> int:
|
||||
if phys is None:
|
||||
return 0
|
||||
|
||||
phy_bits = 0
|
||||
for phy in phys:
|
||||
if phy not in HCI_LE_PHY_TYPE_TO_BIT:
|
||||
raise ValueError('invalid PHY')
|
||||
raise InvalidArgumentError('invalid PHY')
|
||||
phy_bits |= 1 << HCI_LE_PHY_TYPE_TO_BIT[phy]
|
||||
return phy_bits
|
||||
|
||||
@@ -1104,7 +1107,7 @@ HCI_SUPPORTED_COMMANDS_MASKS = {
|
||||
|
||||
# LE Supported Features
|
||||
# See Bluetooth spec @ Vol 6, Part B, 4.6 FEATURE SUPPORT
|
||||
class LeFeature(enum.IntEnum):
|
||||
class LeFeature(OpenIntEnum):
|
||||
LE_ENCRYPTION = 0
|
||||
CONNECTION_PARAMETERS_REQUEST_PROCEDURE = 1
|
||||
EXTENDED_REJECT_INDICATION = 2
|
||||
@@ -1380,7 +1383,7 @@ class LmpFeatureMask(enum.IntFlag):
|
||||
STATUS_SPEC = {'size': 1, 'mapper': lambda x: HCI_Constant.status_name(x)}
|
||||
|
||||
|
||||
class CodecID(enum.IntEnum):
|
||||
class CodecID(OpenIntEnum):
|
||||
# fmt: off
|
||||
U_LOG = 0x00
|
||||
A_LOG = 0x01
|
||||
@@ -1552,7 +1555,7 @@ class HCI_Object:
|
||||
new_offset, field_value = field_type(data, offset)
|
||||
return (field_value, new_offset - offset)
|
||||
|
||||
raise ValueError(f'unknown field type {field_type}')
|
||||
raise InvalidArgumentError(f'unknown field type {field_type}')
|
||||
|
||||
@staticmethod
|
||||
def dict_from_bytes(data, offset, fields):
|
||||
@@ -1621,7 +1624,7 @@ class HCI_Object:
|
||||
if 0 <= field_value <= 255:
|
||||
field_bytes = bytes([field_value])
|
||||
else:
|
||||
raise ValueError('value too large for *-typed field')
|
||||
raise InvalidArgumentError('value too large for *-typed field')
|
||||
else:
|
||||
field_bytes = bytes(field_value)
|
||||
elif field_type == 'v':
|
||||
@@ -1640,7 +1643,9 @@ class HCI_Object:
|
||||
elif len(field_bytes) > field_type:
|
||||
field_bytes = field_bytes[:field_type]
|
||||
else:
|
||||
raise ValueError(f"don't know how to serialize type {type(field_value)}")
|
||||
raise InvalidArgumentError(
|
||||
f"don't know how to serialize type {type(field_value)}"
|
||||
)
|
||||
|
||||
return field_bytes
|
||||
|
||||
@@ -1834,6 +1839,12 @@ class Address:
|
||||
data, offset, Address.PUBLIC_DEVICE_ADDRESS
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_random_address(data, offset):
|
||||
return Address.parse_address_with_type(
|
||||
data, offset, Address.RANDOM_DEVICE_ADDRESS
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_address_with_type(data, offset, address_type):
|
||||
return offset + 6, Address(data[offset : offset + 6], address_type)
|
||||
@@ -1904,7 +1915,7 @@ class Address:
|
||||
self.address_bytes = bytes(reversed(bytes.fromhex(address)))
|
||||
|
||||
if len(self.address_bytes) != 6:
|
||||
raise ValueError('invalid address length')
|
||||
raise InvalidArgumentError('invalid address length')
|
||||
|
||||
self.address_type = address_type
|
||||
|
||||
@@ -1960,13 +1971,17 @@ class Address:
|
||||
|
||||
def __eq__(self, other):
|
||||
return (
|
||||
self.address_bytes == other.address_bytes
|
||||
isinstance(other, Address)
|
||||
and self.address_bytes == other.address_bytes
|
||||
and self.is_public == other.is_public
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.to_string()
|
||||
|
||||
def __repr__(self):
|
||||
return f'Address({self.to_string(False)}/{self.address_type_name(self.address_type)})'
|
||||
|
||||
|
||||
# Predefined address values
|
||||
Address.NIL = Address(b"\xff\xff\xff\xff\xff\xff", Address.PUBLIC_DEVICE_ADDRESS)
|
||||
@@ -2104,7 +2119,7 @@ class HCI_Command(HCI_Packet):
|
||||
op_code, length = struct.unpack_from('<HB', packet, 1)
|
||||
parameters = packet[4:]
|
||||
if len(parameters) != length:
|
||||
raise ValueError('invalid packet length')
|
||||
raise InvalidPacketError('invalid packet length')
|
||||
|
||||
# Look for a registered class
|
||||
cls = HCI_Command.command_classes.get(op_code)
|
||||
@@ -4452,6 +4467,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(
|
||||
[
|
||||
@@ -4487,14 +4576,6 @@ class HCI_LE_Set_Privacy_Mode_Command(HCI_Command):
|
||||
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(
|
||||
fields=[
|
||||
@@ -4655,6 +4736,14 @@ class HCI_LE_Remove_ISO_Data_Path_Command(HCI_Command):
|
||||
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
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -4729,7 +4818,7 @@ class HCI_Event(HCI_Packet):
|
||||
length = packet[2]
|
||||
parameters = packet[3:]
|
||||
if len(parameters) != length:
|
||||
raise ValueError('invalid packet length')
|
||||
raise InvalidPacketError('invalid packet length')
|
||||
|
||||
cls: Any
|
||||
if event_code == HCI_LE_META_EVENT:
|
||||
@@ -5096,8 +5185,8 @@ class HCI_LE_Data_Length_Change_Event(HCI_LE_Meta_Event):
|
||||
),
|
||||
('peer_address_type', Address.ADDRESS_TYPE_SPEC),
|
||||
('peer_address', Address.parse_address_preceded_by_type),
|
||||
('local_resolvable_private_address', Address.parse_address),
|
||||
('peer_resolvable_private_address', Address.parse_address),
|
||||
('local_resolvable_private_address', Address.parse_random_address),
|
||||
('peer_resolvable_private_address', Address.parse_random_address),
|
||||
('connection_interval', 2),
|
||||
('peripheral_latency', 2),
|
||||
('supervision_timeout', 2),
|
||||
@@ -5271,6 +5360,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(
|
||||
[
|
||||
@@ -5336,6 +5561,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)])
|
||||
class HCI_Inquiry_Complete_Event(HCI_Event):
|
||||
@@ -6104,7 +6353,7 @@ class HCI_AclDataPacket(HCI_Packet):
|
||||
bc_flag = (h >> 14) & 3
|
||||
data = packet[5:]
|
||||
if len(data) != data_total_length:
|
||||
raise ValueError('invalid packet length')
|
||||
raise InvalidPacketError('invalid packet length')
|
||||
return HCI_AclDataPacket(
|
||||
connection_handle, pb_flag, bc_flag, data_total_length, data
|
||||
)
|
||||
@@ -6152,7 +6401,7 @@ class HCI_SynchronousDataPacket(HCI_Packet):
|
||||
packet_status = (h >> 12) & 0b11
|
||||
data = packet[4:]
|
||||
if len(data) != data_total_length:
|
||||
raise ValueError(
|
||||
raise InvalidPacketError(
|
||||
f'invalid packet length {len(data)} != {data_total_length}'
|
||||
)
|
||||
return HCI_SynchronousDataPacket(
|
||||
|
||||
@@ -772,6 +772,8 @@ class Host(AbortableEventEmitter):
|
||||
event.connection_handle,
|
||||
BT_LE_TRANSPORT,
|
||||
event.peer_address,
|
||||
getattr(event, 'local_resolvable_private_address', None),
|
||||
getattr(event, 'peer_resolvable_private_address', None),
|
||||
event.role,
|
||||
connection_parameters,
|
||||
)
|
||||
@@ -787,6 +789,10 @@ class Host(AbortableEventEmitter):
|
||||
# Just use the same implementation as for the non-enhanced event for now
|
||||
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):
|
||||
if event.status == hci.HCI_SUCCESS:
|
||||
# Create/update the connection
|
||||
@@ -813,6 +819,8 @@ class Host(AbortableEventEmitter):
|
||||
event.bd_addr,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
else:
|
||||
logger.debug(f'### BR/EDR CONNECTION FAILED: {event.status}')
|
||||
@@ -905,6 +913,27 @@ class Host(AbortableEventEmitter):
|
||||
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):
|
||||
self.emit(
|
||||
'cis_request',
|
||||
|
||||
@@ -41,7 +41,14 @@ from typing import (
|
||||
|
||||
from .utils import deprecated
|
||||
from .colors import color
|
||||
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
|
||||
from .core import (
|
||||
BT_CENTRAL_ROLE,
|
||||
InvalidStateError,
|
||||
InvalidArgumentError,
|
||||
InvalidPacketError,
|
||||
OutOfResourcesError,
|
||||
ProtocolError,
|
||||
)
|
||||
from .hci import (
|
||||
HCI_LE_Connection_Update_Command,
|
||||
HCI_Object,
|
||||
@@ -189,17 +196,17 @@ class LeCreditBasedChannelSpec:
|
||||
self.max_credits < 1
|
||||
or self.max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
|
||||
):
|
||||
raise ValueError('max credits out of range')
|
||||
raise InvalidArgumentError('max credits out of range')
|
||||
if (
|
||||
self.mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU
|
||||
or self.mtu > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MTU
|
||||
):
|
||||
raise ValueError('MTU out of range')
|
||||
raise InvalidArgumentError('MTU out of range')
|
||||
if (
|
||||
self.mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
|
||||
or self.mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
|
||||
):
|
||||
raise ValueError('MPS out of range')
|
||||
raise InvalidArgumentError('MPS out of range')
|
||||
|
||||
|
||||
class L2CAP_PDU:
|
||||
@@ -211,7 +218,7 @@ class L2CAP_PDU:
|
||||
def from_bytes(data: bytes) -> L2CAP_PDU:
|
||||
# Check parameters
|
||||
if len(data) < 4:
|
||||
raise ValueError('not enough data for L2CAP header')
|
||||
raise InvalidPacketError('not enough data for L2CAP header')
|
||||
|
||||
_, l2cap_pdu_cid = struct.unpack_from('<HH', data, 0)
|
||||
l2cap_pdu_payload = data[4:]
|
||||
@@ -816,7 +823,7 @@ class ClassicChannel(EventEmitter):
|
||||
|
||||
# Check that we can start a new connection
|
||||
if self.connection_result:
|
||||
raise RuntimeError('connection already pending')
|
||||
raise InvalidStateError('connection already pending')
|
||||
|
||||
self._change_state(self.State.WAIT_CONNECT_RSP)
|
||||
self.send_control_frame(
|
||||
@@ -1129,7 +1136,7 @@ class LeCreditBasedChannel(EventEmitter):
|
||||
# Check that we can start a new connection
|
||||
identifier = self.manager.next_identifier(self.connection)
|
||||
if identifier in self.manager.le_coc_requests:
|
||||
raise RuntimeError('too many concurrent connection requests')
|
||||
raise InvalidStateError('too many concurrent connection requests')
|
||||
|
||||
self._change_state(self.State.CONNECTING)
|
||||
request = L2CAP_LE_Credit_Based_Connection_Request(
|
||||
@@ -1516,7 +1523,7 @@ class ChannelManager:
|
||||
if cid not in channels:
|
||||
return cid
|
||||
|
||||
raise RuntimeError('no free CID available')
|
||||
raise OutOfResourcesError('no free CID available')
|
||||
|
||||
@staticmethod
|
||||
def find_free_le_cid(channels: Iterable[int]) -> int:
|
||||
@@ -1529,7 +1536,7 @@ class ChannelManager:
|
||||
if cid not in channels:
|
||||
return cid
|
||||
|
||||
raise RuntimeError('no free CID')
|
||||
raise OutOfResourcesError('no free CID')
|
||||
|
||||
def next_identifier(self, connection: Connection) -> int:
|
||||
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
|
||||
@@ -1576,15 +1583,15 @@ class ChannelManager:
|
||||
else:
|
||||
# Check that the PSM isn't already in use
|
||||
if spec.psm in self.servers:
|
||||
raise ValueError('PSM already in use')
|
||||
raise InvalidArgumentError('PSM already in use')
|
||||
|
||||
# Check that the PSM is valid
|
||||
if spec.psm % 2 == 0:
|
||||
raise ValueError('invalid PSM (not odd)')
|
||||
raise InvalidArgumentError('invalid PSM (not odd)')
|
||||
check = spec.psm >> 8
|
||||
while check:
|
||||
if check % 2 != 0:
|
||||
raise ValueError('invalid PSM')
|
||||
raise InvalidArgumentError('invalid PSM')
|
||||
check >>= 8
|
||||
|
||||
self.servers[spec.psm] = ClassicChannelServer(self, spec.psm, handler, spec.mtu)
|
||||
@@ -1626,7 +1633,7 @@ class ChannelManager:
|
||||
else:
|
||||
# Check that the PSM isn't already in use
|
||||
if spec.psm in self.le_coc_servers:
|
||||
raise ValueError('PSM already in use')
|
||||
raise InvalidArgumentError('PSM already in use')
|
||||
|
||||
self.le_coc_servers[spec.psm] = LeCreditBasedChannelServer(
|
||||
self,
|
||||
@@ -2154,10 +2161,10 @@ class ChannelManager:
|
||||
connection_channels = self.channels.setdefault(connection.handle, {})
|
||||
source_cid = self.find_free_le_cid(connection_channels)
|
||||
if source_cid is None: # Should never happen!
|
||||
raise RuntimeError('all CIDs already in use')
|
||||
raise OutOfResourcesError('all CIDs already in use')
|
||||
|
||||
if spec.psm is None:
|
||||
raise ValueError('PSM cannot be None')
|
||||
raise InvalidArgumentError('PSM cannot be None')
|
||||
|
||||
# Create the channel
|
||||
logger.debug(f'creating coc channel with cid={source_cid} for psm {spec.psm}')
|
||||
@@ -2206,10 +2213,10 @@ class ChannelManager:
|
||||
connection_channels = self.channels.setdefault(connection.handle, {})
|
||||
source_cid = self.find_free_br_edr_cid(connection_channels)
|
||||
if source_cid is None: # Should never happen!
|
||||
raise RuntimeError('all CIDs already in use')
|
||||
raise OutOfResourcesError('all CIDs already in use')
|
||||
|
||||
if spec.psm is None:
|
||||
raise ValueError('PSM cannot be None')
|
||||
raise InvalidArgumentError('PSM cannot be None')
|
||||
|
||||
# Create the channel
|
||||
logger.debug(
|
||||
|
||||
@@ -19,7 +19,12 @@ import logging
|
||||
import asyncio
|
||||
from functools import partial
|
||||
|
||||
from bumble.core import BT_PERIPHERAL_ROLE, BT_BR_EDR_TRANSPORT, BT_LE_TRANSPORT
|
||||
from bumble.core import (
|
||||
BT_PERIPHERAL_ROLE,
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
BT_LE_TRANSPORT,
|
||||
InvalidStateError,
|
||||
)
|
||||
from bumble.colors import color
|
||||
from bumble.hci import (
|
||||
Address,
|
||||
@@ -405,12 +410,12 @@ class RemoteLink:
|
||||
|
||||
def add_controller(self, controller):
|
||||
if self.controller:
|
||||
raise ValueError('controller already set')
|
||||
raise InvalidStateError('controller already set')
|
||||
self.controller = controller
|
||||
|
||||
def remove_controller(self, controller):
|
||||
if self.controller != controller:
|
||||
raise ValueError('controller mismatch')
|
||||
raise InvalidStateError('controller mismatch')
|
||||
self.controller = None
|
||||
|
||||
def get_pending_connection(self):
|
||||
|
||||
@@ -28,6 +28,7 @@ from bumble.core import (
|
||||
BT_PERIPHERAL_ROLE,
|
||||
UUID,
|
||||
AdvertisingData,
|
||||
Appearance,
|
||||
ConnectionError,
|
||||
)
|
||||
from bumble.device import (
|
||||
@@ -988,8 +989,8 @@ class HostService(HostServicer):
|
||||
dt.random_target_addresses.extend(
|
||||
[data[i * 6 :: i * 6 + 6] for i in range(int(len(data) / 6))]
|
||||
)
|
||||
if i := cast(int, ad.get(AdvertisingData.APPEARANCE)):
|
||||
dt.appearance = i
|
||||
if appearance := cast(Appearance, ad.get(AdvertisingData.APPEARANCE)):
|
||||
dt.appearance = int(appearance)
|
||||
if i := cast(int, ad.get(AdvertisingData.ADVERTISING_INTERVAL)):
|
||||
dt.advertising_interval = i
|
||||
if s := cast(str, ad.get(AdvertisingData.URI)):
|
||||
|
||||
@@ -25,6 +25,7 @@ import struct
|
||||
import functools
|
||||
import logging
|
||||
from typing import Optional, List, Union, Type, Dict, Any, Tuple
|
||||
from typing_extensions import Self
|
||||
|
||||
from bumble import core
|
||||
from bumble import colors
|
||||
@@ -32,6 +33,8 @@ from bumble import device
|
||||
from bumble import hci
|
||||
from bumble import gatt
|
||||
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
|
||||
|
||||
|
||||
class SamplingFrequency(enum.IntEnum):
|
||||
class SamplingFrequency(utils.OpenIntEnum):
|
||||
'''Bluetooth Assigned Numbers, Section 6.12.5.1 - Sampling Frequency'''
|
||||
|
||||
# fmt: off
|
||||
@@ -240,7 +243,7 @@ class SupportedFrameDuration(enum.IntFlag):
|
||||
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'''
|
||||
|
||||
# fmt: off
|
||||
@@ -613,7 +616,7 @@ class CodecSpecificConfiguration:
|
||||
* Basic Audio Profile, 4.3.2 - Codec_Specific_Capabilities LTV requirements
|
||||
'''
|
||||
|
||||
class Type(enum.IntEnum):
|
||||
class Type(utils.OpenIntEnum):
|
||||
# fmt: off
|
||||
SAMPLING_FREQUENCY = 0x01
|
||||
FRAME_DURATION = 0x02
|
||||
@@ -682,10 +685,11 @@ class CodecSpecificConfiguration:
|
||||
|
||||
@dataclasses.dataclass
|
||||
class PacRecord:
|
||||
'''Published Audio Capabilities Service, Table 3.2/3.4.'''
|
||||
|
||||
coding_format: hci.CodingFormat
|
||||
codec_specific_capabilities: Union[CodecSpecificCapabilities, bytes]
|
||||
# TODO: Parse Metadata
|
||||
metadata: bytes = b''
|
||||
metadata: le_audio.Metadata = dataclasses.field(default_factory=le_audio.Metadata)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> PacRecord:
|
||||
@@ -698,7 +702,8 @@ class PacRecord:
|
||||
]
|
||||
offset += codec_specific_capabilities_size
|
||||
metadata_size = data[offset]
|
||||
metadata = data[offset : offset + metadata_size]
|
||||
offset += 1
|
||||
metadata = le_audio.Metadata.from_bytes(data[offset : offset + metadata_size])
|
||||
|
||||
codec_specific_capabilities: Union[CodecSpecificCapabilities, bytes]
|
||||
if coding_format.codec_id == hci.CodecID.VENDOR_SPECIFIC:
|
||||
@@ -716,15 +721,109 @@ class PacRecord:
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
capabilities_bytes = bytes(self.codec_specific_capabilities)
|
||||
metadata_bytes = bytes(self.metadata)
|
||||
return (
|
||||
bytes(self.coding_format)
|
||||
+ bytes([len(capabilities_bytes)])
|
||||
+ capabilities_bytes
|
||||
+ bytes([len(self.metadata)])
|
||||
+ self.metadata
|
||||
+ bytes([len(metadata_bytes)])
|
||||
+ metadata_bytes
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -744,9 +843,9 @@ class PublishedAudioCapabilitiesService(gatt.TemplateService):
|
||||
supported_sink_context: ContextType,
|
||||
available_source_context: ContextType,
|
||||
available_sink_context: ContextType,
|
||||
sink_pac: Sequence[PacRecord] = [],
|
||||
sink_pac: Sequence[PacRecord] = (),
|
||||
sink_audio_locations: Optional[AudioLocation] = None,
|
||||
source_pac: Sequence[PacRecord] = [],
|
||||
source_pac: Sequence[PacRecord] = (),
|
||||
source_audio_locations: Optional[AudioLocation] = None,
|
||||
) -> None:
|
||||
characteristics = []
|
||||
@@ -844,8 +943,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
presentation_delay = 0
|
||||
|
||||
# Additional parameters in ENABLING, STREAMING, DISABLING State
|
||||
# TODO: Parse this
|
||||
metadata = b''
|
||||
metadata = le_audio.Metadata()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -992,7 +1090,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
AseReasonCode.NONE,
|
||||
)
|
||||
|
||||
self.metadata = metadata
|
||||
self.metadata = le_audio.Metadata.from_bytes(metadata)
|
||||
self.state = self.State.ENABLING
|
||||
|
||||
return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
|
||||
@@ -1044,7 +1142,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
|
||||
AseReasonCode.NONE,
|
||||
)
|
||||
self.metadata = metadata
|
||||
self.metadata = le_audio.Metadata.from_bytes(metadata)
|
||||
return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
|
||||
|
||||
def on_release(self) -> Tuple[AseResponseCode, AseReasonCode]:
|
||||
@@ -1121,8 +1219,9 @@ class AseStateMachine(gatt.Characteristic):
|
||||
self.State.STREAMING,
|
||||
self.State.DISABLING,
|
||||
):
|
||||
metadata_bytes = bytes(self.metadata)
|
||||
additional_parameters = (
|
||||
bytes([self.cig_id, self.cis_id, len(self.metadata)]) + self.metadata
|
||||
bytes([self.cig_id, self.cis_id, len(metadata_bytes)]) + metadata_bytes
|
||||
)
|
||||
else:
|
||||
additional_parameters = b''
|
||||
|
||||
@@ -113,7 +113,7 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
||||
set_member_rank: Optional[int] = None,
|
||||
) -> None:
|
||||
if len(set_identity_resolving_key) != SET_IDENTITY_RESOLVING_KEY_LENGTH:
|
||||
raise ValueError(
|
||||
raise core.InvalidArgumentError(
|
||||
f'Invalid SIRK length {len(set_identity_resolving_key)}, expected {SET_IDENTITY_RESOLVING_KEY_LENGTH}'
|
||||
)
|
||||
|
||||
@@ -178,7 +178,7 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
||||
key = await connection.device.get_link_key(connection.peer_address)
|
||||
|
||||
if not key:
|
||||
raise RuntimeError('LTK or LinkKey is not present')
|
||||
raise core.InvalidOperationError('LTK or LinkKey is not present')
|
||||
|
||||
sirk_bytes = sef(key, self.set_identity_resolving_key)
|
||||
|
||||
@@ -234,7 +234,7 @@ class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
|
||||
'''Reads SIRK and decrypts if encrypted.'''
|
||||
response = await self.set_identity_resolving_key.read_value()
|
||||
if len(response) != SET_IDENTITY_RESOLVING_KEY_LENGTH + 1:
|
||||
raise RuntimeError('Invalid SIRK value')
|
||||
raise core.InvalidPacketError('Invalid SIRK value')
|
||||
|
||||
sirk_type = SirkType(response[0])
|
||||
if sirk_type == SirkType.PLAINTEXT:
|
||||
@@ -250,7 +250,7 @@ class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
|
||||
key = await device.get_link_key(connection.peer_address)
|
||||
|
||||
if not key:
|
||||
raise RuntimeError('LTK or LinkKey is not present')
|
||||
raise core.InvalidOperationError('LTK or LinkKey is not present')
|
||||
|
||||
sirk = sef(key, response[1:])
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
from enum import IntEnum
|
||||
import struct
|
||||
|
||||
from bumble import core
|
||||
from ..gatt_client import ProfileServiceProxy
|
||||
from ..att import ATT_Error
|
||||
from ..gatt import (
|
||||
@@ -59,17 +60,17 @@ class HeartRateService(TemplateService):
|
||||
rr_intervals=None,
|
||||
):
|
||||
if heart_rate < 0 or heart_rate > 0xFFFF:
|
||||
raise ValueError('heart_rate out of range')
|
||||
raise core.InvalidArgumentError('heart_rate out of range')
|
||||
|
||||
if energy_expended is not None and (
|
||||
energy_expended < 0 or energy_expended > 0xFFFF
|
||||
):
|
||||
raise ValueError('energy_expended out of range')
|
||||
raise core.InvalidArgumentError('energy_expended out of range')
|
||||
|
||||
if rr_intervals:
|
||||
for rr_interval in rr_intervals:
|
||||
if rr_interval < 0 or rr_interval * 1024 > 0xFFFF:
|
||||
raise ValueError('rr_intervals out of range')
|
||||
raise core.InvalidArgumentError('rr_intervals out of range')
|
||||
|
||||
self.heart_rate = heart_rate
|
||||
self.sensor_contact_detected = sensor_contact_detected
|
||||
|
||||
83
bumble/profiles/le_audio.py
Normal file
83
bumble/profiles/le_audio.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# 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 struct
|
||||
from typing import List, Type
|
||||
from typing_extensions import Self
|
||||
|
||||
from bumble import utils
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class Metadata:
|
||||
'''Bluetooth Assigned Numbers, Section 6.12.6 - Metadata LTV structures.
|
||||
|
||||
As Metadata fields may extend, and Spec doesn't forbid duplication, we don't parse
|
||||
Metadata into a key-value style dataclass here. Rather, we encourage users to parse
|
||||
again outside the lib.
|
||||
'''
|
||||
|
||||
class Tag(utils.OpenIntEnum):
|
||||
# fmt: off
|
||||
PREFERRED_AUDIO_CONTEXTS = 0x01
|
||||
STREAMING_AUDIO_CONTEXTS = 0x02
|
||||
PROGRAM_INFO = 0x03
|
||||
LANGUAGE = 0x04
|
||||
CCID_LIST = 0x05
|
||||
PARENTAL_RATING = 0x06
|
||||
PROGRAM_INFO_URI = 0x07
|
||||
AUDIO_ACTIVE_STATE = 0x08
|
||||
BROADCAST_AUDIO_IMMEDIATE_RENDERING_FLAG = 0x09
|
||||
ASSISTED_LISTENING_STREAM = 0x0A
|
||||
BROADCAST_NAME = 0x0B
|
||||
EXTENDED_METADATA = 0xFE
|
||||
VENDOR_SPECIFIC = 0xFF
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Entry:
|
||||
tag: Metadata.Tag
|
||||
data: bytes
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls: Type[Self], data: bytes) -> Self:
|
||||
return cls(tag=Metadata.Tag(data[0]), data=data[1:])
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return bytes([len(self.data) + 1, self.tag]) + self.data
|
||||
|
||||
entries: List[Entry] = dataclasses.field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls: Type[Self], data: bytes) -> Self:
|
||||
entries = []
|
||||
offset = 0
|
||||
length = len(data)
|
||||
while offset < length:
|
||||
entry_length = data[offset]
|
||||
offset += 1
|
||||
entries.append(cls.Entry.from_bytes(data[offset : offset + entry_length]))
|
||||
offset += entry_length
|
||||
|
||||
return cls(entries)
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return b''.join([bytes(entry) for entry in self.entries])
|
||||
448
bumble/profiles/mcp.py
Normal file
448
bumble/profiles/mcp.py
Normal file
@@ -0,0 +1,448 @@
|
||||
# Copyright 2021-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 enum
|
||||
import struct
|
||||
|
||||
from bumble import core
|
||||
from bumble import device
|
||||
from bumble import gatt
|
||||
from bumble import gatt_client
|
||||
from bumble import utils
|
||||
|
||||
from typing import Type, Optional, ClassVar, Dict, TYPE_CHECKING
|
||||
from typing_extensions import Self
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Constants
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PlayingOrder(utils.OpenIntEnum):
|
||||
'''See Media Control Service 3.15. Playing Order.'''
|
||||
|
||||
SINGLE_ONCE = 0x01
|
||||
SINGLE_REPEAT = 0x02
|
||||
IN_ORDER_ONCE = 0x03
|
||||
IN_ORDER_REPEAT = 0x04
|
||||
OLDEST_ONCE = 0x05
|
||||
OLDEST_REPEAT = 0x06
|
||||
NEWEST_ONCE = 0x07
|
||||
NEWEST_REPEAT = 0x08
|
||||
SHUFFLE_ONCE = 0x09
|
||||
SHUFFLE_REPEAT = 0x0A
|
||||
|
||||
|
||||
class PlayingOrderSupported(enum.IntFlag):
|
||||
'''See Media Control Service 3.16. Playing Orders Supported.'''
|
||||
|
||||
SINGLE_ONCE = 0x0001
|
||||
SINGLE_REPEAT = 0x0002
|
||||
IN_ORDER_ONCE = 0x0004
|
||||
IN_ORDER_REPEAT = 0x0008
|
||||
OLDEST_ONCE = 0x0010
|
||||
OLDEST_REPEAT = 0x0020
|
||||
NEWEST_ONCE = 0x0040
|
||||
NEWEST_REPEAT = 0x0080
|
||||
SHUFFLE_ONCE = 0x0100
|
||||
SHUFFLE_REPEAT = 0x0200
|
||||
|
||||
|
||||
class MediaState(utils.OpenIntEnum):
|
||||
'''See Media Control Service 3.17. Media State.'''
|
||||
|
||||
INACTIVE = 0x00
|
||||
PLAYING = 0x01
|
||||
PAUSED = 0x02
|
||||
SEEKING = 0x03
|
||||
|
||||
|
||||
class MediaControlPointOpcode(utils.OpenIntEnum):
|
||||
'''See Media Control Service 3.18. Media Control Point.'''
|
||||
|
||||
PLAY = 0x01
|
||||
PAUSE = 0x02
|
||||
FAST_REWIND = 0x03
|
||||
FAST_FORWARD = 0x04
|
||||
STOP = 0x05
|
||||
MOVE_RELATIVE = 0x10
|
||||
PREVIOUS_SEGMENT = 0x20
|
||||
NEXT_SEGMENT = 0x21
|
||||
FIRST_SEGMENT = 0x22
|
||||
LAST_SEGMENT = 0x23
|
||||
GOTO_SEGMENT = 0x24
|
||||
PREVIOUS_TRACK = 0x30
|
||||
NEXT_TRACK = 0x31
|
||||
FIRST_TRACK = 0x32
|
||||
LAST_TRACK = 0x33
|
||||
GOTO_TRACK = 0x34
|
||||
PREVIOUS_GROUP = 0x40
|
||||
NEXT_GROUP = 0x41
|
||||
FIRST_GROUP = 0x42
|
||||
LAST_GROUP = 0x43
|
||||
GOTO_GROUP = 0x44
|
||||
|
||||
|
||||
class MediaControlPointResultCode(enum.IntFlag):
|
||||
'''See Media Control Service 3.18.2. Media Control Point Notification.'''
|
||||
|
||||
SUCCESS = 0x01
|
||||
OPCODE_NOT_SUPPORTED = 0x02
|
||||
MEDIA_PLAYER_INACTIVE = 0x03
|
||||
COMMAND_CANNOT_BE_COMPLETED = 0x04
|
||||
|
||||
|
||||
class MediaControlPointOpcodeSupported(enum.IntFlag):
|
||||
'''See Media Control Service 3.19. Media Control Point Opcodes Supported.'''
|
||||
|
||||
PLAY = 0x00000001
|
||||
PAUSE = 0x00000002
|
||||
FAST_REWIND = 0x00000004
|
||||
FAST_FORWARD = 0x00000008
|
||||
STOP = 0x00000010
|
||||
MOVE_RELATIVE = 0x00000020
|
||||
PREVIOUS_SEGMENT = 0x00000040
|
||||
NEXT_SEGMENT = 0x00000080
|
||||
FIRST_SEGMENT = 0x00000100
|
||||
LAST_SEGMENT = 0x00000200
|
||||
GOTO_SEGMENT = 0x00000400
|
||||
PREVIOUS_TRACK = 0x00000800
|
||||
NEXT_TRACK = 0x00001000
|
||||
FIRST_TRACK = 0x00002000
|
||||
LAST_TRACK = 0x00004000
|
||||
GOTO_TRACK = 0x00008000
|
||||
PREVIOUS_GROUP = 0x00010000
|
||||
NEXT_GROUP = 0x00020000
|
||||
FIRST_GROUP = 0x00040000
|
||||
LAST_GROUP = 0x00080000
|
||||
GOTO_GROUP = 0x00100000
|
||||
|
||||
|
||||
class SearchControlPointItemType(utils.OpenIntEnum):
|
||||
'''See Media Control Service 3.20. Search Control Point.'''
|
||||
|
||||
TRACK_NAME = 0x01
|
||||
ARTIST_NAME = 0x02
|
||||
ALBUM_NAME = 0x03
|
||||
GROUP_NAME = 0x04
|
||||
EARLIEST_YEAR = 0x05
|
||||
LATEST_YEAR = 0x06
|
||||
GENRE = 0x07
|
||||
ONLY_TRACKS = 0x08
|
||||
ONLY_GROUPS = 0x09
|
||||
|
||||
|
||||
class ObjectType(utils.OpenIntEnum):
|
||||
'''See Media Control Service 4.4.1. Object Type field.'''
|
||||
|
||||
TASK = 0
|
||||
GROUP = 1
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ObjectId(int):
|
||||
'''See Media Control Service 4.4.2. Object ID field.'''
|
||||
|
||||
@classmethod
|
||||
def create_from_bytes(cls: Type[Self], data: bytes) -> Self:
|
||||
return cls(int.from_bytes(data, byteorder='little', signed=False))
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return self.to_bytes(6, 'little')
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class GroupObjectType:
|
||||
'''See Media Control Service 4.4. Group Object Type.'''
|
||||
|
||||
object_type: ObjectType
|
||||
object_id: ObjectId
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls: Type[Self], data: bytes) -> Self:
|
||||
return cls(
|
||||
object_type=ObjectType(data[0]),
|
||||
object_id=ObjectId.create_from_bytes(data[1:]),
|
||||
)
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return bytes([self.object_type]) + bytes(self.object_id)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Server
|
||||
# -----------------------------------------------------------------------------
|
||||
class MediaControlService(gatt.TemplateService):
|
||||
'''Media Control Service server implementation, only for testing currently.'''
|
||||
|
||||
UUID = gatt.GATT_MEDIA_CONTROL_SERVICE
|
||||
|
||||
def __init__(self, media_player_name: Optional[str] = None) -> None:
|
||||
self.track_position = 0
|
||||
|
||||
self.media_player_name_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_MEDIA_PLAYER_NAME_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.READ
|
||||
| gatt.Characteristic.Properties.NOTIFY,
|
||||
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||
value=media_player_name or 'Bumble Player',
|
||||
)
|
||||
self.track_changed_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_TRACK_CHANGED_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.NOTIFY,
|
||||
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||
value=b'',
|
||||
)
|
||||
self.track_title_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_TRACK_TITLE_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.READ
|
||||
| gatt.Characteristic.Properties.NOTIFY,
|
||||
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||
value=b'',
|
||||
)
|
||||
self.track_duration_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_TRACK_DURATION_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.READ
|
||||
| gatt.Characteristic.Properties.NOTIFY,
|
||||
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||
value=b'',
|
||||
)
|
||||
self.track_position_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_TRACK_POSITION_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.READ
|
||||
| gatt.Characteristic.Properties.WRITE
|
||||
| gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE
|
||||
| gatt.Characteristic.Properties.NOTIFY,
|
||||
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
|
||||
| gatt.Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
|
||||
value=b'',
|
||||
)
|
||||
self.media_state_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_MEDIA_STATE_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.READ
|
||||
| gatt.Characteristic.Properties.NOTIFY,
|
||||
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||
value=b'',
|
||||
)
|
||||
self.media_control_point_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_MEDIA_CONTROL_POINT_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.WRITE
|
||||
| gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE
|
||||
| gatt.Characteristic.Properties.NOTIFY,
|
||||
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
|
||||
| gatt.Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
|
||||
value=gatt.CharacteristicValue(write=self.on_media_control_point),
|
||||
)
|
||||
self.media_control_point_opcodes_supported_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.READ
|
||||
| gatt.Characteristic.Properties.NOTIFY,
|
||||
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||
value=b'',
|
||||
)
|
||||
self.content_control_id_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_CONTENT_CONTROL_ID_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.READ,
|
||||
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||
value=b'',
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
[
|
||||
self.media_player_name_characteristic,
|
||||
self.track_changed_characteristic,
|
||||
self.track_title_characteristic,
|
||||
self.track_duration_characteristic,
|
||||
self.track_position_characteristic,
|
||||
self.media_state_characteristic,
|
||||
self.media_control_point_characteristic,
|
||||
self.media_control_point_opcodes_supported_characteristic,
|
||||
self.content_control_id_characteristic,
|
||||
]
|
||||
)
|
||||
|
||||
async def on_media_control_point(
|
||||
self, connection: Optional[device.Connection], data: bytes
|
||||
) -> None:
|
||||
if not connection:
|
||||
raise core.InvalidStateError()
|
||||
|
||||
opcode = MediaControlPointOpcode(data[0])
|
||||
|
||||
await connection.device.notify_subscriber(
|
||||
connection,
|
||||
self.media_control_point_characteristic,
|
||||
value=bytes([opcode, MediaControlPointResultCode.SUCCESS]),
|
||||
)
|
||||
|
||||
|
||||
class GenericMediaControlService(MediaControlService):
|
||||
UUID = gatt.GATT_GENERIC_MEDIA_CONTROL_SERVICE
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Client
|
||||
# -----------------------------------------------------------------------------
|
||||
class MediaControlServiceProxy(
|
||||
gatt_client.ProfileServiceProxy, utils.CompositeEventEmitter
|
||||
):
|
||||
SERVICE_CLASS = MediaControlService
|
||||
|
||||
_CHARACTERISTICS: ClassVar[Dict[str, core.UUID]] = {
|
||||
'media_player_name': gatt.GATT_MEDIA_PLAYER_NAME_CHARACTERISTIC,
|
||||
'media_player_icon_object_id': gatt.GATT_MEDIA_PLAYER_ICON_OBJECT_ID_CHARACTERISTIC,
|
||||
'media_player_icon_url': gatt.GATT_MEDIA_PLAYER_ICON_URL_CHARACTERISTIC,
|
||||
'track_changed': gatt.GATT_TRACK_CHANGED_CHARACTERISTIC,
|
||||
'track_title': gatt.GATT_TRACK_TITLE_CHARACTERISTIC,
|
||||
'track_duration': gatt.GATT_TRACK_DURATION_CHARACTERISTIC,
|
||||
'track_position': gatt.GATT_TRACK_POSITION_CHARACTERISTIC,
|
||||
'playback_speed': gatt.GATT_PLAYBACK_SPEED_CHARACTERISTIC,
|
||||
'seeking_speed': gatt.GATT_SEEKING_SPEED_CHARACTERISTIC,
|
||||
'current_track_segments_object_id': gatt.GATT_CURRENT_TRACK_SEGMENTS_OBJECT_ID_CHARACTERISTIC,
|
||||
'current_track_object_id': gatt.GATT_CURRENT_TRACK_OBJECT_ID_CHARACTERISTIC,
|
||||
'next_track_object_id': gatt.GATT_NEXT_TRACK_OBJECT_ID_CHARACTERISTIC,
|
||||
'parent_group_object_id': gatt.GATT_PARENT_GROUP_OBJECT_ID_CHARACTERISTIC,
|
||||
'current_group_object_id': gatt.GATT_CURRENT_GROUP_OBJECT_ID_CHARACTERISTIC,
|
||||
'playing_order': gatt.GATT_PLAYING_ORDER_CHARACTERISTIC,
|
||||
'playing_orders_supported': gatt.GATT_PLAYING_ORDERS_SUPPORTED_CHARACTERISTIC,
|
||||
'media_state': gatt.GATT_MEDIA_STATE_CHARACTERISTIC,
|
||||
'media_control_point': gatt.GATT_MEDIA_CONTROL_POINT_CHARACTERISTIC,
|
||||
'media_control_point_opcodes_supported': gatt.GATT_MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_CHARACTERISTIC,
|
||||
'search_control_point': gatt.GATT_SEARCH_CONTROL_POINT_CHARACTERISTIC,
|
||||
'search_results_object_id': gatt.GATT_SEARCH_RESULTS_OBJECT_ID_CHARACTERISTIC,
|
||||
'content_control_id': gatt.GATT_CONTENT_CONTROL_ID_CHARACTERISTIC,
|
||||
}
|
||||
|
||||
media_player_name: Optional[gatt_client.CharacteristicProxy] = None
|
||||
media_player_icon_object_id: Optional[gatt_client.CharacteristicProxy] = None
|
||||
media_player_icon_url: Optional[gatt_client.CharacteristicProxy] = None
|
||||
track_changed: Optional[gatt_client.CharacteristicProxy] = None
|
||||
track_title: Optional[gatt_client.CharacteristicProxy] = None
|
||||
track_duration: Optional[gatt_client.CharacteristicProxy] = None
|
||||
track_position: Optional[gatt_client.CharacteristicProxy] = None
|
||||
playback_speed: Optional[gatt_client.CharacteristicProxy] = None
|
||||
seeking_speed: Optional[gatt_client.CharacteristicProxy] = None
|
||||
current_track_segments_object_id: Optional[gatt_client.CharacteristicProxy] = None
|
||||
current_track_object_id: Optional[gatt_client.CharacteristicProxy] = None
|
||||
next_track_object_id: Optional[gatt_client.CharacteristicProxy] = None
|
||||
parent_group_object_id: Optional[gatt_client.CharacteristicProxy] = None
|
||||
current_group_object_id: Optional[gatt_client.CharacteristicProxy] = None
|
||||
playing_order: Optional[gatt_client.CharacteristicProxy] = None
|
||||
playing_orders_supported: Optional[gatt_client.CharacteristicProxy] = None
|
||||
media_state: Optional[gatt_client.CharacteristicProxy] = None
|
||||
media_control_point: Optional[gatt_client.CharacteristicProxy] = None
|
||||
media_control_point_opcodes_supported: Optional[gatt_client.CharacteristicProxy] = (
|
||||
None
|
||||
)
|
||||
search_control_point: Optional[gatt_client.CharacteristicProxy] = None
|
||||
search_results_object_id: Optional[gatt_client.CharacteristicProxy] = None
|
||||
content_control_id: Optional[gatt_client.CharacteristicProxy] = None
|
||||
|
||||
if TYPE_CHECKING:
|
||||
media_control_point_notifications: asyncio.Queue[bytes]
|
||||
|
||||
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
||||
utils.CompositeEventEmitter.__init__(self)
|
||||
self.service_proxy = service_proxy
|
||||
self.lock = asyncio.Lock()
|
||||
self.media_control_point_notifications = asyncio.Queue()
|
||||
|
||||
for field, uuid in self._CHARACTERISTICS.items():
|
||||
if characteristics := service_proxy.get_characteristics_by_uuid(uuid):
|
||||
setattr(self, field, characteristics[0])
|
||||
|
||||
async def subscribe_characteristics(self) -> None:
|
||||
if self.media_control_point:
|
||||
await self.media_control_point.subscribe(self._on_media_control_point)
|
||||
if self.media_state:
|
||||
await self.media_state.subscribe(self._on_media_state)
|
||||
if self.track_changed:
|
||||
await self.track_changed.subscribe(self._on_track_changed)
|
||||
if self.track_title:
|
||||
await self.track_title.subscribe(self._on_track_title)
|
||||
if self.track_duration:
|
||||
await self.track_duration.subscribe(self._on_track_duration)
|
||||
if self.track_position:
|
||||
await self.track_position.subscribe(self._on_track_position)
|
||||
|
||||
async def write_control_point(
|
||||
self, opcode: MediaControlPointOpcode
|
||||
) -> MediaControlPointResultCode:
|
||||
'''Writes a Media Control Point Opcode to peer and waits for the notification.
|
||||
|
||||
The write operation will be executed when there isn't other pending commands.
|
||||
|
||||
Args:
|
||||
opcode: opcode defined in `MediaControlPointOpcode`.
|
||||
|
||||
Returns:
|
||||
Response code provided in `MediaControlPointResultCode`
|
||||
|
||||
Raises:
|
||||
InvalidOperationError: Server does not have Media Control Point Characteristic.
|
||||
InvalidStateError: Server replies a notification with mismatched opcode.
|
||||
'''
|
||||
if not self.media_control_point:
|
||||
raise core.InvalidOperationError("Peer does not have media control point")
|
||||
|
||||
async with self.lock:
|
||||
await self.media_control_point.write_value(
|
||||
bytes([opcode]),
|
||||
with_response=False,
|
||||
)
|
||||
|
||||
(
|
||||
response_opcode,
|
||||
response_code,
|
||||
) = await self.media_control_point_notifications.get()
|
||||
if response_opcode != opcode:
|
||||
raise core.InvalidStateError(
|
||||
f"Expected {opcode} notification, but get {response_opcode}"
|
||||
)
|
||||
return MediaControlPointResultCode(response_code)
|
||||
|
||||
def _on_media_control_point(self, data: bytes) -> None:
|
||||
self.media_control_point_notifications.put_nowait(data)
|
||||
|
||||
def _on_media_state(self, data: bytes) -> None:
|
||||
self.emit('media_state', MediaState(data[0]))
|
||||
|
||||
def _on_track_changed(self, data: bytes) -> None:
|
||||
del data
|
||||
self.emit('track_changed')
|
||||
|
||||
def _on_track_title(self, data: bytes) -> None:
|
||||
self.emit('track_title', data.decode("utf-8"))
|
||||
|
||||
def _on_track_duration(self, data: bytes) -> None:
|
||||
self.emit('track_duration', struct.unpack_from('<i', data)[0])
|
||||
|
||||
def _on_track_position(self, data: bytes) -> None:
|
||||
self.emit('track_position', struct.unpack_from('<i', data)[0])
|
||||
|
||||
|
||||
class GenericMediaControlServiceProxy(MediaControlServiceProxy):
|
||||
SERVICE_CLASS = GenericMediaControlService
|
||||
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)
|
||||
)
|
||||
@@ -36,7 +36,9 @@ from .core import (
|
||||
BT_RFCOMM_PROTOCOL_ID,
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
BT_L2CAP_PROTOCOL_ID,
|
||||
InvalidArgumentError,
|
||||
InvalidStateError,
|
||||
InvalidPacketError,
|
||||
ProtocolError,
|
||||
)
|
||||
|
||||
@@ -335,7 +337,7 @@ class RFCOMM_Frame:
|
||||
frame = RFCOMM_Frame(frame_type, c_r, dlci, p_f, information)
|
||||
if frame.fcs != fcs:
|
||||
logger.warning(f'FCS mismatch: got {fcs:02X}, expected {frame.fcs:02X}')
|
||||
raise ValueError('fcs mismatch')
|
||||
raise InvalidPacketError('fcs mismatch')
|
||||
|
||||
return frame
|
||||
|
||||
@@ -713,7 +715,7 @@ class DLC(EventEmitter):
|
||||
# Automatically convert strings to bytes using UTF-8
|
||||
data = data.encode('utf-8')
|
||||
else:
|
||||
raise ValueError('write only accept bytes or strings')
|
||||
raise InvalidArgumentError('write only accept bytes or strings')
|
||||
|
||||
self.tx_buffer += data
|
||||
self.drained.clear()
|
||||
@@ -734,7 +736,16 @@ class DLC(EventEmitter):
|
||||
self.emit('close')
|
||||
|
||||
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}'
|
||||
')'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -23,7 +23,7 @@ from typing_extensions import Self
|
||||
|
||||
from . import core, l2cap
|
||||
from .colors import color
|
||||
from .core import InvalidStateError
|
||||
from .core import InvalidStateError, InvalidArgumentError, InvalidPacketError
|
||||
from .hci import HCI_Object, name_or_number, key_with_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -189,7 +189,9 @@ class DataElement:
|
||||
self.bytes = None
|
||||
if element_type in (DataElement.UNSIGNED_INTEGER, DataElement.SIGNED_INTEGER):
|
||||
if value_size is None:
|
||||
raise ValueError('integer types must have a value size specified')
|
||||
raise InvalidArgumentError(
|
||||
'integer types must have a value size specified'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def nil() -> DataElement:
|
||||
@@ -265,7 +267,7 @@ class DataElement:
|
||||
if len(data) == 8:
|
||||
return struct.unpack('>Q', data)[0]
|
||||
|
||||
raise ValueError(f'invalid integer length {len(data)}')
|
||||
raise InvalidPacketError(f'invalid integer length {len(data)}')
|
||||
|
||||
@staticmethod
|
||||
def signed_integer_from_bytes(data):
|
||||
@@ -281,7 +283,7 @@ class DataElement:
|
||||
if len(data) == 8:
|
||||
return struct.unpack('>q', data)[0]
|
||||
|
||||
raise ValueError(f'invalid integer length {len(data)}')
|
||||
raise InvalidPacketError(f'invalid integer length {len(data)}')
|
||||
|
||||
@staticmethod
|
||||
def list_from_bytes(data):
|
||||
@@ -354,7 +356,7 @@ class DataElement:
|
||||
data = b''
|
||||
elif self.type == DataElement.UNSIGNED_INTEGER:
|
||||
if self.value < 0:
|
||||
raise ValueError('UNSIGNED_INTEGER cannot be negative')
|
||||
raise InvalidArgumentError('UNSIGNED_INTEGER cannot be negative')
|
||||
|
||||
if self.value_size == 1:
|
||||
data = struct.pack('B', self.value)
|
||||
@@ -365,7 +367,7 @@ class DataElement:
|
||||
elif self.value_size == 8:
|
||||
data = struct.pack('>Q', self.value)
|
||||
else:
|
||||
raise ValueError('invalid value_size')
|
||||
raise InvalidArgumentError('invalid value_size')
|
||||
elif self.type == DataElement.SIGNED_INTEGER:
|
||||
if self.value_size == 1:
|
||||
data = struct.pack('b', self.value)
|
||||
@@ -376,7 +378,7 @@ class DataElement:
|
||||
elif self.value_size == 8:
|
||||
data = struct.pack('>q', self.value)
|
||||
else:
|
||||
raise ValueError('invalid value_size')
|
||||
raise InvalidArgumentError('invalid value_size')
|
||||
elif self.type == DataElement.UUID:
|
||||
data = bytes(reversed(bytes(self.value)))
|
||||
elif self.type == DataElement.URL:
|
||||
@@ -392,7 +394,7 @@ class DataElement:
|
||||
size_bytes = b''
|
||||
if self.type == DataElement.NIL:
|
||||
if size != 0:
|
||||
raise ValueError('NIL must be empty')
|
||||
raise InvalidArgumentError('NIL must be empty')
|
||||
size_index = 0
|
||||
elif self.type in (
|
||||
DataElement.UNSIGNED_INTEGER,
|
||||
@@ -410,7 +412,7 @@ class DataElement:
|
||||
elif size == 16:
|
||||
size_index = 4
|
||||
else:
|
||||
raise ValueError('invalid data size')
|
||||
raise InvalidArgumentError('invalid data size')
|
||||
elif self.type in (
|
||||
DataElement.TEXT_STRING,
|
||||
DataElement.SEQUENCE,
|
||||
@@ -427,10 +429,10 @@ class DataElement:
|
||||
size_index = 7
|
||||
size_bytes = struct.pack('>I', size)
|
||||
else:
|
||||
raise ValueError('invalid data size')
|
||||
raise InvalidArgumentError('invalid data size')
|
||||
elif self.type == DataElement.BOOLEAN:
|
||||
if size != 1:
|
||||
raise ValueError('boolean must be 1 byte')
|
||||
raise InvalidArgumentError('boolean must be 1 byte')
|
||||
size_index = 0
|
||||
|
||||
self.bytes = bytes([self.type << 3 | size_index]) + size_bytes + data
|
||||
|
||||
@@ -55,6 +55,7 @@ from .core import (
|
||||
BT_CENTRAL_ROLE,
|
||||
BT_LE_TRANSPORT,
|
||||
AdvertisingData,
|
||||
InvalidArgumentError,
|
||||
ProtocolError,
|
||||
name_or_number,
|
||||
)
|
||||
@@ -766,8 +767,11 @@ class Session:
|
||||
self.oob_data_flag = 0 if pairing_config.oob is None else 1
|
||||
|
||||
# Set up addresses
|
||||
self_address = connection.self_address
|
||||
self_address = connection.self_resolvable_address or connection.self_address
|
||||
peer_address = connection.peer_resolvable_address or connection.peer_address
|
||||
logger.debug(
|
||||
f"pairing with self_address={self_address}, peer_address={peer_address}"
|
||||
)
|
||||
if self.is_initiator:
|
||||
self.ia = bytes(self_address)
|
||||
self.iat = 1 if self_address.is_random else 0
|
||||
@@ -784,7 +788,7 @@ class Session:
|
||||
self.peer_oob_data = pairing_config.oob.peer_data
|
||||
if pairing_config.sc:
|
||||
if pairing_config.oob.our_context is None:
|
||||
raise ValueError(
|
||||
raise InvalidArgumentError(
|
||||
"oob pairing config requires a context when sc is True"
|
||||
)
|
||||
self.r = pairing_config.oob.our_context.r
|
||||
@@ -793,7 +797,7 @@ class Session:
|
||||
self.tk = pairing_config.oob.legacy_context.tk
|
||||
else:
|
||||
if pairing_config.oob.legacy_context is None:
|
||||
raise ValueError(
|
||||
raise InvalidArgumentError(
|
||||
"oob pairing config requires a legacy context when sc is False"
|
||||
)
|
||||
self.r = bytes(16)
|
||||
@@ -1075,9 +1079,9 @@ class Session:
|
||||
|
||||
def send_identity_address_command(self) -> None:
|
||||
identity_address = {
|
||||
None: self.connection.self_address,
|
||||
None: self.manager.device.static_address,
|
||||
Address.PUBLIC_DEVICE_ADDRESS: self.manager.device.public_address,
|
||||
Address.RANDOM_DEVICE_ADDRESS: self.manager.device.random_address,
|
||||
Address.RANDOM_DEVICE_ADDRESS: self.manager.device.static_address,
|
||||
}[self.pairing_config.identity_address_type]
|
||||
self.send_command(
|
||||
SMP_Identity_Address_Information_Command(
|
||||
|
||||
@@ -23,6 +23,7 @@ import datetime
|
||||
from typing import BinaryIO, Generator
|
||||
import os
|
||||
|
||||
from bumble import core
|
||||
from bumble.hci import HCI_COMMAND_PACKET, HCI_EVENT_PACKET
|
||||
|
||||
|
||||
@@ -138,13 +139,13 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]:
|
||||
|
||||
"""
|
||||
if ':' not in spec:
|
||||
raise ValueError('snooper type prefix missing')
|
||||
raise core.InvalidArgumentError('snooper type prefix missing')
|
||||
|
||||
snooper_type, snooper_args = spec.split(':', maxsplit=1)
|
||||
|
||||
if snooper_type == 'btsnoop':
|
||||
if ':' not in snooper_args:
|
||||
raise ValueError('I/O type for btsnoop snooper type missing')
|
||||
raise core.InvalidArgumentError('I/O type for btsnoop snooper type missing')
|
||||
|
||||
io_type, io_name = snooper_args.split(':', maxsplit=1)
|
||||
if io_type == 'file':
|
||||
@@ -165,6 +166,6 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]:
|
||||
_SNOOPER_INSTANCE_COUNT -= 1
|
||||
return
|
||||
|
||||
raise ValueError(f'I/O type {io_type} not supported')
|
||||
raise core.InvalidArgumentError(f'I/O type {io_type} not supported')
|
||||
|
||||
raise ValueError(f'snooper type {snooper_type} not found')
|
||||
raise core.InvalidArgumentError(f'snooper type {snooper_type} not found')
|
||||
|
||||
@@ -20,7 +20,7 @@ import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from .common import Transport, AsyncPipeSink, SnoopingTransport
|
||||
from .common import Transport, AsyncPipeSink, SnoopingTransport, TransportSpecError
|
||||
from ..snoop import create_snooper
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -180,7 +180,13 @@ async def _open_transport(scheme: str, spec: Optional[str]) -> Transport:
|
||||
|
||||
return await open_android_netsim_transport(spec)
|
||||
|
||||
raise ValueError('unknown transport scheme')
|
||||
if scheme == 'unix':
|
||||
from .unix import open_unix_client_transport
|
||||
|
||||
assert spec
|
||||
return await open_unix_client_transport(spec)
|
||||
|
||||
raise TransportSpecError('unknown transport scheme')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -20,7 +20,13 @@ import grpc.aio
|
||||
|
||||
from typing import Optional, Union
|
||||
|
||||
from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink, Transport
|
||||
from .common import (
|
||||
PumpedTransport,
|
||||
PumpedPacketSource,
|
||||
PumpedPacketSink,
|
||||
Transport,
|
||||
TransportSpecError,
|
||||
)
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from .grpc_protobuf.emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
|
||||
@@ -77,7 +83,7 @@ async def open_android_emulator_transport(spec: Optional[str]) -> Transport:
|
||||
elif ':' in param:
|
||||
server_host, server_port = param.split(':')
|
||||
else:
|
||||
raise ValueError('invalid parameter')
|
||||
raise TransportSpecError('invalid parameter')
|
||||
|
||||
# Connect to the gRPC server
|
||||
server_address = f'{server_host}:{server_port}'
|
||||
@@ -94,7 +100,7 @@ async def open_android_emulator_transport(spec: Optional[str]) -> Transport:
|
||||
service = VhciForwardingServiceStub(channel)
|
||||
hci_device = HciDevice(service.attachVhci())
|
||||
else:
|
||||
raise ValueError('invalid mode')
|
||||
raise TransportSpecError('invalid mode')
|
||||
|
||||
# Create the transport object
|
||||
class EmulatorTransport(PumpedTransport):
|
||||
|
||||
@@ -31,6 +31,8 @@ from .common import (
|
||||
PumpedPacketSource,
|
||||
PumpedPacketSink,
|
||||
Transport,
|
||||
TransportSpecError,
|
||||
TransportInitError,
|
||||
)
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
@@ -135,7 +137,7 @@ async def open_android_netsim_controller_transport(
|
||||
server_host: Optional[str], server_port: int, options: Dict[str, str]
|
||||
) -> Transport:
|
||||
if not server_port:
|
||||
raise ValueError('invalid port')
|
||||
raise TransportSpecError('invalid port')
|
||||
if server_host == '_' or not server_host:
|
||||
server_host = 'localhost'
|
||||
|
||||
@@ -288,7 +290,7 @@ async def open_android_netsim_host_transport_with_address(
|
||||
instance_number = 0 if options is None else int(options.get('instance', '0'))
|
||||
server_port = find_grpc_port(instance_number)
|
||||
if not server_port:
|
||||
raise RuntimeError('gRPC server port not found')
|
||||
raise TransportInitError('gRPC server port not found')
|
||||
|
||||
# Connect to the gRPC server
|
||||
server_address = f'{server_host}:{server_port}'
|
||||
@@ -326,7 +328,7 @@ async def open_android_netsim_host_transport_with_channel(
|
||||
|
||||
if response_type == 'error':
|
||||
logger.warning(f'received error: {response.error}')
|
||||
raise RuntimeError(response.error)
|
||||
raise TransportInitError(response.error)
|
||||
|
||||
if response_type == 'hci_packet':
|
||||
return (
|
||||
@@ -334,7 +336,7 @@ async def open_android_netsim_host_transport_with_channel(
|
||||
+ response.hci_packet.packet
|
||||
)
|
||||
|
||||
raise ValueError('unsupported response type')
|
||||
raise TransportSpecError('unsupported response type')
|
||||
|
||||
async def write(self, packet):
|
||||
await self.hci_device.write(
|
||||
@@ -429,7 +431,7 @@ async def open_android_netsim_transport(spec: Optional[str]) -> Transport:
|
||||
options: Dict[str, str] = {}
|
||||
for param in params[params_offset:]:
|
||||
if '=' not in param:
|
||||
raise ValueError('invalid parameter, expected <name>=<value>')
|
||||
raise TransportSpecError('invalid parameter, expected <name>=<value>')
|
||||
option_name, option_value = param.split('=')
|
||||
options[option_name] = option_value
|
||||
|
||||
@@ -440,7 +442,7 @@ async def open_android_netsim_transport(spec: Optional[str]) -> Transport:
|
||||
)
|
||||
if mode == 'controller':
|
||||
if host is None:
|
||||
raise ValueError('<host>:<port> missing')
|
||||
raise TransportSpecError('<host>:<port> missing')
|
||||
return await open_android_netsim_controller_transport(host, port, options)
|
||||
|
||||
raise ValueError('invalid mode option')
|
||||
raise TransportSpecError('invalid mode option')
|
||||
|
||||
@@ -23,6 +23,7 @@ import logging
|
||||
import io
|
||||
from typing import Any, ContextManager, Tuple, Optional, Protocol, Dict
|
||||
|
||||
from bumble import core
|
||||
from bumble import hci
|
||||
from bumble.colors import color
|
||||
from bumble.snoop import Snooper
|
||||
@@ -49,10 +50,16 @@ HCI_PACKET_INFO: Dict[int, Tuple[int, int, str]] = {
|
||||
# -----------------------------------------------------------------------------
|
||||
# Errors
|
||||
# -----------------------------------------------------------------------------
|
||||
class TransportLostError(Exception):
|
||||
"""
|
||||
The Transport has been lost/disconnected.
|
||||
"""
|
||||
class TransportLostError(core.BaseBumbleError, RuntimeError):
|
||||
"""The Transport has been lost/disconnected."""
|
||||
|
||||
|
||||
class TransportInitError(core.BaseBumbleError, RuntimeError):
|
||||
"""Error raised when the transport cannot be initialized."""
|
||||
|
||||
|
||||
class TransportSpecError(core.BaseBumbleError, ValueError):
|
||||
"""Error raised when the transport spec is invalid."""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -132,7 +139,9 @@ class PacketParser:
|
||||
packet_type
|
||||
) or self.extended_packet_info.get(packet_type)
|
||||
if self.packet_info is None:
|
||||
raise ValueError(f'invalid packet type {packet_type}')
|
||||
raise core.InvalidPacketError(
|
||||
f'invalid packet type {packet_type}'
|
||||
)
|
||||
self.state = PacketParser.NEED_LENGTH
|
||||
self.bytes_needed = self.packet_info[0] + self.packet_info[1]
|
||||
elif self.state == PacketParser.NEED_LENGTH:
|
||||
@@ -178,19 +187,19 @@ class PacketReader:
|
||||
# Get the packet info based on its type
|
||||
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
||||
if packet_info is None:
|
||||
raise ValueError(f'invalid packet type {packet_type[0]} found')
|
||||
raise core.InvalidPacketError(f'invalid packet type {packet_type[0]} found')
|
||||
|
||||
# Read the header (that includes the length)
|
||||
header_size = packet_info[0] + packet_info[1]
|
||||
header = self.source.read(header_size)
|
||||
if len(header) != header_size:
|
||||
raise ValueError('packet too short')
|
||||
raise core.InvalidPacketError('packet too short')
|
||||
|
||||
# Read the body
|
||||
body_length = struct.unpack_from(packet_info[2], header, packet_info[1])[0]
|
||||
body = self.source.read(body_length)
|
||||
if len(body) != body_length:
|
||||
raise ValueError('packet too short')
|
||||
raise core.InvalidPacketError('packet too short')
|
||||
|
||||
return packet_type + header + body
|
||||
|
||||
@@ -211,7 +220,7 @@ class AsyncPacketReader:
|
||||
# Get the packet info based on its type
|
||||
packet_info = HCI_PACKET_INFO.get(packet_type[0])
|
||||
if packet_info is None:
|
||||
raise ValueError(f'invalid packet type {packet_type[0]} found')
|
||||
raise core.InvalidPacketError(f'invalid packet type {packet_type[0]} found')
|
||||
|
||||
# Read the header (that includes the length)
|
||||
header_size = packet_info[0] + packet_info[1]
|
||||
@@ -420,7 +429,7 @@ class SnoopingTransport(Transport):
|
||||
return SnoopingTransport(
|
||||
transport, exit_stack.enter_context(snooper), exit_stack.pop_all().close
|
||||
)
|
||||
raise RuntimeError('unexpected code path') # Satisfy the type checker
|
||||
raise core.UnreachableError() # Satisfy the type checker
|
||||
|
||||
class Source:
|
||||
sink: TransportSink
|
||||
|
||||
@@ -29,7 +29,7 @@ from usb.core import USBError
|
||||
from usb.util import CTRL_TYPE_CLASS, CTRL_RECIPIENT_OTHER
|
||||
from usb.legacy import REQ_SET_FEATURE, REQ_CLEAR_FEATURE, CLASS_HUB
|
||||
|
||||
from .common import Transport, ParserSource
|
||||
from .common import Transport, ParserSource, TransportInitError
|
||||
from .. import hci
|
||||
from ..colors import color
|
||||
|
||||
@@ -259,7 +259,7 @@ async def open_pyusb_transport(spec: str) -> Transport:
|
||||
device = None
|
||||
|
||||
if device is None:
|
||||
raise ValueError('device not found')
|
||||
raise TransportInitError('device not found')
|
||||
logger.debug(f'USB Device: {device}')
|
||||
|
||||
# Power Cycle the device
|
||||
|
||||
56
bumble/transport/unix.py
Normal file
56
bumble/transport/unix.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# Copyright 2021-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
|
||||
|
||||
from .common import Transport, StreamPacketSource, StreamPacketSink
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_unix_client_transport(spec: str) -> Transport:
|
||||
'''Open a UNIX socket client transport.
|
||||
|
||||
The parameter is the path of unix socket. For abstract socket, the first character
|
||||
needs to be '@'.
|
||||
|
||||
Example:
|
||||
* /tmp/hci.socket
|
||||
* @hci_socket
|
||||
'''
|
||||
|
||||
class UnixPacketSource(StreamPacketSource):
|
||||
def connection_lost(self, exc):
|
||||
logger.debug(f'connection lost: {exc}')
|
||||
self.on_transport_lost()
|
||||
|
||||
# For abstract socket, the first character should be null character.
|
||||
if spec.startswith('@'):
|
||||
spec = '\0' + spec[1:]
|
||||
|
||||
(
|
||||
unix_transport,
|
||||
packet_source,
|
||||
) = await asyncio.get_running_loop().create_unix_connection(UnixPacketSource, spec)
|
||||
packet_sink = StreamPacketSink(unix_transport)
|
||||
|
||||
return Transport(packet_source, packet_sink)
|
||||
@@ -15,19 +15,18 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import collections
|
||||
import ctypes
|
||||
import platform
|
||||
|
||||
import usb1
|
||||
|
||||
from bumble.transport.common import Transport, ParserSource
|
||||
from bumble.transport.common import Transport, ParserSource, TransportInitError
|
||||
from bumble import hci
|
||||
from bumble.colors import color
|
||||
from bumble.utils import AsyncRunner
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -115,13 +114,17 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
self.device = device
|
||||
self.acl_out = acl_out
|
||||
self.acl_out_transfer = device.getTransfer()
|
||||
self.packets = collections.deque() # Queue of packets waiting to be sent
|
||||
self.acl_out_transfer_ready = asyncio.Semaphore(1)
|
||||
self.packets: asyncio.Queue[bytes] = (
|
||||
asyncio.Queue()
|
||||
) # Queue of packets waiting to be sent
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.queue_task = None
|
||||
self.cancel_done = self.loop.create_future()
|
||||
self.closed = False
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
self.queue_task = asyncio.create_task(self.process_queue())
|
||||
|
||||
def on_packet(self, packet):
|
||||
# Ignore packets if we're closed
|
||||
@@ -133,62 +136,64 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
return
|
||||
|
||||
# Queue the packet
|
||||
self.packets.append(packet)
|
||||
if len(self.packets) == 1:
|
||||
# The queue was previously empty, re-prime the pump
|
||||
self.process_queue()
|
||||
self.packets.put_nowait(packet)
|
||||
|
||||
def transfer_callback(self, transfer):
|
||||
self.acl_out_transfer_ready.release()
|
||||
status = transfer.getStatus()
|
||||
|
||||
# pylint: disable=no-member
|
||||
if status == usb1.TRANSFER_COMPLETED:
|
||||
self.loop.call_soon_threadsafe(self.on_packet_sent)
|
||||
elif status == usb1.TRANSFER_CANCELLED:
|
||||
if status == usb1.TRANSFER_CANCELLED:
|
||||
self.loop.call_soon_threadsafe(self.cancel_done.set_result, None)
|
||||
else:
|
||||
return
|
||||
|
||||
if status != usb1.TRANSFER_COMPLETED:
|
||||
logger.warning(
|
||||
color(f'!!! OUT transfer not completed: status={status}', 'red')
|
||||
)
|
||||
|
||||
def on_packet_sent(self):
|
||||
if self.packets:
|
||||
self.packets.popleft()
|
||||
self.process_queue()
|
||||
async def process_queue(self):
|
||||
while True:
|
||||
# Wait for a packet to transfer.
|
||||
packet = await self.packets.get()
|
||||
|
||||
def process_queue(self):
|
||||
if len(self.packets) == 0:
|
||||
return # Nothing to do
|
||||
# Wait until we can start a transfer.
|
||||
await self.acl_out_transfer_ready.acquire()
|
||||
|
||||
packet = self.packets[0]
|
||||
packet_type = packet[0]
|
||||
if packet_type == hci.HCI_ACL_DATA_PACKET:
|
||||
self.acl_out_transfer.setBulk(
|
||||
self.acl_out, packet[1:], callback=self.transfer_callback
|
||||
)
|
||||
self.acl_out_transfer.submit()
|
||||
elif packet_type == hci.HCI_COMMAND_PACKET:
|
||||
self.acl_out_transfer.setControl(
|
||||
USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
packet[1:],
|
||||
callback=self.transfer_callback,
|
||||
)
|
||||
self.acl_out_transfer.submit()
|
||||
else:
|
||||
logger.warning(color(f'unsupported packet type {packet_type}', 'red'))
|
||||
# Transfer the packet.
|
||||
packet_type = packet[0]
|
||||
if packet_type == hci.HCI_ACL_DATA_PACKET:
|
||||
self.acl_out_transfer.setBulk(
|
||||
self.acl_out, packet[1:], callback=self.transfer_callback
|
||||
)
|
||||
self.acl_out_transfer.submit()
|
||||
elif packet_type == hci.HCI_COMMAND_PACKET:
|
||||
self.acl_out_transfer.setControl(
|
||||
USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
packet[1:],
|
||||
callback=self.transfer_callback,
|
||||
)
|
||||
self.acl_out_transfer.submit()
|
||||
else:
|
||||
logger.warning(
|
||||
color(f'unsupported packet type {packet_type}', 'red')
|
||||
)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
if self.queue_task:
|
||||
self.queue_task.cancel()
|
||||
|
||||
async def terminate(self):
|
||||
if not self.closed:
|
||||
self.close()
|
||||
|
||||
# Empty the packet queue so that we don't send any more data
|
||||
self.packets.clear()
|
||||
while not self.packets.empty():
|
||||
self.packets.get_nowait()
|
||||
|
||||
# If we have a transfer in flight, cancel it
|
||||
if self.acl_out_transfer.isSubmitted():
|
||||
@@ -442,7 +447,7 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
|
||||
if found is None:
|
||||
context.close()
|
||||
raise ValueError('device not found')
|
||||
raise TransportInitError('device not found')
|
||||
|
||||
logger.debug(f'USB Device: {found}')
|
||||
|
||||
@@ -507,7 +512,7 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
|
||||
endpoints = find_endpoints(found)
|
||||
if endpoints is None:
|
||||
raise ValueError('no compatible interface found for device')
|
||||
raise TransportInitError('no compatible interface found for device')
|
||||
(configuration, interface, setting, acl_in, acl_out, events_in) = endpoints
|
||||
logger.debug(
|
||||
f'selected endpoints: configuration={configuration}, '
|
||||
|
||||
BIN
docs/images/favicon.ico
Normal file
BIN
docs/images/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
7
examples/device_with_rpa.json
Normal file
7
examples/device_with_rpa.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "Bumble",
|
||||
"address": "F0:F1:F2:F3:F4:F5",
|
||||
"keystore": "JsonKeyStore",
|
||||
"irk": "865F81FF5A8B486EAAE29A27AD9F77DC",
|
||||
"le_privacy_enabled": true
|
||||
}
|
||||
@@ -3,5 +3,6 @@
|
||||
"keystore": "JsonKeyStore",
|
||||
"address": "F0:F1:F2:F3:F4:FA",
|
||||
"class_of_device": 2376708,
|
||||
"cis_enabled": true,
|
||||
"advertising_interval": 100
|
||||
}
|
||||
|
||||
83
examples/mcp_server.html
Normal file
83
examples/mcp_server.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<html data-bs-theme="dark">
|
||||
|
||||
<head>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar navbar-dark bg-primary">
|
||||
<div class="container">
|
||||
<span class="navbar-brand mb-0 h1">Bumble LEA Media Control Client</span>
|
||||
</div>
|
||||
</nav>
|
||||
<br>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<label class="form-label">Server Port</label>
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" aria-label="Port Number" value="8989" id="port">
|
||||
<button class="btn btn-primary" type="button" onclick="connect()">Connect</button>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="send_opcode(0x01)">Play</button>
|
||||
<button class="btn btn-primary" onclick="send_opcode(0x02)">Pause</button>
|
||||
<button class="btn btn-primary" onclick="send_opcode(0x03)">Fast Rewind</button>
|
||||
<button class="btn btn-primary" onclick="send_opcode(0x04)">Fast Forward</button>
|
||||
<button class="btn btn-primary" onclick="send_opcode(0x05)">Stop</button>
|
||||
|
||||
</br></br>
|
||||
|
||||
<button class="btn btn-primary" onclick="send_opcode(0x30)">Previous Track</button>
|
||||
<button class="btn btn-primary" onclick="send_opcode(0x31)">Next Track</button>
|
||||
|
||||
<hr>
|
||||
|
||||
<div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2">
|
||||
<h3>Log</h3>
|
||||
<code id="log" style="white-space: pre-line;"></code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
let portInput = document.getElementById("port")
|
||||
let log = document.getElementById("log")
|
||||
let socket
|
||||
|
||||
function connect() {
|
||||
socket = new WebSocket(`ws://localhost:${portInput.value}`);
|
||||
socket.onopen = _ => {
|
||||
log.textContent += 'OPEN\n'
|
||||
}
|
||||
socket.onclose = _ => {
|
||||
log.textContent += 'CLOSED\n'
|
||||
}
|
||||
socket.onerror = (error) => {
|
||||
log.textContent += 'ERROR\n'
|
||||
console.log(`ERROR: ${error}`)
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
log.textContent += `<-- ${event.data}\n`
|
||||
}
|
||||
}
|
||||
|
||||
function send(message) {
|
||||
if (socket && socket.readyState == WebSocket.OPEN) {
|
||||
let jsonMessage = JSON.stringify(message)
|
||||
log.textContent += `--> ${jsonMessage}\n`
|
||||
socket.send(jsonMessage)
|
||||
} else {
|
||||
log.textContent += 'NOT CONNECTED\n'
|
||||
}
|
||||
}
|
||||
|
||||
function send_opcode(opcode) {
|
||||
send({ 'opcode': opcode })
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
196
examples/run_mcp_client.py
Normal file
196
examples/run_mcp_client.py
Normal file
@@ -0,0 +1,196 @@
|
||||
# Copyright 2021-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 sys
|
||||
import os
|
||||
import websockets
|
||||
import json
|
||||
|
||||
from bumble.core import AdvertisingData
|
||||
from bumble.device import (
|
||||
Device,
|
||||
AdvertisingParameters,
|
||||
AdvertisingEventProperties,
|
||||
Connection,
|
||||
Peer,
|
||||
)
|
||||
from bumble.hci import (
|
||||
CodecID,
|
||||
CodingFormat,
|
||||
OwnAddressType,
|
||||
)
|
||||
from bumble.profiles.bap import (
|
||||
CodecSpecificCapabilities,
|
||||
ContextType,
|
||||
AudioLocation,
|
||||
SupportedSamplingFrequency,
|
||||
SupportedFrameDuration,
|
||||
PacRecord,
|
||||
PublishedAudioCapabilitiesService,
|
||||
AudioStreamControlService,
|
||||
UnicastServerAdvertisingData,
|
||||
)
|
||||
from bumble.profiles.mcp import (
|
||||
MediaControlServiceProxy,
|
||||
GenericMediaControlServiceProxy,
|
||||
MediaState,
|
||||
MediaControlPointOpcode,
|
||||
)
|
||||
|
||||
from bumble.transport import open_transport_or_link
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def main() -> None:
|
||||
if len(sys.argv) < 3:
|
||||
print('Usage: run_mcp_client.py <config-file>' '<transport-spec-for-device>')
|
||||
return
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||
print('<<< connected')
|
||||
|
||||
device = Device.from_config_file_with_hci(
|
||||
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||
)
|
||||
|
||||
await device.power_on()
|
||||
|
||||
# Add "placeholder" services to enable Android LEA features.
|
||||
device.add_service(
|
||||
PublishedAudioCapabilitiesService(
|
||||
supported_source_context=ContextType.PROHIBITED,
|
||||
available_source_context=ContextType.PROHIBITED,
|
||||
supported_sink_context=ContextType.MEDIA,
|
||||
available_sink_context=ContextType.MEDIA,
|
||||
sink_audio_locations=(
|
||||
AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT
|
||||
),
|
||||
sink_pac=[
|
||||
PacRecord(
|
||||
coding_format=CodingFormat(CodecID.LC3),
|
||||
codec_specific_capabilities=CodecSpecificCapabilities(
|
||||
supported_sampling_frequencies=(
|
||||
SupportedSamplingFrequency.FREQ_16000
|
||||
| SupportedSamplingFrequency.FREQ_32000
|
||||
| SupportedSamplingFrequency.FREQ_48000
|
||||
),
|
||||
supported_frame_durations=(
|
||||
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
||||
),
|
||||
supported_audio_channel_count=[1, 2],
|
||||
min_octets_per_codec_frame=0,
|
||||
max_octets_per_codec_frame=320,
|
||||
supported_max_codec_frames_per_sdu=2,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
device.add_service(AudioStreamControlService(device, sink_ase_id=[1]))
|
||||
|
||||
ws: Optional[websockets.WebSocketServerProtocol] = None
|
||||
mcp: Optional[MediaControlServiceProxy] = None
|
||||
|
||||
advertising_data = bytes(
|
||||
AdvertisingData(
|
||||
[
|
||||
(
|
||||
AdvertisingData.COMPLETE_LOCAL_NAME,
|
||||
bytes('Bumble LE Audio', 'utf-8'),
|
||||
),
|
||||
(
|
||||
AdvertisingData.FLAGS,
|
||||
bytes([AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG]),
|
||||
),
|
||||
(
|
||||
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||
bytes(PublishedAudioCapabilitiesService.UUID),
|
||||
),
|
||||
]
|
||||
)
|
||||
) + bytes(UnicastServerAdvertisingData())
|
||||
|
||||
await device.create_advertising_set(
|
||||
advertising_parameters=AdvertisingParameters(
|
||||
advertising_event_properties=AdvertisingEventProperties(),
|
||||
own_address_type=OwnAddressType.RANDOM,
|
||||
primary_advertising_interval_max=100,
|
||||
primary_advertising_interval_min=100,
|
||||
),
|
||||
advertising_data=advertising_data,
|
||||
auto_restart=True,
|
||||
)
|
||||
|
||||
def on_media_state(media_state: MediaState) -> None:
|
||||
if ws:
|
||||
asyncio.create_task(
|
||||
ws.send(json.dumps({'media_state': media_state.name}))
|
||||
)
|
||||
|
||||
def on_track_title(title: str) -> None:
|
||||
if ws:
|
||||
asyncio.create_task(ws.send(json.dumps({'title': title})))
|
||||
|
||||
def on_track_duration(duration: int) -> None:
|
||||
if ws:
|
||||
asyncio.create_task(ws.send(json.dumps({'duration': duration})))
|
||||
|
||||
def on_track_position(position: int) -> None:
|
||||
if ws:
|
||||
asyncio.create_task(ws.send(json.dumps({'position': position})))
|
||||
|
||||
def on_connection(connection: Connection) -> None:
|
||||
async def on_connection_async():
|
||||
async with Peer(connection) as peer:
|
||||
nonlocal mcp
|
||||
mcp = peer.create_service_proxy(MediaControlServiceProxy)
|
||||
if not mcp:
|
||||
mcp = peer.create_service_proxy(GenericMediaControlServiceProxy)
|
||||
mcp.on('media_state', on_media_state)
|
||||
mcp.on('track_title', on_track_title)
|
||||
mcp.on('track_duration', on_track_duration)
|
||||
mcp.on('track_position', on_track_position)
|
||||
await mcp.subscribe_characteristics()
|
||||
|
||||
connection.abort_on('disconnection', on_connection_async())
|
||||
|
||||
device.on('connection', on_connection)
|
||||
|
||||
async def serve(websocket: websockets.WebSocketServerProtocol, _path):
|
||||
nonlocal ws
|
||||
ws = websocket
|
||||
async for message in websocket:
|
||||
request = json.loads(message)
|
||||
if mcp:
|
||||
await mcp.write_control_point(
|
||||
MediaControlPointOpcode(request['opcode'])
|
||||
)
|
||||
ws = None
|
||||
|
||||
await websockets.serve(serve, 'localhost', 8989)
|
||||
|
||||
await hci_transport.source.terminated
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
asyncio.run(main())
|
||||
@@ -150,7 +150,8 @@ class AppViewModel : ViewModel() {
|
||||
} else if (senderPacketSizeSlider < 0.5F) {
|
||||
512
|
||||
} else if (senderPacketSizeSlider < 0.7F) {
|
||||
1024
|
||||
// 970 is a value that works well on Android.
|
||||
970
|
||||
} else if (senderPacketSizeSlider < 0.9F) {
|
||||
2048
|
||||
} else {
|
||||
|
||||
21
tasks.py
21
tasks.py
@@ -20,7 +20,10 @@ Invoke tasks
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import os
|
||||
|
||||
import glob
|
||||
import shutil
|
||||
import urllib
|
||||
from pathlib import Path
|
||||
from invoke import task, call, Collection
|
||||
from invoke.exceptions import Exit, UnexpectedExit
|
||||
|
||||
@@ -205,5 +208,21 @@ def serve(ctx, port=8000):
|
||||
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(web_build, name="build")
|
||||
|
||||
@@ -48,6 +48,7 @@ from bumble.profiles.bap import (
|
||||
PublishedAudioCapabilitiesService,
|
||||
PublishedAudioCapabilitiesServiceProxy,
|
||||
)
|
||||
from bumble.profiles.le_audio import Metadata
|
||||
from tests.test_utils import TwoDevices
|
||||
|
||||
|
||||
@@ -97,7 +98,7 @@ def test_pac_record() -> None:
|
||||
pac_record = PacRecord(
|
||||
coding_format=CodingFormat(CodecID.LC3),
|
||||
codec_specific_capabilities=cap,
|
||||
metadata=b'',
|
||||
metadata=Metadata([Metadata.Entry(tag=Metadata.Tag.VENDOR_SPECIFIC, data=b'')]),
|
||||
)
|
||||
assert PacRecord.from_bytes(bytes(pac_record)) == pac_record
|
||||
|
||||
@@ -142,7 +143,7 @@ def test_ASE_Config_QOS() -> None:
|
||||
def test_ASE_Enable() -> None:
|
||||
operation = ASE_Enable(
|
||||
ase_id=[1, 2],
|
||||
metadata=[b'foo', b'bar'],
|
||||
metadata=[b'', b''],
|
||||
)
|
||||
basic_check(operation)
|
||||
|
||||
@@ -151,7 +152,7 @@ def test_ASE_Enable() -> None:
|
||||
def test_ASE_Update_Metadata() -> None:
|
||||
operation = ASE_Update_Metadata(
|
||||
ase_id=[1, 2],
|
||||
metadata=[b'foo', b'bar'],
|
||||
metadata=[b'', b''],
|
||||
)
|
||||
basic_check(operation)
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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__':
|
||||
test_ad_data()
|
||||
test_get_dict_key_by_value()
|
||||
test_uuid_to_hex_str()
|
||||
test_appearance()
|
||||
|
||||
@@ -276,36 +276,6 @@ async def test_legacy_advertising():
|
||||
assert not device.is_advertising
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize(
|
||||
'own_address_type,',
|
||||
(OwnAddressType.PUBLIC, OwnAddressType.RANDOM),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_advertising_connection(own_address_type):
|
||||
device = Device(host=mock.AsyncMock(Host))
|
||||
peer_address = Address('F0:F1:F2:F3:F4:F5')
|
||||
|
||||
# Start advertising
|
||||
await device.start_advertising()
|
||||
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
|
||||
|
||||
# For unknown reason, read_phy() in on_connection() would be killed at the end of
|
||||
# test, so we force scheduling here to avoid an warning.
|
||||
await asyncio.sleep(0.0001)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize(
|
||||
'auto_restart,',
|
||||
@@ -320,6 +290,8 @@ async def test_legacy_advertising_disconnection(auto_restart):
|
||||
0x0001,
|
||||
BT_LE_TRANSPORT,
|
||||
peer_address,
|
||||
None,
|
||||
None,
|
||||
BT_PERIPHERAL_ROLE,
|
||||
ConnectionParameters(0, 0, 0),
|
||||
)
|
||||
@@ -369,6 +341,8 @@ async def test_extended_advertising_connection(own_address_type):
|
||||
0x0001,
|
||||
BT_LE_TRANSPORT,
|
||||
peer_address,
|
||||
None,
|
||||
None,
|
||||
BT_PERIPHERAL_ROLE,
|
||||
ConnectionParameters(0, 0, 0),
|
||||
)
|
||||
@@ -384,9 +358,43 @@ async def test_extended_advertising_connection(own_address_type):
|
||||
else:
|
||||
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
|
||||
# test, so we force scheduling here to avoid an warning.
|
||||
await asyncio.sleep(0.0001)
|
||||
await async_barrier()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@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,
|
||||
None,
|
||||
None,
|
||||
BT_PERIPHERAL_ROLE,
|
||||
ConnectionParameters(0, 0, 0),
|
||||
)
|
||||
|
||||
if own_address_type == OwnAddressType.PUBLIC:
|
||||
assert device.lookup_connection(0x0001).self_address == device.public_address
|
||||
else:
|
||||
assert device.lookup_connection(0x0001).self_address == device.random_address
|
||||
|
||||
await async_barrier()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -879,6 +879,57 @@ async def test_unsubscribe():
|
||||
mock1.assert_called_once_with(ANY, False, False)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_all():
|
||||
[client, server] = LinkedDevices().devices[:2]
|
||||
|
||||
characteristic1 = Characteristic(
|
||||
'FDB159DB-036C-49E3-B3DB-6325AC750806',
|
||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE,
|
||||
bytes([1, 2, 3]),
|
||||
)
|
||||
|
||||
descriptor1 = Descriptor('2902', 'READABLE,WRITEABLE')
|
||||
descriptor2 = Descriptor('AAAA', 'READABLE,WRITEABLE')
|
||||
characteristic2 = Characteristic(
|
||||
'3234C4F4-3F34-4616-8935-45A50EE05DEB',
|
||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE,
|
||||
bytes([1, 2, 3]),
|
||||
descriptors=[descriptor1, descriptor2],
|
||||
)
|
||||
|
||||
service1 = Service(
|
||||
'3A657F47-D34F-46B3-B1EC-698E29B6B829',
|
||||
[characteristic1, characteristic2],
|
||||
)
|
||||
service2 = Service('1111', [])
|
||||
server.add_services([service1, service2])
|
||||
|
||||
await client.power_on()
|
||||
await server.power_on()
|
||||
connection = await client.connect(server.random_address)
|
||||
peer = Peer(connection)
|
||||
|
||||
await peer.discover_all()
|
||||
assert len(peer.gatt_client.services) == 3
|
||||
# service 1800 gets added automatically
|
||||
assert peer.gatt_client.services[0].uuid == UUID('1800')
|
||||
assert peer.gatt_client.services[1].uuid == service1.uuid
|
||||
assert peer.gatt_client.services[2].uuid == service2.uuid
|
||||
s = peer.get_services_by_uuid(service1.uuid)
|
||||
assert len(s) == 1
|
||||
assert len(s[0].characteristics) == 2
|
||||
c = peer.get_characteristics_by_uuid(uuid=characteristic2.uuid, service=s[0])
|
||||
assert len(c) == 1
|
||||
assert len(c[0].descriptors) == 2
|
||||
s = peer.get_services_by_uuid(service2.uuid)
|
||||
assert len(s) == 1
|
||||
assert len(s[0].characteristics) == 0
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_mtu_exchange():
|
||||
@@ -1146,6 +1197,56 @@ def test_get_attribute_group():
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_characteristics_by_uuid():
|
||||
[client, server] = LinkedDevices().devices[:2]
|
||||
|
||||
characteristic1 = Characteristic(
|
||||
'1234',
|
||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE,
|
||||
bytes([1, 2, 3]),
|
||||
)
|
||||
characteristic2 = Characteristic(
|
||||
'5678',
|
||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE,
|
||||
bytes([1, 2, 3]),
|
||||
)
|
||||
service1 = Service(
|
||||
'ABCD',
|
||||
[characteristic1, characteristic2],
|
||||
)
|
||||
service2 = Service(
|
||||
'FFFF',
|
||||
[characteristic1],
|
||||
)
|
||||
|
||||
server.add_services([service1, service2])
|
||||
|
||||
await client.power_on()
|
||||
await server.power_on()
|
||||
connection = await client.connect(server.random_address)
|
||||
peer = Peer(connection)
|
||||
|
||||
await peer.discover_services()
|
||||
await peer.discover_characteristics()
|
||||
c = peer.get_characteristics_by_uuid(uuid=UUID('1234'))
|
||||
assert len(c) == 2
|
||||
assert isinstance(c[0], CharacteristicProxy)
|
||||
c = peer.get_characteristics_by_uuid(uuid=UUID('1234'), service=UUID('ABCD'))
|
||||
assert len(c) == 1
|
||||
assert isinstance(c[0], CharacteristicProxy)
|
||||
c = peer.get_characteristics_by_uuid(uuid=UUID('1234'), service=UUID('AAAA'))
|
||||
assert len(c) == 0
|
||||
|
||||
s = peer.get_services_by_uuid(uuid=UUID('ABCD'))
|
||||
assert len(s) == 1
|
||||
c = peer.get_characteristics_by_uuid(uuid=UUID('1234'), service=s[0])
|
||||
assert len(s) == 1
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
|
||||
39
tests/le_audio_test.py
Normal file
39
tests/le_audio_test.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# Copyright 2021-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 bumble.profiles import le_audio
|
||||
|
||||
|
||||
def test_parse_metadata():
|
||||
metadata = le_audio.Metadata(
|
||||
entries=[
|
||||
le_audio.Metadata.Entry(
|
||||
tag=le_audio.Metadata.Tag.PROGRAM_INFO,
|
||||
data=b'',
|
||||
),
|
||||
le_audio.Metadata.Entry(
|
||||
tag=le_audio.Metadata.Tag.STREAMING_AUDIO_CONTEXTS,
|
||||
data=bytes([0, 0]),
|
||||
),
|
||||
le_audio.Metadata.Entry(
|
||||
tag=le_audio.Metadata.Tag.PREFERRED_AUDIO_CONTEXTS,
|
||||
data=bytes([1, 2]),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
assert le_audio.Metadata.from_bytes(bytes(metadata)) == metadata
|
||||
132
tests/mcp_test.py
Normal file
132
tests/mcp_test.py
Normal file
@@ -0,0 +1,132 @@
|
||||
# Copyright 2021-2023 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import struct
|
||||
import logging
|
||||
|
||||
from bumble import device
|
||||
from bumble.profiles import mcp
|
||||
from tests.test_utils import TwoDevices
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
TIMEOUT = 0.1
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class GmcsContext:
|
||||
devices: TwoDevices
|
||||
client: mcp.GenericMediaControlServiceProxy
|
||||
server: mcp.GenericMediaControlService
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest_asyncio.fixture
|
||||
async def gmcs_context():
|
||||
devices = TwoDevices()
|
||||
server = mcp.GenericMediaControlService()
|
||||
devices[0].add_service(server)
|
||||
|
||||
await devices.setup_connection()
|
||||
devices.connections[0].encryption = 1
|
||||
devices.connections[1].encryption = 1
|
||||
peer = device.Peer(devices.connections[1])
|
||||
client = await peer.discover_service_and_create_proxy(
|
||||
mcp.GenericMediaControlServiceProxy
|
||||
)
|
||||
await client.subscribe_characteristics()
|
||||
|
||||
return GmcsContext(devices=devices, server=server, client=client)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_media_state(gmcs_context):
|
||||
state = asyncio.Queue()
|
||||
gmcs_context.client.on('media_state', state.put_nowait)
|
||||
|
||||
await gmcs_context.devices[0].notify_subscribers(
|
||||
gmcs_context.server.media_state_characteristic,
|
||||
value=bytes([mcp.MediaState.PLAYING]),
|
||||
)
|
||||
|
||||
assert (await asyncio.wait_for(state.get(), TIMEOUT)) == mcp.MediaState.PLAYING
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_track_title(gmcs_context):
|
||||
state = asyncio.Queue()
|
||||
gmcs_context.client.on('track_title', state.put_nowait)
|
||||
|
||||
await gmcs_context.devices[0].notify_subscribers(
|
||||
gmcs_context.server.track_title_characteristic,
|
||||
value="My Song".encode(),
|
||||
)
|
||||
|
||||
assert (await asyncio.wait_for(state.get(), TIMEOUT)) == "My Song"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_track_duration(gmcs_context):
|
||||
state = asyncio.Queue()
|
||||
gmcs_context.client.on('track_duration', state.put_nowait)
|
||||
|
||||
await gmcs_context.devices[0].notify_subscribers(
|
||||
gmcs_context.server.track_duration_characteristic,
|
||||
value=struct.pack("<i", 1000),
|
||||
)
|
||||
|
||||
assert (await asyncio.wait_for(state.get(), TIMEOUT)) == 1000
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_track_position(gmcs_context):
|
||||
state = asyncio.Queue()
|
||||
gmcs_context.client.on('track_position', state.put_nowait)
|
||||
|
||||
await gmcs_context.devices[0].notify_subscribers(
|
||||
gmcs_context.server.track_position_characteristic,
|
||||
value=struct.pack("<i", 1000),
|
||||
)
|
||||
|
||||
assert (await asyncio.wait_for(state.get(), TIMEOUT)) == 1000
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_media_control_point(gmcs_context):
|
||||
assert (
|
||||
await asyncio.wait_for(
|
||||
gmcs_context.client.write_control_point(mcp.MediaControlPointOpcode.PAUSE),
|
||||
TIMEOUT,
|
||||
)
|
||||
) == mcp.MediaControlPointResultCode.SUCCESS
|
||||
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
|
||||
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`.
|
||||
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).
|
||||
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
|
||||
@@ -45,4 +50,6 @@ Example:
|
||||
|
||||
|
||||
NOTE: to get a local build of the Bumble package, use `inv build`, the built `.whl` file can be found in the `dist` directory.
|
||||
Make a copy of the built `.whl` file in the `web` directory.
|
||||
Make a copy of the built `.whl` file in the `web` directory.
|
||||
|
||||
Tip: During web developement, disable caching. [Chrome](https://stackoverflow.com/a/7000899]) / [Firefiox](https://stackoverflow.com/a/289771)
|
||||
@@ -75,7 +75,6 @@ export class Bumble extends EventTarget {
|
||||
}
|
||||
|
||||
// Load the Bumble module
|
||||
bumblePackage ||= 'bumble';
|
||||
console.log('Installing micropip');
|
||||
this.log(`Installing ${bumblePackage}`)
|
||||
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) {
|
||||
// Load Bumble
|
||||
log('Loading Bumble');
|
||||
@@ -173,8 +186,7 @@ export async function setupSimpleApp(appUrl, bumbleControls, log) {
|
||||
bumble.addEventListener('log', (event) => {
|
||||
log(event.message);
|
||||
})
|
||||
const params = (new URL(document.location)).searchParams;
|
||||
await bumble.loadRuntime(params.get('package'));
|
||||
await bumble.loadRuntime(await getBumblePackage());
|
||||
|
||||
log('Bumble is ready!')
|
||||
const app = await bumble.loadApp(appUrl);
|
||||
|
||||
1
web/favicon.ico
Symbolic link
1
web/favicon.ico
Symbolic link
@@ -0,0 +1 @@
|
||||
../docs/images/favicon.ico
|
||||
@@ -15,12 +15,21 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import pyee
|
||||
|
||||
from bumble.device import Device
|
||||
from bumble.hci import HCI_Reset_Command
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Scanner:
|
||||
class Scanner(pyee.EventEmitter):
|
||||
"""
|
||||
Scanner web app
|
||||
|
||||
Emitted events:
|
||||
update: Emit when new `ScanEntry` are available.
|
||||
"""
|
||||
|
||||
class ScanEntry:
|
||||
def __init__(self, advertisement):
|
||||
self.address = advertisement.address.to_string(False)
|
||||
@@ -39,13 +48,12 @@ class Scanner:
|
||||
'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
|
||||
)
|
||||
self.scan_entries = {}
|
||||
self.listeners = {}
|
||||
self.device.on('advertisement', self.on_advertisement)
|
||||
|
||||
async def start(self):
|
||||
print('### Starting Scanner')
|
||||
self.scan_entries = {}
|
||||
self.emit_update()
|
||||
self.emit('update', self.scan_entries)
|
||||
await self.device.power_on()
|
||||
await self.device.start_scanning()
|
||||
print('### Scanner started')
|
||||
@@ -56,16 +64,9 @@ class Scanner:
|
||||
await self.device.power_off()
|
||||
print('### Scanner stopped')
|
||||
|
||||
def emit_update(self):
|
||||
if listener := self.listeners.get('update'):
|
||||
listener(list(self.scan_entries.values()))
|
||||
|
||||
def on(self, event_name, listener):
|
||||
self.listeners[event_name] = listener
|
||||
|
||||
def on_advertisement(self, advertisement):
|
||||
self.scan_entries[advertisement.address] = self.ScanEntry(advertisement)
|
||||
self.emit_update()
|
||||
self.emit('update', self.scan_entries)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user