No public description PiperOrigin-RevId: 568108467
diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ec8596 --- /dev/null +++ b/README.md
@@ -0,0 +1,43 @@ +# Google Home UI Automator + +Google Home Automator can help you automating your Google Home App. + +## Getting Started + +### Prerequisites + +#### Python 3 + +You need a python 3 environment to run the script. + +#### Android phone + +1. turn on `User Debugging` mode on your android phone. +1. connect your android phone to computer. + +#### Google Home App + +1. You need to install Google Home App on your Android phone. +1. Login to your Google Home App. +1. Make sure the Google Home App's version is between `3.1.1.14` and `3.5.1.4`. + +### Installation + +1. clone this repo. +1. `cd` to the folder. + +## Usage + +### Commissioning Matter device + +1. run script `mobly-manager --commission m5stack,34970112332,Office` + - the parameters are + * desired Matter device + * pairing code of your Matter device + * room that is going to be assigned + +## Roadmap + +- [x] Commissioning Matter device + +## Notice
diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/__init__.py
diff --git a/errors.py b/errors.py new file mode 100644 index 0000000..56ecc3c --- /dev/null +++ b/errors.py
@@ -0,0 +1,24 @@ +"""Module for UI Automator related errors. + +The error subclasses are intended to make it easier to distinguish between and +handle different types of error exceptions. +""" +DEFAULT_ERROR_CODE = 0 + + +class NoAndroidDeviceError(Exception): + """Raised when no Android device connected to the host computer.""" + + err_code = 1 + + +class AndroidDeviceNotReadyError(Exception): + """Raised when a Android device is not ready to be controlled.""" + + err_code = 2 + + +class AdbError(Exception): + """Raised when adb command fails.""" + + err_code = 3
diff --git a/ui_automator.py b/ui_automator.py new file mode 100644 index 0000000..88148f7 --- /dev/null +++ b/ui_automator.py
@@ -0,0 +1,134 @@ +"""UI Automator controller in python layer. + +A python controller that can trigger mobly UI automator snippet to achieve some +automated UI operations on Android phones. +""" +import logging +import os + +from mobly import utils +from mobly.controllers import android_device +from mobly.controllers.android_device_lib import adb +from mobly.snippet import errors as snippet_errors + +from ui_automator import errors + +_MOBLY_SNIPPET_APK: str = 'com.chip.interop.moblysnippet' +_MOBLY_SNIPPET_APK_NAME: str = 'mbs' + + +class UIAutomator: + """UI Automator controller in python layer.""" + + def __init__(self, logger: logging.Logger = logging.getLogger()): + """Inits UIAutomator. + + Interacts with android device by pre-installed apk. Can perform + methods provided by a snippet installed on the android device. + + Args: + logger: Injected logger, if None, specify the default(root) one. + """ + self._logger = logger + self._connected_device: android_device.AndroidDevice | None = None + + def load_device(self): + """Selects the first connected android device. + + Raises: + NoAndroidDeviceError: When no device is connected. + """ + try: + android_devices = android_device.get_all_instances() + except adb.AdbError as exc: + raise errors.AdbError( + 'Please install adb and add it to your PATH environment variable.' + ) from exc + + if not android_devices: + raise errors.NoAndroidDeviceError( + 'No Android device connected to the host computer.' + ) + self._connected_device = android_devices[0] + self._logger.info(f'connected device: [{self._connected_device}]') + + def load_snippet(self): + """Loads needed mobly snippet installed on android device. + + Raises: + NoAndroidDeviceError: When no device is connected. + AndroidDeviceNotReadyError: When required snippet apk can not be loaded + on device. + """ + if not self._connected_device: + raise errors.NoAndroidDeviceError( + 'No Android device connected to the host computer.' + ) + + if not self._is_apk_installed(self._connected_device, _MOBLY_SNIPPET_APK): + self._install_apk(self._connected_device, self._get_mbs_apk_path()) + + try: + self._connected_device.load_snippet( + _MOBLY_SNIPPET_APK_NAME, _MOBLY_SNIPPET_APK + ) + except snippet_errors.ServerStartPreCheckError as exc: + # Raises when apk not be installed or instrumented on device. + raise errors.AndroidDeviceNotReadyError( + f"Check device({self._connected_device.device_info['serial']}) has" + ' installed required apk.' + ) from exc + except ( + snippet_errors.ServerStartError, + snippet_errors.ProtocolError, + ) as exc: + raise errors.AndroidDeviceNotReadyError( + f"Check device({self._connected_device.device_info['serial']}) can" + ' load required apk.' + ) from exc + except android_device.SnippetError as e: + # Error raises when registering a package twice or a package with + # duplicated name. Thus, It is okay to pass here as long as the snippet + # has been loaded. + self._logger.debug(str(e)) + + def _is_apk_installed( + self, device: android_device.AndroidDevice, package_name: str + ) -> bool: + """Checks if given package is already installed on the Android device. + + Args: + device: An AndroidDevice object. + package_name: The Android app package name. + + Raises: + adb.AdbError: When executing adb command fails. + + Returns: + True if package is installed. False otherwise. + """ + out = device.adb.shell(['pm', 'list', 'package']) + return bool(utils.grep('^package:%s$' % package_name, out)) + + def _install_apk( + self, device: android_device.AndroidDevice, apk_path: str + ) -> None: + """Installs required apk snippet to the given Android device. + + Args: + device: An AndroidDevice object. + apk_path: The absolute file path where the apk is located. + """ + device.adb.install(['-r', '-g', apk_path]) + + def _get_mbs_apk_path(self) -> str: + return os.path.join( + os.path.dirname(os.path.abspath(__file__)), + 'android', + 'app', + 'snippet-0.0.0.apk', + ) + + +if __name__ == '__main__': + UIAutomator().load_device()
diff --git a/ui_automator_test.py b/ui_automator_test.py new file mode 100644 index 0000000..951c80a --- /dev/null +++ b/ui_automator_test.py
@@ -0,0 +1,165 @@ +"""Unittest Lab exercise to test implementation of "Synonym Dictionary".""" +import os +import unittest + +from unittest import mock +from mobly.controllers import android_device +from mobly.controllers.android_device_lib import adb +from mobly.snippet import errors as snippet_errors + +from ui_automator import errors +from ui_automator import ui_automator + + +class UIAutomatorTest(unittest.TestCase): + + def setUp(self): + """This method will be run before each of the test methods in the class.""" + super().setUp() + self.ui_automator = ui_automator.UIAutomator() + self.mock_android_device = mock.patch.object( + android_device, 'AndroidDevice' + ).start() + + @mock.patch.object(android_device, 'get_all_instances', autospec=True) + def test_load_device_raises_an_error_when_adb_not_installed( + self, mock_get_all_instances + ): + mock_get_all_instances.side_effect = adb.AdbError( + cmd='adb devices', + stdout='fake_msg', + stderr='adb command not found', + ret_code=1, + ) + with self.assertRaisesRegex( + errors.AdbError, + r'Please install adb and add it to your PATH environment variable\.', + ): + self.ui_automator.load_device() + + @mock.patch.object( + android_device, 'get_all_instances', autospec=True, return_value=[] + ) + def test_load_device_no_android_device_error( + self, unused_mock_get_all_instances + ): + with self.assertRaises(errors.NoAndroidDeviceError): + self.ui_automator.load_device() + + @mock.patch.object( + android_device, 'get_all_instances', autospec=True, return_value=[1, 2] + ) + def test_load_device_success(self, unused_mock_get_all_instances): + with self.assertLogs() as cm: + self.ui_automator.load_device() + self.assertEqual(cm.output, ['INFO:root:connected device: [1]']) + + def test_load_snippet_without_load_device_raises_error(self): + with self.assertRaises(errors.NoAndroidDeviceError): + self.ui_automator.load_snippet() + + @mock.patch.object(android_device, 'get_all_instances', autospec=True) + def test_load_snippet_success(self, mock_get_all_instances): + mock_get_all_instances.return_value = [self.mock_android_device] + + self.ui_automator.load_device() + self.ui_automator.load_snippet() + + @mock.patch.object(android_device, 'get_all_instances', autospec=True) + def test_load_snippet_fails_with_server_start_pre_check_error( + self, mock_get_all_instances + ): + self.mock_android_device.load_snippet.side_effect = ( + snippet_errors.ServerStartPreCheckError('fake_ad', 'fake_msg') + ) + mock_get_all_instances.return_value = [self.mock_android_device] + + self.ui_automator.load_device() + + with self.assertRaises(errors.AndroidDeviceNotReadyError): + self.ui_automator.load_snippet() + + @mock.patch.object(android_device, 'get_all_instances', autospec=True) + def test_load_snippet_fails_with_server_start_error( + self, mock_get_all_instances + ): + self.mock_android_device.load_snippet.side_effect = ( + snippet_errors.ServerStartError('fake_ad', 'fake_msg') + ) + mock_get_all_instances.return_value = [self.mock_android_device] + + self.ui_automator.load_device() + + with self.assertRaises(errors.AndroidDeviceNotReadyError): + self.ui_automator.load_snippet() + + @mock.patch.object(android_device, 'get_all_instances', autospec=True) + def test_load_snippet_fails_with_protocol_error(self, mock_get_all_instances): + self.mock_android_device.load_snippet.side_effect = ( + snippet_errors.ProtocolError('fake_ad', 'fake_msg') + ) + mock_get_all_instances.return_value = [self.mock_android_device] + + self.ui_automator.load_device() + + with self.assertRaises(errors.AndroidDeviceNotReadyError): + self.ui_automator.load_snippet() + + @mock.patch.object(android_device, 'get_all_instances', autospec=True) + def test_load_snippet_fails_with_snippet_error(self, mock_get_all_instances): + self.mock_android_device.load_snippet.side_effect = ( + android_device.SnippetError('fake_device', 'fake_msg') + ) + mock_get_all_instances.return_value = [self.mock_android_device] + + self.ui_automator.load_device() + + with self.assertLogs(level='DEBUG') as cm: + self.ui_automator.load_snippet() + self.assertEqual( + cm.output, + [ + "DEBUG:root:'fake_device'::Service<SnippetManagementService>" + ' fake_msg' + ], + ) + + @mock.patch.object(android_device, 'get_all_instances', autospec=True) + @mock.patch.object( + os.path, 'dirname', autospec=True, return_value='/path/to/' + ) + def test_load_snippet_installs_apk_when_apk_is_not_installed( + self, mock_dirname, mock_get_all_instances + ): + self.mock_android_device.adb.shell.return_value = ( + b'package:installed.apk\n' + ) + mock_get_all_instances.return_value = [self.mock_android_device] + self.ui_automator.load_device() + + self.ui_automator.load_snippet() + + self.mock_android_device.adb.install.assert_called_once_with( + ['-r', '-g', '/path/to/android/app/snippet-0.0.0.apk'] + ) + mock_dirname.assert_called_once() + + @mock.patch.object(android_device, 'get_all_instances', autospec=True) + @mock.patch.object(os.path, 'dirname', autospec=True) + def test_load_snippet_should_not_install_apk_when_apk_is_installed( + self, mock_dirname, mock_get_all_instances + ): + self.mock_android_device.adb.shell.return_value = ( + b'package:com.chip.interop.moblysnippet' + ) + mock_get_all_instances.return_value = [self.mock_android_device] + self.ui_automator.load_device() + + self.ui_automator.load_snippet() + + self.mock_android_device.adb.install.assert_not_called() + mock_dirname.assert_not_called() + + +if __name__ == '__main__': + unittest.main()