Compare commits

...

20 Commits

Author SHA1 Message Date
Charlie Boutier 7237619d3b A2DP example: Codec selection based on file type
Currently support SBC and AAC
2025-05-08 14:24:42 -07:00
Slvr a88a034ce2 cryptography: bump version to 44.0.3 to fix python parsing (#684)
Bug: 404336381
2025-05-08 08:28:33 -07:00
zxzxwu 6b2cd1147d Merge pull request #682 from zxzxwu/linkkey
Move connection.link_key_type to keystore
2025-05-08 11:23:28 +08:00
Josh Wu bb8dcaf63e Move connection.link_key_type to keystore 2025-05-06 02:11:25 +08:00
Gilles Boccon-Gibod 8e84b528ce Merge pull request #679 from google/gbg/pairing-ios 2025-05-05 09:50:49 -07:00
Gilles Boccon-Gibod 8b59b4f515 address PR comments 2025-05-04 17:50:00 -07:00
Gilles Boccon-Gibod dcc72e49a2 forward legacy constants 2025-05-04 11:34:11 -07:00
Gilles Boccon-Gibod ce04c163db fix merge conflict 2025-05-04 11:32:25 -07:00
Gilles Boccon-Gibod 9f1e95d87f more merge fixes 2025-05-04 11:31:15 -07:00
Gilles Boccon-Gibod 088bcbed0b resolve merge conflicts 2025-05-04 11:31:15 -07:00
Gilles Boccon-Gibod 57fbad6fa4 add LE advertisement and HR service 2025-05-04 11:31:15 -07:00
Gilles Boccon-Gibod 6926d5cb70 Merge pull request #678 from google/gbg/fix-timescales
fix a few timescale adjustments
2025-05-04 11:19:05 -07:00
Gilles Boccon-Gibod 00c7df6a11 update pyee version 2025-05-03 12:24:59 -07:00
Gilles Boccon-Gibod fbd03ed4a5 fix a few timescale adjustments 2025-05-03 12:07:53 -07:00
Gilles Boccon-Gibod d3bd5a759f Revert "fix a few timescale adjustments"
This reverts commit dedef79bef.
2025-05-03 12:05:31 -07:00
Gilles Boccon-Gibod dedef79bef fix a few timescale adjustments 2025-05-03 12:00:34 -07:00
zxzxwu 8db974877e Merge pull request #677 from zxzxwu/java-workflow
Add a workflow to build btbench
2025-04-26 09:44:50 -07:00
Josh Wu e7d1531eae Add a workflow to build btbench 2025-04-26 18:51:19 +08:00
zxzxwu 4785fe6002 Merge pull request #674 from zxzxwu/event
Declare emitted events as constants
2025-04-26 02:45:50 -07:00
Josh Wu 22d6a7bf05 Declare emitted events as constants 2025-04-26 03:55:31 +08:00
40 changed files with 1343 additions and 575 deletions
+26
View File
@@ -0,0 +1,26 @@
#
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
org.gradle.configureondemand=true
org.gradle.caching=true
org.gradle.parallel=true
# Declare we support AndroidX
android.useAndroidX=true
org.gradle.jvmargs=-Xmx4608m -XX:MaxMetaspaceSize=1536m -XX:+HeapDumpOnOutOfMemoryError
kotlin.compiler.execution.strategy=in-process
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
python -m pip install ".[build,test,development]" python -m pip install ".[build,examples,test,development]"
- name: Check - name: Check
run: | run: |
invoke project.pre-commit invoke project.pre-commit
+33
View File
@@ -0,0 +1,33 @@
name: Gradle Android Build & test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
paths:
- 'extras/android/BtBench/**'
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Check out from Git
uses: actions/checkout@v3
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- name: Build with Gradle
run: cd extras/android/BtBench && ./gradlew build
+4 -2
View File
@@ -121,9 +121,9 @@ def print_connection(connection):
params.append( params.append(
'Parameters=' 'Parameters='
f'{connection.parameters.connection_interval * 1.25:.2f}/' f'{connection.parameters.connection_interval:.2f}/'
f'{connection.parameters.peripheral_latency}/' f'{connection.parameters.peripheral_latency}/'
f'{connection.parameters.supervision_timeout * 10} ' f'{connection.parameters.supervision_timeout:.2f} '
) )
params.append(f'MTU={connection.att_mtu}') params.append(f'MTU={connection.att_mtu}')
@@ -1256,6 +1256,7 @@ class Central(Connection.Listener):
self.device.classic_enabled = self.classic self.device.classic_enabled = self.classic
# Set up a pairing config factory with minimal requirements. # Set up a pairing config factory with minimal requirements.
self.device.config.keystore = "JsonKeyStore"
self.device.pairing_config_factory = lambda _: PairingConfig( self.device.pairing_config_factory = lambda _: PairingConfig(
sc=False, mitm=False, bonding=False sc=False, mitm=False, bonding=False
) )
@@ -1408,6 +1409,7 @@ class Peripheral(Device.Listener, Connection.Listener):
self.device.classic_enabled = self.classic self.device.classic_enabled = self.classic
# Set up a pairing config factory with minimal requirements. # Set up a pairing config factory with minimal requirements.
self.device.config.keystore = "JsonKeyStore"
self.device.pairing_config_factory = lambda _: PairingConfig( self.device.pairing_config_factory = lambda _: PairingConfig(
sc=False, mitm=False, bonding=False sc=False, mitm=False, bonding=False
) )
+2 -2
View File
@@ -335,9 +335,9 @@ class ConsoleApp:
elif self.connected_peer: elif self.connected_peer:
connection = self.connected_peer.connection connection = self.connected_peer.connection
connection_parameters = ( connection_parameters = (
f'{connection.parameters.connection_interval}/' f'{connection.parameters.connection_interval:.2f}/'
f'{connection.parameters.peripheral_latency}/' f'{connection.parameters.peripheral_latency}/'
f'{connection.parameters.supervision_timeout}' f'{connection.parameters.supervision_timeout:.2f}'
) )
if self.connection_phy is not None: if self.connection_phy is not None:
phy_state = ( phy_state = (
+185 -32
View File
@@ -18,9 +18,12 @@
import asyncio import asyncio
import os import os
import logging import logging
import struct
import click import click
from prompt_toolkit.shortcuts import PromptSession from prompt_toolkit.shortcuts import PromptSession
from bumble.a2dp import make_audio_sink_service_sdp_records
from bumble.colors import color from bumble.colors import color
from bumble.device import Device, Peer from bumble.device import Device, Peer
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
@@ -30,16 +33,20 @@ from bumble.smp import error_name as smp_error_name
from bumble.keys import JsonKeyStore from bumble.keys import JsonKeyStore
from bumble.core import ( from bumble.core import (
AdvertisingData, AdvertisingData,
Appearance,
ProtocolError, ProtocolError,
PhysicalTransport, PhysicalTransport,
UUID,
) )
from bumble.gatt import ( from bumble.gatt import (
GATT_DEVICE_NAME_CHARACTERISTIC, GATT_DEVICE_NAME_CHARACTERISTIC,
GATT_GENERIC_ACCESS_SERVICE, GATT_GENERIC_ACCESS_SERVICE,
GATT_HEART_RATE_SERVICE,
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
Service, Service,
Characteristic, Characteristic,
CharacteristicValue,
) )
from bumble.hci import OwnAddressType
from bumble.att import ( from bumble.att import (
ATT_Error, ATT_Error,
ATT_INSUFFICIENT_AUTHENTICATION_ERROR, ATT_INSUFFICIENT_AUTHENTICATION_ERROR,
@@ -62,7 +69,7 @@ class Waiter:
self.linger = linger self.linger = linger
def terminate(self): def terminate(self):
if not self.linger: if not self.linger and not self.done.done:
self.done.set_result(None) self.done.set_result(None)
async def wait_until_terminated(self): async def wait_until_terminated(self):
@@ -193,7 +200,7 @@ class Delegate(PairingDelegate):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def get_peer_name(peer, mode): async def get_peer_name(peer, mode):
if mode == 'classic': if peer.connection.transport == PhysicalTransport.BR_EDR:
return await peer.request_name() return await peer.request_name()
# Try to get the peer name from GATT # Try to get the peer name from GATT
@@ -225,13 +232,14 @@ def read_with_error(connection):
raise ATT_Error(ATT_INSUFFICIENT_AUTHENTICATION_ERROR) raise ATT_Error(ATT_INSUFFICIENT_AUTHENTICATION_ERROR)
def write_with_error(connection, _value): # -----------------------------------------------------------------------------
if not connection.is_encrypted: def sdp_records():
raise ATT_Error(ATT_INSUFFICIENT_ENCRYPTION_ERROR) service_record_handle = 0x00010001
return {
if not AUTHENTICATION_ERROR_RETURNED[1]: service_record_handle: make_audio_sink_service_sdp_records(
AUTHENTICATION_ERROR_RETURNED[1] = True service_record_handle
raise ATT_Error(ATT_INSUFFICIENT_AUTHENTICATION_ERROR) )
}
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -239,15 +247,19 @@ def on_connection(connection, request):
print(color(f'<<< Connection: {connection}', 'green')) print(color(f'<<< Connection: {connection}', 'green'))
# Listen for pairing events # Listen for pairing events
connection.on('pairing_start', on_pairing_start) connection.on(connection.EVENT_PAIRING_START, on_pairing_start)
connection.on('pairing', lambda keys: on_pairing(connection, keys)) connection.on(connection.EVENT_PAIRING, lambda keys: on_pairing(connection, keys))
connection.on( connection.on(
'pairing_failure', lambda reason: on_pairing_failure(connection, reason) connection.EVENT_CLASSIC_PAIRING, lambda: on_classic_pairing(connection)
)
connection.on(
connection.EVENT_PAIRING_FAILURE,
lambda reason: on_pairing_failure(connection, reason),
) )
# Listen for encryption changes # Listen for encryption changes
connection.on( connection.on(
'connection_encryption_change', connection.EVENT_CONNECTION_ENCRYPTION_CHANGE,
lambda: on_connection_encryption_change(connection), lambda: on_connection_encryption_change(connection),
) )
@@ -288,6 +300,20 @@ async def on_pairing(connection, keys):
Waiter.instance.terminate() Waiter.instance.terminate()
# -----------------------------------------------------------------------------
@AsyncRunner.run_in_task()
async def on_classic_pairing(connection):
print(color('***-----------------------------------', 'cyan'))
print(
color(
f'*** Paired [Classic]! (peer identity={connection.peer_address})', 'cyan'
)
)
print(color('***-----------------------------------', 'cyan'))
await asyncio.sleep(POST_PAIRING_DELAY)
Waiter.instance.terminate()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@AsyncRunner.run_in_task() @AsyncRunner.run_in_task()
async def on_pairing_failure(connection, reason): async def on_pairing_failure(connection, reason):
@@ -305,6 +331,7 @@ async def pair(
mitm, mitm,
bond, bond,
ctkd, ctkd,
advertising_address,
identity_address, identity_address,
linger, linger,
io, io,
@@ -313,6 +340,8 @@ async def pair(
request, request,
print_keys, print_keys,
keystore_file, keystore_file,
advertise_service_uuids,
advertise_appearance,
device_config, device_config,
hci_transport, hci_transport,
address_or_name, address_or_name,
@@ -328,29 +357,33 @@ async def pair(
# Expose a GATT characteristic that can be used to trigger pairing by # Expose a GATT characteristic that can be used to trigger pairing by
# responding with an authentication error when read # responding with an authentication error when read
if mode == 'le': if mode in ('le', 'dual'):
device.le_enabled = True
device.add_service( device.add_service(
Service( Service(
'50DB505C-8AC4-4738-8448-3B1D9CC09CC5', GATT_HEART_RATE_SERVICE,
[ [
Characteristic( Characteristic(
'552957FB-CF1F-4A31-9535-E78847E1A714', GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
Characteristic.Properties.READ Characteristic.Properties.READ,
| Characteristic.Properties.WRITE, Characteristic.READ_REQUIRES_AUTHENTICATION,
Characteristic.READABLE | Characteristic.WRITEABLE, bytes(1),
CharacteristicValue(
read=read_with_error, write=write_with_error
),
) )
], ],
) )
) )
# Select LE or Classic # LE and Classic support
if mode == 'classic': if mode in ('classic', 'dual'):
device.classic_enabled = True device.classic_enabled = True
device.classic_smp_enabled = ctkd device.classic_smp_enabled = ctkd
if mode in ('le', 'dual'):
device.le_enabled = True
if mode == 'dual':
device.le_simultaneous_enabled = True
# Setup SDP
if mode in ('classic', 'dual'):
device.sdp_service_records = sdp_records()
# Get things going # Get things going
await device.power_on() await device.power_on()
@@ -436,13 +469,109 @@ async def pair(
print(color(f'Pairing failed: {error}', 'red')) print(color(f'Pairing failed: {error}', 'red'))
else: else:
if mode == 'le': if mode in ('le', 'dual'):
# Advertise so that peers can find us and connect # Advertise so that peers can find us and connect.
await device.start_advertising(auto_restart=True) # Include the heart rate service UUID in the advertisement data
else: # so that devices like iPhones can show this device in their
# Bluetooth selector.
service_uuids_16 = []
service_uuids_32 = []
service_uuids_128 = []
if advertise_service_uuids:
for uuid in advertise_service_uuids:
uuid = uuid.replace("-", "")
if len(uuid) == 4:
service_uuids_16.append(UUID(uuid))
elif len(uuid) == 8:
service_uuids_32.append(UUID(uuid))
elif len(uuid) == 32:
service_uuids_128.append(UUID(uuid))
else:
print(color('Invalid UUID format', 'red'))
return
else:
service_uuids_16.append(GATT_HEART_RATE_SERVICE)
flags = AdvertisingData.Flags.LE_LIMITED_DISCOVERABLE_MODE
if mode == 'le':
flags |= AdvertisingData.Flags.BR_EDR_NOT_SUPPORTED
if mode == 'dual':
flags |= AdvertisingData.Flags.SIMULTANEOUS_LE_BR_EDR_CAPABLE
ad_structs = [
(
AdvertisingData.FLAGS,
bytes([flags]),
),
(AdvertisingData.COMPLETE_LOCAL_NAME, 'Bumble'.encode()),
]
if service_uuids_16:
ad_structs.append(
(
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
b"".join(bytes(uuid) for uuid in service_uuids_16),
)
)
if service_uuids_32:
ad_structs.append(
(
AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
b"".join(bytes(uuid) for uuid in service_uuids_32),
)
)
if service_uuids_128:
ad_structs.append(
(
AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
b"".join(bytes(uuid) for uuid in service_uuids_128),
)
)
if advertise_appearance:
advertise_appearance = advertise_appearance.upper()
try:
advertise_appearance_int = int(advertise_appearance)
except ValueError:
category, subcategory = advertise_appearance.split('/')
try:
category_enum = Appearance.Category[category]
except ValueError:
print(
color(f'Invalid appearance category {category}', 'red')
)
return
subcategory_class = Appearance.SUBCATEGORY_CLASSES[
category_enum
]
try:
subcategory_enum = subcategory_class[subcategory]
except ValueError:
print(color(f'Invalid subcategory {subcategory}', 'red'))
return
advertise_appearance_int = int(
Appearance(category_enum, subcategory_enum)
)
ad_structs.append(
(
AdvertisingData.APPEARANCE,
struct.pack('<H', advertise_appearance_int),
)
)
device.advertising_data = bytes(AdvertisingData(ad_structs))
await device.start_advertising(
auto_restart=True,
own_address_type=(
OwnAddressType.PUBLIC
if advertising_address == 'public'
else OwnAddressType.RANDOM
),
)
if mode in ('classic', 'dual'):
# Become discoverable and connectable # Become discoverable and connectable
await device.set_discoverable(True) await device.set_discoverable(True)
await device.set_connectable(True) await device.set_connectable(True)
print(color('Ready for connections on', 'blue'), device.public_address)
# Run until the user asks to exit # Run until the user asks to exit
await Waiter.instance.wait_until_terminated() await Waiter.instance.wait_until_terminated()
@@ -462,7 +591,10 @@ class LogHandler(logging.Handler):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@click.command() @click.command()
@click.option( @click.option(
'--mode', type=click.Choice(['le', 'classic']), default='le', show_default=True '--mode',
type=click.Choice(['le', 'classic', 'dual']),
default='le',
show_default=True,
) )
@click.option( @click.option(
'--sc', '--sc',
@@ -484,6 +616,10 @@ class LogHandler(logging.Handler):
help='Enable CTKD', help='Enable CTKD',
show_default=True, show_default=True,
) )
@click.option(
'--advertising-address',
type=click.Choice(['random', 'public']),
)
@click.option( @click.option(
'--identity-address', '--identity-address',
type=click.Choice(['random', 'public']), type=click.Choice(['random', 'public']),
@@ -512,9 +648,20 @@ class LogHandler(logging.Handler):
@click.option('--print-keys', is_flag=True, help='Print the bond keys before pairing') @click.option('--print-keys', is_flag=True, help='Print the bond keys before pairing')
@click.option( @click.option(
'--keystore-file', '--keystore-file',
metavar='<filename>', metavar='FILENAME',
help='File in which to store the pairing keys', help='File in which to store the pairing keys',
) )
@click.option(
'--advertise-service-uuid',
metavar="UUID",
multiple=True,
help="Advertise a GATT service UUID (may be specified more than once)",
)
@click.option(
'--advertise-appearance',
metavar='APPEARANCE',
help='Advertise an Appearance ID (int value or string)',
)
@click.argument('device-config') @click.argument('device-config')
@click.argument('hci_transport') @click.argument('hci_transport')
@click.argument('address-or-name', required=False) @click.argument('address-or-name', required=False)
@@ -524,6 +671,7 @@ def main(
mitm, mitm,
bond, bond,
ctkd, ctkd,
advertising_address,
identity_address, identity_address,
linger, linger,
io, io,
@@ -532,6 +680,8 @@ def main(
request, request,
print_keys, print_keys,
keystore_file, keystore_file,
advertise_service_uuid,
advertise_appearance,
device_config, device_config,
hci_transport, hci_transport,
address_or_name, address_or_name,
@@ -550,6 +700,7 @@ def main(
mitm, mitm,
bond, bond,
ctkd, ctkd,
advertising_address,
identity_address, identity_address,
linger, linger,
io, io,
@@ -558,6 +709,8 @@ def main(
request, request,
print_keys, print_keys,
keystore_file, keystore_file,
advertise_service_uuid,
advertise_appearance,
device_config, device_config,
hci_transport, hci_transport,
address_or_name, address_or_name,
+5 -2
View File
@@ -836,6 +836,9 @@ class Attribute(utils.EventEmitter, Generic[_T]):
READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
EVENT_READ = "read"
EVENT_WRITE = "write"
value: Union[AttributeValue[_T], _T, None] value: Union[AttributeValue[_T], _T, None]
def __init__( def __init__(
@@ -906,7 +909,7 @@ class Attribute(utils.EventEmitter, Generic[_T]):
else: else:
value = self.value value = self.value
self.emit('read', connection, b'' if value is None else value) self.emit(self.EVENT_READ, connection, b'' if value is None else value)
return b'' if value is None else self.encode_value(value) return b'' if value is None else self.encode_value(value)
@@ -947,7 +950,7 @@ class Attribute(utils.EventEmitter, Generic[_T]):
else: else:
self.value = decoded_value self.value = decoded_value
self.emit('write', connection, decoded_value) self.emit(self.EVENT_WRITE, connection, decoded_value)
def __repr__(self): def __repr__(self):
if isinstance(self.value, bytes): if isinstance(self.value, bytes):
+2 -2
View File
@@ -166,8 +166,8 @@ class Protocol:
# Register to receive PDUs from the channel # Register to receive PDUs from the channel
l2cap_channel.sink = self.on_pdu l2cap_channel.sink = self.on_pdu
l2cap_channel.on("open", self.on_l2cap_channel_open) l2cap_channel.on(l2cap_channel.EVENT_OPEN, self.on_l2cap_channel_open)
l2cap_channel.on("close", self.on_l2cap_channel_close) l2cap_channel.on(l2cap_channel.EVENT_CLOSE, self.on_l2cap_channel_close)
def on_l2cap_channel_open(self): def on_l2cap_channel_open(self):
logger.debug(color("<<< AVCTP channel open", "magenta")) logger.debug(color("<<< AVCTP channel open", "magenta"))
+122 -68
View File
@@ -896,7 +896,7 @@ class Set_Configuration_Reject(Message):
self.service_category = self.payload[0] self.service_category = self.payload[0]
self.error_code = self.payload[1] self.error_code = self.payload[1]
def __init__(self, service_category, error_code): def __init__(self, error_code: int, service_category: int = 0) -> None:
super().__init__(payload=bytes([service_category, error_code])) super().__init__(payload=bytes([service_category, error_code]))
self.service_category = service_category self.service_category = service_category
self.error_code = error_code self.error_code = error_code
@@ -1132,6 +1132,14 @@ class Security_Control_Command(Message):
See Bluetooth AVDTP spec - 8.17.1 Security Control Command See Bluetooth AVDTP spec - 8.17.1 Security Control Command
''' '''
def init_from_payload(self):
# pylint: disable=attribute-defined-outside-init
self.acp_seid = self.payload[0] >> 2
self.data = self.payload[1:]
def __str__(self) -> str:
return self.to_string([f'ACP_SEID: {self.acp_seid}', f'data: {self.data}'])
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@Message.subclass @Message.subclass
@@ -1200,6 +1208,9 @@ class Protocol(utils.EventEmitter):
transaction_results: List[Optional[asyncio.Future[Message]]] transaction_results: List[Optional[asyncio.Future[Message]]]
channel_connector: Callable[[], Awaitable[l2cap.ClassicChannel]] channel_connector: Callable[[], Awaitable[l2cap.ClassicChannel]]
EVENT_OPEN = "open"
EVENT_CLOSE = "close"
class PacketType(enum.IntEnum): class PacketType(enum.IntEnum):
SINGLE_PACKET = 0 SINGLE_PACKET = 0
START_PACKET = 1 START_PACKET = 1
@@ -1239,8 +1250,8 @@ class Protocol(utils.EventEmitter):
# Register to receive PDUs from the channel # Register to receive PDUs from the channel
l2cap_channel.sink = self.on_pdu l2cap_channel.sink = self.on_pdu
l2cap_channel.on('open', self.on_l2cap_channel_open) l2cap_channel.on(l2cap_channel.EVENT_OPEN, self.on_l2cap_channel_open)
l2cap_channel.on('close', self.on_l2cap_channel_close) l2cap_channel.on(l2cap_channel.EVENT_CLOSE, self.on_l2cap_channel_close)
def get_local_endpoint_by_seid(self, seid: int) -> Optional[LocalStreamEndPoint]: def get_local_endpoint_by_seid(self, seid: int) -> Optional[LocalStreamEndPoint]:
if 0 < seid <= len(self.local_endpoints): if 0 < seid <= len(self.local_endpoints):
@@ -1410,20 +1421,20 @@ class Protocol(utils.EventEmitter):
self.transaction_results[transaction_label] = None self.transaction_results[transaction_label] = None
self.transaction_semaphore.release() self.transaction_semaphore.release()
def on_l2cap_connection(self, channel): def on_l2cap_connection(self, channel: l2cap.ClassicChannel) -> None:
# Forward the channel to the endpoint that's expecting it # Forward the channel to the endpoint that's expecting it
if self.channel_acceptor is None: if self.channel_acceptor is None:
logger.warning(color('!!! l2cap connection with no acceptor', 'red')) logger.warning(color('!!! l2cap connection with no acceptor', 'red'))
return return
self.channel_acceptor.on_l2cap_connection(channel) self.channel_acceptor.on_l2cap_connection(channel)
def on_l2cap_channel_open(self): def on_l2cap_channel_open(self) -> None:
logger.debug(color('<<< L2CAP channel open', 'magenta')) logger.debug(color('<<< L2CAP channel open', 'magenta'))
self.emit('open') self.emit(self.EVENT_OPEN)
def on_l2cap_channel_close(self): def on_l2cap_channel_close(self) -> None:
logger.debug(color('<<< L2CAP channel close', 'magenta')) logger.debug(color('<<< L2CAP channel close', 'magenta'))
self.emit('close') self.emit(self.EVENT_CLOSE)
def send_message(self, transaction_label: int, message: Message) -> None: def send_message(self, transaction_label: int, message: Message) -> None:
logger.debug( logger.debug(
@@ -1541,28 +1552,34 @@ class Protocol(utils.EventEmitter):
async def abort(self, seid: int) -> Abort_Response: async def abort(self, seid: int) -> Abort_Response:
return await self.send_command(Abort_Command(seid)) return await self.send_command(Abort_Command(seid))
def on_discover_command(self, _command): def on_discover_command(self, command: Discover_Command) -> Optional[Message]:
endpoint_infos = [ endpoint_infos = [
EndPointInfo(endpoint.seid, 0, endpoint.media_type, endpoint.tsep) EndPointInfo(endpoint.seid, 0, endpoint.media_type, endpoint.tsep)
for endpoint in self.local_endpoints for endpoint in self.local_endpoints
] ]
return Discover_Response(endpoint_infos) return Discover_Response(endpoint_infos)
def on_get_capabilities_command(self, command): def on_get_capabilities_command(
self, command: Get_Capabilities_Command
) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid) endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None: if endpoint is None:
return Get_Capabilities_Reject(AVDTP_BAD_ACP_SEID_ERROR) return Get_Capabilities_Reject(AVDTP_BAD_ACP_SEID_ERROR)
return Get_Capabilities_Response(endpoint.capabilities) return Get_Capabilities_Response(endpoint.capabilities)
def on_get_all_capabilities_command(self, command): def on_get_all_capabilities_command(
self, command: Get_All_Capabilities_Command
) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid) endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None: if endpoint is None:
return Get_All_Capabilities_Reject(AVDTP_BAD_ACP_SEID_ERROR) return Get_All_Capabilities_Reject(AVDTP_BAD_ACP_SEID_ERROR)
return Get_All_Capabilities_Response(endpoint.capabilities) return Get_All_Capabilities_Response(endpoint.capabilities)
def on_set_configuration_command(self, command): def on_set_configuration_command(
self, command: Set_Configuration_Command
) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid) endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None: if endpoint is None:
return Set_Configuration_Reject(AVDTP_BAD_ACP_SEID_ERROR) return Set_Configuration_Reject(AVDTP_BAD_ACP_SEID_ERROR)
@@ -1578,7 +1595,9 @@ class Protocol(utils.EventEmitter):
result = stream.on_set_configuration_command(command.capabilities) result = stream.on_set_configuration_command(command.capabilities)
return result or Set_Configuration_Response() return result or Set_Configuration_Response()
def on_get_configuration_command(self, command): def on_get_configuration_command(
self, command: Get_Configuration_Command
) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid) endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None: if endpoint is None:
return Get_Configuration_Reject(AVDTP_BAD_ACP_SEID_ERROR) return Get_Configuration_Reject(AVDTP_BAD_ACP_SEID_ERROR)
@@ -1587,7 +1606,7 @@ class Protocol(utils.EventEmitter):
return endpoint.stream.on_get_configuration_command() return endpoint.stream.on_get_configuration_command()
def on_reconfigure_command(self, command): def on_reconfigure_command(self, command: Reconfigure_Command) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid) endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None: if endpoint is None:
return Reconfigure_Reject(0, AVDTP_BAD_ACP_SEID_ERROR) return Reconfigure_Reject(0, AVDTP_BAD_ACP_SEID_ERROR)
@@ -1597,7 +1616,7 @@ class Protocol(utils.EventEmitter):
result = endpoint.stream.on_reconfigure_command(command.capabilities) result = endpoint.stream.on_reconfigure_command(command.capabilities)
return result or Reconfigure_Response() return result or Reconfigure_Response()
def on_open_command(self, command): def on_open_command(self, command: Open_Command) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid) endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None: if endpoint is None:
return Open_Reject(AVDTP_BAD_ACP_SEID_ERROR) return Open_Reject(AVDTP_BAD_ACP_SEID_ERROR)
@@ -1607,25 +1626,26 @@ class Protocol(utils.EventEmitter):
result = endpoint.stream.on_open_command() result = endpoint.stream.on_open_command()
return result or Open_Response() return result or Open_Response()
def on_start_command(self, command): def on_start_command(self, command: Start_Command) -> Optional[Message]:
for seid in command.acp_seids: for seid in command.acp_seids:
endpoint = self.get_local_endpoint_by_seid(seid) endpoint = self.get_local_endpoint_by_seid(seid)
if endpoint is None: if endpoint is None:
return Start_Reject(seid, AVDTP_BAD_ACP_SEID_ERROR) return Start_Reject(seid, AVDTP_BAD_ACP_SEID_ERROR)
if endpoint.stream is None: if endpoint.stream is None:
return Start_Reject(AVDTP_BAD_STATE_ERROR) return Start_Reject(seid, AVDTP_BAD_STATE_ERROR)
# Start all streams # Start all streams
# TODO: deal with partial failures # TODO: deal with partial failures
for seid in command.acp_seids: for seid in command.acp_seids:
endpoint = self.get_local_endpoint_by_seid(seid) endpoint = self.get_local_endpoint_by_seid(seid)
result = endpoint.stream.on_start_command() if not endpoint or not endpoint.stream:
if result is not None: raise InvalidStateError("Should already be checked!")
if (result := endpoint.stream.on_start_command()) is not None:
return result return result
return Start_Response() return Start_Response()
def on_suspend_command(self, command): def on_suspend_command(self, command: Suspend_Command) -> Optional[Message]:
for seid in command.acp_seids: for seid in command.acp_seids:
endpoint = self.get_local_endpoint_by_seid(seid) endpoint = self.get_local_endpoint_by_seid(seid)
if endpoint is None: if endpoint is None:
@@ -1637,13 +1657,14 @@ class Protocol(utils.EventEmitter):
# TODO: deal with partial failures # TODO: deal with partial failures
for seid in command.acp_seids: for seid in command.acp_seids:
endpoint = self.get_local_endpoint_by_seid(seid) endpoint = self.get_local_endpoint_by_seid(seid)
result = endpoint.stream.on_suspend_command() if not endpoint or not endpoint.stream:
if result is not None: raise InvalidStateError("Should already be checked!")
if (result := endpoint.stream.on_suspend_command()) is not None:
return result return result
return Suspend_Response() return Suspend_Response()
def on_close_command(self, command): def on_close_command(self, command: Close_Command) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid) endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None: if endpoint is None:
return Close_Reject(AVDTP_BAD_ACP_SEID_ERROR) return Close_Reject(AVDTP_BAD_ACP_SEID_ERROR)
@@ -1653,7 +1674,7 @@ class Protocol(utils.EventEmitter):
result = endpoint.stream.on_close_command() result = endpoint.stream.on_close_command()
return result or Close_Response() return result or Close_Response()
def on_abort_command(self, command): def on_abort_command(self, command: Abort_Command) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid) endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None or endpoint.stream is None: if endpoint is None or endpoint.stream is None:
return Abort_Response() return Abort_Response()
@@ -1661,15 +1682,17 @@ class Protocol(utils.EventEmitter):
endpoint.stream.on_abort_command() endpoint.stream.on_abort_command()
return Abort_Response() return Abort_Response()
def on_security_control_command(self, command): def on_security_control_command(
self, command: Security_Control_Command
) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid) endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None: if endpoint is None:
return Security_Control_Reject(AVDTP_BAD_ACP_SEID_ERROR) return Security_Control_Reject(AVDTP_BAD_ACP_SEID_ERROR)
result = endpoint.on_security_control_command(command.payload) result = endpoint.on_security_control_command(command.data)
return result or Security_Control_Response() return result or Security_Control_Response()
def on_delayreport_command(self, command): def on_delayreport_command(self, command: DelayReport_Command) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid) endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None: if endpoint is None:
return DelayReport_Reject(AVDTP_BAD_ACP_SEID_ERROR) return DelayReport_Reject(AVDTP_BAD_ACP_SEID_ERROR)
@@ -1682,6 +1705,8 @@ class Protocol(utils.EventEmitter):
class Listener(utils.EventEmitter): class Listener(utils.EventEmitter):
servers: Dict[int, Protocol] servers: Dict[int, Protocol]
EVENT_CONNECTION = "connection"
@staticmethod @staticmethod
def create_registrar(device: device.Device): def create_registrar(device: device.Device):
warnings.warn("Please use Listener.for_device()", DeprecationWarning) warnings.warn("Please use Listener.for_device()", DeprecationWarning)
@@ -1716,7 +1741,7 @@ class Listener(utils.EventEmitter):
l2cap_server = device.create_l2cap_server( l2cap_server = device.create_l2cap_server(
spec=l2cap.ClassicChannelSpec(psm=AVDTP_PSM) spec=l2cap.ClassicChannelSpec(psm=AVDTP_PSM)
) )
l2cap_server.on('connection', listener.on_l2cap_connection) l2cap_server.on(l2cap_server.EVENT_CONNECTION, listener.on_l2cap_connection)
return listener return listener
def on_l2cap_connection(self, channel: l2cap.ClassicChannel) -> None: def on_l2cap_connection(self, channel: l2cap.ClassicChannel) -> None:
@@ -1732,14 +1757,14 @@ class Listener(utils.EventEmitter):
logger.debug('setting up new Protocol for the connection') logger.debug('setting up new Protocol for the connection')
server = Protocol(channel, self.version) server = Protocol(channel, self.version)
self.set_server(channel.connection, server) self.set_server(channel.connection, server)
self.emit('connection', server) self.emit(self.EVENT_CONNECTION, server)
def on_channel_close(): def on_channel_close():
logger.debug('removing Protocol for the connection') logger.debug('removing Protocol for the connection')
self.remove_server(channel.connection) self.remove_server(channel.connection)
channel.on('open', on_channel_open) channel.on(channel.EVENT_OPEN, on_channel_open)
channel.on('close', on_channel_close) channel.on(channel.EVENT_CLOSE, on_channel_close)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -1788,6 +1813,7 @@ class Stream:
) )
async def start(self) -> None: async def start(self) -> None:
"""[Source] Start streaming."""
# Auto-open if needed # Auto-open if needed
if self.state == AVDTP_CONFIGURED_STATE: if self.state == AVDTP_CONFIGURED_STATE:
await self.open() await self.open()
@@ -1804,6 +1830,7 @@ class Stream:
self.change_state(AVDTP_STREAMING_STATE) self.change_state(AVDTP_STREAMING_STATE)
async def stop(self) -> None: async def stop(self) -> None:
"""[Source] Stop streaming and transit to OPEN state."""
if self.state != AVDTP_STREAMING_STATE: if self.state != AVDTP_STREAMING_STATE:
raise InvalidStateError('current state is not STREAMING') raise InvalidStateError('current state is not STREAMING')
@@ -1816,6 +1843,7 @@ class Stream:
self.change_state(AVDTP_OPEN_STATE) self.change_state(AVDTP_OPEN_STATE)
async def close(self) -> None: async def close(self) -> None:
"""[Source] Close channel and transit to IDLE state."""
if self.state not in (AVDTP_OPEN_STATE, AVDTP_STREAMING_STATE): if self.state not in (AVDTP_OPEN_STATE, AVDTP_STREAMING_STATE):
raise InvalidStateError('current state is not OPEN or STREAMING') raise InvalidStateError('current state is not OPEN or STREAMING')
@@ -1847,7 +1875,7 @@ class Stream:
self.change_state(AVDTP_CONFIGURED_STATE) self.change_state(AVDTP_CONFIGURED_STATE)
return None return None
def on_get_configuration_command(self, configuration): def on_get_configuration_command(self):
if self.state not in ( if self.state not in (
AVDTP_CONFIGURED_STATE, AVDTP_CONFIGURED_STATE,
AVDTP_OPEN_STATE, AVDTP_OPEN_STATE,
@@ -1855,7 +1883,7 @@ class Stream:
): ):
return Get_Configuration_Reject(AVDTP_BAD_STATE_ERROR) return Get_Configuration_Reject(AVDTP_BAD_STATE_ERROR)
return self.local_endpoint.on_get_configuration_command(configuration) return self.local_endpoint.on_get_configuration_command()
def on_reconfigure_command(self, configuration): def on_reconfigure_command(self, configuration):
if self.state != AVDTP_OPEN_STATE: if self.state != AVDTP_OPEN_STATE:
@@ -1935,20 +1963,20 @@ class Stream:
# Wait for the RTP channel to be closed # Wait for the RTP channel to be closed
self.change_state(AVDTP_ABORTING_STATE) self.change_state(AVDTP_ABORTING_STATE)
def on_l2cap_connection(self, channel): def on_l2cap_connection(self, channel: l2cap.ClassicChannel) -> None:
logger.debug(color('<<< stream channel connected', 'magenta')) logger.debug(color('<<< stream channel connected', 'magenta'))
self.rtp_channel = channel self.rtp_channel = channel
channel.on('open', self.on_l2cap_channel_open) channel.on(channel.EVENT_OPEN, self.on_l2cap_channel_open)
channel.on('close', self.on_l2cap_channel_close) channel.on(channel.EVENT_CLOSE, self.on_l2cap_channel_close)
# We don't need more channels # We don't need more channels
self.protocol.channel_acceptor = None self.protocol.channel_acceptor = None
def on_l2cap_channel_open(self): def on_l2cap_channel_open(self) -> None:
logger.debug(color('<<< stream channel open', 'magenta')) logger.debug(color('<<< stream channel open', 'magenta'))
self.local_endpoint.on_rtp_channel_open() self.local_endpoint.on_rtp_channel_open()
def on_l2cap_channel_close(self): def on_l2cap_channel_close(self) -> None:
logger.debug(color('<<< stream channel closed', 'magenta')) logger.debug(color('<<< stream channel closed', 'magenta'))
self.local_endpoint.on_rtp_channel_close() self.local_endpoint.on_rtp_channel_close()
self.local_endpoint.in_use = 0 self.local_endpoint.in_use = 0
@@ -2065,6 +2093,19 @@ class DiscoveredStreamEndPoint(StreamEndPoint, StreamEndPointProxy):
class LocalStreamEndPoint(StreamEndPoint, utils.EventEmitter): class LocalStreamEndPoint(StreamEndPoint, utils.EventEmitter):
stream: Optional[Stream] stream: Optional[Stream]
EVENT_CONFIGURATION = "configuration"
EVENT_OPEN = "open"
EVENT_START = "start"
EVENT_STOP = "stop"
EVENT_RTP_PACKET = "rtp_packet"
EVENT_SUSPEND = "suspend"
EVENT_CLOSE = "close"
EVENT_ABORT = "abort"
EVENT_DELAY_REPORT = "delay_report"
EVENT_SECURITY_CONTROL = "security_control"
EVENT_RTP_CHANNEL_OPEN = "rtp_channel_open"
EVENT_RTP_CHANNEL_CLOSE = "rtp_channel_close"
def __init__( def __init__(
self, self,
protocol: Protocol, protocol: Protocol,
@@ -2080,52 +2121,65 @@ class LocalStreamEndPoint(StreamEndPoint, utils.EventEmitter):
self.configuration = configuration if configuration is not None else [] self.configuration = configuration if configuration is not None else []
self.stream = None self.stream = None
async def start(self): async def start(self) -> None:
pass """[Source Only] Handles when receiving start command."""
async def stop(self): async def stop(self) -> None:
pass """[Source Only] Handles when receiving stop command."""
async def close(self): async def close(self) -> None:
pass """[Source Only] Handles when receiving close command."""
def on_reconfigure_command(self, command): def on_reconfigure_command(self, command) -> Optional[Message]:
pass return None
def on_set_configuration_command(self, configuration): def on_set_configuration_command(self, configuration) -> Optional[Message]:
logger.debug( logger.debug(
'<<< received configuration: ' '<<< received configuration: '
f'{",".join([str(capability) for capability in configuration])}' f'{",".join([str(capability) for capability in configuration])}'
) )
self.configuration = configuration self.configuration = configuration
self.emit('configuration') self.emit(self.EVENT_CONFIGURATION)
return None
def on_get_configuration_command(self): def on_get_configuration_command(self) -> Optional[Message]:
return Get_Configuration_Response(self.configuration) return Get_Configuration_Response(self.configuration)
def on_open_command(self): def on_open_command(self) -> Optional[Message]:
self.emit('open') self.emit(self.EVENT_OPEN)
return None
def on_start_command(self): def on_start_command(self) -> Optional[Message]:
self.emit('start') self.emit(self.EVENT_START)
return None
def on_suspend_command(self): def on_suspend_command(self) -> Optional[Message]:
self.emit('suspend') self.emit(self.EVENT_SUSPEND)
return None
def on_close_command(self): def on_close_command(self) -> Optional[Message]:
self.emit('close') self.emit(self.EVENT_CLOSE)
return None
def on_abort_command(self): def on_abort_command(self) -> Optional[Message]:
self.emit('abort') self.emit(self.EVENT_ABORT)
return None
def on_delayreport_command(self, delay: int): def on_delayreport_command(self, delay: int) -> Optional[Message]:
self.emit('delay_report', delay) self.emit(self.EVENT_DELAY_REPORT, delay)
return None
def on_rtp_channel_open(self): def on_security_control_command(self, data: bytes) -> Optional[Message]:
self.emit('rtp_channel_open') self.emit(self.EVENT_SECURITY_CONTROL, data)
return None
def on_rtp_channel_close(self): def on_rtp_channel_open(self) -> None:
self.emit('rtp_channel_close') self.emit(self.EVENT_RTP_CHANNEL_OPEN)
return None
def on_rtp_channel_close(self) -> None:
self.emit(self.EVENT_RTP_CHANNEL_CLOSE)
return None
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -2156,13 +2210,13 @@ class LocalSource(LocalStreamEndPoint):
if self.packet_pump and self.stream and self.stream.rtp_channel: if self.packet_pump and self.stream and self.stream.rtp_channel:
return await self.packet_pump.start(self.stream.rtp_channel) return await self.packet_pump.start(self.stream.rtp_channel)
self.emit('start') self.emit(self.EVENT_START)
async def stop(self) -> None: async def stop(self) -> None:
if self.packet_pump: if self.packet_pump:
return await self.packet_pump.stop() return await self.packet_pump.stop()
self.emit('stop') self.emit(self.EVENT_STOP)
def on_start_command(self): def on_start_command(self):
asyncio.create_task(self.start()) asyncio.create_task(self.start())
@@ -2203,4 +2257,4 @@ class LocalSink(LocalStreamEndPoint):
f'{color("<<< RTP Packet:", "green")} ' f'{color("<<< RTP Packet:", "green")} '
f'{rtp_packet} {rtp_packet.payload[:16].hex()}' f'{rtp_packet} {rtp_packet.payload[:16].hex()}'
) )
self.emit('rtp_packet', rtp_packet) self.emit(self.EVENT_RTP_PACKET, rtp_packet)
+11 -5
View File
@@ -996,6 +996,10 @@ class Delegate:
class Protocol(utils.EventEmitter): class Protocol(utils.EventEmitter):
"""AVRCP Controller and Target protocol.""" """AVRCP Controller and Target protocol."""
EVENT_CONNECTION = "connection"
EVENT_START = "start"
EVENT_STOP = "stop"
class PacketType(enum.IntEnum): class PacketType(enum.IntEnum):
SINGLE = 0b00 SINGLE = 0b00
START = 0b01 START = 0b01
@@ -1456,9 +1460,11 @@ class Protocol(utils.EventEmitter):
def _on_avctp_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None: def _on_avctp_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug("AVCTP connection established") logger.debug("AVCTP connection established")
l2cap_channel.on("open", lambda: self._on_avctp_channel_open(l2cap_channel)) l2cap_channel.on(
l2cap_channel.EVENT_OPEN, lambda: self._on_avctp_channel_open(l2cap_channel)
)
self.emit("connection") self.emit(self.EVENT_CONNECTION)
def _on_avctp_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None: def _on_avctp_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug("AVCTP channel open") logger.debug("AVCTP channel open")
@@ -1473,15 +1479,15 @@ class Protocol(utils.EventEmitter):
self.avctp_protocol.register_response_handler( self.avctp_protocol.register_response_handler(
AVRCP_PID, self._on_avctp_response AVRCP_PID, self._on_avctp_response
) )
l2cap_channel.on("close", self._on_avctp_channel_close) l2cap_channel.on(l2cap_channel.EVENT_CLOSE, self._on_avctp_channel_close)
self.emit("start") self.emit(self.EVENT_START)
def _on_avctp_channel_close(self) -> None: def _on_avctp_channel_close(self) -> None:
logger.debug("AVCTP channel closed") logger.debug("AVCTP channel closed")
self.avctp_protocol = None self.avctp_protocol = None
self.emit("stop") self.emit(self.EVENT_STOP)
def _on_avctp_command( def _on_avctp_command(
self, transaction_label: int, command: avc.CommandFrame self, transaction_label: int, command: avc.CommandFrame
+13 -7
View File
@@ -809,7 +809,7 @@ class Appearance:
STICK_PC = 0x0F STICK_PC = 0x0F
class WatchSubcategory(utils.OpenIntEnum): class WatchSubcategory(utils.OpenIntEnum):
GENENERIC_WATCH = 0x00 GENERIC_WATCH = 0x00
SPORTS_WATCH = 0x01 SPORTS_WATCH = 0x01
SMARTWATCH = 0x02 SMARTWATCH = 0x02
@@ -1127,7 +1127,7 @@ class Appearance:
TURNTABLE = 0x05 TURNTABLE = 0x05
CD_PLAYER = 0x06 CD_PLAYER = 0x06
DVD_PLAYER = 0x07 DVD_PLAYER = 0x07
BLUERAY_PLAYER = 0x08 BLURAY_PLAYER = 0x08
OPTICAL_DISC_PLAYER = 0x09 OPTICAL_DISC_PLAYER = 0x09
SET_TOP_BOX = 0x0A SET_TOP_BOX = 0x0A
@@ -1351,6 +1351,12 @@ class AdvertisingData:
THREE_D_INFORMATION_DATA = 0x3D THREE_D_INFORMATION_DATA = 0x3D
MANUFACTURER_SPECIFIC_DATA = 0xFF MANUFACTURER_SPECIFIC_DATA = 0xFF
class Flags(enum.IntFlag):
LE_LIMITED_DISCOVERABLE_MODE = 1 << 0
LE_GENERAL_DISCOVERABLE_MODE = 1 << 1
BR_EDR_NOT_SUPPORTED = 1 << 2
SIMULTANEOUS_LE_BR_EDR_CAPABLE = 1 << 3
# For backward-compatibility # For backward-compatibility
FLAGS = Type.FLAGS FLAGS = Type.FLAGS
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = Type.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = Type.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS
@@ -1407,11 +1413,11 @@ class AdvertisingData:
THREE_D_INFORMATION_DATA = Type.THREE_D_INFORMATION_DATA THREE_D_INFORMATION_DATA = Type.THREE_D_INFORMATION_DATA
MANUFACTURER_SPECIFIC_DATA = Type.MANUFACTURER_SPECIFIC_DATA MANUFACTURER_SPECIFIC_DATA = Type.MANUFACTURER_SPECIFIC_DATA
LE_LIMITED_DISCOVERABLE_MODE_FLAG = 0x01 LE_LIMITED_DISCOVERABLE_MODE_FLAG = Flags.LE_LIMITED_DISCOVERABLE_MODE
LE_GENERAL_DISCOVERABLE_MODE_FLAG = 0x02 LE_GENERAL_DISCOVERABLE_MODE_FLAG = Flags.LE_GENERAL_DISCOVERABLE_MODE
BR_EDR_NOT_SUPPORTED_FLAG = 0x04 BR_EDR_NOT_SUPPORTED_FLAG = Flags.BR_EDR_NOT_SUPPORTED
BR_EDR_CONTROLLER_FLAG = 0x08 BR_EDR_CONTROLLER_FLAG = Flags.SIMULTANEOUS_LE_BR_EDR_CAPABLE
BR_EDR_HOST_FLAG = 0x10 BR_EDR_HOST_FLAG = 0x10 # Deprecated
ad_structures: list[tuple[int, bytes]] ad_structures: list[tuple[int, bytes]]
+360 -184
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -448,6 +448,8 @@ class Characteristic(Attribute[_T]):
uuid: UUID uuid: UUID
properties: Characteristic.Properties properties: Characteristic.Properties
EVENT_SUBSCRIPTION = "subscription"
class Properties(enum.IntFlag): class Properties(enum.IntFlag):
"""Property flags""" """Property flags"""
+5 -3
View File
@@ -202,6 +202,8 @@ class CharacteristicProxy(AttributeProxy[_T]):
descriptors: List[DescriptorProxy] descriptors: List[DescriptorProxy]
subscribers: Dict[Any, Callable[[_T], Any]] subscribers: Dict[Any, Callable[[_T], Any]]
EVENT_UPDATE = "update"
def __init__( def __init__(
self, self,
client: Client, client: Client,
@@ -308,7 +310,7 @@ class Client:
self.services = [] self.services = []
self.cached_values = {} self.cached_values = {}
connection.on('disconnection', self.on_disconnection) connection.on(connection.EVENT_DISCONNECTION, self.on_disconnection)
def send_gatt_pdu(self, pdu: bytes) -> None: def send_gatt_pdu(self, pdu: bytes) -> None:
self.connection.send_l2cap_pdu(ATT_CID, pdu) self.connection.send_l2cap_pdu(ATT_CID, pdu)
@@ -1142,7 +1144,7 @@ class Client:
if callable(subscriber): if callable(subscriber):
subscriber(notification.attribute_value) subscriber(notification.attribute_value)
else: else:
subscriber.emit('update', notification.attribute_value) subscriber.emit(subscriber.EVENT_UPDATE, notification.attribute_value)
def on_att_handle_value_indication(self, indication): def on_att_handle_value_indication(self, indication):
# Call all subscribers # Call all subscribers
@@ -1157,7 +1159,7 @@ class Client:
if callable(subscriber): if callable(subscriber):
subscriber(indication.attribute_value) subscriber(indication.attribute_value)
else: else:
subscriber.emit('update', indication.attribute_value) subscriber.emit(subscriber.EVENT_UPDATE, indication.attribute_value)
# Confirm that we received the indication # Confirm that we received the indication
self.send_confirmation(ATT_Handle_Value_Confirmation()) self.send_confirmation(ATT_Handle_Value_Confirmation())
+7 -2
View File
@@ -110,6 +110,8 @@ class Server(utils.EventEmitter):
indication_semaphores: defaultdict[int, asyncio.Semaphore] indication_semaphores: defaultdict[int, asyncio.Semaphore]
pending_confirmations: defaultdict[int, Optional[asyncio.futures.Future]] pending_confirmations: defaultdict[int, Optional[asyncio.futures.Future]]
EVENT_CHARACTERISTIC_SUBSCRIPTION = "characteristic_subscription"
def __init__(self, device: Device) -> None: def __init__(self, device: Device) -> None:
super().__init__() super().__init__()
self.device = device self.device = device
@@ -347,10 +349,13 @@ class Server(utils.EventEmitter):
notify_enabled = value[0] & 0x01 != 0 notify_enabled = value[0] & 0x01 != 0
indicate_enabled = value[0] & 0x02 != 0 indicate_enabled = value[0] & 0x02 != 0
characteristic.emit( characteristic.emit(
'subscription', connection, notify_enabled, indicate_enabled characteristic.EVENT_SUBSCRIPTION,
connection,
notify_enabled,
indicate_enabled,
) )
self.emit( self.emit(
'characteristic_subscription', self.EVENT_CHARACTERISTIC_SUBSCRIPTION,
connection, connection,
characteristic, characteristic,
notify_enabled, notify_enabled,
+67 -2
View File
@@ -29,13 +29,12 @@ from typing_extensions import Self
from bumble import crypto from bumble import crypto
from bumble.colors import color from bumble.colors import color
from bumble.core import ( from bumble.core import (
PhysicalTransport,
AdvertisingData, AdvertisingData,
DeviceClass, DeviceClass,
InvalidArgumentError, InvalidArgumentError,
InvalidPacketError, InvalidPacketError,
ProtocolError,
PhysicalTransport, PhysicalTransport,
ProtocolError,
bit_flags_to_strings, bit_flags_to_strings,
name_or_number, name_or_number,
padded_bytes, padded_bytes,
@@ -225,6 +224,7 @@ HCI_CONNECTIONLESS_PERIPHERAL_BROADCAST_CHANNEL_MAP_CHANGE_EVENT = 0X55
HCI_INQUIRY_RESPONSE_NOTIFICATION_EVENT = 0X56 HCI_INQUIRY_RESPONSE_NOTIFICATION_EVENT = 0X56
HCI_AUTHENTICATED_PAYLOAD_TIMEOUT_EXPIRED_EVENT = 0X57 HCI_AUTHENTICATED_PAYLOAD_TIMEOUT_EXPIRED_EVENT = 0X57
HCI_SAM_STATUS_CHANGE_EVENT = 0X58 HCI_SAM_STATUS_CHANGE_EVENT = 0X58
HCI_ENCRYPTION_CHANGE_V2_EVENT = 0x59
HCI_VENDOR_EVENT = 0xFF HCI_VENDOR_EVENT = 0xFF
@@ -3364,6 +3364,20 @@ class HCI_Set_Event_Mask_Page_2_Command(HCI_Command):
See Bluetooth spec @ 7.3.69 Set Event Mask Page 2 Command See Bluetooth spec @ 7.3.69 Set Event Mask Page 2 Command
''' '''
@staticmethod
def mask(event_codes: Iterable[int]) -> bytes:
'''
Compute the event mask value for a list of events.
'''
# NOTE: this implementation takes advantage of the fact that as of version 6.0
# of the core specification, the bit number for each event code is equal to 64
# less than the event code.
# If future versions of the specification deviate from that, a different
# implementation would be needed.
return sum((1 << event_code - 64) for event_code in event_codes).to_bytes(
8, 'little'
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@HCI_Command.command( @HCI_Command.command(
@@ -5971,6 +5985,33 @@ class HCI_LE_Enhanced_Connection_Complete_Event(HCI_LE_Meta_Event):
''' '''
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('status', STATUS_SPEC),
('connection_handle', 2),
(
'role',
{'size': 1, 'mapper': lambda x: 'CENTRAL' if x == 0 else 'PERIPHERAL'},
),
('peer_address_type', Address.ADDRESS_TYPE_SPEC),
('peer_address', Address.parse_address_preceded_by_type),
('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),
('central_clock_accuracy', 1),
('advertising_handle', 1),
('sync_handle', 2),
]
)
class HCI_LE_Enhanced_Connection_Complete_V2_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.10 LE Enhanced Connection Complete Event
'''
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event( @HCI_LE_Meta_Event.event(
[ [
@@ -6950,6 +6991,30 @@ class HCI_Encryption_Change_Event(HCI_Event):
) )
# -----------------------------------------------------------------------------
@HCI_Event.event(
[
('status', STATUS_SPEC),
('connection_handle', 2),
(
'encryption_enabled',
{
'size': 1,
# pylint: disable-next=unnecessary-lambda
'mapper': lambda x: HCI_Encryption_Change_Event.encryption_enabled_name(
x
),
},
),
('encryption_key_size', 1),
]
)
class HCI_Encryption_Change_V2_Event(HCI_Event):
'''
See Bluetooth spec @ 7.7.8 Encryption Change Event
'''
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@HCI_Event.event( @HCI_Event.event(
[('status', STATUS_SPEC), ('connection_handle', 2), ('lmp_features', 8)] [('status', STATUS_SPEC), ('connection_handle', 2), ('lmp_features', 8)]
+44 -20
View File
@@ -720,6 +720,14 @@ class HfProtocol(utils.EventEmitter):
vrec: VoiceRecognitionState vrec: VoiceRecognitionState
""" """
EVENT_CODEC_NEGOTIATION = "codec_negotiation"
EVENT_AG_INDICATOR = "ag_indicator"
EVENT_SPEAKER_VOLUME = "speaker_volume"
EVENT_MICROPHONE_VOLUME = "microphone_volume"
EVENT_RING = "ring"
EVENT_CLI_NOTIFICATION = "cli_notification"
EVENT_VOICE_RECOGNITION = "voice_recognition"
class HfLoopTermination(HfpProtocolError): class HfLoopTermination(HfpProtocolError):
"""Termination signal for run() loop.""" """Termination signal for run() loop."""
@@ -777,7 +785,8 @@ class HfProtocol(utils.EventEmitter):
self.dlc.sink = self._read_at self.dlc.sink = self._read_at
# Stop the run() loop when L2CAP is closed. # Stop the run() loop when L2CAP is closed.
self.dlc.multiplexer.l2cap_channel.on( self.dlc.multiplexer.l2cap_channel.on(
'close', lambda: self.unsolicited_queue.put_nowait(None) self.dlc.multiplexer.l2cap_channel.EVENT_CLOSE,
lambda: self.unsolicited_queue.put_nowait(None),
) )
def supports_hf_feature(self, feature: HfFeature) -> bool: def supports_hf_feature(self, feature: HfFeature) -> bool:
@@ -1034,7 +1043,7 @@ class HfProtocol(utils.EventEmitter):
# ID. The HF shall be ready to accept the synchronous connection # ID. The HF shall be ready to accept the synchronous connection
# establishment as soon as it has sent the AT commands AT+BCS=<Codec ID>. # establishment as soon as it has sent the AT commands AT+BCS=<Codec ID>.
self.active_codec = AudioCodec(codec_id) self.active_codec = AudioCodec(codec_id)
self.emit('codec_negotiation', self.active_codec) self.emit(self.EVENT_CODEC_NEGOTIATION, self.active_codec)
logger.info("codec connection setup completed") logger.info("codec connection setup completed")
@@ -1095,7 +1104,7 @@ class HfProtocol(utils.EventEmitter):
# CIEV is in 1-index, while ag_indicators is in 0-index. # CIEV is in 1-index, while ag_indicators is in 0-index.
ag_indicator = self.ag_indicators[index - 1] ag_indicator = self.ag_indicators[index - 1]
ag_indicator.current_status = value ag_indicator.current_status = value
self.emit('ag_indicator', ag_indicator) self.emit(self.EVENT_AG_INDICATOR, ag_indicator)
logger.info(f"AG indicator updated: {ag_indicator.indicator}, {value}") logger.info(f"AG indicator updated: {ag_indicator.indicator}, {value}")
async def handle_unsolicited(self): async def handle_unsolicited(self):
@@ -1110,19 +1119,21 @@ class HfProtocol(utils.EventEmitter):
int(result.parameters[0]), int(result.parameters[1]) int(result.parameters[0]), int(result.parameters[1])
) )
elif result.code == "+VGS": elif result.code == "+VGS":
self.emit('speaker_volume', int(result.parameters[0])) self.emit(self.EVENT_SPEAKER_VOLUME, int(result.parameters[0]))
elif result.code == "+VGM": elif result.code == "+VGM":
self.emit('microphone_volume', int(result.parameters[0])) self.emit(self.EVENT_MICROPHONE_VOLUME, int(result.parameters[0]))
elif result.code == "RING": elif result.code == "RING":
self.emit('ring') self.emit(self.EVENT_RING)
elif result.code == "+CLIP": elif result.code == "+CLIP":
self.emit( self.emit(
'cli_notification', CallLineIdentification.parse_from(result.parameters) self.EVENT_CLI_NOTIFICATION,
CallLineIdentification.parse_from(result.parameters),
) )
elif result.code == "+BVRA": elif result.code == "+BVRA":
# TODO: Support Enhanced Voice Recognition. # TODO: Support Enhanced Voice Recognition.
self.emit( self.emit(
'voice_recognition', VoiceRecognitionState(int(result.parameters[0])) self.EVENT_VOICE_RECOGNITION,
VoiceRecognitionState(int(result.parameters[0])),
) )
else: else:
logging.info(f"unhandled unsolicited response {result.code}") logging.info(f"unhandled unsolicited response {result.code}")
@@ -1179,6 +1190,19 @@ class AgProtocol(utils.EventEmitter):
volume: Int volume: Int
""" """
EVENT_SLC_COMPLETE = "slc_complete"
EVENT_SUPPORTED_AUDIO_CODECS = "supported_audio_codecs"
EVENT_CODEC_NEGOTIATION = "codec_negotiation"
EVENT_VOICE_RECOGNITION = "voice_recognition"
EVENT_CALL_HOLD = "call_hold"
EVENT_HF_INDICATOR = "hf_indicator"
EVENT_CODEC_CONNECTION_REQUEST = "codec_connection_request"
EVENT_ANSWER = "answer"
EVENT_DIAL = "dial"
EVENT_HANG_UP = "hang_up"
EVENT_SPEAKER_VOLUME = "speaker_volume"
EVENT_MICROPHONE_VOLUME = "microphone_volume"
supported_hf_features: int supported_hf_features: int
supported_hf_indicators: Set[HfIndicator] supported_hf_indicators: Set[HfIndicator]
supported_audio_codecs: List[AudioCodec] supported_audio_codecs: List[AudioCodec]
@@ -1371,7 +1395,7 @@ class AgProtocol(utils.EventEmitter):
def _check_remained_slc_commands(self) -> None: def _check_remained_slc_commands(self) -> None:
if not self._remained_slc_setup_features: if not self._remained_slc_setup_features:
self.emit('slc_complete') self.emit(self.EVENT_SLC_COMPLETE)
def _on_brsf(self, hf_features: bytes) -> None: def _on_brsf(self, hf_features: bytes) -> None:
self.supported_hf_features = int(hf_features) self.supported_hf_features = int(hf_features)
@@ -1390,17 +1414,17 @@ class AgProtocol(utils.EventEmitter):
def _on_bac(self, *args) -> None: def _on_bac(self, *args) -> None:
self.supported_audio_codecs = [AudioCodec(int(value)) for value in args] self.supported_audio_codecs = [AudioCodec(int(value)) for value in args]
self.emit('supported_audio_codecs', self.supported_audio_codecs) self.emit(self.EVENT_SUPPORTED_AUDIO_CODECS, self.supported_audio_codecs)
self.send_ok() self.send_ok()
def _on_bcs(self, codec: bytes) -> None: def _on_bcs(self, codec: bytes) -> None:
self.active_codec = AudioCodec(int(codec)) self.active_codec = AudioCodec(int(codec))
self.send_ok() self.send_ok()
self.emit('codec_negotiation', self.active_codec) self.emit(self.EVENT_CODEC_NEGOTIATION, self.active_codec)
def _on_bvra(self, vrec: bytes) -> None: def _on_bvra(self, vrec: bytes) -> None:
self.send_ok() self.send_ok()
self.emit('voice_recognition', VoiceRecognitionState(int(vrec))) self.emit(self.EVENT_VOICE_RECOGNITION, VoiceRecognitionState(int(vrec)))
def _on_chld(self, operation_code: bytes) -> None: def _on_chld(self, operation_code: bytes) -> None:
call_index: Optional[int] = None call_index: Optional[int] = None
@@ -1427,7 +1451,7 @@ class AgProtocol(utils.EventEmitter):
# Real three-way calls have more complicated situations, but this is not a popular issue - let users to handle the remaining :) # Real three-way calls have more complicated situations, but this is not a popular issue - let users to handle the remaining :)
self.send_ok() self.send_ok()
self.emit('call_hold', operation, call_index) self.emit(self.EVENT_CALL_HOLD, operation, call_index)
def _on_chld_test(self) -> None: def _on_chld_test(self) -> None:
if not self.supports_ag_feature(AgFeature.THREE_WAY_CALLING): if not self.supports_ag_feature(AgFeature.THREE_WAY_CALLING):
@@ -1553,7 +1577,7 @@ class AgProtocol(utils.EventEmitter):
return return
self.hf_indicators[index].current_status = int(value_bytes) self.hf_indicators[index].current_status = int(value_bytes)
self.emit('hf_indicator', self.hf_indicators[index]) self.emit(self.EVENT_HF_INDICATOR, self.hf_indicators[index])
self.send_ok() self.send_ok()
def _on_bia(self, *args) -> None: def _on_bia(self, *args) -> None:
@@ -1562,21 +1586,21 @@ class AgProtocol(utils.EventEmitter):
self.send_ok() self.send_ok()
def _on_bcc(self) -> None: def _on_bcc(self) -> None:
self.emit('codec_connection_request') self.emit(self.EVENT_CODEC_CONNECTION_REQUEST)
self.send_ok() self.send_ok()
def _on_a(self) -> None: def _on_a(self) -> None:
"""ATA handler.""" """ATA handler."""
self.emit('answer') self.emit(self.EVENT_ANSWER)
self.send_ok() self.send_ok()
def _on_d(self, number: bytes) -> None: def _on_d(self, number: bytes) -> None:
"""ATD handler.""" """ATD handler."""
self.emit('dial', number.decode()) self.emit(self.EVENT_DIAL, number.decode())
self.send_ok() self.send_ok()
def _on_chup(self) -> None: def _on_chup(self) -> None:
self.emit('hang_up') self.emit(self.EVENT_HANG_UP)
self.send_ok() self.send_ok()
def _on_clcc(self) -> None: def _on_clcc(self) -> None:
@@ -1602,11 +1626,11 @@ class AgProtocol(utils.EventEmitter):
self.send_ok() self.send_ok()
def _on_vgs(self, level: bytes) -> None: def _on_vgs(self, level: bytes) -> None:
self.emit('speaker_volume', int(level)) self.emit(self.EVENT_SPEAKER_VOLUME, int(level))
self.send_ok() self.send_ok()
def _on_vgm(self, level: bytes) -> None: def _on_vgm(self, level: bytes) -> None:
self.emit('microphone_volume', int(level)) self.emit(self.EVENT_MICROPHONE_VOLUME, int(level))
self.send_ok() self.send_ok()
+24 -12
View File
@@ -201,6 +201,13 @@ class HID(ABC, utils.EventEmitter):
l2cap_intr_channel: Optional[l2cap.ClassicChannel] = None l2cap_intr_channel: Optional[l2cap.ClassicChannel] = None
connection: Optional[device.Connection] = None connection: Optional[device.Connection] = None
EVENT_INTERRUPT_DATA = "interrupt_data"
EVENT_CONTROL_DATA = "control_data"
EVENT_SUSPEND = "suspend"
EVENT_EXIT_SUSPEND = "exit_suspend"
EVENT_VIRTUAL_CABLE_UNPLUG = "virtual_cable_unplug"
EVENT_HANDSHAKE = "handshake"
class Role(enum.IntEnum): class Role(enum.IntEnum):
HOST = 0x00 HOST = 0x00
DEVICE = 0x01 DEVICE = 0x01
@@ -215,7 +222,7 @@ class HID(ABC, utils.EventEmitter):
device.register_l2cap_server(HID_CONTROL_PSM, self.on_l2cap_connection) device.register_l2cap_server(HID_CONTROL_PSM, self.on_l2cap_connection)
device.register_l2cap_server(HID_INTERRUPT_PSM, self.on_l2cap_connection) device.register_l2cap_server(HID_INTERRUPT_PSM, self.on_l2cap_connection)
device.on('connection', self.on_device_connection) device.on(device.EVENT_CONNECTION, self.on_device_connection)
async def connect_control_channel(self) -> None: async def connect_control_channel(self) -> None:
# Create a new L2CAP connection - control channel # Create a new L2CAP connection - control channel
@@ -258,15 +265,20 @@ class HID(ABC, utils.EventEmitter):
def on_device_connection(self, connection: device.Connection) -> None: def on_device_connection(self, connection: device.Connection) -> None:
self.connection = connection self.connection = connection
self.remote_device_bd_address = connection.peer_address self.remote_device_bd_address = connection.peer_address
connection.on('disconnection', self.on_device_disconnection) connection.on(connection.EVENT_DISCONNECTION, self.on_device_disconnection)
def on_device_disconnection(self, reason: int) -> None: def on_device_disconnection(self, reason: int) -> None:
self.connection = None self.connection = None
def on_l2cap_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None: def on_l2cap_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug(f'+++ New L2CAP connection: {l2cap_channel}') logger.debug(f'+++ New L2CAP connection: {l2cap_channel}')
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel)) l2cap_channel.on(
l2cap_channel.on('close', lambda: self.on_l2cap_channel_close(l2cap_channel)) l2cap_channel.EVENT_OPEN, lambda: self.on_l2cap_channel_open(l2cap_channel)
)
l2cap_channel.on(
l2cap_channel.EVENT_CLOSE,
lambda: self.on_l2cap_channel_close(l2cap_channel),
)
def on_l2cap_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None: def on_l2cap_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
if l2cap_channel.psm == HID_CONTROL_PSM: if l2cap_channel.psm == HID_CONTROL_PSM:
@@ -290,7 +302,7 @@ class HID(ABC, utils.EventEmitter):
def on_intr_pdu(self, pdu: bytes) -> None: def on_intr_pdu(self, pdu: bytes) -> None:
logger.debug(f'<<< HID INTERRUPT PDU: {pdu.hex()}') logger.debug(f'<<< HID INTERRUPT PDU: {pdu.hex()}')
self.emit("interrupt_data", pdu) self.emit(self.EVENT_INTERRUPT_DATA, pdu)
def send_pdu_on_ctrl(self, msg: bytes) -> None: def send_pdu_on_ctrl(self, msg: bytes) -> None:
assert self.l2cap_ctrl_channel assert self.l2cap_ctrl_channel
@@ -363,17 +375,17 @@ class Device(HID):
self.handle_set_protocol(pdu) self.handle_set_protocol(pdu)
elif message_type == Message.MessageType.DATA: elif message_type == Message.MessageType.DATA:
logger.debug('<<< HID CONTROL DATA') logger.debug('<<< HID CONTROL DATA')
self.emit('control_data', pdu) self.emit(self.EVENT_CONTROL_DATA, pdu)
elif message_type == Message.MessageType.CONTROL: elif message_type == Message.MessageType.CONTROL:
if param == Message.ControlCommand.SUSPEND: if param == Message.ControlCommand.SUSPEND:
logger.debug('<<< HID SUSPEND') logger.debug('<<< HID SUSPEND')
self.emit('suspend') self.emit(self.EVENT_SUSPEND)
elif param == Message.ControlCommand.EXIT_SUSPEND: elif param == Message.ControlCommand.EXIT_SUSPEND:
logger.debug('<<< HID EXIT SUSPEND') logger.debug('<<< HID EXIT SUSPEND')
self.emit('exit_suspend') self.emit(self.EVENT_EXIT_SUSPEND)
elif param == Message.ControlCommand.VIRTUAL_CABLE_UNPLUG: elif param == Message.ControlCommand.VIRTUAL_CABLE_UNPLUG:
logger.debug('<<< HID VIRTUAL CABLE UNPLUG') logger.debug('<<< HID VIRTUAL CABLE UNPLUG')
self.emit('virtual_cable_unplug') self.emit(self.EVENT_VIRTUAL_CABLE_UNPLUG)
else: else:
logger.debug('<<< HID CONTROL OPERATION UNSUPPORTED') logger.debug('<<< HID CONTROL OPERATION UNSUPPORTED')
else: else:
@@ -538,14 +550,14 @@ class Host(HID):
message_type = pdu[0] >> 4 message_type = pdu[0] >> 4
if message_type == Message.MessageType.HANDSHAKE: if message_type == Message.MessageType.HANDSHAKE:
logger.debug(f'<<< HID HANDSHAKE: {Message.Handshake(param).name}') logger.debug(f'<<< HID HANDSHAKE: {Message.Handshake(param).name}')
self.emit('handshake', Message.Handshake(param)) self.emit(self.EVENT_HANDSHAKE, Message.Handshake(param))
elif message_type == Message.MessageType.DATA: elif message_type == Message.MessageType.DATA:
logger.debug('<<< HID CONTROL DATA') logger.debug('<<< HID CONTROL DATA')
self.emit('control_data', pdu) self.emit(self.EVENT_CONTROL_DATA, pdu)
elif message_type == Message.MessageType.CONTROL: elif message_type == Message.MessageType.CONTROL:
if param == Message.ControlCommand.VIRTUAL_CABLE_UNPLUG: if param == Message.ControlCommand.VIRTUAL_CABLE_UNPLUG:
logger.debug('<<< HID VIRTUAL CABLE UNPLUG') logger.debug('<<< HID VIRTUAL CABLE UNPLUG')
self.emit('virtual_cable_unplug') self.emit(self.EVENT_VIRTUAL_CABLE_UNPLUG)
else: else:
logger.debug('<<< HID CONTROL OPERATION UNSUPPORTED') logger.debug('<<< HID CONTROL OPERATION UNSUPPORTED')
else: else:
+24
View File
@@ -435,6 +435,14 @@ class Host(utils.EventEmitter):
) )
) )
) )
if self.supports_command(hci.HCI_SET_EVENT_MASK_PAGE_2_COMMAND):
await self.send_command(
hci.HCI_Set_Event_Mask_Page_2_Command(
event_mask_page_2=hci.HCI_Set_Event_Mask_Page_2_Command.mask(
[hci.HCI_ENCRYPTION_CHANGE_V2_EVENT]
)
)
)
if ( if (
self.local_version is not None self.local_version is not None
@@ -456,6 +464,7 @@ class Host(utils.EventEmitter):
hci.HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMPLETE_EVENT, hci.HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMPLETE_EVENT,
hci.HCI_LE_GENERATE_DHKEY_COMPLETE_EVENT, hci.HCI_LE_GENERATE_DHKEY_COMPLETE_EVENT,
hci.HCI_LE_ENHANCED_CONNECTION_COMPLETE_EVENT, hci.HCI_LE_ENHANCED_CONNECTION_COMPLETE_EVENT,
hci.HCI_LE_ENHANCED_CONNECTION_COMPLETE_V2_EVENT,
hci.HCI_LE_DIRECTED_ADVERTISING_REPORT_EVENT, hci.HCI_LE_DIRECTED_ADVERTISING_REPORT_EVENT,
hci.HCI_LE_PHY_UPDATE_COMPLETE_EVENT, hci.HCI_LE_PHY_UPDATE_COMPLETE_EVENT,
hci.HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT, hci.HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT,
@@ -1383,6 +1392,21 @@ class Host(utils.EventEmitter):
'connection_encryption_change', 'connection_encryption_change',
event.connection_handle, event.connection_handle,
event.encryption_enabled, event.encryption_enabled,
0,
)
else:
self.emit(
'connection_encryption_failure', event.connection_handle, event.status
)
def on_hci_encryption_change_v2_event(self, event):
# Notify the client
if event.status == hci.HCI_SUCCESS:
self.emit(
'connection_encryption_change',
event.connection_handle,
event.encryption_enabled,
event.encryption_key_size,
) )
else: else:
self.emit( self.emit(
+62 -46
View File
@@ -22,14 +22,15 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import dataclasses
import logging import logging
import os import os
import json import json
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type, Any
from typing_extensions import Self from typing_extensions import Self
from bumble.colors import color from bumble.colors import color
from bumble.hci import Address from bumble import hci
if TYPE_CHECKING: if TYPE_CHECKING:
from bumble.device import Device from bumble.device import Device
@@ -42,16 +43,17 @@ logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@dataclasses.dataclass
class PairingKeys: class PairingKeys:
@dataclasses.dataclass
class Key: class Key:
def __init__(self, value, authenticated=False, ediv=None, rand=None): value: bytes
self.value = value authenticated: bool = False
self.authenticated = authenticated ediv: Optional[int] = None
self.ediv = ediv rand: Optional[bytes] = None
self.rand = rand
@classmethod @classmethod
def from_dict(cls, key_dict): def from_dict(cls, key_dict: dict[str, Any]) -> PairingKeys.Key:
value = bytes.fromhex(key_dict['value']) value = bytes.fromhex(key_dict['value'])
authenticated = key_dict.get('authenticated', False) authenticated = key_dict.get('authenticated', False)
ediv = key_dict.get('ediv') ediv = key_dict.get('ediv')
@@ -61,7 +63,7 @@ class PairingKeys:
return cls(value, authenticated, ediv, rand) return cls(value, authenticated, ediv, rand)
def to_dict(self): def to_dict(self) -> dict[str, Any]:
key_dict = {'value': self.value.hex(), 'authenticated': self.authenticated} key_dict = {'value': self.value.hex(), 'authenticated': self.authenticated}
if self.ediv is not None: if self.ediv is not None:
key_dict['ediv'] = self.ediv key_dict['ediv'] = self.ediv
@@ -70,39 +72,42 @@ class PairingKeys:
return key_dict return key_dict
def __init__(self): address_type: Optional[hci.AddressType] = None
self.address_type = None ltk: Optional[Key] = None
self.ltk = None ltk_central: Optional[Key] = None
self.ltk_central = None ltk_peripheral: Optional[Key] = None
self.ltk_peripheral = None irk: Optional[Key] = None
self.irk = None csrk: Optional[Key] = None
self.csrk = None link_key: Optional[Key] = None # Classic
self.link_key = None # Classic link_key_type: Optional[int] = None # Classic
@staticmethod @classmethod
def key_from_dict(keys_dict, key_name): def key_from_dict(cls, keys_dict: dict[str, Any], key_name: str) -> Optional[Key]:
key_dict = keys_dict.get(key_name) key_dict = keys_dict.get(key_name)
if key_dict is None: if key_dict is None:
return None return None
return PairingKeys.Key.from_dict(key_dict) return PairingKeys.Key.from_dict(key_dict)
@staticmethod @classmethod
def from_dict(keys_dict): def from_dict(cls, keys_dict: dict[str, Any]) -> PairingKeys:
keys = PairingKeys() return PairingKeys(
address_type=(
hci.AddressType(t)
if (t := keys_dict.get('address_type')) is not None
else None
),
ltk=PairingKeys.key_from_dict(keys_dict, 'ltk'),
ltk_central=PairingKeys.key_from_dict(keys_dict, 'ltk_central'),
ltk_peripheral=PairingKeys.key_from_dict(keys_dict, 'ltk_peripheral'),
irk=PairingKeys.key_from_dict(keys_dict, 'irk'),
csrk=PairingKeys.key_from_dict(keys_dict, 'csrk'),
link_key=PairingKeys.key_from_dict(keys_dict, 'link_key'),
link_key_type=keys_dict.get('link_key_type'),
)
keys.address_type = keys_dict.get('address_type') def to_dict(self) -> dict[str, Any]:
keys.ltk = PairingKeys.key_from_dict(keys_dict, 'ltk') keys: dict[str, Any] = {}
keys.ltk_central = PairingKeys.key_from_dict(keys_dict, 'ltk_central')
keys.ltk_peripheral = PairingKeys.key_from_dict(keys_dict, 'ltk_peripheral')
keys.irk = PairingKeys.key_from_dict(keys_dict, 'irk')
keys.csrk = PairingKeys.key_from_dict(keys_dict, 'csrk')
keys.link_key = PairingKeys.key_from_dict(keys_dict, 'link_key')
return keys
def to_dict(self):
keys = {}
if self.address_type is not None: if self.address_type is not None:
keys['address_type'] = self.address_type keys['address_type'] = self.address_type
@@ -125,9 +130,12 @@ class PairingKeys:
if self.link_key is not None: if self.link_key is not None:
keys['link_key'] = self.link_key.to_dict() keys['link_key'] = self.link_key.to_dict()
if self.link_key_type is not None:
keys['link_key_type'] = self.link_key_type
return keys return keys
def print(self, prefix=''): def print(self, prefix: str = '') -> None:
keys_dict = self.to_dict() keys_dict = self.to_dict()
for container_property, value in keys_dict.items(): for container_property, value in keys_dict.items():
if isinstance(value, dict): if isinstance(value, dict):
@@ -156,20 +164,28 @@ class KeyStore:
all_keys = await self.get_all() all_keys = await self.get_all()
await asyncio.gather(*(self.delete(name) for (name, _) in all_keys)) await asyncio.gather(*(self.delete(name) for (name, _) in all_keys))
async def get_resolving_keys(self): async def get_resolving_keys(self) -> list[tuple[bytes, hci.Address]]:
all_keys = await self.get_all() all_keys = await self.get_all()
resolving_keys = [] resolving_keys = []
for name, keys in all_keys: for name, keys in all_keys:
if keys.irk is not None: if keys.irk is not None:
if keys.address_type is None: resolving_keys.append(
address_type = Address.RANDOM_DEVICE_ADDRESS (
else: keys.irk.value,
address_type = keys.address_type hci.Address(
resolving_keys.append((keys.irk.value, Address(name, address_type))) name,
(
keys.address_type
if keys.address_type is not None
else hci.Address.RANDOM_DEVICE_ADDRESS
),
),
)
)
return resolving_keys return resolving_keys
async def print(self, prefix=''): async def print(self, prefix: str = '') -> None:
entries = await self.get_all() entries = await self.get_all()
separator = '' separator = ''
for name, keys in entries: for name, keys in entries:
@@ -177,8 +193,8 @@ class KeyStore:
keys.print(prefix=prefix + ' ') keys.print(prefix=prefix + ' ')
separator = '\n' separator = '\n'
@staticmethod @classmethod
def create_for_device(device: Device) -> KeyStore: def create_for_device(cls, device: Device) -> KeyStore:
if device.config.keystore is None: if device.config.keystore is None:
return MemoryKeyStore() return MemoryKeyStore()
@@ -266,9 +282,9 @@ class JsonKeyStore(KeyStore):
filename = params[0] filename = params[0]
# Use a namespace based on the device address # Use a namespace based on the device address
if device.public_address not in (Address.ANY, Address.ANY_RANDOM): if device.public_address not in (hci.Address.ANY, hci.Address.ANY_RANDOM):
namespace = str(device.public_address) namespace = str(device.public_address)
elif device.random_address != Address.ANY_RANDOM: elif device.random_address != hci.Address.ANY_RANDOM:
namespace = str(device.random_address) namespace = str(device.random_address)
else: else:
namespace = JsonKeyStore.DEFAULT_NAMESPACE namespace = JsonKeyStore.DEFAULT_NAMESPACE
+19 -9
View File
@@ -744,6 +744,9 @@ class ClassicChannel(utils.EventEmitter):
WAIT_FINAL_RSP = 0x16 WAIT_FINAL_RSP = 0x16
WAIT_CONTROL_IND = 0x17 WAIT_CONTROL_IND = 0x17
EVENT_OPEN = "open"
EVENT_CLOSE = "close"
connection_result: Optional[asyncio.Future[None]] connection_result: Optional[asyncio.Future[None]]
disconnection_result: Optional[asyncio.Future[None]] disconnection_result: Optional[asyncio.Future[None]]
response: Optional[asyncio.Future[bytes]] response: Optional[asyncio.Future[bytes]]
@@ -847,7 +850,7 @@ class ClassicChannel(utils.EventEmitter):
def abort(self) -> None: def abort(self) -> None:
if self.state == self.State.OPEN: if self.state == self.State.OPEN:
self._change_state(self.State.CLOSED) self._change_state(self.State.CLOSED)
self.emit('close') self.emit(self.EVENT_CLOSE)
def send_configure_request(self) -> None: def send_configure_request(self) -> None:
options = L2CAP_Control_Frame.encode_configuration_options( options = L2CAP_Control_Frame.encode_configuration_options(
@@ -940,7 +943,7 @@ class ClassicChannel(utils.EventEmitter):
if self.connection_result: if self.connection_result:
self.connection_result.set_result(None) self.connection_result.set_result(None)
self.connection_result = None self.connection_result = None
self.emit('open') self.emit(self.EVENT_OPEN)
elif self.state == self.State.WAIT_CONFIG_REQ_RSP: elif self.state == self.State.WAIT_CONFIG_REQ_RSP:
self._change_state(self.State.WAIT_CONFIG_RSP) self._change_state(self.State.WAIT_CONFIG_RSP)
@@ -956,7 +959,7 @@ class ClassicChannel(utils.EventEmitter):
if self.connection_result: if self.connection_result:
self.connection_result.set_result(None) self.connection_result.set_result(None)
self.connection_result = None self.connection_result = None
self.emit('open') self.emit(self.EVENT_OPEN)
else: else:
logger.warning(color('invalid state', 'red')) logger.warning(color('invalid state', 'red'))
elif ( elif (
@@ -991,7 +994,7 @@ class ClassicChannel(utils.EventEmitter):
) )
) )
self._change_state(self.State.CLOSED) self._change_state(self.State.CLOSED)
self.emit('close') self.emit(self.EVENT_CLOSE)
self.manager.on_channel_closed(self) self.manager.on_channel_closed(self)
else: else:
logger.warning(color('invalid state', 'red')) logger.warning(color('invalid state', 'red'))
@@ -1012,7 +1015,7 @@ class ClassicChannel(utils.EventEmitter):
if self.disconnection_result: if self.disconnection_result:
self.disconnection_result.set_result(None) self.disconnection_result.set_result(None)
self.disconnection_result = None self.disconnection_result = None
self.emit('close') self.emit(self.EVENT_CLOSE)
self.manager.on_channel_closed(self) self.manager.on_channel_closed(self)
def __str__(self) -> str: def __str__(self) -> str:
@@ -1047,6 +1050,9 @@ class LeCreditBasedChannel(utils.EventEmitter):
connection: Connection connection: Connection
sink: Optional[Callable[[bytes], Any]] sink: Optional[Callable[[bytes], Any]]
EVENT_OPEN = "open"
EVENT_CLOSE = "close"
def __init__( def __init__(
self, self,
manager: ChannelManager, manager: ChannelManager,
@@ -1098,9 +1104,9 @@ class LeCreditBasedChannel(utils.EventEmitter):
self.state = new_state self.state = new_state
if new_state == self.State.CONNECTED: if new_state == self.State.CONNECTED:
self.emit('open') self.emit(self.EVENT_OPEN)
elif new_state == self.State.DISCONNECTED: elif new_state == self.State.DISCONNECTED:
self.emit('close') self.emit(self.EVENT_CLOSE)
def send_pdu(self, pdu: Union[SupportsBytes, bytes]) -> None: def send_pdu(self, pdu: Union[SupportsBytes, bytes]) -> None:
self.manager.send_pdu(self.connection, self.destination_cid, pdu) self.manager.send_pdu(self.connection, self.destination_cid, pdu)
@@ -1381,6 +1387,8 @@ class LeCreditBasedChannel(utils.EventEmitter):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class ClassicChannelServer(utils.EventEmitter): class ClassicChannelServer(utils.EventEmitter):
EVENT_CONNECTION = "connection"
def __init__( def __init__(
self, self,
manager: ChannelManager, manager: ChannelManager,
@@ -1395,7 +1403,7 @@ class ClassicChannelServer(utils.EventEmitter):
self.mtu = mtu self.mtu = mtu
def on_connection(self, channel: ClassicChannel) -> None: def on_connection(self, channel: ClassicChannel) -> None:
self.emit('connection', channel) self.emit(self.EVENT_CONNECTION, channel)
if self.handler: if self.handler:
self.handler(channel) self.handler(channel)
@@ -1406,6 +1414,8 @@ class ClassicChannelServer(utils.EventEmitter):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class LeCreditBasedChannelServer(utils.EventEmitter): class LeCreditBasedChannelServer(utils.EventEmitter):
EVENT_CONNECTION = "connection"
def __init__( def __init__(
self, self,
manager: ChannelManager, manager: ChannelManager,
@@ -1424,7 +1434,7 @@ class LeCreditBasedChannelServer(utils.EventEmitter):
self.mps = mps self.mps = mps
def on_connection(self, channel: LeCreditBasedChannel) -> None: def on_connection(self, channel: LeCreditBasedChannel) -> None:
self.emit('connection', channel) self.emit(self.EVENT_CONNECTION, channel)
if self.handler: if self.handler:
self.handler(channel) self.handler(channel)
+11 -11
View File
@@ -296,12 +296,12 @@ class HostService(HostServicer):
def on_disconnection(_: None) -> None: def on_disconnection(_: None) -> None:
disconnection_future.set_result(None) disconnection_future.set_result(None)
connection.on('disconnection', on_disconnection) connection.on(connection.EVENT_DISCONNECTION, on_disconnection)
try: try:
await disconnection_future await disconnection_future
self.log.debug("Disconnected") self.log.debug("Disconnected")
finally: finally:
connection.remove_listener('disconnection', on_disconnection) # type: ignore connection.remove_listener(connection.EVENT_DISCONNECTION, on_disconnection) # type: ignore
return empty_pb2.Empty() return empty_pb2.Empty()
@@ -383,7 +383,7 @@ class HostService(HostServicer):
): ):
connections.put_nowait(connection) connections.put_nowait(connection)
self.device.on('connection', on_connection) self.device.on(self.device.EVENT_CONNECTION, on_connection)
try: try:
# Advertise until RPC is canceled # Advertise until RPC is canceled
@@ -501,7 +501,7 @@ class HostService(HostServicer):
): ):
connections.put_nowait(connection) connections.put_nowait(connection)
self.device.on('connection', on_connection) self.device.on(self.device.EVENT_CONNECTION, on_connection)
try: try:
while True: while True:
@@ -531,7 +531,7 @@ class HostService(HostServicer):
await asyncio.sleep(1) await asyncio.sleep(1)
finally: finally:
if request.connectable: if request.connectable:
self.device.remove_listener('connection', on_connection) # type: ignore self.device.remove_listener(self.device.EVENT_CONNECTION, on_connection) # type: ignore
try: try:
self.log.debug('Stop advertising') self.log.debug('Stop advertising')
@@ -557,7 +557,7 @@ class HostService(HostServicer):
scanning_phys = [int(Phy.LE_1M), int(Phy.LE_CODED)] scanning_phys = [int(Phy.LE_1M), int(Phy.LE_CODED)]
scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue() scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue()
handler = self.device.on('advertisement', scan_queue.put_nowait) handler = self.device.on(self.device.EVENT_ADVERTISEMENT, scan_queue.put_nowait)
await self.device.start_scanning( await self.device.start_scanning(
legacy=request.legacy, legacy=request.legacy,
active=not request.passive, active=not request.passive,
@@ -602,7 +602,7 @@ class HostService(HostServicer):
yield sr yield sr
finally: finally:
self.device.remove_listener('advertisement', handler) # type: ignore self.device.remove_listener(self.device.EVENT_ADVERTISEMENT, handler) # type: ignore
try: try:
self.log.debug('Stop scanning') self.log.debug('Stop scanning')
await bumble.utils.cancel_on_event( await bumble.utils.cancel_on_event(
@@ -621,10 +621,10 @@ class HostService(HostServicer):
Optional[Tuple[Address, int, AdvertisingData, int]] Optional[Tuple[Address, int, AdvertisingData, int]]
] = asyncio.Queue() ] = asyncio.Queue()
complete_handler = self.device.on( complete_handler = self.device.on(
'inquiry_complete', lambda: inquiry_queue.put_nowait(None) self.device.EVENT_INQUIRY_COMPLETE, lambda: inquiry_queue.put_nowait(None)
) )
result_handler = self.device.on( # type: ignore result_handler = self.device.on( # type: ignore
'inquiry_result', self.device.EVENT_INQUIRY_RESULT,
lambda address, class_of_device, eir_data, rssi: inquiry_queue.put_nowait( # type: ignore lambda address, class_of_device, eir_data, rssi: inquiry_queue.put_nowait( # type: ignore
(address, class_of_device, eir_data, rssi) # type: ignore (address, class_of_device, eir_data, rssi) # type: ignore
), ),
@@ -643,8 +643,8 @@ class HostService(HostServicer):
) )
finally: finally:
self.device.remove_listener('inquiry_complete', complete_handler) # type: ignore self.device.remove_listener(self.device.EVENT_INQUIRY_COMPLETE, complete_handler) # type: ignore
self.device.remove_listener('inquiry_result', result_handler) # type: ignore self.device.remove_listener(self.device.EVENT_INQUIRY_RESULT, result_handler) # type: ignore
try: try:
self.log.debug('Stop inquiry') self.log.debug('Stop inquiry')
await bumble.utils.cancel_on_event( await bumble.utils.cancel_on_event(
+2 -2
View File
@@ -83,7 +83,7 @@ class L2CAPService(L2CAPServicer):
close_future.set_result(None) close_future.set_result(None)
l2cap_channel.sink = on_channel_sdu l2cap_channel.sink = on_channel_sdu
l2cap_channel.on('close', on_close) l2cap_channel.on(l2cap_channel.EVENT_CLOSE, on_close)
return ChannelContext(close_future, sdu_queue) return ChannelContext(close_future, sdu_queue)
@@ -151,7 +151,7 @@ class L2CAPService(L2CAPServicer):
spec=spec, handler=on_l2cap_channel spec=spec, handler=on_l2cap_channel
) )
else: else:
l2cap_server.on('connection', on_l2cap_channel) l2cap_server.on(l2cap_server.EVENT_CONNECTION, on_l2cap_channel)
try: try:
self.log.debug('Waiting for a channel connection.') self.log.debug('Waiting for a channel connection.')
+72 -56
View File
@@ -15,6 +15,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
from collections.abc import Awaitable
import grpc import grpc
import logging import logging
@@ -24,6 +25,7 @@ from bumble import hci
from bumble.core import ( from bumble.core import (
PhysicalTransport, PhysicalTransport,
ProtocolError, ProtocolError,
InvalidArgumentError,
) )
import bumble.utils import bumble.utils
from bumble.device import Connection as BumbleConnection, Device from bumble.device import Connection as BumbleConnection, Device
@@ -188,35 +190,6 @@ class PairingDelegate(BasePairingDelegate):
self.service.event_queue.put_nowait(event) self.service.event_queue.put_nowait(event)
BR_LEVEL_REACHED: Dict[SecurityLevel, Callable[[BumbleConnection], bool]] = {
LEVEL0: lambda connection: True,
LEVEL1: lambda connection: connection.encryption == 0 or connection.authenticated,
LEVEL2: lambda connection: connection.encryption != 0 and connection.authenticated,
LEVEL3: lambda connection: connection.encryption != 0
and connection.authenticated
and connection.link_key_type
in (
hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192_TYPE,
hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
),
LEVEL4: lambda connection: connection.encryption
== hci.HCI_Encryption_Change_Event.AES_CCM
and connection.authenticated
and connection.link_key_type
== hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
}
LE_LEVEL_REACHED: Dict[LESecurityLevel, Callable[[BumbleConnection], bool]] = {
LE_LEVEL1: lambda connection: True,
LE_LEVEL2: lambda connection: connection.encryption != 0,
LE_LEVEL3: lambda connection: connection.encryption != 0
and connection.authenticated,
LE_LEVEL4: lambda connection: connection.encryption != 0
and connection.authenticated
and connection.sc,
}
class SecurityService(SecurityServicer): class SecurityService(SecurityServicer):
def __init__(self, device: Device, config: Config) -> None: def __init__(self, device: Device, config: Config) -> None:
self.log = utils.BumbleServerLoggerAdapter( self.log = utils.BumbleServerLoggerAdapter(
@@ -248,6 +221,59 @@ class SecurityService(SecurityServicer):
self.device.pairing_config_factory = pairing_config_factory self.device.pairing_config_factory = pairing_config_factory
async def _classic_level_reached(
self, level: SecurityLevel, connection: BumbleConnection
) -> bool:
if level == LEVEL0:
return True
if level == LEVEL1:
return connection.encryption == 0 or connection.authenticated
if level == LEVEL2:
return connection.encryption != 0 and connection.authenticated
link_key_type: Optional[int] = None
if (keystore := connection.device.keystore) and (
keys := await keystore.get(str(connection.peer_address))
):
link_key_type = keys.link_key_type
self.log.debug("link_key_type: %d", link_key_type)
if level == LEVEL3:
return (
connection.encryption != 0
and connection.authenticated
and link_key_type
in (
hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192_TYPE,
hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
)
)
if level == LEVEL4:
return (
connection.encryption == hci.HCI_Encryption_Change_Event.AES_CCM
and connection.authenticated
and link_key_type
== hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE
)
raise InvalidArgumentError(f"Unexpected level {level}")
def _le_level_reached(
self, level: LESecurityLevel, connection: BumbleConnection
) -> bool:
if level == LE_LEVEL1:
return True
if level == LE_LEVEL2:
return connection.encryption != 0
if level == LE_LEVEL3:
return connection.encryption != 0 and connection.authenticated
if level == LE_LEVEL4:
return (
connection.encryption != 0
and connection.authenticated
and connection.sc
)
raise InvalidArgumentError(f"Unexpected level {level}")
@utils.rpc @utils.rpc
async def OnPairing( async def OnPairing(
self, request: AsyncIterator[PairingEventAnswer], context: grpc.ServicerContext self, request: AsyncIterator[PairingEventAnswer], context: grpc.ServicerContext
@@ -290,7 +316,7 @@ class SecurityService(SecurityServicer):
] == oneof ] == oneof
# security level already reached # security level already reached
if self.reached_security_level(connection, level): if await self.reached_security_level(connection, level):
return SecureResponse(success=empty_pb2.Empty()) return SecureResponse(success=empty_pb2.Empty())
# trigger pairing if needed # trigger pairing if needed
@@ -302,15 +328,15 @@ class SecurityService(SecurityServicer):
with contextlib.closing(bumble.utils.EventWatcher()) as watcher: with contextlib.closing(bumble.utils.EventWatcher()) as watcher:
@watcher.on(connection, 'pairing') @watcher.on(connection, connection.EVENT_PAIRING)
def on_pairing(*_: Any) -> None: def on_pairing(*_: Any) -> None:
security_result.set_result('success') security_result.set_result('success')
@watcher.on(connection, 'pairing_failure') @watcher.on(connection, connection.EVENT_PAIRING_FAILURE)
def on_pairing_failure(*_: Any) -> None: def on_pairing_failure(*_: Any) -> None:
security_result.set_result('pairing_failure') security_result.set_result('pairing_failure')
@watcher.on(connection, 'disconnection') @watcher.on(connection, connection.EVENT_DISCONNECTION)
def on_disconnection(*_: Any) -> None: def on_disconnection(*_: Any) -> None:
security_result.set_result('connection_died') security_result.set_result('connection_died')
@@ -361,7 +387,7 @@ class SecurityService(SecurityServicer):
return SecureResponse(encryption_failure=empty_pb2.Empty()) return SecureResponse(encryption_failure=empty_pb2.Empty())
# security level has been reached ? # security level has been reached ?
if self.reached_security_level(connection, level): if await self.reached_security_level(connection, level):
return SecureResponse(success=empty_pb2.Empty()) return SecureResponse(success=empty_pb2.Empty())
return SecureResponse(not_reached=empty_pb2.Empty()) return SecureResponse(not_reached=empty_pb2.Empty())
@@ -388,13 +414,10 @@ class SecurityService(SecurityServicer):
pair_task: Optional[asyncio.Future[None]] = None pair_task: Optional[asyncio.Future[None]] = None
async def authenticate() -> None: async def authenticate() -> None:
assert connection
if (encryption := connection.encryption) != 0: if (encryption := connection.encryption) != 0:
self.log.debug('Disable encryption...') self.log.debug('Disable encryption...')
try: with contextlib.suppress(Exception):
await connection.encrypt(enable=False) await connection.encrypt(enable=False)
except:
pass
self.log.debug('Disable encryption: done') self.log.debug('Disable encryption: done')
self.log.debug('Authenticate...') self.log.debug('Authenticate...')
@@ -413,15 +436,13 @@ class SecurityService(SecurityServicer):
return wrapper return wrapper
def try_set_success(*_: Any) -> None: async def try_set_success(*_: Any) -> None:
assert connection if await self.reached_security_level(connection, level):
if self.reached_security_level(connection, level):
self.log.debug('Wait for security: done') self.log.debug('Wait for security: done')
wait_for_security.set_result('success') wait_for_security.set_result('success')
def on_encryption_change(*_: Any) -> None: async def on_encryption_change(*_: Any) -> None:
assert connection if await self.reached_security_level(connection, level):
if self.reached_security_level(connection, level):
self.log.debug('Wait for security: done') self.log.debug('Wait for security: done')
wait_for_security.set_result('success') wait_for_security.set_result('success')
elif ( elif (
@@ -436,7 +457,7 @@ class SecurityService(SecurityServicer):
if self.need_pairing(connection, level): if self.need_pairing(connection, level):
pair_task = asyncio.create_task(connection.pair()) pair_task = asyncio.create_task(connection.pair())
listeners: Dict[str, Callable[..., None]] = { listeners: Dict[str, Callable[..., Union[None, Awaitable[None]]]] = {
'disconnection': set_failure('connection_died'), 'disconnection': set_failure('connection_died'),
'pairing_failure': set_failure('pairing_failure'), 'pairing_failure': set_failure('pairing_failure'),
'connection_authentication_failure': set_failure('authentication_failure'), 'connection_authentication_failure': set_failure('authentication_failure'),
@@ -455,7 +476,7 @@ class SecurityService(SecurityServicer):
watcher.on(connection, event, listener) watcher.on(connection, event, listener)
# security level already reached # security level already reached
if self.reached_security_level(connection, level): if await self.reached_security_level(connection, level):
return WaitSecurityResponse(success=empty_pb2.Empty()) return WaitSecurityResponse(success=empty_pb2.Empty())
self.log.debug('Wait for security...') self.log.debug('Wait for security...')
@@ -465,24 +486,20 @@ class SecurityService(SecurityServicer):
# wait for `authenticate` to finish if any # wait for `authenticate` to finish if any
if authenticate_task is not None: if authenticate_task is not None:
self.log.debug('Wait for authentication...') self.log.debug('Wait for authentication...')
try: with contextlib.suppress(Exception):
await authenticate_task # type: ignore await authenticate_task # type: ignore
except:
pass
self.log.debug('Authenticated') self.log.debug('Authenticated')
# wait for `pair` to finish if any # wait for `pair` to finish if any
if pair_task is not None: if pair_task is not None:
self.log.debug('Wait for authentication...') self.log.debug('Wait for authentication...')
try: with contextlib.suppress(Exception):
await pair_task # type: ignore await pair_task # type: ignore
except:
pass
self.log.debug('paired') self.log.debug('paired')
return WaitSecurityResponse(**kwargs) return WaitSecurityResponse(**kwargs)
def reached_security_level( async def reached_security_level(
self, connection: BumbleConnection, level: Union[SecurityLevel, LESecurityLevel] self, connection: BumbleConnection, level: Union[SecurityLevel, LESecurityLevel]
) -> bool: ) -> bool:
self.log.debug( self.log.debug(
@@ -492,15 +509,14 @@ class SecurityService(SecurityServicer):
'encryption': connection.encryption, 'encryption': connection.encryption,
'authenticated': connection.authenticated, 'authenticated': connection.authenticated,
'sc': connection.sc, 'sc': connection.sc,
'link_key_type': connection.link_key_type,
} }
) )
) )
if isinstance(level, LESecurityLevel): if isinstance(level, LESecurityLevel):
return LE_LEVEL_REACHED[level](connection) return self._le_level_reached(level, connection)
return BR_LEVEL_REACHED[level](connection) return await self._classic_level_reached(level, connection)
def need_pairing(self, connection: BumbleConnection, level: int) -> bool: def need_pairing(self, connection: BumbleConnection, level: int) -> bool:
if connection.transport == PhysicalTransport.LE: if connection.transport == PhysicalTransport.LE:
+3 -1
View File
@@ -250,6 +250,8 @@ class AncsClient(utils.EventEmitter):
_expected_response_tuples: int _expected_response_tuples: int
_response_accumulator: bytes _response_accumulator: bytes
EVENT_NOTIFICATION = "notification"
def __init__(self, ancs_proxy: AncsProxy) -> None: def __init__(self, ancs_proxy: AncsProxy) -> None:
super().__init__() super().__init__()
self._ancs_proxy = ancs_proxy self._ancs_proxy = ancs_proxy
@@ -284,7 +286,7 @@ class AncsClient(utils.EventEmitter):
def _on_notification(self, notification: Notification) -> None: def _on_notification(self, notification: Notification) -> None:
logger.debug(f"ANCS NOTIFICATION: {notification}") logger.debug(f"ANCS NOTIFICATION: {notification}")
self.emit("notification", notification) self.emit(self.EVENT_NOTIFICATION, notification)
def _on_data(self, data: bytes) -> None: def _on_data(self, data: bytes) -> None:
logger.debug(f"ANCS DATA: {data.hex()}") logger.debug(f"ANCS DATA: {data.hex()}")
+10 -4
View File
@@ -276,6 +276,8 @@ class AseStateMachine(gatt.Characteristic):
DISABLING = 0x05 DISABLING = 0x05
RELEASING = 0x06 RELEASING = 0x06
EVENT_STATE_CHANGE = "state_change"
cis_link: Optional[device.CisLink] = None cis_link: Optional[device.CisLink] = None
# Additional parameters in CODEC_CONFIGURED State # Additional parameters in CODEC_CONFIGURED State
@@ -329,8 +331,12 @@ class AseStateMachine(gatt.Characteristic):
value=gatt.CharacteristicValue(read=self.on_read), value=gatt.CharacteristicValue(read=self.on_read),
) )
self.service.device.on('cis_request', self.on_cis_request) self.service.device.on(
self.service.device.on('cis_establishment', self.on_cis_establishment) self.service.device.EVENT_CIS_REQUEST, self.on_cis_request
)
self.service.device.on(
self.service.device.EVENT_CIS_ESTABLISHMENT, self.on_cis_establishment
)
def on_cis_request( def on_cis_request(
self, self,
@@ -356,7 +362,7 @@ class AseStateMachine(gatt.Characteristic):
and cis_link.cis_id == self.cis_id and cis_link.cis_id == self.cis_id
and self.state == self.State.ENABLING and self.state == self.State.ENABLING
): ):
cis_link.on('disconnection', self.on_cis_disconnection) cis_link.on(cis_link.EVENT_DISCONNECTION, self.on_cis_disconnection)
async def post_cis_established(): async def post_cis_established():
await cis_link.setup_data_path(direction=self.role) await cis_link.setup_data_path(direction=self.role)
@@ -525,7 +531,7 @@ class AseStateMachine(gatt.Characteristic):
def state(self, new_state: State) -> None: def state(self, new_state: State) -> None:
logger.debug(f'{self} state change -> {colors.color(new_state.name, "cyan")}') logger.debug(f'{self} state change -> {colors.color(new_state.name, "cyan")}')
self._state = new_state self._state = new_state
self.emit('state_change') self.emit(self.EVENT_STATE_CHANGE)
@property @property
def value(self): def value(self):
+9 -4
View File
@@ -88,6 +88,11 @@ class AudioStatus(utils.OpenIntEnum):
class AshaService(gatt.TemplateService): class AshaService(gatt.TemplateService):
UUID = gatt.GATT_ASHA_SERVICE UUID = gatt.GATT_ASHA_SERVICE
EVENT_STARTED = "started"
EVENT_STOPPED = "stopped"
EVENT_DISCONNECTED = "disconnected"
EVENT_VOLUME_CHANGED = "volume_changed"
audio_sink: Optional[Callable[[bytes], Any]] audio_sink: Optional[Callable[[bytes], Any]]
active_codec: Optional[Codec] = None active_codec: Optional[Codec] = None
audio_type: Optional[AudioType] = None audio_type: Optional[AudioType] = None
@@ -211,14 +216,14 @@ class AshaService(gatt.TemplateService):
f'volume={self.volume}, ' f'volume={self.volume}, '
f'other_state={self.other_state}' f'other_state={self.other_state}'
) )
self.emit('started') self.emit(self.EVENT_STARTED)
elif opcode == OpCode.STOP: elif opcode == OpCode.STOP:
_logger.debug('### STOP') _logger.debug('### STOP')
self.active_codec = None self.active_codec = None
self.audio_type = None self.audio_type = None
self.volume = None self.volume = None
self.other_state = None self.other_state = None
self.emit('stopped') self.emit(self.EVENT_STOPPED)
elif opcode == OpCode.STATUS: elif opcode == OpCode.STATUS:
_logger.debug('### STATUS: %s', PeripheralStatus(value[1]).name) _logger.debug('### STATUS: %s', PeripheralStatus(value[1]).name)
@@ -231,7 +236,7 @@ class AshaService(gatt.TemplateService):
self.audio_type = None self.audio_type = None
self.volume = None self.volume = None
self.other_state = None self.other_state = None
self.emit('disconnected') self.emit(self.EVENT_DISCONNECTED)
connection.once('disconnection', on_disconnection) connection.once('disconnection', on_disconnection)
@@ -245,7 +250,7 @@ class AshaService(gatt.TemplateService):
def _on_volume_write(self, connection: Optional[Connection], value: bytes) -> None: def _on_volume_write(self, connection: Optional[Connection], value: bytes) -> None:
_logger.debug(f'--- VOLUME Write:{value[0]}') _logger.debug(f'--- VOLUME Write:{value[0]}')
self.volume = value[0] self.volume = value[0]
self.emit('volume_changed') self.emit(self.EVENT_VOLUME_CHANGED)
# Register an L2CAP CoC server # Register an L2CAP CoC server
def _on_connection(self, channel: l2cap.LeCreditBasedChannel) -> None: def _on_connection(self, channel: l2cap.LeCreditBasedChannel) -> None:
+3 -3
View File
@@ -266,13 +266,13 @@ class HearingAccessService(gatt.TemplateService):
# associate the lowest index as the current active preset at startup # associate the lowest index as the current active preset at startup
self.active_preset_index = sorted(self.preset_records.keys())[0] self.active_preset_index = sorted(self.preset_records.keys())[0]
@device.on('connection') # type: ignore @device.on(device.EVENT_CONNECTION)
def on_connection(connection: Connection) -> None: def on_connection(connection: Connection) -> None:
@connection.on('disconnection') # type: ignore @connection.on(connection.EVENT_DISCONNECTION)
def on_disconnection(_reason) -> None: def on_disconnection(_reason) -> None:
self.currently_connected_clients.remove(connection) self.currently_connected_clients.remove(connection)
@connection.on('pairing') # type: ignore @connection.on(connection.EVENT_PAIRING)
def on_pairing(*_: Any) -> None: def on_pairing(*_: Any) -> None:
self.on_incoming_paired_connection(connection) self.on_incoming_paired_connection(connection)
+11 -5
View File
@@ -338,6 +338,12 @@ class MediaControlServiceProxy(
'content_control_id': gatt.GATT_CONTENT_CONTROL_ID_CHARACTERISTIC, 'content_control_id': gatt.GATT_CONTENT_CONTROL_ID_CHARACTERISTIC,
} }
EVENT_MEDIA_STATE = "media_state"
EVENT_TRACK_CHANGED = "track_changed"
EVENT_TRACK_TITLE = "track_title"
EVENT_TRACK_DURATION = "track_duration"
EVENT_TRACK_POSITION = "track_position"
media_player_name: Optional[gatt_client.CharacteristicProxy[bytes]] = None media_player_name: Optional[gatt_client.CharacteristicProxy[bytes]] = None
media_player_icon_object_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None media_player_icon_object_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None
media_player_icon_url: Optional[gatt_client.CharacteristicProxy[bytes]] = None media_player_icon_url: Optional[gatt_client.CharacteristicProxy[bytes]] = None
@@ -432,20 +438,20 @@ class MediaControlServiceProxy(
self.media_control_point_notifications.put_nowait(data) self.media_control_point_notifications.put_nowait(data)
def _on_media_state(self, data: bytes) -> None: def _on_media_state(self, data: bytes) -> None:
self.emit('media_state', MediaState(data[0])) self.emit(self.EVENT_MEDIA_STATE, MediaState(data[0]))
def _on_track_changed(self, data: bytes) -> None: def _on_track_changed(self, data: bytes) -> None:
del data del data
self.emit('track_changed') self.emit(self.EVENT_TRACK_CHANGED)
def _on_track_title(self, data: bytes) -> None: def _on_track_title(self, data: bytes) -> None:
self.emit('track_title', data.decode("utf-8")) self.emit(self.EVENT_TRACK_TITLE, data.decode("utf-8"))
def _on_track_duration(self, data: bytes) -> None: def _on_track_duration(self, data: bytes) -> None:
self.emit('track_duration', struct.unpack_from('<i', data)[0]) self.emit(self.EVENT_TRACK_DURATION, struct.unpack_from('<i', data)[0])
def _on_track_position(self, data: bytes) -> None: def _on_track_position(self, data: bytes) -> None:
self.emit('track_position', struct.unpack_from('<i', data)[0]) self.emit(self.EVENT_TRACK_POSITION, struct.unpack_from('<i', data)[0])
class GenericMediaControlServiceProxy(MediaControlServiceProxy): class GenericMediaControlServiceProxy(MediaControlServiceProxy):
+3 -1
View File
@@ -91,6 +91,8 @@ class VolumeState:
class VolumeControlService(gatt.TemplateService): class VolumeControlService(gatt.TemplateService):
UUID = gatt.GATT_VOLUME_CONTROL_SERVICE UUID = gatt.GATT_VOLUME_CONTROL_SERVICE
EVENT_VOLUME_STATE_CHANGE = "volume_state_change"
volume_state: gatt.Characteristic[bytes] volume_state: gatt.Characteristic[bytes]
volume_control_point: gatt.Characteristic[bytes] volume_control_point: gatt.Characteristic[bytes]
volume_flags: gatt.Characteristic[bytes] volume_flags: gatt.Characteristic[bytes]
@@ -166,7 +168,7 @@ class VolumeControlService(gatt.TemplateService):
'disconnection', 'disconnection',
connection.device.notify_subscribers(attribute=self.volume_state), connection.device.notify_subscribers(attribute=self.volume_state),
) )
self.emit('volume_state_change') self.emit(self.EVENT_VOLUME_STATE_CHANGE)
def _on_relative_volume_down(self) -> bool: def _on_relative_volume_down(self) -> bool:
old_volume = self.volume_setting old_volume = self.volume_setting
+17 -8
View File
@@ -442,6 +442,9 @@ class RFCOMM_MCC_MSC:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class DLC(utils.EventEmitter): class DLC(utils.EventEmitter):
EVENT_OPEN = "open"
EVENT_CLOSE = "close"
class State(enum.IntEnum): class State(enum.IntEnum):
INIT = 0x00 INIT = 0x00
CONNECTING = 0x01 CONNECTING = 0x01
@@ -529,7 +532,7 @@ class DLC(utils.EventEmitter):
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc)) self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
self.change_state(DLC.State.CONNECTED) self.change_state(DLC.State.CONNECTED)
self.emit('open') self.emit(self.EVENT_OPEN)
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None: def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
if self.state == DLC.State.CONNECTING: if self.state == DLC.State.CONNECTING:
@@ -550,7 +553,7 @@ class DLC(utils.EventEmitter):
self.disconnection_result.set_result(None) self.disconnection_result.set_result(None)
self.disconnection_result = None self.disconnection_result = None
self.multiplexer.on_dlc_disconnection(self) self.multiplexer.on_dlc_disconnection(self)
self.emit('close') self.emit(self.EVENT_CLOSE)
else: else:
logger.warning( logger.warning(
color( color(
@@ -733,7 +736,7 @@ class DLC(utils.EventEmitter):
self.disconnection_result.cancel() self.disconnection_result.cancel()
self.disconnection_result = None self.disconnection_result = None
self.change_state(DLC.State.RESET) self.change_state(DLC.State.RESET)
self.emit('close') self.emit(self.EVENT_CLOSE)
def __str__(self) -> str: def __str__(self) -> str:
return ( return (
@@ -763,6 +766,8 @@ class Multiplexer(utils.EventEmitter):
DISCONNECTED = 0x05 DISCONNECTED = 0x05
RESET = 0x06 RESET = 0x06
EVENT_DLC = "dlc"
connection_result: Optional[asyncio.Future] connection_result: Optional[asyncio.Future]
disconnection_result: Optional[asyncio.Future] disconnection_result: Optional[asyncio.Future]
open_result: Optional[asyncio.Future] open_result: Optional[asyncio.Future]
@@ -785,7 +790,7 @@ class Multiplexer(utils.EventEmitter):
# Become a sink for the L2CAP channel # Become a sink for the L2CAP channel
l2cap_channel.sink = self.on_pdu l2cap_channel.sink = self.on_pdu
l2cap_channel.on('close', self.on_l2cap_channel_close) l2cap_channel.on(l2cap_channel.EVENT_CLOSE, self.on_l2cap_channel_close)
def change_state(self, new_state: State) -> None: def change_state(self, new_state: State) -> None:
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}') logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
@@ -901,7 +906,7 @@ class Multiplexer(utils.EventEmitter):
self.dlcs[pn.dlci] = dlc self.dlcs[pn.dlci] = dlc
# Re-emit the handshake completion event # Re-emit the handshake completion event
dlc.on('open', lambda: self.emit('dlc', dlc)) dlc.on(dlc.EVENT_OPEN, lambda: self.emit(self.EVENT_DLC, dlc))
# Respond to complete the handshake # Respond to complete the handshake
dlc.accept() dlc.accept()
@@ -1076,6 +1081,8 @@ class Client:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Server(utils.EventEmitter): class Server(utils.EventEmitter):
EVENT_START = "start"
def __init__( def __init__(
self, device: Device, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU self, device: Device, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
) -> None: ) -> None:
@@ -1122,7 +1129,9 @@ class Server(utils.EventEmitter):
def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None: def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug(f'+++ new L2CAP connection: {l2cap_channel}') logger.debug(f'+++ new L2CAP connection: {l2cap_channel}')
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel)) l2cap_channel.on(
l2cap_channel.EVENT_OPEN, lambda: self.on_l2cap_channel_open(l2cap_channel)
)
def on_l2cap_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None: def on_l2cap_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}') logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
@@ -1130,10 +1139,10 @@ class Server(utils.EventEmitter):
# Create a new multiplexer for the channel # Create a new multiplexer for the channel
multiplexer = Multiplexer(l2cap_channel, Multiplexer.Role.RESPONDER) multiplexer = Multiplexer(l2cap_channel, Multiplexer.Role.RESPONDER)
multiplexer.acceptor = self.accept_dlc multiplexer.acceptor = self.accept_dlc
multiplexer.on('dlc', self.on_dlc) multiplexer.on(multiplexer.EVENT_DLC, self.on_dlc)
# Notify # Notify
self.emit('start', multiplexer) self.emit(self.EVENT_START, multiplexer)
def accept_dlc(self, channel_number: int) -> Optional[Tuple[int, int]]: def accept_dlc(self, channel_number: int) -> Optional[Tuple[int, int]]:
return self.dlc_configs.get(channel_number) return self.dlc_configs.get(channel_number)
+14 -8
View File
@@ -724,12 +724,13 @@ class Session:
self.is_responder = not self.is_initiator self.is_responder = not self.is_initiator
# Listen for connection events # Listen for connection events
connection.on('disconnection', self.on_disconnection) connection.on(connection.EVENT_DISCONNECTION, self.on_disconnection)
connection.on( connection.on(
'connection_encryption_change', self.on_connection_encryption_change connection.EVENT_CONNECTION_ENCRYPTION_CHANGE,
self.on_connection_encryption_change,
) )
connection.on( connection.on(
'connection_encryption_key_refresh', connection.EVENT_CONNECTION_ENCRYPTION_KEY_REFRESH,
self.on_connection_encryption_key_refresh, self.on_connection_encryption_key_refresh,
) )
@@ -1310,12 +1311,15 @@ class Session:
) )
def on_disconnection(self, _: int) -> None: def on_disconnection(self, _: int) -> None:
self.connection.remove_listener('disconnection', self.on_disconnection)
self.connection.remove_listener( self.connection.remove_listener(
'connection_encryption_change', self.on_connection_encryption_change self.connection.EVENT_DISCONNECTION, self.on_disconnection
) )
self.connection.remove_listener( self.connection.remove_listener(
'connection_encryption_key_refresh', self.connection.EVENT_CONNECTION_ENCRYPTION_CHANGE,
self.on_connection_encryption_change,
)
self.connection.remove_listener(
self.connection.EVENT_CONNECTION_ENCRYPTION_KEY_REFRESH,
self.on_connection_encryption_key_refresh, self.on_connection_encryption_key_refresh,
) )
self.manager.on_session_end(self) self.manager.on_session_end(self)
@@ -1376,8 +1380,10 @@ class Session:
ediv=self.ltk_ediv, ediv=self.ltk_ediv,
rand=self.ltk_rand, rand=self.ltk_rand,
) )
if not self.peer_ltk:
logger.error("peer_ltk is None")
peer_ltk_key = PairingKeys.Key( peer_ltk_key = PairingKeys.Key(
value=self.peer_ltk, value=self.peer_ltk or b'',
authenticated=authenticated, authenticated=authenticated,
ediv=self.peer_ediv, ediv=self.peer_ediv,
rand=self.peer_rand, rand=self.peer_rand,
@@ -1962,7 +1968,7 @@ class Manager(utils.EventEmitter):
def on_smp_security_request_command( def on_smp_security_request_command(
self, connection: Connection, request: SMP_Security_Request_Command self, connection: Connection, request: SMP_Security_Request_Command
) -> None: ) -> None:
connection.emit('security_request', request.auth_req) connection.emit(connection.EVENT_SECURITY_REQUEST, request.auth_req)
def on_smp_pdu(self, connection: Connection, pdu: bytes) -> None: def on_smp_pdu(self, connection: Connection, pdu: bytes) -> None:
# Parse the L2CAP payload into an SMP Command object # Parse the L2CAP payload into an SMP Command object
-6
View File
@@ -33,12 +33,6 @@ from bumble.avdtp import (
from bumble.a2dp import ( from bumble.a2dp import (
make_audio_sink_service_sdp_records, make_audio_sink_service_sdp_records,
A2DP_SBC_CODEC_TYPE, A2DP_SBC_CODEC_TYPE,
SBC_MONO_CHANNEL_MODE,
SBC_DUAL_CHANNEL_MODE,
SBC_SNR_ALLOCATION_METHOD,
SBC_LOUDNESS_ALLOCATION_METHOD,
SBC_STEREO_CHANNEL_MODE,
SBC_JOINT_STEREO_CHANNEL_MODE,
SbcMediaCodecInformation, SbcMediaCodecInformation,
) )
+151 -51
View File
@@ -16,28 +16,43 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import asyncio import asyncio
import sys
import os
import logging import logging
import os
import sys
from dataclasses import dataclass
from bumble.colors import color import ffmpeg
from bumble.device import Device
from bumble.transport import open_transport_or_link from bumble.a2dp import (
from bumble.core import PhysicalTransport A2DP_MPEG_2_4_AAC_CODEC_TYPE,
A2DP_SBC_CODEC_TYPE,
AacMediaCodecInformation,
AacPacketSource,
SbcMediaCodecInformation,
SbcPacketSource,
make_audio_source_service_sdp_records,
)
from bumble.avdtp import ( from bumble.avdtp import (
find_avdtp_service_with_connection,
AVDTP_AUDIO_MEDIA_TYPE, AVDTP_AUDIO_MEDIA_TYPE,
Listener,
MediaCodecCapabilities, MediaCodecCapabilities,
MediaPacketPump, MediaPacketPump,
Protocol, Protocol,
Listener, find_avdtp_service_with_connection,
)
from bumble.a2dp import (
make_audio_source_service_sdp_records,
A2DP_SBC_CODEC_TYPE,
SbcMediaCodecInformation,
SbcPacketSource,
) )
from bumble.colors import color
from bumble.core import PhysicalTransport
from bumble.device import Device
from bumble.transport import open_transport_or_link
from typing import Dict, Union
@dataclass
class CodecCapabilities:
name: str
sample_rate: str
number_of_channels: str
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -51,67 +66,147 @@ def sdp_records():
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def codec_capabilities(): def on_avdtp_connection(
# NOTE: this shouldn't be hardcoded, but should be inferred from the input file read_function, protocol, codec_capabilities: MediaCodecCapabilities
# instead ):
return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_SBC_CODEC_TYPE,
media_codec_information=SbcMediaCodecInformation(
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_44100,
channel_mode=SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
block_length=SbcMediaCodecInformation.BlockLength.BL_16,
subbands=SbcMediaCodecInformation.Subbands.S_8,
allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS,
minimum_bitpool_value=2,
maximum_bitpool_value=53,
),
)
# -----------------------------------------------------------------------------
def on_avdtp_connection(read_function, protocol):
packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu) packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu)
packet_pump = MediaPacketPump(packet_source.packets) packet_pump = MediaPacketPump(packet_source.packets)
protocol.add_source(codec_capabilities(), packet_pump) protocol.add_source(codec_capabilities, packet_pump)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def stream_packets(read_function, protocol): async def stream_packets(
read_function, protocol, codec_capabilities: MediaCodecCapabilities
):
# Discover all endpoints on the remote device # Discover all endpoints on the remote device
endpoints = await protocol.discover_remote_endpoints() endpoints = await protocol.discover_remote_endpoints()
for endpoint in endpoints: for endpoint in endpoints:
print('@@@', endpoint) print('@@@', endpoint)
# Select a sink # Select a sink
assert codec_capabilities.media_codec_type in [
A2DP_SBC_CODEC_TYPE,
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
]
sink = protocol.find_remote_sink_by_codec( sink = protocol.find_remote_sink_by_codec(
AVDTP_AUDIO_MEDIA_TYPE, A2DP_SBC_CODEC_TYPE AVDTP_AUDIO_MEDIA_TYPE, codec_capabilities.media_codec_type
) )
if sink is None: if sink is None:
print(color('!!! no SBC sink found', 'red')) print(color('!!! no Sink found', 'red'))
return return
print(f'### Selected sink: {sink.seid}') print(f'### Selected sink: {sink.seid}')
# Stream the packets # Stream the packets
packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu) packet_sources = {
packet_pump = MediaPacketPump(packet_source.packets) A2DP_SBC_CODEC_TYPE: SbcPacketSource(
source = protocol.add_source(codec_capabilities(), packet_pump) read_function, protocol.l2cap_channel.peer_mtu
),
A2DP_MPEG_2_4_AAC_CODEC_TYPE: AacPacketSource(
read_function, protocol.l2cap_channel.peer_mtu
),
}
packet_source = packet_sources[codec_capabilities.media_codec_type]
packet_pump = MediaPacketPump(packet_source.packets) # type: ignore
source = protocol.add_source(codec_capabilities, packet_pump)
stream = await protocol.create_stream(source, sink) stream = await protocol.create_stream(source, sink)
await stream.start() await stream.start()
await asyncio.sleep(5) await asyncio.sleep(60)
await stream.stop()
await asyncio.sleep(5)
await stream.start()
await asyncio.sleep(5)
await stream.stop() await stream.stop()
await stream.close() await stream.close()
# -----------------------------------------------------------------------------
def fetch_codec_informations(filepath) -> MediaCodecCapabilities:
probe = ffmpeg.probe(filepath)
assert 'streams' in probe
streams = probe['streams']
if not streams or len(streams) > 1:
print(streams)
print(color('!!! file not supported', 'red'))
exit()
audio_stream = streams[0]
media_codec_type = None
media_codec_information: Union[
SbcMediaCodecInformation, AacMediaCodecInformation, None
] = None
assert 'codec_name' in audio_stream
codec_name: str = audio_stream['codec_name']
if codec_name == "sbc":
media_codec_type = A2DP_SBC_CODEC_TYPE
sbc_sampling_frequency: Dict[
str, SbcMediaCodecInformation.SamplingFrequency
] = {
'16000': SbcMediaCodecInformation.SamplingFrequency.SF_16000,
'32000': SbcMediaCodecInformation.SamplingFrequency.SF_32000,
'44100': SbcMediaCodecInformation.SamplingFrequency.SF_44100,
'48000': SbcMediaCodecInformation.SamplingFrequency.SF_48000,
}
sbc_channel_mode: Dict[int, SbcMediaCodecInformation.ChannelMode] = {
1: SbcMediaCodecInformation.ChannelMode.MONO,
2: SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
}
assert 'sample_rate' in audio_stream
assert 'channels' in audio_stream
media_codec_information = SbcMediaCodecInformation(
sampling_frequency=sbc_sampling_frequency[audio_stream['sample_rate']],
channel_mode=sbc_channel_mode[audio_stream['channels']],
block_length=SbcMediaCodecInformation.BlockLength.BL_16,
subbands=SbcMediaCodecInformation.Subbands.S_8,
allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS,
minimum_bitpool_value=2,
maximum_bitpool_value=53,
)
elif codec_name == "aac":
media_codec_type = A2DP_MPEG_2_4_AAC_CODEC_TYPE
object_type: Dict[str, AacMediaCodecInformation.ObjectType] = {
'LC': AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC,
'LTP': AacMediaCodecInformation.ObjectType.MPEG_4_AAC_LTP,
'SSR': AacMediaCodecInformation.ObjectType.MPEG_4_AAC_SCALABLE,
}
aac_sampling_frequency: Dict[
str, AacMediaCodecInformation.SamplingFrequency
] = {
'44100': AacMediaCodecInformation.SamplingFrequency.SF_44100,
'48000': AacMediaCodecInformation.SamplingFrequency.SF_48000,
}
aac_channel_mode: Dict[int, AacMediaCodecInformation.Channels] = {
1: AacMediaCodecInformation.Channels.MONO,
2: AacMediaCodecInformation.Channels.STEREO,
}
assert 'profile' in audio_stream
assert 'sample_rate' in audio_stream
assert 'channels' in audio_stream
media_codec_information = AacMediaCodecInformation(
object_type=object_type[audio_stream['profile']],
sampling_frequency=aac_sampling_frequency[audio_stream['sample_rate']],
channels=aac_channel_mode[audio_stream['channels']],
vbr=1,
bitrate=128000,
)
else:
print(color('!!! codec not supported, only aac & sbc are supported', 'red'))
exit()
assert media_codec_type is not None
assert media_codec_information is not None
return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=media_codec_type,
media_codec_information=media_codec_information,
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main() -> None: async def main() -> None:
if len(sys.argv) < 4: if len(sys.argv) < 4:
print( print(
'Usage: run_a2dp_source.py <device-config> <transport-spec> <sbc-file> ' 'Usage: run_a2dp_source.py <device-config> <transport-spec> <audio-file> '
'[<bluetooth-address>]' '[<bluetooth-address>]'
) )
print( print(
@@ -135,11 +230,13 @@ async def main() -> None:
# Start # Start
await device.power_on() await device.power_on()
with open(sys.argv[3], 'rb') as sbc_file: with open(sys.argv[3], 'rb') as audio_file:
# NOTE: this should be using asyncio file reading, but blocking reads are # NOTE: this should be using asyncio file reading, but blocking reads are
# good enough for testing # good enough for testing
async def read(byte_count): async def read(byte_count):
return sbc_file.read(byte_count) return audio_file.read(byte_count)
codec_capabilities = fetch_codec_informations(sys.argv[3])
if len(sys.argv) > 4: if len(sys.argv) > 4:
# Connect to a peer # Connect to a peer
@@ -170,12 +267,15 @@ async def main() -> None:
protocol = await Protocol.connect(connection, avdtp_version) protocol = await Protocol.connect(connection, avdtp_version)
# Start streaming # Start streaming
await stream_packets(read, protocol) await stream_packets(read, protocol, codec_capabilities)
else: else:
# Create a listener to wait for AVDTP connections # Create a listener to wait for AVDTP connections
listener = Listener.for_device(device=device, version=(1, 2)) listener = Listener.for_device(device=device, version=(1, 2))
listener.on( listener.on(
'connection', lambda protocol: on_avdtp_connection(read, protocol) 'connection',
lambda protocol: on_avdtp_connection(
read, protocol, codec_capabilities
),
) )
# Become connectable and wait for a connection # Become connectable and wait for a connection
+1 -6
View File
@@ -1,12 +1,7 @@
*.iml *.iml
.gradle .gradle
/local.properties /local.properties
/.idea/caches .idea/
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store .DS_Store
/build /build
/captures /captures
@@ -5,6 +5,7 @@
<!-- Request legacy Bluetooth permissions on older devices. --> <!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" /> <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
@@ -6,13 +6,14 @@ import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY import android.bluetooth.le.AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY
import android.os.Build import androidx.annotation.RequiresApi
import java.util.logging.Logger import java.util.logging.Logger
private val Log = Logger.getLogger("btbench.advertiser") private val Log = Logger.getLogger("btbench.advertiser")
class Advertiser(private val bluetoothAdapter: BluetoothAdapter) : AdvertiseCallback() { class Advertiser(private val bluetoothAdapter: BluetoothAdapter) : AdvertiseCallback() {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
@RequiresApi(34)
fun start() { fun start() {
val advertiseSettingsBuilder = AdvertiseSettings.Builder() val advertiseSettingsBuilder = AdvertiseSettings.Builder()
.setAdvertiseMode(ADVERTISE_MODE_LOW_LATENCY) .setAdvertiseMode(ADVERTISE_MODE_LOW_LATENCY)
@@ -26,6 +26,8 @@ import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothStatusCodes import android.bluetooth.BluetoothStatusCodes
import android.content.Context import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import java.io.IOException import java.io.IOException
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
@@ -174,6 +176,7 @@ class GattServer(
} }
} }
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
override fun run() { override fun run() {
viewModel.running = true viewModel.running = true
@@ -16,14 +16,9 @@ package com.github.google.bumble.btbench
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY
import android.os.Build import android.os.Build
import java.io.IOException import androidx.annotation.RequiresApi
import java.util.logging.Logger import java.util.logging.Logger
import kotlin.concurrent.thread
private val Log = Logger.getLogger("btbench.l2cap-server") private val Log = Logger.getLogger("btbench.l2cap-server")
@@ -34,6 +29,7 @@ class L2capServer(
) : Mode { ) : Mode {
private var socketServer: SocketServer? = null private var socketServer: SocketServer? = null
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
override fun run() { override fun run() {
// Advertise so that the peer can find us and connect. // Advertise so that the peer can find us and connect.
+10 -3
View File
@@ -13,11 +13,11 @@ dependencies = [
"aiohttp ~= 3.8; platform_system!='Emscripten'", "aiohttp ~= 3.8; platform_system!='Emscripten'",
"appdirs >= 1.4; platform_system!='Emscripten'", "appdirs >= 1.4; platform_system!='Emscripten'",
"click >= 8.1.3; platform_system!='Emscripten'", "click >= 8.1.3; platform_system!='Emscripten'",
"cryptography >= 39; platform_system!='Emscripten'", "cryptography >= 44.0.3; platform_system!='Emscripten'",
# Pyodide bundles a version of cryptography that is built for wasm, which may not match the # Pyodide bundles a version of cryptography that is built for wasm, which may not match the
# versions available on PyPI. Relax the version requirement since it's better than being # versions available on PyPI. Relax the version requirement since it's better than being
# completely unable to import the package in case of version mismatch. # completely unable to import the package in case of version mismatch.
"cryptography >= 39.0; platform_system=='Emscripten'", "cryptography >= 44.0.3; platform_system=='Emscripten'",
"grpcio >= 1.62.1; platform_system!='Emscripten'", "grpcio >= 1.62.1; platform_system!='Emscripten'",
"humanize >= 4.6.0; platform_system!='Emscripten'", "humanize >= 4.6.0; platform_system!='Emscripten'",
"libusb1 >= 2.0.1; platform_system!='Emscripten'", "libusb1 >= 2.0.1; platform_system!='Emscripten'",
@@ -26,7 +26,7 @@ dependencies = [
"prompt_toolkit >= 3.0.16; platform_system!='Emscripten'", "prompt_toolkit >= 3.0.16; platform_system!='Emscripten'",
"prettytable >= 3.6.0; platform_system!='Emscripten'", "prettytable >= 3.6.0; platform_system!='Emscripten'",
"protobuf >= 3.12.4; platform_system!='Emscripten'", "protobuf >= 3.12.4; platform_system!='Emscripten'",
"pyee >= 8.2.2", "pyee >= 13.0.0",
"pyserial-asyncio >= 0.5; platform_system!='Emscripten'", "pyserial-asyncio >= 0.5; platform_system!='Emscripten'",
"pyserial >= 3.5; platform_system!='Emscripten'", "pyserial >= 3.5; platform_system!='Emscripten'",
"pyusb >= 1.2; platform_system!='Emscripten'", "pyusb >= 1.2; platform_system!='Emscripten'",
@@ -55,6 +55,9 @@ development = [
"types-invoke >= 1.7.3", "types-invoke >= 1.7.3",
"types-protobuf >= 4.21.0", "types-protobuf >= 4.21.0",
] ]
examples = [
"ffmpeg-python == 0.2.0",
]
avatar = [ avatar = [
"pandora-avatar == 0.0.10", "pandora-avatar == 0.0.10",
"rootcanal == 1.11.1 ; python_version>='3.10'", "rootcanal == 1.11.1 ; python_version>='3.10'",
@@ -184,6 +187,10 @@ ignore_missing_imports = true
module = "construct.*" module = "construct.*"
ignore_missing_imports = true ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "ffmpeg.*"
ignore_missing_imports = true
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = "grpc.*" module = "grpc.*"
ignore_missing_imports = true ignore_missing_imports = true