Android TV Remote integration (#89935)

* Android TV Remote integration

* Add diagnostics

* Remove test pem files from when api was not mocked

* Address review comments

* Remove hass.data call in test

* Store the certificate and key in /config/.storage

* update comments

* Update homeassistant/components/androidtv_remote/__init__.py

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

* import callback

* use async_generate_cert_if_missing

---------

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
tronikos 2023-04-05 18:00:40 -07:00 committed by GitHub
parent a6c5b5e238
commit 49468ef5d0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1792 additions and 0 deletions

View file

@ -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

View file

@ -0,0 +1,67 @@
"""The Android TV Remote integration."""
from __future__ import annotations
from androidtvremote2 import (
AndroidTVRemote,
CannotConnect,
ConnectionClosed,
InvalidAuth,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import DOMAIN
from .helpers import create_api
PLATFORMS: list[Platform] = [Platform.REMOTE]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Android TV Remote from a config entry."""
api = create_api(hass, entry.data[CONF_HOST])
try:
await api.async_connect()
except InvalidAuth as exc:
# The Android TV is hard reset or the certificate and key files were deleted.
raise ConfigEntryAuthFailed from exc
except (CannotConnect, ConnectionClosed) as exc:
# The Android TV is network unreachable. Raise exception and let Home Assistant retry
# later. If device gets a new IP address the zeroconf flow will update the config.
raise ConfigEntryNotReady from exc
def reauth_needed() -> None:
"""Start a reauth flow if Android TV is hard reset while reconnecting."""
entry.async_start_reauth(hass)
# Start a task (canceled in disconnect) to keep reconnecting if device becomes
# network unreachable. If device gets a new IP address the zeroconf flow will
# update the config entry data and reload the config entry.
api.keep_reconnecting(reauth_needed)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@callback
def on_hass_stop(event) -> None:
"""Stop push updates when hass stops."""
api.disconnect()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
api: AndroidTVRemote = hass.data[DOMAIN].pop(entry.entry_id)
api.disconnect()
return unload_ok

View file

@ -0,0 +1,187 @@
"""Config flow for Android TV Remote integration."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from androidtvremote2 import (
AndroidTVRemote,
CannotConnect,
ConnectionClosed,
InvalidAuth,
)
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN
from .helpers import create_api
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required("host"): str,
}
)
STEP_PAIR_DATA_SCHEMA = vol.Schema(
{
vol.Required("pin"): str,
}
)
class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Android TV Remote."""
VERSION = 1
def __init__(self) -> None:
"""Initialize a new AndroidTVRemoteConfigFlow."""
self.api: AndroidTVRemote | None = None
self.reauth_entry: config_entries.ConfigEntry | None = None
self.host: str | None = None
self.name: str | None = None
self.mac: str | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self.host = user_input["host"]
assert self.host
api = create_api(self.hass, self.host)
try:
self.name, self.mac = await api.async_get_name_and_mac()
assert self.mac
await self.async_set_unique_id(format_mac(self.mac))
self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
return await self._async_start_pair()
except (CannotConnect, ConnectionClosed):
# Likely invalid IP address or device is network unreachable. Stay
# in the user step allowing the user to enter a different host.
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def _async_start_pair(self) -> FlowResult:
"""Start pairing with the Android TV. Navigate to the pair flow to enter the PIN shown on screen."""
assert self.host
self.api = create_api(self.hass, self.host)
await self.api.async_generate_cert_if_missing()
await self.api.async_start_pairing()
return await self.async_step_pair()
async def async_step_pair(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the pair step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
pin = user_input["pin"]
assert self.api
await self.api.async_finish_pairing(pin)
if self.reauth_entry:
await self.hass.config_entries.async_reload(
self.reauth_entry.entry_id
)
return self.async_abort(reason="reauth_successful")
assert self.name
return self.async_create_entry(
title=self.name,
data={
CONF_HOST: self.host,
CONF_NAME: self.name,
CONF_MAC: self.mac,
},
)
except InvalidAuth:
# Invalid PIN. Stay in the pair step allowing the user to enter
# a different PIN.
errors["base"] = "invalid_auth"
except ConnectionClosed:
# Either user canceled pairing on the Android TV itself (most common)
# or device doesn't respond to the specified host (device was unplugged,
# network was unplugged, or device got a new IP address).
# Attempt to pair again.
try:
return await self._async_start_pair()
except (CannotConnect, ConnectionClosed):
# Device doesn't respond to the specified host. Abort.
# If we are in the user flow we could go back to the user step to allow
# them to enter a new IP address but we cannot do that for the zeroconf
# flow. Simpler to abort for both flows.
return self.async_abort(reason="cannot_connect")
return self.async_show_form(
step_id="pair",
data_schema=STEP_PAIR_DATA_SCHEMA,
description_placeholders={CONF_NAME: self.name},
errors=errors,
)
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> FlowResult:
"""Handle zeroconf discovery."""
self.host = discovery_info.host
self.name = discovery_info.name.removesuffix("._androidtvremote2._tcp.local.")
self.mac = discovery_info.properties.get("bt")
assert self.mac
await self.async_set_unique_id(format_mac(self.mac))
self._abort_if_unique_id_configured(
updates={CONF_HOST: self.host, CONF_NAME: self.name}
)
self.context.update({"title_placeholders": {CONF_NAME: self.name}})
return await self.async_step_zeroconf_confirm()
async def async_step_zeroconf_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initiated by zeroconf."""
if user_input is not None:
try:
return await self._async_start_pair()
except (CannotConnect, ConnectionClosed):
# Device became network unreachable after discovery.
# Abort and let discovery find it again later.
return self.async_abort(reason="cannot_connect")
return self.async_show_form(
step_id="zeroconf_confirm",
description_placeholders={CONF_NAME: self.name},
)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Handle configuration by re-auth."""
self.host = entry_data[CONF_HOST]
self.name = entry_data[CONF_NAME]
self.mac = entry_data[CONF_MAC]
self.reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Dialog that informs the user that reauth is required."""
errors: dict[str, str] = {}
if user_input is not None:
try:
return await self._async_start_pair()
except (CannotConnect, ConnectionClosed):
# Device is network unreachable. Abort.
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={CONF_NAME: self.name},
errors=errors,
)

