diff --git a/.coveragerc b/.coveragerc index 02d59b55f5f..6b239402cb1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -31,7 +31,6 @@ omit = homeassistant/components/amcrest/* homeassistant/components/ampio/* homeassistant/components/android_ip_webcam/* - homeassistant/components/androidtv/* homeassistant/components/anel_pwrctrl/switch.py homeassistant/components/anthemav/media_player.py homeassistant/components/apache_kafka/* diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 047eaaaf5db..91ea4019c05 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -3,7 +3,7 @@ "name": "Androidtv", "documentation": "https://www.home-assistant.io/components/androidtv", "requirements": [ - "androidtv==0.0.24" + "androidtv==0.0.25" ], "dependencies": [], "codeowners": ["@JeffLIrion"] diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index db4ff9e851e..2db210b56f3 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -431,8 +431,10 @@ class AndroidTVDevice(ADBDevice): # Try to connect self._available = self.aftv.connect(always_log_errors=False) - # To be safe, wait until the next update to run ADB commands. - return + # To be safe, wait until the next update to run ADB commands if + # using the Python ADB implementation. + if not self.aftv.adb_server_ip: + return # If the ADB connection is not intact, don't update. if not self._available: @@ -443,7 +445,9 @@ class AndroidTVDevice(ADBDevice): self.aftv.update() ) - self._state = ANDROIDTV_STATES[state] + self._state = ANDROIDTV_STATES.get(state) + if self._state is None: + self._available = False @property def is_volume_muted(self): @@ -506,8 +510,10 @@ class FireTVDevice(ADBDevice): # Try to connect self._available = self.aftv.connect(always_log_errors=False) - # To be safe, wait until the next update to run ADB commands. - return + # To be safe, wait until the next update to run ADB commands if + # using the Python ADB implementation. + if not self.aftv.adb_server_ip: + return # If the ADB connection is not intact, don't update. if not self._available: @@ -518,7 +524,9 @@ class FireTVDevice(ADBDevice): self._get_sources ) - self._state = ANDROIDTV_STATES[state] + self._state = ANDROIDTV_STATES.get(state) + if self._state is None: + self._available = False @property def source(self): diff --git a/requirements_all.txt b/requirements_all.txt index cb489a0aa68..c241f5fd4a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -194,7 +194,7 @@ ambiclimate==0.2.1 amcrest==1.5.3 # homeassistant.components.androidtv -androidtv==0.0.24 +androidtv==0.0.25 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1caf72deed..ed0689654a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -78,6 +78,9 @@ aiowwlln==1.0.0 # homeassistant.components.ambiclimate ambiclimate==0.2.1 +# homeassistant.components.androidtv +androidtv==0.0.25 + # homeassistant.components.apns apns2==0.3.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ce0aa672135..6a181ab6b00 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -55,6 +55,7 @@ TEST_REQUIREMENTS = ( "aiounifi", "aioswitcher", "aiowwlln", + "androidtv", "apns2", "aprslib", "av", diff --git a/tests/components/androidtv/__init__.py b/tests/components/androidtv/__init__.py new file mode 100644 index 00000000000..34e8c745fdc --- /dev/null +++ b/tests/components/androidtv/__init__.py @@ -0,0 +1 @@ +"""Tests for the androidtv component.""" diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py new file mode 100644 index 00000000000..e787fddd3bc --- /dev/null +++ b/tests/components/androidtv/test_media_player.py @@ -0,0 +1,232 @@ +"""The tests for the androidtv platform.""" +import logging +from socket import error as socket_error +import unittest +from unittest.mock import patch + +from homeassistant.components.androidtv.media_player import ( + AndroidTVDevice, + FireTVDevice, + setup, +) + + +def connect_device_success(self, *args, **kwargs): + """Return `self`, which will result in the ADB connection being interpreted as available.""" + return self + + +def connect_device_fail(self, *args, **kwargs): + """Raise a socket error.""" + raise socket_error + + +def adb_shell_python_adb_error(self, cmd): + """Raise an error that is among those caught for the Python ADB implementation.""" + raise AttributeError + + +def adb_shell_adb_server_error(self, cmd): + """Raise an error that is among those caught for the ADB server implementation.""" + raise ConnectionResetError + + +class AdbAvailable: + """A class that indicates the ADB connection is available.""" + + def shell(self, cmd): + """Send an ADB shell command (ADB server implementation).""" + return "" + + +class AdbUnavailable: + """A class with ADB shell methods that raise errors.""" + + def __bool__(self): + """Return `False` to indicate that the ADB connection is unavailable.""" + return False + + def shell(self, cmd): + """Raise an error that pertains to the Python ADB implementation.""" + raise ConnectionResetError + + +PATCH_PYTHON_ADB_CONNECT_SUCCESS = patch( + "adb.adb_commands.AdbCommands.ConnectDevice", connect_device_success +) +PATCH_PYTHON_ADB_COMMAND_SUCCESS = patch( + "adb.adb_commands.AdbCommands.Shell", return_value="" +) +PATCH_PYTHON_ADB_CONNECT_FAIL = patch( + "adb.adb_commands.AdbCommands.ConnectDevice", connect_device_fail +) +PATCH_PYTHON_ADB_COMMAND_FAIL = patch( + "adb.adb_commands.AdbCommands.Shell", adb_shell_python_adb_error +) +PATCH_PYTHON_ADB_COMMAND_NONE = patch( + "adb.adb_commands.AdbCommands.Shell", return_value=None +) + +PATCH_ADB_SERVER_CONNECT_SUCCESS = patch( + "adb_messenger.client.Client.device", return_value=AdbAvailable() +) +PATCH_ADB_SERVER_AVAILABLE = patch( + "androidtv.basetv.BaseTV.available", return_value=True +) +PATCH_ADB_SERVER_CONNECT_FAIL = patch( + "adb_messenger.client.Client.device", return_value=AdbUnavailable() +) +PATCH_ADB_SERVER_COMMAND_FAIL = patch( + "{}.AdbAvailable.shell".format(__name__), adb_shell_adb_server_error +) +PATCH_ADB_SERVER_COMMAND_NONE = patch( + "{}.AdbAvailable.shell".format(__name__), return_value=None +) + + +class TestAndroidTVPythonImplementation(unittest.TestCase): + """Test the androidtv media player for an Android TV device.""" + + def setUp(self): + """Set up an `AndroidTVDevice` media player.""" + with PATCH_PYTHON_ADB_CONNECT_SUCCESS, PATCH_PYTHON_ADB_COMMAND_SUCCESS: + aftv = setup("IP:PORT", device_class="androidtv") + self.aftv = AndroidTVDevice(aftv, "Fake Android TV", {}, None, None) + + def test_reconnect(self): + """Test that the error and reconnection attempts are logged correctly. + + "Handles device/service unavailable. Log a warning once when + unavailable, log once when reconnected." + + https://developers.home-assistant.io/docs/en/integration_quality_scale_index.html + """ + with self.assertLogs(level=logging.WARNING) as logs: + with PATCH_PYTHON_ADB_CONNECT_FAIL, PATCH_PYTHON_ADB_COMMAND_FAIL: + for _ in range(5): + self.aftv.update() + self.assertFalse(self.aftv.available) + self.assertIsNone(self.aftv.state) + + assert len(logs.output) == 2 + assert logs.output[0].startswith("ERROR") + assert logs.output[1].startswith("WARNING") + + with self.assertLogs(level=logging.DEBUG) as logs: + with PATCH_PYTHON_ADB_CONNECT_SUCCESS, PATCH_PYTHON_ADB_COMMAND_SUCCESS: + # Update 1 will reconnect + self.aftv.update() + self.assertTrue(self.aftv.available) + + # Update 2 will update the state + self.aftv.update() + self.assertTrue(self.aftv.available) + self.assertIsNotNone(self.aftv.state) + + assert ( + "ADB connection to {} successfully established".format(self.aftv.aftv.host) + in logs.output[0] + ) + + def test_adb_shell_returns_none(self): + """Test the case that the ADB shell command returns `None`. + + The state should be `None` and the device should be unavailable. + """ + with PATCH_PYTHON_ADB_COMMAND_NONE: + self.aftv.update() + self.assertFalse(self.aftv.available) + self.assertIsNone(self.aftv.state) + + with PATCH_PYTHON_ADB_CONNECT_SUCCESS, PATCH_PYTHON_ADB_COMMAND_SUCCESS: + # Update 1 will reconnect + self.aftv.update() + self.assertTrue(self.aftv.available) + + # Update 2 will update the state + self.aftv.update() + self.assertTrue(self.aftv.available) + self.assertIsNotNone(self.aftv.state) + + +class TestAndroidTVServerImplementation(unittest.TestCase): + """Test the androidtv media player for an Android TV device.""" + + def setUp(self): + """Set up an `AndroidTVDevice` media player.""" + with PATCH_ADB_SERVER_CONNECT_SUCCESS, PATCH_ADB_SERVER_AVAILABLE: + aftv = setup( + "IP:PORT", adb_server_ip="ADB_SERVER_IP", device_class="androidtv" + ) + self.aftv = AndroidTVDevice(aftv, "Fake Android TV", {}, None, None) + + def test_reconnect(self): + """Test that the error and reconnection attempts are logged correctly. + + "Handles device/service unavailable. Log a warning once when + unavailable, log once when reconnected." + + https://developers.home-assistant.io/docs/en/integration_quality_scale_index.html + """ + with self.assertLogs(level=logging.WARNING) as logs: + with PATCH_ADB_SERVER_CONNECT_FAIL, PATCH_ADB_SERVER_COMMAND_FAIL: + for _ in range(5): + self.aftv.update() + self.assertFalse(self.aftv.available) + self.assertIsNone(self.aftv.state) + + assert len(logs.output) == 2 + assert logs.output[0].startswith("ERROR") + assert logs.output[1].startswith("WARNING") + + with self.assertLogs(level=logging.DEBUG) as logs: + with PATCH_ADB_SERVER_CONNECT_SUCCESS: + self.aftv.update() + self.assertTrue(self.aftv.available) + self.assertIsNotNone(self.aftv.state) + + assert ( + "ADB connection to {} via ADB server {}:{} successfully established".format( + self.aftv.aftv.host, + self.aftv.aftv.adb_server_ip, + self.aftv.aftv.adb_server_port, + ) + in logs.output[0] + ) + + def test_adb_shell_returns_none(self): + """Test the case that the ADB shell command returns `None`. + + The state should be `None` and the device should be unavailable. + """ + with PATCH_ADB_SERVER_COMMAND_NONE: + self.aftv.update() + self.assertFalse(self.aftv.available) + self.assertIsNone(self.aftv.state) + + with PATCH_ADB_SERVER_CONNECT_SUCCESS: + self.aftv.update() + self.assertTrue(self.aftv.available) + self.assertIsNotNone(self.aftv.state) + + +class TestFireTVPythonImplementation(TestAndroidTVPythonImplementation): + """Test the androidtv media player for a Fire TV device.""" + + def setUp(self): + """Set up a `FireTVDevice` media player.""" + with PATCH_PYTHON_ADB_CONNECT_SUCCESS, PATCH_PYTHON_ADB_COMMAND_SUCCESS: + aftv = setup("IP:PORT", device_class="firetv") + self.aftv = FireTVDevice(aftv, "Fake Fire TV", {}, True, None, None) + + +class TestFireTVServerImplementation(TestAndroidTVServerImplementation): + """Test the androidtv media player for a Fire TV device.""" + + def setUp(self): + """Set up a `FireTVDevice` media player.""" + with PATCH_ADB_SERVER_CONNECT_SUCCESS, PATCH_ADB_SERVER_AVAILABLE: + aftv = setup( + "IP:PORT", adb_server_ip="ADB_SERVER_IP", device_class="firetv" + ) + self.aftv = FireTVDevice(aftv, "Fake Fire TV", {}, True, None, None)