diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index a59e4c64105..65049db8c4f 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -80,6 +80,7 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): hass.data[DOMAIN] = { "config": conf, "devices": {}, + "coordinators": {}, "local_ip": conf.get(CONF_LOCAL_IP, local_ip), } @@ -139,5 +140,9 @@ async def async_unload_entry( hass: HomeAssistantType, config_entry: ConfigEntry ) -> bool: """Unload a UPnP/IGD device from a config entry.""" + udn = config_entry.data.get(CONFIG_ENTRY_UDN) + del hass.data[DOMAIN]["devices"][udn] + del hass.data[DOMAIN]["coordinators"][udn] + _LOGGER.debug("Deleting sensors") return await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 237e97b98a8..8afd3465f07 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -1,4 +1,5 @@ """Config flow for UPNP.""" +from datetime import timedelta from typing import Mapping, Optional import voluptuous as vol @@ -6,6 +7,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import callback from .const import ( # pylint: disable=unused-import CONFIG_ENTRY_SCAN_INTERVAL, @@ -57,9 +59,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( discovery[DISCOVERY_USN], raise_on_progress=False ) - return await self._async_create_entry_from_discovery( - discovery, user_input[CONF_SCAN_INTERVAL] - ) + return await self._async_create_entry_from_discovery(discovery) # Discover devices. discoveries = await Device.async_discover(self.hass) @@ -87,9 +87,6 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): for discovery in self._discoveries } ), - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL, - ): vol.All(vol.Coerce(int), vol.Range(min=30)), } ) return self.async_show_form(step_id="user", data_schema=data_schema,) @@ -127,9 +124,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="no_devices_found") discovery = self._discoveries[0] - return await self._async_create_entry_from_discovery( - discovery, DEFAULT_SCAN_INTERVAL - ) + return await self._async_create_entry_from_discovery(discovery) async def async_step_ssdp(self, discovery_info: Mapping): """Handle a discovered UPnP/IGD device. @@ -170,18 +165,20 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="ssdp_confirm") discovery = self._discoveries[0] - return await self._async_create_entry_from_discovery( - discovery, DEFAULT_SCAN_INTERVAL - ) + return await self._async_create_entry_from_discovery(discovery) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Define the config flow to handle options.""" + return UpnpOptionsFlowHandler(config_entry) async def _async_create_entry_from_discovery( - self, discovery: Mapping, scan_interval + self, discovery: Mapping, ): """Create an entry from discovery.""" _LOGGER.debug( - "_async_create_entry_from_data: discovery: %s, scan_interval: %s", - discovery, - scan_interval, + "_async_create_entry_from_data: discovery: %s", discovery, ) # Get name from device, if not found already. if DISCOVERY_NAME not in discovery and DISCOVERY_LOCATION in discovery: @@ -193,7 +190,6 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data = { CONFIG_ENTRY_UDN: discovery[DISCOVERY_UDN], CONFIG_ENTRY_ST: discovery[DISCOVERY_ST], - CONFIG_ENTRY_SCAN_INTERVAL: scan_interval, } return self.async_create_entry(title=title, data=data) @@ -204,3 +200,37 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.hass, discovery[DISCOVERY_LOCATION] ) return device.name + + +class UpnpOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a UPnP options flow.""" + + def __init__(self, config_entry): + """Initialize.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + udn = self.config_entry.data.get(CONFIG_ENTRY_UDN) + coordinator = self.hass.data[DOMAIN]["coordinators"][udn] + update_interval_sec = user_input.get( + CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ) + update_interval = timedelta(seconds=update_interval_sec) + coordinator.update_interval = update_interval + return self.async_create_entry(title="", data=user_input) + + scan_interval = self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ) + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional(CONF_SCAN_INTERVAL, default=scan_interval,): vol.All( + vol.Coerce(int), vol.Range(min=30) + ), + } + ), + ) diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 14ed442407f..29bdf7429ab 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -88,7 +88,9 @@ async def async_setup_entry( device: Device = hass.data[DOMAIN]["devices"][udn] - update_interval_sec = data.get(CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + update_interval_sec = config_entry.options.get( + CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ) update_interval = timedelta(seconds=update_interval_sec) _LOGGER.debug("update_interval: %s", update_interval) _LOGGER.debug("Adding sensors") @@ -100,6 +102,7 @@ async def async_setup_entry( update_interval=update_interval, ) await coordinator.async_refresh() + hass.data[DOMAIN]["coordinators"][udn] = coordinator sensors = [ RawUpnpSensor(coordinator, device, SENSOR_TYPES[BYTES_RECEIVED]), diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index fc9c8fe8cd5..c0f5b6b4ff2 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -1,7 +1,6 @@ """Test UPnP/IGD config flow.""" -import pytest -import voluptuous as vol +from datetime import timedelta from homeassistant import config_entries, data_entry_flow from homeassistant.components import ssdp @@ -18,10 +17,12 @@ from homeassistant.components.upnp.const import ( ) from homeassistant.components.upnp.device import Device from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.setup import async_setup_component from .mock_device import MockDevice from tests.async_mock import AsyncMock, patch +from tests.common import MockConfigEntry async def test_flow_ssdp_discovery(hass: HomeAssistantType): @@ -61,7 +62,6 @@ async def test_flow_ssdp_discovery(hass: HomeAssistantType): assert result["data"] == { CONFIG_ENTRY_ST: mock_device.device_type, CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, } @@ -99,83 +99,9 @@ async def test_flow_user(hass: HomeAssistantType): assert result["data"] == { CONFIG_ENTRY_ST: mock_device.device_type, CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, } -async def test_flow_user_update_interval(hass: HomeAssistantType): - """Test config flow: discovered + configured through user with non-default scan_interval.""" - udn = "uuid:device_1" - mock_device = MockDevice(udn) - usn = f"{mock_device.udn}::{mock_device.device_type}" - scan_interval = 60 - discovery_infos = [ - { - DISCOVERY_USN: usn, - DISCOVERY_ST: mock_device.device_type, - DISCOVERY_UDN: mock_device.udn, - DISCOVERY_LOCATION: "dummy", - } - ] - - with patch.object( - Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object(Device, "async_discover", AsyncMock(return_value=discovery_infos)): - # Discovered via step user. - 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" - - # Confirmed via step user. - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"usn": usn, CONFIG_ENTRY_SCAN_INTERVAL: scan_interval}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == mock_device.name - assert result["data"] == { - CONFIG_ENTRY_ST: mock_device.device_type, - CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_SCAN_INTERVAL: scan_interval, - } - - -async def test_flow_user_update_interval_min_30(hass: HomeAssistantType): - """Test config flow: discovered + configured through user with non-default scan_interval.""" - udn = "uuid:device_1" - mock_device = MockDevice(udn) - usn = f"{mock_device.udn}::{mock_device.device_type}" - scan_interval = 15 - discovery_infos = [ - { - DISCOVERY_USN: usn, - DISCOVERY_ST: mock_device.device_type, - DISCOVERY_UDN: mock_device.udn, - DISCOVERY_LOCATION: "dummy", - } - ] - - with patch.object( - Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object(Device, "async_discover", AsyncMock(return_value=discovery_infos)): - # Discovered via step user. - 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" - - # Confirmed via step user. - with pytest.raises(vol.error.MultipleInvalid): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"usn": usn, CONFIG_ENTRY_SCAN_INTERVAL: scan_interval}, - ) - - async def test_flow_config(hass: HomeAssistantType): """Test config flow: discovered + configured through configuration.yaml.""" udn = "uuid:device_1" @@ -203,5 +129,58 @@ async def test_flow_config(hass: HomeAssistantType): assert result["data"] == { CONFIG_ENTRY_ST: mock_device.device_type, CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, } + + +async def test_options_flow(hass: HomeAssistantType): + """Test options flow.""" + # Set up config entry. + udn = "uuid:device_1" + mock_device = MockDevice(udn) + discovery_infos = [ + { + DISCOVERY_UDN: mock_device.udn, + DISCOVERY_ST: mock_device.device_type, + DISCOVERY_LOCATION: "http://192.168.1.1/desc.xml", + } + ] + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONFIG_ENTRY_UDN: mock_device.udn, + CONFIG_ENTRY_ST: mock_device.device_type, + }, + options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, + ) + config_entry.add_to_hass(hass) + + config = { + # no upnp, ensures no import-flow is started. + } + async_discover = AsyncMock(return_value=discovery_infos) + with patch.object( + Device, "async_create_device", AsyncMock(return_value=mock_device) + ), patch.object(Device, "async_discover", async_discover): + # Initialisation of component. + await async_setup_component(hass, "upnp", config) + await hass.async_block_till_done() + + # DataUpdateCoordinator gets a default of 30 seconds for updates. + coordinator = hass.data[DOMAIN]["coordinators"][mock_device.udn] + assert coordinator.update_interval == timedelta(seconds=DEFAULT_SCAN_INTERVAL) + + # Options flow with no input results in form. + result = await hass.config_entries.options.async_init(config_entry.entry_id,) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + # Options flow with input results in update to entry. + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONFIG_ENTRY_SCAN_INTERVAL: 60}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + CONFIG_ENTRY_SCAN_INTERVAL: 60, + } + + # Also updates DataUpdateCoordinator. + assert coordinator.update_interval == timedelta(seconds=60)