diff --git a/CODEOWNERS b/CODEOWNERS index 225dc8fe0fb..c00a0f853b4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -80,6 +80,8 @@ build.json @home-assistant/supervisor /tests/components/android_ip_webcam/ @engrbm87 /homeassistant/components/androidtv/ @JeffLIrion @ollo69 /tests/components/androidtv/ @JeffLIrion @ollo69 +/homeassistant/components/androidtv_remote/ @tronikos +/tests/components/androidtv_remote/ @tronikos /homeassistant/components/anthemav/ @hyralex /tests/components/anthemav/ @hyralex /homeassistant/components/apache_kafka/ @bachya diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py new file mode 100644 index 00000000000..fb275342cb0 --- /dev/null +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -0,0 +1,67 @@ +"""The Android TV Remote integration.""" +from __future__ import annotations + +from androidtvremote2 import ( + AndroidTVRemote, + CannotConnect, + ConnectionClosed, + InvalidAuth, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .const import DOMAIN +from .helpers import create_api + +PLATFORMS: list[Platform] = [Platform.REMOTE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Android TV Remote from a config entry.""" + + api = create_api(hass, entry.data[CONF_HOST]) + try: + await api.async_connect() + except InvalidAuth as exc: + # The Android TV is hard reset or the certificate and key files were deleted. + raise ConfigEntryAuthFailed from exc + except (CannotConnect, ConnectionClosed) as exc: + # The Android TV is network unreachable. Raise exception and let Home Assistant retry + # later. If device gets a new IP address the zeroconf flow will update the config. + raise ConfigEntryNotReady from exc + + def reauth_needed() -> None: + """Start a reauth flow if Android TV is hard reset while reconnecting.""" + entry.async_start_reauth(hass) + + # Start a task (canceled in disconnect) to keep reconnecting if device becomes + # network unreachable. If device gets a new IP address the zeroconf flow will + # update the config entry data and reload the config entry. + api.keep_reconnecting(reauth_needed) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + @callback + def on_hass_stop(event) -> None: + """Stop push updates when hass stops.""" + api.disconnect() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + api: AndroidTVRemote = hass.data[DOMAIN].pop(entry.entry_id) + api.disconnect() + + return unload_ok diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py new file mode 100644 index 00000000000..24b64c622a9 --- /dev/null +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -0,0 +1,187 @@ +"""Config flow for Android TV Remote integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from androidtvremote2 import ( + AndroidTVRemote, + CannotConnect, + ConnectionClosed, + InvalidAuth, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN +from .helpers import create_api + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required("host"): str, + } +) + +STEP_PAIR_DATA_SCHEMA = vol.Schema( + { + vol.Required("pin"): str, + } +) + + +class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Android TV Remote.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize a new AndroidTVRemoteConfigFlow.""" + self.api: AndroidTVRemote | None = None + self.reauth_entry: config_entries.ConfigEntry | None = None + self.host: str | None = None + self.name: str | None = None + self.mac: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self.host = user_input["host"] + assert self.host + api = create_api(self.hass, self.host) + try: + self.name, self.mac = await api.async_get_name_and_mac() + assert self.mac + await self.async_set_unique_id(format_mac(self.mac)) + self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Likely invalid IP address or device is network unreachable. Stay + # in the user step allowing the user to enter a different host. + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def _async_start_pair(self) -> FlowResult: + """Start pairing with the Android TV. Navigate to the pair flow to enter the PIN shown on screen.""" + assert self.host + self.api = create_api(self.hass, self.host) + await self.api.async_generate_cert_if_missing() + await self.api.async_start_pairing() + return await self.async_step_pair() + + async def async_step_pair( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the pair step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + pin = user_input["pin"] + assert self.api + await self.api.async_finish_pairing(pin) + if self.reauth_entry: + await self.hass.config_entries.async_reload( + self.reauth_entry.entry_id + ) + return self.async_abort(reason="reauth_successful") + assert self.name + return self.async_create_entry( + title=self.name, + data={ + CONF_HOST: self.host, + CONF_NAME: self.name, + CONF_MAC: self.mac, + }, + ) + except InvalidAuth: + # Invalid PIN. Stay in the pair step allowing the user to enter + # a different PIN. + errors["base"] = "invalid_auth" + except ConnectionClosed: + # Either user canceled pairing on the Android TV itself (most common) + # or device doesn't respond to the specified host (device was unplugged, + # network was unplugged, or device got a new IP address). + # Attempt to pair again. + try: + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Device doesn't respond to the specified host. Abort. + # If we are in the user flow we could go back to the user step to allow + # them to enter a new IP address but we cannot do that for the zeroconf + # flow. Simpler to abort for both flows. + return self.async_abort(reason="cannot_connect") + return self.async_show_form( + step_id="pair", + data_schema=STEP_PAIR_DATA_SCHEMA, + description_placeholders={CONF_NAME: self.name}, + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + self.host = discovery_info.host + self.name = discovery_info.name.removesuffix("._androidtvremote2._tcp.local.") + self.mac = discovery_info.properties.get("bt") + assert self.mac + await self.async_set_unique_id(format_mac(self.mac)) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self.host, CONF_NAME: self.name} + ) + self.context.update({"title_placeholders": {CONF_NAME: self.name}}) + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + if user_input is not None: + try: + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Device became network unreachable after discovery. + # Abort and let discovery find it again later. + return self.async_abort(reason="cannot_connect") + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={CONF_NAME: self.name}, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self.host = entry_data[CONF_HOST] + self.name = entry_data[CONF_NAME] + self.mac = entry_data[CONF_MAC] + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Device is network unreachable. Abort. + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: self.name}, + errors=errors, + ) diff --git a/homeassistant/components/androidtv_remote/const.py b/homeassistant/components/androidtv_remote/const.py new file mode 100644 index 00000000000..82f494b81aa --- /dev/null +++ b/homeassistant/components/androidtv_remote/const.py @@ -0,0 +1,6 @@ +"""Constants for the Android TV Remote integration.""" +from __future__ import annotations + +from typing import Final + +DOMAIN: Final = "androidtv_remote" diff --git a/homeassistant/components/androidtv_remote/diagnostics.py b/homeassistant/components/androidtv_remote/diagnostics.py new file mode 100644 index 00000000000..28d16bf94fe --- /dev/null +++ b/homeassistant/components/androidtv_remote/diagnostics.py @@ -0,0 +1,29 @@ +"""Diagnostics support for Android TV Remote.""" +from __future__ import annotations + +from typing import Any + +from androidtvremote2 import AndroidTVRemote + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_MAC +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {CONF_HOST, CONF_MAC} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + api: AndroidTVRemote = hass.data[DOMAIN].pop(entry.entry_id) + return async_redact_data( + { + "api_device_info": api.device_info, + "config_entry_data": entry.data, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/androidtv_remote/helpers.py b/homeassistant/components/androidtv_remote/helpers.py new file mode 100644 index 00000000000..0bc1f1b904f --- /dev/null +++ b/homeassistant/components/androidtv_remote/helpers.py @@ -0,0 +1,18 @@ +"""Helper functions for Android TV Remote integration.""" +from __future__ import annotations + +from androidtvremote2 import AndroidTVRemote + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import STORAGE_DIR + + +def create_api(hass: HomeAssistant, host: str) -> AndroidTVRemote: + """Create an AndroidTVRemote instance.""" + return AndroidTVRemote( + client_name="Home Assistant", + certfile=hass.config.path(STORAGE_DIR, "androidtv_remote_cert.pem"), + keyfile=hass.config.path(STORAGE_DIR, "androidtv_remote_key.pem"), + host=host, + loop=hass.loop, + ) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json new file mode 100644 index 00000000000..702e3b9a2c3 --- /dev/null +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "androidtv_remote", + "name": "Android TV Remote", + "codeowners": ["@tronikos"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/androidtv_remote", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["androidtvremote2"], + "quality_scale": "platinum", + "requirements": ["androidtvremote2==0.0.4"], + "zeroconf": ["_androidtvremote2._tcp.local."] +} diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py new file mode 100644 index 00000000000..1c68c92bc68 --- /dev/null +++ b/homeassistant/components/androidtv_remote/remote.py @@ -0,0 +1,154 @@ +"""Remote control support for Android TV Remote.""" +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +import logging +from typing import Any + +from androidtvremote2 import AndroidTVRemote, ConnectionClosed + +from homeassistant.components.remote import ( + ATTR_ACTIVITY, + ATTR_DELAY_SECS, + ATTR_HOLD_SECS, + ATTR_NUM_REPEATS, + DEFAULT_DELAY_SECS, + DEFAULT_HOLD_SECS, + DEFAULT_NUM_REPEATS, + RemoteEntity, + RemoteEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + +PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Android TV remote entity based on a config entry.""" + api: AndroidTVRemote = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([AndroidTVRemoteEntity(api, config_entry)]) + + +class AndroidTVRemoteEntity(RemoteEntity): + """Representation of an Android TV Remote.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None: + """Initialize device.""" + self._api = api + self._host = config_entry.data[CONF_HOST] + self._name = config_entry.data[CONF_NAME] + self._attr_unique_id = config_entry.unique_id + self._attr_supported_features = RemoteEntityFeature.ACTIVITY + self._attr_is_on = api.is_on + self._attr_current_activity = api.current_app + device_info = api.device_info + assert config_entry.unique_id + assert device_info + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, config_entry.data[CONF_MAC])}, + identifiers={(DOMAIN, config_entry.unique_id)}, + name=self._name, + manufacturer=device_info["manufacturer"], + model=device_info["model"], + ) + + @callback + def is_on_updated(is_on: bool) -> None: + self._attr_is_on = is_on + self.async_write_ha_state() + + @callback + def current_app_updated(current_app: str) -> None: + self._attr_current_activity = current_app + self.async_write_ha_state() + + @callback + def is_available_updated(is_available: bool) -> None: + if is_available: + _LOGGER.info( + "Reconnected to %s at %s", + self._name, + self._host, + ) + else: + _LOGGER.warning( + "Disconnected from %s at %s", + self._name, + self._host, + ) + self._attr_available = is_available + self.async_write_ha_state() + + api.add_is_on_updated_callback(is_on_updated) + api.add_current_app_updated_callback(current_app_updated) + api.add_is_available_updated_callback(is_available_updated) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the Android TV on.""" + if not self.is_on: + self._send_key_command("POWER") + activity = kwargs.get(ATTR_ACTIVITY, "") + if activity: + self._send_launch_app_command(activity) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the Android TV off.""" + if self.is_on: + self._send_key_command("POWER") + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send commands to one device.""" + num_repeats = kwargs.get(ATTR_NUM_REPEATS, DEFAULT_NUM_REPEATS) + delay_secs = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + hold_secs = kwargs.get(ATTR_HOLD_SECS, DEFAULT_HOLD_SECS) + + for _ in range(num_repeats): + for single_command in command: + if hold_secs: + self._send_key_command(single_command, "START_LONG") + await asyncio.sleep(hold_secs) + self._send_key_command(single_command, "END_LONG") + else: + self._send_key_command(single_command, "SHORT") + await asyncio.sleep(delay_secs) + + def _send_key_command(self, key_code: str, direction: str = "SHORT") -> None: + """Send a key press to Android TV. + + This does not block; it buffers the data and arranges for it to be sent out asynchronously. + """ + try: + self._api.send_key_command(key_code, direction) + except ConnectionClosed as exc: + raise HomeAssistantError( + "Connection to Android TV device is closed" + ) from exc + + def _send_launch_app_command(self, app_link: str) -> None: + """Launch an app on Android TV. + + This does not block; it buffers the data and arranges for it to be sent out asynchronously. + """ + try: + self._api.send_launch_app_command(app_link) + except ConnectionClosed as exc: + raise HomeAssistantError( + "Connection to Android TV device is closed" + ) from exc diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json new file mode 100644 index 00000000000..983c604370b --- /dev/null +++ b/homeassistant/components/androidtv_remote/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Enter the IP address of the Android TV you want to add to Home Assistant. It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "zeroconf_confirm": { + "title": "Discovered Android TV", + "description": "Do you want to add the Android TV ({name}) to Home Assistant? It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen." + }, + "pair": { + "description": "Enter the pairing code displayed on the Android TV ({name}).", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "You need to pair again with the Android TV ({name})." + } + }, + "error": { + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2f84b0b10d2..0370c4249ee 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -39,6 +39,7 @@ FLOWS = { "ambient_station", "android_ip_webcam", "androidtv", + "androidtv_remote", "anthemav", "apcupsd", "apple_tv", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 066dff4a5a8..9517cba3486 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -246,6 +246,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "androidtv_remote": { + "name": "Android TV Remote", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "anel_pwrctrl": { "name": "Anel NET-PwrCtrl", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 2f3dbaefb17..1771d9d63bf 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -279,6 +279,11 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_androidtvremote2._tcp.local.": [ + { + "domain": "androidtv_remote", + }, + ], "_api._tcp.local.": [ { "domain": "baf", diff --git a/requirements_all.txt b/requirements_all.txt index abe75daf553..71de7e8a124 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -332,6 +332,9 @@ amcrest==1.9.7 # homeassistant.components.androidtv androidtv[async]==0.0.70 +# homeassistant.components.androidtv_remote +androidtvremote2==0.0.4 + # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74d2372591a..c6ffc1c4cdf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -307,6 +307,9 @@ ambiclimate==0.2.1 # homeassistant.components.androidtv androidtv[async]==0.0.70 +# homeassistant.components.androidtv_remote +androidtvremote2==0.0.4 + # homeassistant.components.anthemav anthemav==1.4.1 diff --git a/tests/components/androidtv_remote/__init__.py b/tests/components/androidtv_remote/__init__.py new file mode 100644 index 00000000000..41b9d292807 --- /dev/null +++ b/tests/components/androidtv_remote/__init__.py @@ -0,0 +1 @@ +"""Tests for the Android TV Remote integration.""" diff --git a/tests/components/androidtv_remote/conftest.py b/tests/components/androidtv_remote/conftest.py new file mode 100644 index 00000000000..ffe9d8b8dbe --- /dev/null +++ b/tests/components/androidtv_remote/conftest.py @@ -0,0 +1,57 @@ +"""Fixtures for the Android TV Remote integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.androidtv_remote.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.androidtv_remote.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_unload_entry() -> Generator[AsyncMock, None, None]: + """Mock unloading a config entry.""" + with patch( + "homeassistant.components.androidtv_remote.async_unload_entry", + return_value=True, + ) as mock_unload_entry: + yield mock_unload_entry + + +@pytest.fixture +def mock_api() -> Generator[None, MagicMock, None]: + """Return a mocked AndroidTVRemote.""" + with patch( + "homeassistant.components.androidtv_remote.helpers.AndroidTVRemote", + ) as mock_api_cl: + mock_api = mock_api_cl.return_value + mock_api.async_connect = AsyncMock(return_value=None) + mock_api.device_info = { + "manufacturer": "My Android TV manufacturer", + "model": "My Android TV model", + } + yield mock_api + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="My Android TV", + domain=DOMAIN, + data={"host": "1.2.3.4", "name": "My Android TV", "mac": "1A:2B:3C:4D:5E:6F"}, + unique_id="1a:2b:3c:4d:5e:6f", + state=ConfigEntryState.NOT_LOADED, + ) diff --git a/tests/components/androidtv_remote/snapshots/test_diagnostics.ambr b/tests/components/androidtv_remote/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..8282f1dedde --- /dev/null +++ b/tests/components/androidtv_remote/snapshots/test_diagnostics.ambr @@ -0,0 +1,14 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'api_device_info': dict({ + 'manufacturer': 'My Android TV manufacturer', + 'model': 'My Android TV model', + }), + 'config_entry_data': dict({ + 'host': '**REDACTED**', + 'mac': '**REDACTED**', + 'name': 'My Android TV', + }), + }) +# --- diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py new file mode 100644 index 00000000000..ea1f4abfc1d --- /dev/null +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -0,0 +1,835 @@ +"""Test the Android TV Remote config flow.""" +from unittest.mock import AsyncMock, MagicMock + +from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.androidtv_remote.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_user_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test the full user flow from start to finish without any exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert not result["errors"] + + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + pin = "123456" + + mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac)) + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": host} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + mock_api.async_finish_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": pin} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == name + assert result["data"] == {"host": host, "name": name, "mac": mac} + assert result["context"]["source"] == "user" + assert result["context"]["unique_id"] == unique_id + + mock_api.async_finish_pairing.assert_called_with(pin) + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_flow_cannot_connect( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test async_get_name_and_mac raises CannotConnect. + + This is when the user entered an invalid IP address so we stay + in the user step allowing the user to enter a different host. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert not result["errors"] + + host = "1.2.3.4" + + mock_api.async_get_name_and_mac = AsyncMock(side_effect=CannotConnect()) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": host} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert result["errors"] == {"base": "cannot_connect"} + + mock_api.async_get_name_and_mac.assert_called() + mock_api.async_start_pairing.assert_not_called() + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_user_flow_pairing_invalid_auth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test async_finish_pairing raises InvalidAuth. + + This is when the user entered an invalid PIN. We stay in the pair step + allowing the user to enter a different PIN. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert not result["errors"] + + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + pin = "123456" + + mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac)) + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": host} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + mock_api.async_finish_pairing = AsyncMock(side_effect=InvalidAuth()) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": pin} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert result["errors"] == {"base": "invalid_auth"} + + mock_api.async_finish_pairing.assert_called_with(pin) + + assert mock_api.async_get_name_and_mac.call_count == 1 + assert mock_api.async_start_pairing.call_count == 1 + assert mock_api.async_finish_pairing.call_count == 1 + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_user_flow_pairing_connection_closed( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test async_finish_pairing raises ConnectionClosed. + + This is when the user canceled pairing on the Android TV itself before calling async_finish_pairing. + We call async_start_pairing again which succeeds and we have a chance to enter a new PIN. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert not result["errors"] + + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + pin = "123456" + + mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac)) + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": host} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + mock_api.async_finish_pairing = AsyncMock(side_effect=ConnectionClosed()) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": pin} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_finish_pairing.assert_called_with(pin) + + assert mock_api.async_get_name_and_mac.call_count == 1 + assert mock_api.async_start_pairing.call_count == 2 + assert mock_api.async_finish_pairing.call_count == 1 + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_user_flow_pairing_connection_closed_followed_by_cannot_connect( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test async_finish_pairing raises ConnectionClosed and then async_start_pairing raises CannotConnect. + + This is when the user unplugs the Android TV before calling async_finish_pairing. + We call async_start_pairing again which fails with CannotConnect so we abort. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert not result["errors"] + + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + pin = "123456" + + mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac)) + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(side_effect=[None, CannotConnect()]) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": host} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + mock_api.async_finish_pairing = AsyncMock(side_effect=ConnectionClosed()) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": pin} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + mock_api.async_finish_pairing.assert_called_with(pin) + + assert mock_api.async_get_name_and_mac.call_count == 1 + assert mock_api.async_start_pairing.call_count == 2 + assert mock_api.async_finish_pairing.call_count == 1 + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_user_flow_already_configured_host_changed_reloads_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test we abort the user flow if already configured and reload if host changed.""" + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + name_existing = "existing name if different is from discovery and should not change" + host_existing = "1.2.3.45" + assert host_existing != host + + mock_config_entry = MockConfigEntry( + title=name, + domain=DOMAIN, + data={ + "host": host_existing, + "name": name_existing, + "mac": mac, + }, + unique_id=unique_id, + state=ConfigEntryState.LOADED, + ) + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac)) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": host} + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + mock_api.async_get_name_and_mac.assert_called() + mock_api.async_start_pairing.assert_not_called() + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert hass.config_entries.async_entries(DOMAIN)[0].data == { + "host": host, + "name": name_existing, + "mac": mac, + } + + +async def test_user_flow_already_configured_host_not_changed_no_reload_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test we abort the user flow if already configured and no reload if host not changed.""" + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + name_existing = "existing name if different is from discovery and should not change" + host_existing = host + + mock_config_entry = MockConfigEntry( + title=name, + domain=DOMAIN, + data={ + "host": host_existing, + "name": name_existing, + "mac": mac, + }, + unique_id=unique_id, + state=ConfigEntryState.LOADED, + ) + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac)) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": host} + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + mock_api.async_get_name_and_mac.assert_called() + mock_api.async_start_pairing.assert_not_called() + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + assert hass.config_entries.async_entries(DOMAIN)[0].data == { + "host": host, + "name": name_existing, + "mac": mac, + } + + +async def test_zeroconf_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test the full zeroconf flow from start to finish without any exceptions.""" + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + pin = "123456" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host=host, + addresses=[host], + port=6466, + hostname=host, + type="mock_type", + name=name + "._androidtvremote2._tcp.local.", + properties={"bt": mac}, + ), + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert not result["data_schema"] + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "zeroconf_confirm" + assert result["context"]["source"] == "zeroconf" + assert result["context"]["unique_id"] == unique_id + assert result["context"]["title_placeholders"] == {"name": name} + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + mock_api.async_finish_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": pin} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == name + assert result["data"] == { + "host": host, + "name": name, + "mac": mac, + } + assert result["context"]["source"] == "zeroconf" + assert result["context"]["unique_id"] == unique_id + + mock_api.async_finish_pairing.assert_called_with(pin) + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_flow_cannot_connect( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test async_start_pairing raises CannotConnect in the zeroconf flow. + + This is when the Android TV became network unreachable after discovery. + We abort and let discovery find it again later. + """ + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host=host, + addresses=[host], + port=6466, + hostname=host, + type="mock_type", + name=name + "._androidtvremote2._tcp.local.", + properties={"bt": mac}, + ), + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert not result["data_schema"] + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(side_effect=CannotConnect()) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_zeroconf_flow_pairing_invalid_auth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test async_finish_pairing raises InvalidAuth in the zeroconf flow. + + This is when the user entered an invalid PIN. We stay in the pair step + allowing the user to enter a different PIN. + """ + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + pin = "123456" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host=host, + addresses=[host], + port=6466, + hostname=host, + type="mock_type", + name=name + "._androidtvremote2._tcp.local.", + properties={"bt": mac}, + ), + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert not result["data_schema"] + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + mock_api.async_finish_pairing = AsyncMock(side_effect=InvalidAuth()) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": pin} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert result["errors"] == {"base": "invalid_auth"} + + mock_api.async_finish_pairing.assert_called_with(pin) + + assert mock_api.async_get_name_and_mac.call_count == 0 + assert mock_api.async_start_pairing.call_count == 1 + assert mock_api.async_finish_pairing.call_count == 1 + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_zeroconf_flow_already_configured_host_changed_reloads_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test we abort the zeroconf flow if already configured and reload if host or name changed.""" + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + name_existing = "existing name should change since we prefer one from discovery" + host_existing = "1.2.3.45" + assert host_existing != host + assert name_existing != name + + mock_config_entry = MockConfigEntry( + title=name, + domain=DOMAIN, + data={ + "host": host_existing, + "name": name_existing, + "mac": mac, + }, + unique_id=unique_id, + state=ConfigEntryState.LOADED, + ) + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host=host, + addresses=[host], + port=6466, + hostname=host, + type="mock_type", + name=name + "._androidtvremote2._tcp.local.", + properties={"bt": mac}, + ), + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN)[0].data == { + "host": host, + "name": name, + "mac": mac, + } + assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_flow_already_configured_host_not_changed_no_reload_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test we abort the zeroconf flow if already configured and no reload if host and name not changed.""" + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + name_existing = name + host_existing = host + + mock_config_entry = MockConfigEntry( + title=name, + domain=DOMAIN, + data={ + "host": host_existing, + "name": name_existing, + "mac": mac, + }, + unique_id=unique_id, + state=ConfigEntryState.LOADED, + ) + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host=host, + addresses=[host], + port=6466, + hostname=host, + type="mock_type", + name=name + "._androidtvremote2._tcp.local.", + properties={"bt": mac}, + ), + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN)[0].data == { + "host": host, + "name": name, + "mac": mac, + } + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test the full reauth flow from start to finish without any exceptions.""" + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + pin = "123456" + + mock_config_entry = MockConfigEntry( + title=name, + domain=DOMAIN, + data={ + "host": host, + "name": name, + "mac": mac, + }, + unique_id=unique_id, + state=ConfigEntryState.LOADED, + ) + mock_config_entry.add_to_hass(hass) + + mock_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + assert result["context"]["source"] == "reauth" + assert result["context"]["unique_id"] == unique_id + assert result["context"]["title_placeholders"] == {"name": name} + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_get_name_and_mac.assert_not_called() + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + mock_api.async_finish_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": pin} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + mock_api.async_finish_pairing.assert_called_with(pin) + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN)[0].data == { + "host": host, + "name": name, + "mac": mac, + } + assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow_cannot_connect( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test async_start_pairing raises CannotConnect in the reauth flow.""" + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + + mock_config_entry = MockConfigEntry( + title=name, + domain=DOMAIN, + data={ + "host": host, + "name": name, + "mac": mac, + }, + unique_id=unique_id, + state=ConfigEntryState.LOADED, + ) + mock_config_entry.add_to_hass(hass) + + mock_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + assert result["context"]["source"] == "reauth" + assert result["context"]["unique_id"] == unique_id + assert result["context"]["title_placeholders"] == {"name": name} + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(side_effect=CannotConnect()) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + mock_api.async_get_name_and_mac.assert_not_called() + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/androidtv_remote/test_diagnostics.py b/tests/components/androidtv_remote/test_diagnostics.py new file mode 100644 index 00000000000..93410fd4511 --- /dev/null +++ b/tests/components/androidtv_remote/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for the diagnostics data provided by the Android TV Remote integration.""" +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + mock_api.is_on = True + mock_api.current_app = "some app" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/androidtv_remote/test_init.py b/tests/components/androidtv_remote/test_init.py new file mode 100644 index 00000000000..f3f61eb268e --- /dev/null +++ b/tests/components/androidtv_remote/test_init.py @@ -0,0 +1,106 @@ +"""Tests for the Android TV Remote integration.""" +from collections.abc import Callable +from unittest.mock import AsyncMock, MagicMock + +from androidtvremote2 import CannotConnect, InvalidAuth + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_api.async_connect.call_count == 1 + assert mock_api.keep_reconnecting.call_count == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert mock_api.disconnect.call_count == 1 + + +async def test_config_entry_not_ready( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote configuration entry not ready.""" + mock_api.async_connect = AsyncMock(side_effect=CannotConnect()) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_api.async_connect.call_count == 1 + assert mock_api.keep_reconnecting.call_count == 0 + + +async def test_config_entry_reauth_at_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote configuration entry needs reauth at setup.""" + mock_api.async_connect = AsyncMock(side_effect=InvalidAuth()) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any(mock_config_entry.async_get_active_flows(hass, {"reauth"})) + assert mock_api.async_connect.call_count == 1 + assert mock_api.keep_reconnecting.call_count == 0 + + +async def test_config_entry_reauth_while_reconnecting( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote configuration entry needs reauth while reconnecting.""" + invalid_auth_callback: Callable | None = None + + def mocked_keep_reconnecting(callback: Callable): + nonlocal invalid_auth_callback + invalid_auth_callback = callback + + mock_api.keep_reconnecting.side_effect = mocked_keep_reconnecting + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert not any(mock_config_entry.async_get_active_flows(hass, {"reauth"})) + assert mock_api.async_connect.call_count == 1 + assert mock_api.keep_reconnecting.call_count == 1 + + assert invalid_auth_callback is not None + invalid_auth_callback() + await hass.async_block_till_done() + assert any(mock_config_entry.async_get_active_flows(hass, {"reauth"})) + + +async def test_disconnect_on_stop( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test we close the connection with the Android TV when Home Assistants stops.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_api.async_connect.call_count == 1 + assert mock_api.keep_reconnecting.call_count == 1 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert mock_api.disconnect.call_count == 1 diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py new file mode 100644 index 00000000000..d0372b8a65a --- /dev/null +++ b/tests/components/androidtv_remote/test_remote.py @@ -0,0 +1,219 @@ +"""Tests for the Android TV Remote remote platform.""" +from collections.abc import Callable +from unittest.mock import MagicMock, call + +from androidtvremote2 import ConnectionClosed +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + +REMOTE_ENTITY = "remote.my_android_tv" + + +async def test_remote_receives_push_updates( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote receives push updates and state is updated.""" + is_on_updated_callback: Callable | None = None + current_app_updated_callback: Callable | None = None + is_available_updated_callback: Callable | None = None + + def mocked_add_is_on_updated_callback(callback: Callable): + nonlocal is_on_updated_callback + is_on_updated_callback = callback + + def mocked_add_current_app_updated_callback(callback: Callable): + nonlocal current_app_updated_callback + current_app_updated_callback = callback + + def mocked_add_is_available_updated_callback(callback: Callable): + nonlocal is_available_updated_callback + is_available_updated_callback = callback + + mock_api.add_is_on_updated_callback.side_effect = mocked_add_is_on_updated_callback + mock_api.add_current_app_updated_callback.side_effect = ( + mocked_add_current_app_updated_callback + ) + mock_api.add_is_available_updated_callback.side_effect = ( + mocked_add_is_available_updated_callback + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + is_on_updated_callback(False) + assert hass.states.is_state(REMOTE_ENTITY, STATE_OFF) + + is_on_updated_callback(True) + assert hass.states.is_state(REMOTE_ENTITY, STATE_ON) + + current_app_updated_callback("activity1") + assert ( + hass.states.get(REMOTE_ENTITY).attributes.get("current_activity") == "activity1" + ) + + is_available_updated_callback(False) + assert hass.states.is_state(REMOTE_ENTITY, STATE_UNAVAILABLE) + + is_available_updated_callback(True) + assert hass.states.is_state(REMOTE_ENTITY, STATE_ON) + + +async def test_remote_toggles( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote toggles.""" + is_on_updated_callback: Callable | None = None + + def mocked_add_is_on_updated_callback(callback: Callable): + nonlocal is_on_updated_callback + is_on_updated_callback = callback + + mock_api.add_is_on_updated_callback.side_effect = mocked_add_is_on_updated_callback + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.services.async_call( + "remote", + "turn_off", + {"entity_id": REMOTE_ENTITY}, + blocking=True, + ) + is_on_updated_callback(False) + + mock_api.send_key_command.assert_called_with("POWER", "SHORT") + + assert await hass.services.async_call( + "remote", + "turn_on", + {"entity_id": REMOTE_ENTITY}, + blocking=True, + ) + is_on_updated_callback(True) + + mock_api.send_key_command.assert_called_with("POWER", "SHORT") + assert mock_api.send_key_command.call_count == 2 + + assert await hass.services.async_call( + "remote", + "turn_on", + {"entity_id": REMOTE_ENTITY, "activity": "activity1"}, + blocking=True, + ) + + mock_api.send_key_command.send_launch_app_command("activity1") + assert mock_api.send_key_command.call_count == 2 + assert mock_api.send_launch_app_command.call_count == 1 + + +async def test_remote_send_command( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test remote.send_command service.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": "DPAD_LEFT", + "num_repeats": 2, + "delay_secs": 0.01, + }, + blocking=True, + ) + mock_api.send_key_command.assert_called_with("DPAD_LEFT", "SHORT") + assert mock_api.send_key_command.call_count == 2 + + +async def test_remote_send_command_multiple( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test remote.send_command service with multiple commands.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": ["DPAD_LEFT", "DPAD_UP"], + "delay_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [ + call("DPAD_LEFT", "SHORT"), + call("DPAD_UP", "SHORT"), + ] + + +async def test_remote_send_command_with_hold_secs( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test remote.send_command service with hold_secs.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": "DPAD_RIGHT", + "delay_secs": 0.01, + "hold_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [ + call("DPAD_RIGHT", "START_LONG"), + call("DPAD_RIGHT", "END_LONG"), + ] + + +async def test_remote_connection_closed( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test commands raise HomeAssistantError if ConnectionClosed.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_api.send_key_command.side_effect = ConnectionClosed() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": "DPAD_LEFT", + "delay_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [call("DPAD_LEFT", "SHORT")] + + mock_api.send_launch_app_command.side_effect = ConnectionClosed() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "remote", + "turn_on", + {"entity_id": REMOTE_ENTITY, "activity": "activity1"}, + blocking=True, + ) + assert mock_api.send_launch_app_command.mock_calls == [call("activity1")]