diff --git a/homeassistant/components/spider/__init__.py b/homeassistant/components/spider/__init__.py index 125799b394a..f2e9a06fb94 100644 --- a/homeassistant/components/spider/__init__.py +++ b/homeassistant/components/spider/__init__.py @@ -1,29 +1,27 @@ """Support for Spider Smart devices.""" -from datetime import timedelta +import asyncio import logging from spiderpy.spiderapi import SpiderApi, UnauthorizedException import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) -DOMAIN = "spider" - -SPIDER_COMPONENTS = ["climate", "switch"] - -SCAN_INTERVAL = timedelta(seconds=120) - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, } ) }, @@ -31,27 +29,66 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass, config): - """Set up Spider Component.""" +def _spider_startup_wrapper(entry): + """Startup wrapper for spider.""" + api = SpiderApi( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_SCAN_INTERVAL], + ) + return api - username = config[DOMAIN][CONF_USERNAME] - password = config[DOMAIN][CONF_PASSWORD] - refresh_rate = config[DOMAIN][CONF_SCAN_INTERVAL] - try: - api = SpiderApi(username, password, refresh_rate.total_seconds()) - - hass.data[DOMAIN] = { - "controller": api, - "thermostats": api.get_thermostats(), - "power_plugs": api.get_power_plugs(), - } - - for component in SPIDER_COMPONENTS: - load_platform(hass, component, DOMAIN, {}, config) - - _LOGGER.debug("Connection with Spider API succeeded") +async def async_setup(hass, config): + """Set up a config entry.""" + hass.data[DOMAIN] = {} + if DOMAIN not in config: return True + + conf = config[DOMAIN] + + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up Spider via config entry.""" + try: + hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( + _spider_startup_wrapper, entry + ) except UnauthorizedException: _LOGGER.error("Can't connect to the Spider API") return False + + 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, entry): + """Unload Spider entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + if not unload_ok: + return False + + hass.data[DOMAIN].pop(entry.entry_id) + + return True diff --git a/homeassistant/components/spider/climate.py b/homeassistant/components/spider/climate.py index 78c77f3679a..015606286e2 100644 --- a/homeassistant/components/spider/climate.py +++ b/homeassistant/components/spider/climate.py @@ -12,7 +12,7 @@ from homeassistant.components.climate.const import ( ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from . import DOMAIN as SPIDER_DOMAIN +from .const import DOMAIN SUPPORT_FAN = ["Auto", "Low", "Medium", "High", "Boost 10", "Boost 20", "Boost 30"] @@ -29,16 +29,13 @@ SPIDER_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_SPIDER.items()} _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Spider thermostat.""" - if discovery_info is None: - return +async def async_setup_entry(hass, config, async_add_entities): + """Initialize a Spider thermostat.""" + api = hass.data[DOMAIN][config.entry_id] - devices = [ - SpiderThermostat(hass.data[SPIDER_DOMAIN]["controller"], device) - for device in hass.data[SPIDER_DOMAIN]["thermostats"] - ] - add_entities(devices, True) + entities = [SpiderThermostat(api, entity) for entity in api.get_thermostats()] + + async_add_entities(entities) class SpiderThermostat(ClimateEntity): diff --git a/homeassistant/components/spider/config_flow.py b/homeassistant/components/spider/config_flow.py new file mode 100644 index 00000000000..e1026f344b0 --- /dev/null +++ b/homeassistant/components/spider/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for Spider.""" +import logging + +from spiderpy.spiderapi import SpiderApi, SpiderApiException, UnauthorizedException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA_USER = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + +RESULT_AUTH_FAILED = "auth_failed" +RESULT_CONN_ERROR = "conn_error" +RESULT_SUCCESS = "success" + + +class SpiderConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Spider config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize the Spider flow.""" + self.data = { + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + } + + def _try_connect(self): + """Try to connect and check auth.""" + try: + SpiderApi( + self.data[CONF_USERNAME], + self.data[CONF_PASSWORD], + self.data[CONF_SCAN_INTERVAL], + ) + except SpiderApiException: + return RESULT_CONN_ERROR + except UnauthorizedException: + return RESULT_AUTH_FAILED + + return RESULT_SUCCESS + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors = {} + if user_input is not None: + self.data[CONF_USERNAME] = user_input["username"] + self.data[CONF_PASSWORD] = user_input["password"] + + result = await self.hass.async_add_executor_job(self._try_connect) + + if result == RESULT_SUCCESS: + return self.async_create_entry(title=DOMAIN, data=self.data,) + if result != RESULT_AUTH_FAILED: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return self.async_abort(reason=result) + + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors, + ) + + async def async_step_import(self, import_data): + """Import spider config from configuration.yaml.""" + return await self.async_step_user(import_data) diff --git a/homeassistant/components/spider/const.py b/homeassistant/components/spider/const.py new file mode 100644 index 00000000000..420767fd221 --- /dev/null +++ b/homeassistant/components/spider/const.py @@ -0,0 +1,6 @@ +"""Constants for the Spider integration.""" + +DOMAIN = "spider" +DEFAULT_SCAN_INTERVAL = 300 + +PLATFORMS = ["climate", "switch"] diff --git a/homeassistant/components/spider/manifest.json b/homeassistant/components/spider/manifest.json index 8fa108f24f7..b285cafcfa9 100644 --- a/homeassistant/components/spider/manifest.json +++ b/homeassistant/components/spider/manifest.json @@ -2,6 +2,11 @@ "domain": "spider", "name": "Itho Daalderop Spider", "documentation": "https://www.home-assistant.io/integrations/spider", - "requirements": ["spiderpy==1.3.1"], - "codeowners": ["@peternijssen"] + "requirements": [ + "spiderpy==1.3.1" + ], + "codeowners": [ + "@peternijssen" + ], + "config_flow": true } diff --git a/homeassistant/components/spider/strings.json b/homeassistant/components/spider/strings.json new file mode 100644 index 00000000000..2e86f47dd2d --- /dev/null +++ b/homeassistant/components/spider/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "title": "Sign-in with mijn.ithodaalderop.nl account", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + } +} diff --git a/homeassistant/components/spider/switch.py b/homeassistant/components/spider/switch.py index 58a45cf7b4d..cea20d8c6be 100644 --- a/homeassistant/components/spider/switch.py +++ b/homeassistant/components/spider/switch.py @@ -3,22 +3,18 @@ import logging from homeassistant.components.switch import SwitchEntity -from . import DOMAIN as SPIDER_DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Spider thermostat.""" - if discovery_info is None: - return +async def async_setup_entry(hass, config, async_add_entities): + """Initialize a Spider thermostat.""" + api = hass.data[DOMAIN][config.entry_id] - devices = [ - SpiderPowerPlug(hass.data[SPIDER_DOMAIN]["controller"], device) - for device in hass.data[SPIDER_DOMAIN]["power_plugs"] - ] + entities = [SpiderPowerPlug(api, entity) for entity in api.get_power_plugs()] - add_entities(devices, True) + async_add_entities(entities) class SpiderPowerPlug(SwitchEntity): diff --git a/homeassistant/components/spider/translations/en.json b/homeassistant/components/spider/translations/en.json new file mode 100644 index 00000000000..0eca909fd09 --- /dev/null +++ b/homeassistant/components/spider/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "title": "Sign-in with your mijn.ithodaalderop.nl account" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9fab383d718..d1f31841a30 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -158,6 +158,7 @@ FLOWS = [ "songpal", "sonos", "speedtestdotnet", + "spider", "spotify", "squeezebox", "starline", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1fac73fb770..182ae7b0506 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -904,6 +904,9 @@ speak2mary==1.4.0 # homeassistant.components.speedtestdotnet speedtest-cli==2.1.2 +# homeassistant.components.spider +spiderpy==1.3.1 + # homeassistant.components.spotify spotipy==2.12.0 diff --git a/tests/components/spider/__init__.py b/tests/components/spider/__init__.py new file mode 100644 index 00000000000..d145f4efc09 --- /dev/null +++ b/tests/components/spider/__init__.py @@ -0,0 +1 @@ +"""Tests for the Spider component.""" diff --git a/tests/components/spider/test_config_flow.py b/tests/components/spider/test_config_flow.py new file mode 100644 index 00000000000..5c2c074027f --- /dev/null +++ b/tests/components/spider/test_config_flow.py @@ -0,0 +1,100 @@ +"""Tests for the Spider config flow.""" +import pytest + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.spider.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.async_mock import Mock, patch +from tests.common import MockConfigEntry + +USERNAME = "spider-username" +PASSWORD = "spider-password" + +SPIDER_USER_DATA = { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, +} + + +@pytest.fixture(name="spider") +def spider_fixture() -> Mock: + """Patch libraries.""" + with patch("homeassistant.components.spider.config_flow.SpiderApi") as spider: + yield spider + + +async def test_user(hass, spider): + """Test user config.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.spider.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.spider.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=SPIDER_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DOMAIN + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert not result["result"].unique_id + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass, spider): + """Test import step.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.spider.async_setup", return_value=True, + ) as mock_setup, patch( + "homeassistant.components.spider.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=SPIDER_USER_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DOMAIN + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert not result["result"].unique_id + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_abort_if_already_setup(hass, spider): + """Test we abort if Spider is already setup.""" + MockConfigEntry(domain=DOMAIN, data=SPIDER_USER_DATA).add_to_hass(hass) + + # Should fail, config exist (import) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=SPIDER_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + # Should fail, config exist (flow) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=SPIDER_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed"