Compare commits

...

1 Commits

Author SHA1 Message Date
uael
a7c87e7ad2 asha: import ASHA Pandora service from AOSP 2023-10-03 19:42:18 -07:00
3 changed files with 156 additions and 44 deletions

View File

@@ -24,8 +24,10 @@ import grpc.aio
from .config import Config from .config import Config
from .device import PandoraDevice from .device import PandoraDevice
from .asha import AshaService
from .host import HostService from .host import HostService
from .security import SecurityService, SecurityStorageService 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.host_grpc_aio import add_HostServicer_to_server
from pandora.security_grpc_aio import ( from pandora.security_grpc_aio import (
add_SecurityServicer_to_server, add_SecurityServicer_to_server,
@@ -68,6 +70,7 @@ async def serve(
config.load_from_dict(bumble.config.get('server', {})) config.load_from_dict(bumble.config.get('server', {}))
# add Pandora services to the gRPC server. # add Pandora services to the gRPC server.
add_ASHAServicer_to_server(AshaService(bumble.device), server)
add_HostServicer_to_server( add_HostServicer_to_server(
HostService(server, bumble.device, config), server HostService(server, bumble.device, config), server
) )

96
bumble/pandora/asha.py Normal file
View File

@@ -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

View File

@@ -32,6 +32,7 @@ from ..gatt import (
Characteristic, Characteristic,
CharacteristicValue, CharacteristicValue,
) )
from ..l2cap import Channel
from ..utils import AsyncRunner from ..utils import AsyncRunner
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -52,46 +53,48 @@ class AshaService(TemplateService):
SUPPORTED_CODEC_ID = [0x02, 0x01] # Codec IDs [G.722 at 16 kHz] SUPPORTED_CODEC_ID = [0x02, 0x01] # Codec IDs [G.722 at 16 kHz]
RENDER_DELAY = [00, 00] 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.hisyncid = hisyncid
self.capability = capability # Device Capabilities [Left, Monaural] self.capability = capability # Device Capabilities [Left, Monaural]
self.device = device self.device = device
self.audio_out_data = b'' self.audio_out_data = b""
self.psm = psm # a non-zero psm is mainly for testing purpose self.psm: int = psm # a non-zero psm is mainly for testing purpose
# Handler for volume control # Handler for volume control
def on_volume_write(connection, value): def on_volume_write(connection: Connection, value: bytes) -> None:
logger.info(f'--- VOLUME Write:{value[0]}') logger.info(f"--- VOLUME Write:{value[0]}")
self.emit('volume', connection, value[0]) self.emit("volume", connection, value[0])
# Handler for audio control commands # Handler for audio control commands
def on_audio_control_point_write(connection: Connection, value): def on_audio_control_point_write(connection: Connection, value: bytes) -> None:
logger.info(f'--- AUDIO CONTROL POINT Write:{value.hex()}') logger.info(f"--- AUDIO CONTROL POINT Write:{value.hex()}")
opcode = value[0] opcode = value[0]
if opcode == AshaService.OPCODE_START: if opcode == AshaService.OPCODE_START:
# Start # Start
audio_type = ('Unknown', 'Ringtone', 'Phone Call', 'Media')[value[2]] audio_type = ("Unknown", "Ringtone", "Phone Call", "Media")[value[2]]
logger.info( logger.info(
f'### START: codec={value[1]}, ' f"### START: codec={value[1]}, "
f'audio_type={audio_type}, ' f"audio_type={audio_type}, "
f'volume={value[3]}, ' f"volume={value[3]}, "
f'otherstate={value[4]}' f"otherstate={value[4]}"
) )
self.emit( self.emit(
'start', "start",
connection, connection,
{ {
'codec': value[1], "codec": value[1],
'audiotype': value[2], "audiotype": value[2],
'volume': value[3], "volume": value[3],
'otherstate': value[4], "otherstate": value[4],
}, },
) )
elif opcode == AshaService.OPCODE_STOP: elif opcode == AshaService.OPCODE_STOP:
logger.info('### STOP') logger.info("### STOP")
self.emit('stop', connection) self.emit("stop", connection)
elif opcode == AshaService.OPCODE_STATUS: 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 # OPCODE_STATUS does not need audio status point update
if opcode != AshaService.OPCODE_STATUS: if opcode != AshaService.OPCODE_STATUS:
@@ -101,49 +104,59 @@ class AshaService(TemplateService):
) )
) )
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("<H", self.psm)
self.read_only_properties_characteristic = Characteristic( self.read_only_properties_characteristic = Characteristic(
GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
Characteristic.Properties.READ, Characteristic.READ,
Characteristic.READABLE, Characteristic.READABLE,
bytes( CharacteristicValue(read=on_read_only_properties_read),
[
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( self.audio_control_point_characteristic = Characteristic(
GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC, GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
Characteristic.Properties.WRITE Characteristic.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE,
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
Characteristic.WRITEABLE, Characteristic.WRITEABLE,
CharacteristicValue(write=on_audio_control_point_write), CharacteristicValue(write=on_audio_control_point_write),
) )
self.audio_status_characteristic = Characteristic( self.audio_status_characteristic = Characteristic(
GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC, GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC,
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY, Characteristic.READ | Characteristic.NOTIFY,
Characteristic.READABLE, Characteristic.READABLE,
bytes([0]), bytes([0]),
) )
self.volume_characteristic = Characteristic( self.volume_characteristic = Characteristic(
GATT_ASHA_VOLUME_CHARACTERISTIC, GATT_ASHA_VOLUME_CHARACTERISTIC,
Characteristic.Properties.WRITE_WITHOUT_RESPONSE, Characteristic.WRITE_WITHOUT_RESPONSE,
Characteristic.WRITEABLE, Characteristic.WRITEABLE,
CharacteristicValue(write=on_volume_write), CharacteristicValue(write=on_volume_write),
) )
# Register an L2CAP CoC server # Register an L2CAP CoC server
def on_coc(channel): def on_coc(channel: Channel) -> None:
def on_data(data): def on_data(data: bytes) -> None:
logging.debug(f'<<< data received:{data}') logging.debug(f"data received:{data.hex()}")
self.emit('data', channel.connection, data) self.emit("data", channel.connection, data)
self.audio_out_data += data self.audio_out_data += data
channel.sink = on_data channel.sink = on_data
@@ -152,9 +165,9 @@ class AshaService(TemplateService):
self.psm = self.device.register_l2cap_channel_server(self.psm, on_coc, 8) self.psm = self.device.register_l2cap_channel_server(self.psm, on_coc, 8)
self.le_psm_out_characteristic = Characteristic( self.le_psm_out_characteristic = Characteristic(
GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC, GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC,
Characteristic.Properties.READ, Characteristic.READ,
Characteristic.READABLE, Characteristic.READABLE,
struct.pack('<H', self.psm), CharacteristicValue(read=on_le_psm_out_read),
) )
characteristics = [ characteristics = [
@@ -167,7 +180,7 @@ class AshaService(TemplateService):
super().__init__(characteristics) super().__init__(characteristics)
def get_advertising_data(self): def get_advertising_data(self) -> bytes:
# Advertisement only uses 4 least significant bytes of the HiSyncId. # Advertisement only uses 4 least significant bytes of the HiSyncId.
return bytes( return bytes(
AdvertisingData( AdvertisingData(