From 04311b4c90aa4c8de646844196738362fd936fcc Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Sat, 17 Aug 2024 00:46:04 +0800 Subject: [PATCH] Refactor ASHA service and integrate with examples --- bumble/decoder.py | 24 +-- bumble/profiles/asha.py | 295 ++++++++++++++++++++++++++++++++ bumble/profiles/asha_service.py | 193 --------------------- examples/asha_sink.html | 95 ++++++++++ examples/asha_sink1.json | 3 +- examples/asha_sink2.json | 3 +- examples/run_asha_sink.py | 222 ++++++++---------------- tests/asha_test.py | 163 ++++++++++++++++++ 8 files changed, 638 insertions(+), 360 deletions(-) create mode 100644 bumble/profiles/asha.py delete mode 100644 bumble/profiles/asha_service.py create mode 100644 examples/asha_sink.html create mode 100644 tests/asha_test.py diff --git a/bumble/decoder.py b/bumble/decoder.py index 2eb70bc..83a23b1 100644 --- a/bumble/decoder.py +++ b/bumble/decoder.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Union + # ----------------------------------------------------------------------------- # Constants # ----------------------------------------------------------------------------- @@ -149,7 +151,7 @@ QMF_COEFFS = [3, -11, 12, 32, -210, 951, 3876, -805, 362, -156, 53, -11] # ----------------------------------------------------------------------------- # Classes # ----------------------------------------------------------------------------- -class G722Decoder(object): +class G722Decoder: """G.722 decoder with bitrate 64kbit/s. For the Blocks in the sub-band decoders, please refer to the G.722 @@ -157,7 +159,7 @@ class G722Decoder(object): https://www.itu.int/rec/T-REC-G.722-201209-I """ - def __init__(self): + def __init__(self) -> None: self._x = [0] * 24 self._band = [Band(), Band()] # The initial value in BLOCK 3L @@ -165,12 +167,12 @@ class G722Decoder(object): # The initial value in BLOCK 3H self._band[1].det = 8 - def decode_frame(self, encoded_data) -> bytearray: + def decode_frame(self, encoded_data: Union[bytes, bytearray]) -> bytearray: result_array = bytearray(len(encoded_data) * 4) self.g722_decode(result_array, encoded_data) return result_array - def g722_decode(self, result_array, encoded_data) -> int: + def g722_decode(self, result_array, encoded_data: Union[bytes, bytearray]) -> int: """Decode the data frame using g722 decoder.""" result_length = 0 @@ -198,14 +200,16 @@ class G722Decoder(object): return result_length - def update_decoded_result(self, xout, byte_length, byte_array) -> int: + def update_decoded_result( + self, xout: int, byte_length: int, byte_array: bytearray + ) -> int: result = (int)(xout >> 11) bytes_result = result.to_bytes(2, 'little', signed=True) byte_array[byte_length] = bytes_result[0] byte_array[byte_length + 1] = bytes_result[1] return byte_length + 2 - def lower_sub_band_decoder(self, lower_bits) -> int: + def lower_sub_band_decoder(self, lower_bits: int) -> int: """Lower sub-band decoder for last six bits.""" # Block 5L @@ -258,7 +262,7 @@ class G722Decoder(object): return rlow - def higher_sub_band_decoder(self, higher_bits) -> int: + def higher_sub_band_decoder(self, higher_bits: int) -> int: """Higher sub-band decoder for first two bits.""" # Block 2H @@ -306,14 +310,14 @@ class G722Decoder(object): # ----------------------------------------------------------------------------- -class Band(object): - """Structure for G722 decode proccessing.""" +class Band: + """Structure for G722 decode processing.""" s: int = 0 nb: int = 0 det: int = 0 - def __init__(self): + def __init__(self) -> None: self._sp = 0 self._sz = 0 self._r = [0] * 3 diff --git a/bumble/profiles/asha.py b/bumble/profiles/asha.py new file mode 100644 index 0000000..b2aa441 --- /dev/null +++ b/bumble/profiles/asha.py @@ -0,0 +1,295 @@ +# Copyright 2021-2022 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 enum +import struct +import logging +from typing import List, Optional, Callable, Union, Any + +from bumble import l2cap +from bumble import utils +from bumble import gatt +from bumble import gatt_client +from bumble.core import AdvertisingData +from bumble.device import Device, Connection + +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- +_logger = logging.getLogger(__name__) + + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- +class DeviceCapabilities(enum.IntFlag): + IS_RIGHT = 0x01 + IS_DUAL = 0x02 + CSIS_SUPPORTED = 0x04 + + +class FeatureMap(enum.IntFlag): + LE_COC_AUDIO_OUTPUT_STREAMING_SUPPORTED = 0x01 + + +class AudioType(utils.OpenIntEnum): + UNKNOWN = 0x00 + RINGTONE = 0x01 + PHONE_CALL = 0x02 + MEDIA = 0x03 + + +class OpCode(utils.OpenIntEnum): + START = 1 + STOP = 2 + STATUS = 3 + + +class Codec(utils.OpenIntEnum): + G_722_16KHZ = 1 + + +class SupportedCodecs(enum.IntFlag): + G_722_16KHZ = 1 << Codec.G_722_16KHZ + + +class PeripheralStatus(utils.OpenIntEnum): + """Status update on the other peripheral.""" + + OTHER_PERIPHERAL_DISCONNECTED = 1 + OTHER_PERIPHERAL_CONNECTED = 2 + CONNECTION_PARAMETER_UPDATED = 3 + + +class AudioStatus(utils.OpenIntEnum): + """Status report field for the audio control point.""" + + OK = 0 + UNKNOWN_COMMAND = -1 + ILLEGAL_PARAMETERS = -2 + + +# ----------------------------------------------------------------------------- +class AshaService(gatt.TemplateService): + UUID = gatt.GATT_ASHA_SERVICE + + audio_sink: Optional[Callable[[bytes], Any]] + active_codec: Optional[Codec] = None + audio_type: Optional[AudioType] = None + volume: Optional[int] = None + other_state: Optional[int] = None + connection: Optional[Connection] = None + + def __init__( + self, + capability: int, + hisyncid: Union[List[int], bytes], + device: Device, + psm: int = 0, + audio_sink: Optional[Callable[[bytes], Any]] = None, + feature_map: int = FeatureMap.LE_COC_AUDIO_OUTPUT_STREAMING_SUPPORTED, + protocol_version: int = 0x01, + render_delay_milliseconds: int = 0, + supported_codecs: int = SupportedCodecs.G_722_16KHZ, + ) -> None: + if len(hisyncid) != 8: + _logger.warning('HiSyncId should have a length of 8, got %d', len(hisyncid)) + + self.hisyncid = bytes(hisyncid) + self.capability = capability + self.device = device + self.audio_out_data = b'' + self.psm = psm # a non-zero psm is mainly for testing purpose + self.audio_sink = audio_sink + self.protocol_version = protocol_version + + self.read_only_properties_characteristic = gatt.Characteristic( + gatt.GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, + gatt.Characteristic.Properties.READ, + gatt.Characteristic.READABLE, + struct.pack( + " bytes: + # Advertisement only uses 4 least significant bytes of the HiSyncId. + return bytes( + AdvertisingData( + [ + ( + AdvertisingData.SERVICE_DATA_16_BIT_UUID, + bytes(gatt.GATT_ASHA_SERVICE) + + bytes([self.protocol_version, self.capability]) + + self.hisyncid[:4], + ), + ] + ) + ) + + # Handler for audio control commands + async def _on_audio_control_point_write( + self, connection: Optional[Connection], value: bytes + ) -> None: + _logger.debug(f'--- AUDIO CONTROL POINT Write:{value.hex()}') + opcode = value[0] + if opcode == OpCode.START: + # Start + self.active_codec = Codec(value[1]) + self.audio_type = AudioType(value[2]) + self.volume = value[3] + self.other_state = value[4] + _logger.debug( + f'### START: codec={self.active_codec.name}, ' + f'audio_type={self.audio_type.name}, ' + f'volume={self.volume}, ' + f'other_state={self.other_state}' + ) + self.emit('started') + elif opcode == OpCode.STOP: + _logger.debug('### STOP') + self.active_codec = None + self.audio_type = None + self.volume = None + self.other_state = None + self.emit('stopped') + elif opcode == OpCode.STATUS: + _logger.debug('### STATUS: %s', PeripheralStatus(value[1]).name) + + if self.connection is None and connection: + self.connection = connection + + def on_disconnection(_reason) -> None: + self.connection = None + self.active_codec = None + self.audio_type = None + self.volume = None + self.other_state = None + self.emit('disconnected') + + connection.once('disconnection', on_disconnection) + + # OPCODE_STATUS does not need audio status point update + if opcode != OpCode.STATUS: + await self.device.notify_subscribers( + self.audio_status_characteristic, force=True + ) + + # Handler for volume control + def _on_volume_write(self, connection: Optional[Connection], value: bytes) -> None: + _logger.debug(f'--- VOLUME Write:{value[0]}') + self.volume = value[0] + self.emit('volume_changed') + + # Register an L2CAP CoC server + def _on_connection(self, channel: l2cap.LeCreditBasedChannel) -> None: + def on_data(data: bytes) -> None: + if self.audio_sink: # pylint: disable=not-callable + self.audio_sink(data) + + channel.sink = on_data + + +# ----------------------------------------------------------------------------- +class AshaServiceProxy(gatt_client.ProfileServiceProxy): + SERVICE_CLASS = AshaService + read_only_properties_characteristic: gatt_client.CharacteristicProxy + audio_control_point_characteristic: gatt_client.CharacteristicProxy + audio_status_point_characteristic: gatt_client.CharacteristicProxy + volume_characteristic: gatt_client.CharacteristicProxy + psm_characteristic: gatt_client.CharacteristicProxy + + def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None: + self.service_proxy = service_proxy + + for uuid, attribute_name in ( + ( + gatt.GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, + 'read_only_properties_characteristic', + ), + ( + gatt.GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC, + 'audio_control_point_characteristic', + ), + ( + gatt.GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC, + 'audio_status_point_characteristic', + ), + ( + gatt.GATT_ASHA_VOLUME_CHARACTERISTIC, + 'volume_characteristic', + ), + ( + gatt.GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC, + 'psm_characteristic', + ), + ): + if not ( + characteristics := self.service_proxy.get_characteristics_by_uuid(uuid) + ): + raise gatt.InvalidServiceError(f"Missing {uuid} Characteristic") + setattr(self, attribute_name, characteristics[0]) diff --git a/bumble/profiles/asha_service.py b/bumble/profiles/asha_service.py deleted file mode 100644 index acbc47e..0000000 --- a/bumble/profiles/asha_service.py +++ /dev/null @@ -1,193 +0,0 @@ -# Copyright 2021-2022 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 struct -import logging -from typing import List, Optional - -from bumble import l2cap -from ..core import AdvertisingData -from ..device import Device, Connection -from ..gatt import ( - GATT_ASHA_SERVICE, - GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, - GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC, - GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC, - GATT_ASHA_VOLUME_CHARACTERISTIC, - GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC, - TemplateService, - Characteristic, - CharacteristicValue, -) -from ..utils import AsyncRunner - -# ----------------------------------------------------------------------------- -# Logging -# ----------------------------------------------------------------------------- -logger = logging.getLogger(__name__) - - -# ----------------------------------------------------------------------------- -class AshaService(TemplateService): - UUID = GATT_ASHA_SERVICE - OPCODE_START = 1 - OPCODE_STOP = 2 - OPCODE_STATUS = 3 - PROTOCOL_VERSION = 0x01 - RESERVED_FOR_FUTURE_USE = [00, 00] - FEATURE_MAP = [0x01] # [LE CoC audio output streaming supported] - SUPPORTED_CODEC_ID = [0x02, 0x01] # Codec IDs [G.722 at 16 kHz] - RENDER_DELAY = [00, 00] - - def __init__(self, capability: int, hisyncid: List[int], device: Device, psm=0): - self.hisyncid = hisyncid - self.capability = capability # Device Capabilities [Left, Monaural] - self.device = device - self.audio_out_data = b'' - self.psm = psm # a non-zero psm is mainly for testing purpose - - # Handler for volume control - def on_volume_write(connection, value): - logger.info(f'--- VOLUME Write:{value[0]}') - self.emit('volume', connection, value[0]) - - # Handler for audio control commands - def on_audio_control_point_write(connection: Optional[Connection], value): - logger.info(f'--- AUDIO CONTROL POINT Write:{value.hex()}') - opcode = value[0] - if opcode == AshaService.OPCODE_START: - # Start - audio_type = ('Unknown', 'Ringtone', 'Phone Call', 'Media')[value[2]] - logger.info( - f'### START: codec={value[1]}, ' - f'audio_type={audio_type}, ' - f'volume={value[3]}, ' - f'otherstate={value[4]}' - ) - self.emit( - 'start', - connection, - { - 'codec': value[1], - 'audiotype': value[2], - 'volume': value[3], - 'otherstate': value[4], - }, - ) - elif opcode == AshaService.OPCODE_STOP: - logger.info('### STOP') - self.emit('stop', connection) - elif opcode == AshaService.OPCODE_STATUS: - logger.info(f'### STATUS: connected={value[1]}') - - # OPCODE_STATUS does not need audio status point update - if opcode != AshaService.OPCODE_STATUS: - AsyncRunner.spawn( - device.notify_subscribers( - self.audio_status_characteristic, force=True - ) - ) - - self.read_only_properties_characteristic = Characteristic( - GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, - Characteristic.Properties.READ, - Characteristic.READABLE, - bytes( - [ - AshaService.PROTOCOL_VERSION, # Version - self.capability, - ] - ) - + bytes(self.hisyncid) - + bytes(AshaService.FEATURE_MAP) - + bytes(AshaService.RENDER_DELAY) - + bytes(AshaService.RESERVED_FOR_FUTURE_USE) - + bytes(AshaService.SUPPORTED_CODEC_ID), - ) - - self.audio_control_point_characteristic = Characteristic( - GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC, - Characteristic.Properties.WRITE - | Characteristic.Properties.WRITE_WITHOUT_RESPONSE, - Characteristic.WRITEABLE, - CharacteristicValue(write=on_audio_control_point_write), - ) - self.audio_status_characteristic = Characteristic( - GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC, - Characteristic.Properties.READ | Characteristic.Properties.NOTIFY, - Characteristic.READABLE, - bytes([0]), - ) - self.volume_characteristic = Characteristic( - GATT_ASHA_VOLUME_CHARACTERISTIC, - Characteristic.Properties.WRITE_WITHOUT_RESPONSE, - Characteristic.WRITEABLE, - CharacteristicValue(write=on_volume_write), - ) - - # Register an L2CAP CoC server - def on_coc(channel): - def on_data(data): - logging.debug(f'<<< data received:{data}') - - self.emit('data', channel.connection, data) - self.audio_out_data += data - - channel.sink = on_data - - # let the server find a free PSM - self.psm = device.create_l2cap_server( - spec=l2cap.LeCreditBasedChannelSpec(psm=self.psm, max_credits=8), - handler=on_coc, - ).psm - self.le_psm_out_characteristic = Characteristic( - GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC, - Characteristic.Properties.READ, - Characteristic.READABLE, - struct.pack(' + + + + + + + + +
+ +
+ +
+
+ +
+
+ +
+ +
+
+ + +
+
+ +
+ +
+

