From a611b22145c6f5be001495e57d9fffc416822976 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 18 Mar 2023 09:26:50 +0000 Subject: [PATCH 01/10] Android TV Remote integration --- CODEOWNERS | 2 + .../components/androidtv_remote/__init__.py | 61 ++ .../androidtv_remote/config_flow.py | 172 ++++ .../components/androidtv_remote/const.py | 6 + .../components/androidtv_remote/helpers.py | 17 + .../components/androidtv_remote/manifest.json | 13 + .../components/androidtv_remote/remote.py | 152 ++++ .../components/androidtv_remote/strings.json | 38 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/androidtv_remote/__init__.py | 1 + tests/components/androidtv_remote/conftest.py | 57 ++ .../androidtv_remote/test_config_flow.py | 826 ++++++++++++++++++ .../components/androidtv_remote/test_init.py | 108 +++ .../androidtv_remote/test_remote.py | 219 +++++ .../testing_config/androidtv_remote_cert.pem | 18 + tests/testing_config/androidtv_remote_key.pem | 27 + 20 files changed, 1735 insertions(+) create mode 100644 homeassistant/components/androidtv_remote/__init__.py create mode 100644 homeassistant/components/androidtv_remote/config_flow.py create mode 100644 homeassistant/components/androidtv_remote/const.py create mode 100644 homeassistant/components/androidtv_remote/helpers.py create mode 100644 homeassistant/components/androidtv_remote/manifest.json create mode 100644 homeassistant/components/androidtv_remote/remote.py create mode 100644 homeassistant/components/androidtv_remote/strings.json create mode 100644 tests/components/androidtv_remote/__init__.py create mode 100644 tests/components/androidtv_remote/conftest.py create mode 100644 tests/components/androidtv_remote/test_config_flow.py create mode 100644 tests/components/androidtv_remote/test_init.py create mode 100644 tests/components/androidtv_remote/test_remote.py create mode 100644 tests/testing_config/androidtv_remote_cert.pem create mode 100644 tests/testing_config/androidtv_remote_key.pem diff --git a/CODEOWNERS b/CODEOWNERS index cb559a7d7bb459..804d6835c3e533 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 00000000000000..bbeefef65040d8 --- /dev/null +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -0,0 +1,61 @@ +"""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 +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.""" + + hass.data.setdefault(DOMAIN, {}) + + api = create_api(hass, entry.data[CONF_HOST]) + try: + await api.async_connect() + except InvalidAuth as exc: + raise ConfigEntryAuthFailed from exc + except (CannotConnect, ConnectionClosed) as exc: + raise ConfigEntryNotReady from exc + + def reauth_needed(): + entry.async_start_reauth(hass) + + api.keep_reconnecting(reauth_needed) + + hass.data[DOMAIN][entry.entry_id] = api + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def on_hass_stop(event): + """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 00000000000000..1d2dcff1e8c53c --- /dev/null +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -0,0 +1,172 @@ +"""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): + 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) + self.api.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: + errors["base"] = "invalid_auth" + except ConnectionClosed: + try: + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + 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): + 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): + 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 00000000000000..82f494b81aad03 --- /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/helpers.py b/homeassistant/components/androidtv_remote/helpers.py new file mode 100644 index 00000000000000..62dab92a607318 --- /dev/null +++ b/homeassistant/components/androidtv_remote/helpers.py @@ -0,0 +1,17 @@ +"""Helper functions for Android TV Remote integration.""" +from __future__ import annotations + +from androidtvremote2 import AndroidTVRemote + +from homeassistant.core import HomeAssistant + + +def create_api(hass: HomeAssistant, host: str) -> AndroidTVRemote: + """Create an AndroidTVRemote instance.""" + return AndroidTVRemote( + client_name="Home Assistant", + certfile=hass.config.path("androidtv_remote_cert.pem"), + keyfile=hass.config.path("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 00000000000000..18e63fddbb16c0 --- /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.2"], + "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 00000000000000..9d69c718f83ca8 --- /dev/null +++ b/homeassistant/components/androidtv_remote/remote.py @@ -0,0 +1,152 @@ +"""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 + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, config_entry.data[CONF_MAC])}, + identifiers={(DOMAIN, str(config_entry.unique_id))}, + name=self._name, + manufacturer=None if not device_info else device_info["manufacturer"], + model=None if not device_info else device_info["model"], + ) + + @callback + def is_on_updated(is_on: bool): + self._attr_is_on = is_on + self.async_write_ha_state() + + @callback + def current_app_updated(current_app: str): + self._attr_current_activity = current_app + self.async_write_ha_state() + + @callback + def is_available_updated(is_available: bool): + 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"): + """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): + """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 00000000000000..983c604370b863 --- /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 28ceb593845bb5..1b603f17550714 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -39,6 +39,7 @@ "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 cee5b2167a8631..1a45264c9c9992 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 ae9668f37298be..53f60070986d2a 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -279,6 +279,11 @@ "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 bbbb5e30ed90e5..252e88445e5223 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.2 + # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5bea952a0299a..946f35c19ac634 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -304,6 +304,9 @@ ambiclimate==0.2.1 # homeassistant.components.androidtv androidtv[async]==0.0.70 +# homeassistant.components.androidtv_remote +androidtvremote2==0.0.2 + # 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 00000000000000..41b9d2928079a0 --- /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 00000000000000..ffe9d8b8dbe9e2 --- /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/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py new file mode 100644 index 00000000000000..704927717ce5dc --- /dev/null +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -0,0 +1,826 @@ +"""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_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.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_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.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_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.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_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.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_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.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_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.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_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.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_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.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_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.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_init.py b/tests/components/androidtv_remote/test_init.py new file mode 100644 index 00000000000000..470bcc241deb2d --- /dev/null +++ b/tests/components/androidtv_remote/test_init.py @@ -0,0 +1,108 @@ +"""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.components.androidtv_remote.const import DOMAIN +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 not hass.data.get(DOMAIN) + 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 00000000000000..d0372b8a65a4c0 --- /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")] diff --git a/tests/testing_config/androidtv_remote_cert.pem b/tests/testing_config/androidtv_remote_cert.pem new file mode 100644 index 00000000000000..ccea39032dd9ba --- /dev/null +++ b/tests/testing_config/androidtv_remote_cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC3DCCAcSgAwIBAgICA+gwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwOSG9t +ZSBBc3Npc3RhbnQwHhcNMjMwMzE4MDkxMDQ0WhcNMzMwMzE1MDkxMDQ0WjAZMRcw +FQYDVQQDDA5Ib21lIEFzc2lzdGFudDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAKM95d4Yypqlej8dgOF4xPl9BY9kDJc0NHfSPz6Rowz+/wIEnS+ze/EA +rysQktiaSJxHwcWB8K5gSJBYsFe/BfHeSqilMc+O59e32DExUO9Kj6a8iaxo4Oad +jC7NAMclq3ig6RmNoEshXdVWARF2II5eo6Oh8+TLp/faF02cn5HbkcH5KxksCIMV +1liwZiTipFSR1h0Utvc4N6WebN8BD2IOWEuDMoBAjPMCITh2mclgdXHnooC5LM5O +FmjTpcSh5ztgCCwNtMfL8ia/62q2N3/82L9xlljN1iD3Vi5LEVUSxHY0NpgUszdW +smldeLZTRniM8JlVmBtnrtObF62mU/0CAwEAAaMuMCwwDwYDVR0TBAgwBgEB/wIB +ADAZBgNVHREEEjAQgg5Ib21lIEFzc2lzdGFudDANBgkqhkiG9w0BAQsFAAOCAQEA +aXrj20THe8MvDxL1w1hUZnk9Gn7jWCVGfofXqb8H+s9DdjMlqBf6Wr4XwwCG8GWG +RdY70m4h/JLdZJbpLmdTNhmEy1y97mvBAQu8Pr1Og7jKCx9GH5GCmfmZVw/hMQIh +wdnESGAB0po2+E7T8NyqD/4z8UN1WOwCsJt9mx5/9zqtdc8S8e1t4BcF23jsCR6W +G7ax0B1nCXAV+w3Tr+q52cTJg7AMSTpT0ZSVBxodfev3LO+JqxejqM17Jq7Dyisp +7SoAhlSDi2Sipc2IpmFS8CeD1bdRc7T6DkuTkfJlCLSp5WiNqk2vxM2rQhV1Epbd +xL+nm75h+fyeZcekWlOdLw== +-----END CERTIFICATE----- diff --git a/tests/testing_config/androidtv_remote_key.pem b/tests/testing_config/androidtv_remote_key.pem new file mode 100644 index 00000000000000..012d0d51065a7e --- /dev/null +++ b/tests/testing_config/androidtv_remote_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEoQIBAAKCAQEAoz3l3hjKmqV6Px2A4XjE+X0Fj2QMlzQ0d9I/PpGjDP7/AgSd +L7N78QCvKxCS2JpInEfBxYHwrmBIkFiwV78F8d5KqKUxz47n17fYMTFQ70qPpryJ +rGjg5p2MLs0AxyWreKDpGY2gSyFd1VYBEXYgjl6jo6Hz5Mun99oXTZyfkduRwfkr +GSwIgxXWWLBmJOKkVJHWHRS29zg3pZ5s3wEPYg5YS4MygECM8wIhOHaZyWB1ceei +gLkszk4WaNOlxKHnO2AILA20x8vyJr/rarY3f/zYv3GWWM3WIPdWLksRVRLEdjQ2 +mBSzN1ayaV14tlNGeIzwmVWYG2eu05sXraZT/QIDAQABAoH/CCFNEYKqlIkpTXtq +r9AT/1j6remNtvoiotUV0UfvlvYcPT19lqKZyab/EmYA6kjE1QO94thV5FlnQ8km +/zfEVAd+MGgJ0wyK/3NokQqjvUc5YFSDhtKyYMn38VzEbSazI47gDSXAlf1TbwyZ +gQUhGfYcsEmgnASImQ+DmZnzWJBClR+lnhAaebn8U6alSa5Gh8ns+Idsesxyq4ug +N2laAGjDcH5l2aazVyLjdsKSGqayjlYEQH7Lbti9zf8gt91YgUgHfc5zig/pAjp7 +AKk0SNhVJKcH3VxUtH+B4OT/f7zq4Lo9IiyZXXPqzZpnT5WPaAPnIiD8J8jJtdHi +RjYxAoGBAMrIUx25HZbyj/nGQvMssRBKC5g/pBruNLcIR0THfasFUoCho8HhBECk ++8Mbf6KvdA1U7fLGXbOxZdcw/4df1F2rFUob/StMegQ4YOjFDKEM8abIkRAWmiB3 +SAnT5XeOdXD+jQ2QoDHmN+eq3sxWb9UWIDa3XVd0VaqMfr71LUINAoGBAM4VFOVl +4do0kK5TcwXEdpTSBJ0Lhs4OloZDHwS3Y+idxZRtflCeQEx5dTj22CcBY9dnmNsj +h7bJwZuNZwsFlQmr2598xmRr/Zwx3Le767WpRRQVS3ba+JE0pgOJizeFpAXZIk71 +H4WMUFcshI/7veABiafXSzXs9QloFW/txQ2xAoGBAJV/cqdrHkF2umZuoFUfEbrs +m/e2k4m3K+30kHZIjIFU/yoOAOiit7vg5itFTqflqi1Z+8MMM3CSzH4FhZ2MLMiR +pLRqRg9x5lLOLcPt4g2puU44d5ngz+g3iEiWi4nE4RhY0iy5/yBKldSZyH/lCbWT +BlTQgmRDRBTkV1z47FDRAoGAHPBi7c+/xneJNaGsTkwHZuCTG0aKGJd91b7JNU1X +b0vweeZiTYnbd6G6r1QiiF+umIoIsgUsQqPUVzalGKwhAKmsfpnz9hggFfX+i6KM ++AacF7Th0v/u1d0xzt9auGJA5T1XjwTm90b75THwL5PnVvSAUxxtiFYNFtQR5pTa +DhECgYA03gEBN1MAJmcX98VFS9158FR7IB9b7SXLUxrRDIfh6g25iqPsLlHMD60J +tkTqxUogr2nPaKoD/HqR4nycR0ML4uMzymL74Dw+sZEgeL5KysepH7W1JtwiVGCt +3mev9AgFkXZouO+9t4lGkTtD0U22olqqU6mL5RdSpJCxNQKSHg== +-----END RSA PRIVATE KEY----- From cc18b4b0a18eb77c6848305cde548b17d2fade53 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 19 Mar 2023 05:45:05 +0000 Subject: [PATCH 02/10] Add diagnostics --- .../androidtv_remote/diagnostics.py | 29 +++++++++++++++++++ .../snapshots/test_diagnostics.ambr | 14 +++++++++ .../androidtv_remote/test_diagnostics.py | 28 ++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 homeassistant/components/androidtv_remote/diagnostics.py create mode 100644 tests/components/androidtv_remote/snapshots/test_diagnostics.ambr create mode 100644 tests/components/androidtv_remote/test_diagnostics.py diff --git a/homeassistant/components/androidtv_remote/diagnostics.py b/homeassistant/components/androidtv_remote/diagnostics.py new file mode 100644 index 00000000000000..28d16bf94fec3c --- /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/tests/components/androidtv_remote/snapshots/test_diagnostics.ambr b/tests/components/androidtv_remote/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..8282f1deddec9c --- /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_diagnostics.py b/tests/components/androidtv_remote/test_diagnostics.py new file mode 100644 index 00000000000000..93410fd4511982 --- /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 + ) From 254938b4c0353ef6903b99b8958bd1ec558121fe Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 19 Mar 2023 06:23:57 +0000 Subject: [PATCH 03/10] Remove test pem files from when api was not mocked --- .../testing_config/androidtv_remote_cert.pem | 18 ------------- tests/testing_config/androidtv_remote_key.pem | 27 ------------------- 2 files changed, 45 deletions(-) delete mode 100644 tests/testing_config/androidtv_remote_cert.pem delete mode 100644 tests/testing_config/androidtv_remote_key.pem diff --git a/tests/testing_config/androidtv_remote_cert.pem b/tests/testing_config/androidtv_remote_cert.pem deleted file mode 100644 index ccea39032dd9ba..00000000000000 --- a/tests/testing_config/androidtv_remote_cert.pem +++ /dev/null @@ -1,18 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIC3DCCAcSgAwIBAgICA+gwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwOSG9t -ZSBBc3Npc3RhbnQwHhcNMjMwMzE4MDkxMDQ0WhcNMzMwMzE1MDkxMDQ0WjAZMRcw -FQYDVQQDDA5Ib21lIEFzc2lzdGFudDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC -AQoCggEBAKM95d4Yypqlej8dgOF4xPl9BY9kDJc0NHfSPz6Rowz+/wIEnS+ze/EA -rysQktiaSJxHwcWB8K5gSJBYsFe/BfHeSqilMc+O59e32DExUO9Kj6a8iaxo4Oad -jC7NAMclq3ig6RmNoEshXdVWARF2II5eo6Oh8+TLp/faF02cn5HbkcH5KxksCIMV -1liwZiTipFSR1h0Utvc4N6WebN8BD2IOWEuDMoBAjPMCITh2mclgdXHnooC5LM5O -FmjTpcSh5ztgCCwNtMfL8ia/62q2N3/82L9xlljN1iD3Vi5LEVUSxHY0NpgUszdW -smldeLZTRniM8JlVmBtnrtObF62mU/0CAwEAAaMuMCwwDwYDVR0TBAgwBgEB/wIB -ADAZBgNVHREEEjAQgg5Ib21lIEFzc2lzdGFudDANBgkqhkiG9w0BAQsFAAOCAQEA -aXrj20THe8MvDxL1w1hUZnk9Gn7jWCVGfofXqb8H+s9DdjMlqBf6Wr4XwwCG8GWG -RdY70m4h/JLdZJbpLmdTNhmEy1y97mvBAQu8Pr1Og7jKCx9GH5GCmfmZVw/hMQIh -wdnESGAB0po2+E7T8NyqD/4z8UN1WOwCsJt9mx5/9zqtdc8S8e1t4BcF23jsCR6W -G7ax0B1nCXAV+w3Tr+q52cTJg7AMSTpT0ZSVBxodfev3LO+JqxejqM17Jq7Dyisp -7SoAhlSDi2Sipc2IpmFS8CeD1bdRc7T6DkuTkfJlCLSp5WiNqk2vxM2rQhV1Epbd -xL+nm75h+fyeZcekWlOdLw== ------END CERTIFICATE----- diff --git a/tests/testing_config/androidtv_remote_key.pem b/tests/testing_config/androidtv_remote_key.pem deleted file mode 100644 index 012d0d51065a7e..00000000000000 --- a/tests/testing_config/androidtv_remote_key.pem +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEoQIBAAKCAQEAoz3l3hjKmqV6Px2A4XjE+X0Fj2QMlzQ0d9I/PpGjDP7/AgSd -L7N78QCvKxCS2JpInEfBxYHwrmBIkFiwV78F8d5KqKUxz47n17fYMTFQ70qPpryJ -rGjg5p2MLs0AxyWreKDpGY2gSyFd1VYBEXYgjl6jo6Hz5Mun99oXTZyfkduRwfkr -GSwIgxXWWLBmJOKkVJHWHRS29zg3pZ5s3wEPYg5YS4MygECM8wIhOHaZyWB1ceei -gLkszk4WaNOlxKHnO2AILA20x8vyJr/rarY3f/zYv3GWWM3WIPdWLksRVRLEdjQ2 -mBSzN1ayaV14tlNGeIzwmVWYG2eu05sXraZT/QIDAQABAoH/CCFNEYKqlIkpTXtq -r9AT/1j6remNtvoiotUV0UfvlvYcPT19lqKZyab/EmYA6kjE1QO94thV5FlnQ8km -/zfEVAd+MGgJ0wyK/3NokQqjvUc5YFSDhtKyYMn38VzEbSazI47gDSXAlf1TbwyZ -gQUhGfYcsEmgnASImQ+DmZnzWJBClR+lnhAaebn8U6alSa5Gh8ns+Idsesxyq4ug -N2laAGjDcH5l2aazVyLjdsKSGqayjlYEQH7Lbti9zf8gt91YgUgHfc5zig/pAjp7 -AKk0SNhVJKcH3VxUtH+B4OT/f7zq4Lo9IiyZXXPqzZpnT5WPaAPnIiD8J8jJtdHi -RjYxAoGBAMrIUx25HZbyj/nGQvMssRBKC5g/pBruNLcIR0THfasFUoCho8HhBECk -+8Mbf6KvdA1U7fLGXbOxZdcw/4df1F2rFUob/StMegQ4YOjFDKEM8abIkRAWmiB3 -SAnT5XeOdXD+jQ2QoDHmN+eq3sxWb9UWIDa3XVd0VaqMfr71LUINAoGBAM4VFOVl -4do0kK5TcwXEdpTSBJ0Lhs4OloZDHwS3Y+idxZRtflCeQEx5dTj22CcBY9dnmNsj -h7bJwZuNZwsFlQmr2598xmRr/Zwx3Le767WpRRQVS3ba+JE0pgOJizeFpAXZIk71 -H4WMUFcshI/7veABiafXSzXs9QloFW/txQ2xAoGBAJV/cqdrHkF2umZuoFUfEbrs -m/e2k4m3K+30kHZIjIFU/yoOAOiit7vg5itFTqflqi1Z+8MMM3CSzH4FhZ2MLMiR -pLRqRg9x5lLOLcPt4g2puU44d5ngz+g3iEiWi4nE4RhY0iy5/yBKldSZyH/lCbWT -BlTQgmRDRBTkV1z47FDRAoGAHPBi7c+/xneJNaGsTkwHZuCTG0aKGJd91b7JNU1X -b0vweeZiTYnbd6G6r1QiiF+umIoIsgUsQqPUVzalGKwhAKmsfpnz9hggFfX+i6KM -+AacF7Th0v/u1d0xzt9auGJA5T1XjwTm90b75THwL5PnVvSAUxxtiFYNFtQR5pTa -DhECgYA03gEBN1MAJmcX98VFS9158FR7IB9b7SXLUxrRDIfh6g25iqPsLlHMD60J -tkTqxUogr2nPaKoD/HqR4nycR0ML4uMzymL74Dw+sZEgeL5KysepH7W1JtwiVGCt -3mev9AgFkXZouO+9t4lGkTtD0U22olqqU6mL5RdSpJCxNQKSHg== ------END RSA PRIVATE KEY----- From e0c8de49f765f4ca31a0e4671380b309138c1e3c Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 19 Mar 2023 20:02:43 +0000 Subject: [PATCH 04/10] Address review comments --- .../components/androidtv_remote/__init__.py | 16 +++++++++++----- .../components/androidtv_remote/config_flow.py | 15 +++++++++++++++ .../components/androidtv_remote/remote.py | 18 ++++++++++-------- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index bbeefef65040d8..a7b08f75cadd3e 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -22,26 +22,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Android TV Remote from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - api = create_api(hass, entry.data[CONF_HOST]) try: await api.async_connect() except InvalidAuth as exc: + # Typically the Android TV is hard reset. raise ConfigEntryAuthFailed from exc except (CannotConnect, ConnectionClosed) as exc: + # Typically the Android TV isn't network reachable. 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(): + 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[DOMAIN][entry.entry_id] = api + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def on_hass_stop(event): + async def on_hass_stop(event) -> None: """Stop push updates when hass stops.""" api.disconnect() diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 1d2dcff1e8c53c..7e62b3562a2182 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -63,6 +63,8 @@ async def async_step_user( self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) return await self._async_start_pair() except (CannotConnect, ConnectionClosed): + # Typically invalid IP address. 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", @@ -103,11 +105,21 @@ async def async_step_pair( }, ) except InvalidAuth: + # Typically 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", @@ -139,6 +151,8 @@ async def async_step_zeroconf_confirm( 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", @@ -164,6 +178,7 @@ async def async_step_reauth_confirm( try: return await self._async_start_pair() except (CannotConnect, ConnectionClosed): + # Device isn't network reachable. Abort. errors["base"] = "cannot_connect" return self.async_show_form( step_id="reauth_confirm", diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py index 9d69c718f83ca8..1c68c92bc684f9 100644 --- a/homeassistant/components/androidtv_remote/remote.py +++ b/homeassistant/components/androidtv_remote/remote.py @@ -59,26 +59,28 @@ def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None: 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, str(config_entry.unique_id))}, + identifiers={(DOMAIN, config_entry.unique_id)}, name=self._name, - manufacturer=None if not device_info else device_info["manufacturer"], - model=None if not device_info else device_info["model"], + manufacturer=device_info["manufacturer"], + model=device_info["model"], ) @callback - def is_on_updated(is_on: bool): + 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): + 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): + def is_available_updated(is_available: bool) -> None: if is_available: _LOGGER.info( "Reconnected to %s at %s", @@ -127,7 +129,7 @@ async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> Non self._send_key_command(single_command, "SHORT") await asyncio.sleep(delay_secs) - def _send_key_command(self, key_code: str, direction: str = "SHORT"): + 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. @@ -139,7 +141,7 @@ def _send_key_command(self, key_code: str, direction: str = "SHORT"): "Connection to Android TV device is closed" ) from exc - def _send_launch_app_command(self, app_link: str): + 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. From c783a8fda3704c0813cdc519ae2781ee6ba5153e Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 19 Mar 2023 21:04:54 +0000 Subject: [PATCH 05/10] Remove hass.data call in test --- tests/components/androidtv_remote/test_init.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/components/androidtv_remote/test_init.py b/tests/components/androidtv_remote/test_init.py index 470bcc241deb2d..f3f61eb268efc7 100644 --- a/tests/components/androidtv_remote/test_init.py +++ b/tests/components/androidtv_remote/test_init.py @@ -4,7 +4,6 @@ from androidtvremote2 import CannotConnect, InvalidAuth -from homeassistant.components.androidtv_remote.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -27,7 +26,6 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert mock_api.disconnect.call_count == 1 From 11fb8b7a7fba180dd62e6a8f8db7a1e50ab0edf8 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 20 Mar 2023 04:25:32 +0000 Subject: [PATCH 06/10] Store the certificate and key in /config/.storage --- homeassistant/components/androidtv_remote/__init__.py | 4 ++-- homeassistant/components/androidtv_remote/helpers.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index a7b08f75cadd3e..f356c36d543e63 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -26,10 +26,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await api.async_connect() except InvalidAuth as exc: - # Typically the Android TV is hard reset. + # Typically the Android TV is hard reset or the certificate and key files were deleted. raise ConfigEntryAuthFailed from exc except (CannotConnect, ConnectionClosed) as exc: - # Typically the Android TV isn't network reachable. Raise exception and let + # Typically 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 diff --git a/homeassistant/components/androidtv_remote/helpers.py b/homeassistant/components/androidtv_remote/helpers.py index 62dab92a607318..0bc1f1b904f14a 100644 --- a/homeassistant/components/androidtv_remote/helpers.py +++ b/homeassistant/components/androidtv_remote/helpers.py @@ -4,14 +4,15 @@ 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("androidtv_remote_cert.pem"), - keyfile=hass.config.path("androidtv_remote_key.pem"), + 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, ) From ea79359baf1dc53802c844da09a74be48248368b Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 20 Mar 2023 05:13:37 +0000 Subject: [PATCH 07/10] update comments --- homeassistant/components/androidtv_remote/__init__.py | 7 +++---- .../components/androidtv_remote/config_flow.py | 10 +++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index f356c36d543e63..88dfafb1ae9624 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -26,12 +26,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await api.async_connect() except InvalidAuth as exc: - # Typically the Android TV is hard reset or the certificate and key files were deleted. + # The Android TV is hard reset or the certificate and key files were deleted. raise ConfigEntryAuthFailed from exc except (CannotConnect, ConnectionClosed) as exc: - # Typically 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. + # 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: diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 7e62b3562a2182..0a1ebace15b98f 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -63,8 +63,8 @@ async def async_step_user( self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) return await self._async_start_pair() except (CannotConnect, ConnectionClosed): - # Typically invalid IP address. Stay in the user step allowing the user - # to enter a different host. + # 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", @@ -105,8 +105,8 @@ async def async_step_pair( }, ) except InvalidAuth: - # Typically invalid PIN. Stay in the pair step allowing the user - # to enter a different PIN. + # 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) @@ -178,7 +178,7 @@ async def async_step_reauth_confirm( try: return await self._async_start_pair() except (CannotConnect, ConnectionClosed): - # Device isn't network reachable. Abort. + # Device is network unreachable. Abort. errors["base"] = "cannot_connect" return self.async_show_form( step_id="reauth_confirm", From 97ef80f4e23684a08b4a90ad6b2d4ecb1da2a634 Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 4 Apr 2023 22:42:26 -0700 Subject: [PATCH 08/10] Update homeassistant/components/androidtv_remote/__init__.py Co-authored-by: Paulus Schoutsen --- homeassistant/components/androidtv_remote/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index 88dfafb1ae9624..09cf028f5672d2 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -46,7 +46,8 @@ def reauth_needed() -> None: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def on_hass_stop(event) -> None: + @callback + def on_hass_stop(event) -> None: """Stop push updates when hass stops.""" api.disconnect() From fa1d78c5c694389a5e6bbf9cc60a7ad3dbe96ff4 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 5 Apr 2023 05:50:04 +0000 Subject: [PATCH 09/10] import callback --- homeassistant/components/androidtv_remote/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index 09cf028f5672d2..fb275342cb0ce3 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import DOMAIN From 3307be07b83fe7edfa347c0f027d58f7e30666a3 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 5 Apr 2023 05:55:06 +0000 Subject: [PATCH 10/10] use async_generate_cert_if_missing --- .../androidtv_remote/config_flow.py | 2 +- .../components/androidtv_remote/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../androidtv_remote/test_config_flow.py | 27 ++++++++++++------- 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 0a1ebace15b98f..24b64c622a9960 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -76,7 +76,7 @@ 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) - self.api.generate_cert_if_missing() + await self.api.async_generate_cert_if_missing() await self.api.async_start_pairing() return await self.async_step_pair() diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index 18e63fddbb16c0..702e3b9a2c393b 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["androidtvremote2"], "quality_scale": "platinum", - "requirements": ["androidtvremote2==0.0.2"], + "requirements": ["androidtvremote2==0.0.4"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 3610091534e931..660b4262b0a3f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -333,7 +333,7 @@ amcrest==1.9.7 androidtv[async]==0.0.70 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.2 +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 f0fa52da89c11f..84e11d1ec7cfb6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -308,7 +308,7 @@ ambiclimate==0.2.1 androidtv[async]==0.0.70 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.2 +androidtvremote2==0.0.4 # homeassistant.components.anthemav anthemav==1.4.1 diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index 704927717ce5dc..ea1f4abfc1db74 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -35,6 +35,7 @@ async def test_user_flow_success( 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( @@ -46,7 +47,7 @@ async def test_user_flow_success( assert "pin" in result["data_schema"].schema assert not result["errors"] - mock_api.generate_cert_if_missing.assert_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) @@ -133,6 +134,7 @@ async def test_user_flow_pairing_invalid_auth( 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( @@ -144,7 +146,7 @@ async def test_user_flow_pairing_invalid_auth( assert "pin" in result["data_schema"].schema assert not result["errors"] - mock_api.generate_cert_if_missing.assert_called() + 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()) @@ -194,6 +196,7 @@ async def test_user_flow_pairing_connection_closed( 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( @@ -205,7 +208,7 @@ async def test_user_flow_pairing_connection_closed( assert "pin" in result["data_schema"].schema assert not result["errors"] - mock_api.generate_cert_if_missing.assert_called() + 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()) @@ -255,6 +258,7 @@ async def test_user_flow_pairing_connection_closed_followed_by_cannot_connect( 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( @@ -266,7 +270,7 @@ async def test_user_flow_pairing_connection_closed_followed_by_cannot_connect( assert "pin" in result["data_schema"].schema assert not result["errors"] - mock_api.generate_cert_if_missing.assert_called() + 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()) @@ -442,6 +446,7 @@ async def test_zeroconf_flow_success( 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( @@ -453,7 +458,7 @@ async def test_zeroconf_flow_success( assert "pin" in result["data_schema"].schema assert not result["errors"] - mock_api.generate_cert_if_missing.assert_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) @@ -511,6 +516,7 @@ async def test_zeroconf_flow_cannot_connect( 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( @@ -520,7 +526,7 @@ async def test_zeroconf_flow_cannot_connect( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "cannot_connect" - mock_api.generate_cert_if_missing.assert_called() + mock_api.async_generate_cert_if_missing.assert_called() mock_api.async_start_pairing.assert_called() await hass.async_block_till_done() @@ -561,6 +567,7 @@ async def test_zeroconf_flow_pairing_invalid_auth( 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( @@ -572,7 +579,7 @@ async def test_zeroconf_flow_pairing_invalid_auth( assert "pin" in result["data_schema"].schema assert not result["errors"] - mock_api.generate_cert_if_missing.assert_called() + 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()) @@ -742,6 +749,7 @@ async def test_reauth_flow_success( 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"], {}) @@ -751,7 +759,7 @@ async def test_reauth_flow_success( assert not result["errors"] mock_api.async_get_name_and_mac.assert_not_called() - mock_api.generate_cert_if_missing.assert_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) @@ -810,6 +818,7 @@ async def test_reauth_flow_cannot_connect( 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"], {}) @@ -818,7 +827,7 @@ async def test_reauth_flow_cannot_connect( assert result["errors"] == {"base": "cannot_connect"} mock_api.async_get_name_and_mac.assert_not_called() - mock_api.generate_cert_if_missing.assert_called() + mock_api.async_generate_cert_if_missing.assert_called() mock_api.async_start_pairing.assert_called() await hass.async_block_till_done()