Add config_flow to AndroidTV integration (#54444)
Co-authored-by: Robert Hillis <tkdrob4390@yahoo.com>
This commit is contained in:
parent
2dfd4c49da
commit
5a41251d45
14 changed files with 1850 additions and 535 deletions
|
@ -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/*
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}")
|
||||||
|
|
378
homeassistant/components/androidtv/config_flow.py
Normal file
378
homeassistant/components/androidtv/config_flow.py
Normal 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
|
34
homeassistant/components/androidtv/const.py
Normal file
34
homeassistant/components/androidtv/const.py
Normal 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"
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
66
homeassistant/components/androidtv/strings.json
Normal file
66
homeassistant/components/androidtv/strings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
66
homeassistant/components/androidtv/translations/en.json
Normal file
66
homeassistant/components/androidtv/translations/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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(
|
||||||
|
|
581
tests/components/androidtv/test_config_flow.py
Normal file
581
tests/components/androidtv/test_config_flow.py
Normal 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"
|
|
@ -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)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue