Add config_flow to AndroidTV integration (#54444)

Co-authored-by: Robert Hillis <tkdrob4390@yahoo.com>
This commit is contained in:
ollo69 2021-12-20 20:08:35 +01:00 committed by GitHub
parent 2dfd4c49da
commit 5a41251d45
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1850 additions and 535 deletions

View file

@ -55,6 +55,7 @@ omit =
homeassistant/components/amcrest/* homeassistant/components/amcrest/*
homeassistant/components/ampio/* homeassistant/components/ampio/*
homeassistant/components/android_ip_webcam/* homeassistant/components/android_ip_webcam/*
homeassistant/components/androidtv/__init__.py
homeassistant/components/anel_pwrctrl/switch.py homeassistant/components/anel_pwrctrl/switch.py
homeassistant/components/anthemav/media_player.py homeassistant/components/anthemav/media_player.py
homeassistant/components/apcupsd/* homeassistant/components/apcupsd/*

View file

@ -63,8 +63,8 @@ tests/components/ambient_station/* @bachya
homeassistant/components/amcrest/* @flacjacket homeassistant/components/amcrest/* @flacjacket
homeassistant/components/analytics/* @home-assistant/core @ludeeus homeassistant/components/analytics/* @home-assistant/core @ludeeus
tests/components/analytics/* @home-assistant/core @ludeeus tests/components/analytics/* @home-assistant/core @ludeeus
homeassistant/components/androidtv/* @JeffLIrion homeassistant/components/androidtv/* @JeffLIrion @ollo69
tests/components/androidtv/* @JeffLIrion tests/components/androidtv/* @JeffLIrion @ollo69
homeassistant/components/apache_kafka/* @bachya homeassistant/components/apache_kafka/* @bachya
tests/components/apache_kafka/* @bachya tests/components/apache_kafka/* @bachya
homeassistant/components/api/* @home-assistant/core homeassistant/components/api/* @home-assistant/core

View file

@ -1 +1,192 @@
"""Support for functionality to interact with Android TV/Fire TV devices.""" """Support for functionality to interact with Android TV/Fire TV devices."""
import logging
import os
from adb_shell.auth.keygen import keygen
from androidtv.adb_manager.adb_manager_sync import ADBPythonSync
from androidtv.setup_async import setup
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_HOST,
CONF_PORT,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.storage import STORAGE_DIR
from .const import (
ANDROID_DEV,
ANDROID_DEV_OPT,
CONF_ADB_SERVER_IP,
CONF_ADB_SERVER_PORT,
CONF_ADBKEY,
CONF_STATE_DETECTION_RULES,
DEFAULT_ADB_SERVER_PORT,
DEVICE_ANDROIDTV,
DEVICE_FIRETV,
DOMAIN,
PROP_SERIALNO,
SIGNAL_CONFIG_ENTITY,
)
PLATFORMS = [Platform.MEDIA_PLAYER]
RELOAD_OPTIONS = [CONF_STATE_DETECTION_RULES]
_LOGGER = logging.getLogger(__name__)
def _setup_androidtv(hass, config):
"""Generate an ADB key (if needed) and load it."""
adbkey = config.get(CONF_ADBKEY, hass.config.path(STORAGE_DIR, "androidtv_adbkey"))
if CONF_ADB_SERVER_IP not in config:
# Use "adb_shell" (Python ADB implementation)
if not os.path.isfile(adbkey):
# Generate ADB key files
keygen(adbkey)
# Load the ADB key
signer = ADBPythonSync.load_adbkey(adbkey)
adb_log = f"using Python ADB implementation with adbkey='{adbkey}'"
else:
# Use "pure-python-adb" (communicate with ADB server)
signer = None
adb_log = f"using ADB server at {config[CONF_ADB_SERVER_IP]}:{config[CONF_ADB_SERVER_PORT]}"
return adbkey, signer, adb_log
async def async_connect_androidtv(
hass, config, *, state_detection_rules=None, timeout=30.0
):
"""Connect to Android device."""
address = f"{config[CONF_HOST]}:{config[CONF_PORT]}"
adbkey, signer, adb_log = await hass.async_add_executor_job(
_setup_androidtv, hass, config
)
aftv = await setup(
config[CONF_HOST],
config[CONF_PORT],
adbkey,
config.get(CONF_ADB_SERVER_IP),
config.get(CONF_ADB_SERVER_PORT, DEFAULT_ADB_SERVER_PORT),
state_detection_rules,
config[CONF_DEVICE_CLASS],
timeout,
signer,
)
if not aftv.available:
# Determine the name that will be used for the device in the log
if config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV:
device_name = "Android TV device"
elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV:
device_name = "Fire TV device"
else:
device_name = "Android TV / Fire TV device"
error_message = f"Could not connect to {device_name} at {address} {adb_log}"
return None, error_message
return aftv, None
def _migrate_aftv_entity(hass, aftv, entry_unique_id):
"""Migrate a entity to new unique id."""
entity_reg = er.async_get(hass)
entity_unique_id = entry_unique_id
if entity_reg.async_get_entity_id(MP_DOMAIN, DOMAIN, entity_unique_id):
# entity already exist, nothing to do
return
old_unique_id = aftv.device_properties.get(PROP_SERIALNO)
if not old_unique_id:
# serial no not found, exit
return
migr_entity = entity_reg.async_get_entity_id(MP_DOMAIN, DOMAIN, old_unique_id)
if not migr_entity:
# old entity not found, exit
return
try:
entity_reg.async_update_entity(migr_entity, new_unique_id=entity_unique_id)
except ValueError as exp:
_LOGGER.warning("Migration of old entity failed: %s", exp)
async def async_setup(hass, config):
"""Set up the Android TV integration."""
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Android TV platform."""
state_det_rules = entry.options.get(CONF_STATE_DETECTION_RULES)
aftv, error_message = await async_connect_androidtv(
hass, entry.data, state_detection_rules=state_det_rules
)
if not aftv:
raise ConfigEntryNotReady(error_message)
# migrate existing entity to new unique ID
if entry.source == SOURCE_IMPORT:
_migrate_aftv_entity(hass, aftv, entry.unique_id)
async def async_close_connection(event):
"""Close Android TV connection on HA Stop."""
await aftv.adb_close()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_close_connection)
)
entry.async_on_unload(entry.add_update_listener(update_listener))
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
ANDROID_DEV: aftv,
ANDROID_DEV_OPT: entry.options.copy(),
}
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV]
await aftv.adb_close()
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
"""Update when config_entry options update."""
reload_opt = False
old_options = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV_OPT]
for opt_key, opt_val in entry.options.items():
if opt_key in RELOAD_OPTIONS:
old_val = old_options.get(opt_key)
if old_val is None or old_val != opt_val:
reload_opt = True
break
if reload_opt:
await hass.config_entries.async_reload(entry.entry_id)
return
hass.data[DOMAIN][entry.entry_id][ANDROID_DEV_OPT] = entry.options.copy()
async_dispatcher_send(hass, f"{SIGNAL_CONFIG_ENTITY}_{entry.entry_id}")

View file

@ -0,0 +1,378 @@
"""Config flow to configure the Android TV integration."""
import json
import logging
import os
import socket
from androidtv import state_detection_rules_validator
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from . import async_connect_androidtv
from .const import (
CONF_ADB_SERVER_IP,
CONF_ADB_SERVER_PORT,
CONF_ADBKEY,
CONF_APPS,
CONF_EXCLUDE_UNNAMED_APPS,
CONF_GET_SOURCES,
CONF_MIGRATION_OPTIONS,
CONF_SCREENCAP,
CONF_STATE_DETECTION_RULES,
CONF_TURN_OFF_COMMAND,
CONF_TURN_ON_COMMAND,
DEFAULT_ADB_SERVER_PORT,
DEFAULT_DEVICE_CLASS,
DEFAULT_EXCLUDE_UNNAMED_APPS,
DEFAULT_GET_SOURCES,
DEFAULT_PORT,
DEFAULT_SCREENCAP,
DEVICE_CLASSES,
DOMAIN,
PROP_ETHMAC,
PROP_WIFIMAC,
)
APPS_NEW_ID = "NewApp"
CONF_APP_DELETE = "app_delete"
CONF_APP_ID = "app_id"
CONF_APP_NAME = "app_name"
RULES_NEW_ID = "NewRule"
CONF_RULE_DELETE = "rule_delete"
CONF_RULE_ID = "rule_id"
CONF_RULE_VALUES = "rule_values"
RESULT_CONN_ERROR = "cannot_connect"
RESULT_UNKNOWN = "unknown"
_LOGGER = logging.getLogger(__name__)
def _is_file(value):
"""Validate that the value is an existing file."""
file_in = os.path.expanduser(str(value))
return os.path.isfile(file_in) and os.access(file_in, os.R_OK)
def _get_ip(host):
"""Get the ip address from the host name."""
try:
return socket.gethostbyname(host)
except socket.gaierror:
return None
class AndroidTVFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
VERSION = 1
def __init__(self):
"""Initialize AndroidTV config flow."""
self._import_options = None
@callback
def _show_setup_form(self, user_input=None, error=None):
"""Show the setup form to the user."""
user_input = user_input or {}
data_schema = vol.Schema(
{
vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
vol.Required(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): vol.In(
DEVICE_CLASSES
),
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
},
)
if self.show_advanced_options:
data_schema = data_schema.extend(
{
vol.Optional(CONF_ADBKEY): str,
vol.Optional(CONF_ADB_SERVER_IP): str,
vol.Required(
CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT
): cv.port,
}
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors={"base": error},
)
async def _async_check_connection(self, user_input):
"""Attempt to connect the Android TV."""
try:
aftv, error_message = await async_connect_androidtv(self.hass, user_input)
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Unknown error connecting with Android TV at %s", user_input[CONF_HOST]
)
return RESULT_UNKNOWN, None
if not aftv:
_LOGGER.warning(error_message)
return RESULT_CONN_ERROR, None
dev_prop = aftv.device_properties
unique_id = format_mac(
dev_prop.get(PROP_ETHMAC) or dev_prop.get(PROP_WIFIMAC, "")
)
await aftv.adb_close()
return None, unique_id
async def async_step_user(self, user_input=None):
"""Handle a flow initiated by the user."""
error = None
if user_input is not None:
host = user_input[CONF_HOST]
adb_key = user_input.get(CONF_ADBKEY)
adb_server = user_input.get(CONF_ADB_SERVER_IP)
if adb_key and adb_server:
return self._show_setup_form(user_input, "key_and_server")
if adb_key:
isfile = await self.hass.async_add_executor_job(_is_file, adb_key)
if not isfile:
return self._show_setup_form(user_input, "adbkey_not_file")
ip_address = await self.hass.async_add_executor_job(_get_ip, host)
if not ip_address:
return self._show_setup_form(user_input, "invalid_host")
self._async_abort_entries_match({CONF_HOST: host})
if ip_address != host:
self._async_abort_entries_match({CONF_HOST: ip_address})
error, unique_id = await self._async_check_connection(user_input)
if error is None:
if not unique_id:
return self.async_abort(reason="invalid_unique_id")
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input.get(CONF_NAME) or host,
data=user_input,
options=self._import_options,
)
user_input = user_input or {}
return self._show_setup_form(user_input, error)
async def async_step_import(self, import_config=None):
"""Import a config entry."""
for entry in self._async_current_entries():
if entry.data[CONF_HOST] == import_config[CONF_HOST]:
_LOGGER.warning(
"Host [%s] already configured. This yaml configuration has already been imported. Please remove it",
import_config[CONF_HOST],
)
return self.async_abort(reason="already_configured")
self._import_options = import_config.pop(CONF_MIGRATION_OPTIONS, None)
return await self.async_step_user(import_config)
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle an option flow for Android TV."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
apps = config_entry.options.get(CONF_APPS, {})
det_rules = config_entry.options.get(CONF_STATE_DETECTION_RULES, {})
self._apps = apps.copy()
self._state_det_rules = det_rules.copy()
self._conf_app_id = None
self._conf_rule_id = None
@callback
def _save_config(self, data):
"""Save the updated options."""
new_data = {
k: v
for k, v in data.items()
if k not in [CONF_APPS, CONF_STATE_DETECTION_RULES]
}
if self._apps:
new_data[CONF_APPS] = self._apps
if self._state_det_rules:
new_data[CONF_STATE_DETECTION_RULES] = self._state_det_rules
return self.async_create_entry(title="", data=new_data)
async def async_step_init(self, user_input=None):
"""Handle options flow."""
if user_input is not None:
if sel_app := user_input.get(CONF_APPS):
return await self.async_step_apps(None, sel_app)
if sel_rule := user_input.get(CONF_STATE_DETECTION_RULES):
return await self.async_step_rules(None, sel_rule)
return self._save_config(user_input)
return self._async_init_form()
@callback
def _async_init_form(self):
"""Return initial configuration form."""
apps_list = {k: f"{v} ({k})" if v else k for k, v in self._apps.items()}
apps = {APPS_NEW_ID: "Add new", **apps_list}
rules = [RULES_NEW_ID] + list(self._state_det_rules)
options = self.config_entry.options
data_schema = vol.Schema(
{
vol.Optional(CONF_APPS): vol.In(apps),
vol.Optional(
CONF_GET_SOURCES,
default=options.get(CONF_GET_SOURCES, DEFAULT_GET_SOURCES),
): bool,
vol.Optional(
CONF_EXCLUDE_UNNAMED_APPS,
default=options.get(
CONF_EXCLUDE_UNNAMED_APPS, DEFAULT_EXCLUDE_UNNAMED_APPS
),
): bool,
vol.Optional(
CONF_SCREENCAP,
default=options.get(CONF_SCREENCAP, DEFAULT_SCREENCAP),
): bool,
vol.Optional(
CONF_TURN_OFF_COMMAND,
description={
"suggested_value": options.get(CONF_TURN_OFF_COMMAND, "")
},
): str,
vol.Optional(
CONF_TURN_ON_COMMAND,
description={
"suggested_value": options.get(CONF_TURN_ON_COMMAND, "")
},
): str,
vol.Optional(CONF_STATE_DETECTION_RULES): vol.In(rules),
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)
async def async_step_apps(self, user_input=None, app_id=None):
"""Handle options flow for apps list."""
if app_id is not None:
self._conf_app_id = app_id if app_id != APPS_NEW_ID else None
return self._async_apps_form(app_id)
if user_input is not None:
app_id = user_input.get(CONF_APP_ID, self._conf_app_id)
if app_id:
if user_input.get(CONF_APP_DELETE, False):
self._apps.pop(app_id)
else:
self._apps[app_id] = user_input.get(CONF_APP_NAME, "")
return await self.async_step_init()
@callback
def _async_apps_form(self, app_id):
"""Return configuration form for apps."""
data_schema = {
vol.Optional(
CONF_APP_NAME,
description={"suggested_value": self._apps.get(app_id, "")},
): str,
}
if app_id == APPS_NEW_ID:
data_schema[vol.Optional(CONF_APP_ID)] = str
else:
data_schema[vol.Optional(CONF_APP_DELETE, default=False)] = bool
return self.async_show_form(
step_id="apps",
data_schema=vol.Schema(data_schema),
description_placeholders={
"app_id": f"`{app_id}`" if app_id != APPS_NEW_ID else "",
},
)
async def async_step_rules(self, user_input=None, rule_id=None):
"""Handle options flow for detection rules."""
if rule_id is not None:
self._conf_rule_id = rule_id if rule_id != RULES_NEW_ID else None
return self._async_rules_form(rule_id)
if user_input is not None:
rule_id = user_input.get(CONF_RULE_ID, self._conf_rule_id)
if rule_id:
if user_input.get(CONF_RULE_DELETE, False):
self._state_det_rules.pop(rule_id)
elif str_det_rule := user_input.get(CONF_RULE_VALUES):
state_det_rule = _validate_state_det_rules(str_det_rule)
if state_det_rule is None:
return self._async_rules_form(
rule_id=self._conf_rule_id or RULES_NEW_ID,
default_id=rule_id,
errors={"base": "invalid_det_rules"},
)
self._state_det_rules[rule_id] = state_det_rule
return await self.async_step_init()
@callback
def _async_rules_form(self, rule_id, default_id="", errors=None):
"""Return configuration form for detection rules."""
state_det_rule = self._state_det_rules.get(rule_id)
str_det_rule = json.dumps(state_det_rule) if state_det_rule else ""
data_schema = {}
if rule_id == RULES_NEW_ID:
data_schema[vol.Optional(CONF_RULE_ID, default=default_id)] = str
data_schema[vol.Optional(CONF_RULE_VALUES, default=str_det_rule)] = str
if rule_id != RULES_NEW_ID:
data_schema[vol.Optional(CONF_RULE_DELETE, default=False)] = bool
return self.async_show_form(
step_id="rules",
data_schema=vol.Schema(data_schema),
description_placeholders={
"rule_id": f"`{rule_id}`" if rule_id != RULES_NEW_ID else "",
},
errors=errors,
)
def _validate_state_det_rules(state_det_rules):
"""Validate a string that contain state detection rules and return a dict."""
try:
json_rules = json.loads(state_det_rules)
except ValueError:
_LOGGER.warning("Error loading state detection rules")
return None
if not isinstance(json_rules, list):
json_rules = [json_rules]
try:
state_detection_rules_validator(json_rules, ValueError)
except ValueError as exc:
_LOGGER.warning("Invalid state detection rules: %s", exc)
return None
return json_rules

View file

@ -0,0 +1,34 @@
"""Android TV component constants."""
DOMAIN = "androidtv"
ANDROID_DEV = DOMAIN
ANDROID_DEV_OPT = "androidtv_opt"
CONF_ADB_SERVER_IP = "adb_server_ip"
CONF_ADB_SERVER_PORT = "adb_server_port"
CONF_ADBKEY = "adbkey"
CONF_APPS = "apps"
CONF_EXCLUDE_UNNAMED_APPS = "exclude_unnamed_apps"
CONF_GET_SOURCES = "get_sources"
CONF_MIGRATION_OPTIONS = "migration_options"
CONF_SCREENCAP = "screencap"
CONF_STATE_DETECTION_RULES = "state_detection_rules"
CONF_TURN_OFF_COMMAND = "turn_off_command"
CONF_TURN_ON_COMMAND = "turn_on_command"
DEFAULT_ADB_SERVER_PORT = 5037
DEFAULT_DEVICE_CLASS = "auto"
DEFAULT_EXCLUDE_UNNAMED_APPS = False
DEFAULT_GET_SOURCES = True
DEFAULT_PORT = 5555
DEFAULT_SCREENCAP = True
DEVICE_ANDROIDTV = "androidtv"
DEVICE_FIRETV = "firetv"
DEVICE_CLASSES = [DEFAULT_DEVICE_CLASS, DEVICE_ANDROIDTV, DEVICE_FIRETV]
PROP_ETHMAC = "ethmac"
PROP_SERIALNO = "serialno"
PROP_WIFIMAC = "wifimac"
SIGNAL_CONFIG_ENTITY = "androidtv_config"

View file

@ -7,6 +7,7 @@
"androidtv[async]==0.0.60", "androidtv[async]==0.0.60",
"pure-python-adb[async]==0.3.0.dev0" "pure-python-adb[async]==0.3.0.dev0"
], ],
"codeowners": ["@JeffLIrion"], "codeowners": ["@JeffLIrion", "@ollo69"],
"config_flow": true,
"iot_class": "local_polling" "iot_class": "local_polling"
} }

View file

@ -1,10 +1,10 @@
"""Support for functionality to interact with Android TV / Fire TV devices.""" """Support for functionality to interact with Android TV / Fire TV devices."""
from __future__ import annotations
from datetime import datetime from datetime import datetime
import functools import functools
import logging import logging
import os
from adb_shell.auth.keygen import keygen
from adb_shell.exceptions import ( from adb_shell.exceptions import (
AdbTimeoutError, AdbTimeoutError,
InvalidChecksumError, InvalidChecksumError,
@ -13,10 +13,8 @@ from adb_shell.exceptions import (
TcpTimeoutException, TcpTimeoutException,
) )
from androidtv import ha_state_detection_rules_validator from androidtv import ha_state_detection_rules_validator
from androidtv.adb_manager.adb_manager_sync import ADBPythonSync
from androidtv.constants import APPS, KEYS from androidtv.constants import APPS, KEYS
from androidtv.exceptions import LockNotAcquiredException from androidtv.exceptions import LockNotAcquiredException
from androidtv.setup_async import setup
import voluptuous as vol import voluptuous as vol
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity
@ -33,25 +31,58 @@ from homeassistant.components.media_player.const import (
SUPPORT_VOLUME_SET, SUPPORT_VOLUME_SET,
SUPPORT_VOLUME_STEP, SUPPORT_VOLUME_STEP,
) )
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
ATTR_COMMAND, ATTR_COMMAND,
ATTR_ENTITY_ID, ATTR_CONNECTIONS,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_SW_VERSION,
CONF_DEVICE_CLASS, CONF_DEVICE_CLASS,
CONF_HOST, CONF_HOST,
CONF_NAME, CONF_NAME,
CONF_PORT, CONF_PORT,
EVENT_HOMEASSISTANT_STOP,
STATE_IDLE, STATE_IDLE,
STATE_OFF, STATE_OFF,
STATE_PAUSED, STATE_PAUSED,
STATE_PLAYING, STATE_PLAYING,
STATE_STANDBY, STATE_STANDBY,
) )
from homeassistant.exceptions import PlatformNotReady from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
ANDROIDTV_DOMAIN = "androidtv" from .const import (
ANDROID_DEV,
ANDROID_DEV_OPT,
CONF_ADB_SERVER_IP,
CONF_ADB_SERVER_PORT,
CONF_ADBKEY,
CONF_APPS,
CONF_EXCLUDE_UNNAMED_APPS,
CONF_GET_SOURCES,
CONF_MIGRATION_OPTIONS,
CONF_SCREENCAP,
CONF_STATE_DETECTION_RULES,
CONF_TURN_OFF_COMMAND,
CONF_TURN_ON_COMMAND,
DEFAULT_ADB_SERVER_PORT,
DEFAULT_DEVICE_CLASS,
DEFAULT_EXCLUDE_UNNAMED_APPS,
DEFAULT_GET_SOURCES,
DEFAULT_PORT,
DEFAULT_SCREENCAP,
DEVICE_ANDROIDTV,
DEVICE_CLASSES,
DOMAIN,
PROP_ETHMAC,
PROP_WIFIMAC,
SIGNAL_CONFIG_ENTITY,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -85,55 +116,17 @@ ATTR_DEVICE_PATH = "device_path"
ATTR_HDMI_INPUT = "hdmi_input" ATTR_HDMI_INPUT = "hdmi_input"
ATTR_LOCAL_PATH = "local_path" ATTR_LOCAL_PATH = "local_path"
CONF_ADBKEY = "adbkey"
CONF_ADB_SERVER_IP = "adb_server_ip"
CONF_ADB_SERVER_PORT = "adb_server_port"
CONF_APPS = "apps"
CONF_EXCLUDE_UNNAMED_APPS = "exclude_unnamed_apps"
CONF_GET_SOURCES = "get_sources"
CONF_STATE_DETECTION_RULES = "state_detection_rules"
CONF_TURN_ON_COMMAND = "turn_on_command"
CONF_TURN_OFF_COMMAND = "turn_off_command"
CONF_SCREENCAP = "screencap"
DEFAULT_NAME = "Android TV"
DEFAULT_PORT = 5555
DEFAULT_ADB_SERVER_PORT = 5037
DEFAULT_GET_SOURCES = True
DEFAULT_DEVICE_CLASS = "auto"
DEFAULT_SCREENCAP = True
DEVICE_ANDROIDTV = "androidtv"
DEVICE_FIRETV = "firetv"
DEVICE_CLASSES = [DEFAULT_DEVICE_CLASS, DEVICE_ANDROIDTV, DEVICE_FIRETV]
SERVICE_ADB_COMMAND = "adb_command" SERVICE_ADB_COMMAND = "adb_command"
SERVICE_DOWNLOAD = "download" SERVICE_DOWNLOAD = "download"
SERVICE_LEARN_SENDEVENT = "learn_sendevent" SERVICE_LEARN_SENDEVENT = "learn_sendevent"
SERVICE_UPLOAD = "upload" SERVICE_UPLOAD = "upload"
SERVICE_ADB_COMMAND_SCHEMA = vol.Schema( DEFAULT_NAME = "Android TV"
{vol.Required(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_COMMAND): cv.string}
)
SERVICE_DOWNLOAD_SCHEMA = vol.Schema( # Deprecated in Home Assistant 2022.2
{ PLATFORM_SCHEMA = cv.deprecated(
vol.Required(ATTR_ENTITY_ID): cv.entity_id, vol.All(
vol.Required(ATTR_DEVICE_PATH): cv.string, PLATFORM_SCHEMA=PLATFORM_SCHEMA.extend(
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(
{ {
vol.Required(CONF_HOST): cv.string, vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): vol.In( vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): vol.In(
@ -143,7 +136,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_ADBKEY): cv.isfile, vol.Optional(CONF_ADBKEY): cv.isfile,
vol.Optional(CONF_ADB_SERVER_IP): cv.string, vol.Optional(CONF_ADB_SERVER_IP): cv.string,
vol.Optional(CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT): cv.port, vol.Optional(
CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT
): cv.port,
vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean, vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean,
vol.Optional(CONF_APPS, default={}): vol.Schema( vol.Optional(CONF_APPS, default={}): vol.Schema(
{cv.string: vol.Any(cv.string, None)} {cv.string: vol.Any(cv.string, None)}
@ -153,9 +148,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
vol.Optional(CONF_STATE_DETECTION_RULES, default={}): vol.Schema( vol.Optional(CONF_STATE_DETECTION_RULES, default={}): vol.Schema(
{cv.string: ha_state_detection_rules_validator(vol.Invalid)} {cv.string: ha_state_detection_rules_validator(vol.Invalid)}
), ),
vol.Optional(CONF_EXCLUDE_UNNAMED_APPS, default=False): cv.boolean, vol.Optional(
CONF_EXCLUDE_UNNAMED_APPS, default=DEFAULT_EXCLUDE_UNNAMED_APPS
): cv.boolean,
vol.Optional(CONF_SCREENCAP, default=DEFAULT_SCREENCAP): cv.boolean, vol.Optional(CONF_SCREENCAP, default=DEFAULT_SCREENCAP): cv.boolean,
} }
),
)
) )
# Translate from `AndroidTV` / `FireTV` reported state to HA state. # Translate from `AndroidTV` / `FireTV` reported state to HA state.
@ -168,180 +167,108 @@ ANDROIDTV_STATES = {
} }
def setup_androidtv(hass, config): async def async_setup_platform(
"""Generate an ADB key (if needed) and load it.""" hass: HomeAssistant,
adbkey = config.get(CONF_ADBKEY, hass.config.path(STORAGE_DIR, "androidtv_adbkey")) config: ConfigType,
if CONF_ADB_SERVER_IP not in config: async_add_entities: AddEntitiesCallback,
# Use "adb_shell" (Python ADB implementation) discovery_info=None,
if not os.path.isfile(adbkey): ) -> None:
# Generate ADB key files
keygen(adbkey)
# Load the ADB key
signer = ADBPythonSync.load_adbkey(adbkey)
adb_log = f"using Python ADB implementation with adbkey='{adbkey}'"
else:
# Use "pure-python-adb" (communicate with ADB server)
signer = None
adb_log = f"using ADB server at {config[CONF_ADB_SERVER_IP]}:{config[CONF_ADB_SERVER_PORT]}"
return adbkey, signer, adb_log
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Android TV / Fire TV platform.""" """Set up the Android TV / Fire TV platform."""
hass.data.setdefault(ANDROIDTV_DOMAIN, {})
address = f"{config[CONF_HOST]}:{config[CONF_PORT]}" host = config[CONF_HOST]
if address in hass.data[ANDROIDTV_DOMAIN]: # get main data
_LOGGER.warning("Platform already setup on %s, skipping", address) config_data = {
return CONF_HOST: host,
CONF_DEVICE_CLASS: config.get(CONF_DEVICE_CLASS, DEFAULT_DEVICE_CLASS),
CONF_PORT: config.get(CONF_PORT, DEFAULT_PORT),
}
for key in (CONF_ADBKEY, CONF_ADB_SERVER_IP, CONF_ADB_SERVER_PORT, CONF_NAME):
if key in config:
config_data[key] = config[key]
adbkey, signer, adb_log = await hass.async_add_executor_job( # get options
setup_androidtv, hass, config config_options = {
key: config[key]
for key in (
CONF_APPS,
CONF_EXCLUDE_UNNAMED_APPS,
CONF_GET_SOURCES,
CONF_SCREENCAP,
CONF_STATE_DETECTION_RULES,
CONF_TURN_OFF_COMMAND,
CONF_TURN_ON_COMMAND,
)
if key in config
}
# save option to use with entry
if config_options:
config_data[CONF_MIGRATION_OPTIONS] = config_options
# Launch config entries setup
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config_data
)
) )
aftv = await setup(
config[CONF_HOST],
config[CONF_PORT],
adbkey,
config.get(CONF_ADB_SERVER_IP, ""),
config[CONF_ADB_SERVER_PORT],
config[CONF_STATE_DETECTION_RULES],
config[CONF_DEVICE_CLASS],
10.0,
signer,
)
if not aftv.available: async def async_setup_entry(
# Determine the name that will be used for the device in the log hass: HomeAssistant,
if CONF_NAME in config: entry: ConfigEntry,
device_name = config[CONF_NAME] async_add_entities: AddEntitiesCallback,
elif config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV: ) -> None:
device_name = "Android TV device" """Set up the Android TV entity."""
elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV: aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV]
device_name = "Fire TV device" device_class = aftv.DEVICE_CLASS
device_type = "Android TV" if device_class == DEVICE_ANDROIDTV else "Fire TV"
if CONF_NAME in entry.data:
device_name = entry.data[CONF_NAME]
else: else:
device_name = "Android TV / Fire TV device" device_name = f"{device_type} {entry.data[CONF_HOST]}"
_LOGGER.warning(
"Could not connect to %s at %s %s", device_name, address, adb_log
)
raise PlatformNotReady
async def _async_close(event):
"""Close the ADB socket connection when HA stops."""
await aftv.adb_close()
# Close the ADB connection when HA stops
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close)
device_args = [ device_args = [
aftv, aftv,
config[CONF_NAME], device_name,
config[CONF_APPS], device_type,
config[CONF_GET_SOURCES], entry.unique_id,
config.get(CONF_TURN_ON_COMMAND), entry.entry_id,
config.get(CONF_TURN_OFF_COMMAND), hass.data[DOMAIN][entry.entry_id],
config[CONF_EXCLUDE_UNNAMED_APPS],
config[CONF_SCREENCAP],
] ]
if aftv.DEVICE_CLASS == DEVICE_ANDROIDTV: async_add_entities(
device = AndroidTVDevice(*device_args) [
device_name = config.get(CONF_NAME, "Android TV") AndroidTVDevice(*device_args)
else: if device_class == DEVICE_ANDROIDTV
device = FireTVDevice(*device_args) else FireTVDevice(*device_args)
device_name = config.get(CONF_NAME, "Fire TV") ]
)
async_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
platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
async def service_adb_command(service):
"""Dispatch service calls to target entities."""
cmd = service.data[ATTR_COMMAND]
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:
output = await target_device.adb_command(cmd)
# log the output, if there is any
if output:
_LOGGER.info(
"Output of command '%s' from '%s': %s",
cmd,
target_device.entity_id,
output,
)
hass.services.async_register(
ANDROIDTV_DOMAIN,
SERVICE_ADB_COMMAND, SERVICE_ADB_COMMAND,
service_adb_command, {vol.Required(ATTR_COMMAND): cv.string},
schema=SERVICE_ADB_COMMAND_SCHEMA, "adb_command",
) )
platform.async_register_entity_service( platform.async_register_entity_service(
SERVICE_LEARN_SENDEVENT, {}, "learn_sendevent" SERVICE_LEARN_SENDEVENT, {}, "learn_sendevent"
) )
platform.async_register_entity_service(
async 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]
await target_device.adb_pull(local_path, device_path)
hass.services.async_register(
ANDROIDTV_DOMAIN,
SERVICE_DOWNLOAD, SERVICE_DOWNLOAD,
service_download, {
schema=SERVICE_DOWNLOAD_SCHEMA, vol.Required(ATTR_DEVICE_PATH): cv.string,
vol.Required(ATTR_LOCAL_PATH): cv.string,
},
"service_download",
) )
platform.async_register_entity_service(
async def service_upload(service): SERVICE_UPLOAD,
"""Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" {
local_path = service.data[ATTR_LOCAL_PATH] vol.Required(ATTR_DEVICE_PATH): cv.string,
if not hass.config.is_allowed_path(local_path): vol.Required(ATTR_LOCAL_PATH): cv.string,
_LOGGER.warning("'%s' is not secure to load data from!", local_path) },
return "service_upload",
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:
await target_device.adb_push(local_path, device_path)
hass.services.async_register(
ANDROIDTV_DOMAIN, SERVICE_UPLOAD, service_upload, schema=SERVICE_UPLOAD_SCHEMA
) )
@ -398,37 +325,42 @@ class ADBDevice(MediaPlayerEntity):
self, self,
aftv, aftv,
name, name,
apps, dev_type,
get_sources, unique_id,
turn_on_command, entry_id,
turn_off_command, entry_data,
exclude_unnamed_apps,
screencap,
): ):
"""Initialize the Android TV / Fire TV device.""" """Initialize the Android TV / Fire TV device."""
self.aftv = aftv self.aftv = aftv
self._attr_name = name self._attr_name = name
self._app_id_to_name = APPS.copy() self._attr_unique_id = unique_id
self._app_id_to_name.update(apps) self._entry_id = entry_id
self._app_name_to_id = { self._entry_data = entry_data
value: key for key, value in self._app_id_to_name.items() if value
}
# Make sure that apps overridden via the `apps` parameter are reflected info = aftv.device_properties
# in `self._app_name_to_id` model = info.get(ATTR_MODEL)
for key, value in apps.items(): self._attr_device_info = DeviceInfo(
self._app_name_to_id[value] = key identifiers={(DOMAIN, unique_id)},
self._get_sources = get_sources model=f"{model} ({dev_type})" if model else dev_type,
self._attr_unique_id = self.aftv.device_properties.get("serialno") name=name,
)
if manufacturer := info.get(ATTR_MANUFACTURER):
self._attr_device_info[ATTR_MANUFACTURER] = manufacturer
if sw_version := info.get(ATTR_SW_VERSION):
self._attr_device_info[ATTR_SW_VERSION] = sw_version
if mac := format_mac(info.get(PROP_ETHMAC) or info.get(PROP_WIFIMAC, "")):
self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)}
self.turn_on_command = turn_on_command self._app_id_to_name = {}
self.turn_off_command = turn_off_command self._app_name_to_id = {}
self._get_sources = DEFAULT_GET_SOURCES
self._exclude_unnamed_apps = exclude_unnamed_apps self._exclude_unnamed_apps = DEFAULT_EXCLUDE_UNNAMED_APPS
self._screencap = screencap self._screencap = DEFAULT_SCREENCAP
self.turn_on_command = None
self.turn_off_command = None
# ADB exceptions to catch # ADB exceptions to catch
if not self.aftv.adb_server_ip: if not aftv.adb_server_ip:
# Using "adb_shell" (Python ADB implementation) # Using "adb_shell" (Python ADB implementation)
self.exceptions = ( self.exceptions = (
AdbTimeoutError, AdbTimeoutError,
@ -450,8 +382,46 @@ class ADBDevice(MediaPlayerEntity):
ATTR_HDMI_INPUT: None, ATTR_HDMI_INPUT: None,
} }
def _process_config(self):
"""Load the config options."""
_LOGGER.debug("Loading configuration options")
options = self._entry_data[ANDROID_DEV_OPT]
apps = options.get(CONF_APPS, {})
self._app_id_to_name = APPS.copy()
self._app_id_to_name.update(apps)
self._app_name_to_id = {
value: key for key, value in self._app_id_to_name.items() if value
}
# Make sure that apps overridden via the `apps` parameter are reflected
# in `self._app_name_to_id`
for key, value in apps.items():
self._app_name_to_id[value] = key
self._get_sources = options.get(CONF_GET_SOURCES, DEFAULT_GET_SOURCES)
self._exclude_unnamed_apps = options.get(
CONF_EXCLUDE_UNNAMED_APPS, DEFAULT_EXCLUDE_UNNAMED_APPS
)
self._screencap = options.get(CONF_SCREENCAP, DEFAULT_SCREENCAP)
self.turn_off_command = options.get(CONF_TURN_OFF_COMMAND)
self.turn_on_command = options.get(CONF_TURN_ON_COMMAND)
async def async_added_to_hass(self):
"""Set config parameter when add to hass."""
await super().async_added_to_hass()
self._process_config()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_CONFIG_ENTITY}_{self._entry_id}",
self._process_config,
)
)
return
@property @property
def media_image_hash(self): def media_image_hash(self) -> str | None:
"""Hash value for media image.""" """Hash value for media image."""
return f"{datetime.now().timestamp()}" if self._screencap else None return f"{datetime.now().timestamp()}" if self._screencap else None
@ -531,13 +501,13 @@ class ADBDevice(MediaPlayerEntity):
await self.aftv.stop_app(self._app_name_to_id.get(source_, source_)) await self.aftv.stop_app(self._app_name_to_id.get(source_, source_))
@adb_decorator() @adb_decorator()
async def adb_command(self, cmd): async def adb_command(self, command):
"""Send an ADB command to an Android TV / Fire TV device.""" """Send an ADB command to an Android TV / Fire TV device."""
if key := KEYS.get(cmd): if key := KEYS.get(command):
await self.aftv.adb_shell(f"input keyevent {key}") await self.aftv.adb_shell(f"input keyevent {key}")
return return
if cmd == "GET_PROPERTIES": if command == "GET_PROPERTIES":
self._attr_extra_state_attributes[ATTR_ADB_RESPONSE] = str( self._attr_extra_state_attributes[ATTR_ADB_RESPONSE] = str(
await self.aftv.get_properties_dict() await self.aftv.get_properties_dict()
) )
@ -545,7 +515,7 @@ class ADBDevice(MediaPlayerEntity):
return return
try: try:
response = await self.aftv.adb_shell(cmd) response = await self.aftv.adb_shell(command)
except UnicodeDecodeError: except UnicodeDecodeError:
return return
@ -571,13 +541,21 @@ class ADBDevice(MediaPlayerEntity):
_LOGGER.info("%s", msg) _LOGGER.info("%s", msg)
@adb_decorator() @adb_decorator()
async def adb_pull(self, local_path, device_path): async def service_download(self, device_path, local_path):
"""Download a file from your Android TV / Fire TV device to your Home Assistant instance.""" """Download a file from your Android TV / Fire TV device to your Home Assistant instance."""
if not self.hass.config.is_allowed_path(local_path):
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
return
await self.aftv.adb_pull(local_path, device_path) await self.aftv.adb_pull(local_path, device_path)
@adb_decorator() @adb_decorator()
async def adb_push(self, local_path, device_path): async def service_upload(self, device_path, local_path):
"""Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" """Upload a file from your Home Assistant instance to an Android TV / Fire TV device."""
if not self.hass.config.is_allowed_path(local_path):
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
return
await self.aftv.adb_push(local_path, device_path) await self.aftv.adb_push(local_path, device_path)

View file

@ -3,14 +3,11 @@
adb_command: adb_command:
name: ADB command name: ADB command
description: Send an ADB command to an Android TV / Fire TV device. description: Send an ADB command to an Android TV / Fire TV device.
fields: target:
entity_id:
description: Name(s) of Android TV / Fire TV entities.
required: true
selector:
entity: entity:
integration: androidtv integration: androidtv
domain: media_player domain: media_player
fields:
command: command:
name: Command name: Command
description: Either a key command or an ADB shell command. description: Either a key command or an ADB shell command.
@ -21,14 +18,11 @@ adb_command:
download: download:
name: Download name: Download
description: Download a file from your Android TV / Fire TV device to your Home Assistant instance. description: Download a file from your Android TV / Fire TV device to your Home Assistant instance.
fields: target:
entity_id:
description: Name of Android TV / Fire TV entity.
required: true
selector:
entity: entity:
integration: androidtv integration: androidtv
domain: media_player domain: media_player
fields:
device_path: device_path:
name: Device path name: Device path
description: The filepath on the Android TV / Fire TV device. description: The filepath on the Android TV / Fire TV device.
@ -46,14 +40,11 @@ download:
upload: upload:
name: Upload name: Upload
description: Upload a file from your Home Assistant instance to an Android TV / Fire TV device. description: Upload a file from your Home Assistant instance to an Android TV / Fire TV device.
fields: target:
entity_id:
description: Name(s) of Android TV / Fire TV entities.
required: true
selector:
entity: entity:
integration: androidtv integration: androidtv
domain: media_player domain: media_player
fields:
device_path: device_path:
name: Device path name: Device path
description: The filepath on the Android TV / Fire TV device. description: The filepath on the Android TV / Fire TV device.

View file

@ -0,0 +1,66 @@
{
"config": {
"step": {
"user": {
"title": "Android TV",
"description": "Set required parameters to connect to your Android TV device",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"adbkey": "Path to your ADB key file (leave empty to auto generate)",
"adb_server_ip": "IP address of the ADB server (leave empty to not use)",
"adb_server_port": "Port of the ADB server",
"device_class": "The type of device",
"port": "[%key:common::config_flow::data::port%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
"adbkey_not_file": "ADB key file not found",
"key_and_server": "Only provide ADB Key or ADB Server",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"invalid_unique_id": "Impossible to determine a valid unique id for the device"
}
},
"options": {
"step": {
"init": {
"title": "Android TV Options",
"data": {
"apps": "Configure applications list",
"get_sources": "Whether or not to retrieve the running apps as the list of sources",
"exclude_unnamed_apps": "Exclude app with unknown name",
"screencap": "Determines if album art should be pulled from what is shown on screen",
"state_detection_rules": "Configure state detection rules",
"turn_off_command": "ADB shell command to override default turn_off command",
"turn_on_command": "ADB shell command to override default turn_on command"
}
},
"apps": {
"title": "Configure Android TV Apps",
"description": "Configure application id {app_id}",
"data": {
"app_name": "Application Name",
"app_id": "Application ID",
"app_delete": "Check to delete this application"
}
},
"rules": {
"title": "Configure Android TV state detection rules",
"description": "Configure detection rule for application id {rule_id}",
"data": {
"rule_id": "Application ID",
"rule_values": "List of state detection rules (see documentation)",
"rule_delete": "Check to delete this rule"
}
}
},
"error": {
"invalid_det_rules": "Invalid state detection rules"
}
}
}

View file

@ -0,0 +1,66 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured",
"invalid_unique_id": "Impossible to determine a valid unique id for the device"
},
"error": {
"adbkey_not_file": "ADB key file not found",
"cannot_connect": "Failed to connect",
"invalid_host": "Invalid hostname or IP address",
"key_and_server": "Only provide ADB Key or ADB Server",
"unknown": "Unexpected error"
},
"step": {
"user": {
"data": {
"adb_server_ip": "IP address of the ADB server (leave empty to not use)",
"adb_server_port": "Port of the ADB server",
"adbkey": "Path to your ADB key file (leave empty to auto generate)",
"device_class": "The type of device",
"host": "Host",
"port": "Port"
},
"description": "Set required parameters to connect to your Android TV device",
"title": "Android TV"
}
}
},
"options": {
"error": {
"invalid_det_rules": "Invalid state detection rules"
},
"step": {
"apps": {
"data": {
"app_delete": "Check to delete this application",
"app_id": "Application ID",
"app_name": "Application Name"
},
"description": "Configure application id {app_id}",
"title": "Configure Android TV Apps"
},
"init": {
"data": {
"apps": "Configure applications list",
"exclude_unnamed_apps": "Exclude app with unknown name",
"get_sources": "Whether or not to retrieve the running apps as the list of sources",
"screencap": "Determines if album art should be pulled from what is shown on screen",
"state_detection_rules": "Configure state detection rules",
"turn_off_command": "ADB shell command to override default turn_off command",
"turn_on_command": "ADB shell command to override default turn_on command"
},
"title": "Android TV Options"
},
"rules": {
"data": {
"rule_delete": "Check to delete this rule",
"rule_id": "Application ID",
"rule_values": "List of state detection rules (see documentation)"
},
"description": "Configure detection rule for application id {rule_id}",
"title": "Configure Android TV state detection rules"
}
}
}
}

View file

@ -25,6 +25,7 @@ FLOWS = [
"amberelectric", "amberelectric",
"ambiclimate", "ambiclimate",
"ambient_station", "ambient_station",
"androidtv",
"apple_tv", "apple_tv",
"arcam_fmj", "arcam_fmj",
"aseko_pool_live", "aseko_pool_live",

View file

@ -139,9 +139,9 @@ PATCH_ADB_DEVICE_TCP = patch(
PATCH_ANDROIDTV_OPEN = patch( PATCH_ANDROIDTV_OPEN = patch(
"homeassistant.components.androidtv.media_player.open", mock_open() "homeassistant.components.androidtv.media_player.open", mock_open()
) )
PATCH_KEYGEN = patch("homeassistant.components.androidtv.media_player.keygen") PATCH_KEYGEN = patch("homeassistant.components.androidtv.keygen")
PATCH_SIGNER = patch( PATCH_SIGNER = patch(
"homeassistant.components.androidtv.media_player.ADBPythonSync.load_adbkey", "homeassistant.components.androidtv.ADBPythonSync.load_adbkey",
return_value="signer for testing", return_value="signer for testing",
) )
@ -151,10 +151,6 @@ def isfile(filepath):
return filepath.endswith("adbkey") return filepath.endswith("adbkey")
PATCH_ISFILE = patch("os.path.isfile", isfile)
PATCH_ACCESS = patch("os.access", return_value=True)
def patch_firetv_update(state, current_app, running_apps, hdmi_input): def patch_firetv_update(state, current_app, running_apps, hdmi_input):
"""Patch the `FireTV.update()` method.""" """Patch the `FireTV.update()` method."""
return patch( return patch(

View file

@ -0,0 +1,581 @@
"""Tests for the AndroidTV config flow."""
import json
from socket import gaierror
from unittest.mock import patch
from homeassistant import data_entry_flow
from homeassistant.components.androidtv.config_flow import (
APPS_NEW_ID,
CONF_APP_DELETE,
CONF_APP_ID,
CONF_APP_NAME,
CONF_RULE_DELETE,
CONF_RULE_ID,
CONF_RULE_VALUES,
RULES_NEW_ID,
)
from homeassistant.components.androidtv.const import (
CONF_ADB_SERVER_IP,
CONF_ADB_SERVER_PORT,
CONF_ADBKEY,
CONF_APPS,
CONF_EXCLUDE_UNNAMED_APPS,
CONF_GET_SOURCES,
CONF_SCREENCAP,
CONF_STATE_DETECTION_RULES,
CONF_TURN_OFF_COMMAND,
CONF_TURN_ON_COMMAND,
DEFAULT_ADB_SERVER_PORT,
DEFAULT_PORT,
DOMAIN,
PROP_ETHMAC,
)
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_PLATFORM, CONF_PORT
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
from tests.components.androidtv.patchers import isfile
ADBKEY = "adbkey"
ETH_MAC = "a1:b1:c1:d1:e1:f1"
HOST = "127.0.0.1"
VALID_DETECT_RULE = [{"paused": {"media_session_state": 3}}]
# Android TV device with Python ADB implementation
CONFIG_PYTHON_ADB = {
CONF_HOST: HOST,
CONF_PORT: DEFAULT_PORT,
CONF_DEVICE_CLASS: "androidtv",
CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT,
}
# Android TV device with ADB server
CONFIG_ADB_SERVER = {
CONF_HOST: HOST,
CONF_PORT: DEFAULT_PORT,
CONF_DEVICE_CLASS: "androidtv",
CONF_ADB_SERVER_IP: "127.0.0.1",
CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT,
}
CONNECT_METHOD = (
"homeassistant.components.androidtv.config_flow.async_connect_androidtv"
)
PATCH_ACCESS = patch(
"homeassistant.components.androidtv.config_flow.os.access", return_value=True
)
PATCH_GET_HOST_IP = patch(
"homeassistant.components.androidtv.config_flow.socket.gethostbyname",
return_value=HOST,
)
PATCH_ISFILE = patch(
"homeassistant.components.androidtv.config_flow.os.path.isfile", isfile
)
PATCH_SETUP_ENTRY = patch(
"homeassistant.components.androidtv.async_setup_entry",
return_value=True,
)
class MockConfigDevice:
"""Mock class to emulate Android TV device."""
def __init__(self, eth_mac=ETH_MAC):
"""Initialize a fake device to test config flow."""
self.available = True
self.device_properties = {PROP_ETHMAC: eth_mac}
async def adb_close(self):
"""Fake method to close connection."""
self.available = False
async def _test_user(hass, config):
"""Test user config."""
flow_result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True}
)
assert flow_result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert flow_result["step_id"] == "user"
# test with all provided
with patch(
CONNECT_METHOD,
return_value=(MockConfigDevice(), None),
), PATCH_SETUP_ENTRY as mock_setup_entry, PATCH_GET_HOST_IP:
result = await hass.config_entries.flow.async_configure(
flow_result["flow_id"], user_input=config
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == HOST
assert result["data"] == config
assert len(mock_setup_entry.mock_calls) == 1
async def test_user_python_adb(hass):
"""Test user config for Python ADB."""
await _test_user(hass, CONFIG_PYTHON_ADB)
async def test_user_adb_server(hass):
"""Test user config for ADB server."""
await _test_user(hass, CONFIG_ADB_SERVER)
async def test_import(hass):
"""Test import config."""
# test with all provided
with patch(
CONNECT_METHOD,
return_value=(MockConfigDevice(), None),
), PATCH_SETUP_ENTRY as mock_setup_entry, PATCH_GET_HOST_IP:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=CONFIG_PYTHON_ADB,
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == HOST
assert result["data"] == CONFIG_PYTHON_ADB
assert len(mock_setup_entry.mock_calls) == 1
async def test_user_adbkey(hass):
"""Test user step with adbkey file."""
config_data = CONFIG_PYTHON_ADB.copy()
config_data[CONF_ADBKEY] = ADBKEY
with patch(
CONNECT_METHOD,
return_value=(MockConfigDevice(), None),
), PATCH_SETUP_ENTRY as mock_setup_entry, PATCH_GET_HOST_IP, PATCH_ISFILE, PATCH_ACCESS:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER, "show_advanced_options": True},
data=config_data,
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == HOST
assert result["data"] == config_data
assert len(mock_setup_entry.mock_calls) == 1
async def test_import_data(hass):
"""Test import from configuration file."""
config_data = CONFIG_PYTHON_ADB.copy()
config_data[CONF_PLATFORM] = DOMAIN
config_data[CONF_ADBKEY] = ADBKEY
config_data[CONF_TURN_OFF_COMMAND] = "off"
config_data[CONF_STATE_DETECTION_RULES] = {"a": "b"}
platform_data = {MP_DOMAIN: config_data}
with patch(
CONNECT_METHOD,
return_value=(MockConfigDevice(), None),
), PATCH_SETUP_ENTRY as mock_setup_entry, PATCH_GET_HOST_IP, PATCH_ISFILE, PATCH_ACCESS:
assert await async_setup_component(hass, MP_DOMAIN, platform_data)
await hass.async_block_till_done()
assert len(mock_setup_entry.mock_calls) == 1
async def test_error_both_key_server(hass):
"""Test we abort if both adb key and server are provided."""
config_data = CONFIG_ADB_SERVER.copy()
config_data[CONF_ADBKEY] = ADBKEY
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER, "show_advanced_options": True},
data=config_data,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "key_and_server"}
with patch(
CONNECT_METHOD,
return_value=(MockConfigDevice(), None),
), PATCH_SETUP_ENTRY, PATCH_GET_HOST_IP:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=CONFIG_ADB_SERVER
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == HOST
assert result2["data"] == CONFIG_ADB_SERVER
async def test_error_invalid_key(hass):
"""Test we abort if component is already setup."""
config_data = CONFIG_PYTHON_ADB.copy()
config_data[CONF_ADBKEY] = ADBKEY
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER, "show_advanced_options": True},
data=config_data,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "adbkey_not_file"}
with patch(
CONNECT_METHOD,
return_value=(MockConfigDevice(), None),
), PATCH_SETUP_ENTRY, PATCH_GET_HOST_IP:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=CONFIG_ADB_SERVER
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == HOST
assert result2["data"] == CONFIG_ADB_SERVER
async def test_error_invalid_host(hass):
"""Test we abort if host name is invalid."""
with patch(
"socket.gethostbyname",
side_effect=gaierror,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER, "show_advanced_options": True},
data=CONFIG_ADB_SERVER,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "invalid_host"}
with patch(
CONNECT_METHOD,
return_value=(MockConfigDevice(), None),
), PATCH_SETUP_ENTRY, PATCH_GET_HOST_IP:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=CONFIG_ADB_SERVER
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == HOST
assert result2["data"] == CONFIG_ADB_SERVER
async def test_invalid_serial(hass):
"""Test for invallid serialno."""
with patch(
CONNECT_METHOD,
return_value=(MockConfigDevice(eth_mac=""), None),
), PATCH_GET_HOST_IP:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=CONFIG_ADB_SERVER,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "invalid_unique_id"
async def test_abort_if_host_exist(hass):
"""Test we abort if component is already setup."""
MockConfigEntry(
domain=DOMAIN, data=CONFIG_ADB_SERVER, unique_id=ETH_MAC
).add_to_hass(hass)
config_data = CONFIG_ADB_SERVER.copy()
config_data[CONF_HOST] = "name"
# Should fail, same IP Address (by PATCH_GET_HOST_IP)
with PATCH_GET_HOST_IP:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=config_data,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_abort_import_if_host_exist(hass):
"""Test we abort if component is already setup."""
MockConfigEntry(
domain=DOMAIN, data=CONFIG_ADB_SERVER, unique_id=ETH_MAC
).add_to_hass(hass)
# Should fail, same Host in entry
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=CONFIG_ADB_SERVER,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_abort_if_unique_exist(hass):
"""Test we abort if component is already setup."""
config_data = CONFIG_ADB_SERVER.copy()
config_data[CONF_HOST] = "127.0.0.2"
MockConfigEntry(domain=DOMAIN, data=config_data, unique_id=ETH_MAC).add_to_hass(
hass
)
# Should fail, same SerialNo
with patch(
CONNECT_METHOD,
return_value=(MockConfigDevice(), None),
), PATCH_GET_HOST_IP:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=CONFIG_ADB_SERVER,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_on_connect_failed(hass):
"""Test when we have errors connecting the router."""
flow_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER, "show_advanced_options": True},
)
with patch(CONNECT_METHOD, return_value=(None, "Error")), PATCH_GET_HOST_IP:
result = await hass.config_entries.flow.async_configure(
flow_result["flow_id"], user_input=CONFIG_ADB_SERVER
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "cannot_connect"}
with patch(
CONNECT_METHOD,
side_effect=TypeError,
), PATCH_GET_HOST_IP:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=CONFIG_ADB_SERVER
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["errors"] == {"base": "unknown"}
with patch(
CONNECT_METHOD,
return_value=(MockConfigDevice(), None),
), PATCH_SETUP_ENTRY, PATCH_GET_HOST_IP:
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"], user_input=CONFIG_ADB_SERVER
)
await hass.async_block_till_done()
assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result3["title"] == HOST
assert result3["data"] == CONFIG_ADB_SERVER
async def test_options_flow(hass):
"""Test config flow options."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data=CONFIG_ADB_SERVER,
unique_id=ETH_MAC,
options={
CONF_APPS: {"app1": "App1"},
CONF_STATE_DETECTION_RULES: {"com.plexapp.android": VALID_DETECT_RULE},
},
)
config_entry.add_to_hass(hass)
with PATCH_SETUP_ENTRY:
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
# test app form with existing app
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_APPS: "app1",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "apps"
# test change value in apps form
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_APP_NAME: "Appl1",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
# test app form with new app
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_APPS: APPS_NEW_ID,
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "apps"
# test save value for new app
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_APP_ID: "app2",
CONF_APP_NAME: "Appl2",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
# test app form for delete
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_APPS: "app1",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "apps"
# test delete app1
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_APP_NAME: "Appl1",
CONF_APP_DELETE: True,
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
# test rules form with existing rule
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_STATE_DETECTION_RULES: "com.plexapp.android",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "rules"
# test change value in rule form with invalid json rule
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_RULE_VALUES: "a",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "rules"
assert result["errors"] == {"base": "invalid_det_rules"}
# test change value in rule form with invalid rule
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_RULE_VALUES: json.dumps({"a": "b"}),
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "rules"
assert result["errors"] == {"base": "invalid_det_rules"}
# test change value in rule form with valid rule
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_RULE_VALUES: json.dumps(["standby"]),
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
# test rule form with new rule
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_STATE_DETECTION_RULES: RULES_NEW_ID,
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "rules"
# test save value for new rule
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_RULE_ID: "rule2",
CONF_RULE_VALUES: json.dumps(VALID_DETECT_RULE),
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
# test rules form with delete existing rule
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_STATE_DETECTION_RULES: "com.plexapp.android",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "rules"
# test delete rule
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_RULE_DELETE: True,
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_GET_SOURCES: True,
CONF_EXCLUDE_UNNAMED_APPS: True,
CONF_SCREENCAP: True,
CONF_TURN_OFF_COMMAND: "off",
CONF_TURN_ON_COMMAND: "on",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
apps_options = config_entry.options[CONF_APPS]
assert apps_options.get("app1") is None
assert apps_options["app2"] == "Appl2"
assert config_entry.options[CONF_GET_SOURCES] is True
assert config_entry.options[CONF_EXCLUDE_UNNAMED_APPS] is True
assert config_entry.options[CONF_SCREENCAP] is True
assert config_entry.options[CONF_TURN_OFF_COMMAND] == "off"
assert config_entry.options[CONF_TURN_ON_COMMAND] == "on"

View file

@ -4,22 +4,25 @@ import copy
import logging import logging
from unittest.mock import patch from unittest.mock import patch
from androidtv.constants import APPS as ANDROIDTV_APPS from androidtv.constants import APPS as ANDROIDTV_APPS, KEYS
from androidtv.exceptions import LockNotAcquiredException from androidtv.exceptions import LockNotAcquiredException
import pytest import pytest
from homeassistant.components.androidtv.media_player import ( from homeassistant.components.androidtv.const import (
ANDROIDTV_DOMAIN,
ATTR_COMMAND,
ATTR_DEVICE_PATH,
ATTR_LOCAL_PATH,
CONF_ADB_SERVER_IP, CONF_ADB_SERVER_IP,
CONF_ADB_SERVER_PORT,
CONF_ADBKEY, CONF_ADBKEY,
CONF_APPS, CONF_APPS,
CONF_EXCLUDE_UNNAMED_APPS, CONF_EXCLUDE_UNNAMED_APPS,
CONF_TURN_OFF_COMMAND, CONF_TURN_OFF_COMMAND,
CONF_TURN_ON_COMMAND, CONF_TURN_ON_COMMAND,
KEYS, DEFAULT_ADB_SERVER_PORT,
DEFAULT_PORT,
DOMAIN,
)
from homeassistant.components.androidtv.media_player import (
ATTR_DEVICE_PATH,
ATTR_LOCAL_PATH,
SERVICE_ADB_COMMAND, SERVICE_ADB_COMMAND,
SERVICE_DOWNLOAD, SERVICE_DOWNLOAD,
SERVICE_LEARN_SENDEVENT, SERVICE_LEARN_SENDEVENT,
@ -29,7 +32,7 @@ from homeassistant.components.media_player import (
ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE,
ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED, ATTR_MEDIA_VOLUME_MUTED,
DOMAIN, DOMAIN as MP_DOMAIN,
SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_NEXT_TRACK,
SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY,
@ -46,63 +49,71 @@ from homeassistant.components.media_player import (
) )
from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.components.websocket_api.const import TYPE_RESULT
from homeassistant.const import ( from homeassistant.const import (
ATTR_COMMAND,
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
CONF_DEVICE_CLASS, CONF_DEVICE_CLASS,
CONF_HOST, CONF_HOST,
CONF_NAME, CONF_PORT,
CONF_PLATFORM,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
STATE_OFF, STATE_OFF,
STATE_PLAYING, STATE_PLAYING,
STATE_STANDBY, STATE_STANDBY,
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
) )
from homeassistant.setup import async_setup_component from homeassistant.util import slugify
from tests.common import MockConfigEntry
from tests.components.androidtv import patchers from tests.components.androidtv import patchers
CONF_OPTIONS = "options"
PATCH_ACCESS = patch("homeassistant.components.androidtv.os.access", return_value=True)
PATCH_ISFILE = patch(
"homeassistant.components.androidtv.os.path.isfile", patchers.isfile
)
SHELL_RESPONSE_OFF = "" SHELL_RESPONSE_OFF = ""
SHELL_RESPONSE_STANDBY = "1" SHELL_RESPONSE_STANDBY = "1"
# Android TV device with Python ADB implementation # Android TV device with Python ADB implementation
CONFIG_ANDROIDTV_PYTHON_ADB = { CONFIG_ANDROIDTV_PYTHON_ADB = {
DOMAIN: { DOMAIN: {
CONF_PLATFORM: ANDROIDTV_DOMAIN,
CONF_HOST: "127.0.0.1", CONF_HOST: "127.0.0.1",
CONF_NAME: "Android TV", CONF_PORT: DEFAULT_PORT,
CONF_DEVICE_CLASS: "androidtv", CONF_DEVICE_CLASS: "androidtv",
CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT,
} }
} }
# Android TV device with ADB server # Android TV device with ADB server
CONFIG_ANDROIDTV_ADB_SERVER = { CONFIG_ANDROIDTV_ADB_SERVER = {
DOMAIN: { DOMAIN: {
CONF_PLATFORM: ANDROIDTV_DOMAIN,
CONF_HOST: "127.0.0.1", CONF_HOST: "127.0.0.1",
CONF_NAME: "Android TV", CONF_PORT: DEFAULT_PORT,
CONF_DEVICE_CLASS: "androidtv", CONF_DEVICE_CLASS: "androidtv",
CONF_ADB_SERVER_IP: "127.0.0.1", CONF_ADB_SERVER_IP: "127.0.0.1",
CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT,
} }
} }
# Fire TV device with Python ADB implementation # Fire TV device with Python ADB implementation
CONFIG_FIRETV_PYTHON_ADB = { CONFIG_FIRETV_PYTHON_ADB = {
DOMAIN: { DOMAIN: {
CONF_PLATFORM: ANDROIDTV_DOMAIN,
CONF_HOST: "127.0.0.1", CONF_HOST: "127.0.0.1",
CONF_NAME: "Fire TV", CONF_PORT: DEFAULT_PORT,
CONF_DEVICE_CLASS: "firetv", CONF_DEVICE_CLASS: "firetv",
CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT,
} }
} }
# Fire TV device with ADB server # Fire TV device with ADB server
CONFIG_FIRETV_ADB_SERVER = { CONFIG_FIRETV_ADB_SERVER = {
DOMAIN: { DOMAIN: {
CONF_PLATFORM: ANDROIDTV_DOMAIN,
CONF_HOST: "127.0.0.1", CONF_HOST: "127.0.0.1",
CONF_NAME: "Fire TV", CONF_PORT: DEFAULT_PORT,
CONF_DEVICE_CLASS: "firetv", CONF_DEVICE_CLASS: "firetv",
CONF_ADB_SERVER_IP: "127.0.0.1", CONF_ADB_SERVER_IP: "127.0.0.1",
CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT,
} }
} }
@ -114,12 +125,42 @@ def _setup(config):
else: else:
patch_key = "server" patch_key = "server"
host = config[DOMAIN][CONF_HOST]
if config[DOMAIN].get(CONF_DEVICE_CLASS) != "firetv": if config[DOMAIN].get(CONF_DEVICE_CLASS) != "firetv":
entity_id = "media_player.android_tv" entity_id = slugify(f"Android TV {host}")
else: else:
entity_id = "media_player.fire_tv" entity_id = slugify(f"Fire TV {host}")
entity_id = f"{MP_DOMAIN}.{entity_id}"
return patch_key, entity_id config_entry = MockConfigEntry(
domain=DOMAIN,
data=config[DOMAIN],
unique_id="a1:b1:c1:d1:e1:f1",
options=config[DOMAIN].get(CONF_OPTIONS),
)
return patch_key, entity_id, config_entry
async def test_setup_with_properties(hass):
"""Test that setup succeeds with device properties.
the response must be a string with the following info separated with line break:
"manufacturer, model, serialno, version, mac_wlan0_output, mac_eth0_output"
"""
patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER)
config_entry.add_to_hass(hass)
response = "fake\nfake\n0123456\nfake\nether a1:b1:c1:d1:e1:f1 brd\nnone"
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key
], patchers.patch_shell(response)[patch_key]:
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
async def _test_reconnect(hass, caplog, config): async def _test_reconnect(hass, caplog, config):
@ -130,14 +171,16 @@ async def _test_reconnect(hass, caplog, config):
https://developers.home-assistant.io/docs/en/integration_quality_scale_index.html https://developers.home-assistant.io/docs/en/integration_quality_scale_index.html
""" """
patch_key, entity_id = _setup(config) patch_key, entity_id, config_entry = _setup(config)
config_entry.add_to_hass(hass)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[ ], patchers.patch_shell(SHELL_RESPONSE_OFF)[
patch_key patch_key
], patchers.PATCH_KEYGEN, patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER: ], patchers.PATCH_KEYGEN, patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER:
assert await async_setup_component(hass, DOMAIN, config) assert await hass.config_entries.async_setup(config_entry.entry_id)
# assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done() await hass.async_block_till_done()
await hass.helpers.entity_component.async_update_entity(entity_id) await hass.helpers.entity_component.async_update_entity(entity_id)
@ -190,14 +233,15 @@ async def _test_adb_shell_returns_none(hass, config):
The state should be `None` and the device should be unavailable. The state should be `None` and the device should be unavailable.
""" """
patch_key, entity_id = _setup(config) patch_key, entity_id, config_entry = _setup(config)
config_entry.add_to_hass(hass)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[ ], patchers.patch_shell(SHELL_RESPONSE_OFF)[
patch_key patch_key
], patchers.PATCH_KEYGEN, patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER: ], patchers.PATCH_KEYGEN, patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER:
assert await async_setup_component(hass, DOMAIN, config) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
await hass.helpers.entity_component.async_update_entity(entity_id) await hass.helpers.entity_component.async_update_entity(entity_id)
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
@ -299,14 +343,15 @@ async def test_setup_with_adbkey(hass):
"""Test that setup succeeds when using an ADB key.""" """Test that setup succeeds when using an ADB key."""
config = copy.deepcopy(CONFIG_ANDROIDTV_PYTHON_ADB) config = copy.deepcopy(CONFIG_ANDROIDTV_PYTHON_ADB)
config[DOMAIN][CONF_ADBKEY] = hass.config.path("user_provided_adbkey") config[DOMAIN][CONF_ADBKEY] = hass.config.path("user_provided_adbkey")
patch_key, entity_id = _setup(config) patch_key, entity_id, config_entry = _setup(config)
config_entry.add_to_hass(hass)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[ ], patchers.patch_shell(SHELL_RESPONSE_OFF)[
patch_key patch_key
], patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER, patchers.PATCH_ISFILE, patchers.PATCH_ACCESS: ], patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER, PATCH_ISFILE, PATCH_ACCESS:
assert await async_setup_component(hass, DOMAIN, config) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
await hass.helpers.entity_component.async_update_entity(entity_id) await hass.helpers.entity_component.async_update_entity(entity_id)
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
@ -317,17 +362,22 @@ async def test_setup_with_adbkey(hass):
async def _test_sources(hass, config0): async def _test_sources(hass, config0):
"""Test that sources (i.e., apps) are handled correctly for Android TV and Fire TV devices.""" """Test that sources (i.e., apps) are handled correctly for Android TV and Fire TV devices."""
config = copy.deepcopy(config0) config = copy.deepcopy(config0)
config[DOMAIN][CONF_APPS] = { config[DOMAIN].setdefault(CONF_OPTIONS, {}).update(
{
CONF_APPS: {
"com.app.test1": "TEST 1", "com.app.test1": "TEST 1",
"com.app.test3": None, "com.app.test3": None,
"com.app.test4": SHELL_RESPONSE_OFF, "com.app.test4": SHELL_RESPONSE_OFF,
} }
patch_key, entity_id = _setup(config) }
)
patch_key, entity_id, config_entry = _setup(config)
config_entry.add_to_hass(hass)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, config) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
await hass.helpers.entity_component.async_update_entity(entity_id) await hass.helpers.entity_component.async_update_entity(entity_id)
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
@ -402,17 +452,22 @@ async def test_firetv_sources(hass):
async def _test_exclude_sources(hass, config0, expected_sources): async def _test_exclude_sources(hass, config0, expected_sources):
"""Test that sources (i.e., apps) are handled correctly when the `exclude_unnamed_apps` config parameter is provided.""" """Test that sources (i.e., apps) are handled correctly when the `exclude_unnamed_apps` config parameter is provided."""
config = copy.deepcopy(config0) config = copy.deepcopy(config0)
config[DOMAIN][CONF_APPS] = { config[DOMAIN].setdefault(CONF_OPTIONS, {}).update(
{
CONF_APPS: {
"com.app.test1": "TEST 1", "com.app.test1": "TEST 1",
"com.app.test3": None, "com.app.test3": None,
"com.app.test4": SHELL_RESPONSE_OFF, "com.app.test4": SHELL_RESPONSE_OFF,
} }
patch_key, entity_id = _setup(config) }
)
patch_key, entity_id, config_entry = _setup(config)
config_entry.add_to_hass(hass)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, config) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
await hass.helpers.entity_component.async_update_entity(entity_id) await hass.helpers.entity_component.async_update_entity(entity_id)
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
@ -463,31 +518,36 @@ async def _test_exclude_sources(hass, config0, expected_sources):
async def test_androidtv_exclude_sources(hass): async def test_androidtv_exclude_sources(hass):
"""Test that sources (i.e., apps) are handled correctly for Android TV devices when the `exclude_unnamed_apps` config parameter is provided as true.""" """Test that sources (i.e., apps) are handled correctly for Android TV devices when the `exclude_unnamed_apps` config parameter is provided as true."""
config = copy.deepcopy(CONFIG_ANDROIDTV_ADB_SERVER) config = copy.deepcopy(CONFIG_ANDROIDTV_ADB_SERVER)
config[DOMAIN][CONF_EXCLUDE_UNNAMED_APPS] = True config[DOMAIN][CONF_OPTIONS] = {CONF_EXCLUDE_UNNAMED_APPS: True}
assert await _test_exclude_sources(hass, config, ["TEST 1"]) assert await _test_exclude_sources(hass, config, ["TEST 1"])
async def test_firetv_exclude_sources(hass): async def test_firetv_exclude_sources(hass):
"""Test that sources (i.e., apps) are handled correctly for Fire TV devices when the `exclude_unnamed_apps` config parameter is provided as true.""" """Test that sources (i.e., apps) are handled correctly for Fire TV devices when the `exclude_unnamed_apps` config parameter is provided as true."""
config = copy.deepcopy(CONFIG_FIRETV_ADB_SERVER) config = copy.deepcopy(CONFIG_FIRETV_ADB_SERVER)
config[DOMAIN][CONF_EXCLUDE_UNNAMED_APPS] = True config[DOMAIN][CONF_OPTIONS] = {CONF_EXCLUDE_UNNAMED_APPS: True}
assert await _test_exclude_sources(hass, config, ["TEST 1"]) assert await _test_exclude_sources(hass, config, ["TEST 1"])
async def _test_select_source(hass, config0, source, expected_arg, method_patch): 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.""" """Test that the methods for launching and stopping apps are called correctly when selecting a source."""
config = copy.deepcopy(config0) config = copy.deepcopy(config0)
config[DOMAIN][CONF_APPS] = { config[DOMAIN].setdefault(CONF_OPTIONS, {}).update(
{
CONF_APPS: {
"com.app.test1": "TEST 1", "com.app.test1": "TEST 1",
"com.app.test3": None, "com.app.test3": None,
"com.youtube.test": "YouTube", "com.youtube.test": "YouTube",
} }
patch_key, entity_id = _setup(config) }
)
patch_key, entity_id, config_entry = _setup(config)
config_entry.add_to_hass(hass)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, config) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
await hass.helpers.entity_component.async_update_entity(entity_id) await hass.helpers.entity_component.async_update_entity(entity_id)
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
@ -496,7 +556,7 @@ async def _test_select_source(hass, config0, source, expected_arg, method_patch)
with method_patch as method_patch_: with method_patch as method_patch_:
await hass.services.async_call( await hass.services.async_call(
DOMAIN, MP_DOMAIN,
SERVICE_SELECT_SOURCE, SERVICE_SELECT_SOURCE,
{ATTR_ENTITY_ID: entity_id, ATTR_INPUT_SOURCE: source}, {ATTR_ENTITY_ID: entity_id, ATTR_INPUT_SOURCE: source},
blocking=True, blocking=True,
@ -698,14 +758,15 @@ async def test_firetv_select_source_stop_hidden(hass):
async def _test_setup_fail(hass, config): async def _test_setup_fail(hass, config):
"""Test that the entity is not created when the ADB connection is not established.""" """Test that the entity is not created when the ADB connection is not established."""
patch_key, entity_id = _setup(config) patch_key, entity_id, config_entry = _setup(config)
config_entry.add_to_hass(hass)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(False)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(False)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[ ], patchers.patch_shell(SHELL_RESPONSE_OFF)[
patch_key patch_key
], patchers.PATCH_KEYGEN, patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER: ], patchers.PATCH_KEYGEN, patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER:
assert await async_setup_component(hass, DOMAIN, config) assert await hass.config_entries.async_setup(config_entry.entry_id) is False
await hass.async_block_till_done() await hass.async_block_till_done()
await hass.helpers.entity_component.async_update_entity(entity_id) await hass.helpers.entity_component.async_update_entity(entity_id)
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
@ -724,68 +785,24 @@ async def test_setup_fail_firetv(hass):
assert await _test_setup_fail(hass, CONFIG_FIRETV_PYTHON_ADB) assert await _test_setup_fail(hass, CONFIG_FIRETV_PYTHON_ADB)
async def test_setup_two_devices(hass):
"""Test that two devices can be set up."""
config = {
DOMAIN: [
CONFIG_ANDROIDTV_ADB_SERVER[DOMAIN],
copy.deepcopy(CONFIG_FIRETV_ADB_SERVER[DOMAIN]),
]
}
config[DOMAIN][1][CONF_HOST] = "127.0.0.2"
patch_key = "server"
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done()
for entity_id in ["media_player.android_tv", "media_player.fire_tv"]:
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
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(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER)
await hass.async_block_till_done()
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(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER)
await hass.async_block_till_done()
async def test_adb_command(hass): async def test_adb_command(hass):
"""Test sending a command via the `androidtv.adb_command` service.""" """Test sending a command via the `androidtv.adb_command` service."""
patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER)
config_entry.add_to_hass(hass)
command = "test command" command = "test command"
response = "test response" response = "test response"
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch(
"androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", return_value=response "androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", return_value=response
) as patch_shell: ) as patch_shell:
await hass.services.async_call( await hass.services.async_call(
ANDROIDTV_DOMAIN, DOMAIN,
SERVICE_ADB_COMMAND, SERVICE_ADB_COMMAND,
{ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command},
blocking=True, blocking=True,
@ -799,14 +816,15 @@ async def test_adb_command(hass):
async def test_adb_command_unicode_decode_error(hass): async def test_adb_command_unicode_decode_error(hass):
"""Test sending a command via the `androidtv.adb_command` service that raises a UnicodeDecodeError exception.""" """Test sending a command via the `androidtv.adb_command` service that raises a UnicodeDecodeError exception."""
patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER)
config_entry.add_to_hass(hass)
command = "test command" command = "test command"
response = b"test response" response = b"test response"
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch(
@ -814,7 +832,7 @@ async def test_adb_command_unicode_decode_error(hass):
side_effect=UnicodeDecodeError("utf-8", response, 0, len(response), "TEST"), side_effect=UnicodeDecodeError("utf-8", response, 0, len(response), "TEST"),
): ):
await hass.services.async_call( await hass.services.async_call(
ANDROIDTV_DOMAIN, DOMAIN,
SERVICE_ADB_COMMAND, SERVICE_ADB_COMMAND,
{ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command},
blocking=True, blocking=True,
@ -828,22 +846,22 @@ async def test_adb_command_unicode_decode_error(hass):
async def test_adb_command_key(hass): async def test_adb_command_key(hass):
"""Test sending a key command via the `androidtv.adb_command` service.""" """Test sending a key command via the `androidtv.adb_command` service."""
patch_key = "server" patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER)
entity_id = "media_player.android_tv" config_entry.add_to_hass(hass)
command = "HOME" command = "HOME"
response = None response = None
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch(
"androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", return_value=response "androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", return_value=response
) as patch_shell: ) as patch_shell:
await hass.services.async_call( await hass.services.async_call(
ANDROIDTV_DOMAIN, DOMAIN,
SERVICE_ADB_COMMAND, SERVICE_ADB_COMMAND,
{ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command},
blocking=True, blocking=True,
@ -857,15 +875,15 @@ async def test_adb_command_key(hass):
async def test_adb_command_get_properties(hass): async def test_adb_command_get_properties(hass):
"""Test sending the "GET_PROPERTIES" command via the `androidtv.adb_command` service.""" """Test sending the "GET_PROPERTIES" command via the `androidtv.adb_command` service."""
patch_key = "server" patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER)
entity_id = "media_player.android_tv" config_entry.add_to_hass(hass)
command = "GET_PROPERTIES" command = "GET_PROPERTIES"
response = {"test key": "test value"} response = {"test key": "test value"}
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch(
@ -873,7 +891,7 @@ async def test_adb_command_get_properties(hass):
return_value=response, return_value=response,
) as patch_get_props: ) as patch_get_props:
await hass.services.async_call( await hass.services.async_call(
ANDROIDTV_DOMAIN, DOMAIN,
SERVICE_ADB_COMMAND, SERVICE_ADB_COMMAND,
{ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command},
blocking=True, blocking=True,
@ -887,14 +905,14 @@ async def test_adb_command_get_properties(hass):
async def test_learn_sendevent(hass): async def test_learn_sendevent(hass):
"""Test the `androidtv.learn_sendevent` service.""" """Test the `androidtv.learn_sendevent` service."""
patch_key = "server" patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER)
entity_id = "media_player.android_tv" config_entry.add_to_hass(hass)
response = "sendevent 1 2 3 4" response = "sendevent 1 2 3 4"
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch(
@ -902,7 +920,7 @@ async def test_learn_sendevent(hass):
return_value=response, return_value=response,
) as patch_learn_sendevent: ) as patch_learn_sendevent:
await hass.services.async_call( await hass.services.async_call(
ANDROIDTV_DOMAIN, DOMAIN,
SERVICE_LEARN_SENDEVENT, SERVICE_LEARN_SENDEVENT,
{ATTR_ENTITY_ID: entity_id}, {ATTR_ENTITY_ID: entity_id},
blocking=True, blocking=True,
@ -916,12 +934,13 @@ async def test_learn_sendevent(hass):
async def test_update_lock_not_acquired(hass): async def test_update_lock_not_acquired(hass):
"""Test that the state does not get updated when a `LockNotAcquiredException` is raised.""" """Test that the state does not get updated when a `LockNotAcquiredException` is raised."""
patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER)
config_entry.add_to_hass(hass)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
@ -948,20 +967,21 @@ async def test_update_lock_not_acquired(hass):
async def test_download(hass): async def test_download(hass):
"""Test the `androidtv.download` service.""" """Test the `androidtv.download` service."""
patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER)
config_entry.add_to_hass(hass)
device_path = "device/path" device_path = "device/path"
local_path = "local/path" local_path = "local/path"
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
# Failed download because path is not whitelisted # Failed download because path is not whitelisted
with patch("androidtv.basetv.basetv_async.BaseTVAsync.adb_pull") as patch_pull: with patch("androidtv.basetv.basetv_async.BaseTVAsync.adb_pull") as patch_pull:
await hass.services.async_call( await hass.services.async_call(
ANDROIDTV_DOMAIN, DOMAIN,
SERVICE_DOWNLOAD, SERVICE_DOWNLOAD,
{ {
ATTR_ENTITY_ID: entity_id, ATTR_ENTITY_ID: entity_id,
@ -975,9 +995,11 @@ async def test_download(hass):
# Successful download # Successful download
with patch( with patch(
"androidtv.basetv.basetv_async.BaseTVAsync.adb_pull" "androidtv.basetv.basetv_async.BaseTVAsync.adb_pull"
) as patch_pull, patch.object(hass.config, "is_allowed_path", return_value=True): ) as patch_pull, patch.object(
hass.config, "is_allowed_path", return_value=True
):
await hass.services.async_call( await hass.services.async_call(
ANDROIDTV_DOMAIN, DOMAIN,
SERVICE_DOWNLOAD, SERVICE_DOWNLOAD,
{ {
ATTR_ENTITY_ID: entity_id, ATTR_ENTITY_ID: entity_id,
@ -991,20 +1013,21 @@ async def test_download(hass):
async def test_upload(hass): async def test_upload(hass):
"""Test the `androidtv.upload` service.""" """Test the `androidtv.upload` service."""
patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER)
config_entry.add_to_hass(hass)
device_path = "device/path" device_path = "device/path"
local_path = "local/path" local_path = "local/path"
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
# Failed upload because path is not whitelisted # Failed upload because path is not whitelisted
with patch("androidtv.basetv.basetv_async.BaseTVAsync.adb_push") as patch_push: with patch("androidtv.basetv.basetv_async.BaseTVAsync.adb_push") as patch_push:
await hass.services.async_call( await hass.services.async_call(
ANDROIDTV_DOMAIN, DOMAIN,
SERVICE_UPLOAD, SERVICE_UPLOAD,
{ {
ATTR_ENTITY_ID: entity_id, ATTR_ENTITY_ID: entity_id,
@ -1018,9 +1041,11 @@ async def test_upload(hass):
# Successful upload # Successful upload
with patch( with patch(
"androidtv.basetv.basetv_async.BaseTVAsync.adb_push" "androidtv.basetv.basetv_async.BaseTVAsync.adb_push"
) as patch_push, patch.object(hass.config, "is_allowed_path", return_value=True): ) as patch_push, patch.object(
hass.config, "is_allowed_path", return_value=True
):
await hass.services.async_call( await hass.services.async_call(
ANDROIDTV_DOMAIN, DOMAIN,
SERVICE_UPLOAD, SERVICE_UPLOAD,
{ {
ATTR_ENTITY_ID: entity_id, ATTR_ENTITY_ID: entity_id,
@ -1034,19 +1059,20 @@ async def test_upload(hass):
async def test_androidtv_volume_set(hass): async def test_androidtv_volume_set(hass):
"""Test setting the volume for an Android TV device.""" """Test setting the volume for an Android TV device."""
patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER)
config_entry.add_to_hass(hass)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch(
"androidtv.basetv.basetv_async.BaseTVAsync.set_volume_level", return_value=0.5 "androidtv.basetv.basetv_async.BaseTVAsync.set_volume_level", return_value=0.5
) as patch_set_volume_level: ) as patch_set_volume_level:
await hass.services.async_call( await hass.services.async_call(
DOMAIN, MP_DOMAIN,
SERVICE_VOLUME_SET, SERVICE_VOLUME_SET,
{ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_LEVEL: 0.5},
blocking=True, blocking=True,
@ -1060,12 +1086,13 @@ async def test_get_image(hass, hass_ws_client):
This is based on `test_get_image` in tests/components/media_player/test_init.py. This is based on `test_get_image` in tests/components/media_player/test_init.py.
""" """
patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER)
config_entry.add_to_hass(hass)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
with patchers.patch_shell("11")[patch_key]: with patchers.patch_shell("11")[patch_key]:
@ -1126,7 +1153,7 @@ async def _test_service(
f"androidtv.{androidtv_patch}.{androidtv_method}", return_value=return_value f"androidtv.{androidtv_patch}.{androidtv_method}", return_value=return_value
) as service_call: ) as service_call:
await hass.services.async_call( await hass.services.async_call(
DOMAIN, MP_DOMAIN,
ha_service_name, ha_service_name,
service_data=service_data, service_data=service_data,
blocking=True, blocking=True,
@ -1136,13 +1163,12 @@ async def _test_service(
async def test_services_androidtv(hass): async def test_services_androidtv(hass):
"""Test media player services for an Android TV device.""" """Test media player services for an Android TV device."""
patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER)
config_entry.add_to_hass(hass)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[patch_key]: with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[patch_key]:
with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component( assert await hass.config_entries.async_setup(config_entry.entry_id)
hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER
)
await hass.async_block_till_done() await hass.async_block_till_done()
with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key]: with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key]:
@ -1185,14 +1211,17 @@ async def test_services_androidtv(hass):
async def test_services_firetv(hass): async def test_services_firetv(hass):
"""Test media player services for a Fire TV device.""" """Test media player services for a Fire TV device."""
patch_key, entity_id = _setup(CONFIG_FIRETV_ADB_SERVER)
config = copy.deepcopy(CONFIG_FIRETV_ADB_SERVER) config = copy.deepcopy(CONFIG_FIRETV_ADB_SERVER)
config[DOMAIN][CONF_TURN_OFF_COMMAND] = "test off" config[DOMAIN][CONF_OPTIONS] = {
config[DOMAIN][CONF_TURN_ON_COMMAND] = "test on" CONF_TURN_OFF_COMMAND: "test off",
CONF_TURN_ON_COMMAND: "test on",
}
patch_key, entity_id, config_entry = _setup(config)
config_entry.add_to_hass(hass)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[patch_key]: with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[patch_key]:
with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, config) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key]: with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key]:
@ -1203,12 +1232,13 @@ async def test_services_firetv(hass):
async def test_connection_closed_on_ha_stop(hass): async def test_connection_closed_on_ha_stop(hass):
"""Test that the ADB socket connection is closed when HA stops.""" """Test that the ADB socket connection is closed when HA stops."""
patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER)
config_entry.add_to_hass(hass)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]:
assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch(
@ -1224,14 +1254,15 @@ async def test_exception(hass):
HA will attempt to reconnect on the next update. HA will attempt to reconnect on the next update.
""" """
patch_key, entity_id = _setup(CONFIG_ANDROIDTV_PYTHON_ADB) patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_PYTHON_ADB)
config_entry.add_to_hass(hass)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
patch_key patch_key
], patchers.patch_shell(SHELL_RESPONSE_OFF)[ ], patchers.patch_shell(SHELL_RESPONSE_OFF)[
patch_key patch_key
], patchers.PATCH_KEYGEN, patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER: ], patchers.PATCH_KEYGEN, patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER:
assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_PYTHON_ADB) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
await hass.helpers.entity_component.async_update_entity(entity_id) await hass.helpers.entity_component.async_update_entity(entity_id)