diff --git a/.coveragerc b/.coveragerc
index 83285b9bd6c..555dccadde7 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -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
diff --git a/homeassistant/components/roku/.translations/en.json b/homeassistant/components/roku/.translations/en.json
new file mode 100644
index 00000000000..8dccd065121
--- /dev/null
+++ b/homeassistant/components/roku/.translations/en.json
@@ -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"
+ }
+}
diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py
index b84b6dd1e63..636260b510c 100644
--- a/homeassistant/components/roku/__init__.py
+++ b/homeassistant/components/roku/__init__.py
@@ -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}
Host: {1}
".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:
" + "
".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
diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py
new file mode 100644
index 00000000000..32e66901e0f
--- /dev/null
+++ b/homeassistant/components/roku/config_flow.py
@@ -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."""
diff --git a/homeassistant/components/roku/const.py b/homeassistant/components/roku/const.py
index 54c52de2622..b06eed5df9f 100644
--- a/homeassistant/components/roku/const.py
+++ b/homeassistant/components/roku/const.py
@@ -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"
diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json
index 20461c789e2..e9cdb897115 100644
--- a/homeassistant/components/roku/manifest.json
+++ b/homeassistant/components/roku/manifest.json
@@ -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
}
diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py
index 21a2f562293..ba923f0fdd2 100644
--- a/homeassistant/components/roku/media_player.py
+++ b/homeassistant/components/roku/media_player.py
@@ -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."""
diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py
index c953d9ba734..548282d6b2f 100644
--- a/homeassistant/components/roku/remote.py
+++ b/homeassistant/components/roku/remote.py
@@ -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."""
diff --git a/homeassistant/components/roku/services.yaml b/homeassistant/components/roku/services.yaml
deleted file mode 100644
index 956ecb0dd2d..00000000000
--- a/homeassistant/components/roku/services.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-roku_scan:
- description: Scans the local network for Rokus. All found devices are presented as a persistent notification.
diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json
new file mode 100644
index 00000000000..0069728d14a
--- /dev/null
+++ b/homeassistant/components/roku/strings.json
@@ -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"
+ }
+ }
+}
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index c19e9fafbc0..0ca18cec442 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -83,6 +83,7 @@ FLOWS = [
"rachio",
"rainmachine",
"ring",
+ "roku",
"samsungtv",
"sense",
"sentry",
diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py
index 3bf54b1d9f7..1df265bffe5 100644
--- a/homeassistant/generated/ssdp.py
+++ b/homeassistant/generated/ssdp.py
@@ -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"
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index abc9d340dba..651471aba6c 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -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
diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py
new file mode 100644
index 00000000000..638a37b193a
--- /dev/null
+++ b/tests/components/roku/__init__.py
@@ -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 "" % (
+ 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
diff --git a/tests/components/roku/test_config_flow.py b/tests/components/roku/test_config_flow.py
new file mode 100644
index 00000000000..93d3fbb938d
--- /dev/null
+++ b/tests/components/roku/test_config_flow.py
@@ -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
diff --git a/tests/components/roku/test_init.py b/tests/components/roku/test_init.py
new file mode 100644
index 00000000000..c9eff43c858
--- /dev/null
+++ b/tests/components/roku/test_init.py
@@ -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