From 5ec5df77cc2cc956486e468f55492294bbf962ca Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Mon, 6 Jan 2020 14:10:13 -0800 Subject: [PATCH] Register 'androidtv.download' and 'androidtv.upload' services (#30086) * Add tests * Add FileSync test * Fill in services.yaml for 'androidtv.adb_filesync' service * Update example paths in services.yaml * Bump androidtv to 0.0.37 * Bump androidtv to 0.0.37 * Bump androidtv to 0.0.37 * Import LockNotAcquiredException * Import LockNotAcquiredException from androidtv.exceptions * Rename 'host' to 'address' * Add a logging statement when an ADB command is skipped * Check hass.config.is_allowed_path(local_path) * Add return * Fix pylint * Reduce duplicated code (AndroidTVDevice vs. FireTVDevice) * Split 'adb_filesync' service into 'download' and 'upload' services * Don't use '.get()' for required data; return if the services are already registered * Replace "command" with ATTR_COMMAND * Don't try to connect to a device if it is a duplicate --- .../components/androidtv/manifest.json | 2 +- .../components/androidtv/media_player.py | 142 ++++++++--- .../components/androidtv/services.yaml | 24 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/androidtv/test_media_player.py | 226 +++++++++++++++++- 6 files changed, 358 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 39e5bfb2cdf..92cee56f04a 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/androidtv", "requirements": [ "adb-shell==0.1.0", - "androidtv==0.0.36", + "androidtv==0.0.37", "pure-python-adb==0.2.2.dev0" ], "dependencies": [], diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 15acd594bee..24f8282f1b9 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -12,6 +12,7 @@ from adb_shell.exceptions import ( ) from androidtv import ha_state_detection_rules_validator, setup from androidtv.constants import APPS, KEYS +from androidtv.exceptions import LockNotAcquiredException import voluptuous as vol from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice @@ -72,6 +73,9 @@ SUPPORT_FIRETV = ( | SUPPORT_STOP ) +ATTR_DEVICE_PATH = "device_path" +ATTR_LOCAL_PATH = "local_path" + CONF_ADBKEY = "adbkey" CONF_ADB_SERVER_IP = "adb_server_ip" CONF_ADB_SERVER_PORT = "adb_server_port" @@ -92,11 +96,29 @@ DEVICE_FIRETV = "firetv" DEVICE_CLASSES = [DEFAULT_DEVICE_CLASS, DEVICE_ANDROIDTV, DEVICE_FIRETV] SERVICE_ADB_COMMAND = "adb_command" +SERVICE_DOWNLOAD = "download" +SERVICE_UPLOAD = "upload" SERVICE_ADB_COMMAND_SCHEMA = vol.Schema( {vol.Required(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_COMMAND): cv.string} ) +SERVICE_DOWNLOAD_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_DEVICE_PATH): cv.string, + vol.Required(ATTR_LOCAL_PATH): cv.string, + } +) + +SERVICE_UPLOAD_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_DEVICE_PATH): cv.string, + vol.Required(ATTR_LOCAL_PATH): cv.string, + } +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -133,7 +155,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Android TV / Fire TV platform.""" hass.data.setdefault(ANDROIDTV_DOMAIN, {}) - host = f"{config[CONF_HOST]}:{config[CONF_PORT]}" + address = f"{config[CONF_HOST]}:{config[CONF_PORT]}" + + if address in hass.data[ANDROIDTV_DOMAIN]: + _LOGGER.warning("Platform already setup on %s, skipping", address) + return if CONF_ADB_SERVER_IP not in config: # Use "adb_shell" (Python ADB implementation) @@ -192,44 +218,38 @@ def setup_platform(hass, config, add_entities, discovery_info=None): else: device_name = "Android TV / Fire TV device" - _LOGGER.warning("Could not connect to %s at %s %s", device_name, host, adb_log) + _LOGGER.warning( + "Could not connect to %s at %s %s", device_name, address, adb_log + ) raise PlatformNotReady - if host in hass.data[ANDROIDTV_DOMAIN]: - _LOGGER.warning("Platform already setup on %s, skipping", host) - else: - if aftv.DEVICE_CLASS == DEVICE_ANDROIDTV: - device = AndroidTVDevice( - aftv, - config[CONF_NAME], - config[CONF_APPS], - config[CONF_GET_SOURCES], - config.get(CONF_TURN_ON_COMMAND), - config.get(CONF_TURN_OFF_COMMAND), - ) - device_name = config[CONF_NAME] if CONF_NAME in config else "Android TV" - else: - device = FireTVDevice( - aftv, - config[CONF_NAME], - config[CONF_APPS], - config[CONF_GET_SOURCES], - config.get(CONF_TURN_ON_COMMAND), - config.get(CONF_TURN_OFF_COMMAND), - ) - device_name = config[CONF_NAME] if CONF_NAME in config else "Fire TV" + device_args = [ + aftv, + config[CONF_NAME], + config[CONF_APPS], + config[CONF_GET_SOURCES], + config.get(CONF_TURN_ON_COMMAND), + config.get(CONF_TURN_OFF_COMMAND), + ] - add_entities([device]) - _LOGGER.debug("Setup %s at %s %s", device_name, host, adb_log) - hass.data[ANDROIDTV_DOMAIN][host] = device + if aftv.DEVICE_CLASS == DEVICE_ANDROIDTV: + device = AndroidTVDevice(*device_args) + device_name = config.get(CONF_NAME, "Android TV") + else: + device = FireTVDevice(*device_args) + device_name = config.get(CONF_NAME, "Fire TV") + + add_entities([device]) + _LOGGER.debug("Setup %s at %s %s", device_name, address, adb_log) + hass.data[ANDROIDTV_DOMAIN][address] = device if hass.services.has_service(ANDROIDTV_DOMAIN, SERVICE_ADB_COMMAND): return def service_adb_command(service): """Dispatch service calls to target entities.""" - cmd = service.data.get(ATTR_COMMAND) - entity_id = service.data.get(ATTR_ENTITY_ID) + cmd = service.data[ATTR_COMMAND] + entity_id = service.data[ATTR_ENTITY_ID] target_devices = [ dev for dev in hass.data[ANDROIDTV_DOMAIN].values() @@ -255,6 +275,52 @@ def setup_platform(hass, config, add_entities, discovery_info=None): schema=SERVICE_ADB_COMMAND_SCHEMA, ) + def service_download(service): + """Download a file from your Android TV / Fire TV device to your Home Assistant instance.""" + local_path = service.data[ATTR_LOCAL_PATH] + if not hass.config.is_allowed_path(local_path): + _LOGGER.warning("'%s' is not secure to load data from!", local_path) + return + + device_path = service.data[ATTR_DEVICE_PATH] + entity_id = service.data[ATTR_ENTITY_ID] + target_device = [ + dev + for dev in hass.data[ANDROIDTV_DOMAIN].values() + if dev.entity_id in entity_id + ][0] + + target_device.adb_pull(local_path, device_path) + + hass.services.register( + ANDROIDTV_DOMAIN, + SERVICE_DOWNLOAD, + service_download, + schema=SERVICE_DOWNLOAD_SCHEMA, + ) + + def service_upload(service): + """Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" + local_path = service.data[ATTR_LOCAL_PATH] + if not hass.config.is_allowed_path(local_path): + _LOGGER.warning("'%s' is not secure to load data from!", local_path) + return + + device_path = service.data[ATTR_DEVICE_PATH] + entity_id = service.data[ATTR_ENTITY_ID] + target_devices = [ + dev + for dev in hass.data[ANDROIDTV_DOMAIN].values() + if dev.entity_id in entity_id + ] + + for target_device in target_devices: + target_device.adb_push(local_path, device_path) + + hass.services.register( + ANDROIDTV_DOMAIN, SERVICE_UPLOAD, service_upload, schema=SERVICE_UPLOAD_SCHEMA, + ) + def adb_decorator(override_available=False): """Wrap ADB methods and catch exceptions. @@ -274,6 +340,12 @@ def adb_decorator(override_available=False): try: return func(self, *args, **kwargs) + except LockNotAcquiredException: + # If the ADB lock could not be acquired, skip this command + _LOGGER.info( + "ADB command not executed because the connection is currently in use" + ) + return except self.exceptions as err: _LOGGER.error( "Failed to execute an ADB command. ADB connection re-" @@ -465,6 +537,16 @@ class ADBDevice(MediaPlayerDevice): self.schedule_update_ha_state() return self._adb_response + @adb_decorator() + def adb_pull(self, local_path, device_path): + """Download a file from your Android TV / Fire TV device to your Home Assistant instance.""" + self.aftv.adb_pull(local_path, device_path) + + @adb_decorator() + def adb_push(self, local_path, device_path): + """Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" + self.aftv.adb_push(local_path, device_path) + class AndroidTVDevice(ADBDevice): """Representation of an Android TV device.""" diff --git a/homeassistant/components/androidtv/services.yaml b/homeassistant/components/androidtv/services.yaml index 78ff0a828f6..96d70ef4998 100644 --- a/homeassistant/components/androidtv/services.yaml +++ b/homeassistant/components/androidtv/services.yaml @@ -9,3 +9,27 @@ adb_command: command: description: Either a key command or an ADB shell command. example: 'HOME' +download: + description: Download a file from your Android TV / Fire TV device to your Home Assistant instance. + fields: + entity_id: + description: Name of Android TV / Fire TV entity. + example: 'media_player.android_tv_living_room' + device_path: + description: The filepath on the Android TV / Fire TV device. + example: '/storage/emulated/0/Download/example.txt' + local_path: + description: The filepath on your Home Assistant instance. + example: '/config/example.txt' +upload: + description: Upload a file from your Home Assistant instance to an Android TV / Fire TV device. + fields: + entity_id: + description: Name(s) of Android TV / Fire TV entities. + example: 'media_player.android_tv_living_room' + device_path: + description: The filepath on the Android TV / Fire TV device. + example: '/storage/emulated/0/Download/example.txt' + local_path: + description: The filepath on your Home Assistant instance. + example: '/config/example.txt' diff --git a/requirements_all.txt b/requirements_all.txt index b82561c44e9..7e15cee687d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -220,7 +220,7 @@ ambiclimate==0.2.1 amcrest==1.5.3 # homeassistant.components.androidtv -androidtv==0.0.36 +androidtv==0.0.37 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 904322fe954..5ae63fa957f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -87,7 +87,7 @@ airly==0.0.2 ambiclimate==0.2.1 # homeassistant.components.androidtv -androidtv==0.0.36 +androidtv==0.0.37 # homeassistant.components.apns apns2==0.3.0 diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index 15c4897c136..b4030172939 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -1,11 +1,21 @@ """The tests for the androidtv platform.""" import logging +from unittest.mock import patch + +from androidtv.exceptions import LockNotAcquiredException from homeassistant.components.androidtv.media_player import ( ANDROIDTV_DOMAIN, + ATTR_COMMAND, + ATTR_DEVICE_PATH, + ATTR_LOCAL_PATH, CONF_ADB_SERVER_IP, CONF_ADBKEY, CONF_APPS, + KEYS, + SERVICE_ADB_COMMAND, + SERVICE_DOWNLOAD, + SERVICE_UPLOAD, ) from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, @@ -70,7 +80,7 @@ CONFIG_FIRETV_ADB_SERVER = { } -def _setup(hass, config): +def _setup(config): """Perform common setup tasks for the tests.""" if CONF_ADB_SERVER_IP not in config[DOMAIN]: patch_key = "python" @@ -93,7 +103,7 @@ async def _test_reconnect(hass, caplog, config): https://developers.home-assistant.io/docs/en/integration_quality_scale_index.html """ - patch_key, entity_id = _setup(hass, config) + patch_key, entity_id = _setup(config) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key @@ -164,7 +174,7 @@ async def _test_adb_shell_returns_none(hass, config): The state should be `None` and the device should be unavailable. """ - patch_key, entity_id = _setup(hass, config) + patch_key, entity_id = _setup(config) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key @@ -272,7 +282,7 @@ async def test_setup_with_adbkey(hass): """Test that setup succeeds when using an ADB key.""" config = CONFIG_ANDROIDTV_PYTHON_ADB.copy() config[DOMAIN][CONF_ADBKEY] = hass.config.path("user_provided_adbkey") - patch_key, entity_id = _setup(hass, config) + patch_key, entity_id = _setup(config) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key @@ -290,7 +300,7 @@ async def _test_sources(hass, config0): """Test that sources (i.e., apps) are handled correctly for Android TV and Fire TV devices.""" config = config0.copy() config[DOMAIN][CONF_APPS] = {"com.app.test1": "TEST 1"} - patch_key, entity_id = _setup(hass, config) + patch_key, entity_id = _setup(config) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key @@ -362,7 +372,7 @@ async def _test_select_source(hass, config0, source, expected_arg, method_patch) """Test that the methods for launching and stopping apps are called correctly when selecting a source.""" config = config0.copy() config[DOMAIN][CONF_APPS] = {"com.app.test1": "TEST 1"} - patch_key, entity_id = _setup(hass, config) + patch_key, entity_id = _setup(config) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key @@ -519,7 +529,7 @@ async def test_firetv_select_source_stop_app_id_no_name(hass): async def _test_setup_fail(hass, config): """Test that the entity is not created when the ADB connection is not established.""" - patch_key, entity_id = _setup(hass, config) + patch_key, entity_id = _setup(config) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(False)[ patch_key @@ -569,14 +579,216 @@ async def test_setup_two_devices(hass): async def test_setup_same_device_twice(hass): """Test that setup succeeds with a duplicated config entry.""" + patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + + with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ + patch_key + ], patchers.patch_shell("")[patch_key]: + assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + state = hass.states.get(entity_id) + assert state is not None + + assert hass.services.has_service(ANDROIDTV_DOMAIN, SERVICE_ADB_COMMAND) + + with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ + patch_key + ], patchers.patch_shell("")[patch_key]: + assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + + +async def test_adb_command(hass): + """Test sending a command via the `androidtv.adb_command` service.""" + patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + command = "test command" + response = "test response" + + with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ + patch_key + ], patchers.patch_shell("")[patch_key]: + assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + + with patch( + "androidtv.basetv.BaseTV.adb_shell", return_value=response + ) as patch_shell: + await hass.services.async_call( + ANDROIDTV_DOMAIN, + SERVICE_ADB_COMMAND, + {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, + blocking=True, + ) + + patch_shell.assert_called_with(command) + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["adb_response"] == response + + +async def test_adb_command_key(hass): + """Test sending a key command via the `androidtv.adb_command` service.""" patch_key = "server" + entity_id = "media_player.android_tv" + command = "HOME" + response = None with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell("")[patch_key]: assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + with patch( + "androidtv.basetv.BaseTV.adb_shell", return_value=response + ) as patch_shell: + await hass.services.async_call( + ANDROIDTV_DOMAIN, + SERVICE_ADB_COMMAND, + {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, + blocking=True, + ) + + patch_shell.assert_called_with(f"input keyevent {KEYS[command]}") + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["adb_response"] is None + + +async def test_adb_command_get_properties(hass): + """Test sending the "GET_PROPERTIES" command via the `androidtv.adb_command` service.""" + patch_key = "server" + entity_id = "media_player.android_tv" + command = "GET_PROPERTIES" + response = {"test key": "test value"} + with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell("")[patch_key]: assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + + with patch( + "androidtv.androidtv.AndroidTV.get_properties_dict", return_value=response + ) as patch_get_props: + await hass.services.async_call( + ANDROIDTV_DOMAIN, + SERVICE_ADB_COMMAND, + {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, + blocking=True, + ) + + patch_get_props.assert_called() + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["adb_response"] == str(response) + + +async def test_update_lock_not_acquired(hass): + """Test that the state does not get updated when a `LockNotAcquiredException` is raised.""" + patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + + with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ + patch_key + ], patchers.patch_shell("")[patch_key]: + assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + + with patchers.patch_shell("")[patch_key]: + await hass.helpers.entity_component.async_update_entity(entity_id) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + with patch( + "androidtv.androidtv.AndroidTV.update", side_effect=LockNotAcquiredException + ): + with patchers.patch_shell("1")[patch_key]: + await hass.helpers.entity_component.async_update_entity(entity_id) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + with patchers.patch_shell("1")[patch_key]: + await hass.helpers.entity_component.async_update_entity(entity_id) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_IDLE + + +async def test_download(hass): + """Test the `androidtv.download` service.""" + patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + device_path = "device/path" + local_path = "local/path" + + with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ + patch_key + ], patchers.patch_shell("")[patch_key]: + assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + + # Failed download because path is not whitelisted + with patch("androidtv.basetv.BaseTV.adb_pull") as patch_pull: + await hass.services.async_call( + ANDROIDTV_DOMAIN, + SERVICE_DOWNLOAD, + { + ATTR_ENTITY_ID: entity_id, + ATTR_DEVICE_PATH: device_path, + ATTR_LOCAL_PATH: local_path, + }, + blocking=True, + ) + patch_pull.assert_not_called() + + # Successful download + with patch("androidtv.basetv.BaseTV.adb_pull") as patch_pull, patch.object( + hass.config, "is_allowed_path", return_value=True + ): + await hass.services.async_call( + ANDROIDTV_DOMAIN, + SERVICE_DOWNLOAD, + { + ATTR_ENTITY_ID: entity_id, + ATTR_DEVICE_PATH: device_path, + ATTR_LOCAL_PATH: local_path, + }, + blocking=True, + ) + patch_pull.assert_called_with(local_path, device_path) + + +async def test_upload(hass): + """Test the `androidtv.upload` service.""" + patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + device_path = "device/path" + local_path = "local/path" + + with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ + patch_key + ], patchers.patch_shell("")[patch_key]: + assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + + # Failed upload because path is not whitelisted + with patch("androidtv.basetv.BaseTV.adb_push") as patch_push: + await hass.services.async_call( + ANDROIDTV_DOMAIN, + SERVICE_UPLOAD, + { + ATTR_ENTITY_ID: entity_id, + ATTR_DEVICE_PATH: device_path, + ATTR_LOCAL_PATH: local_path, + }, + blocking=True, + ) + patch_push.assert_not_called() + + # Successful upload + with patch("androidtv.basetv.BaseTV.adb_push") as patch_push, patch.object( + hass.config, "is_allowed_path", return_value=True + ): + await hass.services.async_call( + ANDROIDTV_DOMAIN, + SERVICE_UPLOAD, + { + ATTR_ENTITY_ID: entity_id, + ATTR_DEVICE_PATH: device_path, + ATTR_LOCAL_PATH: local_path, + }, + blocking=True, + ) + patch_push.assert_called_with(local_path, device_path)