From 320671efd9dd7d47cf17967a02a20c545dfcf512 Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Tue, 3 Oct 2023 10:53:59 +0800 Subject: [PATCH 1/4] LE Audio GATT constants --- bumble/gatt.py | 44 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/bumble/gatt.py b/bumble/gatt.py index fe3e85c0..1528283a 100644 --- a/bumble/gatt.py +++ b/bumble/gatt.py @@ -99,12 +99,21 @@ 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_DEVICE_TIME_SERVICE = UUID.from_16_bits(0x1847, 'Device Time') -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_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1848, '[LE Audio] Media Control Service') +GATT_GENERIC_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1849, '[LE Audio] Generic Media Control Service') GATT_CONSTANT_TONE_EXTENSION_SERVICE = UUID.from_16_bits(0x184A, 'Constant Tone Extension') -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') +GATT_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184B, '[LE Audio] Telephone Bearer Service') +GATT_GENERIC_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184C, '[LE Audio] Generic Telephone Bearer Service') +GATT_MICROPHONE_CONTROL_SERVICE = UUID.from_16_bits(0x184D, '[LE Audio] Microphone Control') +GATT_AUDIO_STREAM_CONTROL_SERVICE = UUID.from_16_bits(0x184E, '[LE Audio] Audio Stream Control Service') +GATT_BROADCAST_AUDIO_SCAN_SERVICE = UUID.from_16_bits(0x184F, '[LE Audio] Broadcast Audio Scan Service') +GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE = UUID.from_16_bits(0x1850, '[LE Audio] Published Audio Capabilities Service') +GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1851, '[LE Audio] Basic Audio Announcement Service') +GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1852, '[LE Audio] Broadcast Audio Announcement Service') +GATT_COMMON_AUDIO_SERVICE = UUID.from_16_bits(0x1853, '[LE Audio] Common Audio Service') +GATT_HEARING_AID_SERVICE = UUID.from_16_bits(0x1854, '[LE Audio] Hearing Aid Service') +GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE = UUID.from_16_bits(0x1855, '[LE Audio] Telephony and Media Audio Service') +GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1856, '[LE Audio] Public Broadcast Announcement Service') # Types GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2800, 'Primary Service') @@ -156,6 +165,31 @@ # Battery Service GATT_BATTERY_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A19, 'Battery Level') +# Coordinated Set Identification Service +GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC = UUID.from_16_bits(0x2B84, '[CSIS] Set Identity Resolving Key') +GATT_COORDINATED_SET_SIZE_CHARACTERISTIC = UUID.from_16_bits(0x2B85, '[CSIS] Coordinated Set Size') +GATT_SET_MEMBER_LOCK_CHARACTERISTIC = UUID.from_16_bits(0x2B86, '[CSIS] Set Member Lock') +GATT_SET_MEMBER_RANK_CHARACTERISTIC = UUID.from_16_bits(0x2B87, '[CSIS] Set Member Rank') + +# Audio Stream Control Service +GATT_SINK_ASE_CHARACTERISTIC = UUID.from_16_bits(0x2BC4, '[ASCS] Sink ASE') +GATT_SOURCE_ASE_CHARACTERISTIC = UUID.from_16_bits(0x2BC5, '[ASCS] Source ASE') +GATT_ASE_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BC6, '[ASCS] ASE Control Point') + +# Published Audio Capabilities Service +GATT_SINK_PAC_CHARACTERISTIC = UUID.from_16_bits(0x2BC9, '[PACS] Sink PAC') +GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2BCA, '[PACS] Sink Audio Location') +GATT_SOURCE_PAC_CHARACTERISTIC = UUID.from_16_bits(0x2BCB, '[PACS] Source PAC') +GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2BCC, '[PACS] Source Audio Location') +GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCD, '[PACS] Available Audio Contexts') +GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCE, '[PACS] Supported Audio Contexts') + +# Volume Control Service +GATT_VOLUME_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B7D, '[VCS] Volume State') +GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2B7E, '[VCS] Volume Control Point') +GATT_VOLUME_FLAGS_CHARACTERISTIC = UUID.from_16_bits(0x2B7F, '[VCS] Volume Flags') +GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B80, '[VCS] Volume Offset State') + # ASHA Service GATT_ASHA_SERVICE = UUID.from_16_bits(0xFDF0, 'Audio Streaming for Hearing Aid') GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC = UUID('6333651e-c481-4a3e-9169-7c902aad37bb', 'ReadOnlyProperties') From e030b0379899a3e2f2cc99ebd2976b39b7f79d30 Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Tue, 3 Oct 2023 11:40:54 +0800 Subject: [PATCH 2/4] Minimal LE Audio sink example(without CIS) --- examples/adv_short_interval.json | 7 ++ examples/run_leaudio_unicast_sink.py | 145 +++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 examples/adv_short_interval.json create mode 100644 examples/run_leaudio_unicast_sink.py diff --git a/examples/adv_short_interval.json b/examples/adv_short_interval.json new file mode 100644 index 00000000..0d81e77d --- /dev/null +++ b/examples/adv_short_interval.json @@ -0,0 +1,7 @@ +{ + "name": "Bumble", + "address": "F0:F1:F2:F3:F4:F5", + "advertising_interval": 100, + "keystore": "JsonKeyStore", + "irk": "865F81FF5A8B486EAAE29A27AD9F77DC" +} diff --git a/examples/run_leaudio_unicast_sink.py b/examples/run_leaudio_unicast_sink.py new file mode 100644 index 00000000..c51e15dd --- /dev/null +++ b/examples/run_leaudio_unicast_sink.py @@ -0,0 +1,145 @@ +# 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 sys +import os +import logging +import struct + +from bumble.device import Device, Connection, AdvertisingData +from bumble.transport import open_transport_or_link +from bumble.gatt import ( + Characteristic, + Service, + GATT_AUDIO_STREAM_CONTROL_SERVICE, + GATT_ASE_CONTROL_POINT_CHARACTERISTIC, + GATT_COMMON_AUDIO_SERVICE, + GATT_COORDINATED_SET_IDENTIFICATION_SERVICE, + GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE, + GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC, + GATT_SINK_ASE_CHARACTERISTIC, +) + + +# ----------------------------------------------------------------------------- +class Listener(Device.Listener, Connection.Listener): + def __init__(self, device): + self.device = device + + def on_connection(self, connection): + print(f'=== Connected to {connection}') + connection.listener = self + + def on_disconnection(self, reason): + print(f'### Disconnected, reason={reason}') + + def on_characteristic_subscription( + self, connection, characteristic, notify_enabled, indicate_enabled + ): + print( + f'$$$ Characteristic subscription for handle {characteristic.handle} ' + f'from {connection}: ' + f'notify {"enabled" if notify_enabled else "disabled"}, ' + f'indicate {"enabled" if indicate_enabled else "disabled"}' + ) + + +# ----------------------------------------------------------------------------- +async def main() -> None: + if len(sys.argv) < 3: + print('Usage: run_leaudio_unicast_sink.py ') + print('example: run_leaudio_unicast_sink.py adv_short_interval.json usb:0') + return + + print('<<< connecting to HCI...') + async with await open_transport_or_link(sys.argv[2]) as hci_transport: + print('<<< connected') + + # Create a device to manage the host + device = Device.from_config_file_with_hci( + sys.argv[1], hci_transport.source, hci_transport.sink + ) + device.listener = Listener(device) + + sirk = Characteristic( + GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC, + Characteristic.READ, + Characteristic.READABLE, + b'\x01' + b'\x01' * 16, + ) + ase_control_point = Characteristic( + GATT_ASE_CONTROL_POINT_CHARACTERISTIC, + Characteristic.WRITE + | Characteristic.WRITE_WITHOUT_RESPONSE + | Characteristic.NOTIFY, + Characteristic.WRITEABLE, + ) + sink_ase = Characteristic( + GATT_SINK_ASE_CHARACTERISTIC, + Characteristic.READ | Characteristic.NOTIFY, + Characteristic.READABLE, + ) + ascs = Service( + GATT_AUDIO_STREAM_CONTROL_SERVICE, + [ase_control_point, sink_ase], + ) + device.add_service(ascs) + + pacs = Service(GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE, []) + device.add_service(pacs) + + csis = Service( + GATT_COORDINATED_SET_IDENTIFICATION_SERVICE, + [sirk], + ) + cas = Service( + GATT_COMMON_AUDIO_SERVICE, + characteristics=[], + included_services=[csis], + ) + device.add_service(cas) + + # Debug print + for attribute in device.gatt_server.attributes: + print(attribute) + device.advertising_data = bytes( + AdvertisingData( + [ + (AdvertisingData.COMPLETE_LOCAL_NAME, bytes(device.name, 'utf-8')), + (AdvertisingData.FLAGS, bytes([0x06])), + ] + ) + ) + + # Get things going + await device.power_on() + + # Connect to a peer + if len(sys.argv) > 3: + target_address = sys.argv[3] + print(f'=== Connecting to {target_address}...') + await device.connect(target_address) + else: + await device.start_advertising(auto_restart=True) + + await hci_transport.source.terminated + + +# ----------------------------------------------------------------------------- +logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper()) +asyncio.run(main()) From d5332e4933e952a14a3ffdd589384ef965d09064 Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Fri, 24 Nov 2023 17:58:27 +0800 Subject: [PATCH 3/4] Add PACS and Generic Audio constants --- bumble/profiles/pacs.py | 464 ++++++++++++++++++++++++++++++++++++ tests/profiles/pacs_test.py | 59 +++++ 2 files changed, 523 insertions(+) create mode 100644 bumble/profiles/pacs.py create mode 100644 tests/profiles/pacs_test.py diff --git a/bumble/profiles/pacs.py b/bumble/profiles/pacs.py new file mode 100644 index 00000000..26198ac8 --- /dev/null +++ b/bumble/profiles/pacs.py @@ -0,0 +1,464 @@ +# 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 +from collections.abc import Sequence +import dataclasses +import enum +import functools +import struct +from typing import Optional + +from bumble import gatt +from bumble import gatt_client + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- + + +class AudioLocation(enum.IntFlag): + '''Bluetooth Assigned Numbers, Section 6.12.1 - Audio Location''' + + # fmt: off + NOT_ALLOWED = 0x00000000 + FRONT_LEFT = 0x00000001 + FRONT_RIGHT = 0x00000002 + FRONT_CENTER = 0x00000004 + LOW_FREQUENCY_EFFECTS_1 = 0x00000008 + BACK_LEFT = 0x00000010 + BACK_RIGHT = 0x00000020 + FRONT_LEFT_OF_CENTER = 0x00000040 + FRONT_RIGHT_OF_CENTER = 0x00000080 + BACK_CENTER = 0x00000100 + LOW_FREQUENCY_EFFECTS_2 = 0x00000200 + SIDE_LEFT = 0x00000400 + SIDE_RIGHT = 0x00000800 + TOP_FRONT_LEFT = 0x00001000 + TOP_FRONT_RIGHT = 0x00002000 + TOP_FRONT_CENTER = 0x00004000 + TOP_CENTER = 0x00008000 + TOP_BACK_LEFT = 0x00010000 + TOP_BACK_RIGHT = 0x00020000 + TOP_SIDE_LEFT = 0x00040000 + TOP_SIDE_RIGHT = 0x00080000 + TOP_BACK_CENTER = 0x00100000 + BOTTOM_FRONT_CENTER = 0x00200000 + BOTTOM_FRONT_LEFT = 0x00400000 + BOTTOM_FRONT_RIGHT = 0x00800000 + FRONT_LEFT_WIDE = 0x01000000 + FRONT_RIGHT_WIDE = 0x02000000 + LEFT_SURROUND = 0x04000000 + RIGHT_SURROUND = 0x08000000 + + +class AudioInputType(enum.IntEnum): + '''Bluetooth Assigned Numbers, Section 6.12.2 - Audio Input Type''' + + # fmt: off + UNSPECIFIED = 0x00 + BLUETOOTH = 0x01 + MICROPHONE = 0x02 + ANALOG = 0x03 + DIGITAL = 0x04 + RADIO = 0x05 + STREAMING = 0x06 + AMBIENT = 0x07 + + +class ContextType(enum.IntFlag): + '''Bluetooth Assigned Numbers, Section 6.12.3 - Context Type''' + + # fmt: off + PROHIBITED = 0x0000 + CONVERSATIONAL = 0x0002 + MEDIA = 0x0004 + GAME = 0x0008 + INSTRUCTIONAL = 0x0010 + VOICE_ASSISTANTS = 0x0020 + LIVE = 0x0040 + SOUND_EFFECTS = 0x0080 + NOTIFICATIONS = 0x0100 + RINGTONE = 0x0200 + ALERTS = 0x0400 + EMERGENCY_ALARM = 0x0800 + + +class SampleFrequency(enum.IntEnum): + '''Bluetooth Assigned Numbers, Section 6.12.5.1 - Sample Frequency''' + + # fmt: off + FREQ_8000 = 0x01 + FREQ_11025 = 0x02 + FREQ_16000 = 0x03 + FREQ_22050 = 0x04 + FREQ_24000 = 0x05 + FREQ_32000 = 0x06 + FREQ_44100 = 0x07 + FREQ_48000 = 0x08 + FREQ_88200 = 0x09 + FREQ_96000 = 0x0A + FREQ_176400 = 0x0B + FREQ_192000 = 0x0C + FREQ_384000 = 0x0D + # fmt: on + + @classmethod + def from_hz(cls, frequency: int) -> SampleFrequency: + return { + 8000: SampleFrequency.FREQ_8000, + 11025: SampleFrequency.FREQ_11025, + 16000: SampleFrequency.FREQ_16000, + 22050: SampleFrequency.FREQ_22050, + 24000: SampleFrequency.FREQ_24000, + 32000: SampleFrequency.FREQ_32000, + 44100: SampleFrequency.FREQ_44100, + 48000: SampleFrequency.FREQ_48000, + 88200: SampleFrequency.FREQ_88200, + 96000: SampleFrequency.FREQ_96000, + 176400: SampleFrequency.FREQ_176400, + 192000: SampleFrequency.FREQ_192000, + 384000: SampleFrequency.FREQ_384000, + }[frequency] + + @property + def hz(self) -> int: + return { + SampleFrequency.FREQ_8000: 8000, + SampleFrequency.FREQ_11025: 11025, + SampleFrequency.FREQ_16000: 16000, + SampleFrequency.FREQ_22050: 22050, + SampleFrequency.FREQ_24000: 24000, + SampleFrequency.FREQ_32000: 32000, + SampleFrequency.FREQ_44100: 44100, + SampleFrequency.FREQ_48000: 48000, + SampleFrequency.FREQ_88200: 88200, + SampleFrequency.FREQ_96000: 96000, + SampleFrequency.FREQ_176400: 176400, + SampleFrequency.FREQ_192000: 192000, + SampleFrequency.FREQ_384000: 384000, + }[self] + + +class SupportedSampleFrequency(enum.IntFlag): + '''Bluetooth Assigned Numbers, Section 6.12.4.1 - Sample Frequency''' + + # fmt: off + FREQ_8000 = 1 << (SampleFrequency.FREQ_8000 - 1) + FREQ_11025 = 1 << (SampleFrequency.FREQ_11025 - 1) + FREQ_16000 = 1 << (SampleFrequency.FREQ_16000 - 1) + FREQ_22050 = 1 << (SampleFrequency.FREQ_22050 - 1) + FREQ_24000 = 1 << (SampleFrequency.FREQ_24000 - 1) + FREQ_32000 = 1 << (SampleFrequency.FREQ_32000 - 1) + FREQ_44100 = 1 << (SampleFrequency.FREQ_44100 - 1) + FREQ_48000 = 1 << (SampleFrequency.FREQ_48000 - 1) + FREQ_88200 = 1 << (SampleFrequency.FREQ_88200 - 1) + FREQ_96000 = 1 << (SampleFrequency.FREQ_96000 - 1) + FREQ_176400 = 1 << (SampleFrequency.FREQ_176400 - 1) + FREQ_192000 = 1 << (SampleFrequency.FREQ_192000 - 1) + FREQ_384000 = 1 << (SampleFrequency.FREQ_384000 - 1) + # fmt: on + + @classmethod + def from_hz(cls, frequencies: Sequence[int]) -> SupportedSampleFrequency: + MAPPING = { + 8000: SupportedSampleFrequency.FREQ_8000, + 11025: SupportedSampleFrequency.FREQ_11025, + 16000: SupportedSampleFrequency.FREQ_16000, + 22050: SupportedSampleFrequency.FREQ_22050, + 24000: SupportedSampleFrequency.FREQ_24000, + 32000: SupportedSampleFrequency.FREQ_32000, + 44100: SupportedSampleFrequency.FREQ_44100, + 48000: SupportedSampleFrequency.FREQ_48000, + 88200: SupportedSampleFrequency.FREQ_88200, + 96000: SupportedSampleFrequency.FREQ_96000, + 176400: SupportedSampleFrequency.FREQ_176400, + 192000: SupportedSampleFrequency.FREQ_192000, + 384000: SupportedSampleFrequency.FREQ_384000, + } + + return functools.reduce( + lambda x, y: x | MAPPING[y], + frequencies, + cls(0), + ) + + +class FrameDuration(enum.IntEnum): + '''Bluetooth Assigned Numbers, Section 6.12.5.2 - Frame Duration''' + + # fmt: off + DURATION_7500_US = 0x00 + DURATION_10000_US = 0x01 + + +class SupportedFrameDuration(enum.IntFlag): + '''Bluetooth Assigned Numbers, Section 6.12.4.2 - Frame Duration''' + + # fmt: off + DURATION_7500_US_SUPPORTED = 0b0001 + DURATION_10000_US_SUPPORTED = 0b0010 + DURATION_7500_US_PREFERRED = 0b0001 + DURATION_10000_US_PREFERRED = 0b0010 + + +# ----------------------------------------------------------------------------- +# Utils +# ----------------------------------------------------------------------------- + + +def bits_to_channel_counts(data: int) -> Sequence[int]: + pos = 0 + counts = [] + while data != 0: + # Bit 0 = count 1 + # Bit 1 = count 2, and so on + pos += 1 + if data & 1: + counts.append(pos) + data >>= 1 + return counts + + +def channel_counts_to_bits(counts: Sequence[int]) -> int: + return sum(set([1 << (count - 1) for count in counts])) + + +# ----------------------------------------------------------------------------- +# Structures +# ----------------------------------------------------------------------------- + + +@dataclasses.dataclass +class CodecSpecificCapabilities: + '''Bluetooth Assigned Numbers, Section 6.12.5 - Codec Specific Capabilities LTV Structures.''' + + class Type(enum.IntEnum): + # fmt: off + SAMPLE_FREQUENCY = 0x01 + FRAME_DURATION = 0x02 + AUDIO_CHANNEL_COUNT = 0x03 + OCTETS_PER_SAMPLE = 0x04 + CODEC_FRAMES_PER_SDU = 0x05 + + supported_sample_frequencies: SupportedSampleFrequency + supported_frame_durations: SupportedFrameDuration + supported_audio_channel_counts: Sequence[int] + min_octets_per_sample: int + max_octets_per_sample: int + supported_max_codec_frames_per_sdu: int + + @classmethod + def from_bytes(cls, data: bytes) -> CodecSpecificCapabilities: + pos = 0 + while pos < len(data): + length, type = struct.unpack_from('BB', data, pos) + pos += 2 + value = int.from_bytes(data[pos : pos + length - 1], 'little') + pos += length - 1 + + if type == CodecSpecificCapabilities.Type.SAMPLE_FREQUENCY: + supported_sample_frequencies = SupportedSampleFrequency(value) + elif type == CodecSpecificCapabilities.Type.FRAME_DURATION: + supported_frame_durations = SupportedFrameDuration(value) + elif type == CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT: + supported_audio_channel_counts = bits_to_channel_counts(value) + elif type == CodecSpecificCapabilities.Type.OCTETS_PER_SAMPLE: + min_octets_per_sample = value & 0xFFFF + max_octets_per_sample = value >> 16 + elif type == CodecSpecificCapabilities.Type.CODEC_FRAMES_PER_SDU: + supported_max_codec_frames_per_sdu = value + + # It is expected here that if some fields are missing, an error should be raised. + return CodecSpecificCapabilities( + supported_sample_frequencies=supported_sample_frequencies, + supported_frame_durations=supported_frame_durations, + supported_audio_channel_counts=supported_audio_channel_counts, + min_octets_per_sample=min_octets_per_sample, + max_octets_per_sample=max_octets_per_sample, + supported_max_codec_frames_per_sdu=supported_max_codec_frames_per_sdu, + ) + + def __bytes__(self) -> bytes: + return struct.pack( + ' PAC_Record: + codec_id, size = struct.unpack_from('5sB', data) + pos = 5 + 1 + codec_specific_capabilities, size = struct.unpack_from(f'{size}sB', data, pos) + pos += len(codec_specific_capabilities) + 1 + (metadata,) = struct.unpack_from(f'{size}s', data, pos) + return PAC_Record( + codec_id=codec_id, + codec_specific_capabilities=CodecSpecificCapabilities.from_bytes( + codec_specific_capabilities + ), + metadata=metadata, + ) + + def __bytes__(self) -> bytes: + capabilities_bytes = bytes(self.codec_specific_capabilities) + return struct.pack( + f'5sB{len(capabilities_bytes)}sB{len(self.metadata)}s', + self.codec_id, + len(capabilities_bytes), + capabilities_bytes, + len(self.metadata), + self.metadata, + ) + + +# ----------------------------------------------------------------------------- +# Server +# ----------------------------------------------------------------------------- +class Service(gatt.TemplateService): + UUID = gatt.GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE + + def __init__( + self, + supported_source_context: ContextType, + supported_sink_context: ContextType, + available_source_context: ContextType, + available_sink_context: ContextType, + sink_pacs: Optional[Sequence[PAC_Record]] = None, + sink_audio_location: Optional[AudioLocation] = None, + source_pacs: Optional[Sequence[PAC_Record]] = None, + source_audio_location: Optional[AudioLocation] = None, + ): + characteristics = [] + supported_audio_context_characteristic = gatt.Characteristic( + uuid=gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC, + properties=gatt.Characteristic.Properties.READ + | gatt.Characteristic.Properties.NOTIFY, + permissions=gatt.Characteristic.Permissions.READABLE, + value=struct.pack(' None: + SAMPLE_FREQUENCY = pacs.SupportedSampleFrequency.FREQ_16000 + FRAME_SURATION = pacs.SupportedFrameDuration.DURATION_10000_US_SUPPORTED + AUDIO_CHANNEL_COUNTS = [1] + cap = pacs.CodecSpecificCapabilities( + supported_sample_frequencies=SAMPLE_FREQUENCY, + supported_frame_durations=FRAME_SURATION, + supported_audio_channel_counts=AUDIO_CHANNEL_COUNTS, + min_octets_per_sample=40, + max_octets_per_sample=40, + supported_max_codec_frames_per_sdu=1, + ) + assert pacs.CodecSpecificCapabilities.from_bytes(bytes(cap)) == cap + + +# ----------------------------------------------------------------------------- +def test_pac_record() -> None: + SAMPLE_FREQUENCY = pacs.SupportedSampleFrequency.FREQ_16000 + FRAME_SURATION = pacs.SupportedFrameDuration.DURATION_10000_US_SUPPORTED + AUDIO_CHANNEL_COUNTS = [1] + cap = pacs.CodecSpecificCapabilities( + supported_sample_frequencies=SAMPLE_FREQUENCY, + supported_frame_durations=FRAME_SURATION, + supported_audio_channel_counts=AUDIO_CHANNEL_COUNTS, + min_octets_per_sample=40, + max_octets_per_sample=40, + supported_max_codec_frames_per_sdu=1, + ) + + pac_record = pacs.PAC_Record( + codec_id=b'12345', codec_specific_capabilities=cap, metadata=b'' + ) + assert pacs.PAC_Record.from_bytes(bytes(pac_record)) == pac_record + + +# ----------------------------------------------------------------------------- +if __name__ == '__main__': + test_codec_specific_capabilities() From 1baa613144798dd236194c1305f80d60afcb5ea7 Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Sat, 25 Nov 2023 00:25:11 +0800 Subject: [PATCH 4/4] Add CSIS --- bumble/gatt.py | 63 +++++++-------- bumble/profiles/ascs.py | 155 ++++++++++++++++++++++++++++++++++++ bumble/profiles/csis.py | 127 +++++++++++++++++++++++++++++ bumble/profiles/pacs.py | 138 ++++++++++++++++---------------- tests/profiles/pacs_test.py | 12 +-- 5 files changed, 389 insertions(+), 106 deletions(-) create mode 100644 bumble/profiles/ascs.py create mode 100644 bumble/profiles/csis.py diff --git a/bumble/gatt.py b/bumble/gatt.py index 1528283a..55f9691b 100644 --- a/bumble/gatt.py +++ b/bumble/gatt.py @@ -99,21 +99,22 @@ 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_DEVICE_TIME_SERVICE = UUID.from_16_bits(0x1847, 'Device Time') -GATT_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1848, '[LE Audio] Media Control Service') -GATT_GENERIC_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1849, '[LE Audio] Generic Media Control Service') +# 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') -GATT_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184B, '[LE Audio] Telephone Bearer Service') -GATT_GENERIC_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184C, '[LE Audio] Generic Telephone Bearer Service') -GATT_MICROPHONE_CONTROL_SERVICE = UUID.from_16_bits(0x184D, '[LE Audio] Microphone Control') -GATT_AUDIO_STREAM_CONTROL_SERVICE = UUID.from_16_bits(0x184E, '[LE Audio] Audio Stream Control Service') -GATT_BROADCAST_AUDIO_SCAN_SERVICE = UUID.from_16_bits(0x184F, '[LE Audio] Broadcast Audio Scan Service') -GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE = UUID.from_16_bits(0x1850, '[LE Audio] Published Audio Capabilities Service') -GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1851, '[LE Audio] Basic Audio Announcement Service') -GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1852, '[LE Audio] Broadcast Audio Announcement Service') -GATT_COMMON_AUDIO_SERVICE = UUID.from_16_bits(0x1853, '[LE Audio] Common Audio Service') -GATT_HEARING_AID_SERVICE = UUID.from_16_bits(0x1854, '[LE Audio] Hearing Aid Service') -GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE = UUID.from_16_bits(0x1855, '[LE Audio] Telephony and Media Audio Service') -GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1856, '[LE Audio] Public Broadcast Announcement 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') +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_AID_SERVICE = UUID.from_16_bits(0x1854, 'Hearing Aid 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') # Types GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2800, 'Primary Service') @@ -166,29 +167,29 @@ GATT_BATTERY_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A19, 'Battery Level') # Coordinated Set Identification Service -GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC = UUID.from_16_bits(0x2B84, '[CSIS] Set Identity Resolving Key') -GATT_COORDINATED_SET_SIZE_CHARACTERISTIC = UUID.from_16_bits(0x2B85, '[CSIS] Coordinated Set Size') -GATT_SET_MEMBER_LOCK_CHARACTERISTIC = UUID.from_16_bits(0x2B86, '[CSIS] Set Member Lock') -GATT_SET_MEMBER_RANK_CHARACTERISTIC = UUID.from_16_bits(0x2B87, '[CSIS] Set Member Rank') +GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC = UUID.from_16_bits(0x2B84, 'Set Identity Resolving Key') +GATT_COORDINATED_SET_SIZE_CHARACTERISTIC = UUID.from_16_bits(0x2B85, 'Coordinated Set Size') +GATT_SET_MEMBER_LOCK_CHARACTERISTIC = UUID.from_16_bits(0x2B86, 'Set Member Lock') +GATT_SET_MEMBER_RANK_CHARACTERISTIC = UUID.from_16_bits(0x2B87, 'Set Member Rank') # Audio Stream Control Service -GATT_SINK_ASE_CHARACTERISTIC = UUID.from_16_bits(0x2BC4, '[ASCS] Sink ASE') -GATT_SOURCE_ASE_CHARACTERISTIC = UUID.from_16_bits(0x2BC5, '[ASCS] Source ASE') -GATT_ASE_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BC6, '[ASCS] ASE Control Point') +GATT_SINK_ASE_CHARACTERISTIC = UUID.from_16_bits(0x2BC4, 'Sink ASE') +GATT_SOURCE_ASE_CHARACTERISTIC = UUID.from_16_bits(0x2BC5, 'Source ASE') +GATT_ASE_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BC6, 'ASE Control Point') # Published Audio Capabilities Service -GATT_SINK_PAC_CHARACTERISTIC = UUID.from_16_bits(0x2BC9, '[PACS] Sink PAC') -GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2BCA, '[PACS] Sink Audio Location') -GATT_SOURCE_PAC_CHARACTERISTIC = UUID.from_16_bits(0x2BCB, '[PACS] Source PAC') -GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2BCC, '[PACS] Source Audio Location') -GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCD, '[PACS] Available Audio Contexts') -GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCE, '[PACS] Supported Audio Contexts') +GATT_SINK_PAC_CHARACTERISTIC = UUID.from_16_bits(0x2BC9, 'Sink PAC') +GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2BCA, 'Sink Audio Location') +GATT_SOURCE_PAC_CHARACTERISTIC = UUID.from_16_bits(0x2BCB, 'Source PAC') +GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2BCC, 'Source Audio Location') +GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCD, 'Available Audio Contexts') +GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCE, 'Supported Audio Contexts') # Volume Control Service -GATT_VOLUME_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B7D, '[VCS] Volume State') -GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2B7E, '[VCS] Volume Control Point') -GATT_VOLUME_FLAGS_CHARACTERISTIC = UUID.from_16_bits(0x2B7F, '[VCS] Volume Flags') -GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B80, '[VCS] Volume Offset State') +GATT_VOLUME_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B7D, 'Volume State') +GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2B7E, 'Volume Control Point') +GATT_VOLUME_FLAGS_CHARACTERISTIC = UUID.from_16_bits(0x2B7F, 'Volume Flags') +GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B80, 'Volume Offset State') # ASHA Service GATT_ASHA_SERVICE = UUID.from_16_bits(0xFDF0, 'Audio Streaming for Hearing Aid') diff --git a/bumble/profiles/ascs.py b/bumble/profiles/ascs.py new file mode 100644 index 00000000..d78eb067 --- /dev/null +++ b/bumble/profiles/ascs.py @@ -0,0 +1,155 @@ +# 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, List + +from bumble import device +from bumble import gatt +from bumble import gatt_client + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- + + +class MemberLock(enum.IntEnum): + # fmt: off + UNLOCKED = 0x01 + LOCKED = 0x02 + + +class AseState(enum.IntEnum): + # fmt: off + IDLE = 0x00 + CODEC_CONFIGURED = 0x01 + QOS_CONFIGURED = 0x02 + ENABLING = 0x03 + STREAMING = 0x04 + DISABLING = 0x05 + RELEASING = 0x06 + + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- +class AudioStreamEndpoint: + state = AseState.IDLE + + def __init__(self, id: int, characteristic: gatt.Characteristic) -> None: + self.id = id + self.characteristic = characteristic + self.characteristic.value = gatt.CharacteristicValue(read=self.on_read) + + def on_read(self, connection: device.Connection) -> bytes: + return struct.pack('BB', self.id, self.state) + + +# ----------------------------------------------------------------------------- +# Server +# ----------------------------------------------------------------------------- +class Service(gatt.TemplateService): + UUID = gatt.GATT_COORDINATED_SET_IDENTIFICATION_SERVICE + + sink_ase: List[AudioStreamEndpoint] = [] + source_ase: List[AudioStreamEndpoint] = [] + + def __init__(self, num_sink_ase: int = 0, num_source_ase: int = 0) -> None: + characteristics = [] + + for i in range(1, num_sink_ase + 1): + characteristic = gatt.Characteristic( + uuid=gatt.GATT_SINK_ASE_CHARACTERISTIC, + properties=gatt.Characteristic.Properties.READ + | gatt.Characteristic.Properties.NOTIFY, + permissions=gatt.Characteristic.Permissions.READABLE, + ) + self.sink_ase.append( + AudioStreamEndpoint( + id=i, + characteristic=characteristic, + ) + ) + characteristics.append(characteristic) + + for i in range(1, num_source_ase + 1): + characteristic = gatt.Characteristic( + uuid=gatt.GATT_SOURCE_ASE_CHARACTERISTIC, + properties=gatt.Characteristic.Properties.READ + | gatt.Characteristic.Properties.NOTIFY, + permissions=gatt.Characteristic.Permissions.READABLE, + ) + self.source_ase.append( + AudioStreamEndpoint( + id=i, + characteristic=characteristic, + ) + ) + characteristics.append(characteristic) + characteristics.append( + gatt.Characteristic( + uuid=gatt.GATT_ASE_CONTROL_POINT_CHARACTERISTIC, + properties=gatt.Characteristic.Properties.WRITE + | gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE + | gatt.Characteristic.Properties.NOTIFY, + permissions=gatt.Characteristic.Permissions.READABLE + | gatt.Characteristic.Permissions.WRITEABLE, + value=gatt.CharacteristicValue(write=self.on_ase_control_point), + ) + ) + + super().__init__(characteristics) + + def on_ase_control_point(self, connection: device.Connection, data: bytes) -> None: + pass + + +# ----------------------------------------------------------------------------- +# Client +# ----------------------------------------------------------------------------- +class ServiceProxy(gatt_client.ProfileServiceProxy): + SERVICE_CLASS = Service + + sirk: gatt_client.CharacteristicProxy + set_size: Optional[gatt_client.CharacteristicProxy] = None + lock: Optional[gatt_client.CharacteristicProxy] = None + rank: Optional[gatt_client.CharacteristicProxy] = None + + def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None: + self.service_proxy = service_proxy + + self.sirk = 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.set_size = characteristics[0] + + if characteristics := service_proxy.get_characteristics_by_uuid( + gatt.GATT_SET_MEMBER_LOCK_CHARACTERISTIC + ): + self.lock = characteristics[0] + + if characteristics := service_proxy.get_characteristics_by_uuid( + gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC + ): + self.rank = characteristics[0] diff --git a/bumble/profiles/csis.py b/bumble/profiles/csis.py new file mode 100644 index 00000000..28f8b2ed --- /dev/null +++ b/bumble/profiles/csis.py @@ -0,0 +1,127 @@ +# 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 MemberLock(enum.IntEnum): + UNLOCKED = 0x01 + LOCKED = 0x02 + + +# ----------------------------------------------------------------------------- +# Server +# ----------------------------------------------------------------------------- +class Service(gatt.TemplateService): + UUID = gatt.GATT_COORDINATED_SET_IDENTIFICATION_SERVICE + + 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 = [] + + 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, + value=set_identity_resolving_key, + ) + characteristics.append(set_identity_resolving_key_characteristic) + + if coordinated_set_size is not None: + coordinated_set_size_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, + value=struct.pack('B', coordinated_set_size), + ) + characteristics.append(coordinated_set_size_characteristic) + + if set_member_lock is not None: + set_member_lock_characteristic = gatt.Characteristic( + uuid=gatt.GATT_SET_IDENTITY_RESOLVING_KEY_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(set_member_lock_characteristic) + + if set_member_rank is not None: + set_member_rank_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, + value=struct.pack('B', set_member_rank), + ) + characteristics.append(set_member_rank_characteristic) + + super().__init__(characteristics) + + +# ----------------------------------------------------------------------------- +# Client +# ----------------------------------------------------------------------------- +class ServiceProxy(gatt_client.ProfileServiceProxy): + SERVICE_CLASS = Service + + 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/bumble/profiles/pacs.py b/bumble/profiles/pacs.py index 26198ac8..387738fb 100644 --- a/bumble/profiles/pacs.py +++ b/bumble/profiles/pacs.py @@ -99,8 +99,8 @@ class ContextType(enum.IntFlag): EMERGENCY_ALARM = 0x0800 -class SampleFrequency(enum.IntEnum): - '''Bluetooth Assigned Numbers, Section 6.12.5.1 - Sample Frequency''' +class SamplingFrequency(enum.IntEnum): + '''Bluetooth Assigned Numbers, Section 6.12.5.1 - Sampling Frequency''' # fmt: off FREQ_8000 = 0x01 @@ -119,77 +119,77 @@ class SampleFrequency(enum.IntEnum): # fmt: on @classmethod - def from_hz(cls, frequency: int) -> SampleFrequency: + def from_hz(cls, frequency: int) -> SamplingFrequency: return { - 8000: SampleFrequency.FREQ_8000, - 11025: SampleFrequency.FREQ_11025, - 16000: SampleFrequency.FREQ_16000, - 22050: SampleFrequency.FREQ_22050, - 24000: SampleFrequency.FREQ_24000, - 32000: SampleFrequency.FREQ_32000, - 44100: SampleFrequency.FREQ_44100, - 48000: SampleFrequency.FREQ_48000, - 88200: SampleFrequency.FREQ_88200, - 96000: SampleFrequency.FREQ_96000, - 176400: SampleFrequency.FREQ_176400, - 192000: SampleFrequency.FREQ_192000, - 384000: SampleFrequency.FREQ_384000, + 8000: SamplingFrequency.FREQ_8000, + 11025: SamplingFrequency.FREQ_11025, + 16000: SamplingFrequency.FREQ_16000, + 22050: SamplingFrequency.FREQ_22050, + 24000: SamplingFrequency.FREQ_24000, + 32000: SamplingFrequency.FREQ_32000, + 44100: SamplingFrequency.FREQ_44100, + 48000: SamplingFrequency.FREQ_48000, + 88200: SamplingFrequency.FREQ_88200, + 96000: SamplingFrequency.FREQ_96000, + 176400: SamplingFrequency.FREQ_176400, + 192000: SamplingFrequency.FREQ_192000, + 384000: SamplingFrequency.FREQ_384000, }[frequency] @property def hz(self) -> int: return { - SampleFrequency.FREQ_8000: 8000, - SampleFrequency.FREQ_11025: 11025, - SampleFrequency.FREQ_16000: 16000, - SampleFrequency.FREQ_22050: 22050, - SampleFrequency.FREQ_24000: 24000, - SampleFrequency.FREQ_32000: 32000, - SampleFrequency.FREQ_44100: 44100, - SampleFrequency.FREQ_48000: 48000, - SampleFrequency.FREQ_88200: 88200, - SampleFrequency.FREQ_96000: 96000, - SampleFrequency.FREQ_176400: 176400, - SampleFrequency.FREQ_192000: 192000, - SampleFrequency.FREQ_384000: 384000, + SamplingFrequency.FREQ_8000: 8000, + SamplingFrequency.FREQ_11025: 11025, + SamplingFrequency.FREQ_16000: 16000, + SamplingFrequency.FREQ_22050: 22050, + SamplingFrequency.FREQ_24000: 24000, + SamplingFrequency.FREQ_32000: 32000, + SamplingFrequency.FREQ_44100: 44100, + SamplingFrequency.FREQ_48000: 48000, + SamplingFrequency.FREQ_88200: 88200, + SamplingFrequency.FREQ_96000: 96000, + SamplingFrequency.FREQ_176400: 176400, + SamplingFrequency.FREQ_192000: 192000, + SamplingFrequency.FREQ_384000: 384000, }[self] -class SupportedSampleFrequency(enum.IntFlag): +class SupportedSamplingFrequency(enum.IntFlag): '''Bluetooth Assigned Numbers, Section 6.12.4.1 - Sample Frequency''' # fmt: off - FREQ_8000 = 1 << (SampleFrequency.FREQ_8000 - 1) - FREQ_11025 = 1 << (SampleFrequency.FREQ_11025 - 1) - FREQ_16000 = 1 << (SampleFrequency.FREQ_16000 - 1) - FREQ_22050 = 1 << (SampleFrequency.FREQ_22050 - 1) - FREQ_24000 = 1 << (SampleFrequency.FREQ_24000 - 1) - FREQ_32000 = 1 << (SampleFrequency.FREQ_32000 - 1) - FREQ_44100 = 1 << (SampleFrequency.FREQ_44100 - 1) - FREQ_48000 = 1 << (SampleFrequency.FREQ_48000 - 1) - FREQ_88200 = 1 << (SampleFrequency.FREQ_88200 - 1) - FREQ_96000 = 1 << (SampleFrequency.FREQ_96000 - 1) - FREQ_176400 = 1 << (SampleFrequency.FREQ_176400 - 1) - FREQ_192000 = 1 << (SampleFrequency.FREQ_192000 - 1) - FREQ_384000 = 1 << (SampleFrequency.FREQ_384000 - 1) + FREQ_8000 = 1 << (SamplingFrequency.FREQ_8000 - 1) + FREQ_11025 = 1 << (SamplingFrequency.FREQ_11025 - 1) + FREQ_16000 = 1 << (SamplingFrequency.FREQ_16000 - 1) + FREQ_22050 = 1 << (SamplingFrequency.FREQ_22050 - 1) + FREQ_24000 = 1 << (SamplingFrequency.FREQ_24000 - 1) + FREQ_32000 = 1 << (SamplingFrequency.FREQ_32000 - 1) + FREQ_44100 = 1 << (SamplingFrequency.FREQ_44100 - 1) + FREQ_48000 = 1 << (SamplingFrequency.FREQ_48000 - 1) + FREQ_88200 = 1 << (SamplingFrequency.FREQ_88200 - 1) + FREQ_96000 = 1 << (SamplingFrequency.FREQ_96000 - 1) + FREQ_176400 = 1 << (SamplingFrequency.FREQ_176400 - 1) + FREQ_192000 = 1 << (SamplingFrequency.FREQ_192000 - 1) + FREQ_384000 = 1 << (SamplingFrequency.FREQ_384000 - 1) # fmt: on @classmethod - def from_hz(cls, frequencies: Sequence[int]) -> SupportedSampleFrequency: + def from_hz(cls, frequencies: Sequence[int]) -> SupportedSamplingFrequency: MAPPING = { - 8000: SupportedSampleFrequency.FREQ_8000, - 11025: SupportedSampleFrequency.FREQ_11025, - 16000: SupportedSampleFrequency.FREQ_16000, - 22050: SupportedSampleFrequency.FREQ_22050, - 24000: SupportedSampleFrequency.FREQ_24000, - 32000: SupportedSampleFrequency.FREQ_32000, - 44100: SupportedSampleFrequency.FREQ_44100, - 48000: SupportedSampleFrequency.FREQ_48000, - 88200: SupportedSampleFrequency.FREQ_88200, - 96000: SupportedSampleFrequency.FREQ_96000, - 176400: SupportedSampleFrequency.FREQ_176400, - 192000: SupportedSampleFrequency.FREQ_192000, - 384000: SupportedSampleFrequency.FREQ_384000, + 8000: SupportedSamplingFrequency.FREQ_8000, + 11025: SupportedSamplingFrequency.FREQ_11025, + 16000: SupportedSamplingFrequency.FREQ_16000, + 22050: SupportedSamplingFrequency.FREQ_22050, + 24000: SupportedSamplingFrequency.FREQ_24000, + 32000: SupportedSamplingFrequency.FREQ_32000, + 44100: SupportedSamplingFrequency.FREQ_44100, + 48000: SupportedSamplingFrequency.FREQ_48000, + 88200: SupportedSamplingFrequency.FREQ_88200, + 96000: SupportedSamplingFrequency.FREQ_96000, + 176400: SupportedSamplingFrequency.FREQ_176400, + 192000: SupportedSamplingFrequency.FREQ_192000, + 384000: SupportedSamplingFrequency.FREQ_384000, } return functools.reduce( @@ -250,13 +250,13 @@ class CodecSpecificCapabilities: class Type(enum.IntEnum): # fmt: off - SAMPLE_FREQUENCY = 0x01 + SAMPLING_FREQUENCY = 0x01 FRAME_DURATION = 0x02 AUDIO_CHANNEL_COUNT = 0x03 OCTETS_PER_SAMPLE = 0x04 CODEC_FRAMES_PER_SDU = 0x05 - supported_sample_frequencies: SupportedSampleFrequency + supported_sampling_frequencies: SupportedSamplingFrequency supported_frame_durations: SupportedFrameDuration supported_audio_channel_counts: Sequence[int] min_octets_per_sample: int @@ -272,8 +272,8 @@ def from_bytes(cls, data: bytes) -> CodecSpecificCapabilities: value = int.from_bytes(data[pos : pos + length - 1], 'little') pos += length - 1 - if type == CodecSpecificCapabilities.Type.SAMPLE_FREQUENCY: - supported_sample_frequencies = SupportedSampleFrequency(value) + if type == CodecSpecificCapabilities.Type.SAMPLING_FREQUENCY: + supported_sampling_frequencies = SupportedSamplingFrequency(value) elif type == CodecSpecificCapabilities.Type.FRAME_DURATION: supported_frame_durations = SupportedFrameDuration(value) elif type == CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT: @@ -286,7 +286,7 @@ def from_bytes(cls, data: bytes) -> CodecSpecificCapabilities: # It is expected here that if some fields are missing, an error should be raised. return CodecSpecificCapabilities( - supported_sample_frequencies=supported_sample_frequencies, + supported_sampling_frequencies=supported_sampling_frequencies, supported_frame_durations=supported_frame_durations, supported_audio_channel_counts=supported_audio_channel_counts, min_octets_per_sample=min_octets_per_sample, @@ -298,8 +298,8 @@ def __bytes__(self) -> bytes: return struct.pack( ' bytes: @dataclasses.dataclass -class PAC_Record: +class PacRecord: codec_id: bytes codec_specific_capabilities: CodecSpecificCapabilities metadata: bytes @classmethod - def from_bytes(cls, data: bytes) -> PAC_Record: + def from_bytes(cls, data: bytes) -> PacRecord: codec_id, size = struct.unpack_from('5sB', data) pos = 5 + 1 codec_specific_capabilities, size = struct.unpack_from(f'{size}sB', data, pos) pos += len(codec_specific_capabilities) + 1 (metadata,) = struct.unpack_from(f'{size}s', data, pos) - return PAC_Record( + return PacRecord( codec_id=codec_id, codec_specific_capabilities=CodecSpecificCapabilities.from_bytes( codec_specific_capabilities @@ -361,9 +361,9 @@ def __init__( supported_sink_context: ContextType, available_source_context: ContextType, available_sink_context: ContextType, - sink_pacs: Optional[Sequence[PAC_Record]] = None, + sink_pacs: Optional[Sequence[PacRecord]] = None, sink_audio_location: Optional[AudioLocation] = None, - source_pacs: Optional[Sequence[PAC_Record]] = None, + source_pacs: Optional[Sequence[PacRecord]] = None, source_audio_location: Optional[AudioLocation] = None, ): characteristics = [] diff --git a/tests/profiles/pacs_test.py b/tests/profiles/pacs_test.py index e04c514f..6265867f 100644 --- a/tests/profiles/pacs_test.py +++ b/tests/profiles/pacs_test.py @@ -20,11 +20,11 @@ # ----------------------------------------------------------------------------- def test_codec_specific_capabilities() -> None: - SAMPLE_FREQUENCY = pacs.SupportedSampleFrequency.FREQ_16000 + SAMPLE_FREQUENCY = pacs.SupportedSamplingFrequency.FREQ_16000 FRAME_SURATION = pacs.SupportedFrameDuration.DURATION_10000_US_SUPPORTED AUDIO_CHANNEL_COUNTS = [1] cap = pacs.CodecSpecificCapabilities( - supported_sample_frequencies=SAMPLE_FREQUENCY, + supported_sampling_frequencies=SAMPLE_FREQUENCY, supported_frame_durations=FRAME_SURATION, supported_audio_channel_counts=AUDIO_CHANNEL_COUNTS, min_octets_per_sample=40, @@ -36,11 +36,11 @@ def test_codec_specific_capabilities() -> None: # ----------------------------------------------------------------------------- def test_pac_record() -> None: - SAMPLE_FREQUENCY = pacs.SupportedSampleFrequency.FREQ_16000 + SAMPLE_FREQUENCY = pacs.SupportedSamplingFrequency.FREQ_16000 FRAME_SURATION = pacs.SupportedFrameDuration.DURATION_10000_US_SUPPORTED AUDIO_CHANNEL_COUNTS = [1] cap = pacs.CodecSpecificCapabilities( - supported_sample_frequencies=SAMPLE_FREQUENCY, + supported_sampling_frequencies=SAMPLE_FREQUENCY, supported_frame_durations=FRAME_SURATION, supported_audio_channel_counts=AUDIO_CHANNEL_COUNTS, min_octets_per_sample=40, @@ -48,10 +48,10 @@ def test_pac_record() -> None: supported_max_codec_frames_per_sdu=1, ) - pac_record = pacs.PAC_Record( + pac_record = pacs.PacRecord( codec_id=b'12345', codec_specific_capabilities=cap, metadata=b'' ) - assert pacs.PAC_Record.from_bytes(bytes(pac_record)) == pac_record + assert pacs.PacRecord.from_bytes(bytes(pac_record)) == pac_record # -----------------------------------------------------------------------------