| # 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 asyncio | |
| import logging | |
| import os | |
| import pytest | |
| import pytest_asyncio | |
| from bumble import core, hci, hfp, rfcomm | |
| from .test_utils import TwoDevices | |
| # ----------------------------------------------------------------------------- | |
| # Logging | |
| # ----------------------------------------------------------------------------- | |
| logger = logging.getLogger(__name__) | |
| # ----------------------------------------------------------------------------- | |
| def _default_hf_configuration() -> hfp.HfConfiguration: | |
| return hfp.HfConfiguration( | |
| supported_hf_features=[ | |
| hfp.HfFeature.CODEC_NEGOTIATION, | |
| hfp.HfFeature.ESCO_S4_SETTINGS_SUPPORTED, | |
| hfp.HfFeature.HF_INDICATORS, | |
| hfp.HfFeature.ENHANCED_CALL_STATUS, | |
| hfp.HfFeature.THREE_WAY_CALLING, | |
| hfp.HfFeature.CLI_PRESENTATION_CAPABILITY, | |
| ], | |
| supported_hf_indicators=[ | |
| hfp.HfIndicator.ENHANCED_SAFETY, | |
| hfp.HfIndicator.BATTERY_LEVEL, | |
| ], | |
| supported_audio_codecs=[ | |
| hfp.AudioCodec.CVSD, | |
| hfp.AudioCodec.MSBC, | |
| ], | |
| ) | |
| # ----------------------------------------------------------------------------- | |
| def _default_hf_sdp_features() -> hfp.HfSdpFeature: | |
| return ( | |
| hfp.HfSdpFeature.WIDE_BAND_SPEECH | |
| | hfp.HfSdpFeature.THREE_WAY_CALLING | |
| | hfp.HfSdpFeature.CLI_PRESENTATION_CAPABILITY | |
| ) | |
| # ----------------------------------------------------------------------------- | |
| def _default_ag_configuration() -> hfp.AgConfiguration: | |
| return hfp.AgConfiguration( | |
| supported_ag_features=[ | |
| hfp.AgFeature.HF_INDICATORS, | |
| hfp.AgFeature.IN_BAND_RING_TONE_CAPABILITY, | |
| hfp.AgFeature.REJECT_CALL, | |
| hfp.AgFeature.CODEC_NEGOTIATION, | |
| hfp.AgFeature.ESCO_S4_SETTINGS_SUPPORTED, | |
| hfp.AgFeature.ENHANCED_CALL_STATUS, | |
| hfp.AgFeature.THREE_WAY_CALLING, | |
| ], | |
| supported_ag_indicators=[ | |
| hfp.AgIndicatorState.call(), | |
| hfp.AgIndicatorState.service(), | |
| hfp.AgIndicatorState.callsetup(), | |
| hfp.AgIndicatorState.callsetup(), | |
| hfp.AgIndicatorState.signal(), | |
| hfp.AgIndicatorState.roam(), | |
| hfp.AgIndicatorState.battchg(), | |
| ], | |
| supported_hf_indicators=[ | |
| hfp.HfIndicator.ENHANCED_SAFETY, | |
| hfp.HfIndicator.BATTERY_LEVEL, | |
| ], | |
| supported_ag_call_hold_operations=[ | |
| hfp.CallHoldOperation.ADD_HELD_CALL, | |
| hfp.CallHoldOperation.HOLD_ALL_ACTIVE_CALLS, | |
| hfp.CallHoldOperation.HOLD_ALL_CALLS_EXCEPT, | |
| hfp.CallHoldOperation.RELEASE_ALL_ACTIVE_CALLS, | |
| hfp.CallHoldOperation.RELEASE_ALL_HELD_CALLS, | |
| hfp.CallHoldOperation.RELEASE_SPECIFIC_CALL, | |
| hfp.CallHoldOperation.CONNECT_TWO_CALLS, | |
| ], | |
| supported_audio_codecs=[hfp.AudioCodec.CVSD, hfp.AudioCodec.MSBC], | |
| ) | |
| # ----------------------------------------------------------------------------- | |
| def _default_ag_sdp_features() -> hfp.AgSdpFeature: | |
| return ( | |
| hfp.AgSdpFeature.WIDE_BAND_SPEECH | |
| | hfp.AgSdpFeature.IN_BAND_RING_TONE_CAPABILITY | |
| | hfp.AgSdpFeature.THREE_WAY_CALLING | |
| ) | |
| # ----------------------------------------------------------------------------- | |
| async def make_hfp_connections( | |
| hf_config: hfp.HfConfiguration | None = None, | |
| ag_config: hfp.AgConfiguration | None = None, | |
| ): | |
| if not hf_config: | |
| hf_config = _default_hf_configuration() | |
| if not ag_config: | |
| ag_config = _default_ag_configuration() | |
| # Setup devices | |
| devices = TwoDevices() | |
| await devices.setup_connection() | |
| # Setup RFCOMM channel | |
| wait_dlc = asyncio.get_running_loop().create_future() | |
| rfcomm_channel = rfcomm.Server(devices.devices[0]).listen(wait_dlc.set_result) | |
| assert devices.connections[0] | |
| assert devices.connections[1] | |
| client_mux = await rfcomm.Client(devices.connections[1]).start() | |
| client_dlc = await client_mux.open_dlc(rfcomm_channel) | |
| server_dlc = await wait_dlc | |
| # Setup HFP connection | |
| hf = hfp.HfProtocol(client_dlc, hf_config) | |
| ag = hfp.AgProtocol(server_dlc, ag_config) | |
| await hf.initiate_slc() | |
| return (hf, ag) | |
| # ----------------------------------------------------------------------------- | |
| @pytest_asyncio.fixture | |
| async def hfp_connections(): | |
| hf, ag = await make_hfp_connections() | |
| hf_loop_task = asyncio.create_task(hf.run()) | |
| try: | |
| yield (hf, ag) | |
| finally: | |
| # Close the coroutine. | |
| hf.unsolicited_queue.put_nowait(None) | |
| await hf_loop_task | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_slc_with_minimal_features(): | |
| hf, ag = await make_hfp_connections( | |
| hfp.HfConfiguration( | |
| supported_audio_codecs=[], | |
| supported_hf_features=[], | |
| supported_hf_indicators=[], | |
| ), | |
| hfp.AgConfiguration( | |
| supported_ag_call_hold_operations=[], | |
| supported_ag_features=[], | |
| supported_ag_indicators=[ | |
| hfp.AgIndicatorState( | |
| indicator=hfp.AgIndicator.CALL, | |
| supported_values={0, 1}, | |
| current_status=0, | |
| ) | |
| ], | |
| supported_hf_indicators=[], | |
| supported_audio_codecs=[], | |
| ), | |
| ) | |
| assert hf.supported_ag_features == ag.supported_ag_features | |
| assert hf.supported_hf_features == ag.supported_hf_features | |
| assert hf.supported_ag_call_hold_operations == ag.supported_ag_call_hold_operations | |
| for a, b in zip(hf.ag_indicators, ag.ag_indicators): | |
| assert a.indicator == b.indicator | |
| assert a.current_status == b.current_status | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_slc(hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol]): | |
| hf, ag = hfp_connections | |
| assert hf.supported_ag_features == ag.supported_ag_features | |
| assert hf.supported_hf_features == ag.supported_hf_features | |
| assert hf.supported_ag_call_hold_operations == ag.supported_ag_call_hold_operations | |
| for a, b in zip(hf.ag_indicators, ag.ag_indicators): | |
| assert a.indicator == b.indicator | |
| assert a.current_status == b.current_status | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_ag_indicator(hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol]): | |
| hf, ag = hfp_connections | |
| future = asyncio.get_running_loop().create_future() | |
| hf.on('ag_indicator', future.set_result) | |
| ag.update_ag_indicator(hfp.AgIndicator.CALL, 1) | |
| indicator: hfp.AgIndicatorState = await future | |
| assert indicator.current_status == 1 | |
| assert indicator.indicator == hfp.AgIndicator.CALL | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_hf_indicator(hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol]): | |
| hf, ag = hfp_connections | |
| future = asyncio.get_running_loop().create_future() | |
| ag.on('hf_indicator', future.set_result) | |
| await hf.execute_command('AT+BIEV=2,100') | |
| indicator: hfp.HfIndicatorState = await future | |
| assert indicator.current_status == 100 | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_codec_negotiation( | |
| hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol], | |
| ): | |
| hf, ag = hfp_connections | |
| futures = [ | |
| asyncio.get_running_loop().create_future(), | |
| asyncio.get_running_loop().create_future(), | |
| ] | |
| hf.on('codec_negotiation', futures[0].set_result) | |
| ag.on('codec_negotiation', futures[1].set_result) | |
| await ag.negotiate_codec(hfp.AudioCodec.MSBC) | |
| assert await futures[0] == await futures[1] | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_dial(hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol]): | |
| hf, ag = hfp_connections | |
| NUMBER = 'ATD123456789' | |
| future = asyncio.get_running_loop().create_future() | |
| ag.on('dial', future.set_result) | |
| await hf.execute_command(f'ATD{NUMBER}') | |
| number: str = await future | |
| assert number == NUMBER | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_answer(hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol]): | |
| hf, ag = hfp_connections | |
| future = asyncio.get_running_loop().create_future() | |
| ag.on('answer', lambda: future.set_result(None)) | |
| await hf.answer_incoming_call() | |
| await future | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_reject_incoming_call( | |
| hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol], | |
| ): | |
| hf, ag = hfp_connections | |
| future = asyncio.get_running_loop().create_future() | |
| ag.on('hang_up', lambda: future.set_result(None)) | |
| await hf.reject_incoming_call() | |
| await future | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_terminate_call(hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol]): | |
| hf, ag = hfp_connections | |
| future = asyncio.get_running_loop().create_future() | |
| ag.on('hang_up', lambda: future.set_result(None)) | |
| await hf.terminate_call() | |
| await future | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_query_calls_without_calls( | |
| hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol], | |
| ): | |
| hf, ag = hfp_connections | |
| assert await hf.query_current_calls() == [] | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_query_calls_with_calls( | |
| hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol], | |
| ): | |
| hf, ag = hfp_connections | |
| ag.calls.append( | |
| hfp.CallInfo( | |
| index=1, | |
| direction=hfp.CallInfoDirection.MOBILE_ORIGINATED_CALL, | |
| status=hfp.CallInfoStatus.ACTIVE, | |
| mode=hfp.CallInfoMode.VOICE, | |
| multi_party=hfp.CallInfoMultiParty.NOT_IN_CONFERENCE, | |
| number='123456789', | |
| ) | |
| ) | |
| assert await hf.query_current_calls() == ag.calls | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| @pytest.mark.parametrize( | |
| "operation,", | |
| ( | |
| hfp.CallHoldOperation.RELEASE_ALL_HELD_CALLS, | |
| hfp.CallHoldOperation.RELEASE_ALL_ACTIVE_CALLS, | |
| hfp.CallHoldOperation.HOLD_ALL_ACTIVE_CALLS, | |
| hfp.CallHoldOperation.ADD_HELD_CALL, | |
| hfp.CallHoldOperation.CONNECT_TWO_CALLS, | |
| ), | |
| ) | |
| async def test_hold_call_without_call_index( | |
| hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol], | |
| operation: hfp.CallHoldOperation, | |
| ): | |
| hf, ag = hfp_connections | |
| call_hold_future = asyncio.get_running_loop().create_future() | |
| ag.on("call_hold", lambda op, index: call_hold_future.set_result((op, index))) | |
| await hf.execute_command(f"AT+CHLD={operation.value}") | |
| assert (await call_hold_future) == (operation, None) | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| @pytest.mark.parametrize( | |
| "operation,", | |
| ( | |
| hfp.CallHoldOperation.RELEASE_SPECIFIC_CALL, | |
| hfp.CallHoldOperation.HOLD_ALL_CALLS_EXCEPT, | |
| ), | |
| ) | |
| async def test_hold_call_with_call_index( | |
| hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol], | |
| operation: hfp.CallHoldOperation, | |
| ): | |
| hf, ag = hfp_connections | |
| call_hold_future = asyncio.get_running_loop().create_future() | |
| ag.on("call_hold", lambda op, index: call_hold_future.set_result((op, index))) | |
| ag.calls.append( | |
| hfp.CallInfo( | |
| index=1, | |
| direction=hfp.CallInfoDirection.MOBILE_ORIGINATED_CALL, | |
| status=hfp.CallInfoStatus.ACTIVE, | |
| mode=hfp.CallInfoMode.VOICE, | |
| multi_party=hfp.CallInfoMultiParty.NOT_IN_CONFERENCE, | |
| number='123456789', | |
| ) | |
| ) | |
| await hf.execute_command(f"AT+CHLD={operation.value.replace('x', '1')}") | |
| assert (await call_hold_future) == (operation, 1) | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_ring(hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol]): | |
| hf, ag = hfp_connections | |
| ring_future = asyncio.get_running_loop().create_future() | |
| hf.on("ring", lambda: ring_future.set_result(None)) | |
| ag.send_ring() | |
| await ring_future | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_speaker_volume(hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol]): | |
| hf, ag = hfp_connections | |
| speaker_volume_future = asyncio.get_running_loop().create_future() | |
| hf.on("speaker_volume", speaker_volume_future.set_result) | |
| ag.set_speaker_volume(10) | |
| assert await speaker_volume_future == 10 | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_microphone_volume( | |
| hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol], | |
| ): | |
| hf, ag = hfp_connections | |
| microphone_volume_future = asyncio.get_running_loop().create_future() | |
| hf.on("microphone_volume", microphone_volume_future.set_result) | |
| ag.set_microphone_volume(10) | |
| assert await microphone_volume_future == 10 | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_cli_notification(hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol]): | |
| hf, ag = hfp_connections | |
| cli_notification_future = asyncio.get_running_loop().create_future() | |
| hf.on("cli_notification", cli_notification_future.set_result) | |
| ag.send_cli_notification( | |
| hfp.CallLineIdentification(number="\"123456789\"", type=129, alpha="\"Bumble\"") | |
| ) | |
| assert await cli_notification_future == hfp.CallLineIdentification( | |
| number="123456789", type=129, alpha="Bumble", subaddr="", satype=None | |
| ) | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_voice_recognition_from_hf( | |
| hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol], | |
| ): | |
| hf, ag = hfp_connections | |
| voice_recognition_future = asyncio.get_running_loop().create_future() | |
| ag.on("voice_recognition", voice_recognition_future.set_result) | |
| await hf.execute_command("AT+BVRA=1") | |
| assert await voice_recognition_future == hfp.VoiceRecognitionState.ENABLE | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_voice_recognition_from_ag( | |
| hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol], | |
| ): | |
| hf, ag = hfp_connections | |
| voice_recognition_future = asyncio.get_running_loop().create_future() | |
| hf.on("voice_recognition", voice_recognition_future.set_result) | |
| ag.send_response("+BVRA: 1") | |
| assert await voice_recognition_future == hfp.VoiceRecognitionState.ENABLE | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_hf_sdp_record(): | |
| devices = TwoDevices() | |
| await devices.setup_connection() | |
| devices[0].sdp_service_records[1] = hfp.make_hf_sdp_records( | |
| 1, 2, _default_hf_configuration(), hfp.ProfileVersion.V1_8 | |
| ) | |
| assert await hfp.find_hf_sdp_record(devices.connections[1]) == ( | |
| 2, | |
| hfp.ProfileVersion.V1_8, | |
| _default_hf_sdp_features(), | |
| ) | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_ag_sdp_record(): | |
| devices = TwoDevices() | |
| await devices.setup_connection() | |
| devices[0].sdp_service_records[1] = hfp.make_ag_sdp_records( | |
| 1, 2, _default_ag_configuration(), hfp.ProfileVersion.V1_8 | |
| ) | |
| assert await hfp.find_ag_sdp_record(devices.connections[1]) == ( | |
| 2, | |
| hfp.ProfileVersion.V1_8, | |
| _default_ag_sdp_features(), | |
| ) | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_sco_setup(): | |
| devices = TwoDevices() | |
| # Enable Classic connections | |
| devices[0].classic_enabled = True | |
| devices[1].classic_enabled = True | |
| # Start | |
| await devices[0].power_on() | |
| await devices[1].power_on() | |
| connections = await asyncio.gather( | |
| devices[0].connect( | |
| devices[1].public_address, transport=core.PhysicalTransport.BR_EDR | |
| ), | |
| devices[1].accept(devices[0].public_address), | |
| ) | |
| def on_sco_request(_connection, _link_type: int): | |
| connections[1].abort_on( | |
| 'disconnection', | |
| devices[1].send_command( | |
| hci.HCI_Enhanced_Accept_Synchronous_Connection_Request_Command( | |
| bd_addr=connections[1].peer_address, | |
| **hfp.ESCO_PARAMETERS[ | |
| hfp.DefaultCodecParameters.ESCO_CVSD_S1 | |
| ].asdict(), | |
| ) | |
| ), | |
| ) | |
| devices[1].on('sco_request', on_sco_request) | |
| sco_connection_futures = [ | |
| asyncio.get_running_loop().create_future(), | |
| asyncio.get_running_loop().create_future(), | |
| ] | |
| for device, future in zip(devices, sco_connection_futures): | |
| device.on('sco_connection', future.set_result) | |
| await devices[0].send_command( | |
| hci.HCI_Enhanced_Setup_Synchronous_Connection_Command( | |
| connection_handle=connections[0].handle, | |
| **hfp.ESCO_PARAMETERS[hfp.DefaultCodecParameters.ESCO_CVSD_S1].asdict(), | |
| ) | |
| ) | |
| sco_connections = await asyncio.gather(*sco_connection_futures) | |
| sco_disconnection_futures = [ | |
| asyncio.get_running_loop().create_future(), | |
| asyncio.get_running_loop().create_future(), | |
| ] | |
| for future, sco_connection in zip(sco_disconnection_futures, sco_connections): | |
| sco_connection.on('disconnection', future.set_result) | |
| await sco_connections[0].disconnect() | |
| await asyncio.gather(*sco_disconnection_futures) | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_hf_batched_response( | |
| hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol], | |
| ): | |
| hf, ag = hfp_connections | |
| ag.dlc.write(b'\r\n+BIND: (1,2)\r\n\r\nOK\r\n') | |
| await hf.execute_command("AT+BIND=?", response_type=hfp.AtResponseType.SINGLE) | |
| # ----------------------------------------------------------------------------- | |
| @pytest.mark.asyncio | |
| async def test_ag_batched_commands( | |
| hfp_connections: tuple[hfp.HfProtocol, hfp.AgProtocol], | |
| ): | |
| hf, ag = hfp_connections | |
| answer_future = asyncio.get_running_loop().create_future() | |
| ag.on('answer', lambda: answer_future.set_result(None)) | |
| hang_up_future = asyncio.get_running_loop().create_future() | |
| ag.on('hang_up', lambda: hang_up_future.set_result(None)) | |
| hf.dlc.write(b'ATA\rAT+CHUP\r') | |
| await answer_future | |
| await hang_up_future | |
| # ----------------------------------------------------------------------------- | |
| async def run(): | |
| await test_slc() | |
| # ----------------------------------------------------------------------------- | |
| if __name__ == '__main__': | |
| logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) | |
| asyncio.run(run()) |