Make roku async (#35104)

* Update manifest.json

* work on roku async.

* Update config_flow.py

* Update __init__.py

* Update media_player.py

* Update media_player.py

* Update __init__.py

* Update __init__.py

* Update media_player.py

* Update media_player.py

* Update remote.py

* Update test_media_player.py

* Update test_media_player.py

* Update test_config_flow.py

* Update media_player.py

* Update remote.py

* Update config_flow.py

* Update test_media_player.py

* Update config_flow.py

* Update test_config_flow.py
This commit is contained in:
Chris Talkington 2020-05-08 16:44:34 -05:00 committed by GitHub
parent 54584e970c
commit 3feb55a8e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 715 additions and 490 deletions

View File

@ -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,
}

View File

@ -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."""

View File

@ -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"

View File

@ -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",

View File

@ -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)

View File

@ -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)

View File

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

View File

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

View File

@ -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()

View File

@ -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
)

View File

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

View File

@ -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")

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8" ?>
<device-info>
<udn>015e5108-9000-1046-8035-b0a737964dfb</udn>
<serial-number>1GU48T017973</serial-number>
<device-id>1GU48T017973</device-id>
<vendor-name>Roku</vendor-name>
<model-number>4200X</model-number>
<model-name>Roku 3</model-name>
<model-region>US</model-region>
<supports-ethernet>true</supports-ethernet>
<wifi-mac>b0:a7:37:96:4d:fb</wifi-mac>
<ethernet-mac>b0:a7:37:96:4d:fa</ethernet-mac>
<network-type>ethernet</network-type>
<user-device-name>My Roku 3</user-device-name>
<software-version>7.5.0</software-version>
<software-build>09021</software-build>
<secure-device>true</secure-device>
<language>en</language>
<country>US</country>
<locale>en_US</locale>
<time-zone>US/Pacific</time-zone>
<time-zone-offset>-480</time-zone-offset>
<power-mode>PowerOff</power-mode>
<supports-suspend>false</supports-suspend>
<supports-find-remote>false</supports-find-remote>
<supports-audio-guide>false</supports-audio-guide>
<developer-enabled>true</developer-enabled>
<keyed-developer-id>70f6ed9c90cf60718a26f3a7c3e5af1c3ec29558</keyed-developer-id>
<search-enabled>true</search-enabled>
<voice-search-enabled>true</voice-search-enabled>
<notifications-enabled>true</notifications-enabled>
<notifications-first-use>false</notifications-first-use>
<supports-private-listening>false</supports-private-listening>
<headphones-connected>false</headphones-connected>
</device-info>

View File

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8" ?>
<device-info>
<udn>015e5555-9000-5555-5555-b0a555555dfb</udn>
<serial-number>YN00H5555555</serial-number>
<device-id>0S596H055555</device-id>
<advertising-id>055555a9-d82b-5c75-b8fe-5555550cb7ee</advertising-id>
<vendor-name>Onn</vendor-name>
<model-name>100005844</model-name>
<model-number>7820X</model-number>
<model-region>US</model-region>
<is-tv>true</is-tv>
<is-stick>false</is-stick>
<screen-size>58</screen-size>
<panel-id>2</panel-id>
<tuner-type>ATSC</tuner-type>
<supports-ethernet>true</supports-ethernet>
<wifi-mac>d8:13:99:f8:b0:c6</wifi-mac>
<wifi-driver>realtek</wifi-driver>
<ethernet-mac>d4:3a:2e:07:fd:cb</ethernet-mac>
<network-type>wifi</network-type>
<network-name>NetworkSSID</network-name>
<friendly-device-name>58" Onn Roku TV</friendly-device-name>
<friendly-model-name>Onn Roku TV</friendly-model-name>
<default-device-name>Onn Roku TV - YN00H5555555</default-device-name>
<user-device-name>58" Onn Roku TV</user-device-name>
<user-device-location>Living room</user-device-location>
<build-number>AT9.20E04502A</build-number>
<software-version>9.2.0</software-version>
<software-build>4502</software-build>
<secure-device>true</secure-device>
<language>en</language>
<country>US</country>
<locale>en_US</locale>
<time-zone-auto>true</time-zone-auto>
<time-zone>US/Central</time-zone>
<time-zone-name>United States/Central</time-zone-name>
<time-zone-tz>America/Chicago</time-zone-tz>
<time-zone-offset>-300</time-zone-offset>
<clock-format>12-hour</clock-format>
<uptime>264789</uptime>
<power-mode>PowerOn</power-mode>
<supports-suspend>true</supports-suspend>
<supports-find-remote>true</supports-find-remote>
<find-remote-is-possible>false</find-remote-is-possible>
<supports-audio-guide>true</supports-audio-guide>
<supports-rva>true</supports-rva>
<developer-enabled>false</developer-enabled>
<keyed-developer-id/>
<search-enabled>true</search-enabled>
<search-channels-enabled>true</search-channels-enabled>
<voice-search-enabled>true</voice-search-enabled>
<notifications-enabled>true</notifications-enabled>
<notifications-first-use>false</notifications-first-use>
<supports-private-listening>true</supports-private-listening>
<supports-private-listening-dtv>true</supports-private-listening-dtv>
<supports-warm-standby>true</supports-warm-standby>
<headphones-connected>false</headphones-connected>
<expert-pq-enabled>0.9</expert-pq-enabled>
<supports-ecs-textedit>true</supports-ecs-textedit>
<supports-ecs-microphone>true</supports-ecs-microphone>
<supports-wake-on-wlan>true</supports-wake-on-wlan>
<has-play-on-roku>true</has-play-on-roku>
<has-mobile-screensaver>true</has-mobile-screensaver>
<support-url>https://www.onntvsupport.com/</support-url>
<grandcentral-version>2.9.57</grandcentral-version>
<trc-version>3.0</trc-version>
<trc-channel-version>2.9.42</trc-channel-version>
<davinci-version>2.8.20</davinci-version>
<has-wifi-extender>false</has-wifi-extender>
<has-wifi-5G-support>true</has-wifi-5G-support>
<can-use-wifi-extender>true</can-use-wifi-extender>
</device-info>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<tv-channel>
<channel>
<number>14.3</number>
<name>getTV</name>
<type>air-digital</type>
<user-hidden>false</user-hidden>
<active-input>true</active-input>
<signal-state>valid</signal-state>
<signal-mode>480i</signal-mode>
<signal-quality>20</signal-quality>
<signal-strength>-75</signal-strength>
<program-title>Airwolf</program-title>
<program-description>The team will travel all around the world in order to shut down a global crime ring.</program-description>
<program-ratings>TV-14-D-V</program-ratings>
<program-analog-audio>none</program-analog-audio>
<program-digital-audio>stereo</program-digital-audio>
<program-audio-languages>eng</program-audio-languages>
<program-audio-formats>AC3</program-audio-formats>
<program-audio-language>eng</program-audio-language>
<program-audio-format>AC3</program-audio-format>
<program-has-cc>true</program-has-cc>
</channel>
</tv-channel>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?>
<tv-channels>
<channel>
<number>1.1</number>
<name>WhatsOn</name>
<type>air-digital</type>
<user-hidden>false</user-hidden>
</channel>
<channel>
<number>1.3</number>
<name>QVC</name>
<type>air-digital</type>
<user-hidden>false</user-hidden>
</channel>
</tv-channels>