View file

@ -0,0 +1,6 @@
"""Constants for the Android TV Remote integration."""
from __future__ import annotations
from typing import Final
DOMAIN: Final = "androidtv_remote"

View file

@ -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,
)

View file

@ -0,0 +1,18 @@
"""Helper functions for Android TV Remote integration."""
from __future__ import annotations
from androidtvremote2 import AndroidTVRemote
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import STORAGE_DIR
def create_api(hass: HomeAssistant, host: str) -> AndroidTVRemote:
"""Create an AndroidTVRemote instance."""
return AndroidTVRemote(
client_name="Home Assistant",
certfile=hass.config.path(STORAGE_DIR, "androidtv_remote_cert.pem"),
keyfile=hass.config.path(STORAGE_DIR, "androidtv_remote_key.pem"),
host=host,
loop=hass.loop,
)

View file

@ -0,0 +1,13 @@
{
"domain": "androidtv_remote",
"name": "Android TV Remote",
"codeowners": ["@tronikos"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/androidtv_remote",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["androidtvremote2"],
"quality_scale": "platinum",
"requirements": ["androidtvremote2==0.0.4"],
"zeroconf": ["_androidtvremote2._tcp.local."]
}

View file

@ -0,0 +1,154 @@
"""Remote control support for Android TV Remote."""
from __future__ import annotations
import asyncio
from collections.abc import Iterable
import logging
from typing import Any
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
from homeassistant.components.remote import (
ATTR_ACTIVITY,
ATTR_DELAY_SECS,
ATTR_HOLD_SECS,
ATTR_NUM_REPEATS,
DEFAULT_DELAY_SECS,
DEFAULT_HOLD_SECS,
DEFAULT_NUM_REPEATS,
RemoteEntity,
RemoteEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Android TV remote entity based on a config entry."""
api: AndroidTVRemote = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities([AndroidTVRemoteEntity(api, config_entry)])
class AndroidTVRemoteEntity(RemoteEntity):
"""Representation of an Android TV Remote."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None:
"""Initialize device."""
self._api = api
self._host = config_entry.data[CONF_HOST]
self._name = config_entry.data[CONF_NAME]
self._attr_unique_id = config_entry.unique_id
self._attr_supported_features = RemoteEntityFeature.ACTIVITY
self._attr_is_on = api.is_on
self._attr_current_activity = api.current_app
device_info = api.device_info
assert config_entry.unique_id
assert device_info
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, config_entry.data[CONF_MAC])},
identifiers={(DOMAIN, config_entry.unique_id)},
name=self._name,
manufacturer=device_info["manufacturer"],
model=device_info["model"],
)
@callback
def is_on_updated(is_on: bool) -> None:
self._attr_is_on = is_on
self.async_write_ha_state()
@callback
def current_app_updated(current_app: str) -> None:
self._attr_current_activity = current_app
self.async_write_ha_state()
@callback
def is_available_updated(is_available: bool) -> None:
if is_available:
_LOGGER.info(
"Reconnected to %s at %s",
self._name,
self._host,
)
else:
_LOGGER.warning(
"Disconnected from %s at %s",
self._name,
self._host,
)
self._attr_available = is_available
self.async_write_ha_state()
api.add_is_on_updated_callback(is_on_updated)
api.add_current_app_updated_callback(current_app_updated)
api.add_is_available_updated_callback(is_available_updated)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the Android TV on."""
if not self.is_on:
self._send_key_command("POWER")
activity = kwargs.get(ATTR_ACTIVITY, "")
if activity:
self._send_launch_app_command(activity)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the Android TV off."""
if self.is_on:
self._send_key_command("POWER")
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
"""Send commands to one device."""
num_repeats = kwargs.get(ATTR_NUM_REPEATS, DEFAULT_NUM_REPEATS)
delay_secs = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS)
hold_secs = kwargs.get(ATTR_HOLD_SECS, DEFAULT_HOLD_SECS)
for _ in range(num_repeats):
for single_command in command:
if hold_secs:
self._send_key_command(single_command, "START_LONG")
await asyncio.sleep(hold_secs)
self._send_key_command(single_command, "END_LONG")
else:
self._send_key_command(single_command, "SHORT")
await asyncio.sleep(delay_secs)
def _send_key_command(self, key_code: str, direction: str = "SHORT") -> None:
"""Send a key press to Android TV.
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
"""
try:
self._api.send_key_command(key_code, direction)
except ConnectionClosed as exc:
raise HomeAssistantError(
"Connection to Android TV device is closed"
) from exc
def _send_launch_app_command(self, app_link: str) -> None:
"""Launch an app on Android TV.
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
"""
try:
self._api.send_launch_app_command(app_link)
except ConnectionClosed as exc:
raise HomeAssistantError(
"Connection to Android TV device is closed"
) from exc

