From a7c87e7ad2d45a7c40aa0ed06118ef81fc9a192b Mon Sep 17 00:00:00 2001 From: uael Date: Tue, 3 Oct 2023 19:39:54 -0700 Subject: [PATCH] asha: import ASHA Pandora service from AOSP --- bumble/pandora/__init__.py | 3 + bumble/pandora/asha.py | 96 ++++++++++++++++++++++++++++++ bumble/profiles/asha_service.py | 101 ++++++++++++++++++-------------- 3 files changed, 156 insertions(+), 44 deletions(-) create mode 100644 bumble/pandora/asha.py diff --git a/bumble/pandora/__init__.py b/bumble/pandora/__init__.py index e02f54a9..0727cba7 100644 --- a/bumble/pandora/__init__.py +++ b/bumble/pandora/__init__.py @@ -24,8 +24,10 @@ from .config import Config from .device import PandoraDevice +from .asha import AshaService from .host import HostService from .security import SecurityService, SecurityStorageService +from pandora.asha_grpc_aio import add_ASHAServicer_to_server from pandora.host_grpc_aio import add_HostServicer_to_server from pandora.security_grpc_aio import ( add_SecurityServicer_to_server, @@ -68,6 +70,7 @@ async def serve( config.load_from_dict(bumble.config.get('server', {})) # add Pandora services to the gRPC server. + add_ASHAServicer_to_server(AshaService(bumble.device), server) add_HostServicer_to_server( HostService(server, bumble.device, config), server ) diff --git a/bumble/pandora/asha.py b/bumble/pandora/asha.py new file mode 100644 index 00000000..f5e9718e --- /dev/null +++ b/bumble/pandora/asha.py @@ -0,0 +1,96 @@ +# Copyright 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. + +import asyncio +import grpc +import logging + +from bumble.decoder import G722Decoder +from bumble.device import Connection, Device +from bumble.pandora import utils +from bumble.profiles import asha_service +from google.protobuf.empty_pb2 import Empty # pytype: disable=pyi-error +from pandora.asha_grpc_aio import ASHAServicer +from pandora.asha_pb2 import CaptureAudioRequest, CaptureAudioResponse, RegisterRequest +from typing import AsyncGenerator, Optional + + +class AshaService(ASHAServicer): + DECODE_FRAME_LENGTH = 80 + + device: Device + asha_service: Optional[asha_service.AshaService] + + def __init__(self, device: Device) -> None: + self.log = utils.BumbleServerLoggerAdapter( + logging.getLogger(), {"service_name": "Asha", "device": device} + ) + self.device = device + self.asha_service = None + + @utils.rpc + async def Register( + self, request: RegisterRequest, context: grpc.ServicerContext + ) -> Empty: + logging.info("Register") + if self.asha_service: + self.asha_service.capability = request.capability + self.asha_service.hisyncid = request.hisyncid + else: + self.asha_service = asha_service.AshaService( + request.capability, request.hisyncid, self.device + ) + self.device.add_service(self.asha_service) # type: ignore[no-untyped-call] + return Empty() + + @utils.rpc + async def CaptureAudio( + self, request: CaptureAudioRequest, context: grpc.ServicerContext + ) -> AsyncGenerator[CaptureAudioResponse, None]: + connection_handle = int.from_bytes(request.connection.cookie.value, "big") + logging.info(f"CaptureAudioData connection_handle:{connection_handle}") + + if not (connection := self.device.lookup_connection(connection_handle)): + raise RuntimeError( + f"Unknown connection for connection_handle:{connection_handle}" + ) + + decoder = G722Decoder() # type: ignore + queue: asyncio.Queue[bytes] = asyncio.Queue() + + def on_data(asha_connection: Connection, data: bytes) -> None: + if asha_connection == connection: + queue.put_nowait(data) + + self.asha_service.on("data", on_data) # type: ignore + + try: + while data := await queue.get(): + output_bytes = bytearray() + # First byte is sequence number, last 160 bytes are audio payload. + audio_payload = data[1:] + data_length = int(len(audio_payload) / AshaService.DECODE_FRAME_LENGTH) + for i in range(0, data_length): + input_data = audio_payload[ + i + * AshaService.DECODE_FRAME_LENGTH : i + * AshaService.DECODE_FRAME_LENGTH + + AshaService.DECODE_FRAME_LENGTH + ] + decoded_data = decoder.decode_frame(input_data) + output_bytes.extend(decoded_data) + + yield CaptureAudioResponse(data=bytes(output_bytes)) + finally: + self.asha_service.remove_listener("data", on_data) # type: ignore diff --git a/bumble/profiles/asha_service.py b/bumble/profiles/asha_service.py index 68983973..fdd0e63e 100644 --- a/bumble/profiles/asha_service.py +++ b/bumble/profiles/asha_service.py @@ -32,6 +32,7 @@ Characteristic, CharacteristicValue, ) +from ..l2cap import Channel from ..utils import AsyncRunner # ----------------------------------------------------------------------------- @@ -52,46 +53,48 @@ class AshaService(TemplateService): 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): + def __init__( + self, capability: int, hisyncid: List[int], device: Device, psm: int = 0 + ) -> None: 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 + self.audio_out_data = b"" + self.psm: int = 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]) + def on_volume_write(connection: Connection, value: bytes) -> None: + 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: Connection, value): - logger.info(f'--- AUDIO CONTROL POINT Write:{value.hex()}') + def on_audio_control_point_write(connection: Connection, value: bytes) -> None: + 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]] + 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]}' + f"### START: codec={value[1]}, " + f"audio_type={audio_type}, " + f"volume={value[3]}, " + f"otherstate={value[4]}" ) self.emit( - 'start', + "start", connection, { - 'codec': value[1], - 'audiotype': value[2], - 'volume': value[3], - 'otherstate': value[4], + "codec": value[1], + "audiotype": value[2], + "volume": value[3], + "otherstate": value[4], }, ) elif opcode == AshaService.OPCODE_STOP: - logger.info('### STOP') - self.emit('stop', connection) + logger.info("### STOP") + self.emit("stop", connection) elif opcode == AshaService.OPCODE_STATUS: - logger.info(f'### STATUS: connected={value[1]}') + logger.info(f"### STATUS: connected={value[1]}") # OPCODE_STATUS does not need audio status point update if opcode != AshaService.OPCODE_STATUS: @@ -101,49 +104,59 @@ def on_audio_control_point_write(connection: Connection, value): ) ) + def on_read_only_properties_read(connection: Connection) -> bytes: + value = ( + 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.emit("read_only_properties", connection, value) + return value + + def on_le_psm_out_read(connection: Connection) -> bytes: + self.emit("le_psm_out", connection, self.psm) + return struct.pack(" None: + def on_data(data: bytes) -> None: + logging.debug(f"data received:{data.hex()}") - self.emit('data', channel.connection, data) + self.emit("data", channel.connection, data) self.audio_out_data += data channel.sink = on_data @@ -152,9 +165,9 @@ def on_data(data): self.psm = self.device.register_l2cap_channel_server(self.psm, on_coc, 8) self.le_psm_out_characteristic = Characteristic( GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC, - Characteristic.Properties.READ, + Characteristic.READ, Characteristic.READABLE, - struct.pack(' bytes: # Advertisement only uses 4 least significant bytes of the HiSyncId. return bytes( AdvertisingData(