Loading android/pandora/test/asha_test.py +12 −3 Original line number Diff line number Diff line Loading @@ -18,15 +18,17 @@ import enum import grpc import logging from avatar import BumblePandoraDevice, PandoraDevice, PandoraDevices, asynchronous from avatar import BumblePandoraDevice, PandoraDevice, PandoraDevices, asynchronous, bumble_server from bumble.gatt import GATT_ASHA_SERVICE from bumble.smp import PairingDelegate from bumble_experimental.asha import ASHAService from mobly import base_test, signals, test_runner from mobly.asserts import assert_equal # type: ignore from mobly.asserts import assert_in # type: ignore from pandora._utils import AioStream from pandora.host_pb2 import PUBLIC, RANDOM, AdvertiseResponse, Connection, DataTypes, OwnAddressType, ScanningResponse from pandora.security_pb2 import LE_LEVEL3, LESecurityLevel from pandora_experimental.asha_grpc_aio import Asha as AioAsha, add_AshaServicer_to_server from typing import List, Optional, Tuple ASHA_UUID = GATT_ASHA_SERVICE.to_hex_str() Loading Loading @@ -54,6 +56,11 @@ class ASHATest(base_test.BaseTestClass): # type: ignore[misc] ref_right: PandoraDevice def setup_class(self) -> None: # Register experimental bumble servicers hook. bumble_server.register_servicer_hook( lambda bumble, server: add_AshaServicer_to_server(ASHAService(bumble.device), server) ) self.devices = PandoraDevices(self) self.dut, self.ref_left, self.ref_right, *_ = self.devices Loading Loading @@ -84,7 +91,8 @@ class ASHATest(base_test.BaseTestClass): # type: ignore[misc] :return: Ref device's advertise stream """ # Ref starts advertising with ASHA service data await ref_device.aio.asha.Register(capability=CAPABILITY, hisyncid=HISYCNID) asha = AioAsha(ref_device.aio.channel) await asha.Register(capability=CAPABILITY, hisyncid=HISYCNID) return ref_device.aio.host.Advertise( legacy=True, connectable=True, Loading Loading @@ -171,7 +179,8 @@ class ASHATest(base_test.BaseTestClass): # type: ignore[misc] protocol_version = 0x01 truncated_hisyncid = HISYCNID[:4] await self.ref_left.aio.asha.Register(capability=CAPABILITY, hisyncid=HISYCNID) asha = AioAsha(self.ref_left.aio.channel) await asha.Register(capability=CAPABILITY, hisyncid=HISYCNID) # advertise with ASHA service data in scan response advertisement = self.ref_left.aio.host.Advertise( Loading pandora/interfaces/pandora_experimental/asha.proto 0 → 100644 +87 −0 Original line number Diff line number Diff line // 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. syntax = "proto3"; option java_outer_classname = "AshaProto"; package pandora.asha; import "google/protobuf/empty.proto"; import "pandora/host.proto"; // Service to trigger Audio Streaming for Hearing Aid (ASHA) procedures. // ASHA uses connection-oriented L2CAP channels (CoC) and GATT. service Asha { // Register ASHA Service. rpc Register(RegisterRequest) returns (google.protobuf.Empty); // Capture Audio. rpc CaptureAudio(CaptureAudioRequest) returns (stream CaptureAudioResponse); // Start a suspended stream. rpc Start(StartRequest) returns (StartResponse); // Playback audio rpc PlaybackAudio(stream PlaybackAudioRequest) returns (PlaybackAudioResponse); // Stop a started stream. rpc Stop(stream StopRequest) returns (StopResponse); } // Request of the `Register` method. message RegisterRequest { uint32 capability = 1; // left or right device, monaural or binaural device. repeated uint32 hisyncid = 2; // id identifying two hearing aids as one pair. } // Request of the `CaptureAudio` method. message CaptureAudioRequest { // Low Energy connection. Connection connection = 1; } // Response of the `CaptureAudio` method. message CaptureAudioResponse { // Audio data received on peripheral side. // `data` is decoded by G722 decoder. bytes data = 1; } // Request of the `Start` method. message StartRequest { // Low Energy connection. Connection connection = 1; } // Response of the `Start` method. message StartResponse {} // Request of the `PlaybackAudio` method. message PlaybackAudioRequest { // Low Energy connection. Connection connection = 1; // Audio data to playback. // `data` should be interleaved stereo frames with 16-bit signed little-endian // linear PCM samples at 44100Hz sample rate bytes data = 2; } // Response of the `PlaybackAudio` method. message PlaybackAudioResponse {} // Request of the `Stop` method. message StopRequest { // Low Energy connection. Connection connection = 1; } // Response of the `Stop` method. message StopResponse {} No newline at end of file pandora/interfaces/python/Android.bp +5 −0 Original line number Diff line number Diff line Loading @@ -34,6 +34,10 @@ genrule { "pandora_experimental/a2dp_grpc_aio.py", "pandora_experimental/a2dp_pb2.py", "pandora_experimental/a2dp_pb2.pyi", "pandora_experimental/asha_grpc.py", "pandora_experimental/asha_grpc_aio.py", "pandora_experimental/asha_pb2.py", "pandora_experimental/asha_pb2.pyi", "pandora_experimental/avrcp_grpc.py", "pandora_experimental/avrcp_grpc_aio.py", "pandora_experimental/avrcp_pb2.py", Loading Loading @@ -89,6 +93,7 @@ filegroup { ":pandora_experimental-python-gen-src{pandora_experimental/py.typed}", ":pandora_experimental-python-gen-src{pandora_experimental/_android_pb2.pyi}", ":pandora_experimental-python-gen-src{pandora_experimental/a2dp_pb2.pyi}", ":pandora_experimental-python-gen-src{pandora_experimental/asha_pb2.pyi}", ":pandora_experimental-python-gen-src{pandora_experimental/avrcp_pb2.pyi}", ":pandora_experimental-python-gen-src{pandora_experimental/gatt_pb2.pyi}", ":pandora_experimental-python-gen-src{pandora_experimental/hfp_pb2.pyi}", Loading pandora/server/Android.bp +3 −0 Original line number Diff line number Diff line Loading @@ -26,4 +26,7 @@ python_library_host { "pandora_experimental-python", "bumble", ], data: [ "bumble_experimental/py.typed", ] } pandora/server/bumble_experimental/asha.py 0 → 100644 +67 −0 Original line number Diff line number Diff line # 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 avatar.bumble_server import utils from bumble.device import Connection as BumbleConnection, Device from bumble.profiles.asha_service import AshaService from google.protobuf.empty_pb2 import Empty # pytype: disable=pyi-error from pandora_experimental.asha_grpc_aio import AshaServicer from pandora_experimental.asha_pb2 import CaptureAudioRequest, CaptureAudioResponse, RegisterRequest from typing import AsyncGenerator, Optional class ASHAService(AshaServicer): device: Device asha_service: Optional[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') # asha service from bumble profile self.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}") queue: asyncio.Queue[bytes] = asyncio.Queue() def on_data(asha_connection: BumbleConnection, 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(): yield CaptureAudioResponse(data=data) finally: self.asha_service.remove_listener('data', on_data) # type: ignore Loading
android/pandora/test/asha_test.py +12 −3 Original line number Diff line number Diff line Loading @@ -18,15 +18,17 @@ import enum import grpc import logging from avatar import BumblePandoraDevice, PandoraDevice, PandoraDevices, asynchronous from avatar import BumblePandoraDevice, PandoraDevice, PandoraDevices, asynchronous, bumble_server from bumble.gatt import GATT_ASHA_SERVICE from bumble.smp import PairingDelegate from bumble_experimental.asha import ASHAService from mobly import base_test, signals, test_runner from mobly.asserts import assert_equal # type: ignore from mobly.asserts import assert_in # type: ignore from pandora._utils import AioStream from pandora.host_pb2 import PUBLIC, RANDOM, AdvertiseResponse, Connection, DataTypes, OwnAddressType, ScanningResponse from pandora.security_pb2 import LE_LEVEL3, LESecurityLevel from pandora_experimental.asha_grpc_aio import Asha as AioAsha, add_AshaServicer_to_server from typing import List, Optional, Tuple ASHA_UUID = GATT_ASHA_SERVICE.to_hex_str() Loading Loading @@ -54,6 +56,11 @@ class ASHATest(base_test.BaseTestClass): # type: ignore[misc] ref_right: PandoraDevice def setup_class(self) -> None: # Register experimental bumble servicers hook. bumble_server.register_servicer_hook( lambda bumble, server: add_AshaServicer_to_server(ASHAService(bumble.device), server) ) self.devices = PandoraDevices(self) self.dut, self.ref_left, self.ref_right, *_ = self.devices Loading Loading @@ -84,7 +91,8 @@ class ASHATest(base_test.BaseTestClass): # type: ignore[misc] :return: Ref device's advertise stream """ # Ref starts advertising with ASHA service data await ref_device.aio.asha.Register(capability=CAPABILITY, hisyncid=HISYCNID) asha = AioAsha(ref_device.aio.channel) await asha.Register(capability=CAPABILITY, hisyncid=HISYCNID) return ref_device.aio.host.Advertise( legacy=True, connectable=True, Loading Loading @@ -171,7 +179,8 @@ class ASHATest(base_test.BaseTestClass): # type: ignore[misc] protocol_version = 0x01 truncated_hisyncid = HISYCNID[:4] await self.ref_left.aio.asha.Register(capability=CAPABILITY, hisyncid=HISYCNID) asha = AioAsha(self.ref_left.aio.channel) await asha.Register(capability=CAPABILITY, hisyncid=HISYCNID) # advertise with ASHA service data in scan response advertisement = self.ref_left.aio.host.Advertise( Loading
pandora/interfaces/pandora_experimental/asha.proto 0 → 100644 +87 −0 Original line number Diff line number Diff line // 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. syntax = "proto3"; option java_outer_classname = "AshaProto"; package pandora.asha; import "google/protobuf/empty.proto"; import "pandora/host.proto"; // Service to trigger Audio Streaming for Hearing Aid (ASHA) procedures. // ASHA uses connection-oriented L2CAP channels (CoC) and GATT. service Asha { // Register ASHA Service. rpc Register(RegisterRequest) returns (google.protobuf.Empty); // Capture Audio. rpc CaptureAudio(CaptureAudioRequest) returns (stream CaptureAudioResponse); // Start a suspended stream. rpc Start(StartRequest) returns (StartResponse); // Playback audio rpc PlaybackAudio(stream PlaybackAudioRequest) returns (PlaybackAudioResponse); // Stop a started stream. rpc Stop(stream StopRequest) returns (StopResponse); } // Request of the `Register` method. message RegisterRequest { uint32 capability = 1; // left or right device, monaural or binaural device. repeated uint32 hisyncid = 2; // id identifying two hearing aids as one pair. } // Request of the `CaptureAudio` method. message CaptureAudioRequest { // Low Energy connection. Connection connection = 1; } // Response of the `CaptureAudio` method. message CaptureAudioResponse { // Audio data received on peripheral side. // `data` is decoded by G722 decoder. bytes data = 1; } // Request of the `Start` method. message StartRequest { // Low Energy connection. Connection connection = 1; } // Response of the `Start` method. message StartResponse {} // Request of the `PlaybackAudio` method. message PlaybackAudioRequest { // Low Energy connection. Connection connection = 1; // Audio data to playback. // `data` should be interleaved stereo frames with 16-bit signed little-endian // linear PCM samples at 44100Hz sample rate bytes data = 2; } // Response of the `PlaybackAudio` method. message PlaybackAudioResponse {} // Request of the `Stop` method. message StopRequest { // Low Energy connection. Connection connection = 1; } // Response of the `Stop` method. message StopResponse {} No newline at end of file
pandora/interfaces/python/Android.bp +5 −0 Original line number Diff line number Diff line Loading @@ -34,6 +34,10 @@ genrule { "pandora_experimental/a2dp_grpc_aio.py", "pandora_experimental/a2dp_pb2.py", "pandora_experimental/a2dp_pb2.pyi", "pandora_experimental/asha_grpc.py", "pandora_experimental/asha_grpc_aio.py", "pandora_experimental/asha_pb2.py", "pandora_experimental/asha_pb2.pyi", "pandora_experimental/avrcp_grpc.py", "pandora_experimental/avrcp_grpc_aio.py", "pandora_experimental/avrcp_pb2.py", Loading Loading @@ -89,6 +93,7 @@ filegroup { ":pandora_experimental-python-gen-src{pandora_experimental/py.typed}", ":pandora_experimental-python-gen-src{pandora_experimental/_android_pb2.pyi}", ":pandora_experimental-python-gen-src{pandora_experimental/a2dp_pb2.pyi}", ":pandora_experimental-python-gen-src{pandora_experimental/asha_pb2.pyi}", ":pandora_experimental-python-gen-src{pandora_experimental/avrcp_pb2.pyi}", ":pandora_experimental-python-gen-src{pandora_experimental/gatt_pb2.pyi}", ":pandora_experimental-python-gen-src{pandora_experimental/hfp_pb2.pyi}", Loading
pandora/server/Android.bp +3 −0 Original line number Diff line number Diff line Loading @@ -26,4 +26,7 @@ python_library_host { "pandora_experimental-python", "bumble", ], data: [ "bumble_experimental/py.typed", ] }
pandora/server/bumble_experimental/asha.py 0 → 100644 +67 −0 Original line number Diff line number Diff line # 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 avatar.bumble_server import utils from bumble.device import Connection as BumbleConnection, Device from bumble.profiles.asha_service import AshaService from google.protobuf.empty_pb2 import Empty # pytype: disable=pyi-error from pandora_experimental.asha_grpc_aio import AshaServicer from pandora_experimental.asha_pb2 import CaptureAudioRequest, CaptureAudioResponse, RegisterRequest from typing import AsyncGenerator, Optional class ASHAService(AshaServicer): device: Device asha_service: Optional[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') # asha service from bumble profile self.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}") queue: asyncio.Queue[bytes] = asyncio.Queue() def on_data(asha_connection: BumbleConnection, 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(): yield CaptureAudioResponse(data=data) finally: self.asha_service.remove_listener('data', on_data) # type: ignore