Log

+ +
+
+ + + + + + + \ No newline at end of file diff --git a/examples/asha_sink1.json b/examples/asha_sink1.json index badef8b..dc383e8 100644 --- a/examples/asha_sink1.json +++ b/examples/asha_sink1.json @@ -1,5 +1,6 @@ { "name": "Bumble Aid Left", "address": "F1:F2:F3:F4:F5:F6", + "identity_address_type": 1, "keystore": "JsonKeyStore" -} +} \ No newline at end of file diff --git a/examples/asha_sink2.json b/examples/asha_sink2.json index 785d406..b8dc6b8 100644 --- a/examples/asha_sink2.json +++ b/examples/asha_sink2.json @@ -1,5 +1,6 @@ { "name": "Bumble Aid Right", "address": "F7:F8:F9:FA:FB:FC", + "identity_address_type": 1, "keystore": "JsonKeyStore" -} +} \ No newline at end of file diff --git a/examples/run_asha_sink.py b/examples/run_asha_sink.py index 105eb75..485e17e 100644 --- a/examples/run_asha_sink.py +++ b/examples/run_asha_sink.py @@ -16,192 +16,104 @@ # Imports # ----------------------------------------------------------------------------- import asyncio -import struct import sys import os import logging +import websockets -from bumble import l2cap +from typing import Optional + +from bumble import decoder +from bumble import gatt from bumble.core import AdvertisingData -from bumble.device import Device +from bumble.device import Device, AdvertisingParameters from bumble.transport import open_transport_or_link -from bumble.core import UUID -from bumble.gatt import Service, Characteristic, CharacteristicValue +from bumble.profiles import asha + +ws_connection: Optional[websockets.WebSocketServerProtocol] = None +g722_decoder = decoder.G722Decoder() -# ----------------------------------------------------------------------------- -# Constants -# ----------------------------------------------------------------------------- -ASHA_SERVICE = UUID.from_16_bits(0xFDF0, 'Audio Streaming for Hearing Aid') -ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC = UUID( - '6333651e-c481-4a3e-9169-7c902aad37bb', 'ReadOnlyProperties' -) -ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC = UUID( - 'f0d4de7e-4a88-476c-9d9f-1937b0996cc0', 'AudioControlPoint' -) -ASHA_AUDIO_STATUS_CHARACTERISTIC = UUID( - '38663f1a-e711-4cac-b641-326b56404837', 'AudioStatus' -) -ASHA_VOLUME_CHARACTERISTIC = UUID('00e4ca9e-ab14-41e4-8823-f9e70c7e91df', 'Volume') -ASHA_LE_PSM_OUT_CHARACTERISTIC = UUID( - '2d410339-82b6-42aa-b34e-e2e01df8cc1a', 'LE_PSM_OUT' -) +async def ws_server(ws_client: websockets.WebSocketServerProtocol, path: str): + del path + global ws_connection + ws_connection = ws_client + + async for message in ws_client: + print(message) # ----------------------------------------------------------------------------- async def main() -> None: - if len(sys.argv) != 4: - print( - 'Usage: python run_asha_sink.py ' - '' - ) - print('example: python run_asha_sink.py device1.json usb:0 audio_out.g722') + if len(sys.argv) != 3: + print('Usage: python run_asha_sink.py ') + print('example: python run_asha_sink.py device1.json usb:0') return - audio_out = open(sys.argv[3], 'wb') - async with await open_transport_or_link(sys.argv[2]) as hci_transport: device = Device.from_config_file_with_hci( sys.argv[1], hci_transport.source, hci_transport.sink ) - # Handler for audio control commands - def on_audio_control_point_write(_connection, value): - print('--- AUDIO CONTROL POINT Write:', value.hex()) - opcode = value[0] - if opcode == 1: - # Start - audio_type = ('Unknown', 'Ringtone', 'Phone Call', 'Media')[value[2]] - print( - f'### START: codec={value[1]}, audio_type={audio_type}, ' - f'volume={value[3]}, otherstate={value[4]}' - ) - elif opcode == 2: - print('### STOP') - elif opcode == 3: - print(f'### STATUS: connected={value[1]}') + def on_audio_packet(packet: bytes) -> None: + global ws_connection + if ws_connection: + offset = 1 + while offset < len(packet): + pcm_data = g722_decoder.decode_frame(packet[offset : offset + 80]) + offset += 80 + asyncio.get_running_loop().create_task(ws_connection.send(pcm_data)) + else: + logging.info("No active client") - # Respond with a status - asyncio.create_task( - device.notify_subscribers(audio_status_characteristic, force=True) - ) - - # Handler for volume control - def on_volume_write(_connection, value): - print('--- VOLUME Write:', value[0]) - - # Register an L2CAP CoC server - def on_coc(channel): - def on_data(data): - print('<<< Voice data received:', data.hex()) - audio_out.write(data) - - channel.sink = on_data - - server = device.create_l2cap_server( - spec=l2cap.LeCreditBasedChannelSpec(max_credits=8), handler=on_coc - ) - print(f'### LE_PSM_OUT = {server.psm}') - - # Add the ASHA service to the GATT server - read_only_properties_characteristic = Characteristic( - ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, - Characteristic.Properties.READ, - Characteristic.READABLE, - bytes( - [ - 0x01, # Version - 0x00, # Device Capabilities [Left, Monaural] - 0x01, - 0x02, - 0x03, - 0x04, - 0x05, - 0x06, - 0x07, - 0x08, # HiSyncId - 0x01, # Feature Map [LE CoC audio output streaming supported] - 0x00, - 0x00, # Render Delay - 0x00, - 0x00, # RFU - 0x02, - 0x00, # Codec IDs [G.722 at 16 kHz] - ] - ), - ) - audio_control_point_characteristic = Characteristic( - ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC, - Characteristic.Properties.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE, - Characteristic.WRITEABLE, - CharacteristicValue(write=on_audio_control_point_write), - ) - audio_status_characteristic = Characteristic( - ASHA_AUDIO_STATUS_CHARACTERISTIC, - Characteristic.Properties.READ | Characteristic.Properties.NOTIFY, - Characteristic.READABLE, - bytes([0]), - ) - volume_characteristic = Characteristic( - ASHA_VOLUME_CHARACTERISTIC, - Characteristic.WRITE_WITHOUT_RESPONSE, - Characteristic.WRITEABLE, - CharacteristicValue(write=on_volume_write), - ) - le_psm_out_characteristic = Characteristic( - ASHA_LE_PSM_OUT_CHARACTERISTIC, - Characteristic.Properties.READ, - Characteristic.READABLE, - struct.pack('