diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index bc762dadcb7..35e924c807c 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -34,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await fritz_tools.async_setup() - await fritz_tools.async_start() + await fritz_tools.async_start(entry.options) except FritzSecurityError as ex: raise ConfigEntryAuthFailed from ex except FritzConnectionException as ex: @@ -53,6 +53,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_unload) ) + entry.async_on_unload(entry.add_update_listener(update_listener)) + # Load the other platforms like switch hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -79,3 +81,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await async_unload_services(hass) return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Update when config_entry options update.""" + if entry.options: + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 47c3ef88681..7fe7069de17 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -16,6 +16,7 @@ from fritzconnection.core.exceptions import ( from fritzconnection.lib.fritzhosts import FritzHosts from fritzconnection.lib.fritzstatus import FritzStatus +from homeassistant.components.device_tracker.const import CONF_CONSIDER_HOME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC @@ -59,6 +60,7 @@ class FritzBoxTools: """Initialize FritzboxTools class.""" self._cancel_scan = None self._devices: dict[str, Any] = {} + self._options = None self._unique_id = None self.connection = None self.fritz_hosts = None @@ -95,10 +97,10 @@ class FritzBoxTools: self.sw_version = info.get("NewSoftwareVersion") self.mac = self.unique_id - async def async_start(self): + async def async_start(self, options): """Start FritzHosts connection.""" self.fritz_hosts = FritzHosts(fc=self.connection) - + self._options = options await self.hass.async_add_executor_job(self.scan_devices) self._cancel_scan = async_track_time_interval( @@ -141,6 +143,8 @@ class FritzBoxTools: """Scan for new devices and return a list of found device ids.""" _LOGGER.debug("Checking devices for FRITZ!Box router %s", self.host) + consider_home = self._options[CONF_CONSIDER_HOME] + new_device = False for known_host in self._update_info(): if not known_host.get("mac"): @@ -154,10 +158,10 @@ class FritzBoxTools: dev_info = Device(dev_mac, dev_ip, dev_name) if dev_mac in self._devices: - self._devices[dev_mac].update(dev_info, dev_home) + self._devices[dev_mac].update(dev_info, dev_home, consider_home) else: device = FritzDevice(dev_mac) - device.update(dev_info, dev_home) + device.update(dev_info, dev_home, consider_home) self._devices[dev_mac] = device new_device = True @@ -204,19 +208,25 @@ class FritzDevice: self._last_activity = None self._connected = False - def update(self, dev_info, dev_home): + def update(self, dev_info, dev_home, consider_home): """Update device info.""" utc_point_in_time = dt_util.utcnow() + if not self._name: self._name = dev_info.name or self._mac.replace(":", "_") - self._connected = dev_home - if not self._connected: + if not dev_home and self._last_activity: + self._connected = ( + utc_point_in_time - self._last_activity + ).total_seconds() < consider_home + else: + self._connected = dev_home + + if self._connected: + self._ip_address = dev_info.ip_address + self._last_activity = utc_point_in_time + else: self._ip_address = None - return - - self._last_activity = utc_point_in_time - self._ip_address = dev_info.ip_address @property def is_connected(self): diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 103ddbef9d9..4001dcadc71 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -2,19 +2,25 @@ from __future__ import annotations import logging +from typing import Any from urllib.parse import urlparse from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError import voluptuous as vol +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, +) from homeassistant.components.ssdp import ( ATTR_SSDP_LOCATION, ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN, ) -from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .common import FritzBoxTools from .const import ( @@ -34,6 +40,12 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return FritzBoxToolsOptionsFlowHandler(config_entry) + def __init__(self): """Initialize FRITZ!Box Tools flow.""" self._host = None @@ -85,6 +97,9 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): CONF_PORT: self.fritz_tools.port, CONF_USERNAME: self.fritz_tools.username, }, + options={ + CONF_CONSIDER_HOME: DEFAULT_CONSIDER_HOME.total_seconds(), + }, ) async def async_step_ssdp(self, discovery_info): @@ -244,3 +259,31 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): CONF_PORT: import_config.get(CONF_PORT, DEFAULT_PORT), } ) + + +class FritzBoxToolsOptionsFlowHandler(OptionsFlow): + """Handle a option flow.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle options flow.""" + + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_CONSIDER_HOME, + default=self.config_entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() + ), + ): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)), + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index 743918fa33c..dbf6bc0df93 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -116,6 +116,7 @@ class FritzBoxTracker(ScannerEntity): self._router = router self._mac = device.mac_address self._name = device.hostname or DEFAULT_DEVICE_NAME + self._last_activity = device.last_activity self._active = False self._attrs: dict = {} @@ -186,16 +187,22 @@ class FritzBoxTracker(ScannerEntity): """Return if the entity should be enabled when first added to the entity registry.""" return False + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return the attributes.""" + attrs: dict[str, str] = {} + if self._last_activity is not None: + attrs["last_time_reachable"] = self._last_activity.isoformat( + timespec="seconds" + ) + return attrs + @callback def async_process_update(self) -> None: """Update device.""" - device = self._router.devices[self._mac] + device: FritzDevice = self._router.devices[self._mac] self._active = device.is_connected - - if device.last_activity: - self._attrs["last_time_reachable"] = device.last_activity.isoformat( - timespec="seconds" - ) + self._last_activity = device.last_activity @callback def async_on_demand_update(self): diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 407f08b6ddf..f1cdb719741 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -40,5 +40,14 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Seconds to consider a device at 'home'" + } + } + } } } diff --git a/homeassistant/components/fritz/translations/en.json b/homeassistant/components/fritz/translations/en.json index cd5e1776d76..0fa47bd8328 100644 --- a/homeassistant/components/fritz/translations/en.json +++ b/homeassistant/components/fritz/translations/en.json @@ -51,5 +51,14 @@ "title": "Setup FRITZ!Box Tools" } } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Seconds to consider a device at 'home'" + } + } + } } } \ No newline at end of file diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index b86e9934b24..6e051ef1bdd 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -4,6 +4,10 @@ from unittest.mock import patch from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError import pytest +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, +) from homeassistant.components.fritz.const import ( DOMAIN, ERROR_AUTH_INVALID, @@ -83,6 +87,10 @@ async def test_user(hass: HomeAssistant, fc_class_mock): assert result["data"][CONF_HOST] == "fake_host" assert result["data"][CONF_PASSWORD] == "fake_pass" assert result["data"][CONF_USERNAME] == "fake_user" + assert ( + result["options"][CONF_CONSIDER_HOME] + == DEFAULT_CONSIDER_HOME.total_seconds() + ) assert not result["result"].unique_id await hass.async_block_till_done() @@ -416,3 +424,30 @@ async def test_import(hass: HomeAssistant, fc_class_mock): await hass.async_block_till_done() assert mock_setup_entry.called + + +async def test_options_flow(hass: HomeAssistant, fc_class_mock): + """Test options flow.""" + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.fritz.common.FritzConnection", + side_effect=fc_class_mock, + ), patch("homeassistant.components.fritz.common.FritzStatus"), patch( + "homeassistant.components.fritz.common.FritzBoxTools" + ): + result = await hass.config_entries.options.async_init(mock_config.entry_id) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_init(mock_config.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 37, + }, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert mock_config.options[CONF_CONSIDER_HOME] == 37