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:
parent
a6c5b5e238
commit
49468ef5d0
21 changed files with 1792 additions and 0 deletions
|
@ -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
|
||||
|
|
67
homeassistant/components/androidtv_remote/__init__.py
Normal file
67
homeassistant/components/androidtv_remote/__init__.py
Normal 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
|
187
homeassistant/components/androidtv_remote/config_flow.py
Normal file
187
homeassistant/components/androidtv_remote/config_flow.py
Normal 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,
|
||||
)
|
6
homeassistant/components/androidtv_remote/const.py
Normal file
6
homeassistant/components/androidtv_remote/const.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
"""Constants for the Android TV Remote integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "androidtv_remote"
|
29
homeassistant/components/androidtv_remote/diagnostics.py
Normal file
29
homeassistant/components/androidtv_remote/diagnostics.py
Normal 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,
|
||||
)
|
18
homeassistant/components/androidtv_remote/helpers.py
Normal file
18
homeassistant/components/androidtv_remote/helpers.py
Normal 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,
|
||||
)
|
13
homeassistant/components/androidtv_remote/manifest.json
Normal file
13
homeassistant/components/androidtv_remote/manifest.json
Normal 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."]
|
||||
}
|
154
homeassistant/components/androidtv_remote/remote.py
Normal file
154
homeassistant/components/androidtv_remote/remote.py
Normal 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
|
38
homeassistant/components/androidtv_remote/strings.json
Normal file
38
homeassistant/components/androidtv_remote/strings.json
Normal 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%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -39,6 +39,7 @@ FLOWS = {
|
|||
"ambient_station",
|
||||
"android_ip_webcam",
|
||||
"androidtv",
|
||||
"androidtv_remote",
|
||||
"anthemav",
|
||||
"apcupsd",
|
||||
"apple_tv",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -279,6 +279,11 @@ ZEROCONF = {
|
|||
"domain": "apple_tv",
|
||||
},
|
||||
],
|
||||
"_androidtvremote2._tcp.local.": [
|
||||
{
|
||||
"domain": "androidtv_remote",
|
||||
},
|
||||
],
|
||||
"_api._tcp.local.": [
|
||||
{
|
||||
"domain": "baf",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
1
tests/components/androidtv_remote/__init__.py
Normal file
1
tests/components/androidtv_remote/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the Android TV Remote integration."""
|
57
tests/components/androidtv_remote/conftest.py
Normal file
57
tests/components/androidtv_remote/conftest.py
Normal 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,
|
||||
)
|
|
@ -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',
|
||||
}),
|
||||
})
|
||||
# ---
|
835
tests/components/androidtv_remote/test_config_flow.py
Normal file
835
tests/components/androidtv_remote/test_config_flow.py
Normal 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
|
28
tests/components/androidtv_remote/test_diagnostics.py
Normal file
28
tests/components/androidtv_remote/test_diagnostics.py
Normal 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
|
||||
)
|
106
tests/components/androidtv_remote/test_init.py
Normal file
106
tests/components/androidtv_remote/test_init.py
Normal 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
|
219
tests/components/androidtv_remote/test_remote.py
Normal file
219
tests/components/androidtv_remote/test_remote.py
Normal 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")]
|
Loading…
Add table
Add a link
Reference in a new issue