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