View file

@ -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%]"
}
}
}

View file

@ -39,6 +39,7 @@ FLOWS = {
"ambient_station",
"android_ip_webcam",
"androidtv",
"androidtv_remote",
"anthemav",
"apcupsd",
"apple_tv",

View file

@ -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",

View file

@ -279,6 +279,11 @@ ZEROCONF = {
"domain": "apple_tv",
},
],
"_androidtvremote2._tcp.local.": [
{
"domain": "androidtv_remote",
},
],
"_api._tcp.local.": [
{
"domain": "baf",

View file

@ -332,6 +332,9 @@ amcrest==1.9.7
# homeassistant.components.androidtv
androidtv[async]==0.0.70
# homeassistant.components.androidtv_remote
androidtvremote2==0.0.4
# homeassistant.components.anel_pwrctrl
anel_pwrctrl-homeassistant==0.0.1.dev2

View file

@ -307,6 +307,9 @@ ambiclimate==0.2.1
# homeassistant.components.androidtv
androidtv[async]==0.0.70
# homeassistant.components.androidtv_remote
androidtvremote2==0.0.4
# homeassistant.components.anthemav
anthemav==1.4.1

View file

@ -0,0 +1 @@
"""Tests for the Android TV Remote integration."""

View file

@ -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,
)

View file

@ -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',
}),
})
# ---

View file

