From 3feb55a8e474cf85e3f3eb484405ca7be43a59be Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Fri, 8 May 2020 16:44:34 -0500 Subject: [PATCH] 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 --- homeassistant/components/roku/__init__.py | 126 +++++++--- homeassistant/components/roku/config_flow.py | 92 ++++--- homeassistant/components/roku/const.py | 9 +- homeassistant/components/roku/manifest.json | 2 +- homeassistant/components/roku/media_player.py | 230 ++++++++---------- homeassistant/components/roku/remote.py | 82 ++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/roku/__init__.py | 129 +++++++++- tests/components/roku/test_config_flow.py | 155 +++++------- tests/components/roku/test_init.py | 42 +--- tests/components/roku/test_media_player.py | 188 ++++++++------ .../roku/roku3-device-info-power-off.xml | 35 +++ .../roku/rokutv-device-info-power-off.xml | 72 ++++++ .../roku/rokutv-tv-active-channel.xml | 24 ++ tests/fixtures/roku/rokutv-tv-channels.xml | 15 ++ 16 files changed, 715 insertions(+), 490 deletions(-) create mode 100644 tests/fixtures/roku/roku3-device-info-power-off.xml create mode 100644 tests/fixtures/roku/rokutv-device-info-power-off.xml create mode 100644 tests/fixtures/roku/rokutv-tv-active-channel.xml create mode 100644 tests/fixtures/roku/rokutv-tv-channels.xml diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index 636260b510c..46583c28d5f 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -1,22 +1,31 @@ """Support for Roku.""" import asyncio from datetime import timedelta -from socket import gaierror as SocketGIAError -from typing import Dict +import logging +from typing import Any, Dict -from requests.exceptions import RequestException -from roku import Roku, RokuException +from rokuecp import Roku, RokuError +from rokuecp.models import Device import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_NAME, CONF_HOST from homeassistant.exceptions import ConfigEntryNotReady 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( { @@ -29,20 +38,10 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = [MEDIA_PLAYER_DOMAIN, REMOTE_DOMAIN] SCAN_INTERVAL = timedelta(seconds=30) +_LOGGER = logging.getLogger(__name__) -def get_roku_data(host: str) -> dict: - """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: +async def async_setup(hass: HomeAssistantType, config: Dict) -> bool: """Set up the Roku integration.""" hass.data.setdefault(DOMAIN, {}) @@ -57,16 +56,15 @@ async def async_setup(hass: HomeAssistant, config: Dict) -> bool: 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.""" - try: - roku_data = await hass.async_add_executor_job( - get_roku_data, entry.data[CONF_HOST], - ) - except (SocketGIAError, RequestException, RokuException) as exception: - raise ConfigEntryNotReady from exception + coordinator = RokuDataUpdateCoordinator(hass, host=entry.data[CONF_HOST]) + await coordinator.async_refresh() - 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: hass.async_create_task( @@ -76,7 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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_ok = all( await asyncio.gather( @@ -91,3 +89,75 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) 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, + } diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index eec5683c95d..27ab63c728b 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -1,11 +1,9 @@ """Config flow for Roku.""" import logging -from socket import gaierror as SocketGIAError from typing import Any, Dict, Optional from urllib.parse import urlparse -from requests.exceptions import RequestException -from roku import Roku, RokuException +from rokuecp import Roku, RokuError import voluptuous as vol 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.const import CONF_HOST, CONF_NAME 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 @@ -28,24 +27,18 @@ ERROR_UNKNOWN = "unknown" _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. Data has the keys from DATA_SCHEMA with values provided by the user. """ - - try: - roku = Roku(data["host"]) - 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 + session = async_get_clientsession(hass) + roku = Roku(data[CONF_HOST], session=session) + device = await roku.update() return { - "title": data["host"], - "host": data["host"], - "serial_num": device_info.serial_num, + "title": device.info.name, + "serial_number": device.info.serial_number, } @@ -55,6 +48,10 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL + def __init__(self): + """Set up the instance.""" + self.discovery_info = {} + @callback def _show_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]: """Show the form to the user.""" @@ -78,16 +75,17 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} try: - info = await self.hass.async_add_executor_job(validate_input, user_input) - except CannotConnect: + info = await validate_input(self.hass, user_input) + except RokuError: + _LOGGER.debug("Roku Error", exc_info=True) errors["base"] = ERROR_CANNOT_CONNECT return self._show_form(errors) - except UnknownError: + except Exception: # pylint: disable=broad-except _LOGGER.exception("Unknown error trying to connect") return self.async_abort(reason=ERROR_UNKNOWN) - await self.async_set_unique_id(info["serial_num"]) - self._abort_if_unique_id_configured() + await self.async_set_unique_id(info["serial_number"]) + self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]}) 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.""" host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname 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}) # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 - self.context.update( - {CONF_HOST: host, CONF_NAME: name, "title_placeholders": {"name": host}} - ) + self.context.update({"title_placeholders": {"name": name}}) + + 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() @@ -114,30 +121,13 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): ) -> Dict[str, Any]: """Handle user-confirmation of discovered device.""" # 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: - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 - 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}, + return self.async_create_entry( + title=self.discovery_info[CONF_NAME], data=self.discovery_info, ) - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class UnknownError(HomeAssistantError): - """Error to indicate we encountered an unknown error.""" diff --git a/homeassistant/components/roku/const.py b/homeassistant/components/roku/const.py index b06eed5df9f..dc51e5d6f9b 100644 --- a/homeassistant/components/roku/const.py +++ b/homeassistant/components/roku/const.py @@ -1,8 +1,11 @@ """Constants for the Roku integration.""" DOMAIN = "roku" -DATA_CLIENT = "client" -DATA_DEVICE_INFO = "device_info" +# Attributes +ATTR_IDENTIFIERS = "identifiers" +ATTR_MANUFACTURER = "manufacturer" +ATTR_MODEL = "model" +ATTR_SOFTWARE_VERSION = "sw_version" +# Default Values DEFAULT_PORT = 8060 -DEFAULT_MANUFACTURER = "Roku" diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index e0c7c9f5c49..8d493aec932 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -2,7 +2,7 @@ "domain": "roku", "name": "Roku", "documentation": "https://www.home-assistant.io/integrations/roku", - "requirements": ["roku==4.1.0"], + "requirements": ["rokuecp==0.2.0"], "ssdp": [ { "st": "roku:ecp", diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 0a71680a03c..f5669992904 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -1,14 +1,10 @@ """Support for the Roku media player.""" import logging - -from requests.exceptions import ( - ConnectionError as RequestsConnectionError, - ReadTimeout as RequestsReadTimeout, -) -from roku import RokuException +from typing import List from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( + MEDIA_TYPE_APP, MEDIA_TYPE_CHANNEL, SUPPORT_NEXT_TRACK, 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 .const import DATA_CLIENT, DEFAULT_MANUFACTURER, DEFAULT_PORT, DOMAIN +from . import RokuDataUpdateCoordinator, RokuEntity +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -41,68 +38,48 @@ SUPPORT_ROKU = ( async def async_setup_entry(hass, entry, async_add_entities): """Set up the Roku config entry.""" - roku = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] - async_add_entities([RokuDevice(roku)], True) + coordinator = hass.data[DOMAIN][entry.entry_id] + unique_id = coordinator.data.info.serial_number + async_add_entities([RokuMediaPlayer(unique_id, coordinator)], True) -class RokuDevice(MediaPlayerEntity): - """Representation of a Roku device on the network.""" +class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): + """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.""" - self.roku = roku - self.ip_address = roku.host - self.channels = [] - self.current_app = None - self._available = False - self._device_info = {} - self._power_state = "Unknown" + super().__init__( + coordinator=coordinator, + name=coordinator.data.info.name, + device_id=unique_id, + ) - def update(self): - """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) + self._unique_id = unique_id @property - def should_poll(self): - """Device should be polled.""" - return True + def unique_id(self) -> str: + """Return the unique ID for this entity.""" + return self._unique_id @property - def name(self): - """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): + def state(self) -> str: """Return the state of the device.""" - if self._power_state == "Off": + if self.coordinator.data.state.standby: return STATE_STANDBY - if self.current_app is None: + if self.coordinator.data.app is 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 - if self.current_app.name == "Roku": + if self.coordinator.data.app.name == "Roku": return STATE_HOME - if self.current_app.name is not None: + if self.coordinator.data.app.name is not None: return STATE_PLAYING return None @@ -113,109 +90,108 @@ class RokuDevice(MediaPlayerEntity): return SUPPORT_ROKU @property - def available(self): - """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): + def media_content_type(self) -> str: """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 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 - def media_image_url(self): + def media_image_url(self) -> str: """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 - if self.current_app.id is None: - return None - - return ( - f"http://{self.ip_address}:{DEFAULT_PORT}/query/icon/{self.current_app.id}" - ) + return self.coordinator.roku.app_icon_url(self.app_id) @property - def app_name(self): + def app_name(self) -> str: """Name of the current running app.""" - if self.current_app is not None: - return self.current_app.name + if self.coordinator.data.app is not None: + return self.coordinator.data.app.name + + return None @property - def app_id(self): + def app_id(self) -> str: """Return the ID of the current running app.""" - if self.current_app is not None: - return self.current_app.id + if self.coordinator.data.app is not None: + return self.coordinator.data.app.app_id + + return None @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.""" - if self.current_app is not None: - return self.current_app.name + if self.coordinator.data.app is not None: + return self.coordinator.data.app.name + + return None @property - def source_list(self): + def source_list(self) -> List: """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.""" - self.roku.poweron() + await self.coordinator.roku.remote("poweron") - def turn_off(self): + async def async_turn_off(self) -> None: """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.""" - if self.current_app is not None: - self.roku.play() + await self.coordinator.roku.remote("play") - def media_previous_track(self): + async def async_media_previous_track(self) -> None: """Send previous track command.""" - if self.current_app is not None: - self.roku.reverse() + await self.coordinator.roku.remote("reverse") - def media_next_track(self): + async def async_media_next_track(self) -> None: """Send next track command.""" - if self.current_app is not None: - self.roku.forward() + await self.coordinator.roku.remote("forward") - def mute_volume(self, mute): + async def async_mute_volume(self, mute) -> None: """Mute the volume.""" - if self.current_app is not None: - self.roku.volume_mute() + await self.coordinator.roku.remote("volume_mute") - def volume_up(self): + async def async_volume_up(self) -> None: """Volume up media player.""" - if self.current_app is not None: - self.roku.volume_up() + await self.coordinator.roku.remote("volume_up") - def volume_down(self): + async def async_volume_down(self) -> None: """Volume down media player.""" - if self.current_app is not None: - self.roku.volume_down() + await self.coordinator.roku.remote("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.""" if media_type != MEDIA_TYPE_CHANNEL: _LOGGER.error( @@ -225,16 +201,16 @@ class RokuDevice(MediaPlayerEntity): ) return - if self.current_app is not None: - self.roku.launch(self.roku["tvinput.dtv"], {"ch": media_id}) + await self.coordinator.roku.tune(media_id) - def select_source(self, source): + async def async_select_source(self, source: str) -> None: """Select input source.""" - if self.current_app is None: - return - if source == "Home": - self.roku.home() - else: - channel = self.roku[source] - channel.launch() + await self.coordinator.roku.remote("home") + + appl = next( + (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) diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py index 22102ac8282..78ccaa10e79 100644 --- a/homeassistant/components/roku/remote.py +++ b/homeassistant/components/roku/remote.py @@ -1,17 +1,12 @@ """Support for the Roku remote.""" 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.config_entries import ConfigEntry 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( @@ -20,75 +15,38 @@ async def async_setup_entry( async_add_entities: Callable[[List, bool], None], ) -> bool: """Load Roku remote based on a config entry.""" - roku = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] - async_add_entities([RokuRemote(roku)], True) + coordinator = hass.data[DOMAIN][entry.entry_id] + 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.""" - def __init__(self, roku): + def __init__(self, unique_id: str, coordinator: RokuDataUpdateCoordinator) -> None: """Initialize the Roku device.""" - self.roku = roku - self._available = False - self._device_info = {} + super().__init__( + device_id=unique_id, + name=coordinator.data.info.name, + coordinator=coordinator, + ) - def update(self): - """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 + self._unique_id = unique_id @property - def name(self): - """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}" + def unique_id(self) -> str: + """Return the unique ID for this entity.""" + return self._unique_id @property - def available(self): - """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): + def is_on(self) -> bool: """Return true if device is on.""" - return True + return not self.coordinator.data.state.standby - @property - def should_poll(self): - """No polling needed for Roku.""" - return False - - def send_command(self, command, **kwargs): + async def async_send_command(self, command: List, **kwargs) -> None: """Send a command to one device.""" num_repeats = kwargs[ATTR_NUM_REPEATS] for _ in range(num_repeats): for single_command in command: - if not hasattr(self.roku, single_command): - continue - - getattr(self.roku, single_command)() + await self.coordinator.roku.remote(single_command) diff --git a/requirements_all.txt b/requirements_all.txt index 76fa8bb9357..52625ce2a04 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1850,7 +1850,7 @@ rjpl==0.3.5 rocketchat-API==0.6.1 # homeassistant.components.roku -roku==4.1.0 +rokuecp==0.2.0 # homeassistant.components.roomba roombapy==1.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9f935748ef..b873df76b2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -735,7 +735,7 @@ rflink==0.0.52 ring_doorbell==0.6.0 # homeassistant.components.roku -roku==4.1.0 +rokuecp==0.2.0 # homeassistant.components.roomba roombapy==1.5.3 diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py index 7d6082f2877..dd697ec86f0 100644 --- a/tests/components/roku/__init__.py +++ b/tests/components/roku/__init__.py @@ -1,11 +1,18 @@ """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.ssdp import ( + ATTR_SSDP_LOCATION, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, +) from homeassistant.const import CONF_HOST from homeassistant.helpers.typing import HomeAssistantType from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker HOST = "192.168.1.160" NAME = "Roku 3" @@ -13,38 +20,132 @@ SSDP_LOCATION = "http://192.168.1.160/" UPNP_FRIENDLY_NAME = "My Roku 3" 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( - 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: """Mock the Roku connection.""" 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", - text=load_fixture(f"roku/{device}-device-info.xml"), + text=load_fixture(info_fixture), + headers={"Content-Type": "application/xml"}, ) apps_fixture = "roku/apps.xml" if device == "rokutv": apps_fixture = "roku/apps-tv.xml" - requests_mocker.get( - f"{roku_url}/query/apps", text=load_fixture(apps_fixture), + aioclient_mock.get( + f"{roku_url}/query/apps", + text=load_fixture(apps_fixture), + headers={"Content-Type": "application/xml"}, ) - requests_mocker.get( - f"{roku_url}/query/active-app", text=load_fixture(f"roku/active-app-{app}.xml"), + aioclient_mock.get( + 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( hass: HomeAssistantType, - requests_mocker: Mocker, + aioclient_mock: AiohttpClientMocker, device: str = "roku3", app: str = "roku", host: str = HOST, unique_id: str = UPNP_SERIAL, + error: bool = False, + power: bool = True, + server_error: bool = False, skip_entry_setup: bool = False, ) -> MockConfigEntry: """Set up the Roku integration in Home Assistant.""" @@ -53,7 +154,15 @@ async def setup_integration( entry.add_to_hass(hass) 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.async_block_till_done() diff --git a/tests/components/roku/test_config_flow.py b/tests/components/roku/test_config_flow.py index f59b36b6f30..403e25e46c6 100644 --- a/tests/components/roku/test_config_flow.py +++ b/tests/components/roku/test_config_flow.py @@ -1,16 +1,5 @@ """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.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.const import CONF_HOST, CONF_NAME, CONF_SOURCE 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.components.roku import ( HOST, - SSDP_LOCATION, + MOCK_SSDP_DISCOVERY_INFO, UPNP_FRIENDLY_NAME, - UPNP_SERIAL, mock_connection, 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.""" - await setup_integration(hass, requests_mock, skip_entry_setup=True) - - mock_connection(requests_mock) + await setup_integration(hass, aioclient_mock, skip_entry_setup=True) + mock_connection(aioclient_mock) user_input = {CONF_HOST: HOST} 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["reason"] == "already_configured" - discovery_info = { - ATTR_UPNP_FRIENDLY_NAME: UPNP_FRIENDLY_NAME, - ATTR_SSDP_LOCATION: SSDP_LOCATION, - ATTR_UPNP_SERIAL: UPNP_SERIAL, - } + discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() result = await hass.config_entries.flow.async_init( 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" -async def test_form(hass: HomeAssistantType, requests_mock: Mocker) -> None: +async def test_form( + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker +) -> None: """Test the user step.""" await async_setup_component(hass, "persistent_notification", {}) - - mock_connection(requests_mock) + mock_connection(aioclient_mock) result = await hass.config_entries.flow.async_init( 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["title"] == HOST + assert result["title"] == UPNP_FRIENDLY_NAME assert result["data"] 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 -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.""" + mock_connection(aioclient_mock, error=True) + result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) - with patch( - "homeassistant.components.roku.config_flow.Roku._call", - 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} + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input={CONF_HOST: HOST} ) - 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["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: """Test we handle unknown error.""" @@ -173,7 +113,7 @@ async def test_form_unknown_error(hass: HomeAssistantType) -> None: user_input = {CONF_HOST: HOST} 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: result = await hass.config_entries.flow.async_configure( 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 -async def test_import(hass: HomeAssistantType, requests_mock: Mocker) -> None: +async def test_import( + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker +) -> None: """Test the import step.""" - mock_connection(requests_mock) + mock_connection(aioclient_mock) user_input = {CONF_HOST: HOST} 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["title"] == HOST + assert result["title"] == UPNP_FRIENDLY_NAME assert result["data"] 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 -async def test_ssdp_discovery(hass: HomeAssistantType, requests_mock: Mocker) -> None: - """Test the ssdp discovery step.""" - mock_connection(requests_mock) +async def test_ssdp_cannot_connect( + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort SSDP flow on connection error.""" + mock_connection(aioclient_mock, error=True) - discovery_info = { - ATTR_SSDP_LOCATION: SSDP_LOCATION, - ATTR_UPNP_FRIENDLY_NAME: UPNP_FRIENDLY_NAME, - ATTR_UPNP_SERIAL: UPNP_SERIAL, - } + discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() + 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"] == "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( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info ) diff --git a/tests/components/roku/test_init.py b/tests/components/roku/test_init.py index fcbe9aa4b7d..3a627db72a5 100644 --- a/tests/components/roku/test_init.py +++ b/tests/components/roku/test_init.py @@ -1,10 +1,4 @@ """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.config_entries import ( ENTRY_STATE_LOADED, @@ -15,46 +9,20 @@ from homeassistant.helpers.typing import HomeAssistantType from tests.async_mock import patch from tests.components.roku import setup_integration +from tests.test_util.aiohttp import AiohttpClientMocker async def test_config_entry_not_ready( - hass: HomeAssistantType, requests_mock: Mocker + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: """Test the Roku configuration entry not ready.""" - with patch( - "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) + entry = await setup_integration(hass, aioclient_mock, error=True) assert entry.state == ENTRY_STATE_SETUP_RETRY async def test_unload_config_entry( - hass: HomeAssistantType, requests_mock: Mocker + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: """Test the Roku configuration entry unloading.""" with patch( @@ -63,7 +31,7 @@ async def test_unload_config_entry( ), patch( "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 entry.state == ENTRY_STATE_LOADED diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 3b11844450e..c34e320b032 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -1,19 +1,19 @@ """Tests for the Roku Media Player platform.""" from datetime import timedelta -from requests.exceptions import ( - ConnectionError as RequestsConnectionError, - ReadTimeout as RequestsReadTimeout, -) -from requests_mock import Mocker -from roku import RokuException +from rokuecp import RokuError from homeassistant.components.media_player.const import ( + ATTR_APP_ID, + ATTR_APP_NAME, ATTR_INPUT_SOURCE, + ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_TITLE, ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MP_DOMAIN, + MEDIA_TYPE_APP, MEDIA_TYPE_CHANNEL, SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, @@ -38,6 +38,7 @@ from homeassistant.const import ( SERVICE_VOLUME_MUTE, SERVICE_VOLUME_UP, STATE_HOME, + STATE_IDLE, STATE_PLAYING, STATE_STANDBY, STATE_UNAVAILABLE, @@ -45,9 +46,10 @@ from homeassistant.const import ( from homeassistant.helpers.typing import HomeAssistantType 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.components.roku import UPNP_SERIAL, setup_integration +from tests.test_util.aiohttp import AiohttpClientMocker MAIN_ENTITY_ID = f"{MP_DOMAIN}.my_roku_3" TV_ENTITY_ID = f"{MP_DOMAIN}.58_onn_roku_tv" @@ -56,34 +58,37 @@ TV_HOST = "192.168.1.161" 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.""" - await setup_integration(hass, requests_mock) + await setup_integration(hass, aioclient_mock) entity_registry = await hass.helpers.entity_registry.async_get_registry() - main = entity_registry.async_get(MAIN_ENTITY_ID) + assert hass.states.get(MAIN_ENTITY_ID) + assert main 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.""" - with patch( - "homeassistant.components.roku.Roku.power_state", - new_callable=PropertyMock(return_value="Off"), - ): - await setup_integration(hass, requests_mock) + await setup_integration(hass, aioclient_mock, power=False) state = hass.states.get(MAIN_ENTITY_ID) 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.""" await setup_integration( hass, - requests_mock, + aioclient_mock, device="rokutv", app="tvinput-dtv", 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() - tv = entity_registry.async_get(TV_ENTITY_ID) + assert hass.states.get(TV_ENTITY_ID) + assert tv 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.""" now = dt_util.utcnow() future = now + timedelta(minutes=1) 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( - "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 - ): + with patch( + "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 @@ -139,10 +129,10 @@ async def test_availability(hass: HomeAssistantType, requests_mock: Mocker) -> N async def test_supported_features( - hass: HomeAssistantType, requests_mock: Mocker + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: """Test supported features.""" - await setup_integration(hass, requests_mock) + await setup_integration(hass, aioclient_mock) # Features supported for Rokus state = hass.states.get(MAIN_ENTITY_ID) @@ -161,12 +151,12 @@ async def test_supported_features( async def test_tv_supported_features( - hass: HomeAssistantType, requests_mock: Mocker + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: """Test supported features for Roku TV.""" await setup_integration( hass, - requests_mock, + aioclient_mock, device="rokutv", app="tvinput-dtv", 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.""" - await setup_integration(hass, requests_mock) + await setup_integration(hass, aioclient_mock) state = hass.states.get(MAIN_ENTITY_ID) assert state.state == STATE_HOME 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, 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.""" await setup_integration( hass, - requests_mock, + aioclient_mock, device="rokutv", app="tvinput-dtv", host=TV_HOST, @@ -213,29 +239,35 @@ async def test_tv_attributes(hass: HomeAssistantType, requests_mock: Mocker) -> state = hass.states.get(TV_ENTITY_ID) 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_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.""" - 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( 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( 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( MP_DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, @@ -243,9 +275,9 @@ async def test_services(hass: HomeAssistantType, requests_mock: Mocker) -> None: 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( MP_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, @@ -253,9 +285,9 @@ async def test_services(hass: HomeAssistantType, requests_mock: Mocker) -> None: 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( MP_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, @@ -263,9 +295,9 @@ async def test_services(hass: HomeAssistantType, requests_mock: Mocker) -> None: 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( MP_DOMAIN, SERVICE_SELECT_SOURCE, @@ -273,9 +305,9 @@ async def test_services(hass: HomeAssistantType, requests_mock: Mocker) -> None: 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( MP_DOMAIN, SERVICE_SELECT_SOURCE, @@ -283,28 +315,30 @@ async def test_services(hass: HomeAssistantType, requests_mock: Mocker) -> None: 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.""" await setup_integration( hass, - requests_mock, + aioclient_mock, device="rokutv", app="tvinput-dtv", host=TV_HOST, 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( 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( MP_DOMAIN, SERVICE_VOLUME_DOWN, @@ -312,9 +346,9 @@ async def test_tv_services(hass: HomeAssistantType, requests_mock: Mocker) -> No 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( MP_DOMAIN, SERVICE_VOLUME_MUTE, @@ -322,9 +356,9 @@ async def test_tv_services(hass: HomeAssistantType, requests_mock: Mocker) -> No 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( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -336,4 +370,4 @@ async def test_tv_services(hass: HomeAssistantType, requests_mock: Mocker) -> No blocking=True, ) - tune_mock.assert_called_once() + tune_mock.assert_called_once_with("55") diff --git a/tests/fixtures/roku/roku3-device-info-power-off.xml b/tests/fixtures/roku/roku3-device-info-power-off.xml new file mode 100644 index 00000000000..4a89724016b --- /dev/null +++ b/tests/fixtures/roku/roku3-device-info-power-off.xml @@ -0,0 +1,35 @@ + + + 015e5108-9000-1046-8035-b0a737964dfb + 1GU48T017973 + 1GU48T017973 + Roku + 4200X + Roku 3 + US + true + b0:a7:37:96:4d:fb + b0:a7:37:96:4d:fa + ethernet + My Roku 3 + 7.5.0 + 09021 + true + en + US + en_US + US/Pacific + -480 + PowerOff + false + false + false + true + 70f6ed9c90cf60718a26f3a7c3e5af1c3ec29558 + true + true + true + false + false + false + diff --git a/tests/fixtures/roku/rokutv-device-info-power-off.xml b/tests/fixtures/roku/rokutv-device-info-power-off.xml new file mode 100644 index 00000000000..658fc130629 --- /dev/null +++ b/tests/fixtures/roku/rokutv-device-info-power-off.xml @@ -0,0 +1,72 @@ + + + 015e5555-9000-5555-5555-b0a555555dfb + YN00H5555555 + 0S596H055555 + 055555a9-d82b-5c75-b8fe-5555550cb7ee + Onn + 100005844 + 7820X + US + true + false + 58 + 2 + ATSC + true + d8:13:99:f8:b0:c6 + realtek + d4:3a:2e:07:fd:cb + wifi + NetworkSSID + 58" Onn Roku TV + Onn Roku TV + Onn Roku TV - YN00H5555555 + 58" Onn Roku TV + Living room + AT9.20E04502A + 9.2.0 + 4502 + true + en + US + en_US + true + US/Central + United States/Central + America/Chicago + -300 + 12-hour + 264789 + PowerOn + true + true + false + true + true + false + + true + true + true + true + false + true + true + true + false + 0.9 + true + true + true + true + true + https://www.onntvsupport.com/ + 2.9.57 + 3.0 + 2.9.42 + 2.8.20 + false + true + true + diff --git a/tests/fixtures/roku/rokutv-tv-active-channel.xml b/tests/fixtures/roku/rokutv-tv-active-channel.xml new file mode 100644 index 00000000000..9d6bf582726 --- /dev/null +++ b/tests/fixtures/roku/rokutv-tv-active-channel.xml @@ -0,0 +1,24 @@ + + + + 14.3 + getTV + air-digital + false + true + valid + 480i + 20 + -75 + Airwolf + The team will travel all around the world in order to shut down a global crime ring. + TV-14-D-V + none + stereo + eng + AC3 + eng + AC3 + true + + diff --git a/tests/fixtures/roku/rokutv-tv-channels.xml b/tests/fixtures/roku/rokutv-tv-channels.xml new file mode 100644 index 00000000000..db4b816c9e2 --- /dev/null +++ b/tests/fixtures/roku/rokutv-tv-channels.xml @@ -0,0 +1,15 @@ + + + + 1.1 + WhatsOn + air-digital + false + + + 1.3 + QVC + air-digital + false + +