mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 03:07:37 +00:00
Make roku async (#35104)
* Update manifest.json * work on roku async. * Update config_flow.py * Update __init__.py * Update media_player.py * Update media_player.py * Update __init__.py * Update __init__.py * Update media_player.py * Update media_player.py * Update remote.py * Update test_media_player.py * Update test_media_player.py * Update test_config_flow.py * Update media_player.py * Update remote.py * Update config_flow.py * Update test_media_player.py * Update config_flow.py * Update test_config_flow.py
This commit is contained in:
parent
54584e970c
commit
3feb55a8e4
@ -1,22 +1,31 @@
|
|||||||
"""Support for Roku."""
|
"""Support for Roku."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from socket import gaierror as SocketGIAError
|
import logging
|
||||||
from typing import Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from requests.exceptions import RequestException
|
from rokuecp import Roku, RokuError
|
||||||
from roku import Roku, RokuException
|
from rokuecp.models import Device
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||||
from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN
|
from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN
|
||||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||||
from homeassistant.const import CONF_HOST
|
from homeassistant.const import ATTR_NAME, CONF_HOST
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.entity import Entity
|
||||||
|
from homeassistant.helpers.typing import HomeAssistantType
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN
|
from .const import (
|
||||||
|
ATTR_IDENTIFIERS,
|
||||||
|
ATTR_MANUFACTURER,
|
||||||
|
ATTR_MODEL,
|
||||||
|
ATTR_SOFTWARE_VERSION,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
@ -29,20 +38,10 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
|
|
||||||
PLATFORMS = [MEDIA_PLAYER_DOMAIN, REMOTE_DOMAIN]
|
PLATFORMS = [MEDIA_PLAYER_DOMAIN, REMOTE_DOMAIN]
|
||||||
SCAN_INTERVAL = timedelta(seconds=30)
|
SCAN_INTERVAL = timedelta(seconds=30)
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_roku_data(host: str) -> dict:
|
async def async_setup(hass: HomeAssistantType, config: Dict) -> bool:
|
||||||
"""Retrieve a Roku instance and version info for the device."""
|
|
||||||
roku = Roku(host)
|
|
||||||
roku_device_info = roku.device_info
|
|
||||||
|
|
||||||
return {
|
|
||||||
DATA_CLIENT: roku,
|
|
||||||
DATA_DEVICE_INFO: roku_device_info,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
|
|
||||||
"""Set up the Roku integration."""
|
"""Set up the Roku integration."""
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
|
||||||
@ -57,16 +56,15 @@ async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Roku from a config entry."""
|
"""Set up Roku from a config entry."""
|
||||||
try:
|
coordinator = RokuDataUpdateCoordinator(hass, host=entry.data[CONF_HOST])
|
||||||
roku_data = await hass.async_add_executor_job(
|
await coordinator.async_refresh()
|
||||||
get_roku_data, entry.data[CONF_HOST],
|
|
||||||
)
|
|
||||||
except (SocketGIAError, RequestException, RokuException) as exception:
|
|
||||||
raise ConfigEntryNotReady from exception
|
|
||||||
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = roku_data
|
if not coordinator.last_update_success:
|
||||||
|
raise ConfigEntryNotReady
|
||||||
|
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||||
|
|
||||||
for component in PLATFORMS:
|
for component in PLATFORMS:
|
||||||
hass.async_create_task(
|
hass.async_create_task(
|
||||||
@ -76,7 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
unload_ok = all(
|
unload_ok = all(
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
@ -91,3 +89,75 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
|
class RokuDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
|
"""Class to manage fetching Roku data."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, hass: HomeAssistantType, *, host: str,
|
||||||
|
):
|
||||||
|
"""Initialize global Roku data updater."""
|
||||||
|
self.roku = Roku(host=host, session=async_get_clientsession(hass))
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> Device:
|
||||||
|
"""Fetch data from Roku."""
|
||||||
|
try:
|
||||||
|
return await self.roku.update()
|
||||||
|
except RokuError as error:
|
||||||
|
raise UpdateFailed(f"Invalid response from API: {error}")
|
||||||
|
|
||||||
|
|
||||||
|
class RokuEntity(Entity):
|
||||||
|
"""Defines a base Roku entity."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, *, device_id: str, name: str, coordinator: RokuDataUpdateCoordinator
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the Roku entity."""
|
||||||
|
self._device_id = device_id
|
||||||
|
self._name = name
|
||||||
|
self.coordinator = coordinator
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return True if entity is available."""
|
||||||
|
return self.coordinator.last_update_success
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return the name of the entity."""
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def should_poll(self) -> bool:
|
||||||
|
"""Return the polling requirement of the entity."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Connect to dispatcher listening for entity data notifications."""
|
||||||
|
self.async_on_remove(
|
||||||
|
self.coordinator.async_add_listener(self.async_write_ha_state)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_update(self) -> None:
|
||||||
|
"""Update an Roku entity."""
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> Dict[str, Any]:
|
||||||
|
"""Return device information about this Roku device."""
|
||||||
|
if self._device_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
ATTR_IDENTIFIERS: {(DOMAIN, self._device_id)},
|
||||||
|
ATTR_NAME: self.name,
|
||||||
|
ATTR_MANUFACTURER: self.coordinator.data.info.brand,
|
||||||
|
ATTR_MODEL: self.coordinator.data.info.model_name,
|
||||||
|
ATTR_SOFTWARE_VERSION: self.coordinator.data.info.version,
|
||||||
|
}
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
"""Config flow for Roku."""
|
"""Config flow for Roku."""
|
||||||
import logging
|
import logging
|
||||||
from socket import gaierror as SocketGIAError
|
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from requests.exceptions import RequestException
|
from rokuecp import Roku, RokuError
|
||||||
from roku import Roku, RokuException
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.ssdp import (
|
from homeassistant.components.ssdp import (
|
||||||
@ -16,7 +14,8 @@ from homeassistant.components.ssdp import (
|
|||||||
from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow
|
from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow
|
||||||
from homeassistant.const import CONF_HOST, CONF_NAME
|
from homeassistant.const import CONF_HOST, CONF_NAME
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.typing import HomeAssistantType
|
||||||
|
|
||||||
from .const import DOMAIN # pylint: disable=unused-import
|
from .const import DOMAIN # pylint: disable=unused-import
|
||||||
|
|
||||||
@ -28,24 +27,18 @@ ERROR_UNKNOWN = "unknown"
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def validate_input(data: Dict) -> Dict:
|
async def validate_input(hass: HomeAssistantType, data: Dict) -> Dict:
|
||||||
"""Validate the user input allows us to connect.
|
"""Validate the user input allows us to connect.
|
||||||
|
|
||||||
Data has the keys from DATA_SCHEMA with values provided by the user.
|
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||||
"""
|
"""
|
||||||
|
session = async_get_clientsession(hass)
|
||||||
try:
|
roku = Roku(data[CONF_HOST], session=session)
|
||||||
roku = Roku(data["host"])
|
device = await roku.update()
|
||||||
device_info = roku.device_info
|
|
||||||
except (SocketGIAError, RequestException, RokuException) as exception:
|
|
||||||
raise CannotConnect from exception
|
|
||||||
except Exception as exception: # pylint: disable=broad-except
|
|
||||||
raise UnknownError from exception
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"title": data["host"],
|
"title": device.info.name,
|
||||||
"host": data["host"],
|
"serial_number": device.info.serial_number,
|
||||||
"serial_num": device_info.serial_num,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -55,6 +48,10 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
VERSION = 1
|
VERSION = 1
|
||||||
CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL
|
CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Set up the instance."""
|
||||||
|
self.discovery_info = {}
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _show_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]:
|
def _show_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]:
|
||||||
"""Show the form to the user."""
|
"""Show the form to the user."""
|
||||||
@ -78,16 +75,17 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
info = await self.hass.async_add_executor_job(validate_input, user_input)
|
info = await validate_input(self.hass, user_input)
|
||||||
except CannotConnect:
|
except RokuError:
|
||||||
|
_LOGGER.debug("Roku Error", exc_info=True)
|
||||||
errors["base"] = ERROR_CANNOT_CONNECT
|
errors["base"] = ERROR_CANNOT_CONNECT
|
||||||
return self._show_form(errors)
|
return self._show_form(errors)
|
||||||
except UnknownError:
|
except Exception: # pylint: disable=broad-except
|
||||||
_LOGGER.exception("Unknown error trying to connect")
|
_LOGGER.exception("Unknown error trying to connect")
|
||||||
return self.async_abort(reason=ERROR_UNKNOWN)
|
return self.async_abort(reason=ERROR_UNKNOWN)
|
||||||
|
|
||||||
await self.async_set_unique_id(info["serial_num"])
|
await self.async_set_unique_id(info["serial_number"])
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]})
|
||||||
|
|
||||||
return self.async_create_entry(title=info["title"], data=user_input)
|
return self.async_create_entry(title=info["title"], data=user_input)
|
||||||
|
|
||||||
@ -97,15 +95,24 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Handle a flow initialized by discovery."""
|
"""Handle a flow initialized by discovery."""
|
||||||
host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname
|
host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname
|
||||||
name = discovery_info[ATTR_UPNP_FRIENDLY_NAME]
|
name = discovery_info[ATTR_UPNP_FRIENDLY_NAME]
|
||||||
serial_num = discovery_info[ATTR_UPNP_SERIAL]
|
serial_number = discovery_info[ATTR_UPNP_SERIAL]
|
||||||
|
|
||||||
await self.async_set_unique_id(serial_num)
|
await self.async_set_unique_id(serial_number)
|
||||||
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||||
|
|
||||||
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
|
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
|
||||||
self.context.update(
|
self.context.update({"title_placeholders": {"name": name}})
|
||||||
{CONF_HOST: host, CONF_NAME: name, "title_placeholders": {"name": host}}
|
|
||||||
)
|
self.discovery_info.update({CONF_HOST: host, CONF_NAME: name})
|
||||||
|
|
||||||
|
try:
|
||||||
|
await validate_input(self.hass, self.discovery_info)
|
||||||
|
except RokuError:
|
||||||
|
_LOGGER.debug("Roku Error", exc_info=True)
|
||||||
|
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
_LOGGER.exception("Unknown error trying to connect")
|
||||||
|
return self.async_abort(reason=ERROR_UNKNOWN)
|
||||||
|
|
||||||
return await self.async_step_ssdp_confirm()
|
return await self.async_step_ssdp_confirm()
|
||||||
|
|
||||||
@ -114,30 +121,13 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Handle user-confirmation of discovered device."""
|
"""Handle user-confirmation of discovered device."""
|
||||||
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
|
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
|
||||||
name = self.context.get(CONF_NAME)
|
if user_input is None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="ssdp_confirm",
|
||||||
|
description_placeholders={"name": self.discovery_info[CONF_NAME]},
|
||||||
|
errors={},
|
||||||
|
)
|
||||||
|
|
||||||
if user_input is not None:
|
return self.async_create_entry(
|
||||||
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
|
title=self.discovery_info[CONF_NAME], data=self.discovery_info,
|
||||||
user_input[CONF_HOST] = self.context.get(CONF_HOST)
|
|
||||||
user_input[CONF_NAME] = name
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.hass.async_add_executor_job(validate_input, user_input)
|
|
||||||
return self.async_create_entry(title=name, data=user_input)
|
|
||||||
except CannotConnect:
|
|
||||||
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
|
|
||||||
except UnknownError:
|
|
||||||
_LOGGER.exception("Unknown error trying to connect")
|
|
||||||
return self.async_abort(reason=ERROR_UNKNOWN)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="ssdp_confirm", description_placeholders={"name": name},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CannotConnect(HomeAssistantError):
|
|
||||||
"""Error to indicate we cannot connect."""
|
|
||||||
|
|
||||||
|
|
||||||
class UnknownError(HomeAssistantError):
|
|
||||||
"""Error to indicate we encountered an unknown error."""
|
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
"""Constants for the Roku integration."""
|
"""Constants for the Roku integration."""
|
||||||
DOMAIN = "roku"
|
DOMAIN = "roku"
|
||||||
|
|
||||||
DATA_CLIENT = "client"
|
# Attributes
|
||||||
DATA_DEVICE_INFO = "device_info"
|
ATTR_IDENTIFIERS = "identifiers"
|
||||||
|
ATTR_MANUFACTURER = "manufacturer"
|
||||||
|
ATTR_MODEL = "model"
|
||||||
|
ATTR_SOFTWARE_VERSION = "sw_version"
|
||||||
|
|
||||||
|
# Default Values
|
||||||
DEFAULT_PORT = 8060
|
DEFAULT_PORT = 8060
|
||||||
DEFAULT_MANUFACTURER = "Roku"
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"domain": "roku",
|
"domain": "roku",
|
||||||
"name": "Roku",
|
"name": "Roku",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/roku",
|
"documentation": "https://www.home-assistant.io/integrations/roku",
|
||||||
"requirements": ["roku==4.1.0"],
|
"requirements": ["rokuecp==0.2.0"],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
"st": "roku:ecp",
|
"st": "roku:ecp",
|
||||||
|
@ -1,14 +1,10 @@
|
|||||||
"""Support for the Roku media player."""
|
"""Support for the Roku media player."""
|
||||||
import logging
|
import logging
|
||||||
|
from typing import List
|
||||||
from requests.exceptions import (
|
|
||||||
ConnectionError as RequestsConnectionError,
|
|
||||||
ReadTimeout as RequestsReadTimeout,
|
|
||||||
)
|
|
||||||
from roku import RokuException
|
|
||||||
|
|
||||||
from homeassistant.components.media_player import MediaPlayerEntity
|
from homeassistant.components.media_player import MediaPlayerEntity
|
||||||
from homeassistant.components.media_player.const import (
|
from homeassistant.components.media_player.const import (
|
||||||
|
MEDIA_TYPE_APP,
|
||||||
MEDIA_TYPE_CHANNEL,
|
MEDIA_TYPE_CHANNEL,
|
||||||
SUPPORT_NEXT_TRACK,
|
SUPPORT_NEXT_TRACK,
|
||||||
SUPPORT_PLAY,
|
SUPPORT_PLAY,
|
||||||
@ -22,7 +18,8 @@ from homeassistant.components.media_player.const import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import STATE_HOME, STATE_IDLE, STATE_PLAYING, STATE_STANDBY
|
from homeassistant.const import STATE_HOME, STATE_IDLE, STATE_PLAYING, STATE_STANDBY
|
||||||
|
|
||||||
from .const import DATA_CLIENT, DEFAULT_MANUFACTURER, DEFAULT_PORT, DOMAIN
|
from . import RokuDataUpdateCoordinator, RokuEntity
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -41,68 +38,48 @@ SUPPORT_ROKU = (
|
|||||||
|
|
||||||
async def async_setup_entry(hass, entry, async_add_entities):
|
async def async_setup_entry(hass, entry, async_add_entities):
|
||||||
"""Set up the Roku config entry."""
|
"""Set up the Roku config entry."""
|
||||||
roku = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT]
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
async_add_entities([RokuDevice(roku)], True)
|
unique_id = coordinator.data.info.serial_number
|
||||||
|
async_add_entities([RokuMediaPlayer(unique_id, coordinator)], True)
|
||||||
|
|
||||||
|
|
||||||
class RokuDevice(MediaPlayerEntity):
|
class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
|
||||||
"""Representation of a Roku device on the network."""
|
"""Representation of a Roku media player on the network."""
|
||||||
|
|
||||||
def __init__(self, roku):
|
def __init__(self, unique_id: str, coordinator: RokuDataUpdateCoordinator) -> None:
|
||||||
"""Initialize the Roku device."""
|
"""Initialize the Roku device."""
|
||||||
self.roku = roku
|
super().__init__(
|
||||||
self.ip_address = roku.host
|
coordinator=coordinator,
|
||||||
self.channels = []
|
name=coordinator.data.info.name,
|
||||||
self.current_app = None
|
device_id=unique_id,
|
||||||
self._available = False
|
)
|
||||||
self._device_info = {}
|
|
||||||
self._power_state = "Unknown"
|
|
||||||
|
|
||||||
def update(self):
|
self._unique_id = unique_id
|
||||||
"""Retrieve latest state."""
|
|
||||||
try:
|
|
||||||
self._device_info = self.roku.device_info
|
|
||||||
self._power_state = self.roku.power_state
|
|
||||||
self.ip_address = self.roku.host
|
|
||||||
self.channels = self.get_source_list()
|
|
||||||
self.current_app = self.roku.current_app
|
|
||||||
self._available = True
|
|
||||||
except (RequestsConnectionError, RequestsReadTimeout, RokuException):
|
|
||||||
self._available = False
|
|
||||||
|
|
||||||
def get_source_list(self):
|
|
||||||
"""Get the list of applications to be used as sources."""
|
|
||||||
return ["Home"] + sorted(channel.name for channel in self.roku.apps)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def should_poll(self):
|
def unique_id(self) -> str:
|
||||||
"""Device should be polled."""
|
"""Return the unique ID for this entity."""
|
||||||
return True
|
return self._unique_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def state(self) -> str:
|
||||||
"""Return the name of the device."""
|
|
||||||
if self._device_info.user_device_name:
|
|
||||||
return self._device_info.user_device_name
|
|
||||||
|
|
||||||
return f"Roku {self._device_info.serial_num}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def state(self):
|
|
||||||
"""Return the state of the device."""
|
"""Return the state of the device."""
|
||||||
if self._power_state == "Off":
|
if self.coordinator.data.state.standby:
|
||||||
return STATE_STANDBY
|
return STATE_STANDBY
|
||||||
|
|
||||||
if self.current_app is None:
|
if self.coordinator.data.app is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if self.current_app.name == "Power Saver" or self.current_app.is_screensaver:
|
if (
|
||||||
|
self.coordinator.data.app.name == "Power Saver"
|
||||||
|
or self.coordinator.data.app.screensaver
|
||||||
|
):
|
||||||
return STATE_IDLE
|
return STATE_IDLE
|
||||||
|
|
||||||
if self.current_app.name == "Roku":
|
if self.coordinator.data.app.name == "Roku":
|
||||||
return STATE_HOME
|
return STATE_HOME
|
||||||
|
|
||||||
if self.current_app.name is not None:
|
if self.coordinator.data.app.name is not None:
|
||||||
return STATE_PLAYING
|
return STATE_PLAYING
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@ -113,109 +90,108 @@ class RokuDevice(MediaPlayerEntity):
|
|||||||
return SUPPORT_ROKU
|
return SUPPORT_ROKU
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self):
|
def media_content_type(self) -> str:
|
||||||
"""Return if able to retrieve information from device or not."""
|
|
||||||
return self._available
|
|
||||||
|
|
||||||
@property
|
|
||||||
def unique_id(self):
|
|
||||||
"""Return a unique, Home Assistant friendly identifier for this entity."""
|
|
||||||
return self._device_info.serial_num
|
|
||||||
|
|
||||||
@property
|
|
||||||
def device_info(self):
|
|
||||||
"""Return device specific attributes."""
|
|
||||||
return {
|
|
||||||
"name": self.name,
|
|
||||||
"identifiers": {(DOMAIN, self.unique_id)},
|
|
||||||
"manufacturer": DEFAULT_MANUFACTURER,
|
|
||||||
"model": self._device_info.model_num,
|
|
||||||
"sw_version": self._device_info.software_version,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def media_content_type(self):
|
|
||||||
"""Content type of current playing media."""
|
"""Content type of current playing media."""
|
||||||
if self.current_app is None or self.current_app.name in ("Power Saver", "Roku"):
|
if self.app_id is None or self.app_name in ("Power Saver", "Roku"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return MEDIA_TYPE_CHANNEL
|
if self.app_id == "tvinput.dtv" and self.coordinator.data.channel is not None:
|
||||||
|
return MEDIA_TYPE_CHANNEL
|
||||||
|
|
||||||
|
return MEDIA_TYPE_APP
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def media_image_url(self):
|
def media_image_url(self) -> str:
|
||||||
"""Image url of current playing media."""
|
"""Image url of current playing media."""
|
||||||
if self.current_app is None or self.current_app.name in ("Power Saver", "Roku"):
|
if self.app_id is None or self.app_name in ("Power Saver", "Roku"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if self.current_app.id is None:
|
return self.coordinator.roku.app_icon_url(self.app_id)
|
||||||
return None
|
|
||||||
|
|
||||||
return (
|
|
||||||
f"http://{self.ip_address}:{DEFAULT_PORT}/query/icon/{self.current_app.id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def app_name(self):
|
def app_name(self) -> str:
|
||||||
"""Name of the current running app."""
|
"""Name of the current running app."""
|
||||||
if self.current_app is not None:
|
if self.coordinator.data.app is not None:
|
||||||
return self.current_app.name
|
return self.coordinator.data.app.name
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def app_id(self):
|
def app_id(self) -> str:
|
||||||
"""Return the ID of the current running app."""
|
"""Return the ID of the current running app."""
|
||||||
if self.current_app is not None:
|
if self.coordinator.data.app is not None:
|
||||||
return self.current_app.id
|
return self.coordinator.data.app.app_id
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def source(self):
|
def media_channel(self):
|
||||||
|
"""Return the TV channel currently tuned."""
|
||||||
|
if self.app_id != "tvinput.dtv" or self.coordinator.data.channel is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.coordinator.data.channel.name is not None:
|
||||||
|
return f"{self.coordinator.data.channel.name} ({self.coordinator.data.channel.number})"
|
||||||
|
|
||||||
|
return self.coordinator.data.channel.number
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_title(self):
|
||||||
|
"""Return the title of current playing media."""
|
||||||
|
if self.app_id != "tvinput.dtv" or self.coordinator.data.channel is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.coordinator.data.channel.program_title is not None:
|
||||||
|
return self.coordinator.data.channel.program_title
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self) -> str:
|
||||||
"""Return the current input source."""
|
"""Return the current input source."""
|
||||||
if self.current_app is not None:
|
if self.coordinator.data.app is not None:
|
||||||
return self.current_app.name
|
return self.coordinator.data.app.name
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def source_list(self):
|
def source_list(self) -> List:
|
||||||
"""List of available input sources."""
|
"""List of available input sources."""
|
||||||
return self.channels
|
return ["Home"] + sorted(app.name for app in self.coordinator.data.apps)
|
||||||
|
|
||||||
def turn_on(self):
|
async def async_turn_on(self) -> None:
|
||||||
"""Turn on the Roku."""
|
"""Turn on the Roku."""
|
||||||
self.roku.poweron()
|
await self.coordinator.roku.remote("poweron")
|
||||||
|
|
||||||
def turn_off(self):
|
async def async_turn_off(self) -> None:
|
||||||
"""Turn off the Roku."""
|
"""Turn off the Roku."""
|
||||||
self.roku.poweroff()
|
await self.coordinator.roku.remote("poweroff")
|
||||||
|
|
||||||
def media_play_pause(self):
|
async def async_media_play_pause(self) -> None:
|
||||||
"""Send play/pause command."""
|
"""Send play/pause command."""
|
||||||
if self.current_app is not None:
|
await self.coordinator.roku.remote("play")
|
||||||
self.roku.play()
|
|
||||||
|
|
||||||
def media_previous_track(self):
|
async def async_media_previous_track(self) -> None:
|
||||||
"""Send previous track command."""
|
"""Send previous track command."""
|
||||||
if self.current_app is not None:
|
await self.coordinator.roku.remote("reverse")
|
||||||
self.roku.reverse()
|
|
||||||
|
|
||||||
def media_next_track(self):
|
async def async_media_next_track(self) -> None:
|
||||||
"""Send next track command."""
|
"""Send next track command."""
|
||||||
if self.current_app is not None:
|
await self.coordinator.roku.remote("forward")
|
||||||
self.roku.forward()
|
|
||||||
|
|
||||||
def mute_volume(self, mute):
|
async def async_mute_volume(self, mute) -> None:
|
||||||
"""Mute the volume."""
|
"""Mute the volume."""
|
||||||
if self.current_app is not None:
|
await self.coordinator.roku.remote("volume_mute")
|
||||||
self.roku.volume_mute()
|
|
||||||
|
|
||||||
def volume_up(self):
|
async def async_volume_up(self) -> None:
|
||||||
"""Volume up media player."""
|
"""Volume up media player."""
|
||||||
if self.current_app is not None:
|
await self.coordinator.roku.remote("volume_up")
|
||||||
self.roku.volume_up()
|
|
||||||
|
|
||||||
def volume_down(self):
|
async def async_volume_down(self) -> None:
|
||||||
"""Volume down media player."""
|
"""Volume down media player."""
|
||||||
if self.current_app is not None:
|
await self.coordinator.roku.remote("volume_down")
|
||||||
self.roku.volume_down()
|
|
||||||
|
|
||||||
def play_media(self, media_type, media_id, **kwargs):
|
async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None:
|
||||||
"""Tune to channel."""
|
"""Tune to channel."""
|
||||||
if media_type != MEDIA_TYPE_CHANNEL:
|
if media_type != MEDIA_TYPE_CHANNEL:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
@ -225,16 +201,16 @@ class RokuDevice(MediaPlayerEntity):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.current_app is not None:
|
await self.coordinator.roku.tune(media_id)
|
||||||
self.roku.launch(self.roku["tvinput.dtv"], {"ch": media_id})
|
|
||||||
|
|
||||||
def select_source(self, source):
|
async def async_select_source(self, source: str) -> None:
|
||||||
"""Select input source."""
|
"""Select input source."""
|
||||||
if self.current_app is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if source == "Home":
|
if source == "Home":
|
||||||
self.roku.home()
|
await self.coordinator.roku.remote("home")
|
||||||
else:
|
|
||||||
channel = self.roku[source]
|
appl = next(
|
||||||
channel.launch()
|
(app for app in self.coordinator.data.apps if app.name == source), None
|
||||||
|
)
|
||||||
|
|
||||||
|
if appl is not None:
|
||||||
|
await self.coordinator.roku.launch(appl.app_id)
|
||||||
|
@ -1,17 +1,12 @@
|
|||||||
"""Support for the Roku remote."""
|
"""Support for the Roku remote."""
|
||||||
from typing import Callable, List
|
from typing import Callable, List
|
||||||
|
|
||||||
from requests.exceptions import (
|
|
||||||
ConnectionError as RequestsConnectionError,
|
|
||||||
ReadTimeout as RequestsReadTimeout,
|
|
||||||
)
|
|
||||||
from roku import RokuException
|
|
||||||
|
|
||||||
from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity
|
from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.helpers.typing import HomeAssistantType
|
from homeassistant.helpers.typing import HomeAssistantType
|
||||||
|
|
||||||
from .const import DATA_CLIENT, DEFAULT_MANUFACTURER, DOMAIN
|
from . import RokuDataUpdateCoordinator, RokuEntity
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@ -20,75 +15,38 @@ async def async_setup_entry(
|
|||||||
async_add_entities: Callable[[List, bool], None],
|
async_add_entities: Callable[[List, bool], None],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Load Roku remote based on a config entry."""
|
"""Load Roku remote based on a config entry."""
|
||||||
roku = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT]
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
async_add_entities([RokuRemote(roku)], True)
|
unique_id = coordinator.data.info.serial_number
|
||||||
|
async_add_entities([RokuRemote(unique_id, coordinator)], True)
|
||||||
|
|
||||||
|
|
||||||
class RokuRemote(RemoteEntity):
|
class RokuRemote(RokuEntity, RemoteEntity):
|
||||||
"""Device that sends commands to an Roku."""
|
"""Device that sends commands to an Roku."""
|
||||||
|
|
||||||
def __init__(self, roku):
|
def __init__(self, unique_id: str, coordinator: RokuDataUpdateCoordinator) -> None:
|
||||||
"""Initialize the Roku device."""
|
"""Initialize the Roku device."""
|
||||||
self.roku = roku
|
super().__init__(
|
||||||
self._available = False
|
device_id=unique_id,
|
||||||
self._device_info = {}
|
name=coordinator.data.info.name,
|
||||||
|
coordinator=coordinator,
|
||||||
|
)
|
||||||
|
|
||||||
def update(self):
|
self._unique_id = unique_id
|
||||||
"""Retrieve latest state."""
|
|
||||||
if not self.enabled:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._device_info = self.roku.device_info
|
|
||||||
self._available = True
|
|
||||||
except (RequestsConnectionError, RequestsReadTimeout, RokuException):
|
|
||||||
self._available = False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def unique_id(self) -> str:
|
||||||
"""Return the name of the device."""
|
"""Return the unique ID for this entity."""
|
||||||
if self._device_info.user_device_name:
|
return self._unique_id
|
||||||
return self._device_info.user_device_name
|
|
||||||
return f"Roku {self._device_info.serial_num}"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self):
|
def is_on(self) -> bool:
|
||||||
"""Return if able to retrieve information from device or not."""
|
|
||||||
return self._available
|
|
||||||
|
|
||||||
@property
|
|
||||||
def unique_id(self):
|
|
||||||
"""Return a unique ID."""
|
|
||||||
return self._device_info.serial_num
|
|
||||||
|
|
||||||
@property
|
|
||||||
def device_info(self):
|
|
||||||
"""Return device specific attributes."""
|
|
||||||
return {
|
|
||||||
"name": self.name,
|
|
||||||
"identifiers": {(DOMAIN, self.unique_id)},
|
|
||||||
"manufacturer": DEFAULT_MANUFACTURER,
|
|
||||||
"model": self._device_info.model_num,
|
|
||||||
"sw_version": self._device_info.software_version,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_on(self):
|
|
||||||
"""Return true if device is on."""
|
"""Return true if device is on."""
|
||||||
return True
|
return not self.coordinator.data.state.standby
|
||||||
|
|
||||||
@property
|
async def async_send_command(self, command: List, **kwargs) -> None:
|
||||||
def should_poll(self):
|
|
||||||
"""No polling needed for Roku."""
|
|
||||||
return False
|
|
||||||
|
|
||||||
def send_command(self, command, **kwargs):
|
|
||||||
"""Send a command to one device."""
|
"""Send a command to one device."""
|
||||||
num_repeats = kwargs[ATTR_NUM_REPEATS]
|
num_repeats = kwargs[ATTR_NUM_REPEATS]
|
||||||
|
|
||||||
for _ in range(num_repeats):
|
for _ in range(num_repeats):
|
||||||
for single_command in command:
|
for single_command in command:
|
||||||
if not hasattr(self.roku, single_command):
|
await self.coordinator.roku.remote(single_command)
|
||||||
continue
|
|
||||||
|
|
||||||
getattr(self.roku, single_command)()
|
|
||||||
|
@ -1850,7 +1850,7 @@ rjpl==0.3.5
|
|||||||
rocketchat-API==0.6.1
|
rocketchat-API==0.6.1
|
||||||
|
|
||||||
# homeassistant.components.roku
|
# homeassistant.components.roku
|
||||||
roku==4.1.0
|
rokuecp==0.2.0
|
||||||
|
|
||||||
# homeassistant.components.roomba
|
# homeassistant.components.roomba
|
||||||
roombapy==1.5.3
|
roombapy==1.5.3
|
||||||
|
@ -735,7 +735,7 @@ rflink==0.0.52
|
|||||||
ring_doorbell==0.6.0
|
ring_doorbell==0.6.0
|
||||||
|
|
||||||
# homeassistant.components.roku
|
# homeassistant.components.roku
|
||||||
roku==4.1.0
|
rokuecp==0.2.0
|
||||||
|
|
||||||
# homeassistant.components.roomba
|
# homeassistant.components.roomba
|
||||||
roombapy==1.5.3
|
roombapy==1.5.3
|
||||||
|
@ -1,11 +1,18 @@
|
|||||||
"""Tests for the Roku component."""
|
"""Tests for the Roku component."""
|
||||||
from requests_mock import Mocker
|
import re
|
||||||
|
from socket import gaierror as SocketGIAError
|
||||||
|
|
||||||
from homeassistant.components.roku.const import DOMAIN
|
from homeassistant.components.roku.const import DOMAIN
|
||||||
|
from homeassistant.components.ssdp import (
|
||||||
|
ATTR_SSDP_LOCATION,
|
||||||
|
ATTR_UPNP_FRIENDLY_NAME,
|
||||||
|
ATTR_UPNP_SERIAL,
|
||||||
|
)
|
||||||
from homeassistant.const import CONF_HOST
|
from homeassistant.const import CONF_HOST
|
||||||
from homeassistant.helpers.typing import HomeAssistantType
|
from homeassistant.helpers.typing import HomeAssistantType
|
||||||
|
|
||||||
from tests.common import MockConfigEntry, load_fixture
|
from tests.common import MockConfigEntry, load_fixture
|
||||||
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||||
|
|
||||||
HOST = "192.168.1.160"
|
HOST = "192.168.1.160"
|
||||||
NAME = "Roku 3"
|
NAME = "Roku 3"
|
||||||
@ -13,38 +20,132 @@ SSDP_LOCATION = "http://192.168.1.160/"
|
|||||||
UPNP_FRIENDLY_NAME = "My Roku 3"
|
UPNP_FRIENDLY_NAME = "My Roku 3"
|
||||||
UPNP_SERIAL = "1GU48T017973"
|
UPNP_SERIAL = "1GU48T017973"
|
||||||
|
|
||||||
|
MOCK_SSDP_DISCOVERY_INFO = {
|
||||||
|
ATTR_SSDP_LOCATION: SSDP_LOCATION,
|
||||||
|
ATTR_UPNP_FRIENDLY_NAME: UPNP_FRIENDLY_NAME,
|
||||||
|
ATTR_UPNP_SERIAL: UPNP_SERIAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def mock_connection(
|
def mock_connection(
|
||||||
requests_mocker: Mocker, device: str = "roku3", app: str = "roku", host: str = HOST,
|
aioclient_mock: AiohttpClientMocker,
|
||||||
|
device: str = "roku3",
|
||||||
|
app: str = "roku",
|
||||||
|
host: str = HOST,
|
||||||
|
power: bool = True,
|
||||||
|
error: bool = False,
|
||||||
|
server_error: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Mock the Roku connection."""
|
"""Mock the Roku connection."""
|
||||||
roku_url = f"http://{host}:8060"
|
roku_url = f"http://{host}:8060"
|
||||||
|
|
||||||
requests_mocker.get(
|
if error:
|
||||||
|
mock_connection_error(
|
||||||
|
aioclient_mock=aioclient_mock, device=device, app=app, host=host
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if server_error:
|
||||||
|
mock_connection_server_error(
|
||||||
|
aioclient_mock=aioclient_mock, device=device, app=app, host=host
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
info_fixture = f"roku/{device}-device-info.xml"
|
||||||
|
if not power:
|
||||||
|
info_fixture = f"roku/{device}-device-info-power-off.xml"
|
||||||
|
|
||||||
|
aioclient_mock.get(
|
||||||
f"{roku_url}/query/device-info",
|
f"{roku_url}/query/device-info",
|
||||||
text=load_fixture(f"roku/{device}-device-info.xml"),
|
text=load_fixture(info_fixture),
|
||||||
|
headers={"Content-Type": "application/xml"},
|
||||||
)
|
)
|
||||||
|
|
||||||
apps_fixture = "roku/apps.xml"
|
apps_fixture = "roku/apps.xml"
|
||||||
if device == "rokutv":
|
if device == "rokutv":
|
||||||
apps_fixture = "roku/apps-tv.xml"
|
apps_fixture = "roku/apps-tv.xml"
|
||||||
|
|
||||||
requests_mocker.get(
|
aioclient_mock.get(
|
||||||
f"{roku_url}/query/apps", text=load_fixture(apps_fixture),
|
f"{roku_url}/query/apps",
|
||||||
|
text=load_fixture(apps_fixture),
|
||||||
|
headers={"Content-Type": "application/xml"},
|
||||||
)
|
)
|
||||||
|
|
||||||
requests_mocker.get(
|
aioclient_mock.get(
|
||||||
f"{roku_url}/query/active-app", text=load_fixture(f"roku/active-app-{app}.xml"),
|
f"{roku_url}/query/active-app",
|
||||||
|
text=load_fixture(f"roku/active-app-{app}.xml"),
|
||||||
|
headers={"Content-Type": "application/xml"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
aioclient_mock.get(
|
||||||
|
f"{roku_url}/query/tv-active-channel",
|
||||||
|
text=load_fixture("roku/rokutv-tv-active-channel.xml"),
|
||||||
|
headers={"Content-Type": "application/xml"},
|
||||||
|
)
|
||||||
|
|
||||||
|
aioclient_mock.get(
|
||||||
|
f"{roku_url}/query/tv-channels",
|
||||||
|
text=load_fixture("roku/rokutv-tv-channels.xml"),
|
||||||
|
headers={"Content-Type": "application/xml"},
|
||||||
|
)
|
||||||
|
|
||||||
|
aioclient_mock.post(
|
||||||
|
re.compile(f"{roku_url}/keypress/.*"), text="OK",
|
||||||
|
)
|
||||||
|
|
||||||
|
aioclient_mock.post(
|
||||||
|
re.compile(f"{roku_url}/launch/.*"), text="OK",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mock_connection_error(
|
||||||
|
aioclient_mock: AiohttpClientMocker,
|
||||||
|
device: str = "roku3",
|
||||||
|
app: str = "roku",
|
||||||
|
host: str = HOST,
|
||||||
|
) -> None:
|
||||||
|
"""Mock the Roku connection error."""
|
||||||
|
roku_url = f"http://{host}:8060"
|
||||||
|
|
||||||
|
aioclient_mock.get(f"{roku_url}/query/device-info", exc=SocketGIAError)
|
||||||
|
aioclient_mock.get(f"{roku_url}/query/apps", exc=SocketGIAError)
|
||||||
|
aioclient_mock.get(f"{roku_url}/query/active-app", exc=SocketGIAError)
|
||||||
|
aioclient_mock.get(f"{roku_url}/query/tv-active-channel", exc=SocketGIAError)
|
||||||
|
aioclient_mock.get(f"{roku_url}/query/tv-channels", exc=SocketGIAError)
|
||||||
|
|
||||||
|
aioclient_mock.post(re.compile(f"{roku_url}/keypress/.*"), exc=SocketGIAError)
|
||||||
|
aioclient_mock.post(re.compile(f"{roku_url}/launch/.*"), exc=SocketGIAError)
|
||||||
|
|
||||||
|
|
||||||
|
def mock_connection_server_error(
|
||||||
|
aioclient_mock: AiohttpClientMocker,
|
||||||
|
device: str = "roku3",
|
||||||
|
app: str = "roku",
|
||||||
|
host: str = HOST,
|
||||||
|
) -> None:
|
||||||
|
"""Mock the Roku server error."""
|
||||||
|
roku_url = f"http://{host}:8060"
|
||||||
|
|
||||||
|
aioclient_mock.get(f"{roku_url}/query/device-info", status=500)
|
||||||
|
aioclient_mock.get(f"{roku_url}/query/apps", status=500)
|
||||||
|
aioclient_mock.get(f"{roku_url}/query/active-app", status=500)
|
||||||
|
aioclient_mock.get(f"{roku_url}/query/tv-active-channel", status=500)
|
||||||
|
aioclient_mock.get(f"{roku_url}/query/tv-channels", status=500)
|
||||||
|
|
||||||
|
aioclient_mock.post(re.compile(f"{roku_url}/keypress/.*"), status=500)
|
||||||
|
aioclient_mock.post(re.compile(f"{roku_url}/launch/.*"), status=500)
|
||||||
|
|
||||||
|
|
||||||
async def setup_integration(
|
async def setup_integration(
|
||||||
hass: HomeAssistantType,
|
hass: HomeAssistantType,
|
||||||
requests_mocker: Mocker,
|
aioclient_mock: AiohttpClientMocker,
|
||||||
device: str = "roku3",
|
device: str = "roku3",
|
||||||
app: str = "roku",
|
app: str = "roku",
|
||||||
host: str = HOST,
|
host: str = HOST,
|
||||||
unique_id: str = UPNP_SERIAL,
|
unique_id: str = UPNP_SERIAL,
|
||||||
|
error: bool = False,
|
||||||
|
power: bool = True,
|
||||||
|
server_error: bool = False,
|
||||||
skip_entry_setup: bool = False,
|
skip_entry_setup: bool = False,
|
||||||
) -> MockConfigEntry:
|
) -> MockConfigEntry:
|
||||||
"""Set up the Roku integration in Home Assistant."""
|
"""Set up the Roku integration in Home Assistant."""
|
||||||
@ -53,7 +154,15 @@ async def setup_integration(
|
|||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
if not skip_entry_setup:
|
if not skip_entry_setup:
|
||||||
mock_connection(requests_mocker, device, app=app, host=host)
|
mock_connection(
|
||||||
|
aioclient_mock,
|
||||||
|
device,
|
||||||
|
app=app,
|
||||||
|
host=host,
|
||||||
|
error=error,
|
||||||
|
power=power,
|
||||||
|
server_error=server_error,
|
||||||
|
)
|
||||||
await hass.config_entries.async_setup(entry.entry_id)
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
@ -1,16 +1,5 @@
|
|||||||
"""Test the Roku config flow."""
|
"""Test the Roku config flow."""
|
||||||
from socket import gaierror as SocketGIAError
|
|
||||||
|
|
||||||
from requests.exceptions import RequestException
|
|
||||||
from requests_mock import Mocker
|
|
||||||
from roku import RokuException
|
|
||||||
|
|
||||||
from homeassistant.components.roku.const import DOMAIN
|
from homeassistant.components.roku.const import DOMAIN
|
||||||
from homeassistant.components.ssdp import (
|
|
||||||
ATTR_SSDP_LOCATION,
|
|
||||||
ATTR_UPNP_FRIENDLY_NAME,
|
|
||||||
ATTR_UPNP_SERIAL,
|
|
||||||
)
|
|
||||||
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP, SOURCE_USER
|
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP, SOURCE_USER
|
||||||
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE
|
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE
|
||||||
from homeassistant.data_entry_flow import (
|
from homeassistant.data_entry_flow import (
|
||||||
@ -24,19 +13,20 @@ from homeassistant.setup import async_setup_component
|
|||||||
from tests.async_mock import patch
|
from tests.async_mock import patch
|
||||||
from tests.components.roku import (
|
from tests.components.roku import (
|
||||||
HOST,
|
HOST,
|
||||||
SSDP_LOCATION,
|
MOCK_SSDP_DISCOVERY_INFO,
|
||||||
UPNP_FRIENDLY_NAME,
|
UPNP_FRIENDLY_NAME,
|
||||||
UPNP_SERIAL,
|
|
||||||
mock_connection,
|
mock_connection,
|
||||||
setup_integration,
|
setup_integration,
|
||||||
)
|
)
|
||||||
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||||
|
|
||||||
|
|
||||||
async def test_duplicate_error(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
async def test_duplicate_error(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
"""Test that errors are shown when duplicates are added."""
|
"""Test that errors are shown when duplicates are added."""
|
||||||
await setup_integration(hass, requests_mock, skip_entry_setup=True)
|
await setup_integration(hass, aioclient_mock, skip_entry_setup=True)
|
||||||
|
mock_connection(aioclient_mock)
|
||||||
mock_connection(requests_mock)
|
|
||||||
|
|
||||||
user_input = {CONF_HOST: HOST}
|
user_input = {CONF_HOST: HOST}
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
@ -54,11 +44,7 @@ async def test_duplicate_error(hass: HomeAssistantType, requests_mock: Mocker) -
|
|||||||
assert result["type"] == RESULT_TYPE_ABORT
|
assert result["type"] == RESULT_TYPE_ABORT
|
||||||
assert result["reason"] == "already_configured"
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
discovery_info = {
|
discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
|
||||||
ATTR_UPNP_FRIENDLY_NAME: UPNP_FRIENDLY_NAME,
|
|
||||||
ATTR_SSDP_LOCATION: SSDP_LOCATION,
|
|
||||||
ATTR_UPNP_SERIAL: UPNP_SERIAL,
|
|
||||||
}
|
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info
|
DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info
|
||||||
)
|
)
|
||||||
@ -67,11 +53,12 @@ async def test_duplicate_error(hass: HomeAssistantType, requests_mock: Mocker) -
|
|||||||
assert result["reason"] == "already_configured"
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
async def test_form(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
async def test_form(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
"""Test the user step."""
|
"""Test the user step."""
|
||||||
await async_setup_component(hass, "persistent_notification", {})
|
await async_setup_component(hass, "persistent_notification", {})
|
||||||
|
mock_connection(aioclient_mock)
|
||||||
mock_connection(requests_mock)
|
|
||||||
|
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
|
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
|
||||||
@ -90,7 +77,7 @@ async def test_form(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
|
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||||
assert result["title"] == HOST
|
assert result["title"] == UPNP_FRIENDLY_NAME
|
||||||
|
|
||||||
assert result["data"]
|
assert result["data"]
|
||||||
assert result["data"][CONF_HOST] == HOST
|
assert result["data"][CONF_HOST] == HOST
|
||||||
@ -100,70 +87,23 @@ async def test_form(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
|||||||
assert len(mock_setup_entry.mock_calls) == 1
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_form_cannot_connect(hass: HomeAssistantType) -> None:
|
async def test_form_cannot_connect(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
"""Test we handle cannot connect roku error."""
|
"""Test we handle cannot connect roku error."""
|
||||||
|
mock_connection(aioclient_mock, error=True)
|
||||||
|
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
|
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
result = await hass.config_entries.flow.async_configure(
|
||||||
"homeassistant.components.roku.config_flow.Roku._call",
|
flow_id=result["flow_id"], user_input={CONF_HOST: HOST}
|
||||||
side_effect=RokuException,
|
|
||||||
) as mock_validate_input:
|
|
||||||
result = await hass.config_entries.flow.async_configure(
|
|
||||||
flow_id=result["flow_id"], user_input={CONF_HOST: HOST}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["type"] == RESULT_TYPE_FORM
|
|
||||||
assert result["errors"] == {"base": "cannot_connect"}
|
|
||||||
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
assert len(mock_validate_input.mock_calls) == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_form_cannot_connect_request(hass: HomeAssistantType) -> None:
|
|
||||||
"""Test we handle cannot connect request error."""
|
|
||||||
result = await hass.config_entries.flow.async_init(
|
|
||||||
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
user_input = {CONF_HOST: HOST}
|
|
||||||
with patch(
|
|
||||||
"homeassistant.components.roku.config_flow.Roku._call",
|
|
||||||
side_effect=RequestException,
|
|
||||||
) as mock_validate_input:
|
|
||||||
result = await hass.config_entries.flow.async_configure(
|
|
||||||
flow_id=result["flow_id"], user_input=user_input
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["type"] == RESULT_TYPE_FORM
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
assert result["errors"] == {"base": "cannot_connect"}
|
assert result["errors"] == {"base": "cannot_connect"}
|
||||||
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
assert len(mock_validate_input.mock_calls) == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_form_cannot_connect_socket(hass: HomeAssistantType) -> None:
|
|
||||||
"""Test we handle cannot connect socket error."""
|
|
||||||
result = await hass.config_entries.flow.async_init(
|
|
||||||
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
|
|
||||||
)
|
|
||||||
|
|
||||||
user_input = {CONF_HOST: HOST}
|
|
||||||
with patch(
|
|
||||||
"homeassistant.components.roku.config_flow.Roku._call",
|
|
||||||
side_effect=SocketGIAError,
|
|
||||||
) as mock_validate_input:
|
|
||||||
result = await hass.config_entries.flow.async_configure(
|
|
||||||
flow_id=result["flow_id"], user_input=user_input
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["type"] == RESULT_TYPE_FORM
|
|
||||||
assert result["errors"] == {"base": "cannot_connect"}
|
|
||||||
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
assert len(mock_validate_input.mock_calls) == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_form_unknown_error(hass: HomeAssistantType) -> None:
|
async def test_form_unknown_error(hass: HomeAssistantType) -> None:
|
||||||
"""Test we handle unknown error."""
|
"""Test we handle unknown error."""
|
||||||
@ -173,7 +113,7 @@ async def test_form_unknown_error(hass: HomeAssistantType) -> None:
|
|||||||
|
|
||||||
user_input = {CONF_HOST: HOST}
|
user_input = {CONF_HOST: HOST}
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.roku.config_flow.Roku._call", side_effect=Exception,
|
"homeassistant.components.roku.config_flow.Roku.update", side_effect=Exception,
|
||||||
) as mock_validate_input:
|
) as mock_validate_input:
|
||||||
result = await hass.config_entries.flow.async_configure(
|
result = await hass.config_entries.flow.async_configure(
|
||||||
flow_id=result["flow_id"], user_input=user_input
|
flow_id=result["flow_id"], user_input=user_input
|
||||||
@ -186,9 +126,11 @@ async def test_form_unknown_error(hass: HomeAssistantType) -> None:
|
|||||||
assert len(mock_validate_input.mock_calls) == 1
|
assert len(mock_validate_input.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_import(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
async def test_import(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
"""Test the import step."""
|
"""Test the import step."""
|
||||||
mock_connection(requests_mock)
|
mock_connection(aioclient_mock)
|
||||||
|
|
||||||
user_input = {CONF_HOST: HOST}
|
user_input = {CONF_HOST: HOST}
|
||||||
with patch(
|
with patch(
|
||||||
@ -201,7 +143,7 @@ async def test_import(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
|
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||||
assert result["title"] == HOST
|
assert result["title"] == UPNP_FRIENDLY_NAME
|
||||||
|
|
||||||
assert result["data"]
|
assert result["data"]
|
||||||
assert result["data"][CONF_HOST] == HOST
|
assert result["data"][CONF_HOST] == HOST
|
||||||
@ -211,15 +153,44 @@ async def test_import(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
|||||||
assert len(mock_setup_entry.mock_calls) == 1
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_ssdp_discovery(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
async def test_ssdp_cannot_connect(
|
||||||
"""Test the ssdp discovery step."""
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
mock_connection(requests_mock)
|
) -> None:
|
||||||
|
"""Test we abort SSDP flow on connection error."""
|
||||||
|
mock_connection(aioclient_mock, error=True)
|
||||||
|
|
||||||
discovery_info = {
|
discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
|
||||||
ATTR_SSDP_LOCATION: SSDP_LOCATION,
|
result = await hass.config_entries.flow.async_init(
|
||||||
ATTR_UPNP_FRIENDLY_NAME: UPNP_FRIENDLY_NAME,
|
DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info,
|
||||||
ATTR_UPNP_SERIAL: UPNP_SERIAL,
|
)
|
||||||
}
|
|
||||||
|
assert result["type"] == RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "cannot_connect"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ssdp_unknown_error(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
|
"""Test we abort SSDP flow on unknown error."""
|
||||||
|
discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.roku.config_flow.Roku.update", side_effect=Exception,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ssdp_discovery(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
|
"""Test the SSDP discovery flow."""
|
||||||
|
mock_connection(aioclient_mock)
|
||||||
|
|
||||||
|
discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info
|
DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info
|
||||||
)
|
)
|
||||||
|
@ -1,10 +1,4 @@
|
|||||||
"""Tests for the Roku integration."""
|
"""Tests for the Roku integration."""
|
||||||
from socket import gaierror as SocketGIAError
|
|
||||||
|
|
||||||
from requests.exceptions import RequestException
|
|
||||||
from requests_mock import Mocker
|
|
||||||
from roku import RokuException
|
|
||||||
|
|
||||||
from homeassistant.components.roku.const import DOMAIN
|
from homeassistant.components.roku.const import DOMAIN
|
||||||
from homeassistant.config_entries import (
|
from homeassistant.config_entries import (
|
||||||
ENTRY_STATE_LOADED,
|
ENTRY_STATE_LOADED,
|
||||||
@ -15,46 +9,20 @@ from homeassistant.helpers.typing import HomeAssistantType
|
|||||||
|
|
||||||
from tests.async_mock import patch
|
from tests.async_mock import patch
|
||||||
from tests.components.roku import setup_integration
|
from tests.components.roku import setup_integration
|
||||||
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||||
|
|
||||||
|
|
||||||
async def test_config_entry_not_ready(
|
async def test_config_entry_not_ready(
|
||||||
hass: HomeAssistantType, requests_mock: Mocker
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test the Roku configuration entry not ready."""
|
"""Test the Roku configuration entry not ready."""
|
||||||
with patch(
|
entry = await setup_integration(hass, aioclient_mock, error=True)
|
||||||
"homeassistant.components.roku.Roku._call", side_effect=RokuException,
|
|
||||||
):
|
|
||||||
entry = await setup_integration(hass, requests_mock)
|
|
||||||
|
|
||||||
assert entry.state == ENTRY_STATE_SETUP_RETRY
|
|
||||||
|
|
||||||
|
|
||||||
async def test_config_entry_not_ready_request(
|
|
||||||
hass: HomeAssistantType, requests_mock: Mocker
|
|
||||||
) -> None:
|
|
||||||
"""Test the Roku configuration entry not ready."""
|
|
||||||
with patch(
|
|
||||||
"homeassistant.components.roku.Roku._call", side_effect=RequestException,
|
|
||||||
):
|
|
||||||
entry = await setup_integration(hass, requests_mock)
|
|
||||||
|
|
||||||
assert entry.state == ENTRY_STATE_SETUP_RETRY
|
|
||||||
|
|
||||||
|
|
||||||
async def test_config_entry_not_ready_socket(
|
|
||||||
hass: HomeAssistantType, requests_mock: Mocker
|
|
||||||
) -> None:
|
|
||||||
"""Test the Roku configuration entry not ready."""
|
|
||||||
with patch(
|
|
||||||
"homeassistant.components.roku.Roku._call", side_effect=SocketGIAError,
|
|
||||||
):
|
|
||||||
entry = await setup_integration(hass, requests_mock)
|
|
||||||
|
|
||||||
assert entry.state == ENTRY_STATE_SETUP_RETRY
|
assert entry.state == ENTRY_STATE_SETUP_RETRY
|
||||||
|
|
||||||
|
|
||||||
async def test_unload_config_entry(
|
async def test_unload_config_entry(
|
||||||
hass: HomeAssistantType, requests_mock: Mocker
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test the Roku configuration entry unloading."""
|
"""Test the Roku configuration entry unloading."""
|
||||||
with patch(
|
with patch(
|
||||||
@ -63,7 +31,7 @@ async def test_unload_config_entry(
|
|||||||
), patch(
|
), patch(
|
||||||
"homeassistant.components.roku.remote.async_setup_entry", return_value=True,
|
"homeassistant.components.roku.remote.async_setup_entry", return_value=True,
|
||||||
):
|
):
|
||||||
entry = await setup_integration(hass, requests_mock)
|
entry = await setup_integration(hass, aioclient_mock)
|
||||||
|
|
||||||
assert hass.data[DOMAIN][entry.entry_id]
|
assert hass.data[DOMAIN][entry.entry_id]
|
||||||
assert entry.state == ENTRY_STATE_LOADED
|
assert entry.state == ENTRY_STATE_LOADED
|
||||||
|
@ -1,19 +1,19 @@
|
|||||||
"""Tests for the Roku Media Player platform."""
|
"""Tests for the Roku Media Player platform."""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from requests.exceptions import (
|
from rokuecp import RokuError
|
||||||
ConnectionError as RequestsConnectionError,
|
|
||||||
ReadTimeout as RequestsReadTimeout,
|
|
||||||
)
|
|
||||||
from requests_mock import Mocker
|
|
||||||
from roku import RokuException
|
|
||||||
|
|
||||||
from homeassistant.components.media_player.const import (
|
from homeassistant.components.media_player.const import (
|
||||||
|
ATTR_APP_ID,
|
||||||
|
ATTR_APP_NAME,
|
||||||
ATTR_INPUT_SOURCE,
|
ATTR_INPUT_SOURCE,
|
||||||
|
ATTR_MEDIA_CHANNEL,
|
||||||
ATTR_MEDIA_CONTENT_ID,
|
ATTR_MEDIA_CONTENT_ID,
|
||||||
ATTR_MEDIA_CONTENT_TYPE,
|
ATTR_MEDIA_CONTENT_TYPE,
|
||||||
|
ATTR_MEDIA_TITLE,
|
||||||
ATTR_MEDIA_VOLUME_MUTED,
|
ATTR_MEDIA_VOLUME_MUTED,
|
||||||
DOMAIN as MP_DOMAIN,
|
DOMAIN as MP_DOMAIN,
|
||||||
|
MEDIA_TYPE_APP,
|
||||||
MEDIA_TYPE_CHANNEL,
|
MEDIA_TYPE_CHANNEL,
|
||||||
SERVICE_PLAY_MEDIA,
|
SERVICE_PLAY_MEDIA,
|
||||||
SERVICE_SELECT_SOURCE,
|
SERVICE_SELECT_SOURCE,
|
||||||
@ -38,6 +38,7 @@ from homeassistant.const import (
|
|||||||
SERVICE_VOLUME_MUTE,
|
SERVICE_VOLUME_MUTE,
|
||||||
SERVICE_VOLUME_UP,
|
SERVICE_VOLUME_UP,
|
||||||
STATE_HOME,
|
STATE_HOME,
|
||||||
|
STATE_IDLE,
|
||||||
STATE_PLAYING,
|
STATE_PLAYING,
|
||||||
STATE_STANDBY,
|
STATE_STANDBY,
|
||||||
STATE_UNAVAILABLE,
|
STATE_UNAVAILABLE,
|
||||||
@ -45,9 +46,10 @@ from homeassistant.const import (
|
|||||||
from homeassistant.helpers.typing import HomeAssistantType
|
from homeassistant.helpers.typing import HomeAssistantType
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from tests.async_mock import PropertyMock, patch
|
from tests.async_mock import patch
|
||||||
from tests.common import async_fire_time_changed
|
from tests.common import async_fire_time_changed
|
||||||
from tests.components.roku import UPNP_SERIAL, setup_integration
|
from tests.components.roku import UPNP_SERIAL, setup_integration
|
||||||
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||||
|
|
||||||
MAIN_ENTITY_ID = f"{MP_DOMAIN}.my_roku_3"
|
MAIN_ENTITY_ID = f"{MP_DOMAIN}.my_roku_3"
|
||||||
TV_ENTITY_ID = f"{MP_DOMAIN}.58_onn_roku_tv"
|
TV_ENTITY_ID = f"{MP_DOMAIN}.58_onn_roku_tv"
|
||||||
@ -56,34 +58,37 @@ TV_HOST = "192.168.1.161"
|
|||||||
TV_SERIAL = "YN00H5555555"
|
TV_SERIAL = "YN00H5555555"
|
||||||
|
|
||||||
|
|
||||||
async def test_setup(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
async def test_setup(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
"""Test setup with basic config."""
|
"""Test setup with basic config."""
|
||||||
await setup_integration(hass, requests_mock)
|
await setup_integration(hass, aioclient_mock)
|
||||||
|
|
||||||
entity_registry = await hass.helpers.entity_registry.async_get_registry()
|
entity_registry = await hass.helpers.entity_registry.async_get_registry()
|
||||||
|
|
||||||
main = entity_registry.async_get(MAIN_ENTITY_ID)
|
main = entity_registry.async_get(MAIN_ENTITY_ID)
|
||||||
|
|
||||||
assert hass.states.get(MAIN_ENTITY_ID)
|
assert hass.states.get(MAIN_ENTITY_ID)
|
||||||
|
assert main
|
||||||
assert main.unique_id == UPNP_SERIAL
|
assert main.unique_id == UPNP_SERIAL
|
||||||
|
|
||||||
|
|
||||||
async def test_idle_setup(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
async def test_idle_setup(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
"""Test setup with idle device."""
|
"""Test setup with idle device."""
|
||||||
with patch(
|
await setup_integration(hass, aioclient_mock, power=False)
|
||||||
"homeassistant.components.roku.Roku.power_state",
|
|
||||||
new_callable=PropertyMock(return_value="Off"),
|
|
||||||
):
|
|
||||||
await setup_integration(hass, requests_mock)
|
|
||||||
|
|
||||||
state = hass.states.get(MAIN_ENTITY_ID)
|
state = hass.states.get(MAIN_ENTITY_ID)
|
||||||
assert state.state == STATE_STANDBY
|
assert state.state == STATE_STANDBY
|
||||||
|
|
||||||
|
|
||||||
async def test_tv_setup(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
async def test_tv_setup(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
"""Test Roku TV setup."""
|
"""Test Roku TV setup."""
|
||||||
await setup_integration(
|
await setup_integration(
|
||||||
hass,
|
hass,
|
||||||
requests_mock,
|
aioclient_mock,
|
||||||
device="rokutv",
|
device="rokutv",
|
||||||
app="tvinput-dtv",
|
app="tvinput-dtv",
|
||||||
host=TV_HOST,
|
host=TV_HOST,
|
||||||
@ -91,41 +96,26 @@ async def test_tv_setup(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
entity_registry = await hass.helpers.entity_registry.async_get_registry()
|
entity_registry = await hass.helpers.entity_registry.async_get_registry()
|
||||||
|
|
||||||
tv = entity_registry.async_get(TV_ENTITY_ID)
|
tv = entity_registry.async_get(TV_ENTITY_ID)
|
||||||
|
|
||||||
assert hass.states.get(TV_ENTITY_ID)
|
assert hass.states.get(TV_ENTITY_ID)
|
||||||
|
assert tv
|
||||||
assert tv.unique_id == TV_SERIAL
|
assert tv.unique_id == TV_SERIAL
|
||||||
|
|
||||||
|
|
||||||
async def test_availability(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
async def test_availability(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
"""Test entity availability."""
|
"""Test entity availability."""
|
||||||
now = dt_util.utcnow()
|
now = dt_util.utcnow()
|
||||||
future = now + timedelta(minutes=1)
|
future = now + timedelta(minutes=1)
|
||||||
|
|
||||||
with patch("homeassistant.util.dt.utcnow", return_value=now):
|
with patch("homeassistant.util.dt.utcnow", return_value=now):
|
||||||
await setup_integration(hass, requests_mock)
|
await setup_integration(hass, aioclient_mock)
|
||||||
|
|
||||||
with patch("roku.Roku._get", side_effect=RokuException,), patch(
|
with patch(
|
||||||
"homeassistant.util.dt.utcnow", return_value=future
|
"homeassistant.components.roku.Roku.update", side_effect=RokuError
|
||||||
):
|
), patch("homeassistant.util.dt.utcnow", return_value=future):
|
||||||
async_fire_time_changed(hass, future)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE
|
|
||||||
|
|
||||||
future += timedelta(minutes=1)
|
|
||||||
|
|
||||||
with patch("roku.Roku._get", side_effect=RequestsConnectionError,), patch(
|
|
||||||
"homeassistant.util.dt.utcnow", return_value=future
|
|
||||||
):
|
|
||||||
async_fire_time_changed(hass, future)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE
|
|
||||||
|
|
||||||
future += timedelta(minutes=1)
|
|
||||||
|
|
||||||
with patch("roku.Roku._get", side_effect=RequestsReadTimeout,), patch(
|
|
||||||
"homeassistant.util.dt.utcnow", return_value=future
|
|
||||||
):
|
|
||||||
async_fire_time_changed(hass, future)
|
async_fire_time_changed(hass, future)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE
|
assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE
|
||||||
@ -139,10 +129,10 @@ async def test_availability(hass: HomeAssistantType, requests_mock: Mocker) -> N
|
|||||||
|
|
||||||
|
|
||||||
async def test_supported_features(
|
async def test_supported_features(
|
||||||
hass: HomeAssistantType, requests_mock: Mocker
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test supported features."""
|
"""Test supported features."""
|
||||||
await setup_integration(hass, requests_mock)
|
await setup_integration(hass, aioclient_mock)
|
||||||
|
|
||||||
# Features supported for Rokus
|
# Features supported for Rokus
|
||||||
state = hass.states.get(MAIN_ENTITY_ID)
|
state = hass.states.get(MAIN_ENTITY_ID)
|
||||||
@ -161,12 +151,12 @@ async def test_supported_features(
|
|||||||
|
|
||||||
|
|
||||||
async def test_tv_supported_features(
|
async def test_tv_supported_features(
|
||||||
hass: HomeAssistantType, requests_mock: Mocker
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test supported features for Roku TV."""
|
"""Test supported features for Roku TV."""
|
||||||
await setup_integration(
|
await setup_integration(
|
||||||
hass,
|
hass,
|
||||||
requests_mock,
|
aioclient_mock,
|
||||||
device="rokutv",
|
device="rokutv",
|
||||||
app="tvinput-dtv",
|
app="tvinput-dtv",
|
||||||
host=TV_HOST,
|
host=TV_HOST,
|
||||||
@ -188,22 +178,58 @@ async def test_tv_supported_features(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_attributes(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
async def test_attributes(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
"""Test attributes."""
|
"""Test attributes."""
|
||||||
await setup_integration(hass, requests_mock)
|
await setup_integration(hass, aioclient_mock)
|
||||||
|
|
||||||
state = hass.states.get(MAIN_ENTITY_ID)
|
state = hass.states.get(MAIN_ENTITY_ID)
|
||||||
assert state.state == STATE_HOME
|
assert state.state == STATE_HOME
|
||||||
|
|
||||||
assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) is None
|
assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) is None
|
||||||
|
assert state.attributes.get(ATTR_APP_ID) is None
|
||||||
|
assert state.attributes.get(ATTR_APP_NAME) == "Roku"
|
||||||
assert state.attributes.get(ATTR_INPUT_SOURCE) == "Roku"
|
assert state.attributes.get(ATTR_INPUT_SOURCE) == "Roku"
|
||||||
|
|
||||||
|
|
||||||
async def test_tv_attributes(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
async def test_attributes_app(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
|
"""Test attributes for app."""
|
||||||
|
await setup_integration(hass, aioclient_mock, app="netflix")
|
||||||
|
|
||||||
|
state = hass.states.get(MAIN_ENTITY_ID)
|
||||||
|
assert state.state == STATE_PLAYING
|
||||||
|
|
||||||
|
assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == MEDIA_TYPE_APP
|
||||||
|
assert state.attributes.get(ATTR_APP_ID) == "12"
|
||||||
|
assert state.attributes.get(ATTR_APP_NAME) == "Netflix"
|
||||||
|
assert state.attributes.get(ATTR_INPUT_SOURCE) == "Netflix"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_attributes_screensaver(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
|
"""Test attributes for app with screensaver."""
|
||||||
|
await setup_integration(hass, aioclient_mock, app="screensaver")
|
||||||
|
|
||||||
|
state = hass.states.get(MAIN_ENTITY_ID)
|
||||||
|
assert state.state == STATE_IDLE
|
||||||
|
|
||||||
|
assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) is None
|
||||||
|
assert state.attributes.get(ATTR_APP_ID) is None
|
||||||
|
assert state.attributes.get(ATTR_APP_NAME) == "Roku"
|
||||||
|
assert state.attributes.get(ATTR_INPUT_SOURCE) == "Roku"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tv_attributes(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
"""Test attributes for Roku TV."""
|
"""Test attributes for Roku TV."""
|
||||||
await setup_integration(
|
await setup_integration(
|
||||||
hass,
|
hass,
|
||||||
requests_mock,
|
aioclient_mock,
|
||||||
device="rokutv",
|
device="rokutv",
|
||||||
app="tvinput-dtv",
|
app="tvinput-dtv",
|
||||||
host=TV_HOST,
|
host=TV_HOST,
|
||||||
@ -213,29 +239,35 @@ async def test_tv_attributes(hass: HomeAssistantType, requests_mock: Mocker) ->
|
|||||||
state = hass.states.get(TV_ENTITY_ID)
|
state = hass.states.get(TV_ENTITY_ID)
|
||||||
assert state.state == STATE_PLAYING
|
assert state.state == STATE_PLAYING
|
||||||
|
|
||||||
assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == MEDIA_TYPE_CHANNEL
|
assert state.attributes.get(ATTR_APP_ID) == "tvinput.dtv"
|
||||||
|
assert state.attributes.get(ATTR_APP_NAME) == "Antenna TV"
|
||||||
assert state.attributes.get(ATTR_INPUT_SOURCE) == "Antenna TV"
|
assert state.attributes.get(ATTR_INPUT_SOURCE) == "Antenna TV"
|
||||||
|
assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == MEDIA_TYPE_CHANNEL
|
||||||
|
assert state.attributes.get(ATTR_MEDIA_CHANNEL) == "getTV (14.3)"
|
||||||
|
assert state.attributes.get(ATTR_MEDIA_TITLE) == "Airwolf"
|
||||||
|
|
||||||
|
|
||||||
async def test_services(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
async def test_services(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
"""Test the different media player services."""
|
"""Test the different media player services."""
|
||||||
await setup_integration(hass, requests_mock)
|
await setup_integration(hass, aioclient_mock)
|
||||||
|
|
||||||
with patch("roku.Roku._post") as remote_mock:
|
with patch("homeassistant.components.roku.Roku.remote") as remote_mock:
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, blocking=True
|
MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, blocking=True
|
||||||
)
|
)
|
||||||
|
|
||||||
remote_mock.assert_called_once_with("/keypress/PowerOff")
|
remote_mock.assert_called_once_with("poweroff")
|
||||||
|
|
||||||
with patch("roku.Roku._post") as remote_mock:
|
with patch("homeassistant.components.roku.Roku.remote") as remote_mock:
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, blocking=True
|
MP_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, blocking=True
|
||||||
)
|
)
|
||||||
|
|
||||||
remote_mock.assert_called_once_with("/keypress/PowerOn")
|
remote_mock.assert_called_once_with("poweron")
|
||||||
|
|
||||||
with patch("roku.Roku._post") as remote_mock:
|
with patch("homeassistant.components.roku.Roku.remote") as remote_mock:
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN,
|
MP_DOMAIN,
|
||||||
SERVICE_MEDIA_PLAY_PAUSE,
|
SERVICE_MEDIA_PLAY_PAUSE,
|
||||||
@ -243,9 +275,9 @@ async def test_services(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
remote_mock.assert_called_once_with("/keypress/Play")
|
remote_mock.assert_called_once_with("play")
|
||||||
|
|
||||||
with patch("roku.Roku._post") as remote_mock:
|
with patch("homeassistant.components.roku.Roku.remote") as remote_mock:
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN,
|
MP_DOMAIN,
|
||||||
SERVICE_MEDIA_NEXT_TRACK,
|
SERVICE_MEDIA_NEXT_TRACK,
|
||||||
@ -253,9 +285,9 @@ async def test_services(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
remote_mock.assert_called_once_with("/keypress/Fwd")
|
remote_mock.assert_called_once_with("forward")
|
||||||
|
|
||||||
with patch("roku.Roku._post") as remote_mock:
|
with patch("homeassistant.components.roku.Roku.remote") as remote_mock:
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN,
|
MP_DOMAIN,
|
||||||
SERVICE_MEDIA_PREVIOUS_TRACK,
|
SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||||
@ -263,9 +295,9 @@ async def test_services(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
remote_mock.assert_called_once_with("/keypress/Rev")
|
remote_mock.assert_called_once_with("reverse")
|
||||||
|
|
||||||
with patch("roku.Roku._post") as remote_mock:
|
with patch("homeassistant.components.roku.Roku.remote") as remote_mock:
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN,
|
MP_DOMAIN,
|
||||||
SERVICE_SELECT_SOURCE,
|
SERVICE_SELECT_SOURCE,
|
||||||
@ -273,9 +305,9 @@ async def test_services(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
remote_mock.assert_called_once_with("/keypress/Home")
|
remote_mock.assert_called_once_with("home")
|
||||||
|
|
||||||
with patch("roku.Roku._post") as remote_mock:
|
with patch("homeassistant.components.roku.Roku.launch") as remote_mock:
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN,
|
MP_DOMAIN,
|
||||||
SERVICE_SELECT_SOURCE,
|
SERVICE_SELECT_SOURCE,
|
||||||
@ -283,28 +315,30 @@ async def test_services(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
remote_mock.assert_called_once_with("/launch/12", params={"contentID": "12"})
|
remote_mock.assert_called_once_with("12")
|
||||||
|
|
||||||
|
|
||||||
async def test_tv_services(hass: HomeAssistantType, requests_mock: Mocker) -> None:
|
async def test_tv_services(
|
||||||
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
"""Test the media player services related to Roku TV."""
|
"""Test the media player services related to Roku TV."""
|
||||||
await setup_integration(
|
await setup_integration(
|
||||||
hass,
|
hass,
|
||||||
requests_mock,
|
aioclient_mock,
|
||||||
device="rokutv",
|
device="rokutv",
|
||||||
app="tvinput-dtv",
|
app="tvinput-dtv",
|
||||||
host=TV_HOST,
|
host=TV_HOST,
|
||||||
unique_id=TV_SERIAL,
|
unique_id=TV_SERIAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("roku.Roku._post") as remote_mock:
|
with patch("homeassistant.components.roku.Roku.remote") as remote_mock:
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: TV_ENTITY_ID}, blocking=True
|
MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: TV_ENTITY_ID}, blocking=True
|
||||||
)
|
)
|
||||||
|
|
||||||
remote_mock.assert_called_once_with("/keypress/VolumeUp")
|
remote_mock.assert_called_once_with("volume_up")
|
||||||
|
|
||||||
with patch("roku.Roku._post") as remote_mock:
|
with patch("homeassistant.components.roku.Roku.remote") as remote_mock:
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN,
|
MP_DOMAIN,
|
||||||
SERVICE_VOLUME_DOWN,
|
SERVICE_VOLUME_DOWN,
|
||||||
@ -312,9 +346,9 @@ async def test_tv_services(hass: HomeAssistantType, requests_mock: Mocker) -> No
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
remote_mock.assert_called_once_with("/keypress/VolumeDown")
|
remote_mock.assert_called_once_with("volume_down")
|
||||||
|
|
||||||
with patch("roku.Roku._post") as remote_mock:
|
with patch("homeassistant.components.roku.Roku.remote") as remote_mock:
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN,
|
MP_DOMAIN,
|
||||||
SERVICE_VOLUME_MUTE,
|
SERVICE_VOLUME_MUTE,
|
||||||
@ -322,9 +356,9 @@ async def test_tv_services(hass: HomeAssistantType, requests_mock: Mocker) -> No
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
remote_mock.assert_called_once_with("/keypress/VolumeMute")
|
remote_mock.assert_called_once_with("volume_mute")
|
||||||
|
|
||||||
with patch("roku.Roku.launch") as tune_mock:
|
with patch("homeassistant.components.roku.Roku.tune") as tune_mock:
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN,
|
MP_DOMAIN,
|
||||||
SERVICE_PLAY_MEDIA,
|
SERVICE_PLAY_MEDIA,
|
||||||
@ -336,4 +370,4 @@ async def test_tv_services(hass: HomeAssistantType, requests_mock: Mocker) -> No
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
tune_mock.assert_called_once()
|
tune_mock.assert_called_once_with("55")
|
||||||
|
35
tests/fixtures/roku/roku3-device-info-power-off.xml
vendored
Normal file
35
tests/fixtures/roku/roku3-device-info-power-off.xml
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<device-info>
|
||||||
|
<udn>015e5108-9000-1046-8035-b0a737964dfb</udn>
|
||||||
|
<serial-number>1GU48T017973</serial-number>
|
||||||
|
<device-id>1GU48T017973</device-id>
|
||||||
|
<vendor-name>Roku</vendor-name>
|
||||||
|
<model-number>4200X</model-number>
|
||||||
|
<model-name>Roku 3</model-name>
|
||||||
|
<model-region>US</model-region>
|
||||||
|
<supports-ethernet>true</supports-ethernet>
|
||||||
|
<wifi-mac>b0:a7:37:96:4d:fb</wifi-mac>
|
||||||
|
<ethernet-mac>b0:a7:37:96:4d:fa</ethernet-mac>
|
||||||
|
<network-type>ethernet</network-type>
|
||||||
|
<user-device-name>My Roku 3</user-device-name>
|
||||||
|
<software-version>7.5.0</software-version>
|
||||||
|
<software-build>09021</software-build>
|
||||||
|
<secure-device>true</secure-device>
|
||||||
|
<language>en</language>
|
||||||
|
<country>US</country>
|
||||||
|
<locale>en_US</locale>
|
||||||
|
<time-zone>US/Pacific</time-zone>
|
||||||
|
<time-zone-offset>-480</time-zone-offset>
|
||||||
|
<power-mode>PowerOff</power-mode>
|
||||||
|
<supports-suspend>false</supports-suspend>
|
||||||
|
<supports-find-remote>false</supports-find-remote>
|
||||||
|
<supports-audio-guide>false</supports-audio-guide>
|
||||||
|
<developer-enabled>true</developer-enabled>
|
||||||
|
<keyed-developer-id>70f6ed9c90cf60718a26f3a7c3e5af1c3ec29558</keyed-developer-id>
|
||||||
|
<search-enabled>true</search-enabled>
|
||||||
|
<voice-search-enabled>true</voice-search-enabled>
|
||||||
|
<notifications-enabled>true</notifications-enabled>
|
||||||
|
<notifications-first-use>false</notifications-first-use>
|
||||||
|
<supports-private-listening>false</supports-private-listening>
|
||||||
|
<headphones-connected>false</headphones-connected>
|
||||||
|
</device-info>
|
72
tests/fixtures/roku/rokutv-device-info-power-off.xml
vendored
Normal file
72
tests/fixtures/roku/rokutv-device-info-power-off.xml
vendored
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<device-info>
|
||||||
|
<udn>015e5555-9000-5555-5555-b0a555555dfb</udn>
|
||||||
|
<serial-number>YN00H5555555</serial-number>
|
||||||
|
<device-id>0S596H055555</device-id>
|
||||||
|
<advertising-id>055555a9-d82b-5c75-b8fe-5555550cb7ee</advertising-id>
|
||||||
|
<vendor-name>Onn</vendor-name>
|
||||||
|
<model-name>100005844</model-name>
|
||||||
|
<model-number>7820X</model-number>
|
||||||
|
<model-region>US</model-region>
|
||||||
|
<is-tv>true</is-tv>
|
||||||
|
<is-stick>false</is-stick>
|
||||||
|
<screen-size>58</screen-size>
|
||||||
|
<panel-id>2</panel-id>
|
||||||
|
<tuner-type>ATSC</tuner-type>
|
||||||
|
<supports-ethernet>true</supports-ethernet>
|
||||||
|
<wifi-mac>d8:13:99:f8:b0:c6</wifi-mac>
|
||||||
|
<wifi-driver>realtek</wifi-driver>
|
||||||
|
<ethernet-mac>d4:3a:2e:07:fd:cb</ethernet-mac>
|
||||||
|
<network-type>wifi</network-type>
|
||||||
|
<network-name>NetworkSSID</network-name>
|
||||||
|
<friendly-device-name>58" Onn Roku TV</friendly-device-name>
|
||||||
|
<friendly-model-name>Onn Roku TV</friendly-model-name>
|
||||||
|
<default-device-name>Onn Roku TV - YN00H5555555</default-device-name>
|
||||||
|
<user-device-name>58" Onn Roku TV</user-device-name>
|
||||||
|
<user-device-location>Living room</user-device-location>
|
||||||
|
<build-number>AT9.20E04502A</build-number>
|
||||||
|
<software-version>9.2.0</software-version>
|
||||||
|
<software-build>4502</software-build>
|
||||||
|
<secure-device>true</secure-device>
|
||||||
|
<language>en</language>
|
||||||
|
<country>US</country>
|
||||||
|
<locale>en_US</locale>
|
||||||
|
<time-zone-auto>true</time-zone-auto>
|
||||||
|
<time-zone>US/Central</time-zone>
|
||||||
|
<time-zone-name>United States/Central</time-zone-name>
|
||||||
|
<time-zone-tz>America/Chicago</time-zone-tz>
|
||||||
|
<time-zone-offset>-300</time-zone-offset>
|
||||||
|
<clock-format>12-hour</clock-format>
|
||||||
|
<uptime>264789</uptime>
|
||||||
|
<power-mode>PowerOn</power-mode>
|
||||||
|
<supports-suspend>true</supports-suspend>
|
||||||
|
<supports-find-remote>true</supports-find-remote>
|
||||||
|
<find-remote-is-possible>false</find-remote-is-possible>
|
||||||
|
<supports-audio-guide>true</supports-audio-guide>
|
||||||
|
<supports-rva>true</supports-rva>
|
||||||
|
<developer-enabled>false</developer-enabled>
|
||||||
|
<keyed-developer-id/>
|
||||||
|
<search-enabled>true</search-enabled>
|
||||||
|
<search-channels-enabled>true</search-channels-enabled>
|
||||||
|
<voice-search-enabled>true</voice-search-enabled>
|
||||||
|
<notifications-enabled>true</notifications-enabled>
|
||||||
|
<notifications-first-use>false</notifications-first-use>
|
||||||
|
<supports-private-listening>true</supports-private-listening>
|
||||||
|
<supports-private-listening-dtv>true</supports-private-listening-dtv>
|
||||||
|
<supports-warm-standby>true</supports-warm-standby>
|
||||||
|
<headphones-connected>false</headphones-connected>
|
||||||
|
<expert-pq-enabled>0.9</expert-pq-enabled>
|
||||||
|
<supports-ecs-textedit>true</supports-ecs-textedit>
|
||||||
|
<supports-ecs-microphone>true</supports-ecs-microphone>
|
||||||
|
<supports-wake-on-wlan>true</supports-wake-on-wlan>
|
||||||
|
<has-play-on-roku>true</has-play-on-roku>
|
||||||
|
<has-mobile-screensaver>true</has-mobile-screensaver>
|
||||||
|
<support-url>https://www.onntvsupport.com/</support-url>
|
||||||
|
<grandcentral-version>2.9.57</grandcentral-version>
|
||||||
|
<trc-version>3.0</trc-version>
|
||||||
|
<trc-channel-version>2.9.42</trc-channel-version>
|
||||||
|
<davinci-version>2.8.20</davinci-version>
|
||||||
|
<has-wifi-extender>false</has-wifi-extender>
|
||||||
|
<has-wifi-5G-support>true</has-wifi-5G-support>
|
||||||
|
<can-use-wifi-extender>true</can-use-wifi-extender>
|
||||||
|
</device-info>
|
24
tests/fixtures/roku/rokutv-tv-active-channel.xml
vendored
Normal file
24
tests/fixtures/roku/rokutv-tv-active-channel.xml
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<tv-channel>
|
||||||
|
<channel>
|
||||||
|
<number>14.3</number>
|
||||||
|
<name>getTV</name>
|
||||||
|
<type>air-digital</type>
|
||||||
|
<user-hidden>false</user-hidden>
|
||||||
|
<active-input>true</active-input>
|
||||||
|
<signal-state>valid</signal-state>
|
||||||
|
<signal-mode>480i</signal-mode>
|
||||||
|
<signal-quality>20</signal-quality>
|
||||||
|
<signal-strength>-75</signal-strength>
|
||||||
|
<program-title>Airwolf</program-title>
|
||||||
|
<program-description>The team will travel all around the world in order to shut down a global crime ring.</program-description>
|
||||||
|
<program-ratings>TV-14-D-V</program-ratings>
|
||||||
|
<program-analog-audio>none</program-analog-audio>
|
||||||
|
<program-digital-audio>stereo</program-digital-audio>
|
||||||
|
<program-audio-languages>eng</program-audio-languages>
|
||||||
|
<program-audio-formats>AC3</program-audio-formats>
|
||||||
|
<program-audio-language>eng</program-audio-language>
|
||||||
|
<program-audio-format>AC3</program-audio-format>
|
||||||
|
<program-has-cc>true</program-has-cc>
|
||||||
|
</channel>
|
||||||
|
</tv-channel>
|
15
tests/fixtures/roku/rokutv-tv-channels.xml
vendored
Normal file
15
tests/fixtures/roku/rokutv-tv-channels.xml
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<tv-channels>
|
||||||
|
<channel>
|
||||||
|
<number>1.1</number>
|
||||||
|
<name>WhatsOn</name>
|
||||||
|
<type>air-digital</type>
|
||||||
|
<user-hidden>false</user-hidden>
|
||||||
|
</channel>
|
||||||
|
<channel>
|
||||||
|
<number>1.3</number>
|
||||||
|
<name>QVC</name>
|
||||||
|
<type>air-digital</type>
|
||||||
|
<user-hidden>false</user-hidden>
|
||||||
|
</channel>
|
||||||
|
</tv-channels>
|
Loading…
x
Reference in New Issue
Block a user