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()