From 61e8ab13009551a6a9cf2ba39c0719e767487b18 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 6 Sep 2020 22:26:14 +0200 Subject: [PATCH] Add Plugwise scan_interval config option (#37229) Co-authored-by: Tom Scholten Co-authored-by: Tom Scholten --- homeassistant/components/plugwise/__init__.py | 31 +++- .../components/plugwise/binary_sensor.py | 11 +- homeassistant/components/plugwise/climate.py | 11 +- .../components/plugwise/config_flow.py | 43 ++++- homeassistant/components/plugwise/const.py | 3 + homeassistant/components/plugwise/sensor.py | 3 +- .../components/plugwise/strings.json | 10 ++ homeassistant/components/plugwise/switch.py | 4 +- tests/components/plugwise/test_config_flow.py | 170 ++++++++++++++++-- 9 files changed, 255 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index 3cfff3a8521..8c140f65af9 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -10,6 +10,7 @@ import async_timeout import voluptuous as vol from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -20,7 +21,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import DOMAIN +from .const import COORDINATOR, DEFAULT_SCAN_INTERVAL, DOMAIN, UNDO_UPDATE_LISTENER CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) @@ -39,7 +40,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Plugwise Smiles from a config entry.""" websession = async_get_clientsession(hass, verify_ssl=False) api = Smile( - host=entry.data["host"], password=entry.data["password"], websession=websession + host=entry.data[CONF_HOST], + password=entry.data[CONF_PASSWORD], + websession=websession, ) try: @@ -61,9 +64,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Timeout while connecting to Smile") raise ConfigEntryNotReady from err - update_interval = timedelta(seconds=60) - if api.smile_type == "power": - update_interval = timedelta(seconds=10) + update_interval = timedelta( + seconds=entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL[api.smile_type] + ) + ) async def async_update_data(): """Update data via API endpoint.""" @@ -89,9 +94,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api.get_all_devices() + undo_listener = entry.add_update_listener(_update_listener) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { "api": api, - "coordinator": coordinator, + COORDINATOR: coordinator, + UNDO_UPDATE_LISTENER: undo_listener, } device_registry = await dr.async_get_registry(hass) @@ -118,6 +126,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def _update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + coordinator.update_interval = timedelta( + seconds=entry.options.get(CONF_SCAN_INTERVAL) + ) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" unload_ok = all( @@ -128,6 +144,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): ] ) ) + + hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() + if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index d6b6424c7ce..67dcc10a289 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -6,7 +6,14 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import callback -from .const import DOMAIN, FLAME_ICON, FLOW_OFF_ICON, FLOW_ON_ICON, IDLE_ICON +from .const import ( + COORDINATOR, + DOMAIN, + FLAME_ICON, + FLOW_OFF_ICON, + FLOW_ON_ICON, + IDLE_ICON, +) from .sensor import SmileSensor BINARY_SENSOR_MAP = { @@ -20,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Smile binary_sensors from a config entry.""" api = hass.data[DOMAIN][config_entry.entry_id]["api"] - coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] entities = [] diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index dbc9e54e0d7..c6e34ed46cb 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -19,7 +19,14 @@ from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import callback from . import SmileGateway -from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN, SCHEDULE_OFF, SCHEDULE_ON +from .const import ( + COORDINATOR, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + DOMAIN, + SCHEDULE_OFF, + SCHEDULE_ON, +) HVAC_MODES_HEAT_ONLY = [HVAC_MODE_HEAT, HVAC_MODE_AUTO] HVAC_MODES_HEAT_COOL = [HVAC_MODE_HEAT_COOL, HVAC_MODE_AUTO] @@ -32,7 +39,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Smile Thermostats from a config entry.""" api = hass.data[DOMAIN][config_entry.entry_id]["api"] - coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] entities = [] thermostat_classes = [ diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index 1f86394775a..4d1752a2774 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -5,11 +5,12 @@ from Plugwise_Smile.Smile import Smile import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SCAN_INTERVAL +from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import DiscoveryInfoType -from .const import DOMAIN # pylint:disable=unused-import +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN # pylint:disable=unused-import _LOGGER = logging.getLogger(__name__) @@ -56,7 +57,7 @@ async def validate_input(hass: core.HomeAssistant, data): return api -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Plugwise Smile.""" VERSION = 1 @@ -98,7 +99,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: api = await validate_input(self.hass, user_input) - return self.async_create_entry(title=api.smile_name, data=user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -109,13 +109,46 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not errors: await self.async_set_unique_id(api.gateway_id) + self._abort_if_unique_id_configured() return self.async_create_entry(title=api.smile_name, data=user_input) return self.async_show_form( - step_id="user", data_schema=_base_schema(self.discovery_info), errors=errors + step_id="user", + data_schema=_base_schema(self.discovery_info), + errors=errors or {}, ) + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return PlugwiseOptionsFlowHandler(config_entry) + + +class PlugwiseOptionsFlowHandler(config_entries.OptionsFlow): + """Plugwise option flow.""" + + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the Plugwise options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + api = self.hass.data[DOMAIN][self.config_entry.entry_id]["api"] + interval = DEFAULT_SCAN_INTERVAL[api.smile_type] + data = { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get(CONF_SCAN_INTERVAL, interval), + ): int + } + + return self.async_show_form(step_id="init", data_schema=vol.Schema(data)) + class CannotConnect(exceptions.HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index 9dc4c24b1e1..6feceff2d3c 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -39,3 +39,6 @@ FLAME_ICON = "mdi:fire" IDLE_ICON = "mdi:circle-off-outline" FLOW_OFF_ICON = "mdi:water-pump-off" FLOW_ON_ICON = "mdi:water-pump" + +UNDO_UPDATE_LISTENER = "undo_update_listener" +COORDINATOR = "coordinator" diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 1e1cb607ff4..787f4630001 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -22,6 +22,7 @@ from homeassistant.helpers.entity import Entity from . import SmileGateway from .const import ( COOL_ICON, + COORDINATOR, DEVICE_STATE, DOMAIN, FLAME_ICON, @@ -168,7 +169,7 @@ CUSTOM_ICONS = { async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Smile sensors from a config entry.""" api = hass.data[DOMAIN][config_entry.entry_id]["api"] - coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] entities = [] all_devices = api.get_all_devices() diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 70c1d127390..7dc8542698b 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -1,4 +1,14 @@ { + "options": { + "step": { + "init": { + "description": "Adjust Plugwise Options", + "data": { + "scan_interval": "Scan Interval (seconds)" + } + } + } + }, "config": { "step": { "user": { diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index bd831e2f9aa..a34eabe3d2e 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -8,7 +8,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.core import callback from . import SmileGateway -from .const import DOMAIN +from .const import COORDINATOR, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Smile switches from a config entry.""" api = hass.data[DOMAIN][config_entry.entry_id]["api"] - coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] entities = [] all_devices = api.get_all_devices() diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index 4d8a78f11c8..6044381ac51 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -2,10 +2,28 @@ from Plugwise_Smile.Smile import Smile import pytest -from homeassistant import config_entries, setup -from homeassistant.components.plugwise.const import DOMAIN +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.plugwise import config_flow +from homeassistant.components.plugwise.const import DEFAULT_SCAN_INTERVAL, DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_SCAN_INTERVAL -from tests.async_mock import patch +from tests.async_mock import MagicMock, patch +from tests.common import MockConfigEntry + +TEST_HOST = "1.1.1.1" +TEST_HOSTNAME = "smileabcdef" +TEST_PASSWORD = "test_password" +TEST_DISCOVERY = { + "host": TEST_HOST, + "hostname": f"{TEST_HOSTNAME}.local.", + "server": f"{TEST_HOSTNAME}.local.", + "properties": { + "product": "smile", + "version": "1.2.3", + "hostname": f"{TEST_HOSTNAME}.local.", + }, +} @pytest.fixture(name="mock_smile") @@ -25,9 +43,9 @@ async def test_form(hass): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {} with patch( @@ -42,15 +60,56 @@ async def test_form(hass): ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1", "password": "test-password"}, + {"host": TEST_HOST, "password": TEST_PASSWORD}, ) - assert result2["type"] == "create_entry" + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result2["data"] == { - "host": "1.1.1.1", - "password": "test-password", + "host": TEST_HOST, + "password": TEST_PASSWORD, } await hass.async_block_till_done() + + assert result["errors"] == {} + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=TEST_DISCOVERY, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.plugwise.config_flow.Smile.connect", + return_value=True, + ), patch( + "homeassistant.components.plugwise.async_setup", + return_value=True, + ) as mock_setup, patch( + "homeassistant.components.plugwise.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": TEST_PASSWORD}, + ) + + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == { + "host": TEST_HOST, + "password": TEST_PASSWORD, + } + + assert result["errors"] == {} assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -66,10 +125,10 @@ async def test_form_invalid_auth(hass, mock_smile): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1", "password": "test-password"}, + {"host": TEST_HOST, "password": TEST_PASSWORD}, ) - assert result2["type"] == "form" + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["errors"] == {"base": "invalid_auth"} @@ -84,8 +143,93 @@ async def test_form_cannot_connect(hass, mock_smile): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1", "password": "test-password"}, + {"host": TEST_HOST, "password": TEST_PASSWORD}, ) - assert result2["type"] == "form" + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_other_problem(hass, mock_smile): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_smile.connect.side_effect = TimeoutError + mock_smile.gateway_id = "0a636a4fc1704ab4a24e4f7e37fb187a" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": TEST_HOST, "password": TEST_PASSWORD}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_show_zeroconf_form(hass, mock_smile) -> None: + """Test that the zeroconf confirmation form is served.""" + flow = config_flow.PlugwiseConfigFlow() + flow.hass = hass + flow.context = {"source": SOURCE_ZEROCONF} + result = await flow.async_step_zeroconf(TEST_DISCOVERY) + + await hass.async_block_till_done() + assert flow.context["title_placeholders"][CONF_HOST] == TEST_HOST + assert flow.context["title_placeholders"]["name"] == "P1 DSMR v1.2.3" + + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + +async def test_options_flow_power(hass, mock_smile) -> None: + """Test config flow options DSMR environments.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=CONF_NAME, + data={CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD}, + options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, + ) + + hass.data[DOMAIN] = {entry.entry_id: {"api": MagicMock(smile_type="power")}} + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_SCAN_INTERVAL: 10} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_SCAN_INTERVAL: 10, + } + + +async def test_options_flow_thermo(hass, mock_smile) -> None: + """Test config flow options for thermostatic environments.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=CONF_NAME, + data={CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD}, + options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, + ) + + hass.data[DOMAIN] = {entry.entry_id: {"api": MagicMock(smile_type="thermostat")}} + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_SCAN_INTERVAL: 60} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_SCAN_INTERVAL: 60, + }