diff --git a/.coveragerc b/.coveragerc index 47a82d81861..0c48b0ebb25 100644 --- a/.coveragerc +++ b/.coveragerc @@ -55,6 +55,7 @@ omit = homeassistant/components/amcrest/* homeassistant/components/ampio/* homeassistant/components/android_ip_webcam/* + homeassistant/components/androidtv/__init__.py homeassistant/components/anel_pwrctrl/switch.py homeassistant/components/anthemav/media_player.py homeassistant/components/apcupsd/* diff --git a/CODEOWNERS b/CODEOWNERS index 65ab446fb97..bb926fa8b5b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -63,8 +63,8 @@ tests/components/ambient_station/* @bachya homeassistant/components/amcrest/* @flacjacket homeassistant/components/analytics/* @home-assistant/core @ludeeus tests/components/analytics/* @home-assistant/core @ludeeus -homeassistant/components/androidtv/* @JeffLIrion -tests/components/androidtv/* @JeffLIrion +homeassistant/components/androidtv/* @JeffLIrion @ollo69 +tests/components/androidtv/* @JeffLIrion @ollo69 homeassistant/components/apache_kafka/* @bachya tests/components/apache_kafka/* @bachya homeassistant/components/api/* @home-assistant/core diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index 14832aef315..d64329526b8 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -1 +1,192 @@ """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}") diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py new file mode 100644 index 00000000000..c346378fbc2 --- /dev/null +++ b/homeassistant/components/androidtv/config_flow.py @@ -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 diff --git a/homeassistant/components/androidtv/const.py b/homeassistant/components/androidtv/const.py new file mode 100644 index 00000000000..f6f0c07286f --- /dev/null +++ b/homeassistant/components/androidtv/const.py @@ -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" diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 00be4fa50c4..f50876e6629 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -7,6 +7,7 @@ "androidtv[async]==0.0.60", "pure-python-adb[async]==0.3.0.dev0" ], - "codeowners": ["@JeffLIrion"], + "codeowners": ["@JeffLIrion", "@ollo69"], + "config_flow": true, "iot_class": "local_polling" } diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 89deeec25b8..099c4fddba2 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -1,10 +1,10 @@ """Support for functionality to interact with Android TV / Fire TV devices.""" +from __future__ import annotations + from datetime import datetime import functools import logging -import os -from adb_shell.auth.keygen import keygen from adb_shell.exceptions import ( AdbTimeoutError, InvalidChecksumError, @@ -13,10 +13,8 @@ from adb_shell.exceptions import ( TcpTimeoutException, ) 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.exceptions import LockNotAcquiredException -from androidtv.setup_async import setup import voluptuous as vol 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_STEP, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_COMMAND, - ATTR_ENTITY_ID, + ATTR_CONNECTIONS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SW_VERSION, CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME, CONF_PORT, - EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, 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.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__) @@ -85,77 +116,45 @@ ATTR_DEVICE_PATH = "device_path" ATTR_HDMI_INPUT = "hdmi_input" 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_DOWNLOAD = "download" SERVICE_LEARN_SENDEVENT = "learn_sendevent" SERVICE_UPLOAD = "upload" -SERVICE_ADB_COMMAND_SCHEMA = vol.Schema( - {vol.Required(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_COMMAND): cv.string} -) +DEFAULT_NAME = "Android TV" -SERVICE_DOWNLOAD_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_DEVICE_PATH): cv.string, - vol.Required(ATTR_LOCAL_PATH): cv.string, - } -) - -SERVICE_UPLOAD_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_DEVICE_PATH): cv.string, - vol.Required(ATTR_LOCAL_PATH): cv.string, - } -) - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): vol.In( - DEVICE_CLASSES +# Deprecated in Home Assistant 2022.2 +PLATFORM_SCHEMA = cv.deprecated( + vol.All( + PLATFORM_SCHEMA=PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): vol.In( + DEVICE_CLASSES + ), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_ADBKEY): cv.isfile, + vol.Optional(CONF_ADB_SERVER_IP): cv.string, + 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_APPS, default={}): vol.Schema( + {cv.string: vol.Any(cv.string, None)} + ), + vol.Optional(CONF_TURN_ON_COMMAND): cv.string, + vol.Optional(CONF_TURN_OFF_COMMAND): cv.string, + vol.Optional(CONF_STATE_DETECTION_RULES, default={}): vol.Schema( + {cv.string: ha_state_detection_rules_validator(vol.Invalid)} + ), + 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_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_ADBKEY): cv.isfile, - vol.Optional(CONF_ADB_SERVER_IP): cv.string, - 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_APPS, default={}): vol.Schema( - {cv.string: vol.Any(cv.string, None)} - ), - vol.Optional(CONF_TURN_ON_COMMAND): cv.string, - vol.Optional(CONF_TURN_OFF_COMMAND): cv.string, - vol.Optional(CONF_STATE_DETECTION_RULES, default={}): vol.Schema( - {cv.string: ha_state_detection_rules_validator(vol.Invalid)} - ), - vol.Optional(CONF_EXCLUDE_UNNAMED_APPS, default=False): cv.boolean, - vol.Optional(CONF_SCREENCAP, default=DEFAULT_SCREENCAP): cv.boolean, - } + ) ) # Translate from `AndroidTV` / `FireTV` reported state to HA state. @@ -168,180 +167,108 @@ ANDROIDTV_STATES = { } -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_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info=None, +) -> None: """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]: - _LOGGER.warning("Platform already setup on %s, skipping", address) - return + # get main data + config_data = { + 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( - setup_androidtv, hass, config - ) - - 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: - # Determine the name that will be used for the device in the log - if CONF_NAME in config: - device_name = config[CONF_NAME] - elif 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" - - _LOGGER.warning( - "Could not connect to %s at %s %s", device_name, address, adb_log + # get options + 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, ) - raise PlatformNotReady + if key in config + } - async def _async_close(event): - """Close the ADB socket connection when HA stops.""" - await aftv.adb_close() + # save option to use with entry + if config_options: + config_data[CONF_MIGRATION_OPTIONS] = config_options - # Close the ADB connection when HA stops - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close) + # Launch config entries setup + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config_data + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Android TV entity.""" + aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV] + 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: + device_name = f"{device_type} {entry.data[CONF_HOST]}" device_args = [ aftv, - config[CONF_NAME], - config[CONF_APPS], - config[CONF_GET_SOURCES], - config.get(CONF_TURN_ON_COMMAND), - config.get(CONF_TURN_OFF_COMMAND), - config[CONF_EXCLUDE_UNNAMED_APPS], - config[CONF_SCREENCAP], + device_name, + device_type, + entry.unique_id, + entry.entry_id, + hass.data[DOMAIN][entry.entry_id], ] - if aftv.DEVICE_CLASS == DEVICE_ANDROIDTV: - device = AndroidTVDevice(*device_args) - device_name = config.get(CONF_NAME, "Android TV") - else: - device = FireTVDevice(*device_args) - device_name = config.get(CONF_NAME, "Fire TV") - - 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() - - 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 + async_add_entities( + [ + AndroidTVDevice(*device_args) + if device_class == DEVICE_ANDROIDTV + else FireTVDevice(*device_args) ] - - 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, - schema=SERVICE_ADB_COMMAND_SCHEMA, ) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_ADB_COMMAND, + {vol.Required(ATTR_COMMAND): cv.string}, + "adb_command", + ) platform.async_register_entity_service( SERVICE_LEARN_SENDEVENT, {}, "learn_sendevent" ) - - 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, + platform.async_register_entity_service( SERVICE_DOWNLOAD, - service_download, - schema=SERVICE_DOWNLOAD_SCHEMA, + { + vol.Required(ATTR_DEVICE_PATH): cv.string, + vol.Required(ATTR_LOCAL_PATH): cv.string, + }, + "service_download", ) - - async def service_upload(service): - """Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" - local_path = service.data[ATTR_LOCAL_PATH] - if not hass.config.is_allowed_path(local_path): - _LOGGER.warning("'%s' is not secure to load data from!", local_path) - return - - device_path = service.data[ATTR_DEVICE_PATH] - entity_id = service.data[ATTR_ENTITY_ID] - target_devices = [ - dev - for dev in hass.data[ANDROIDTV_DOMAIN].values() - if dev.entity_id in entity_id - ] - - for target_device in target_devices: - await target_device.adb_push(local_path, device_path) - - hass.services.async_register( - ANDROIDTV_DOMAIN, SERVICE_UPLOAD, service_upload, schema=SERVICE_UPLOAD_SCHEMA + platform.async_register_entity_service( + SERVICE_UPLOAD, + { + vol.Required(ATTR_DEVICE_PATH): cv.string, + vol.Required(ATTR_LOCAL_PATH): cv.string, + }, + "service_upload", ) @@ -398,37 +325,42 @@ class ADBDevice(MediaPlayerEntity): self, aftv, name, - apps, - get_sources, - turn_on_command, - turn_off_command, - exclude_unnamed_apps, - screencap, + dev_type, + unique_id, + entry_id, + entry_data, ): """Initialize the Android TV / Fire TV device.""" self.aftv = aftv self._attr_name = name - 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 - } + self._attr_unique_id = unique_id + self._entry_id = entry_id + self._entry_data = entry_data - # 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 = get_sources - self._attr_unique_id = self.aftv.device_properties.get("serialno") + info = aftv.device_properties + model = info.get(ATTR_MODEL) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + model=f"{model} ({dev_type})" if model else dev_type, + 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.turn_off_command = turn_off_command - - self._exclude_unnamed_apps = exclude_unnamed_apps - self._screencap = screencap + self._app_id_to_name = {} + self._app_name_to_id = {} + self._get_sources = DEFAULT_GET_SOURCES + self._exclude_unnamed_apps = DEFAULT_EXCLUDE_UNNAMED_APPS + self._screencap = DEFAULT_SCREENCAP + self.turn_on_command = None + self.turn_off_command = None # ADB exceptions to catch - if not self.aftv.adb_server_ip: + if not aftv.adb_server_ip: # Using "adb_shell" (Python ADB implementation) self.exceptions = ( AdbTimeoutError, @@ -450,8 +382,46 @@ class ADBDevice(MediaPlayerEntity): 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 - def media_image_hash(self): + def media_image_hash(self) -> str | None: """Hash value for media image.""" 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_)) @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.""" - if key := KEYS.get(cmd): + if key := KEYS.get(command): await self.aftv.adb_shell(f"input keyevent {key}") return - if cmd == "GET_PROPERTIES": + if command == "GET_PROPERTIES": self._attr_extra_state_attributes[ATTR_ADB_RESPONSE] = str( await self.aftv.get_properties_dict() ) @@ -545,7 +515,7 @@ class ADBDevice(MediaPlayerEntity): return try: - response = await self.aftv.adb_shell(cmd) + response = await self.aftv.adb_shell(command) except UnicodeDecodeError: return @@ -571,13 +541,21 @@ class ADBDevice(MediaPlayerEntity): _LOGGER.info("%s", msg) @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.""" + 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) @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.""" + 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) diff --git a/homeassistant/components/androidtv/services.yaml b/homeassistant/components/androidtv/services.yaml index 6c8469f46c0..fef06266e52 100644 --- a/homeassistant/components/androidtv/services.yaml +++ b/homeassistant/components/androidtv/services.yaml @@ -3,14 +3,11 @@ adb_command: name: ADB command description: Send an ADB command to an Android TV / Fire TV device. + target: + entity: + integration: androidtv + domain: media_player fields: - entity_id: - description: Name(s) of Android TV / Fire TV entities. - required: true - selector: - entity: - integration: androidtv - domain: media_player command: name: Command description: Either a key command or an ADB shell command. @@ -21,14 +18,11 @@ adb_command: download: name: Download description: Download a file from your Android TV / Fire TV device to your Home Assistant instance. + target: + entity: + integration: androidtv + domain: media_player fields: - entity_id: - description: Name of Android TV / Fire TV entity. - required: true - selector: - entity: - integration: androidtv - domain: media_player device_path: name: Device path description: The filepath on the Android TV / Fire TV device. @@ -46,14 +40,11 @@ download: upload: name: Upload description: Upload a file from your Home Assistant instance to an Android TV / Fire TV device. + target: + entity: + integration: androidtv + domain: media_player fields: - entity_id: - description: Name(s) of Android TV / Fire TV entities. - required: true - selector: - entity: - integration: androidtv - domain: media_player device_path: name: Device path description: The filepath on the Android TV / Fire TV device. diff --git a/homeassistant/components/androidtv/strings.json b/homeassistant/components/androidtv/strings.json new file mode 100644 index 00000000000..c6840f622c7 --- /dev/null +++ b/homeassistant/components/androidtv/strings.json @@ -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" + } + } +} diff --git a/homeassistant/components/androidtv/translations/en.json b/homeassistant/components/androidtv/translations/en.json new file mode 100644 index 00000000000..2b7b86e8bfe --- /dev/null +++ b/homeassistant/components/androidtv/translations/en.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 74a90052fa4..f1474f415d0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -25,6 +25,7 @@ FLOWS = [ "amberelectric", "ambiclimate", "ambient_station", + "androidtv", "apple_tv", "arcam_fmj", "aseko_pool_live", diff --git a/tests/components/androidtv/patchers.py b/tests/components/androidtv/patchers.py index b8ee4aaa2cd..c92ac11ba4b 100644 --- a/tests/components/androidtv/patchers.py +++ b/tests/components/androidtv/patchers.py @@ -139,9 +139,9 @@ PATCH_ADB_DEVICE_TCP = patch( PATCH_ANDROIDTV_OPEN = patch( "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( - "homeassistant.components.androidtv.media_player.ADBPythonSync.load_adbkey", + "homeassistant.components.androidtv.ADBPythonSync.load_adbkey", return_value="signer for testing", ) @@ -151,10 +151,6 @@ def isfile(filepath): 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): """Patch the `FireTV.update()` method.""" return patch( diff --git a/tests/components/androidtv/test_config_flow.py b/tests/components/androidtv/test_config_flow.py new file mode 100644 index 00000000000..3da1a113887 --- /dev/null +++ b/tests/components/androidtv/test_config_flow.py @@ -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" diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index d72cf36438b..f8f08fa2060 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -4,22 +4,25 @@ import copy import logging 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 import pytest -from homeassistant.components.androidtv.media_player import ( - ANDROIDTV_DOMAIN, - ATTR_COMMAND, - ATTR_DEVICE_PATH, - ATTR_LOCAL_PATH, +from homeassistant.components.androidtv.const import ( CONF_ADB_SERVER_IP, + CONF_ADB_SERVER_PORT, CONF_ADBKEY, CONF_APPS, CONF_EXCLUDE_UNNAMED_APPS, CONF_TURN_OFF_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_DOWNLOAD, SERVICE_LEARN_SENDEVENT, @@ -29,7 +32,7 @@ from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, - DOMAIN, + DOMAIN as MP_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, @@ -46,63 +49,71 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import ( + ATTR_COMMAND, ATTR_ENTITY_ID, CONF_DEVICE_CLASS, CONF_HOST, - CONF_NAME, - CONF_PLATFORM, + CONF_PORT, EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_PLAYING, STATE_STANDBY, 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 +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_STANDBY = "1" # Android TV device with Python ADB implementation CONFIG_ANDROIDTV_PYTHON_ADB = { DOMAIN: { - CONF_PLATFORM: ANDROIDTV_DOMAIN, CONF_HOST: "127.0.0.1", - CONF_NAME: "Android TV", + CONF_PORT: DEFAULT_PORT, CONF_DEVICE_CLASS: "androidtv", + CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT, } } # Android TV device with ADB server CONFIG_ANDROIDTV_ADB_SERVER = { DOMAIN: { - CONF_PLATFORM: ANDROIDTV_DOMAIN, CONF_HOST: "127.0.0.1", - CONF_NAME: "Android TV", + CONF_PORT: DEFAULT_PORT, CONF_DEVICE_CLASS: "androidtv", CONF_ADB_SERVER_IP: "127.0.0.1", + CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT, } } # Fire TV device with Python ADB implementation CONFIG_FIRETV_PYTHON_ADB = { DOMAIN: { - CONF_PLATFORM: ANDROIDTV_DOMAIN, CONF_HOST: "127.0.0.1", - CONF_NAME: "Fire TV", + CONF_PORT: DEFAULT_PORT, CONF_DEVICE_CLASS: "firetv", + CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT, } } # Fire TV device with ADB server CONFIG_FIRETV_ADB_SERVER = { DOMAIN: { - CONF_PLATFORM: ANDROIDTV_DOMAIN, CONF_HOST: "127.0.0.1", - CONF_NAME: "Fire TV", + CONF_PORT: DEFAULT_PORT, CONF_DEVICE_CLASS: "firetv", CONF_ADB_SERVER_IP: "127.0.0.1", + CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT, } } @@ -114,12 +125,42 @@ def _setup(config): else: patch_key = "server" + host = config[DOMAIN][CONF_HOST] if config[DOMAIN].get(CONF_DEVICE_CLASS) != "firetv": - entity_id = "media_player.android_tv" + entity_id = slugify(f"Android TV {host}") 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): @@ -130,14 +171,16 @@ async def _test_reconnect(hass, caplog, config): 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)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[ patch_key ], 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.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. """ - 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)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[ patch_key ], 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.helpers.entity_component.async_update_entity(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.""" config = copy.deepcopy(CONFIG_ANDROIDTV_PYTHON_ADB) 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)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[ patch_key - ], patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER, patchers.PATCH_ISFILE, patchers.PATCH_ACCESS: - assert await async_setup_component(hass, DOMAIN, config) + ], patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER, PATCH_ISFILE, PATCH_ACCESS: + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() await hass.helpers.entity_component.async_update_entity(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): """Test that sources (i.e., apps) are handled correctly for Android TV and Fire TV devices.""" config = copy.deepcopy(config0) - config[DOMAIN][CONF_APPS] = { - "com.app.test1": "TEST 1", - "com.app.test3": None, - "com.app.test4": SHELL_RESPONSE_OFF, - } - patch_key, entity_id = _setup(config) + config[DOMAIN].setdefault(CONF_OPTIONS, {}).update( + { + CONF_APPS: { + "com.app.test1": "TEST 1", + "com.app.test3": None, + "com.app.test4": SHELL_RESPONSE_OFF, + } + } + ) + 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 ], 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.helpers.entity_component.async_update_entity(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): """Test that sources (i.e., apps) are handled correctly when the `exclude_unnamed_apps` config parameter is provided.""" config = copy.deepcopy(config0) - config[DOMAIN][CONF_APPS] = { - "com.app.test1": "TEST 1", - "com.app.test3": None, - "com.app.test4": SHELL_RESPONSE_OFF, - } - patch_key, entity_id = _setup(config) + config[DOMAIN].setdefault(CONF_OPTIONS, {}).update( + { + CONF_APPS: { + "com.app.test1": "TEST 1", + "com.app.test3": None, + "com.app.test4": SHELL_RESPONSE_OFF, + } + } + ) + 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 ], 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.helpers.entity_component.async_update_entity(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): """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[DOMAIN][CONF_EXCLUDE_UNNAMED_APPS] = True + config[DOMAIN][CONF_OPTIONS] = {CONF_EXCLUDE_UNNAMED_APPS: True} assert await _test_exclude_sources(hass, config, ["TEST 1"]) 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.""" 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"]) async def _test_select_source(hass, config0, source, expected_arg, method_patch): """Test that the methods for launching and stopping apps are called correctly when selecting a source.""" config = copy.deepcopy(config0) - config[DOMAIN][CONF_APPS] = { - "com.app.test1": "TEST 1", - "com.app.test3": None, - "com.youtube.test": "YouTube", - } - patch_key, entity_id = _setup(config) + config[DOMAIN].setdefault(CONF_OPTIONS, {}).update( + { + CONF_APPS: { + "com.app.test1": "TEST 1", + "com.app.test3": None, + "com.youtube.test": "YouTube", + } + } + ) + 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 ], 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.helpers.entity_component.async_update_entity(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_: await hass.services.async_call( - DOMAIN, + MP_DOMAIN, SERVICE_SELECT_SOURCE, {ATTR_ENTITY_ID: entity_id, ATTR_INPUT_SOURCE: source}, blocking=True, @@ -698,14 +758,15 @@ async def test_firetv_select_source_stop_hidden(hass): async def _test_setup_fail(hass, config): """Test that the entity is not created when the ADB connection is not established.""" - patch_key, entity_id = _setup(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)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[ patch_key ], 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.helpers.entity_component.async_update_entity(entity_id) state = hass.states.get(entity_id) @@ -724,177 +785,134 @@ async def test_setup_fail_firetv(hass): 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): """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" response = "test response" 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) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patch( - "androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", return_value=response - ) as patch_shell: - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_ADB_COMMAND, - {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, - blocking=True, - ) + with patch( + "androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", return_value=response + ) as patch_shell: + await hass.services.async_call( + DOMAIN, + SERVICE_ADB_COMMAND, + {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, + blocking=True, + ) - patch_shell.assert_called_with(command) - state = hass.states.get(entity_id) - assert state is not None - assert state.attributes["adb_response"] == response + patch_shell.assert_called_with(command) + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["adb_response"] == response async def test_adb_command_unicode_decode_error(hass): """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" response = b"test response" 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) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patch( - "androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", - side_effect=UnicodeDecodeError("utf-8", response, 0, len(response), "TEST"), - ): - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_ADB_COMMAND, - {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, - blocking=True, - ) + with patch( + "androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", + side_effect=UnicodeDecodeError("utf-8", response, 0, len(response), "TEST"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ADB_COMMAND, + {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, + blocking=True, + ) - # patch_shell.assert_called_with(command) - state = hass.states.get(entity_id) - assert state is not None - assert state.attributes["adb_response"] is None + # patch_shell.assert_called_with(command) + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["adb_response"] is None async def test_adb_command_key(hass): """Test sending a key command via the `androidtv.adb_command` service.""" - patch_key = "server" - entity_id = "media_player.android_tv" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) command = "HOME" response = None 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) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patch( - "androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", return_value=response - ) as patch_shell: - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_ADB_COMMAND, - {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, - blocking=True, - ) + with patch( + "androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", return_value=response + ) as patch_shell: + await hass.services.async_call( + DOMAIN, + SERVICE_ADB_COMMAND, + {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, + blocking=True, + ) - patch_shell.assert_called_with(f"input keyevent {KEYS[command]}") - state = hass.states.get(entity_id) - assert state is not None - assert state.attributes["adb_response"] is None + patch_shell.assert_called_with(f"input keyevent {KEYS[command]}") + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["adb_response"] is None async def test_adb_command_get_properties(hass): """Test sending the "GET_PROPERTIES" command via the `androidtv.adb_command` service.""" - patch_key = "server" - entity_id = "media_player.android_tv" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) command = "GET_PROPERTIES" response = {"test key": "test value"} 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) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patch( - "androidtv.androidtv.androidtv_async.AndroidTVAsync.get_properties_dict", - return_value=response, - ) as patch_get_props: - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_ADB_COMMAND, - {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, - blocking=True, - ) + with patch( + "androidtv.androidtv.androidtv_async.AndroidTVAsync.get_properties_dict", + return_value=response, + ) as patch_get_props: + await hass.services.async_call( + DOMAIN, + SERVICE_ADB_COMMAND, + {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, + blocking=True, + ) - patch_get_props.assert_called() - state = hass.states.get(entity_id) - assert state is not None - assert state.attributes["adb_response"] == str(response) + patch_get_props.assert_called() + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["adb_response"] == str(response) async def test_learn_sendevent(hass): """Test the `androidtv.learn_sendevent` service.""" - patch_key = "server" - entity_id = "media_player.android_tv" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) response = "sendevent 1 2 3 4" 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) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() with patch( @@ -902,7 +920,7 @@ async def test_learn_sendevent(hass): return_value=response, ) as patch_learn_sendevent: await hass.services.async_call( - ANDROIDTV_DOMAIN, + DOMAIN, SERVICE_LEARN_SENDEVENT, {ATTR_ENTITY_ID: entity_id}, blocking=True, @@ -916,12 +934,13 @@ async def test_learn_sendevent(hass): async def test_update_lock_not_acquired(hass): """Test that the state does not get updated when a `LockNotAcquiredException` is raised.""" - patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + 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 ], 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() with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: @@ -948,105 +967,112 @@ async def test_update_lock_not_acquired(hass): async def test_download(hass): """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" local_path = "local/path" 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) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - # Failed download because path is not whitelisted - with patch("androidtv.basetv.basetv_async.BaseTVAsync.adb_pull") as patch_pull: - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_DOWNLOAD, - { - ATTR_ENTITY_ID: entity_id, - ATTR_DEVICE_PATH: device_path, - ATTR_LOCAL_PATH: local_path, - }, - blocking=True, - ) - patch_pull.assert_not_called() + # Failed download because path is not whitelisted + with patch("androidtv.basetv.basetv_async.BaseTVAsync.adb_pull") as patch_pull: + await hass.services.async_call( + DOMAIN, + SERVICE_DOWNLOAD, + { + ATTR_ENTITY_ID: entity_id, + ATTR_DEVICE_PATH: device_path, + ATTR_LOCAL_PATH: local_path, + }, + blocking=True, + ) + patch_pull.assert_not_called() - # Successful download - with patch( - "androidtv.basetv.basetv_async.BaseTVAsync.adb_pull" - ) as patch_pull, patch.object(hass.config, "is_allowed_path", return_value=True): - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_DOWNLOAD, - { - ATTR_ENTITY_ID: entity_id, - ATTR_DEVICE_PATH: device_path, - ATTR_LOCAL_PATH: local_path, - }, - blocking=True, - ) - patch_pull.assert_called_with(local_path, device_path) + # Successful download + with patch( + "androidtv.basetv.basetv_async.BaseTVAsync.adb_pull" + ) as patch_pull, patch.object( + hass.config, "is_allowed_path", return_value=True + ): + await hass.services.async_call( + DOMAIN, + SERVICE_DOWNLOAD, + { + ATTR_ENTITY_ID: entity_id, + ATTR_DEVICE_PATH: device_path, + ATTR_LOCAL_PATH: local_path, + }, + blocking=True, + ) + patch_pull.assert_called_with(local_path, device_path) async def test_upload(hass): """Test the `androidtv.upload` service.""" - patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) device_path = "device/path" local_path = "local/path" 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) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - # Failed upload because path is not whitelisted - with patch("androidtv.basetv.basetv_async.BaseTVAsync.adb_push") as patch_push: - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_UPLOAD, - { - ATTR_ENTITY_ID: entity_id, - ATTR_DEVICE_PATH: device_path, - ATTR_LOCAL_PATH: local_path, - }, - blocking=True, - ) - patch_push.assert_not_called() + # Failed upload because path is not whitelisted + with patch("androidtv.basetv.basetv_async.BaseTVAsync.adb_push") as patch_push: + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD, + { + ATTR_ENTITY_ID: entity_id, + ATTR_DEVICE_PATH: device_path, + ATTR_LOCAL_PATH: local_path, + }, + blocking=True, + ) + patch_push.assert_not_called() - # Successful upload - with patch( - "androidtv.basetv.basetv_async.BaseTVAsync.adb_push" - ) as patch_push, patch.object(hass.config, "is_allowed_path", return_value=True): - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_UPLOAD, - { - ATTR_ENTITY_ID: entity_id, - ATTR_DEVICE_PATH: device_path, - ATTR_LOCAL_PATH: local_path, - }, - blocking=True, - ) - patch_push.assert_called_with(local_path, device_path) + # Successful upload + with patch( + "androidtv.basetv.basetv_async.BaseTVAsync.adb_push" + ) as patch_push, patch.object( + hass.config, "is_allowed_path", return_value=True + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD, + { + ATTR_ENTITY_ID: entity_id, + ATTR_DEVICE_PATH: device_path, + ATTR_LOCAL_PATH: local_path, + }, + blocking=True, + ) + patch_push.assert_called_with(local_path, device_path) async def test_androidtv_volume_set(hass): """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)[ 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() with patch( "androidtv.basetv.basetv_async.BaseTVAsync.set_volume_level", return_value=0.5 ) as patch_set_volume_level: await hass.services.async_call( - DOMAIN, + MP_DOMAIN, SERVICE_VOLUME_SET, {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, 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. """ - 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 ], 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() 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 ) as service_call: await hass.services.async_call( - DOMAIN, + MP_DOMAIN, ha_service_name, service_data=service_data, blocking=True, @@ -1136,13 +1163,12 @@ async def _test_service( async def test_services_androidtv(hass): """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_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() 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): """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[DOMAIN][CONF_TURN_OFF_COMMAND] = "test off" - config[DOMAIN][CONF_TURN_ON_COMMAND] = "test on" + config[DOMAIN][CONF_OPTIONS] = { + 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_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() 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): """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)[ 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() with patch( @@ -1224,14 +1254,15 @@ async def test_exception(hass): 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)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[ patch_key ], 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.helpers.entity_component.async_update_entity(entity_id)