From 3575f9030ee54c27541726b5135198f5417c1487 Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Thu, 30 Nov 2023 14:31:58 +0800 Subject: [PATCH 1/9] Add Audio Stream Control Service --- bumble/gatt_server.py | 2 +- bumble/hci.py | 13 + bumble/profiles/bap.py | 604 ++++++++++++++++++++++++++++++++- examples/leaudio.json | 1 + examples/run_unicast_server.py | 7 + 5 files changed, 625 insertions(+), 2 deletions(-) diff --git a/bumble/gatt_server.py b/bumble/gatt_server.py index cdf1b5e..eca11ce 100644 --- a/bumble/gatt_server.py +++ b/bumble/gatt_server.py @@ -961,7 +961,7 @@ class Server(EventEmitter): try: attribute.write_value(connection, request.attribute_value) except Exception as error: - logger.warning(f'!!! ignoring exception: {error}') + logger.exception(f'!!! ignoring exception: {error}') def on_att_handle_value_confirmation(self, connection, _confirmation): ''' diff --git a/bumble/hci.py b/bumble/hci.py index 936b681..8d5f9cd 100644 --- a/bumble/hci.py +++ b/bumble/hci.py @@ -728,6 +728,19 @@ HCI_LE_PHY_TYPE_TO_BIT = { HCI_LE_CODED_PHY: HCI_LE_CODED_PHY_BIT } + +class Phy(enum.IntEnum): + LE_1M = 0x01 + LE_2M = 0x02 + LE_CODED = 0x03 + + +class PhyBit(enum.IntFlag): + LE_1M = 0b00000001 + LE_2M = 0b00000010 + LE_CODED = 0b00000100 + + # Connection Parameters HCI_CONNECTION_INTERVAL_MS_PER_UNIT = 1.25 HCI_CONNECTION_LATENCY_MS_PER_UNIT = 1.25 diff --git a/bumble/profiles/bap.py b/bumble/profiles/bap.py index 76015d5..a1cae1b 100644 --- a/bumble/profiles/bap.py +++ b/bumble/profiles/bap.py @@ -23,8 +23,11 @@ import dataclasses import enum import struct import functools -from typing import Optional, List, Union +import logging +from typing import Optional, List, Union, Type, Dict, Any, Tuple, cast +from bumble import colors +from bumble import device from bumble import hci from bumble import gatt from bumble import gatt_client @@ -220,6 +223,231 @@ class SupportedFrameDuration(enum.IntFlag): DURATION_10000_US_PREFERRED = 0b0010 +# ----------------------------------------------------------------------------- +# ASE Operations +# ----------------------------------------------------------------------------- + + +class ASE_Operation: + ''' + See Audio Stream Control Service - 5 ASE Control operations. + ''' + + classes: Dict[int, Type[ASE_Operation]] = {} + op_code: int + name: str + fields: Optional[Sequence[Any]] = None + ase_id: List[int] + + class Opcode(enum.IntEnum): + # fmt: off + CONFIG_CODEC = 0x01 + CONFIG_QOS = 0x02 + ENABLE = 0x03 + RECEIVER_START_READY = 0x04 + DISABLE = 0x05 + RECEIVER_STOP_READY = 0x06 + UPDATE_METADATA = 0x07 + RELEASE = 0x08 + + @staticmethod + def from_bytes(pdu: bytes) -> ASE_Operation: + op_code = pdu[0] + + cls = ASE_Operation.classes.get(op_code) + if cls is None: + instance = ASE_Operation(pdu) + instance.name = ASE_Operation.Opcode(op_code).name + instance.op_code = op_code + return instance + self = cls.__new__(cls) + ASE_Operation.__init__(self, pdu) + if self.fields is not None: + self.init_from_bytes(pdu, 1) + return self + + @staticmethod + def subclass(fields): + def inner(cls: Type[ASE_Operation]): + try: + operation = ASE_Operation.Opcode[cls.__name__[4:].upper()] + cls.name = operation.name + cls.op_code = operation + except: + raise KeyError(f'PDU name {cls.name} not found in Ase_Operation.Opcode') + cls.fields = fields + + # Register a factory for this class + ASE_Operation.classes[cls.op_code] = cls + + return cls + + return inner + + def __init__(self, pdu: Optional[bytes] = None, **kwargs) -> None: + if self.fields is not None and kwargs: + hci.HCI_Object.init_from_fields(self, self.fields, kwargs) + if pdu is None: + pdu = bytes([self.op_code]) + hci.HCI_Object.dict_to_bytes( + kwargs, self.fields + ) + self.pdu = pdu + + def init_from_bytes(self, pdu: bytes, offset: int): + return hci.HCI_Object.init_from_bytes(self, pdu, offset, self.fields) + + def __bytes__(self) -> bytes: + return self.pdu + + def __str__(self) -> str: + result = f'{colors.color(self.name, "yellow")} ' + if fields := getattr(self, 'fields', None): + result += ':\n' + hci.HCI_Object.format_fields(self.__dict__, fields, ' ') + else: + if len(self.pdu) > 1: + result += f': {self.pdu.hex()}' + return result + + +@ASE_Operation.subclass( + [ + [ + ('ase_id', 1), + ('target_latency', 1), + ('target_phy', 1), + ('codec_id', hci.CodingFormat.parse_from_bytes), + ('codec_specific_configuration', 'v'), + ], + ] +) +class ASE_Config_Codec(ASE_Operation): + ''' + See Audio Stream Control Service 5.1 - Config Codec Operation + ''' + + target_latency: List[int] + target_phy: List[int] + codec_id: List[hci.CodingFormat] + codec_specific_configuration: List[bytes] + + +@ASE_Operation.subclass( + [ + [ + ('ase_id', 1), + ('cig_id', 1), + ('cis_id', 1), + ('sdu_interval', 3), + ('framing', 1), + ('phy', 1), + ('max_sdu', 2), + ('retransmission_number', 1), + ('max_transport_latency', 2), + ('presentation_delay', 3), + ], + ] +) +class ASE_Config_QOS(ASE_Operation): + ''' + See Audio Stream Control Service 5.2 - Config Qos Operation + ''' + + cig_id: List[int] + cis_id: List[int] + sdu_interval: List[int] + framing: List[int] + phy: List[int] + max_sdu: List[int] + retransmission_number: List[int] + max_transport_latency: List[int] + presentation_delay: List[int] + + +@ASE_Operation.subclass([[('ase_id', 1), ('metadata', 'v')]]) +class ASE_Enable(ASE_Operation): + ''' + See Audio Stream Control Service 5.3 - Enable Operation + ''' + + metadata: bytes + + +@ASE_Operation.subclass([[('ase_id', 1)]]) +class ASE_Receiver_Start_Ready(ASE_Operation): + ''' + See Audio Stream Control Service 5.4 - Receiver Start Ready Operation + ''' + + +@ASE_Operation.subclass([[('ase_id', 1)]]) +class ASE_Disable(ASE_Operation): + ''' + See Audio Stream Control Service 5.5 - Disable Operation + ''' + + +@ASE_Operation.subclass([[('ase_id', 1)]]) +class ASE_Receiver_Stop_Ready(ASE_Operation): + ''' + See Audio Stream Control Service 5.6 - Receiver Stop Ready Operation + ''' + + +@ASE_Operation.subclass([[('ase_id', 1), ('metadata', 'v')]]) +class ASE_Update_Metadata(ASE_Operation): + ''' + See Audio Stream Control Service 5.7 - Update Metadata Operation + ''' + + metadata: List[bytes] + + +@ASE_Operation.subclass([[('ase_id', 1)]]) +class ASE_Release(ASE_Operation): + ''' + See Audio Stream Control Service 5.8 - Release Operation + ''' + + +class AseResponseCode(enum.IntEnum): + # fmt: off + SUCCESS = 0x00 + UNSUPPORTED_OPCODE = 0x01 + INVALID_LENGTH = 0x02 + INVALID_ASE_ID = 0x03 + INVALID_ASE_STATE_MACHINE_TRANSITION = 0x04 + INVALID_ASE_DIRECTION = 0x05 + UNSUPPORTED_AUDIO_CAPABILITIES = 0x06 + UNSUPPORTED_CONFIGURATION_PARAMETER_VALUE = 0x07 + REJECTED_CONFIGURATION_PARAMETER_VALUE = 0x08 + INVALID_CONFIGURATION_PARAMETER_VALUE = 0x09 + UNSUPPORTED_METADATA = 0x0A + REJECTED_METADATA = 0x0B + INVALID_METADATA = 0x0C + INSUFFICIENT_RESOURCES = 0x0D + UNSPECIFIED_ERROR = 0x0E + + +class AseReasonCode(enum.IntEnum): + # fmt: off + NONE = 0x00 + CODEC_ID = 0x01 + CODEC_SPECIFIC_CONFIGURATION = 0x02 + SDU_INTERVAL = 0x03 + FRAMING = 0x04 + PHY = 0x05 + MAXIMUM_SDU_SIZE = 0x06 + RETRANSMISSION_NUMBER = 0x07 + MAX_TRANSPORT_LATENCY = 0x08 + PRESENTATION_DELAY = 0x09 + INVALID_ASE_CIS_MAPPING = 0x0A + + +class AudioRole(enum.Enum): + SINK = enum.auto() + SOURCE = enum.auto() + + # ----------------------------------------------------------------------------- # Utils # ----------------------------------------------------------------------------- @@ -452,6 +680,380 @@ class PublishedAudioCapabilitiesService(gatt.TemplateService): super().__init__(characteristics) +class AseStateMachine(gatt.Characteristic): + class State(enum.IntEnum): + # fmt: off + IDLE = 0x00 + CODEC_CONFIGURED = 0x01 + QOS_CONFIGURED = 0x02 + ENABLING = 0x03 + STREAMING = 0x04 + DISABLING = 0x05 + RELEASING = 0x06 + + # Additional parameters in CODEC_CONFIGURED State + preferred_framing = 0 # Unframed PDU supported + preferred_phy = 0 + preferred_retransmission_number = 13 + preferred_max_transport_latency = 100 + supported_presentation_delay_min = 0 + supported_presentation_delay_max = 0 + preferred_presentation_delay_min = 0 + preferred_presentation_delay_max = 0 + codec_id = hci.CodingFormat(hci.CodecID.LC3) + # TODO: Parse this + codec_specific_configuration = b'' + + # Additional parameters in QOS_CONFIGURED State + cig_id = 0 + cis_id = 0 + sdu_interval = 0 + framing = 0 + phy = 0 + max_sdu = 0 + retransmission_number = 0 + max_transport_latency = 0 + presentation_delay = 0 + + # Additional parameters in ENABLING, STREAMING, DISABLING State + # TODO: Parse this + metadata = b'' + + def __init__( + self, + role: AudioRole, + ase_id: int, + service: AudioStreamControlService, + ) -> None: + self.service = service + self.ase_id = ase_id + self.state = AseStateMachine.State.IDLE + self.role = role + + uuid = ( + gatt.GATT_SINK_ASE_CHARACTERISTIC + if role == AudioRole.SINK + else gatt.GATT_SOURCE_ASE_CHARACTERISTIC + ) + super().__init__( + uuid=uuid, + properties=gatt.Characteristic.Properties.READ + | gatt.Characteristic.Properties.NOTIFY, + permissions=gatt.Characteristic.Permissions.READABLE, + value=gatt.CharacteristicValue(read=self.on_read), + ) + + self.service.device.on('cis_request', self.on_cis_request) + self.service.device.on('cis_establishment', self.on_cis_establishment) + + def on_cis_request( + self, + acl_connection: device.Connection, + cis_handle: int, + cig_id: int, + cis_id: int, + ) -> None: + if cis_id == self.cis_id and self.state == self.State.ENABLING: + acl_connection.abort_on( + 'flush', self.service.device.accept_cis_request(cis_handle) + ) + + def on_cis_establishment(self, cis_link: device.CisLink) -> None: + if cis_link.cis_id == self.cis_id and self.state == self.State.ENABLING: + self.state = self.State.STREAMING + cis_link.acl_connection.abort_on( + 'flush', self.service.device.notify_subscribers(self, self.value) + ) + + def on_config_codec( + self, + target_latency: int, + target_phy: int, + codec_id: hci.CodingFormat, + codec_specific_configuration: bytes, + ) -> Tuple[AseResponseCode, AseReasonCode]: + if self.state not in ( + self.State.IDLE, + self.State.CODEC_CONFIGURED, + self.State.QOS_CONFIGURED, + ): + return ( + AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION, + AseReasonCode.NONE, + ) + + self.max_transport_latency = target_latency + self.phy = target_phy + self.codec_id = codec_id + self.codec_specific_configuration = codec_specific_configuration + + self.state = self.State.CODEC_CONFIGURED + + return (AseResponseCode.SUCCESS, AseReasonCode.NONE) + + def on_config_qos( + self, + cig_id: int, + cis_id: int, + sdu_interval: int, + framing: int, + phy: int, + max_sdu: int, + retransmission_number: int, + max_transport_latency: int, + presentation_delay: int, + ) -> Tuple[AseResponseCode, AseReasonCode]: + if self.state not in ( + AseStateMachine.State.CODEC_CONFIGURED, + AseStateMachine.State.QOS_CONFIGURED, + ): + return ( + AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION, + AseReasonCode.NONE, + ) + + self.cig_id = cig_id + self.cis_id = cis_id + self.sdu_interval = sdu_interval + self.framing = framing + self.phy = phy + self.max_sdu = max_sdu + self.retransmission_number = retransmission_number + self.max_transport_latency = max_transport_latency + self.presentation_delay = presentation_delay + + self.state = self.State.QOS_CONFIGURED + + return (AseResponseCode.SUCCESS, AseReasonCode.NONE) + + def on_enable(self, metadata: bytes) -> Tuple[AseResponseCode, AseReasonCode]: + if self.state != AseStateMachine.State.QOS_CONFIGURED: + return ( + AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION, + AseReasonCode.NONE, + ) + + self.metadata = metadata + self.state = self.State.ENABLING + + return (AseResponseCode.SUCCESS, AseReasonCode.NONE) + + def on_receiver_start_ready(self) -> Tuple[AseResponseCode, AseReasonCode]: + if self.state != AseStateMachine.State.ENABLING: + return ( + AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION, + AseReasonCode.NONE, + ) + self.state = self.State.STREAMING + return (AseResponseCode.SUCCESS, AseReasonCode.NONE) + + def on_disable(self) -> Tuple[AseResponseCode, AseReasonCode]: + if self.state not in ( + AseStateMachine.State.ENABLING, + AseStateMachine.State.STREAMING, + ): + return ( + AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION, + AseReasonCode.NONE, + ) + self.state = self.State.DISABLING + return (AseResponseCode.SUCCESS, AseReasonCode.NONE) + + def on_receiver_stop_ready(self) -> Tuple[AseResponseCode, AseReasonCode]: + if self.state != AseStateMachine.State.DISABLING: + return ( + AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION, + AseReasonCode.NONE, + ) + self.state = self.State.QOS_CONFIGURED + return (AseResponseCode.SUCCESS, AseReasonCode.NONE) + + def on_update_metadata( + self, metadata: bytes + ) -> Tuple[AseResponseCode, AseReasonCode]: + if self.state not in ( + AseStateMachine.State.ENABLING, + AseStateMachine.State.STREAMING, + ): + return ( + AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION, + AseReasonCode.NONE, + ) + self.metadata = metadata + return (AseResponseCode.SUCCESS, AseReasonCode.NONE) + + def on_release(self) -> Tuple[AseResponseCode, AseReasonCode]: + if self.state != AseStateMachine.State.IDLE: + return ( + AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION, + AseReasonCode.NONE, + ) + self.state = self.State.RELEASING + return (AseResponseCode.SUCCESS, AseReasonCode.NONE) + + @property + def value(self): + '''Returns ASE_ID, ASE_STATE, and ASE Additional Parameters.''' + + if self.state == self.State.CODEC_CONFIGURED: + additional_parameters = ( + struct.pack( + ' bytes: + return self.value + + +class AudioStreamControlService(gatt.TemplateService): + UUID = gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE + + ase_state_machines: Dict[int, AseStateMachine] + ase_control_point: gatt.Characteristic + + def __init__( + self, + device: device.Device, + source_ase_id: Sequence[int] = [], + sink_ase_id: Sequence[int] = [], + ) -> None: + self.device = device + self.ase_state_machines = { + id: AseStateMachine(role=AudioRole.SINK, ase_id=id, service=self) + for id in sink_ase_id + } | { + id: AseStateMachine(role=AudioRole.SOURCE, ase_id=id, service=self) + for id in source_ase_id + } # ASE state machines, by ASE ID + + for ase in self.ase_state_machines.values(): + print(ase.ase_id) + + self.ase_control_point = 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.WRITEABLE, + value=gatt.CharacteristicValue(write=self.on_write_ase_control_point), + ) + + super().__init__([self.ase_control_point, *self.ase_state_machines.values()]) + + def on_operation(self, opcode: ASE_Operation.Opcode, ase_id: int, args): + if ase := self.ase_state_machines.get(ase_id): + handler = getattr(ase, 'on_' + opcode.name.lower()) + return (ase_id, *handler(*args)) + else: + return (ase_id, AseResponseCode.INVALID_ASE_ID, AseReasonCode.NONE) + + def on_write_ase_control_point(self, connection, data): + operation = ASE_Operation.from_bytes(data) + responses = [] + logging.debug(f'*** ASCS Write {operation} ***') + + if operation.op_code == ASE_Operation.Opcode.CONFIG_CODEC: + for ase_id, *args in zip( + operation.ase_id, + operation.target_latency, + operation.target_phy, + operation.codec_id, + operation.codec_specific_configuration, + ): + responses.append(self.on_operation(operation.op_code, ase_id, args)) + elif operation.op_code == ASE_Operation.Opcode.CONFIG_QOS: + for ase_id, *args in zip( + operation.ase_id, + operation.cig_id, + operation.cis_id, + operation.sdu_interval, + operation.framing, + operation.phy, + operation.max_sdu, + operation.retransmission_number, + operation.max_transport_latency, + operation.presentation_delay, + ): + responses.append(self.on_operation(operation.op_code, ase_id, args)) + elif operation.op_code in ( + ASE_Operation.Opcode.ENABLE, + ASE_Operation.Opcode.UPDATE_METADATA, + ): + for ase_id, *args in zip( + operation.ase_id, + operation.metadata, + ): + responses.append(self.on_operation(operation.op_code, ase_id, args)) + elif operation.op_code in ( + ASE_Operation.Opcode.RECEIVER_START_READY, + ASE_Operation.Opcode.DISABLE, + ASE_Operation.Opcode.RECEIVER_STOP_READY, + ASE_Operation.Opcode.RELEASE, + ): + for ase_id in operation.ase_id: + responses.append(self.on_operation(operation.op_code, ase_id, [])) + + control_point_notification = bytes( + [operation.op_code, len(responses)] + ) + b''.join(map(bytes, responses)) + self.device.abort_on( + 'flush', + self.device.notify_subscribers( + self.ase_control_point, control_point_notification + ), + ) + + for ase_id, *_ in responses: + if ase := self.ase_state_machines.get(ase_id): + self.device.abort_on( + 'flush', + self.device.notify_subscribers(ase, ase.value), + ) + + # ----------------------------------------------------------------------------- # Client # ----------------------------------------------------------------------------- diff --git a/examples/leaudio.json b/examples/leaudio.json index 4b6edfc..c4c5a11 100644 --- a/examples/leaudio.json +++ b/examples/leaudio.json @@ -1,5 +1,6 @@ { "name": "Bumble-LEA", "keystore": "JsonKeyStore", + "address": "F0:F1:F2:F3:F4:FA", "advertising_interval": 100 } diff --git a/examples/run_unicast_server.py b/examples/run_unicast_server.py index 868b4f8..4dadec8 100644 --- a/examples/run_unicast_server.py +++ b/examples/run_unicast_server.py @@ -35,6 +35,7 @@ from bumble.profiles.bap import ( SupportedFrameDuration, PacRecord, PublishedAudioCapabilitiesService, + AudioStreamControlService, ) from bumble.transport import open_transport_or_link @@ -103,6 +104,8 @@ async def main() -> None: ) ) + device.add_service(AudioStreamControlService(device, sink_ase_id=[1, 2])) + advertising_data = bytes( AdvertisingData( [ @@ -110,6 +113,10 @@ async def main() -> None: AdvertisingData.COMPLETE_LOCAL_NAME, bytes('Bumble LE Audio', 'utf-8'), ), + ( + AdvertisingData.FLAGS, + bytes([AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG]), + ), ( AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, bytes(PublishedAudioCapabilitiesService.UUID), From af5776222767dcbf28eb5a1043c62f3babf9150d Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Sat, 2 Dec 2023 18:52:48 +0800 Subject: [PATCH 2/9] Parse CodecSpecificConfiguration --- bumble/profiles/bap.py | 91 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/bumble/profiles/bap.py b/bumble/profiles/bap.py index a1cae1b..ef94d54 100644 --- a/bumble/profiles/bap.py +++ b/bumble/profiles/bap.py @@ -553,6 +553,80 @@ class CodecSpecificCapabilities: ) +@dataclasses.dataclass +class CodecSpecificConfiguration: + '''See: + * Bluetooth Assigned Numbers, 6.12.5 - Codec Specific Configuration LTV Structures + * Basic Audio Profile, 4.3.2 - Codec_Specific_Capabilities LTV requirements + ''' + + class Type(enum.IntEnum): + # fmt: off + SAMPLING_FREQUENCY = 0x01 + FRAME_DURATION = 0x02 + AUDIO_CHANNEL_ALLOCATION = 0x03 + OCTETS_PER_FRAME = 0x04 + CODEC_FRAMES_PER_SDU = 0x05 + + sampling_frequency: SamplingFrequency + frame_duration: FrameDuration + audio_channel_allocation: AudioLocation + octets_per_codec_frame: int + codec_frames_per_sdu: int + + @classmethod + def from_bytes(cls, data: bytes) -> CodecSpecificConfiguration: + offset = 0 + # Allowed default values. + audio_channel_allocation = AudioLocation.NOT_ALLOWED + codec_frames_per_sdu = 1 + while offset < len(data): + length, type = struct.unpack_from('BB', data, offset) + offset += 2 + value = int.from_bytes(data[offset : offset + length - 1], 'little') + offset += length - 1 + + if type == CodecSpecificConfiguration.Type.SAMPLING_FREQUENCY: + sampling_frequency = SamplingFrequency(value) + elif type == CodecSpecificConfiguration.Type.FRAME_DURATION: + frame_duration = FrameDuration(value) + elif type == CodecSpecificConfiguration.Type.AUDIO_CHANNEL_ALLOCATION: + audio_channel_allocation = AudioLocation(value) + elif type == CodecSpecificConfiguration.Type.OCTETS_PER_FRAME: + octets_per_codec_frame = value + elif type == CodecSpecificConfiguration.Type.CODEC_FRAMES_PER_SDU: + codec_frames_per_sdu = value + + # It is expected here that if some fields are missing, an error should be raised. + return CodecSpecificConfiguration( + sampling_frequency=sampling_frequency, + frame_duration=frame_duration, + audio_channel_allocation=audio_channel_allocation, + octets_per_codec_frame=octets_per_codec_frame, + codec_frames_per_sdu=codec_frames_per_sdu, + ) + + def __bytes__(self) -> bytes: + return struct.pack( + ' Date: Sat, 2 Dec 2023 19:00:45 +0800 Subject: [PATCH 3/9] Setup data path after CIS established --- bumble/hci.py | 4 ++++ bumble/profiles/bap.py | 24 ++++++++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/bumble/hci.py b/bumble/hci.py index 8d5f9cd..36c049c 100644 --- a/bumble/hci.py +++ b/bumble/hci.py @@ -4554,6 +4554,10 @@ class HCI_LE_Setup_ISO_Data_Path_Command(HCI_Command): See Bluetooth spec @ 7.8.109 LE Setup ISO Data Path command ''' + class Direction(enum.IntEnum): + HOST_TO_CONTROLLER = 0x00 + CONTROLLER_TO_HOST = 0x01 + connection_handle: int data_path_direction: int data_path_id: int diff --git a/bumble/profiles/bap.py b/bumble/profiles/bap.py index ef94d54..aad299d 100644 --- a/bumble/profiles/bap.py +++ b/bumble/profiles/bap.py @@ -443,9 +443,9 @@ class AseReasonCode(enum.IntEnum): INVALID_ASE_CIS_MAPPING = 0x0A -class AudioRole(enum.Enum): - SINK = enum.auto() - SOURCE = enum.auto() +class AudioRole(enum.IntEnum): + SINK = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.CONTROLLER_TO_HOST + SOURCE = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.HOST_TO_CONTROLLER # ----------------------------------------------------------------------------- @@ -834,9 +834,21 @@ class AseStateMachine(gatt.Characteristic): def on_cis_establishment(self, cis_link: device.CisLink) -> None: if cis_link.cis_id == self.cis_id and self.state == self.State.ENABLING: self.state = self.State.STREAMING - cis_link.acl_connection.abort_on( - 'flush', self.service.device.notify_subscribers(self, self.value) - ) + + async def post_cis_established(): + await self.service.device.send_command( + hci.HCI_LE_Setup_ISO_Data_Path_Command( + connection_handle=cis_link.handle, + data_path_direction=self.role, + data_path_id=0x00, # Fixed HCI + codec_id=hci.CodingFormat(hci.CodecID.TRANSPARENT), + controller_delay=0, + codec_configuration=b'', + ) + ) + await self.service.device.notify_subscribers(self, self.value) + + cis_link.acl_connection.abort_on('flush', post_cis_established()) def on_config_codec( self, From 4d6822d31258d0af7386a83b11a8e06976915986 Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Wed, 6 Dec 2023 16:33:55 +0800 Subject: [PATCH 4/9] Remove ISO data path on release --- bumble/profiles/bap.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/bumble/profiles/bap.py b/bumble/profiles/bap.py index aad299d..bc06dae 100644 --- a/bumble/profiles/bap.py +++ b/bumble/profiles/bap.py @@ -765,6 +765,8 @@ class AseStateMachine(gatt.Characteristic): DISABLING = 0x05 RELEASING = 0x06 + cis_link: Optional[device.CisLink] = None + # Additional parameters in CODEC_CONFIGURED State preferred_framing = 0 # Unframed PDU supported preferred_phy = 0 @@ -834,6 +836,7 @@ class AseStateMachine(gatt.Characteristic): def on_cis_establishment(self, cis_link: device.CisLink) -> None: if cis_link.cis_id == self.cis_id and self.state == self.State.ENABLING: self.state = self.State.STREAMING + self.cis_link = cis_link async def post_cis_established(): await self.service.device.send_command( @@ -979,6 +982,18 @@ class AseStateMachine(gatt.Characteristic): AseReasonCode.NONE, ) self.state = self.State.RELEASING + + async def remove_cis_async(): + await self.service.device.send_command( + hci.HCI_LE_Remove_ISO_Data_Path_Command( + connection_handle=self.cis_link.handle, + data_path_direction=self.role, + ) + ) + self.state = self.State.CODEC_CONFIGURED + await self.service.device.notify_subscribers(self, self.value) + + self.service.device.abort_on('flush', remove_cis_async()) return (AseResponseCode.SUCCESS, AseReasonCode.NONE) @property From 55596176c2fef1fbc3b0559c35580ca99c072cea Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Wed, 6 Dec 2023 16:25:41 +0800 Subject: [PATCH 5/9] ffplay routing --- examples/run_unicast_server.py | 47 ++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/examples/run_unicast_server.py b/examples/run_unicast_server.py index 4dadec8..e71cbef 100644 --- a/examples/run_unicast_server.py +++ b/examples/run_unicast_server.py @@ -19,12 +19,14 @@ import asyncio import logging import sys import os +import struct from bumble.core import AdvertisingData -from bumble.device import Device +from bumble.device import Device, CisLink from bumble.hci import ( CodecID, CodingFormat, OwnAddressType, + HCI_IsoDataPacket, HCI_LE_Set_Extended_Advertising_Parameters_Command, ) from bumble.profiles.bap import ( @@ -115,7 +117,13 @@ async def main() -> None: ), ( AdvertisingData.FLAGS, - bytes([AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG]), + bytes( + [ + AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG + | AdvertisingData.BR_EDR_HOST_FLAG + | AdvertisingData.BR_EDR_CONTROLLER_FLAG + ] + ), ), ( AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, @@ -124,6 +132,41 @@ async def main() -> None: ] ) ) + subprocess = await asyncio.create_subprocess_shell( + f'dlc3 | ffplay pipe:0', + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + stdin = subprocess.stdin + assert stdin + + # Write a fake LC3 header to dlc3. + stdin.write( + bytes([0x1C, 0xCC]) # Header. + + struct.pack( + ' Date: Thu, 7 Dec 2023 19:25:50 +0800 Subject: [PATCH 6/9] Fix ASE state change --- bumble/profiles/bap.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/bumble/profiles/bap.py b/bumble/profiles/bap.py index bc06dae..d06dac5 100644 --- a/bumble/profiles/bap.py +++ b/bumble/profiles/bap.py @@ -33,6 +33,11 @@ from bumble import gatt from bumble import gatt_client +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- +logger = logging.getLogger(__name__) + # ----------------------------------------------------------------------------- # Constants # ----------------------------------------------------------------------------- @@ -976,7 +981,7 @@ class AseStateMachine(gatt.Characteristic): return (AseResponseCode.SUCCESS, AseReasonCode.NONE) def on_release(self) -> Tuple[AseResponseCode, AseReasonCode]: - if self.state != AseStateMachine.State.IDLE: + if self.state == AseStateMachine.State.IDLE: return ( AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION, AseReasonCode.NONE, @@ -990,7 +995,7 @@ class AseStateMachine(gatt.Characteristic): data_path_direction=self.role, ) ) - self.state = self.State.CODEC_CONFIGURED + self.state = self.State.IDLE await self.service.device.notify_subscribers(self, self.value) self.service.device.abort_on('flush', remove_cis_async()) @@ -1101,7 +1106,7 @@ class AudioStreamControlService(gatt.TemplateService): def on_write_ase_control_point(self, connection, data): operation = ASE_Operation.from_bytes(data) responses = [] - logging.debug(f'*** ASCS Write {operation} ***') + logger.debug(f'*** ASCS Write {operation} ***') if operation.op_code == ASE_Operation.Opcode.CONFIG_CODEC: for ase_id, *args in zip( From dd090c9e6be690d2c00402e1c927d374181d99fb Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Fri, 8 Dec 2023 11:00:44 +0800 Subject: [PATCH 7/9] Add ASCS tests --- bumble/controller.py | 12 ++ bumble/profiles/bap.py | 21 ++++ tests/bap_test.py | 251 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 284 insertions(+) diff --git a/bumble/controller.py b/bumble/controller.py index d035bcc..4ead098 100644 --- a/bumble/controller.py +++ b/bumble/controller.py @@ -1263,3 +1263,15 @@ class Controller: See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command ''' return struct.pack(' None: SAMPLE_FREQUENCY = SupportedSamplingFrequency.FREQ_16000 @@ -85,6 +108,92 @@ def test_vendor_specific_pac_record() -> None: assert bytes(PacRecord.from_bytes(RAW_DATA)) == RAW_DATA +# ----------------------------------------------------------------------------- +def test_ASE_Config_Codec() -> None: + operation = ASE_Config_Codec( + ase_id=[1, 2], + target_latency=[3, 4], + target_phy=[5, 6], + codec_id=[CodingFormat(CodecID.LC3), CodingFormat(CodecID.LC3)], + codec_specific_configuration=[b'foo', b'bar'], + ) + basic_check(operation) + + +# ----------------------------------------------------------------------------- +def test_ASE_Config_QOS() -> None: + operation = ASE_Config_QOS( + ase_id=[1, 2], + cig_id=[1, 2], + cis_id=[3, 4], + sdu_interval=[5, 6], + framing=[0, 1], + phy=[2, 3], + max_sdu=[4, 5], + retransmission_number=[6, 7], + max_transport_latency=[8, 9], + presentation_delay=[10, 11], + ) + basic_check(operation) + + +# ----------------------------------------------------------------------------- +def test_ASE_Enable() -> None: + operation = ASE_Enable( + ase_id=[1, 2], + metadata=[b'foo', b'bar'], + ) + basic_check(operation) + + +# ----------------------------------------------------------------------------- +def test_ASE_Update_Metadata() -> None: + operation = ASE_Update_Metadata( + ase_id=[1, 2], + metadata=[b'foo', b'bar'], + ) + basic_check(operation) + + +# ----------------------------------------------------------------------------- +def test_ASE_Disable() -> None: + operation = ASE_Disable(ase_id=[1, 2]) + basic_check(operation) + + +# ----------------------------------------------------------------------------- +def test_ASE_Release() -> None: + operation = ASE_Release(ase_id=[1, 2]) + basic_check(operation) + + +# ----------------------------------------------------------------------------- +def test_ASE_Receiver_Start_Ready() -> None: + operation = ASE_Receiver_Start_Ready(ase_id=[1, 2]) + basic_check(operation) + + +# ----------------------------------------------------------------------------- +def test_ASE_Receiver_Stop_Ready() -> None: + operation = ASE_Receiver_Stop_Ready(ase_id=[1, 2]) + basic_check(operation) + + +# ----------------------------------------------------------------------------- +def test_codec_specific_configuration() -> None: + SAMPLE_FREQUENCY = SamplingFrequency.FREQ_16000 + FRAME_SURATION = FrameDuration.DURATION_10000_US + AUDIO_LOCATION = AudioLocation.FRONT_LEFT + config = CodecSpecificConfiguration( + sampling_frequency=SAMPLE_FREQUENCY, + frame_duration=FRAME_SURATION, + audio_channel_allocation=AUDIO_LOCATION, + octets_per_codec_frame=60, + codec_frames_per_sdu=1, + ) + assert CodecSpecificConfiguration.from_bytes(bytes(config)) == config + + # ----------------------------------------------------------------------------- @pytest.mark.asyncio async def test_pacs(): @@ -140,6 +249,148 @@ async def test_pacs(): ) +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_ascs(): + devices = TwoDevices() + devices[0].add_service( + AudioStreamControlService(device=devices[0], sink_ase_id=[1, 2]) + ) + + await devices.setup_connection() + peer = device.Peer(devices.connections[1]) + ascs_client = await peer.discover_service_and_create_proxy( + AudioStreamControlServiceProxy + ) + + notifications = {1: asyncio.Queue(), 2: asyncio.Queue()} + + def on_notification(data: bytes, ase_id: int): + notifications[ase_id].put_nowait(data) + + # Should be idle + assert await ascs_client.sink_ase[0].read_value() == bytes( + [1, AseStateMachine.State.IDLE] + ) + assert await ascs_client.sink_ase[1].read_value() == bytes( + [2, AseStateMachine.State.IDLE] + ) + + # Subscribe + await ascs_client.sink_ase[0].subscribe( + functools.partial(on_notification, ase_id=1) + ) + await ascs_client.sink_ase[1].subscribe( + functools.partial(on_notification, ase_id=2) + ) + + # Config Codec + config = CodecSpecificConfiguration( + sampling_frequency=SamplingFrequency.FREQ_48000, + frame_duration=FrameDuration.DURATION_10000_US, + audio_channel_allocation=AudioLocation.FRONT_LEFT, + octets_per_codec_frame=120, + codec_frames_per_sdu=1, + ) + await ascs_client.ase_control_point.write_value( + ASE_Config_Codec( + ase_id=[1, 2], + target_latency=[3, 4], + target_phy=[5, 6], + codec_id=[CodingFormat(CodecID.LC3), CodingFormat(CodecID.LC3)], + codec_specific_configuration=[config, config], + ) + ) + assert (await notifications[1].get())[:2] == bytes( + [1, AseStateMachine.State.CODEC_CONFIGURED] + ) + assert (await notifications[2].get())[:2] == bytes( + [2, AseStateMachine.State.CODEC_CONFIGURED] + ) + + # Config QOS + await ascs_client.ase_control_point.write_value( + ASE_Config_QOS( + ase_id=[1, 2], + cig_id=[1, 2], + cis_id=[3, 4], + sdu_interval=[5, 6], + framing=[0, 1], + phy=[2, 3], + max_sdu=[4, 5], + retransmission_number=[6, 7], + max_transport_latency=[8, 9], + presentation_delay=[10, 11], + ) + ) + assert (await notifications[1].get())[:2] == bytes( + [1, AseStateMachine.State.QOS_CONFIGURED] + ) + assert (await notifications[2].get())[:2] == bytes( + [2, AseStateMachine.State.QOS_CONFIGURED] + ) + + # Enable + await ascs_client.ase_control_point.write_value( + ASE_Enable( + ase_id=[1, 2], + metadata=[b'foo', b'bar'], + ) + ) + assert (await notifications[1].get())[:2] == bytes( + [1, AseStateMachine.State.ENABLING] + ) + assert (await notifications[2].get())[:2] == bytes( + [2, AseStateMachine.State.ENABLING] + ) + + # CIS establishment + devices[0].emit( + 'cis_establishment', + device.CisLink( + device=devices[0], + acl_connection=devices.connections[0], + handle=5, + cis_id=3, + cig_id=1, + ), + ) + devices[0].emit( + 'cis_establishment', + device.CisLink( + device=devices[0], + acl_connection=devices.connections[0], + handle=6, + cis_id=4, + cig_id=2, + ), + ) + assert (await notifications[1].get())[:2] == bytes( + [1, AseStateMachine.State.STREAMING] + ) + assert (await notifications[2].get())[:2] == bytes( + [2, AseStateMachine.State.STREAMING] + ) + + # Release + await ascs_client.ase_control_point.write_value( + ASE_Release( + ase_id=[1, 2], + metadata=[b'foo', b'bar'], + ) + ) + assert (await notifications[1].get())[:2] == bytes( + [1, AseStateMachine.State.RELEASING] + ) + assert (await notifications[2].get())[:2] == bytes( + [2, AseStateMachine.State.RELEASING] + ) + assert (await notifications[1].get())[:2] == bytes([1, AseStateMachine.State.IDLE]) + assert (await notifications[2].get())[:2] == bytes([2, AseStateMachine.State.IDLE]) + + await asyncio.sleep(0.001) + + # ----------------------------------------------------------------------------- async def run(): await test_pacs() From 81a6b1e097f238cdab733d1d36e868345ef0b9f7 Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Fri, 8 Dec 2023 11:10:17 +0800 Subject: [PATCH 8/9] Replace 3.9 dict merger --- bumble/profiles/bap.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bumble/profiles/bap.py b/bumble/profiles/bap.py index 63048f7..fcaf3f6 100644 --- a/bumble/profiles/bap.py +++ b/bumble/profiles/bap.py @@ -1075,11 +1075,14 @@ class AudioStreamControlService(gatt.TemplateService): ) -> None: self.device = device self.ase_state_machines = { - id: AseStateMachine(role=AudioRole.SINK, ase_id=id, service=self) - for id in sink_ase_id - } | { - id: AseStateMachine(role=AudioRole.SOURCE, ase_id=id, service=self) - for id in source_ase_id + **{ + id: AseStateMachine(role=AudioRole.SINK, ase_id=id, service=self) + for id in sink_ase_id + }, + **{ + id: AseStateMachine(role=AudioRole.SOURCE, ase_id=id, service=self) + for id in source_ase_id + }, } # ASE state machines, by ASE ID for ase in self.ase_state_machines.values(): From f911163e49512e79d91da391e406050f555e7457 Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Tue, 12 Dec 2023 00:36:24 +0800 Subject: [PATCH 9/9] Improve ASCS logging --- bumble/profiles/bap.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/bumble/profiles/bap.py b/bumble/profiles/bap.py index fcaf3f6..4785997 100644 --- a/bumble/profiles/bap.py +++ b/bumble/profiles/bap.py @@ -807,7 +807,7 @@ class AseStateMachine(gatt.Characteristic): ) -> None: self.service = service self.ase_id = ase_id - self.state = AseStateMachine.State.IDLE + self._state = AseStateMachine.State.IDLE self.role = role uuid = ( @@ -1001,6 +1001,15 @@ class AseStateMachine(gatt.Characteristic): self.service.device.abort_on('flush', remove_cis_async()) return (AseResponseCode.SUCCESS, AseReasonCode.NONE) + @property + def state(self) -> State: + return self._state + + @state.setter + def state(self, new_state: State) -> None: + logger.debug(f'{self} state change -> {colors.color(new_state.name, "cyan")}') + self._state = new_state + @property def value(self): '''Returns ASE_ID, ASE_STATE, and ASE Additional Parameters.''' @@ -1060,6 +1069,12 @@ class AseStateMachine(gatt.Characteristic): def on_read(self, _: device.Connection) -> bytes: return self.value + def __str__(self) -> str: + return ( + f'AseStateMachine(id={self.ase_id}, role={self.role.name} ' + f'state={self._state.name})' + ) + class AudioStreamControlService(gatt.TemplateService): UUID = gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE @@ -1085,9 +1100,6 @@ class AudioStreamControlService(gatt.TemplateService): }, } # ASE state machines, by ASE ID - for ase in self.ase_state_machines.values(): - print(ase.ase_id) - self.ase_control_point = gatt.Characteristic( uuid=gatt.GATT_ASE_CONTROL_POINT_CHARACTERISTIC, properties=gatt.Characteristic.Properties.WRITE