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
|
/tests/components/android_ip_webcam/ @engrbm87
|
||||||
/homeassistant/components/androidtv/ @JeffLIrion @ollo69
|
/homeassistant/components/androidtv/ @JeffLIrion @ollo69
|
||||||
/tests/components/androidtv/ @JeffLIrion @ollo69
|
/tests/components/androidtv/ @JeffLIrion @ollo69
|
||||||
|
/homeassistant/components/androidtv_remote/ @tronikos
|
||||||
|
/tests/components/androidtv_remote/ @tronikos
|
||||||
/homeassistant/components/anthemav/ @hyralex
|
/homeassistant/components/anthemav/ @hyralex
|
||||||
/tests/components/anthemav/ @hyralex
|
/tests/components/anthemav/ @hyralex
|
||||||
/homeassistant/components/apache_kafka/ @bachya
|
/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",
|
"ambient_station",
|
||||||
"android_ip_webcam",
|
"android_ip_webcam",
|
||||||
"androidtv",
|
"androidtv",
|
||||||
|
"androidtv_remote",
|
||||||
"anthemav",
|
"anthemav",
|
||||||
"apcupsd",
|
"apcupsd",
|
||||||
"apple_tv",
|
"apple_tv",
|
||||||
|
|
|
@ -246,6 +246,12 @@
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
},
|
},
|
||||||
|
"androidtv_remote": {
|
||||||
|
"name": "Android TV Remote",
|
||||||
|
"integration_type": "device",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_push"
|
||||||
|
},
|
||||||
"anel_pwrctrl": {
|
"anel_pwrctrl": {
|
||||||
"name": "Anel NET-PwrCtrl",
|
"name": "Anel NET-PwrCtrl",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
|
|
@ -279,6 +279,11 @@ ZEROCONF = {
|
||||||
"domain": "apple_tv",
|
"domain": "apple_tv",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"_androidtvremote2._tcp.local.": [
|
||||||
|
{
|
||||||
|
"domain": "androidtv_remote",
|
||||||
|
},
|
||||||
|
],
|
||||||
"_api._tcp.local.": [
|
"_api._tcp.local.": [
|
||||||
{
|
{
|
||||||
"domain": "baf",
|
"domain": "baf",
|
||||||
|
|
|
@ -332,6 +332,9 @@ amcrest==1.9.7
|
||||||
# homeassistant.components.androidtv
|
# homeassistant.components.androidtv
|
||||||
androidtv[async]==0.0.70
|
androidtv[async]==0.0.70
|
||||||
|
|
||||||
|
# homeassistant.components.androidtv_remote
|
||||||
|
androidtvremote2==0.0.4
|
||||||
|
|
||||||
# homeassistant.components.anel_pwrctrl
|
# homeassistant.components.anel_pwrctrl
|
||||||
anel_pwrctrl-homeassistant==0.0.1.dev2
|
anel_pwrctrl-homeassistant==0.0.1.dev2
|
||||||
|
|
||||||
|
|
|
@ -307,6 +307,9 @@ ambiclimate==0.2.1
|
||||||
# homeassistant.components.androidtv
|
# homeassistant.components.androidtv
|
||||||
androidtv[async]==0.0.70
|
androidtv[async]==0.0.70
|
||||||
|
|
||||||
|
# homeassistant.components.androidtv_remote
|
||||||
|
androidtvremote2==0.0.4
|
||||||
|
|
||||||
# homeassistant.components.anthemav
|
# homeassistant.components.anthemav
|
||||||
anthemav==1.4.1
|
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