From cf8dfdae47570d8cb560546ff72b3fa15c4ada85 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sun, 15 Mar 2020 23:13:04 -0500 Subject: [PATCH] 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 --- .coveragerc | 4 +- .../components/roku/.translations/en.json | 27 ++ homeassistant/components/roku/__init__.py | 139 +++++----- homeassistant/components/roku/config_flow.py | 134 ++++++++++ homeassistant/components/roku/const.py | 6 + homeassistant/components/roku/manifest.json | 12 +- homeassistant/components/roku/media_player.py | 52 ++-- homeassistant/components/roku/remote.py | 62 +++-- homeassistant/components/roku/services.yaml | 2 - homeassistant/components/roku/strings.json | 27 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/ssdp.py | 7 + requirements_test_all.txt | 3 + tests/components/roku/__init__.py | 50 ++++ tests/components/roku/test_config_flow.py | 247 ++++++++++++++++++ tests/components/roku/test_init.py | 68 +++++ 16 files changed, 716 insertions(+), 125 deletions(-) create mode 100644 homeassistant/components/roku/.translations/en.json create mode 100644 homeassistant/components/roku/config_flow.py delete mode 100644 homeassistant/components/roku/services.yaml create mode 100644 homeassistant/components/roku/strings.json create mode 100644 tests/components/roku/__init__.py create mode 100644 tests/components/roku/test_config_flow.py create mode 100644 tests/components/roku/test_init.py 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