mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 17:27:52 +00:00
Add config flow to roku (#31988)
* create a dedicated const.py * add DEFAULT_PORT to const.py * work on config flow conversion. * remove discovery. * work on config flow and add tests. other cleanup. * work on config flow and add tests. other cleanup. * add quality scale to manifest. * work on config flow and add tests. other cleanup. * review tweaks. * Update manifest.json * catch more specific errors * catch more errors. * impprt specific exceptions * import specific exceptions * Update __init__.py * Update config_flow.py * Update media_player.py * Update remote.py * Update media_player.py * Update remote.py * Update media_player.py * Update remote.py * Update config_flow.py * Update config_flow.py * Update media_player.py * Update __init__.py * Update __init__.py * Update config_flow.py * Update test_config_flow.py * Update config_flow.py * Update __init__.py * Update test_config_flow.py * Update remote.py * Update test_init.py * Update test_init.py * Update media_player.py * Update media_player.py * Update media_player.py
This commit is contained in:
parent
6e95b90f42
commit
cf8dfdae47
@ -592,7 +592,9 @@ omit =
|
||||
homeassistant/components/ring/camera.py
|
||||
homeassistant/components/ripple/sensor.py
|
||||
homeassistant/components/rocketchat/notify.py
|
||||
homeassistant/components/roku/*
|
||||
homeassistant/components/roku/__init__.py
|
||||
homeassistant/components/roku/media_player.py
|
||||
homeassistant/components/roku/remote.py
|
||||
homeassistant/components/roomba/vacuum.py
|
||||
homeassistant/components/route53/*
|
||||
homeassistant/components/rova/sensor.py
|
||||
|
27
homeassistant/components/roku/.translations/en.json
Normal file
27
homeassistant/components/roku/.translations/en.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Roku device is already configured"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect, please try again",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"flow_title": "Roku: {name}",
|
||||
"step": {
|
||||
"ssdp_confirm": {
|
||||
"data": {},
|
||||
"description": "Do you want to set up {name}? Manual configurations for this device in the yaml files will be overwritten.",
|
||||
"title": "Roku"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Host or IP address"
|
||||
},
|
||||
"description": "Enter your Roku information.",
|
||||
"title": "Roku"
|
||||
}
|
||||
},
|
||||
"title": "Roku"
|
||||
}
|
||||
}
|
@ -1,29 +1,22 @@
|
||||
"""Support for Roku."""
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from socket import gaierror as SocketGIAError
|
||||
from typing import Dict
|
||||
|
||||
from requests.exceptions import RequestException
|
||||
from roku import Roku, RokuException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.discovery import SERVICE_ROKU
|
||||
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.helpers import discovery
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "roku"
|
||||
|
||||
SERVICE_SCAN = "roku_scan"
|
||||
|
||||
ATTR_ROKU = "roku"
|
||||
|
||||
DATA_ROKU = "data_roku"
|
||||
|
||||
NOTIFICATION_ID = "roku_notification"
|
||||
NOTIFICATION_TITLE = "Roku Setup"
|
||||
NOTIFICATION_SCAN_ID = "roku_scan_notification"
|
||||
NOTIFICATION_SCAN_TITLE = "Roku Scan"
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
@ -34,77 +27,67 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
# Currently no attributes but it might change later
|
||||
ROKU_SCAN_SCHEMA = vol.Schema({})
|
||||
PLATFORMS = [MEDIA_PLAYER_DOMAIN, REMOTE_DOMAIN]
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Set up the Roku component."""
|
||||
hass.data[DATA_ROKU] = {}
|
||||
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
|
||||
|
||||
def service_handler(service):
|
||||
"""Handle service calls."""
|
||||
if service.service == SERVICE_SCAN:
|
||||
scan_for_rokus(hass)
|
||||
return {
|
||||
DATA_CLIENT: roku,
|
||||
DATA_DEVICE_INFO: roku_device_info,
|
||||
}
|
||||
|
||||
def roku_discovered(service, info):
|
||||
"""Set up an Roku that was auto discovered."""
|
||||
_setup_roku(hass, config, {CONF_HOST: info["host"]})
|
||||
|
||||
discovery.listen(hass, SERVICE_ROKU, roku_discovered)
|
||||
async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
|
||||
"""Set up the Roku integration."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
for conf in config.get(DOMAIN, []):
|
||||
_setup_roku(hass, config, conf)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_SCAN, service_handler, schema=ROKU_SCAN_SCHEMA
|
||||
)
|
||||
if DOMAIN in config:
|
||||
for entry_config in config[DOMAIN]:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=entry_config,
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def scan_for_rokus(hass):
|
||||
"""Scan for devices and present a notification of the ones found."""
|
||||
|
||||
rokus = Roku.discover()
|
||||
|
||||
devices = []
|
||||
for roku in rokus:
|
||||
try:
|
||||
r_info = roku.device_info
|
||||
except RokuException: # skip non-roku device
|
||||
continue
|
||||
devices.append(
|
||||
"Name: {0}<br />Host: {1}<br />".format(
|
||||
r_info.userdevicename
|
||||
if r_info.userdevicename
|
||||
else f"{r_info.modelname} {r_info.serial_num}",
|
||||
roku.host,
|
||||
)
|
||||
async def async_setup_entry(hass: HomeAssistant, 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],
|
||||
)
|
||||
if not devices:
|
||||
devices = ["No device(s) found"]
|
||||
except (SocketGIAError, RequestException, RokuException) as exception:
|
||||
raise ConfigEntryNotReady from exception
|
||||
|
||||
hass.components.persistent_notification.create(
|
||||
"The following devices were found:<br /><br />" + "<br /><br />".join(devices),
|
||||
title=NOTIFICATION_SCAN_TITLE,
|
||||
notification_id=NOTIFICATION_SCAN_ID,
|
||||
hass.data[DOMAIN][entry.entry_id] = roku_data
|
||||
|
||||
for component in PLATFORMS:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_setup(entry, component)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = all(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_unload(entry, component)
|
||||
for component in PLATFORMS
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
def _setup_roku(hass, hass_config, roku_config):
|
||||
"""Set up a Roku."""
|
||||
|
||||
host = roku_config[CONF_HOST]
|
||||
|
||||
if host in hass.data[DATA_ROKU]:
|
||||
return
|
||||
|
||||
roku = Roku(host)
|
||||
r_info = roku.device_info
|
||||
|
||||
hass.data[DATA_ROKU][host] = {ATTR_ROKU: r_info.serial_num}
|
||||
|
||||
discovery.load_platform(hass, "media_player", DOMAIN, roku_config, hass_config)
|
||||
|
||||
discovery.load_platform(hass, "remote", DOMAIN, roku_config, hass_config)
|
||||
return unload_ok
|
||||
|
134
homeassistant/components/roku/config_flow.py
Normal file
134
homeassistant/components/roku/config_flow.py
Normal file
@ -0,0 +1,134 @@
|
||||
"""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
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.ssdp import (
|
||||
ATTR_SSDP_LOCATION,
|
||||
ATTR_UPNP_FRIENDLY_NAME,
|
||||
ATTR_UPNP_SERIAL,
|
||||
)
|
||||
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 .const import DOMAIN # pylint: disable=unused-import
|
||||
|
||||
DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
|
||||
|
||||
ERROR_CANNOT_CONNECT = "cannot_connect"
|
||||
ERROR_UNKNOWN = "unknown"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_input(data: Dict) -> Dict:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
|
||||
roku = Roku(data["host"])
|
||||
device_info = roku.device_info
|
||||
|
||||
return {
|
||||
"title": data["host"],
|
||||
"host": data["host"],
|
||||
"serial_num": device_info.serial_num,
|
||||
}
|
||||
|
||||
|
||||
class RokuConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Roku config flow."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL
|
||||
|
||||
@callback
|
||||
def _show_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
"""Show the form to the user."""
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors or {},
|
||||
)
|
||||
|
||||
async def async_step_import(
|
||||
self, user_input: Optional[Dict] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle configuration by yaml file."""
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: Optional[Dict] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle a flow initialized by the user."""
|
||||
if not user_input:
|
||||
return self._show_form()
|
||||
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
info = await self.hass.async_add_executor_job(validate_input, user_input)
|
||||
except (SocketGIAError, RequestException, RokuException):
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
return self._show_form(errors)
|
||||
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()
|
||||
|
||||
return self.async_create_entry(title=info["title"], data=user_input)
|
||||
|
||||
async def async_step_ssdp(
|
||||
self, discovery_info: Optional[Dict] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""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]
|
||||
|
||||
await self.async_set_unique_id(serial_num)
|
||||
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}}
|
||||
)
|
||||
|
||||
return await self.async_step_ssdp_confirm()
|
||||
|
||||
async def async_step_ssdp_confirm(
|
||||
self, user_input: Optional[Dict] = None
|
||||
) -> 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 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 (SocketGIAError, RequestException, RokuException):
|
||||
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 self.async_show_form(
|
||||
step_id="ssdp_confirm", description_placeholders={"name": name},
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
@ -1,2 +1,8 @@
|
||||
"""Constants for the Roku integration."""
|
||||
DOMAIN = "roku"
|
||||
|
||||
DATA_CLIENT = "client"
|
||||
DATA_DEVICE_INFO = "device_info"
|
||||
|
||||
DEFAULT_PORT = 8060
|
||||
DEFAULT_MANUFACTURER = "Roku"
|
||||
|
@ -4,6 +4,14 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/roku",
|
||||
"requirements": ["roku==4.0.0"],
|
||||
"dependencies": [],
|
||||
"after_dependencies": ["discovery"],
|
||||
"codeowners": ["@ctalkington"]
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "roku:ecp",
|
||||
"manufacturer": "Roku",
|
||||
"deviceType": "urn:roku-com:device:player:1-0"
|
||||
}
|
||||
],
|
||||
"codeowners": ["@ctalkington"],
|
||||
"quality_scale": "silver",
|
||||
"config_flow": true
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
"""Support for the Roku media player."""
|
||||
import logging
|
||||
|
||||
import requests.exceptions
|
||||
from roku import Roku
|
||||
from requests.exceptions import (
|
||||
ConnectionError as RequestsConnectionError,
|
||||
ReadTimeout as RequestsReadTimeout,
|
||||
)
|
||||
from roku import RokuException
|
||||
|
||||
from homeassistant.components.media_player import MediaPlayerDevice
|
||||
from homeassistant.components.media_player.const import (
|
||||
@ -16,17 +17,9 @@ from homeassistant.components.media_player.const import (
|
||||
SUPPORT_VOLUME_MUTE,
|
||||
SUPPORT_VOLUME_SET,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
STATE_HOME,
|
||||
STATE_IDLE,
|
||||
STATE_PLAYING,
|
||||
STATE_STANDBY,
|
||||
)
|
||||
from homeassistant.const import STATE_HOME, STATE_IDLE, STATE_PLAYING, STATE_STANDBY
|
||||
|
||||
from .const import DEFAULT_PORT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .const import DATA_CLIENT, DEFAULT_MANUFACTURER, DEFAULT_PORT, DOMAIN
|
||||
|
||||
SUPPORT_ROKU = (
|
||||
SUPPORT_PREVIOUS_TRACK
|
||||
@ -40,23 +33,19 @@ SUPPORT_ROKU = (
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the Roku platform."""
|
||||
if not discovery_info:
|
||||
return
|
||||
|
||||
host = discovery_info[CONF_HOST]
|
||||
async_add_entities([RokuDevice(host)], True)
|
||||
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)
|
||||
|
||||
|
||||
class RokuDevice(MediaPlayerDevice):
|
||||
"""Representation of a Roku device on the network."""
|
||||
|
||||
def __init__(self, host):
|
||||
def __init__(self, roku):
|
||||
"""Initialize the Roku device."""
|
||||
|
||||
self.roku = Roku(host)
|
||||
self.ip_address = host
|
||||
self.roku = roku
|
||||
self.ip_address = roku.host
|
||||
self.channels = []
|
||||
self.current_app = None
|
||||
self._available = False
|
||||
@ -77,7 +66,7 @@ class RokuDevice(MediaPlayerDevice):
|
||||
self.current_app = None
|
||||
|
||||
self._available = True
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout):
|
||||
except (RequestsConnectionError, RequestsReadTimeout, RokuException):
|
||||
self._available = False
|
||||
pass
|
||||
|
||||
@ -130,6 +119,17 @@ class RokuDevice(MediaPlayerDevice):
|
||||
"""Return a unique, Home Assistant friendly identifier for this entity."""
|
||||
return self._device_info.serial_num
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return device specific attributes."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"identifiers": {(DOMAIN, self.unique_id)},
|
||||
"manufacturer": DEFAULT_MANUFACTURER,
|
||||
"model": self._device_info.model_num,
|
||||
"sw_version": self._device_info.software_version,
|
||||
}
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
"""Content type of current playing media."""
|
||||
|
@ -1,34 +1,48 @@
|
||||
"""Support for the Roku remote."""
|
||||
import requests.exceptions
|
||||
from roku import Roku
|
||||
from typing import Callable, List
|
||||
|
||||
from homeassistant.components import remote
|
||||
from homeassistant.const import CONF_HOST
|
||||
from requests.exceptions import (
|
||||
ConnectionError as RequestsConnectionError,
|
||||
ReadTimeout as RequestsReadTimeout,
|
||||
)
|
||||
from roku import RokuException
|
||||
|
||||
from homeassistant.components.remote import RemoteDevice
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from .const import DATA_CLIENT, DEFAULT_MANUFACTURER, DOMAIN
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the Roku remote platform."""
|
||||
if not discovery_info:
|
||||
return
|
||||
|
||||
host = discovery_info[CONF_HOST]
|
||||
async_add_entities([RokuRemote(host)], True)
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType,
|
||||
entry: ConfigEntry,
|
||||
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)
|
||||
|
||||
|
||||
class RokuRemote(remote.RemoteDevice):
|
||||
class RokuRemote(RemoteDevice):
|
||||
"""Device that sends commands to an Roku."""
|
||||
|
||||
def __init__(self, host):
|
||||
def __init__(self, roku):
|
||||
"""Initialize the Roku device."""
|
||||
|
||||
self.roku = Roku(host)
|
||||
self.roku = roku
|
||||
self._available = False
|
||||
self._device_info = {}
|
||||
|
||||
def update(self):
|
||||
"""Retrieve latest state."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
self._device_info = self.roku.device_info
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout):
|
||||
self._available = True
|
||||
except (RequestsConnectionError, RequestsReadTimeout, RokuException):
|
||||
self._available = False
|
||||
pass
|
||||
|
||||
@property
|
||||
@ -38,11 +52,27 @@ class RokuRemote(remote.RemoteDevice):
|
||||
return self._device_info.user_device_name
|
||||
return f"Roku {self._device_info.serial_num}"
|
||||
|
||||
@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):
|
||||
"""Return true if device is on."""
|
||||
|
@ -1,2 +0,0 @@
|
||||
roku_scan:
|
||||
description: Scans the local network for Rokus. All found devices are presented as a persistent notification.
|
27
homeassistant/components/roku/strings.json
Normal file
27
homeassistant/components/roku/strings.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "Roku",
|
||||
"flow_title": "Roku: {name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Roku",
|
||||
"description": "Enter your Roku information.",
|
||||
"data": {
|
||||
"host": "Host or IP address"
|
||||
}
|
||||
},
|
||||
"ssdp_confirm": {
|
||||
"title": "Roku",
|
||||
"description": "Do you want to set up {name}? Manual configurations for this device in the yaml files will be overwritten.",
|
||||
"data": {}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect, please try again",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Roku device is already configured"
|
||||
}
|
||||
}
|
||||
}
|
@ -83,6 +83,7 @@ FLOWS = [
|
||||
"rachio",
|
||||
"rainmachine",
|
||||
"ring",
|
||||
"roku",
|
||||
"samsungtv",
|
||||
"sense",
|
||||
"sentry",
|
||||
|
@ -47,6 +47,13 @@ SSDP = {
|
||||
"manufacturer": "konnected.io"
|
||||
}
|
||||
],
|
||||
"roku": [
|
||||
{
|
||||
"deviceType": "urn:roku-com:device:player:1-0",
|
||||
"manufacturer": "Roku",
|
||||
"st": "roku:ecp"
|
||||
}
|
||||
],
|
||||
"samsungtv": [
|
||||
{
|
||||
"st": "urn:samsung.com:device:RemoteControlReceiver:1"
|
||||
|
@ -628,6 +628,9 @@ rflink==0.0.52
|
||||
# homeassistant.components.ring
|
||||
ring_doorbell==0.6.0
|
||||
|
||||
# homeassistant.components.roku
|
||||
roku==4.0.0
|
||||
|
||||
# homeassistant.components.yamaha
|
||||
rxv==0.6.0
|
||||
|
||||
|
50
tests/components/roku/__init__.py
Normal file
50
tests/components/roku/__init__.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""Tests for the Roku component."""
|
||||
from homeassistant.components.roku.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
HOST = "1.2.3.4"
|
||||
NAME = "Roku 3"
|
||||
SSDP_LOCATION = "http://1.2.3.4/"
|
||||
UPNP_FRIENDLY_NAME = "My Roku 3"
|
||||
UPNP_SERIAL = "1GU48T017973"
|
||||
|
||||
|
||||
class MockDeviceInfo(object):
|
||||
"""Mock DeviceInfo for Roku."""
|
||||
|
||||
model_name = NAME
|
||||
model_num = "4200X"
|
||||
software_version = "7.5.0.09021"
|
||||
serial_num = UPNP_SERIAL
|
||||
user_device_name = UPNP_FRIENDLY_NAME
|
||||
roku_type = "Box"
|
||||
|
||||
def __repr__(self):
|
||||
"""Return the object representation of DeviceInfo."""
|
||||
return "<DeviceInfo: %s-%s, SW v%s, Ser# %s (%s)>" % (
|
||||
self.model_name,
|
||||
self.model_num,
|
||||
self.software_version,
|
||||
self.serial_num,
|
||||
self.roku_type,
|
||||
)
|
||||
|
||||
|
||||
async def setup_integration(
|
||||
hass: HomeAssistantType, skip_entry_setup: bool = False
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the Roku integration in Home Assistant."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, unique_id=UPNP_SERIAL, data={CONF_HOST: HOST}
|
||||
)
|
||||
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
if not skip_entry_setup:
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return entry
|
247
tests/components/roku/test_config_flow.py
Normal file
247
tests/components/roku/test_config_flow.py
Normal file
@ -0,0 +1,247 @@
|
||||
"""Test the Roku config flow."""
|
||||
from socket import gaierror as SocketGIAError
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from asynctest import patch
|
||||
from requests.exceptions import RequestException
|
||||
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 (
|
||||
RESULT_TYPE_ABORT,
|
||||
RESULT_TYPE_CREATE_ENTRY,
|
||||
RESULT_TYPE_FORM,
|
||||
)
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.components.roku import (
|
||||
HOST,
|
||||
SSDP_LOCATION,
|
||||
UPNP_FRIENDLY_NAME,
|
||||
UPNP_SERIAL,
|
||||
MockDeviceInfo,
|
||||
setup_integration,
|
||||
)
|
||||
|
||||
|
||||
async def async_configure_flow(
|
||||
hass: HomeAssistantType, flow_id: str, user_input: Optional[Dict] = None
|
||||
) -> Any:
|
||||
"""Set up mock Roku integration flow."""
|
||||
with patch(
|
||||
"homeassistant.components.roku.config_flow.Roku.device_info",
|
||||
new=MockDeviceInfo,
|
||||
):
|
||||
return await hass.config_entries.flow.async_configure(
|
||||
flow_id=flow_id, user_input=user_input
|
||||
)
|
||||
|
||||
|
||||
async def async_init_flow(
|
||||
hass: HomeAssistantType,
|
||||
handler: str = DOMAIN,
|
||||
context: Optional[Dict] = None,
|
||||
data: Any = None,
|
||||
) -> Any:
|
||||
"""Set up mock Roku integration flow."""
|
||||
with patch(
|
||||
"homeassistant.components.roku.config_flow.Roku.device_info",
|
||||
new=MockDeviceInfo,
|
||||
):
|
||||
return await hass.config_entries.flow.async_init(
|
||||
handler=handler, context=context, data=data
|
||||
)
|
||||
|
||||
|
||||
async def test_duplicate_error(hass: HomeAssistantType) -> None:
|
||||
"""Test that errors are shown when duplicates are added."""
|
||||
await setup_integration(hass, skip_entry_setup=True)
|
||||
|
||||
result = await async_init_flow(
|
||||
hass, context={CONF_SOURCE: SOURCE_IMPORT}, data={CONF_HOST: HOST}
|
||||
)
|
||||
|
||||
assert result["type"] == RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
result = await async_init_flow(
|
||||
hass, context={CONF_SOURCE: SOURCE_USER}, data={CONF_HOST: HOST}
|
||||
)
|
||||
|
||||
assert result["type"] == RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
result = await async_init_flow(
|
||||
hass,
|
||||
context={CONF_SOURCE: SOURCE_SSDP},
|
||||
data={
|
||||
ATTR_UPNP_FRIENDLY_NAME: UPNP_FRIENDLY_NAME,
|
||||
ATTR_SSDP_LOCATION: SSDP_LOCATION,
|
||||
ATTR_UPNP_SERIAL: UPNP_SERIAL,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_form(hass: HomeAssistantType) -> None:
|
||||
"""Test the user step."""
|
||||
await async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == RESULT_TYPE_FORM
|
||||
assert result["errors"] == {}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.roku.async_setup", return_value=True
|
||||
) as mock_setup, patch(
|
||||
"homeassistant.components.roku.async_setup_entry", return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result = await async_configure_flow(hass, result["flow_id"], {CONF_HOST: HOST})
|
||||
|
||||
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||
assert result["title"] == HOST
|
||||
assert result["data"] == {CONF_HOST: HOST}
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_cannot_connect(hass: HomeAssistantType) -> None:
|
||||
"""Test we handle cannot connect roku error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.roku.config_flow.validate_input",
|
||||
side_effect=RokuException,
|
||||
) as mock_validate_input:
|
||||
result = await async_configure_flow(hass, result["flow_id"], {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}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.roku.config_flow.validate_input",
|
||||
side_effect=RequestException,
|
||||
) as mock_validate_input:
|
||||
result = await async_configure_flow(hass, result["flow_id"], {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_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}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.roku.config_flow.validate_input",
|
||||
side_effect=SocketGIAError,
|
||||
) as mock_validate_input:
|
||||
result = await async_configure_flow(hass, result["flow_id"], {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_unknown_error(hass: HomeAssistantType) -> None:
|
||||
"""Test we handle unknown error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.roku.config_flow.validate_input",
|
||||
side_effect=Exception,
|
||||
) as mock_validate_input:
|
||||
result = await async_configure_flow(hass, result["flow_id"], {CONF_HOST: HOST},)
|
||||
|
||||
assert result["type"] == RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "unknown"
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_validate_input.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_import(hass: HomeAssistantType) -> None:
|
||||
"""Test the import step."""
|
||||
with patch(
|
||||
"homeassistant.components.roku.async_setup", return_value=True
|
||||
) as mock_setup, patch(
|
||||
"homeassistant.components.roku.async_setup_entry", return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result = await async_init_flow(
|
||||
hass, context={CONF_SOURCE: SOURCE_IMPORT}, data={CONF_HOST: HOST}
|
||||
)
|
||||
|
||||
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||
assert result["title"] == HOST
|
||||
assert result["data"] == {CONF_HOST: HOST}
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_ssdp_discovery(hass: HomeAssistantType) -> None:
|
||||
"""Test the ssdp discovery step."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={CONF_SOURCE: SOURCE_SSDP},
|
||||
data={
|
||||
ATTR_SSDP_LOCATION: SSDP_LOCATION,
|
||||
ATTR_UPNP_FRIENDLY_NAME: UPNP_FRIENDLY_NAME,
|
||||
ATTR_UPNP_SERIAL: UPNP_SERIAL,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "ssdp_confirm"
|
||||
assert result["description_placeholders"] == {CONF_NAME: UPNP_FRIENDLY_NAME}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.roku.async_setup", return_value=True
|
||||
) as mock_setup, patch(
|
||||
"homeassistant.components.roku.async_setup_entry", return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result = await async_configure_flow(hass, result["flow_id"], {})
|
||||
|
||||
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||
assert result["title"] == UPNP_FRIENDLY_NAME
|
||||
assert result["data"] == {
|
||||
CONF_HOST: HOST,
|
||||
CONF_NAME: UPNP_FRIENDLY_NAME,
|
||||
}
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
68
tests/components/roku/test_init.py
Normal file
68
tests/components/roku/test_init.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""Tests for the Roku integration."""
|
||||
from socket import gaierror as SocketGIAError
|
||||
|
||||
from asynctest import patch
|
||||
from requests.exceptions import RequestException
|
||||
from roku import RokuException
|
||||
|
||||
from homeassistant.components.roku.const import DOMAIN
|
||||
from homeassistant.config_entries import (
|
||||
ENTRY_STATE_LOADED,
|
||||
ENTRY_STATE_NOT_LOADED,
|
||||
ENTRY_STATE_SETUP_RETRY,
|
||||
)
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from tests.components.roku import MockDeviceInfo, setup_integration
|
||||
|
||||
|
||||
async def test_config_entry_not_ready(hass: HomeAssistantType) -> None:
|
||||
"""Test the Roku configuration entry not ready."""
|
||||
with patch(
|
||||
"homeassistant.components.roku.Roku._call", side_effect=RokuException,
|
||||
):
|
||||
entry = await setup_integration(hass)
|
||||
|
||||
assert entry.state == ENTRY_STATE_SETUP_RETRY
|
||||
|
||||
|
||||
async def test_config_entry_not_ready_request(hass: HomeAssistantType) -> None:
|
||||
"""Test the Roku configuration entry not ready."""
|
||||
with patch(
|
||||
"homeassistant.components.roku.Roku._call", side_effect=RequestException,
|
||||
):
|
||||
entry = await setup_integration(hass)
|
||||
|
||||
assert entry.state == ENTRY_STATE_SETUP_RETRY
|
||||
|
||||
|
||||
async def test_config_entry_not_ready_socket(hass: HomeAssistantType) -> None:
|
||||
"""Test the Roku configuration entry not ready."""
|
||||
with patch(
|
||||
"homeassistant.components.roku.Roku._call", side_effect=SocketGIAError,
|
||||
):
|
||||
entry = await setup_integration(hass)
|
||||
|
||||
assert entry.state == ENTRY_STATE_SETUP_RETRY
|
||||
|
||||
|
||||
async def test_unload_config_entry(hass: HomeAssistantType) -> None:
|
||||
"""Test the Roku configuration entry unloading."""
|
||||
with patch(
|
||||
"homeassistant.components.roku.Roku.device_info", return_value=MockDeviceInfo,
|
||||
), patch(
|
||||
"homeassistant.components.roku.media_player.async_setup_entry",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"homeassistant.components.roku.remote.async_setup_entry", return_value=True,
|
||||
):
|
||||
entry = await setup_integration(hass)
|
||||
|
||||
assert hass.data[DOMAIN][entry.entry_id]
|
||||
assert entry.state == ENTRY_STATE_LOADED
|
||||
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.entry_id not in hass.data[DOMAIN]
|
||||
assert entry.state == ENTRY_STATE_NOT_LOADED
|
Loading…
x
Reference in New Issue
Block a user