"""Controller class for an android bt device with git_master-bds-dev build. The config for this derived_bt_target_device in mobileharness is: - name: android_bt_target_device devices: - type: MiscTestbedSubDevice dimensions: mobly_type: DerivedBtDevice properties: ModuleName: android_bt_target_device ClassName: AndroidBtTargetDevice Params: config: device_id: phone_serial_number audio_params: channel: 2 duration: 50 music_file: "music.wav" sample_rate: 44100 """ import logging import os import time from typing import Any, Dict, Optional from mobly import asserts from mobly import signals from mobly.controllers import android_device # Internal import from blueberry.utils import android_bluetooth_decorator from blueberry.utils import bt_constants from blueberry.utils import bt_test_utils as btutils _CONNECTION_STATE = bt_constants.BluetoothConnectionStatus ADB_FILE = 'rec.pcm' ADB_PATH = '/sdcard/Music/' WAVE_FILE_TEMPLATE = 'recorded_audio_%s.wav' DEFAULT_WAIT_TIME = 3.0 # A MediaBrowserService implemented in the SL4A app to intercept Media keys and # commands. BLUETOOTH_SL4A_AUDIO_SRC_MBS = 'BluetoothSL4AAudioSrcMBS' A2DP_HFP_PROFILES = [ bt_constants.BluetoothProfile.A2DP_SINK, bt_constants.BluetoothProfile.HEADSET_CLIENT ] class AndroidBtTargetDevice(object): """Implements an android device as a hfp and a2dp sink device. With git_master-bds-dev build, the android device can act as a bluetooth hfp and a2dp sink device. """ def __init__(self, config: Dict[str, Any]) -> None: """Initializes an android hfp device.""" logging.info('Initializes the android hfp device') self.config = config self.pri_ad = None self.sec_ad = None self.serial = config.get('device_id', None) self.audio_params = config.get('audio_params', None) if self.serial: # self._ad for accessing the device at the end of the test self._ad = android_device.AndroidDevice(self.serial) self.aud = adb_ui_device.AdbUiDevice(self._ad) self.pri_ad = android_bluetooth_decorator.AndroidBluetoothDecorator( self._ad) self.pri_ad.init_setup() self.pri_ad.sl4a_setup() self.sl4a = self._ad.services.sl4a self.mac_address = self.sl4a.bluetoothGetLocalAddress() if self.audio_params: self._initialize_audio_params() self.avrcp_ready = False def __getattr__(self, name: str) -> Any: return getattr(self.pri_ad, name) def _disable_profiles(self) -> None: if self.sec_ad is None: raise MissingBtClientDeviceError('Please provide sec_ad forsetting' 'profiles') self.set_profiles_policy_off(self.sec_ad, A2DP_HFP_PROFILES) def _initialize_audio_params(self) -> None: self.audio_capture_path = os.path.join(self._ad.log_path, 'audio_capture') os.makedirs(self.audio_capture_path) self.adb_path = os.path.join(ADB_PATH, ADB_FILE) self.wave_file_template = os.path.join(self.audio_capture_path, WAVE_FILE_TEMPLATE) self.wave_file_number = 0 def _verify_pri_ad(self) -> None: if not self.pri_ad: raise signals.ControllerError('No be target device') def clean_up(self) -> None: """Resets Bluetooth and stops all services when the device is destroyed.""" self.deactivate_ble_pairing_mode() self.factory_reset_bluetooth() self._ad.services.stop_all() def a2dp_sink_connect(self) -> bool: """Establishes the hft connection between self.pri_ad and self.sec_ad.""" self._verify_pri_ad() connected = self.pri_ad.a2dp_sink_connect(self.sec_ad) asserts.assert_true( connected, 'The a2dp sink connection between {} and {} failed'.format( self.serial, self.sec_ad.serial)) self.log.info('The a2dp sink connection between %s and %s succeeded', self.serial, self.sec_ad.serial) return True def activate_pairing_mode(self) -> None: """Makes the android hfp device discoverable over Bluetooth.""" self.log.info('Activating the pairing mode of the android target device') self.pri_ad.activate_pairing_mode() def activate_ble_pairing_mode(self) -> None: """Activates BLE pairing mode on an AndroidBtTargetDevice.""" self.pri_ad.activate_ble_pairing_mode() def deactivate_ble_pairing_mode(self) -> None: """Deactivates BLE pairing mode on an AndroidBtTargetDevice.""" self.pri_ad.deactivate_ble_pairing_mode() def add_pri_ad_device(self, pri_ad: android_device.AndroidDevice) -> None: """Adds primary android device as bt target device. The primary android device should have been initialized with android_bluetooth_decorator. Args: pri_ad: the primary android device as bt target device. """ self._ad = pri_ad self.pri_ad = pri_ad self.sl4a = self._ad.services.sl4a self.mac_address = self.sl4a.bluetoothGetLocalAddress() self.log = self.pri_ad.log self.serial = self.pri_ad.serial self.log.info( 'Adds primary android device with id %s for the bluetooth' 'connection', pri_ad.serial) if self.audio_params: self._initialize_audio_params() def add_sec_ad_device(self, sec_ad: android_device.AndroidDevice) -> None: """Adds second android device for bluetooth connection. The second android device should have sl4a service acitvated. Args: sec_ad: the second android device for bluetooth connection. """ self.log.info( 'Adds second android device with id %s for the bluetooth' 'connection', sec_ad.serial) self.sec_ad = sec_ad self.sec_ad_mac_address = self.sec_ad.sl4a.bluetoothGetLocalAddress() def answer_phone_call(self) -> bool: """Answers an incoming phone call.""" if not self.is_hfp_connected(): self.hfp_connect() # Make sure the device is in ringing state. if not self.wait_for_call_state( bt_constants.CALL_STATE_RINGING, bt_constants.CALL_STATE_TIMEOUT_SEC): raise signals.ControllerError( 'Timed out after %ds waiting for the device %s to be ringing state ' 'before anwsering the incoming phone call.' % (bt_constants.CALL_STATE_TIMEOUT_SEC, self.serial)) self.log.info('Answers the incoming phone call from hf phone %s for %s', self.mac_address, self.sec_ad_mac_address) return self.sl4a.bluetoothHfpClientAcceptCall(self.sec_ad_mac_address) def call_volume_down(self) -> None: """Lowers the volume.""" current_volume = self.mbs.getVoiceCallVolume() if current_volume > 0: change_volume = current_volume - 1 self.log.debug('Set voice call volume from %d to %d.' % (current_volume, change_volume)) self.mbs.setVoiceCallVolume(change_volume) def call_volume_up(self) -> None: """Raises the volume.""" current_volume = self.mbs.getVoiceCallVolume() if current_volume < self.mbs.getVoiceCallMaxVolume(): change_volume = current_volume + 1 self.log.debug('Set voice call volume from %d to %d.' % (current_volume, change_volume)) self.mbs.setVoiceCallVolume(change_volume) def disconnect_all(self) -> None: self._disable_profiles() def factory_reset_bluetooth(self) -> None: """Factory resets Bluetooth on the android hfp device.""" self.log.info('Factory resets Bluetooth on the android target device') self.pri_ad.factory_reset_bluetooth() def get_bluetooth_mac_address(self) -> str: """Gets Bluetooth mac address of this android_bt_device.""" self.log.info('Getting Bluetooth mac address for AndroidBtTargetDevice.') mac_address = self.sl4a.bluetoothGetLocalAddress() self.log.info('Bluetooth mac address of AndroidBtTargetDevice: %s', mac_address) return mac_address def get_audio_params(self) -> Optional[Dict[str, str]]: """Gets audio params from the android_bt_target_device.""" return self.audio_params def get_new_wave_file_path(self) -> str: """Gets a new wave file path for the audio capture.""" wave_file_path = self.wave_file_template % self.wave_file_number while os.path.exists(wave_file_path): self.wave_file_number += 1 wave_file_path = self.wave_file_template % self.wave_file_number return wave_file_path def get_unread_messages(self) -> None: """Gets unread messages from the connected device (MSE).""" self.sl4a.mapGetUnreadMessages(self.sec_ad_mac_address) def hangup_phone_call(self) -> bool: """Hangs up an ongoing phone call.""" if not self.is_hfp_connected(): self.hfp_connect() self.log.info('Hangs up the phone call from hf phone %s for %s', self.mac_address, self.sec_ad_mac_address) return self.sl4a.bluetoothHfpClientTerminateAllCalls( self.sec_ad_mac_address) def hfp_connect(self) -> bool: """Establishes the hft connection between self.pri_ad and self.sec_ad.""" self._verify_pri_ad() connected = self.pri_ad.hfp_connect(self.sec_ad) asserts.assert_true( connected, 'The hfp connection between {} and {} failed'.format( self.serial, self.sec_ad.serial)) self.log.info('The hfp connection between %s and %s succeed', self.serial, self.sec_ad.serial) return connected def init_ambs_for_avrcp(self) -> bool: """Initializes media browser service for avrcp. This is required to be done before running any of the passthrough commands. Steps: 1. Starts up the AvrcpMediaBrowserService on the A2dp source phone. This MediaBrowserService is part of the SL4A app. 2. Switch the playback state to be paused. 3. Connects a MediaBrowser to the A2dp sink's A2dpMediaBrowserService. Returns: True: if it is avrcp ready after the initialization. False: if it is still not avrcp ready after the initialization. Raises: Signals.ControllerError: raise if AvrcpMediaBrowserService on the A2dp source phone fails to be started. """ if self.is_avrcp_ready(): return True if not self.is_a2dp_sink_connected(): self.a2dp_sink_connect() self.sec_ad.log.info('Starting AvrcpMediaBrowserService') self.sec_ad.sl4a.bluetoothMediaPhoneSL4AMBSStart() time.sleep(DEFAULT_WAIT_TIME) # Check if the media session "BluetoothSL4AAudioSrcMBS" is active on sec_ad. active_sessions = self.sec_ad.sl4a.bluetoothMediaGetActiveMediaSessions() if BLUETOOTH_SL4A_AUDIO_SRC_MBS not in active_sessions: raise signals.ControllerError('Failed to start AvrcpMediaBrowserService.') self.log.info('Connecting to A2dp media browser service') self.sl4a.bluetoothMediaConnectToCarMBS() # TODO(user) Wait for an event back instead of sleep time.sleep(DEFAULT_WAIT_TIME) self.avrcp_ready = True return self.avrcp_ready def is_avrcp_ready(self) -> bool: """Checks if the pri_ad and sec_ad are ready for avrcp.""" self._verify_pri_ad() if self.avrcp_ready: return True active_sessions = self.sl4a.bluetoothMediaGetActiveMediaSessions() if not active_sessions: self.log.info('The device is not avrcp ready') self.avrcp_ready = False else: self.log.info('The device is avrcp ready') self.avrcp_ready = True return self.avrcp_ready def is_hfp_connected(self) -> _CONNECTION_STATE: """Checks if the pri_ad and sec_ad are hfp connected.""" self._verify_pri_ad() if self.sec_ad is None: raise MissingBtClientDeviceError('The sec_ad was not added') return self.sl4a.bluetoothHfpClientGetConnectionStatus( self.sec_ad_mac_address) def is_a2dp_sink_connected(self) -> _CONNECTION_STATE: """Checks if the pri_ad and sec_ad are hfp connected.""" self._verify_pri_ad() if self.sec_ad is None: raise MissingBtClientDeviceError('The sec_ad was not added') return self.sl4a.bluetoothA2dpSinkGetConnectionStatus( self.sec_ad_mac_address) def last_number_dial(self) -> None: """Redials last outgoing phone number.""" if not self.is_hfp_connected(): self.hfp_connect() self.log.info('Redials last number from hf phone %s for %s', self.mac_address, self.sec_ad_mac_address) self.sl4a.bluetoothHfpClientDial(self.sec_ad_mac_address, None) def map_connect(self) -> None: """Establishes the map connection between self.pri_ad and self.sec_ad.""" self._verify_pri_ad() connected = self.pri_ad.map_connect(self.sec_ad) asserts.assert_true( connected, 'The map connection between {} and {} failed'.format( self.serial, self.sec_ad.serial)) self.log.info('The map connection between %s and %s succeed', self.serial, self.sec_ad.serial) def map_disconnect(self) -> None: """Initiates a map disconnection to the connected device. Raises: BluetoothProfileConnectionError: raised if failed to disconnect. """ self._verify_pri_ad() if not self.pri_ad.map_disconnect(self.sec_ad_mac_address): raise BluetoothProfileConnectionError( 'Failed to terminate the MAP connection with the device "%s".' % self.sec_ad_mac_address) def pbap_connect(self) -> None: """Establishes the pbap connection between self.pri_ad and self.sec_ad.""" connected = self.pri_ad.pbap_connect(self.sec_ad) asserts.assert_true( connected, 'The pbap connection between {} and {} failed'.format( self.serial, self.sec_ad.serial)) self.log.info('The pbap connection between %s and %s succeed', self.serial, self.sec_ad.serial) def pause(self) -> None: """Sends Avrcp pause command.""" self.send_media_passthrough_cmd(bt_constants.CMD_MEDIA_PAUSE, self.sec_ad) def play(self) -> None: """Sends Avrcp play command.""" self.send_media_passthrough_cmd(bt_constants.CMD_MEDIA_PLAY, self.sec_ad) def power_on(self) -> bool: """Turns the Bluetooth on the android bt garget device.""" self.log.info('Turns on the bluetooth') return self.sl4a.bluetoothToggleState(True) def power_off(self) -> bool: """Turns the Bluetooth off the android bt garget device.""" self.log.info('Turns off the bluetooth') return self.sl4a.bluetoothToggleState(False) def route_call_audio(self, connect: bool = False) -> None: """Routes call audio during a call.""" if not self.is_hfp_connected(): self.hfp_connect() self.log.info( 'Routes call audio during a call from hf phone %s for %s ' 'audio connection %s after routing', self.mac_address, self.sec_ad_mac_address, connect) if connect: self.sl4a.bluetoothHfpClientConnectAudio(self.sec_ad_mac_address) else: self.sl4a.bluetoothHfpClientDisconnectAudio(self.sec_ad_mac_address) def reject_phone_call(self) -> bool: """Rejects an incoming phone call.""" if not self.is_hfp_connected(): self.hfp_connect() # Make sure the device is in ringing state. if not self.wait_for_call_state( bt_constants.CALL_STATE_RINGING, bt_constants.CALL_STATE_TIMEOUT_SEC): raise signals.ControllerError( 'Timed out after %ds waiting for the device %s to be ringing state ' 'before rejecting the incoming phone call.' % (bt_constants.CALL_STATE_TIMEOUT_SEC, self.serial)) self.log.info('Rejects the incoming phone call from hf phone %s for %s', self.mac_address, self.sec_ad_mac_address) return self.sl4a.bluetoothHfpClientRejectCall(self.sec_ad_mac_address) def set_audio_params(self, audio_params: Optional[Dict[str, str]]) -> None: """Sets audio params to the android_bt_target_device.""" self.audio_params = audio_params def track_previous(self) -> None: """Sends Avrcp skip prev command.""" self.send_media_passthrough_cmd( bt_constants.CMD_MEDIA_SKIP_PREV, self.sec_ad) def track_next(self) -> None: """Sends Avrcp skip next command.""" self.send_media_passthrough_cmd( bt_constants.CMD_MEDIA_SKIP_NEXT, self.sec_ad) def start_audio_capture(self, duration_sec: int = 20) -> None: """Starts the audio capture over adb. Args: duration_sec: int, Number of seconds to record audio, 20 secs as default. """ if 'duration' in self.audio_params.keys(): duration_sec = self.audio_params['duration'] if not self.is_a2dp_sink_connected(): self.a2dp_sink_connect() cmd = 'ap2f --usage 1 --start --duration {} --target {}'.format( duration_sec, self.adb_path) self.log.info('Starts capturing audio with adb shell command %s', cmd) self.adb.shell(cmd) def stop_audio_capture(self) -> str: """Stops the audio capture and stores it in wave file. Returns: File name of the recorded file. Raises: MissingAudioParamsError: when self.audio_params is None """ if self.audio_params is None: raise MissingAudioParamsError('Missing audio params for capturing audio') if not self.is_a2dp_sink_connected(): self.a2dp_sink_connect() adb_pull_args = [self.adb_path, self.audio_capture_path] self.log.info('start adb -s %s pull %s', self.serial, adb_pull_args) self._ad.adb.pull(adb_pull_args) pcm_file_path = os.path.join(self.audio_capture_path, ADB_FILE) self.log.info('delete the recored file %s', self.adb_path) self._ad.adb.shell('rm {}'.format(self.adb_path)) wave_file_path = self.get_new_wave_file_path() self.log.info('convert pcm file %s to wav file %s', pcm_file_path, wave_file_path) btutils.convert_pcm_to_wav(pcm_file_path, wave_file_path, self.audio_params) return wave_file_path def stop_all_services(self) -> None: """Stops all services for the pri_ad device.""" self.log.info('Stops all services on the android bt target device') self._ad.services.stop_all() def stop_ambs_for_avrcp(self) -> None: """Stops media browser service for avrcp.""" if self.is_avrcp_ready(): self.log.info('Stops avrcp connection') self.sec_ad.sl4a.bluetoothMediaPhoneSL4AMBSStop() self.avrcp_ready = False def stop_voice_dial(self) -> None: """Stops voice dial.""" if not self.is_hfp_connected(): self.hfp_connect() self.log.info('Stops voice dial from hf phone %s for %s', self.mac_address, self.sec_ad_mac_address) if self.is_hfp_connected(): self.sl4a.bluetoothHfpClientStopVoiceRecognition( self.sec_ad_mac_address) def take_bug_report(self, test_name: Optional[str] = None, begin_time: Optional[int] = None, timeout: float = 300, destination: Optional[str] = None) -> None: """Wrapper method to capture bugreport on the android bt target device.""" self._ad.take_bug_report(test_name, begin_time, timeout, destination) def voice_dial(self) -> None: """Triggers voice dial.""" if not self.is_hfp_connected(): self.hfp_connect() self.log.info('Triggers voice dial from hf phone %s for %s', self.mac_address, self.sec_ad_mac_address) if self.is_hfp_connected(): self.sl4a.bluetoothHfpClientStartVoiceRecognition( self.sec_ad_mac_address) def log_type(self) -> str: """Gets the log type of Android bt target device. Returns: A string, the log type of Android bt target device. """ return bt_constants.LogType.BLUETOOTH_DEVICE_SIMULATOR.value class BluetoothProfileConnectionError(Exception): """Error for Bluetooth Profile connection problems.""" class MissingBtClientDeviceError(Exception): """Error for missing required bluetooth client device.""" class MissingAudioParamsError(Exception): """Error for missing the audio params."""