From 04d5bf3afc5af05be500435b063005eb16542b08 Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Tue, 28 Nov 2023 16:25:08 +0800 Subject: [PATCH 1/5] Typing GATT Client and Device Peer --- bumble/device.py | 87 +++++++++++++++++++++++++++++++------------ bumble/gatt_client.py | 38 +++++++++++++------ 2 files changed, 91 insertions(+), 34 deletions(-) diff --git a/bumble/device.py b/bumble/device.py index 1d40a35..37f1610 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -23,6 +23,7 @@ import asyncio import logging from contextlib import asynccontextmanager, AsyncExitStack from dataclasses import dataclass +from collections.abc import Iterable from typing import ( Any, Callable, @@ -32,6 +33,7 @@ from typing import ( Optional, Tuple, Type, + TypeVar, Set, Union, cast, @@ -440,8 +442,11 @@ class LePhyOptions: # ----------------------------------------------------------------------------- +_PROXY_CLASS = TypeVar('_PROXY_CLASS', bound=gatt_client.ProfileServiceProxy) + + class Peer: - def __init__(self, connection): + def __init__(self, connection: Connection) -> None: self.connection = connection # Create a GATT client for the connection @@ -449,77 +454,113 @@ class Peer: connection.gatt_client = self.gatt_client @property - def services(self): + def services(self) -> List[gatt_client.ServiceProxy]: return self.gatt_client.services - async def request_mtu(self, mtu): + async def request_mtu(self, mtu: int) -> int: mtu = await self.gatt_client.request_mtu(mtu) self.connection.emit('connection_att_mtu_update') return mtu - async def discover_service(self, uuid): + async def discover_service( + self, uuid: Union[core.UUID, str] + ) -> List[gatt_client.ServiceProxy]: return await self.gatt_client.discover_service(uuid) - async def discover_services(self, uuids=()): + async def discover_services( + self, uuids: Iterable[core.UUID] = () + ) -> List[gatt_client.ServiceProxy]: return await self.gatt_client.discover_services(uuids) - async def discover_included_services(self, service): + async def discover_included_services( + self, service: gatt_client.ServiceProxy + ) -> List[gatt_client.ServiceProxy]: return await self.gatt_client.discover_included_services(service) - async def discover_characteristics(self, uuids=(), service=None): + async def discover_characteristics( + self, + uuids: Iterable[Union[core.UUID, str]] = (), + service: Optional[gatt_client.ServiceProxy] = None, + ) -> List[gatt_client.CharacteristicProxy]: return await self.gatt_client.discover_characteristics( uuids=uuids, service=service ) async def discover_descriptors( - self, characteristic=None, start_handle=None, end_handle=None + self, + characteristic: Optional[gatt_client.CharacteristicProxy] = None, + start_handle: Optional[int] = None, + end_handle: Optional[int] = None, ): return await self.gatt_client.discover_descriptors( characteristic, start_handle, end_handle ) - async def discover_attributes(self): + async def discover_attributes(self) -> List[gatt_client.AttributeProxy]: return await self.gatt_client.discover_attributes() - async def subscribe(self, characteristic, subscriber=None, prefer_notify=True): + async def subscribe( + self, + characteristic: gatt_client.CharacteristicProxy, + subscriber: Optional[Callable[[bytes], Any]] = None, + prefer_notify: bool = True, + ) -> None: return await self.gatt_client.subscribe( characteristic, subscriber, prefer_notify ) - async def unsubscribe(self, characteristic, subscriber=None): + async def unsubscribe( + self, + characteristic: gatt_client.CharacteristicProxy, + subscriber: Optional[Callable[[bytes], Any]] = None, + ) -> None: return await self.gatt_client.unsubscribe(characteristic, subscriber) - async def read_value(self, attribute): + async def read_value( + self, attribute: Union[int, gatt_client.AttributeProxy] + ) -> bytes: return await self.gatt_client.read_value(attribute) - async def write_value(self, attribute, value, with_response=False): + async def write_value( + self, + attribute: Union[int, gatt_client.AttributeProxy], + value: bytes, + with_response: bool = False, + ) -> None: return await self.gatt_client.write_value(attribute, value, with_response) - async def read_characteristics_by_uuid(self, uuid, service=None): + async def read_characteristics_by_uuid( + self, uuid: core.UUID, service: Optional[gatt_client.ServiceProxy] = None + ) -> List[bytes]: return await self.gatt_client.read_characteristics_by_uuid(uuid, service) - def get_services_by_uuid(self, uuid): + def get_services_by_uuid(self, uuid: core.UUID) -> List[gatt_client.ServiceProxy]: return self.gatt_client.get_services_by_uuid(uuid) - def get_characteristics_by_uuid(self, uuid, service=None): + def get_characteristics_by_uuid( + self, uuid: core.UUID, service: Optional[gatt_client.ServiceProxy] = None + ) -> List[gatt_client.CharacteristicProxy]: return self.gatt_client.get_characteristics_by_uuid(uuid, service) - def create_service_proxy(self, proxy_class): - return proxy_class.from_client(self.gatt_client) + def create_service_proxy(self, proxy_class: Type[_PROXY_CLASS]) -> _PROXY_CLASS: + return cast(_PROXY_CLASS, proxy_class.from_client(self.gatt_client)) - async def discover_service_and_create_proxy(self, proxy_class): + async def discover_service_and_create_proxy( + self, proxy_class: Type[_PROXY_CLASS] + ) -> Optional[_PROXY_CLASS]: # Discover the first matching service and its characteristics services = await self.discover_service(proxy_class.SERVICE_CLASS.UUID) if services: service = services[0] await service.discover_characteristics() return self.create_service_proxy(proxy_class) + return None - async def sustain(self, timeout=None): + async def sustain(self, timeout: Optional[float] = None) -> None: await self.connection.sustain(timeout) # [Classic only] - async def request_name(self): + async def request_name(self) -> str: return await self.connection.request_remote_name() async def __aenter__(self): @@ -532,7 +573,7 @@ class Peer: async def __aexit__(self, exc_type, exc_value, traceback): pass - def __str__(self): + def __str__(self) -> str: return f'{self.connection.peer_address} as {self.connection.role_name}' @@ -732,7 +773,7 @@ class Connection(CompositeEventEmitter): async def switch_role(self, role: int) -> None: return await self.device.switch_role(self, role) - async def sustain(self, timeout=None): + async def sustain(self, timeout: Optional[float] = None) -> None: """Idles the current task waiting for a disconnect or timeout""" abort = asyncio.get_running_loop().create_future() diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py index e3b8bb2..0c69b12 100644 --- a/bumble/gatt_client.py +++ b/bumble/gatt_client.py @@ -38,6 +38,7 @@ from typing import ( Any, Iterable, Type, + Set, TYPE_CHECKING, ) @@ -128,7 +129,7 @@ class ServiceProxy(AttributeProxy): included_services: List[ServiceProxy] @staticmethod - def from_client(service_class, client, service_uuid): + def from_client(service_class, client: Client, service_uuid: UUID): # The service and its characteristics are considered to have already been # discovered services = client.get_services_by_uuid(service_uuid) @@ -246,8 +247,12 @@ class ProfileServiceProxy: class Client: services: List[ServiceProxy] cached_values: Dict[int, Tuple[datetime, bytes]] - notification_subscribers: Dict[int, Callable[[bytes], Any]] - indication_subscribers: Dict[int, Callable[[bytes], Any]] + notification_subscribers: Dict[ + int, Set[Union[CharacteristicProxy, Callable[[bytes], Any]]] + ] + indication_subscribers: Dict[ + int, Set[Union[CharacteristicProxy, Callable[[bytes], Any]]] + ] pending_response: Optional[asyncio.futures.Future[ATT_PDU]] pending_request: Optional[ATT_PDU] @@ -682,8 +687,8 @@ class Client: async def discover_descriptors( self, characteristic: Optional[CharacteristicProxy] = None, - start_handle=None, - end_handle=None, + start_handle: Optional[int] = None, + end_handle: Optional[int] = None, ) -> List[DescriptorProxy]: ''' See Vol 3, Part G - 4.7.1 Discover All Characteristic Descriptors @@ -789,7 +794,12 @@ class Client: return attributes - async def subscribe(self, characteristic, subscriber=None, prefer_notify=True): + async def subscribe( + self, + characteristic: CharacteristicProxy, + subscriber: Optional[Callable[[bytes], Any]] = None, + prefer_notify: bool = True, + ) -> None: # If we haven't already discovered the descriptors for this characteristic, # do it now if not characteristic.descriptors_discovered: @@ -833,7 +843,11 @@ class Client: await self.write_value(cccd, struct.pack(' None: # If we haven't already discovered the descriptors for this characteristic, # do it now if not characteristic.descriptors_discovered: @@ -853,7 +867,7 @@ class Client: self.notification_subscribers, self.indication_subscribers, ): - subscribers = subscriber_set.get(characteristic.handle, []) + subscribers = subscriber_set.get(characteristic.handle, set()) if subscriber in subscribers: subscribers.remove(subscriber) @@ -871,7 +885,7 @@ class Client: async def read_value( self, attribute: Union[int, AttributeProxy], no_long_read: bool = False - ) -> Any: + ) -> bytes: ''' See Vol 3, Part G - 4.8.1 Read Characteristic Value @@ -1067,7 +1081,7 @@ class Client: def on_att_handle_value_notification(self, notification): # Call all subscribers subscribers = self.notification_subscribers.get( - notification.attribute_handle, [] + notification.attribute_handle, set() ) if not subscribers: logger.warning('!!! received notification with no subscriber') @@ -1081,7 +1095,9 @@ class Client: def on_att_handle_value_indication(self, indication): # Call all subscribers - subscribers = self.indication_subscribers.get(indication.attribute_handle, []) + subscribers = self.indication_subscribers.get( + indication.attribute_handle, set() + ) if not subscribers: logger.warning('!!! received indication with no subscriber') From e85d067fb5cc49cd04e7762e50ca8e0addb4369e Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Tue, 28 Nov 2023 20:02:00 -0800 Subject: [PATCH 2/5] add a few uuids --- bumble/gatt.py | 49 +++++++++++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/bumble/gatt.py b/bumble/gatt.py index 37a54b3..da5934c 100644 --- a/bumble/gatt.py +++ b/bumble/gatt.py @@ -93,30 +93,35 @@ GATT_RECONNECTION_CONFIGURATION_SERVICE = UUID.from_16_bits(0x1829, 'Reconne GATT_INSULIN_DELIVERY_SERVICE = UUID.from_16_bits(0x183A, 'Insulin Delivery') GATT_BINARY_SENSOR_SERVICE = UUID.from_16_bits(0x183B, 'Binary Sensor') GATT_EMERGENCY_CONFIGURATION_SERVICE = UUID.from_16_bits(0x183C, 'Emergency Configuration') +GATT_AUTHORIZATION_CONTROL_SERVICE = UUID.from_16_bits(0x183D, 'Authorization Control') GATT_PHYSICAL_ACTIVITY_MONITOR_SERVICE = UUID.from_16_bits(0x183E, 'Physical Activity Monitor') +GATT_ELAPSED_TIME_SERVICE = UUID.from_16_bits(0x183F, 'Elapsed Time') +GATT_GENERIC_HEALTH_SENSOR_SERVICE = UUID.from_16_bits(0x1840, 'Generic Health Sensor') GATT_AUDIO_INPUT_CONTROL_SERVICE = UUID.from_16_bits(0x1843, 'Audio Input Control') GATT_VOLUME_CONTROL_SERVICE = UUID.from_16_bits(0x1844, 'Volume Control') GATT_VOLUME_OFFSET_CONTROL_SERVICE = UUID.from_16_bits(0x1845, 'Volume Offset Control') -GATT_COORDINATED_SET_IDENTIFICATION_SERVICE = UUID.from_16_bits(0x1846, 'Coordinated Set Identification Service') +GATT_COORDINATED_SET_IDENTIFICATION_SERVICE = UUID.from_16_bits(0x1846, 'Coordinated Set Identification') GATT_DEVICE_TIME_SERVICE = UUID.from_16_bits(0x1847, 'Device Time') -# LE Audio Services -GATT_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1848, 'Media Control Service') -GATT_GENERIC_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1849, 'Generic Media Control Service') -GATT_CONSTANT_TONE_EXTENSION_SERVICE = UUID.from_16_bits(0x184A, 'Constant Tone Extension Service') -GATT_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184B, 'Telephone Bearer Service') -GATT_GENERIC_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184C, 'Generic Telephone Bearer Service') -GATT_MICROPHONE_CONTROL_SERVICE = UUID.from_16_bits(0x184D, 'Microphone Control Service') -GATT_AUDIO_STREAM_CONTROL_SERVICE = UUID.from_16_bits(0x184E, 'Audio Stream Control Service') -GATT_BROADCAST_AUDIO_SCAN_SERVICE = UUID.from_16_bits(0x184F, 'Broadcast Audio Scan Service') -GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE = UUID.from_16_bits(0x1850, 'Published Audio Capabilities Service') -GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1851, 'Basic Audio Announcement Service') -GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1852, 'Broadcast Audio Announcement Service') -GATT_COMMON_AUDIO_SERVICE = UUID.from_16_bits(0x1853, 'Common Audio Service') -GATT_HEARING_ACCESS_SERVICE = UUID.from_16_bits(0x1854, 'Hearing Access Service') -GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE = UUID.from_16_bits(0x1855, 'Telephony and Media Audio Service') -GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1856, 'Public Broadcast Announcement Service') +GATT_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1848, 'Media Control') +GATT_GENERIC_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1849, 'Generic Media Control') +GATT_CONSTANT_TONE_EXTENSION_SERVICE = UUID.from_16_bits(0x184A, 'Constant Tone Extension') +GATT_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184B, 'Telephone Bearer') +GATT_GENERIC_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184C, 'Generic Telephone Bearer') +GATT_MICROPHONE_CONTROL_SERVICE = UUID.from_16_bits(0x184D, 'Microphone Control') +GATT_AUDIO_STREAM_CONTROL_SERVICE = UUID.from_16_bits(0x184E, 'Audio Stream Control') +GATT_BROADCAST_AUDIO_SCAN_SERVICE = UUID.from_16_bits(0x184F, 'Broadcast Audio Scan') +GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE = UUID.from_16_bits(0x1850, 'Published Audio Capabilities') +GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1851, 'Basic Audio Announcement') +GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1852, 'Broadcast Audio Announcement') +GATT_COMMON_AUDIO_SERVICE = UUID.from_16_bits(0x1853, 'Common Audio') +GATT_HEARING_ACCESS_SERVICE = UUID.from_16_bits(0x1854, 'Hearing Access') +GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE = UUID.from_16_bits(0x1855, 'Telephony and Media Audio') +GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1856, 'Public Broadcast Announcement') +GATT_ELECTRONIC_SHELF_LABEL_SERVICE = UUID.from_16_bits(0X1857, 'Electronic Shelf Label') +GATT_GAMING_AUDIO_SERVICE = UUID.from_16_bits(0x1858, 'Gaming Audio') +GATT_MESH_PROXY_SOLICITATION_SERVICE = UUID.from_16_bits(0x1859, 'Mesh Audio Solicitation') -# Types +# Attribute Types GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2800, 'Primary Service') GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2801, 'Secondary Service') GATT_INCLUDE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2802, 'Include') @@ -139,6 +144,8 @@ GATT_ENVIRONMENTAL_SENSING_MEASUREMENT_DESCRIPTOR = UUID.from_16_bits(0x290C, GATT_ENVIRONMENTAL_SENSING_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290D, 'Environmental Sensing Trigger Setting') GATT_TIME_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290E, 'Time Trigger Setting') GATT_COMPLETE_BR_EDR_TRANSPORT_BLOCK_DATA_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Complete BR-EDR Transport Block Data') +GATT_OBSERVATION_SCHEDULE_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Observation Schedule') +GATT_VALID_RANGE_AND_ACCURACY_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Valid Range And Accuracy') # Device Information Service GATT_SYSTEM_ID_CHARACTERISTIC = UUID.from_16_bits(0x2A23, 'System ID') @@ -166,6 +173,9 @@ GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2A39, 'Heart # Battery Service GATT_BATTERY_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A19, 'Battery Level') +# Telephony And Media Audio Service (TMAS) +GATT_TMAP_ROLE_CHARACTERISTIC = UUID.from_16_bits(0x2B51, 'TMAP Role') + # Audio Input Control Service (AICS) GATT_AUDIO_INPUT_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B77, 'Audio Input State') GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC = UUID.from_16_bits(0x2B78, 'Gain Settings Attribute') @@ -274,6 +284,9 @@ GATT_BOOT_KEYBOARD_INPUT_REPORT_CHARACTERISTIC = UUID.from_16_bi GATT_CURRENT_TIME_CHARACTERISTIC = UUID.from_16_bits(0x2A2B, 'Current Time') GATT_BOOT_KEYBOARD_OUTPUT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A32, 'Boot Keyboard Output Report') GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bits(0x2AA6, 'Central Address Resolution') +GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2B29, 'Client Supported Features') +GATT_DATABASE_HASH_CHARACTERISTIC = UUID.from_16_bits(0x2B2A, 'Database Hash') +GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2B3A, 'Server Supported Features') # fmt: on # pylint: enable=line-too-long From 464a476f9fc1550e7f30c6f41f2d0f26b195edda Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Mon, 27 Nov 2023 23:10:19 +0800 Subject: [PATCH 3/5] Add CSIP --- .vscode/settings.json | 3 + bumble/profiles/csip.py | 147 ++++++++++++++++++++++++++++++++++++++++ tests/csip_test.py | 74 ++++++++++++++++++++ tests/test_utils.py | 3 + 4 files changed, 227 insertions(+) create mode 100644 bumble/profiles/csip.py create mode 100644 tests/csip_test.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 466158f..4011e64 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,6 +21,7 @@ "cccds", "cmac", "CONNECTIONLESS", + "csip", "csrcs", "datagram", "DATALINK", @@ -45,6 +46,7 @@ "NONCONN", "OXIMETER", "popleft", + "PRAND", "protobuf", "psms", "pyee", @@ -56,6 +58,7 @@ "SEID", "seids", "SERV", + "SIRK", "ssrc", "strerror", "subband", diff --git a/bumble/profiles/csip.py b/bumble/profiles/csip.py new file mode 100644 index 0000000..9657246 --- /dev/null +++ b/bumble/profiles/csip.py @@ -0,0 +1,147 @@ +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +from __future__ import annotations +import enum +import struct +from typing import Optional + +from bumble import gatt +from bumble import gatt_client + + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- +class SirkType(enum.IntEnum): + '''Coordinated Set Identification Service - 5.1 Set Identity Resolving Key.''' + + ENCRYPTED = 0x00 + PLAINTEXT = 0x01 + + +class MemberLock(enum.IntEnum): + '''Coordinated Set Identification Service - 5.3 Set Member Lock.''' + + UNLOCKED = 0x01 + LOCKED = 0x02 + + +# ----------------------------------------------------------------------------- +# Utils +# ----------------------------------------------------------------------------- +# TODO: Implement RSI Generator + + +# ----------------------------------------------------------------------------- +# Server +# ----------------------------------------------------------------------------- +class CoordinatedSetIdentificationService(gatt.TemplateService): + UUID = gatt.GATT_COORDINATED_SET_IDENTIFICATION_SERVICE + + set_identity_resolving_key_characteristic: gatt.Characteristic + coordinated_set_size_characteristic: Optional[gatt.Characteristic] = None + set_member_lock_characteristic: Optional[gatt.Characteristic] = None + set_member_rank_characteristic: Optional[gatt.Characteristic] = None + + def __init__( + self, + set_identity_resolving_key: bytes, + coordinated_set_size: Optional[int] = None, + set_member_lock: Optional[MemberLock] = None, + set_member_rank: Optional[int] = None, + ) -> None: + characteristics = [] + + self.set_identity_resolving_key_characteristic = gatt.Characteristic( + uuid=gatt.GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC, + properties=gatt.Characteristic.Properties.READ + | gatt.Characteristic.Properties.NOTIFY, + permissions=gatt.Characteristic.Permissions.READABLE, + # TODO: Implement encrypted SIRK reader. + value=struct.pack('B', SirkType.PLAINTEXT) + set_identity_resolving_key, + ) + characteristics.append(self.set_identity_resolving_key_characteristic) + + if coordinated_set_size is not None: + self.coordinated_set_size_characteristic = gatt.Characteristic( + uuid=gatt.GATT_COORDINATED_SET_SIZE_CHARACTERISTIC, + properties=gatt.Characteristic.Properties.READ + | gatt.Characteristic.Properties.NOTIFY, + permissions=gatt.Characteristic.Permissions.READABLE, + value=struct.pack('B', coordinated_set_size), + ) + characteristics.append(self.coordinated_set_size_characteristic) + + if set_member_lock is not None: + self.set_member_lock_characteristic = gatt.Characteristic( + uuid=gatt.GATT_SET_MEMBER_LOCK_CHARACTERISTIC, + properties=gatt.Characteristic.Properties.READ + | gatt.Characteristic.Properties.NOTIFY + | gatt.Characteristic.Properties.WRITE, + permissions=gatt.Characteristic.Permissions.READABLE + | gatt.Characteristic.Permissions.WRITEABLE, + value=struct.pack('B', set_member_lock), + ) + characteristics.append(self.set_member_lock_characteristic) + + if set_member_rank is not None: + self.set_member_rank_characteristic = gatt.Characteristic( + uuid=gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC, + properties=gatt.Characteristic.Properties.READ + | gatt.Characteristic.Properties.NOTIFY, + permissions=gatt.Characteristic.Permissions.READABLE, + value=struct.pack('B', set_member_rank), + ) + characteristics.append(self.set_member_rank_characteristic) + + super().__init__(characteristics) + + +# ----------------------------------------------------------------------------- +# Client +# ----------------------------------------------------------------------------- +class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy): + SERVICE_CLASS = CoordinatedSetIdentificationService + + set_identity_resolving_key: gatt_client.CharacteristicProxy + coordinated_set_size: Optional[gatt_client.CharacteristicProxy] = None + set_member_lock: Optional[gatt_client.CharacteristicProxy] = None + set_member_rank: Optional[gatt_client.CharacteristicProxy] = None + + def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None: + self.service_proxy = service_proxy + + self.set_identity_resolving_key = service_proxy.get_characteristics_by_uuid( + gatt.GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC + )[0] + + if characteristics := service_proxy.get_characteristics_by_uuid( + gatt.GATT_COORDINATED_SET_SIZE_CHARACTERISTIC + ): + self.coordinated_set_size = characteristics[0] + + if characteristics := service_proxy.get_characteristics_by_uuid( + gatt.GATT_SET_MEMBER_LOCK_CHARACTERISTIC + ): + self.set_member_lock = characteristics[0] + + if characteristics := service_proxy.get_characteristics_by_uuid( + gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC + ): + self.set_member_rank = characteristics[0] diff --git a/tests/csip_test.py b/tests/csip_test.py new file mode 100644 index 0000000..6f2c7fd --- /dev/null +++ b/tests/csip_test.py @@ -0,0 +1,74 @@ +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +import asyncio +import os +import pytest +import struct +import logging + +from bumble import device +from bumble.profiles import csip +from .test_utils import TwoDevices + +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- +logger = logging.getLogger(__name__) + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_csis(): + SIRK = bytes.fromhex('2f62c8ae41867d1bb619e788a2605faa') + + devices = TwoDevices() + devices[0].add_service( + csip.CoordinatedSetIdentificationService( + set_identity_resolving_key=SIRK, + coordinated_set_size=2, + set_member_lock=csip.MemberLock.UNLOCKED, + set_member_rank=0, + ) + ) + + await devices.setup_connection() + peer = device.Peer(devices.connections[1]) + csis_client = await peer.discover_service_and_create_proxy( + csip.CoordinatedSetIdentificationProxy + ) + + assert ( + await csis_client.set_identity_resolving_key.read_value() + == bytes([csip.SirkType.PLAINTEXT]) + SIRK + ) + assert await csis_client.coordinated_set_size.read_value() == struct.pack('B', 2) + assert await csis_client.set_member_lock.read_value() == struct.pack( + 'B', csip.MemberLock.UNLOCKED + ) + assert await csis_client.set_member_rank.read_value() == struct.pack('B', 0) + + +# ----------------------------------------------------------------------------- +async def run(): + await test_csis() + + +# ----------------------------------------------------------------------------- +if __name__ == '__main__': + logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) + asyncio.run(run()) diff --git a/tests/test_utils.py b/tests/test_utils.py index f19f18c..bf36e2d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -71,3 +71,6 @@ class TwoDevices: # Check the post conditions assert self.connections[0] is not None assert self.connections[1] is not None + + def __getitem__(self, index: int) -> Device: + return self.devices[index] From 0149c4c2127a79336bcea60ff644959f40640d8e Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Tue, 28 Nov 2023 20:11:09 +0800 Subject: [PATCH 4/5] Log track back in on_packet Many errors are raised in on_packet() callbacks, but currently it only provides a very brief error message. --- bumble/transport/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bumble/transport/common.py b/bumble/transport/common.py index 2786a75..53e5223 100644 --- a/bumble/transport/common.py +++ b/bumble/transport/common.py @@ -150,7 +150,7 @@ class PacketParser: try: self.sink.on_packet(bytes(self.packet)) except Exception as error: - logger.warning( + logger.exception( color(f'!!! Exception in on_packet: {error}', 'red') ) self.reset() From f3cd8f8ed0ef2ac5ec75adb8fea60e568c68b8e3 Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Mon, 27 Nov 2023 16:10:01 +0800 Subject: [PATCH 5/5] Typing helper --- bumble/hci.py | 4 ++ bumble/helpers.py | 109 ++++++++++++++++++++++++++++------------------ bumble/l2cap.py | 8 ++++ 3 files changed, 79 insertions(+), 42 deletions(-) diff --git a/bumble/hci.py b/bumble/hci.py index 8897624..67fe457 100644 --- a/bumble/hci.py +++ b/bumble/hci.py @@ -5296,6 +5296,10 @@ class HCI_Disconnection_Complete_Event(HCI_Event): See Bluetooth spec @ 7.7.5 Disconnection Complete Event ''' + status: int + connection_handle: int + reason: int + # ----------------------------------------------------------------------------- @HCI_Event.event([('status', STATUS_SPEC), ('connection_handle', 2)]) diff --git a/bumble/helpers.py b/bumble/helpers.py index 83c7c6d..6174851 100644 --- a/bumble/helpers.py +++ b/bumble/helpers.py @@ -15,30 +15,39 @@ # ----------------------------------------------------------------------------- # Imports # ----------------------------------------------------------------------------- +from __future__ import annotations + +from collections.abc import Callable, MutableMapping +from typing import cast, Any import logging -from .colors import color -from .att import ATT_CID, ATT_PDU -from .smp import SMP_CID, SMP_Command -from .core import name_or_number -from .l2cap import ( +from bumble import avdtp +from bumble.colors import color +from bumble.att import ATT_CID, ATT_PDU +from bumble.smp import SMP_CID, SMP_Command +from bumble.core import name_or_number +from bumble.l2cap import ( L2CAP_PDU, L2CAP_CONNECTION_REQUEST, L2CAP_CONNECTION_RESPONSE, L2CAP_SIGNALING_CID, L2CAP_LE_SIGNALING_CID, L2CAP_Control_Frame, + L2CAP_Connection_Request, L2CAP_Connection_Response, ) -from .hci import ( +from bumble.hci import ( HCI_EVENT_PACKET, HCI_ACL_DATA_PACKET, HCI_DISCONNECTION_COMPLETE_EVENT, HCI_AclDataPacketAssembler, + HCI_Packet, + HCI_Event, + HCI_AclDataPacket, + HCI_Disconnection_Complete_Event, ) -from .rfcomm import RFCOMM_Frame, RFCOMM_PSM -from .sdp import SDP_PDU, SDP_PSM -from .avdtp import MessageAssembler as AVDTP_MessageAssembler, AVDTP_PSM +from bumble.rfcomm import RFCOMM_Frame, RFCOMM_PSM +from bumble.sdp import SDP_PDU, SDP_PSM # ----------------------------------------------------------------------------- # Logging @@ -50,23 +59,25 @@ logger = logging.getLogger(__name__) PSM_NAMES = { RFCOMM_PSM: 'RFCOMM', SDP_PSM: 'SDP', - AVDTP_PSM: 'AVDTP' - # TODO: add more PSM values + avdtp.AVDTP_PSM: 'AVDTP', } # ----------------------------------------------------------------------------- class PacketTracer: class AclStream: - def __init__(self, analyzer): + psms: MutableMapping[int, int] + peer: PacketTracer.AclStream + avdtp_assemblers: MutableMapping[int, avdtp.MessageAssembler] + + def __init__(self, analyzer: PacketTracer.Analyzer) -> None: self.analyzer = analyzer self.packet_assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu) self.avdtp_assemblers = {} # AVDTP assemblers, by source_cid self.psms = {} # PSM, by source_cid - self.peer = None # ACL stream in the other direction # pylint: disable=too-many-nested-blocks - def on_acl_pdu(self, pdu): + def on_acl_pdu(self, pdu: bytes) -> None: l2cap_pdu = L2CAP_PDU.from_bytes(pdu) if l2cap_pdu.cid == ATT_CID: @@ -81,26 +92,30 @@ class PacketTracer: # Check if this signals a new channel if control_frame.code == L2CAP_CONNECTION_REQUEST: - self.psms[control_frame.source_cid] = control_frame.psm + connection_request = cast(L2CAP_Connection_Request, control_frame) + self.psms[connection_request.source_cid] = connection_request.psm elif control_frame.code == L2CAP_CONNECTION_RESPONSE: + connection_response = cast(L2CAP_Connection_Response, control_frame) if ( - control_frame.result + connection_response.result == L2CAP_Connection_Response.CONNECTION_SUCCESSFUL ): if self.peer: - if psm := self.peer.psms.get(control_frame.source_cid): + if psm := self.peer.psms.get( + connection_response.source_cid + ): # Found a pending connection - self.psms[control_frame.destination_cid] = psm + self.psms[connection_response.destination_cid] = psm # For AVDTP connections, create a packet assembler for # each direction - if psm == AVDTP_PSM: + if psm == avdtp.AVDTP_PSM: self.avdtp_assemblers[ - control_frame.source_cid - ] = AVDTP_MessageAssembler(self.on_avdtp_message) + connection_response.source_cid + ] = avdtp.MessageAssembler(self.on_avdtp_message) self.peer.avdtp_assemblers[ - control_frame.destination_cid - ] = AVDTP_MessageAssembler( + connection_response.destination_cid + ] = avdtp.MessageAssembler( self.peer.on_avdtp_message ) @@ -113,7 +128,7 @@ class PacketTracer: elif psm == RFCOMM_PSM: rfcomm_frame = RFCOMM_Frame.from_bytes(l2cap_pdu.payload) self.analyzer.emit(rfcomm_frame) - elif psm == AVDTP_PSM: + elif psm == avdtp.AVDTP_PSM: self.analyzer.emit( f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, ' f'PSM=AVDTP]: {l2cap_pdu.payload.hex()}' @@ -130,22 +145,26 @@ class PacketTracer: else: self.analyzer.emit(l2cap_pdu) - def on_avdtp_message(self, transaction_label, message): + def on_avdtp_message( + self, transaction_label: int, message: avdtp.Message + ) -> None: self.analyzer.emit( f'{color("AVDTP", "green")} [{transaction_label}] {message}' ) - def feed_packet(self, packet): + def feed_packet(self, packet: HCI_AclDataPacket) -> None: self.packet_assembler.feed_packet(packet) class Analyzer: - def __init__(self, label, emit_message): + acl_streams: MutableMapping[int, PacketTracer.AclStream] + peer: PacketTracer.Analyzer + + def __init__(self, label: str, emit_message: Callable[..., None]) -> None: self.label = label self.emit_message = emit_message self.acl_streams = {} # ACL streams, by connection handle - self.peer = None # Analyzer in the other direction - def start_acl_stream(self, connection_handle): + def start_acl_stream(self, connection_handle: int) -> PacketTracer.AclStream: logger.info( f'[{self.label}] +++ Creating ACL stream for connection ' f'0x{connection_handle:04X}' @@ -160,7 +179,7 @@ class PacketTracer: return stream - def end_acl_stream(self, connection_handle): + def end_acl_stream(self, connection_handle: int) -> None: if connection_handle in self.acl_streams: logger.info( f'[{self.label}] --- Removing ACL stream for connection ' @@ -171,23 +190,29 @@ class PacketTracer: # Let the other forwarder know so it can cleanup its stream as well self.peer.end_acl_stream(connection_handle) - def on_packet(self, packet): + def on_packet(self, packet: HCI_Packet) -> None: self.emit(packet) if packet.hci_packet_type == HCI_ACL_DATA_PACKET: + acl_packet = cast(HCI_AclDataPacket, packet) # Look for an existing stream for this handle, create one if it is the # first ACL packet for that connection handle - if (stream := self.acl_streams.get(packet.connection_handle)) is None: - stream = self.start_acl_stream(packet.connection_handle) - stream.feed_packet(packet) + if ( + stream := self.acl_streams.get(acl_packet.connection_handle) + ) is None: + stream = self.start_acl_stream(acl_packet.connection_handle) + stream.feed_packet(acl_packet) elif packet.hci_packet_type == HCI_EVENT_PACKET: - if packet.event_code == HCI_DISCONNECTION_COMPLETE_EVENT: - self.end_acl_stream(packet.connection_handle) + event_packet = cast(HCI_Event, packet) + if event_packet.event_code == HCI_DISCONNECTION_COMPLETE_EVENT: + self.end_acl_stream( + cast(HCI_Disconnection_Complete_Event, packet).connection_handle + ) - def emit(self, message): + def emit(self, message: Any) -> None: self.emit_message(f'[{self.label}] {message}') - def trace(self, packet, direction=0): + def trace(self, packet: HCI_Packet, direction: int = 0) -> None: if direction == 0: self.host_to_controller_analyzer.on_packet(packet) else: @@ -195,10 +220,10 @@ class PacketTracer: def __init__( self, - host_to_controller_label=color('HOST->CONTROLLER', 'blue'), - controller_to_host_label=color('CONTROLLER->HOST', 'cyan'), - emit_message=logger.info, - ): + host_to_controller_label: str = color('HOST->CONTROLLER', 'blue'), + controller_to_host_label: str = color('CONTROLLER->HOST', 'cyan'), + emit_message: Callable[..., None] = logger.info, + ) -> None: self.host_to_controller_analyzer = PacketTracer.Analyzer( host_to_controller_label, emit_message ) diff --git a/bumble/l2cap.py b/bumble/l2cap.py index 7a2f0ed..4ccdeab 100644 --- a/bumble/l2cap.py +++ b/bumble/l2cap.py @@ -391,6 +391,9 @@ class L2CAP_Connection_Request(L2CAP_Control_Frame): See Bluetooth spec @ Vol 3, Part A - 4.2 CONNECTION REQUEST ''' + psm: int + source_cid: int + @staticmethod def parse_psm(data: bytes, offset: int = 0) -> Tuple[int, int]: psm_length = 2 @@ -432,6 +435,11 @@ class L2CAP_Connection_Response(L2CAP_Control_Frame): See Bluetooth spec @ Vol 3, Part A - 4.3 CONNECTION RESPONSE ''' + source_cid: int + destination_cid: int + status: int + result: int + CONNECTION_SUCCESSFUL = 0x0000 CONNECTION_PENDING = 0x0001 CONNECTION_REFUSED_PSM_NOT_SUPPORTED = 0x0002