@ -0,0 +1,835 @@
"""Test the Android TV Remote config flow."""
from unittest.mock import AsyncMock, MagicMock
from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth
from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.components.androidtv_remote.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_user_flow_success(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_api: MagicMock,
) -> None:
"""Test the full user flow from start to finish without any exceptions."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert "host" in result["data_schema"].schema
assert not result["errors"]
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
unique_id = "1a:2b:3c:4d:5e:6f"
pin = "123456"
mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac))
mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True)
mock_api.async_start_pairing = AsyncMock(return_value=None)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": host}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "pair"
assert "pin" in result["data_schema"].schema
assert not result["errors"]
mock_api.async_generate_cert_if_missing.assert_called()
mock_api.async_start_pairing.assert_called()
mock_api.async_finish_pairing = AsyncMock(return_value=None)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"pin": pin}
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == name
assert result["data"] == {"host": host, "name": name, "mac": mac}
assert result["context"]["source"] == "user"
assert result["context"]["unique_id"] == unique_id
mock_api.async_finish_pairing.assert_called_with(pin)
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 0
assert len(mock_setup_entry.mock_calls) == 1
async def test_user_flow_cannot_connect(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_api: MagicMock,
) -> None:
"""Test async_get_name_and_mac raises CannotConnect.
This is when the user entered an invalid IP address so we stay
in the user step allowing the user to enter a different host.
"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert "host" in result["data_schema"].schema
assert not result["errors"]
host = "1.2.3.4"
mock_api.async_get_name_and_mac = AsyncMock(side_effect=CannotConnect())
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": host}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert "host" in result["data_schema"].schema
assert result["errors"] == {"base": "cannot_connect"}
mock_api.async_get_name_and_mac.assert_called()
mock_api.async_start_pairing.assert_not_called()
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 0
assert len(mock_setup_entry.mock_calls) == 0
async def test_user_flow_pairing_invalid_auth(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_api: MagicMock,
) -> None:
"""Test async_finish_pairing raises InvalidAuth.
This is when the user entered an invalid PIN. We stay in the pair step
allowing the user to enter a different PIN.
"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert "host" in result["data_schema"].schema
assert not result["errors"]
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
pin = "123456"
mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac))
mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True)
mock_api.async_start_pairing = AsyncMock(return_value=None)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": host}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "pair"
assert "pin" in result["data_schema"].schema
assert not result["errors"]
mock_api.async_generate_cert_if_missing.assert_called()
mock_api.async_start_pairing.assert_called()
mock_api.async_finish_pairing = AsyncMock(side_effect=InvalidAuth())
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"pin": pin}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "pair"
assert "pin" in result["data_schema"].schema
assert result["errors"] == {"base": "invalid_auth"}
mock_api.async_finish_pairing.assert_called_with(pin)
assert mock_api.async_get_name_and_mac.call_count == 1
assert mock_api.async_start_pairing.call_count == 1
assert mock_api.async_finish_pairing.call_count == 1
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 0
assert len(mock_setup_entry.mock_calls) == 0
async def test_user_flow_pairing_connection_closed(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_api: MagicMock,
) -> None:
"""Test async_finish_pairing raises ConnectionClosed.
This is when the user canceled pairing on the Android TV itself before calling async_finish_pairing.
We call async_start_pairing again which succeeds and we have a chance to enter a new PIN.
"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert "host" in result["data_schema"].schema
assert not result["errors"]
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
pin = "123456"
mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac))
mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True)
mock_api.async_start_pairing = AsyncMock(return_value=None)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": host}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "pair"
assert "pin" in result["data_schema"].schema
assert not result["errors"]
mock_api.async_generate_cert_if_missing.assert_called()
mock_api.async_start_pairing.assert_called()
mock_api.async_finish_pairing = AsyncMock(side_effect=ConnectionClosed())
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"pin": pin}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "pair"
assert "pin" in result["data_schema"].schema
assert not result["errors"]
mock_api.async_finish_pairing.assert_called_with(pin)
assert mock_api.async_get_name_and_mac.call_count == 1
assert mock_api.async_start_pairing.call_count == 2
assert mock_api.async_finish_pairing.call_count == 1
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 0
assert len(mock_setup_entry.mock_calls) == 0
async def test_user_flow_pairing_connection_closed_followed_by_cannot_connect(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_api: MagicMock,
) -> None:
"""Test async_finish_pairing raises ConnectionClosed and then async_start_pairing raises CannotConnect.
This is when the user unplugs the Android TV before calling async_finish_pairing.
We call async_start_pairing again which fails with CannotConnect so we abort.
"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert "host" in result["data_schema"].schema
assert not result["errors"]
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
pin = "123456"
mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac))
mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True)
mock_api.async_start_pairing = AsyncMock(side_effect=[None, CannotConnect()])
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": host}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "pair"
assert "pin" in result["data_schema"].schema
assert not result["errors"]
mock_api.async_generate_cert_if_missing.assert_called()
mock_api.async_start_pairing.assert_called()
mock_api.async_finish_pairing = AsyncMock(side_effect=ConnectionClosed())
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"pin": pin}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
mock_api.async_finish_pairing.assert_called_with(pin)
assert mock_api.async_get_name_and_mac.call_count == 1
assert mock_api.async_start_pairing.call_count == 2
assert mock_api.async_finish_pairing.call_count == 1
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 0
assert len(mock_setup_entry.mock_calls) == 0
async def test_user_flow_already_configured_host_changed_reloads_entry(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_api: MagicMock,
) -> None:
"""Test we abort the user flow if already configured and reload if host changed."""
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
unique_id = "1a:2b:3c:4d:5e:6f"
name_existing = "existing name if different is from discovery and should not change"
host_existing = "1.2.3.45"
assert host_existing != host
mock_config_entry = MockConfigEntry(
title=name,
domain=DOMAIN,
data={
"host": host_existing,
"name": name_existing,
"mac": mac,
},
unique_id=unique_id,
state=ConfigEntryState.LOADED,
)
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert "host" in result["data_schema"].schema
assert not result["errors"]
mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac))
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": host}
)
assert result.get("type") == FlowResultType.ABORT
assert result.get("reason") == "already_configured"
mock_api.async_get_name_and_mac.assert_called()
mock_api.async_start_pairing.assert_not_called()
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
assert hass.config_entries.async_entries(DOMAIN)[0].data == {
"host": host,
"name": name_existing,
"mac": mac,
}
async def test_user_flow_already_configured_host_not_changed_no_reload_entry(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_api: MagicMock,
) -> None:
"""Test we abort the user flow if already configured and no reload if host not changed."""
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
unique_id = "1a:2b:3c:4d:5e:6f"
name_existing = "existing name if different is from discovery and should not change"
host_existing = host
mock_config_entry = MockConfigEntry(
title=name,
domain=DOMAIN,
data={
"host": host_existing,
"name": name_existing,
"mac": mac,
},
unique_id=unique_id,
state=ConfigEntryState.LOADED,
)
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert "host" in result["data_schema"].schema
assert not result["errors"]
mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac))
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": host}
)
assert result.get("type") == FlowResultType.ABORT
assert result.get("reason") == "already_configured"
mock_api.async_get_name_and_mac.assert_called()
mock_api.async_start_pairing.assert_not_called()
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 0
assert len(mock_setup_entry.mock_calls) == 0
assert hass.config_entries.async_entries(DOMAIN)[0].data == {
"host": host,
"name": name_existing,
"mac": mac,
}
async def test_zeroconf_flow_success(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_api: MagicMock,
) -> None:
"""Test the full zeroconf flow from start to finish without any exceptions."""
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
unique_id = "1a:2b:3c:4d:5e:6f"
pin = "123456"
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host=host,
addresses=[host],
port=6466,
hostname=host,
type="mock_type",
name=name + "._androidtvremote2._tcp.local.",
properties={"bt": mac},
),
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "zeroconf_confirm"
assert not result["data_schema"]
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
result = flows[0]
assert result["step_id"] == "zeroconf_confirm"
assert result["context"]["source"] == "zeroconf"
assert result["context"]["unique_id"] == unique_id
assert result["context"]["title_placeholders"] == {"name": name}
mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True)
mock_api.async_start_pairing = AsyncMock(return_value=None)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "pair"
assert "pin" in result["data_schema"].schema
assert not result["errors"]
mock_api.async_generate_cert_if_missing.assert_called()
mock_api.async_start_pairing.assert_called()
mock_api.async_finish_pairing = AsyncMock(return_value=None)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"pin": pin}
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == name
assert result["data"] == {
"host": host,
"name": name,
"mac": mac,
}
assert result["context"]["source"] == "zeroconf"
assert result["context"]["unique_id"] == unique_id
mock_api.async_finish_pairing.assert_called_with(pin)
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 0
assert len(mock_setup_entry.mock_calls) == 1
async def test_zeroconf_flow_cannot_connect(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_api: MagicMock,
) -> None:
"""Test async_start_pairing raises CannotConnect in the zeroconf flow.
This is when the Android TV became network unreachable after discovery.
We abort and let discovery find it again later.
"""
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host=host,
addresses=[host],
port=6466,
hostname=host,
type="mock_type",
name=name + "._androidtvremote2._tcp.local.",
properties={"bt": mac},
),
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "zeroconf_confirm"
assert not result["data_schema"]
mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True)
mock_api.async_start_pairing = AsyncMock(side_effect=CannotConnect())
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
mock_api.async_generate_cert_if_missing.assert_called()
mock_api.async_start_pairing.assert_called()
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 0
assert len(mock_setup_entry.mock_calls) == 0
async def test_zeroconf_flow_pairing_invalid_auth(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_api: MagicMock,
) -> None:
"""Test async_finish_pairing raises InvalidAuth in the zeroconf flow.
This is when the user entered an invalid PIN. We stay in the pair step
allowing the user to enter a different PIN.
"""
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
pin = "123456"
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host=host,
addresses=[host],
port=6466,
hostname=host,
type="mock_type",
name=name + "._androidtvremote2._tcp.local.",
properties={"bt": mac},
),
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "zeroconf_confirm"
assert not result["data_schema"]
mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True)
mock_api.async_start_pairing = AsyncMock(return_value=None)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "pair"
assert "pin" in result["data_schema"].schema
assert not result["errors"]
mock_api.async_generate_cert_if_missing.assert_called()
mock_api.async_start_pairing.assert_called()
mock_api.async_finish_pairing = AsyncMock(side_effect=InvalidAuth())
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"pin": pin}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "pair"
assert "pin" in result["data_schema"].schema
assert result["errors"] == {"base": "invalid_auth"}
mock_api.async_finish_pairing.assert_called_with(pin)
assert mock_api.async_get_name_and_mac.call_count == 0
assert mock_api.async_start_pairing.call_count == 1
assert mock_api.async_finish_pairing.call_count == 1
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 0
assert len(mock_setup_entry.mock_calls) == 0
async def test_zeroconf_flow_already_configured_host_changed_reloads_entry(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_api: MagicMock,
) -> None:
"""Test we abort the zeroconf flow if already configured and reload if host or name changed."""
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
unique_id = "1a:2b:3c:4d:5e:6f"
name_existing = "existing name should change since we prefer one from discovery"
host_existing = "1.2.3.45"
assert host_existing != host
assert name_existing != name
mock_config_entry = MockConfigEntry(
title=name,
domain=DOMAIN,
data={
"host": host_existing,
"name": name_existing,
"mac": mac,
},
unique_id=unique_id,
state=ConfigEntryState.LOADED,
)
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host=host,
addresses=[host],
port=6466,
hostname=host,
type="mock_type",
name=name + "._androidtvremote2._tcp.local.",
properties={"bt": mac},
),
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
await hass.async_block_till_done()
assert hass.config_entries.async_entries(DOMAIN)[0].data == {
"host": host,
"name": name,
"mac": mac,
}
assert len(mock_unload_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_zeroconf_flow_already_configured_host_not_changed_no_reload_entry(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_api: MagicMock,
) -> None:
"""Test we abort the zeroconf flow if already configured and no reload if host and name not changed."""
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
unique_id = "1a:2b:3c:4d:5e:6f"
name_existing = name
host_existing = host
mock_config_entry = MockConfigEntry(
title=name,
domain=DOMAIN,
data={
"host": host_existing,
"name": name_existing,
"mac": mac,
},
unique_id=unique_id,
state=ConfigEntryState.LOADED,
)
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host=host,
addresses=[host],
port=6466,
hostname=host,
type="mock_type",
name=name + "._androidtvremote2._tcp.local.",
properties={"bt": mac},
),
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
await hass.async_block_till_done()
assert hass.config_entries.async_entries(DOMAIN)[0].data == {
"host": host,
"name": name,
"mac": mac,
}
assert len(mock_unload_entry.mock_calls) == 0
assert len(mock_setup_entry.mock_calls) == 0
async def test_reauth_flow_success(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_api: MagicMock,
) -> None:
"""Test the full reauth flow from start to finish without any exceptions."""
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
unique_id = "1a:2b:3c:4d:5e:6f"
pin = "123456"
mock_config_entry = MockConfigEntry(
title=name,
domain=DOMAIN,
data={
"host": host,
"name": name,
"mac": mac,
},
unique_id=unique_id,
state=ConfigEntryState.LOADED,
)
mock_config_entry.add_to_hass(hass)
mock_config_entry.async_start_reauth(hass)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
result = flows[0]
assert result["step_id"] == "reauth_confirm"
assert result["context"]["source"] == "reauth"
assert result["context"]["unique_id"] == unique_id
assert result["context"]["title_placeholders"] == {"name": name}
mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True)
mock_api.async_start_pairing = AsyncMock(return_value=None)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "pair"
assert "pin" in result["data_schema"].schema
assert not result["errors"]
mock_api.async_get_name_and_mac.assert_not_called()
mock_api.async_generate_cert_if_missing.assert_called()
mock_api.async_start_pairing.assert_called()
mock_api.async_finish_pairing = AsyncMock(return_value=None)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"pin": pin}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
mock_api.async_finish_pairing.assert_called_with(pin)
await hass.async_block_till_done()
assert hass.config_entries.async_entries(DOMAIN)[0].data == {
"host": host,
"name": name,
"mac": mac,
}
assert len(mock_unload_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_reauth_flow_cannot_connect(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_unload_entry: AsyncMock,
mock_api: MagicMock,
) -> None:
"""Test async_start_pairing raises CannotConnect in the reauth flow."""
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
unique_id = "1a:2b:3c:4d:5e:6f"
mock_config_entry = MockConfigEntry(
title=name,
domain=DOMAIN,
data={
"host": host,
"name": name,
"mac": mac,
},
unique_id=unique_id,
state=ConfigEntryState.LOADED,
)
mock_config_entry.add_to_hass(hass)
mock_config_entry.async_start_reauth(hass)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
result = flows[0]
assert result["step_id"] == "reauth_confirm"
assert result["context"]["source"] == "reauth"
assert result["context"]["unique_id"] == unique_id
assert result["context"]["title_placeholders"] == {"name": name}
mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True)
mock_api.async_start_pairing = AsyncMock(side_effect=CannotConnect())
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {"base": "cannot_connect"}
mock_api.async_get_name_and_mac.assert_not_called()
mock_api.async_generate_cert_if_missing.assert_called()
mock_api.async_start_pairing.assert_called()
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 0
assert len(mock_setup_entry.mock_calls) == 0

View file

@ -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
)

View file

@ -0,0 +1,106 @@
"""Tests for the Android TV Remote integration."""
from collections.abc import Callable
from unittest.mock import AsyncMock, MagicMock
from androidtvremote2 import CannotConnect, InvalidAuth
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_load_unload_config_entry(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None:
"""Test the Android TV Remote configuration entry loading/unloading."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_api.async_connect.call_count == 1
assert mock_api.keep_reconnecting.call_count == 1
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
assert mock_api.disconnect.call_count == 1
async def test_config_entry_not_ready(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None:
"""Test the Android TV Remote configuration entry not ready."""
mock_api.async_connect = AsyncMock(side_effect=CannotConnect())
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert mock_api.async_connect.call_count == 1
assert mock_api.keep_reconnecting.call_count == 0
async def test_config_entry_reauth_at_setup(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None:
"""Test the Android TV Remote configuration entry needs reauth at setup."""
mock_api.async_connect = AsyncMock(side_effect=InvalidAuth())
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
assert any(mock_config_entry.async_get_active_flows(hass, {"reauth"}))
assert mock_api.async_connect.call_count == 1
assert mock_api.keep_reconnecting.call_count == 0
async def test_config_entry_reauth_while_reconnecting(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None:
"""Test the Android TV Remote configuration entry needs reauth while reconnecting."""
invalid_auth_callback: Callable | None = None
def mocked_keep_reconnecting(callback: Callable):
nonlocal invalid_auth_callback
invalid_auth_callback = callback
mock_api.keep_reconnecting.side_effect = mocked_keep_reconnecting
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert not any(mock_config_entry.async_get_active_flows(hass, {"reauth"}))
assert mock_api.async_connect.call_count == 1
assert mock_api.keep_reconnecting.call_count == 1
assert invalid_auth_callback is not None
invalid_auth_callback()
await hass.async_block_till_done()
assert any(mock_config_entry.async_get_active_flows(hass, {"reauth"}))
async def test_disconnect_on_stop(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None:
"""Test we close the connection with the Android TV when Home Assistants stops."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_api.async_connect.call_count == 1
assert mock_api.keep_reconnecting.call_count == 1
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
assert mock_api.disconnect.call_count == 1

View file

@ -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")]