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
This commit is contained in:
Jeff Irion 2020-01-06 14:10:13 -08:00 committed by Paulus Schoutsen
parent e88bfda2a8
commit 5ec5df77cc
6 changed files with 358 additions and 40 deletions

View File

@ -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": [],

View File

@ -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."""

View File

@ -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'

View File

@ -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

View File

@ -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

View File

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