From b92f29997e526197fc8f46cce8379ee0354d990e Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 25 Apr 2021 12:10:33 +0200 Subject: [PATCH] Rework Fritz config_flow and device_tracker (#48287) Co-authored-by: J. Nick Koston --- .coveragerc | 3 + CODEOWNERS | 1 + homeassistant/components/fritz/__init__.py | 80 +++- homeassistant/components/fritz/common.py | 235 ++++++++++ homeassistant/components/fritz/config_flow.py | 248 +++++++++++ homeassistant/components/fritz/const.py | 18 + .../components/fritz/device_tracker.py | 246 +++++++---- homeassistant/components/fritz/manifest.json | 19 +- homeassistant/components/fritz/strings.json | 44 ++ .../components/fritz/translations/en.json | 44 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/ssdp.py | 5 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/fritz/__init__.py | 128 ++++++ tests/components/fritz/test_config_flow.py | 416 ++++++++++++++++++ 16 files changed, 1407 insertions(+), 83 deletions(-) create mode 100644 homeassistant/components/fritz/common.py create mode 100644 homeassistant/components/fritz/config_flow.py create mode 100644 homeassistant/components/fritz/const.py create mode 100644 homeassistant/components/fritz/strings.json create mode 100644 homeassistant/components/fritz/translations/en.json create mode 100644 tests/components/fritz/__init__.py create mode 100644 tests/components/fritz/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 26d49395164..a9342397123 100644 --- a/.coveragerc +++ b/.coveragerc @@ -329,6 +329,9 @@ omit = homeassistant/components/freebox/router.py homeassistant/components/freebox/sensor.py homeassistant/components/freebox/switch.py + homeassistant/components/fritz/__init__.py + homeassistant/components/fritz/common.py + homeassistant/components/fritz/const.py homeassistant/components/fritz/device_tracker.py homeassistant/components/fritzbox_callmonitor/__init__.py homeassistant/components/fritzbox_callmonitor/const.py diff --git a/CODEOWNERS b/CODEOWNERS index d6226c08a5d..976a5c7d6ef 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -164,6 +164,7 @@ homeassistant/components/forked_daapd/* @uvjustin homeassistant/components/fortios/* @kimfrellsen homeassistant/components/foscam/* @skgsergio homeassistant/components/freebox/* @hacf-fr @Quentame +homeassistant/components/fritz/* @mammuth @AaronDavidSchneider @chemelli74 homeassistant/components/fronius/* @nielstron homeassistant/components/frontend/* @home-assistant/frontend homeassistant/components/garmin_connect/* @cyberjunky diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index 7069a29f163..6c8f54ea928 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -1 +1,79 @@ -"""The fritz component.""" +"""Support for AVM Fritz!Box functions.""" +import asyncio +import logging + +from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.typing import ConfigType + +from .common import FritzBoxTools +from .const import DOMAIN, PLATFORMS + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up fritzboxtools from config entry.""" + _LOGGER.debug("Setting up FRITZ!Box Tools component") + fritz_tools = FritzBoxTools( + hass=hass, + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + + try: + await fritz_tools.async_setup() + await fritz_tools.async_start() + except FritzSecurityError as ex: + raise ConfigEntryAuthFailed from ex + except FritzConnectionException as ex: + raise ConfigEntryNotReady from ex + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = fritz_tools + + @callback + def _async_unload(event): + fritz_tools.async_unload() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_unload) + ) + # Load the other platforms like switch + for domain in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, domain) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigType) -> bool: + """Unload FRITZ!Box Tools config entry.""" + fritzbox: FritzBoxTools = hass.data[DOMAIN][entry.entry_id] + fritzbox.async_unload() + + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py new file mode 100644 index 00000000000..70783caef25 --- /dev/null +++ b/homeassistant/components/fritz/common.py @@ -0,0 +1,235 @@ +"""Support for AVM FRITZ!Box classes.""" +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging +from typing import Any, Dict, Optional + +# pylint: disable=import-error +from fritzconnection import FritzConnection +from fritzconnection.lib.fritzhosts import FritzHosts +from fritzconnection.lib.fritzstatus import FritzStatus + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util import dt as dt_util + +from .const import ( + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_USERNAME, + DOMAIN, + TRACKER_SCAN_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class Device: + """FRITZ!Box device class.""" + + mac: str + ip_address: str + name: str + + +class FritzBoxTools: + """FrtizBoxTools class.""" + + def __init__( + self, + hass, + password, + username=DEFAULT_USERNAME, + host=DEFAULT_HOST, + port=DEFAULT_PORT, + ): + """Initialize FritzboxTools class.""" + self._cancel_scan = None + self._device_info = None + self._devices: Dict[str, Any] = {} + self._unique_id = None + self.connection = None + self.fritzhosts = None + self.fritzstatus = None + self.hass = hass + self.host = host + self.password = password + self.port = port + self.username = username + + async def async_setup(self): + """Wrap up FritzboxTools class setup.""" + return await self.hass.async_add_executor_job(self.setup) + + def setup(self): + """Set up FritzboxTools class.""" + + self.connection = FritzConnection( + address=self.host, + port=self.port, + user=self.username, + password=self.password, + timeout=60.0, + ) + + self.fritzstatus = FritzStatus(fc=self.connection) + if self._unique_id is None: + self._unique_id = self.connection.call_action("DeviceInfo:1", "GetInfo")[ + "NewSerialNumber" + ] + + self._device_info = self._fetch_device_info() + + async def async_start(self): + """Start FritzHosts connection.""" + self.fritzhosts = FritzHosts(fc=self.connection) + + await self.hass.async_add_executor_job(self.scan_devices) + + self._cancel_scan = async_track_time_interval( + self.hass, self.scan_devices, timedelta(seconds=TRACKER_SCAN_INTERVAL) + ) + + @callback + async def async_unload(self): + """Unload FritzboxTools class.""" + _LOGGER.debug("Unloading FRITZ!Box router integration") + if self._cancel_scan is not None: + self._cancel_scan() + self._cancel_scan = None + + @property + def unique_id(self): + """Return unique id.""" + return self._unique_id + + @property + def fritzbox_model(self): + """Return model.""" + return self._device_info["model"].replace("FRITZ!Box ", "") + + @property + def device_info(self): + """Return device info.""" + return self._device_info + + @property + def devices(self) -> Dict[str, Any]: + """Return devices.""" + return self._devices + + @property + def signal_device_new(self) -> str: + """Event specific per FRITZ!Box entry to signal new device.""" + return f"{DOMAIN}-device-new-{self._unique_id}" + + @property + def signal_device_update(self) -> str: + """Event specific per FRITZ!Box entry to signal updates in devices.""" + return f"{DOMAIN}-device-update-{self._unique_id}" + + def _update_info(self): + """Retrieve latest information from the FRITZ!Box.""" + return self.fritzhosts.get_hosts_info() + + def scan_devices(self, now: Optional[datetime] = None) -> None: + """Scan for new devices and return a list of found device ids.""" + + _LOGGER.debug("Checking devices for FRITZ!Box router %s", self.host) + + new_device = False + for known_host in self._update_info(): + if not known_host.get("mac"): + continue + + dev_mac = known_host["mac"] + dev_name = known_host["name"] + dev_ip = known_host["ip"] + dev_home = known_host["status"] + + dev_info = Device(dev_mac, dev_ip, dev_name) + + if dev_mac in self._devices: + self._devices[dev_mac].update(dev_info, dev_home) + else: + device = FritzDevice(dev_mac) + device.update(dev_info, dev_home) + self._devices[dev_mac] = device + new_device = True + + async_dispatcher_send(self.hass, self.signal_device_update) + if new_device: + async_dispatcher_send(self.hass, self.signal_device_new) + + def _fetch_device_info(self): + """Fetch device info.""" + info = self.connection.call_action("DeviceInfo:1", "GetInfo") + + dev_info = {} + dev_info["identifiers"] = { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.unique_id) + } + dev_info["manufacturer"] = "AVM" + + if dev_name := info.get("NewName"): + dev_info["name"] = dev_name + if dev_model := info.get("NewModelName"): + dev_info["model"] = dev_model + if dev_sw_ver := info.get("NewSoftwareVersion"): + dev_info["sw_version"] = dev_sw_ver + + return dev_info + + +class FritzDevice: + """FritzScanner device.""" + + def __init__(self, mac, name=None): + """Initialize device info.""" + self._mac = mac + self._name = name + self._ip_address = None + self._last_activity = None + self._connected = False + + def update(self, dev_info, dev_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: + self._ip_address = None + return + + self._last_activity = utc_point_in_time + self._ip_address = dev_info.ip_address + + @property + def is_connected(self): + """Return connected status.""" + return self._connected + + @property + def mac_address(self): + """Get MAC address.""" + return self._mac + + @property + def hostname(self): + """Get Name.""" + return self._name + + @property + def ip_address(self): + """Get IP address.""" + return self._ip_address + + @property + def last_activity(self): + """Return device last activity.""" + return self._last_activity diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py new file mode 100644 index 00000000000..8cebf6fd7de --- /dev/null +++ b/homeassistant/components/fritz/config_flow.py @@ -0,0 +1,248 @@ +"""Config flow to configure the FRITZ!Box Tools integration.""" +import logging +from urllib.parse import urlparse + +from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.ssdp import ( + ATTR_SSDP_LOCATION, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import callback + +from .common import FritzBoxTools +from .const import ( + DEFAULT_HOST, + DEFAULT_PORT, + DOMAIN, + ERROR_AUTH_INVALID, + ERROR_CONNECTION_ERROR, + ERROR_UNKNOWN, +) + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class FritzBoxToolsFlowHandler(ConfigFlow): + """Handle a FRITZ!Box Tools config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize FRITZ!Box Tools flow.""" + self._host = None + self._entry = None + self._name = None + self._password = None + self._port = None + self._username = None + self.import_schema = None + self.fritz_tools = None + + async def fritz_tools_init(self): + """Initialize FRITZ!Box Tools class.""" + self.fritz_tools = FritzBoxTools( + hass=self.hass, + host=self._host, + port=self._port, + username=self._username, + password=self._password, + ) + + try: + await self.fritz_tools.async_setup() + except FritzSecurityError: + return ERROR_AUTH_INVALID + except FritzConnectionException: + return ERROR_CONNECTION_ERROR + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return ERROR_UNKNOWN + + return None + + async def async_check_configured_entry(self) -> ConfigEntry: + """Check if entry is configured.""" + for entry in self._async_current_entries(include_ignore=False): + if entry.data[CONF_HOST] == self._host: + return entry + return None + + @callback + def _async_create_entry(self): + """Async create flow handler entry.""" + return self.async_create_entry( + title=self._name, + data={ + CONF_HOST: self.fritz_tools.host, + CONF_PASSWORD: self.fritz_tools.password, + CONF_PORT: self.fritz_tools.port, + CONF_USERNAME: self.fritz_tools.username, + }, + ) + + async def async_step_ssdp(self, discovery_info): + """Handle a flow initialized by discovery.""" + ssdp_location = urlparse(discovery_info[ATTR_SSDP_LOCATION]) + self._host = ssdp_location.hostname + self._port = ssdp_location.port + self._name = discovery_info.get(ATTR_UPNP_FRIENDLY_NAME) + self.context[CONF_HOST] = self._host + + if uuid := discovery_info.get(ATTR_UPNP_UDN): + if uuid.startswith("uuid:"): + uuid = uuid[5:] + await self.async_set_unique_id(uuid) + self._abort_if_unique_id_configured({CONF_HOST: self._host}) + + for progress in self._async_in_progress(): + if progress.get("context", {}).get(CONF_HOST) == self._host: + return self.async_abort(reason="already_in_progress") + + if entry := await self.async_check_configured_entry(): + if uuid and not entry.unique_id: + self.hass.config_entries.async_update_entry(entry, unique_id=uuid) + return self.async_abort(reason="already_configured") + + self.context["title_placeholders"] = { + "name": self._name.replace("FRITZ!Box ", "") + } + return await self.async_step_confirm() + + async def async_step_confirm(self, user_input=None): + """Handle user-confirmation of discovered node.""" + + if user_input is None: + return self._show_setup_form_confirm() + + errors = {} + + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + + error = await self.fritz_tools_init() + + if error: + errors["base"] = error + return self._show_setup_form_confirm(errors) + + return self._async_create_entry() + + def _show_setup_form_init(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional(CONF_HOST, default=DEFAULT_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors or {}, + ) + + def _show_setup_form_confirm(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + description_placeholders={"name": self._name}, + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if user_input is None: + return self._show_setup_form_init() + self._host = user_input[CONF_HOST] + self._port = user_input[CONF_PORT] + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + + if not (error := await self.fritz_tools_init()): + self._name = self.fritz_tools.device_info["model"] + + if await self.async_check_configured_entry(): + error = "already_configured" + + if error: + return self._show_setup_form_init({"base": error}) + + return self._async_create_entry() + + async def async_step_reauth(self, data): + """Handle flow upon an API authentication error.""" + self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + self._host = data[CONF_HOST] + self._port = data[CONF_PORT] + self._username = data[CONF_USERNAME] + self._password = data[CONF_PASSWORD] + return await self.async_step_reauth_confirm() + + def _show_setup_form_reauth_confirm(self, user_input, errors=None): + """Show the reauth form to the user.""" + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME) + ): str, + vol.Required(CONF_PASSWORD): str, + } + ), + description_placeholders={"host": self._host}, + errors=errors or {}, + ) + + async def async_step_reauth_confirm(self, user_input=None): + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self._show_setup_form_reauth_confirm( + user_input={CONF_USERNAME: self._username} + ) + + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + + if error := await self.fritz_tools_init(): + return self._show_setup_form_reauth_confirm( + user_input=user_input, errors={"base": error} + ) + + self.hass.config_entries.async_update_entry( + self._entry, + data={ + CONF_HOST: self._host, + CONF_PASSWORD: self._password, + CONF_PORT: self._port, + CONF_USERNAME: self._username, + }, + ) + await self.hass.config_entries.async_reload(self._entry.entry_id) + return self.async_abort(reason="reauth_successful") + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user( + { + CONF_HOST: import_config[CONF_HOST], + CONF_USERNAME: import_config[CONF_USERNAME], + CONF_PASSWORD: import_config.get(CONF_PASSWORD), + CONF_PORT: import_config.get(CONF_PORT, DEFAULT_PORT), + } + ) diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py new file mode 100644 index 00000000000..90b7d1554e7 --- /dev/null +++ b/homeassistant/components/fritz/const.py @@ -0,0 +1,18 @@ +"""Constants for the FRITZ!Box Tools integration.""" + +DOMAIN = "fritz" + +PLATFORMS = ["device_tracker"] + + +DEFAULT_DEVICE_NAME = "Unknown device" +DEFAULT_HOST = "192.168.178.1" +DEFAULT_PORT = 49000 +DEFAULT_USERNAME = "" + + +ERROR_AUTH_INVALID = "invalid_auth" +ERROR_CONNECTION_ERROR = "connection_error" +ERROR_UNKNOWN = "unknown_error" + +TRACKER_SCAN_INTERVAL = 30 diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index 4da566376a6..03196c0cf94 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -1,107 +1,195 @@ """Support for FRITZ!Box routers.""" import logging +from typing import Dict -from fritzconnection.core import exceptions as fritzexceptions -from fritzconnection.lib.fritzhosts import FritzHosts import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA, - DeviceScanner, + SOURCE_TYPE_ROUTER, ) +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import ConfigType + +from .common import FritzBoxTools +from .const import DEFAULT_DEVICE_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) -DEFAULT_HOST = "169.254.1.1" # This IP is valid for all FRITZ!Box routers. -DEFAULT_USERNAME = "admin" +YAML_DEFAULT_HOST = "169.254.1.1" +YAML_DEFAULT_USERNAME = "admin" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_HOST), + cv.deprecated(CONF_USERNAME), + cv.deprecated(CONF_PASSWORD), + PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_HOST, default=YAML_DEFAULT_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=YAML_DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + } + ), ) -def get_scanner(hass, config): - """Validate the configuration and return FritzBoxScanner.""" - scanner = FritzBoxScanner(config[DOMAIN]) - return scanner if scanner.success_init else None +async def async_get_scanner(hass: HomeAssistant, config: ConfigType): + """Import legacy FRITZ!Box configuration.""" + + _LOGGER.debug("Import legacy FRITZ!Box configuration from YAML") + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DEVICE_TRACKER_DOMAIN], + ) + ) + + _LOGGER.warning( + "Your Fritz configuration has been imported into the UI, " + "please remove it from configuration.yaml. " + "Loading Fritz via scanner setup is now deprecated" + ) + + return None -class FritzBoxScanner(DeviceScanner): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +) -> None: + """Set up device tracker for FRITZ!Box component.""" + _LOGGER.debug("Starting FRITZ!Box device tracker") + router = hass.data[DOMAIN][entry.entry_id] + tracked = set() + + @callback + def update_router(): + """Update the values of the router.""" + _async_add_entities(router, async_add_entities, tracked) + + async_dispatcher_connect(hass, router.signal_device_new, update_router) + + update_router() + + +@callback +def _async_add_entities(router, async_add_entities, tracked): + """Add new tracker entities from the router.""" + new_tracked = [] + + for mac, device in router.devices.items(): + if mac in tracked: + continue + + new_tracked.append(FritzBoxTracker(router, device)) + tracked.add(mac) + + if new_tracked: + async_add_entities(new_tracked) + + +class FritzBoxTracker(ScannerEntity): """This class queries a FRITZ!Box router.""" - def __init__(self, config): - """Initialize the scanner.""" - self.last_results = [] - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config.get(CONF_PASSWORD) - self.success_init = True + def __init__(self, router: FritzBoxTools, device): + """Initialize a FRITZ!Box device.""" + self._router = router + self._mac = device.mac_address + self._name = device.hostname or DEFAULT_DEVICE_NAME + self._active = False + self._attrs = {} - # Establish a connection to the FRITZ!Box. - try: - self.fritz_box = FritzHosts( - address=self.host, user=self.username, password=self.password - ) - except (ValueError, TypeError): - self.fritz_box = None + @property + def is_connected(self): + """Return device status.""" + return self._active - # At this point it is difficult to tell if a connection is established. - # So just check for null objects. - if self.fritz_box is None or not self.fritz_box.modelname: - self.success_init = False + @property + def name(self): + """Return device name.""" + return self._name - if self.success_init: - _LOGGER.info("Successfully connected to %s", self.fritz_box.modelname) - self._update_info() - else: - _LOGGER.error( - "Failed to establish connection to FRITZ!Box with IP: %s", self.host + @property + def unique_id(self): + """Return device unique id.""" + return self._mac + + @property + def ip_address(self) -> str: + """Return the primary ip address of the device.""" + return self._router.devices[self._mac].ip_address + + @property + def mac_address(self) -> str: + """Return the mac address of the device.""" + return self._mac + + @property + def hostname(self) -> str: + """Return hostname of the device.""" + return self._router.devices[self._mac].hostname + + @property + def source_type(self) -> str: + """Return tracker source type.""" + return SOURCE_TYPE_ROUTER + + @property + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + return { + "connections": {(CONNECTION_NETWORK_MAC, self._mac)}, + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "AVM", + "model": "FRITZ!Box Tracked device", + } + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + @property + def icon(self): + """Return device icon.""" + if self.is_connected: + return "mdi:lan-connect" + return "mdi:lan-disconnect" + + @callback + def async_process_update(self) -> None: + """Update device.""" + + device = 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" ) - def scan_devices(self): - """Scan for new devices and return a list of found device ids.""" - self._update_info() - active_hosts = [] - for known_host in self.last_results: - if known_host["status"] and known_host.get("mac"): - active_hosts.append(known_host["mac"]) - return active_hosts + @callback + def async_on_demand_update(self): + """Update state.""" + self.async_process_update() + self.async_write_ha_state() - def get_device_name(self, device): - """Return the name of the given device or None if is not known.""" - ret = self.fritz_box.get_specific_host_entry(device).get("NewHostName") - if ret == {}: - return None - return ret - - def get_extra_attributes(self, device): - """Return the attributes (ip, mac) of the given device or None if is not known.""" - ip_device = None - try: - ip_device = self.fritz_box.get_specific_host_entry(device).get( - "NewIPAddress" + async def async_added_to_hass(self): + """Register state update callback.""" + self.async_process_update() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._router.signal_device_update, + self.async_on_demand_update, ) - except fritzexceptions.FritzLookUpError as fritz_lookup_error: - _LOGGER.warning( - "Host entry for %s not found: %s", device, fritz_lookup_error - ) - - if not ip_device: - return {} - return {"ip": ip_device, "mac": device} - - def _update_info(self): - """Retrieve latest information from the FRITZ!Box.""" - if not self.success_init: - return False - - _LOGGER.debug("Scanning") - self.last_results = self.fritz_box.get_hosts_info() - return True + ) diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 522c7574b06..68b1bde4f38 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -1,8 +1,21 @@ { "domain": "fritz", - "name": "AVM FRITZ!Box", + "name": "AVM FRITZ!Box Tools", "documentation": "https://www.home-assistant.io/integrations/fritz", - "requirements": ["fritzconnection==1.4.2"], - "codeowners": [], + "requirements": [ + "fritzconnection==1.4.2", + "xmltodict==0.12.0" + ], + "codeowners": [ + "@mammuth", + "@AaronDavidSchneider", + "@chemelli74" + ], + "config_flow": true, + "ssdp": [ + { + "st": "urn:schemas-upnp-org:device:fritzbox:1" + } + ], "iot_class": "local_polling" } diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json new file mode 100644 index 00000000000..3a94d39a50c --- /dev/null +++ b/homeassistant/components/fritz/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "flow_title": "FRITZ!Box Tools: {name}", + "step": { + "confirm": { + "title": "Setup FRITZ!Box Tools", + "description": "Discovered FRITZ!Box: {name}\n\nSetup FRITZ!Box Tools to control your {name}", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "start_config": { + "title": "Setup FRITZ!Box Tools - mandatory", + "description": "Setup FRITZ!Box Tools to control your FRITZ!Box.\nMinimum needed: username, password.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth_confirm": { + "title": "Updating FRITZ!Box Tools - credentials", + "description": "Update FRITZ!Box Tools credentials for: {host}.\n\nFRITZ!Box Tools is unable to log in to your FRITZ!Box.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "abort": { + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "connection_error": "[%key:common::config_flow::error::cannot_connect%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + } + } +} diff --git a/homeassistant/components/fritz/translations/en.json b/homeassistant/components/fritz/translations/en.json new file mode 100644 index 00000000000..7497383dcfc --- /dev/null +++ b/homeassistant/components/fritz/translations/en.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "connection_error": "Failed to connect", + "invalid_auth": "Invalid authentication" + }, + "flow_title": "FRITZ!Box Tools: {name}", + "step": { + "confirm": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Discovered FRITZ!Box: {name}\n\nSetup FRITZ!Box Tools to control your {name}", + "title": "Setup FRITZ!Box Tools" + }, + "reauth_confirm": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Update FRITZ!Box Tools credentials for: {host}.\n\nFRITZ!Box Tools is unable to log in to your FRITZ!Box.", + "title": "Updating FRITZ!Box Tools - credentials" + }, + "start_config": { + "data": { + "host": "Host", + "password": "Password", + "port": "Port", + "username": "Username" + }, + "description": "Setup FRITZ!Box Tools to control your FRITZ!Box.\nMinimum needed: username, password.", + "title": "Setup FRITZ!Box Tools - mandatory" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 764ce9e594b..bbf27893dc3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -76,6 +76,7 @@ FLOWS = [ "forked_daapd", "foscam", "freebox", + "fritz", "fritzbox", "fritzbox_callmonitor", "garmin_connect", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 8d28a499aaf..4141de31f73 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -83,6 +83,11 @@ SSDP = { "manufacturer": "DIRECTV" } ], + "fritz": [ + { + "st": "urn:schemas-upnp-org:device:fritzbox:1" + } + ], "fritzbox": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/requirements_all.txt b/requirements_all.txt index a6bc723d275..aa8d605f997 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2356,6 +2356,7 @@ xboxapi==2.0.1 xknx==0.18.1 # homeassistant.components.bluesound +# homeassistant.components.fritz # homeassistant.components.rest # homeassistant.components.startca # homeassistant.components.ted5000 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 460aa483918..5fd213671d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1247,6 +1247,7 @@ xbox-webapi==2.0.8 xknx==0.18.1 # homeassistant.components.bluesound +# homeassistant.components.fritz # homeassistant.components.rest # homeassistant.components.startca # homeassistant.components.ted5000 diff --git a/tests/components/fritz/__init__.py b/tests/components/fritz/__init__.py new file mode 100644 index 00000000000..5a9b6cb1652 --- /dev/null +++ b/tests/components/fritz/__init__.py @@ -0,0 +1,128 @@ +"""Tests for the AVM Fritz!Box integration.""" +from unittest import mock + +from homeassistant.components.fritz.const import DOMAIN +from homeassistant.const import ( + CONF_DEVICES, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) + +MOCK_CONFIG = { + DOMAIN: { + CONF_DEVICES: [ + { + CONF_HOST: "fake_host", + CONF_PORT: "1234", + CONF_PASSWORD: "fake_pass", + CONF_USERNAME: "fake_user", + } + ] + } +} + + +class FritzConnectionMock: # pylint: disable=too-few-public-methods + """FritzConnection mocking.""" + + FRITZBOX_DATA = { + ("WANIPConn:1", "GetStatusInfo"): { + "NewConnectionStatus": "Connected", + "NewUptime": 35307, + }, + ("WANIPConnection:1", "GetStatusInfo"): {}, + ("WANCommonIFC:1", "GetCommonLinkProperties"): { + "NewLayer1DownstreamMaxBitRate": 10087000, + "NewLayer1UpstreamMaxBitRate": 2105000, + "NewPhysicalLinkStatus": "Up", + }, + ("WANCommonIFC:1", "GetAddonInfos"): { + "NewByteSendRate": 3438, + "NewByteReceiveRate": 67649, + "NewTotalBytesSent": 1712232562, + "NewTotalBytesReceived": 5221019883, + }, + ("LANEthernetInterfaceConfig:1", "GetStatistics"): { + "NewBytesSent": 23004321, + "NewBytesReceived": 12045, + }, + ("DeviceInfo:1", "GetInfo"): { + "NewSerialNumber": 1234, + "NewName": "TheName", + "NewModelName": "FRITZ!Box 7490", + }, + } + + FRITZBOX_DATA_INDEXED = { + ("X_AVM-DE_Homeauto:1", "GetGenericDeviceInfos"): [ + { + "NewSwitchIsValid": "VALID", + "NewMultimeterIsValid": "VALID", + "NewTemperatureIsValid": "VALID", + "NewDeviceId": 16, + "NewAIN": "08761 0114116", + "NewDeviceName": "FRITZ!DECT 200 #1", + "NewTemperatureOffset": "0", + "NewSwitchLock": "0", + "NewProductName": "FRITZ!DECT 200", + "NewPresent": "CONNECTED", + "NewMultimeterPower": 1673, + "NewHkrComfortTemperature": "0", + "NewSwitchMode": "AUTO", + "NewManufacturer": "AVM", + "NewMultimeterIsEnabled": "ENABLED", + "NewHkrIsTemperature": "0", + "NewFunctionBitMask": 2944, + "NewTemperatureIsEnabled": "ENABLED", + "NewSwitchState": "ON", + "NewSwitchIsEnabled": "ENABLED", + "NewFirmwareVersion": "03.87", + "NewHkrSetVentilStatus": "CLOSED", + "NewMultimeterEnergy": 5182, + "NewHkrComfortVentilStatus": "CLOSED", + "NewHkrReduceTemperature": "0", + "NewHkrReduceVentilStatus": "CLOSED", + "NewHkrIsEnabled": "DISABLED", + "NewHkrSetTemperature": "0", + "NewTemperatureCelsius": "225", + "NewHkrIsValid": "INVALID", + }, + {}, + ], + ("Hosts1", "GetGenericHostEntry"): [ + { + "NewSerialNumber": 1234, + "NewName": "TheName", + "NewModelName": "FRITZ!Box 7490", + }, + {}, + ], + } + + MODELNAME = "FRITZ!Box 7490" + + def __init__(self): + """Inint Mocking class.""" + type(self).modelname = mock.PropertyMock(return_value=self.MODELNAME) + self.call_action = mock.Mock(side_effect=self._side_effect_callaction) + type(self).actionnames = mock.PropertyMock( + side_effect=self._side_effect_actionnames + ) + services = { + srv: None + for srv, _ in list(self.FRITZBOX_DATA.keys()) + + list(self.FRITZBOX_DATA_INDEXED.keys()) + } + type(self).services = mock.PropertyMock(side_effect=[services]) + + def _side_effect_callaction(self, service, action, **kwargs): + if kwargs: + index = next(iter(kwargs.values())) + return self.FRITZBOX_DATA_INDEXED[(service, action)][index] + + return self.FRITZBOX_DATA[(service, action)] + + def _side_effect_actionnames(self): + return list(self.FRITZBOX_DATA.keys()) + list(self.FRITZBOX_DATA_INDEXED.keys()) diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py new file mode 100644 index 00000000000..14830249da9 --- /dev/null +++ b/tests/components/fritz/test_config_flow.py @@ -0,0 +1,416 @@ +"""Tests for AVM Fritz!Box config flow.""" +from unittest.mock import patch + +from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError +import pytest + +from homeassistant.components.fritz.const import ( + DOMAIN, + ERROR_AUTH_INVALID, + ERROR_CONNECTION_ERROR, + ERROR_UNKNOWN, +) +from homeassistant.components.ssdp import ( + ATTR_SSDP_LOCATION, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, +) +from homeassistant.config_entries import ( + SOURCE_IMPORT, + SOURCE_REAUTH, + SOURCE_SSDP, + SOURCE_USER, +) +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import MOCK_CONFIG, FritzConnectionMock + +from tests.common import MockConfigEntry + +ATTR_HOST = "host" +ATTR_NEW_SERIAL_NUMBER = "NewSerialNumber" + +MOCK_HOST = "fake_host" +MOCK_SERIAL_NUMBER = "fake_serial_number" + + +MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] +MOCK_DEVICE_INFO = { + ATTR_HOST: MOCK_HOST, + ATTR_NEW_SERIAL_NUMBER: MOCK_SERIAL_NUMBER, +} +MOCK_IMPORT_CONFIG = {CONF_HOST: MOCK_HOST, CONF_USERNAME: "username"} +MOCK_SSDP_DATA = { + ATTR_SSDP_LOCATION: "https://fake_host:12345/test", + ATTR_UPNP_FRIENDLY_NAME: "fake_name", + ATTR_UPNP_UDN: "uuid:only-a-test", +} + + +@pytest.fixture() +def fc_class_mock(mocker): + """Fixture that sets up a mocked FritzConnection class.""" + result = mocker.patch("fritzconnection.FritzConnection", autospec=True) + result.return_value = FritzConnectionMock() + yield result + + +async def test_user(hass: HomeAssistant, fc_class_mock): + """Test starting a flow by user.""" + with patch( + "homeassistant.components.fritz.common.FritzConnection", + side_effect=fc_class_mock, + ), patch("homeassistant.components.fritz.common.FritzStatus"), patch( + "homeassistant.components.fritz.async_setup_entry" + ) as mock_setup_entry: + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_PASSWORD] == "fake_pass" + assert result["data"][CONF_USERNAME] == "fake_user" + assert not result["result"].unique_id + await hass.async_block_till_done() + + assert mock_setup_entry.called + + +async def test_user_already_configured(hass: HomeAssistant, fc_class_mock): + """Test starting a flow by user with an already configured device.""" + + 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"): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_exception_security(hass: HomeAssistant): + """Test starting a flow by user with invalid credentials.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.fritz.common.FritzConnection", + side_effect=FritzSecurityError, + ), patch("homeassistant.components.fritz.common.FritzStatus"): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == ERROR_AUTH_INVALID + + +async def test_exception_connection(hass: HomeAssistant): + """Test starting a flow by user with a connection error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.fritz.common.FritzConnection", + side_effect=FritzConnectionException, + ), patch("homeassistant.components.fritz.common.FritzStatus"): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == ERROR_CONNECTION_ERROR + + +async def test_exception_unknown(hass: HomeAssistant): + """Test starting a flow by user with an unknown exception.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.fritz.common.FritzConnection", + side_effect=OSError, + ), patch("homeassistant.components.fritz.common.FritzStatus"): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == ERROR_UNKNOWN + + +async def test_reauth_successful(hass: HomeAssistant, fc_class_mock): + """Test starting a reauthentication 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.async_setup_entry" + ) as mock_setup_entry: + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, + data=mock_config.data, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "other_fake_user", + CONF_PASSWORD: "other_fake_password", + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + assert mock_setup_entry.called + + +async def test_reauth_not_successful(hass: HomeAssistant, fc_class_mock): + """Test starting a reauthentication flow but no connection found.""" + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.fritz.common.FritzConnection", + side_effect=FritzConnectionException, + ), patch("homeassistant.components.fritz.common.FritzStatus"): + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, + data=mock_config.data, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "other_fake_user", + CONF_PASSWORD: "other_fake_password", + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + +async def test_ssdp_already_configured(hass: HomeAssistant, fc_class_mock): + """Test starting a flow from discovery with an already configured device.""" + + mock_config = MockConfigEntry( + domain=DOMAIN, + data=MOCK_USER_DATA, + unique_id="only-a-test", + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.fritz.common.FritzConnection", + side_effect=fc_class_mock, + ), patch("homeassistant.components.fritz.common.FritzStatus"): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_ssdp_already_configured_host(hass: HomeAssistant, fc_class_mock): + """Test starting a flow from discovery with an already configured host.""" + + mock_config = MockConfigEntry( + domain=DOMAIN, + data=MOCK_USER_DATA, + unique_id="different-test", + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.fritz.common.FritzConnection", + side_effect=fc_class_mock, + ), patch("homeassistant.components.fritz.common.FritzStatus"): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_ssdp_already_configured_host_uuid(hass: HomeAssistant, fc_class_mock): + """Test starting a flow from discovery with a laready configured uuid.""" + + mock_config = MockConfigEntry( + domain=DOMAIN, + data=MOCK_USER_DATA, + unique_id=None, + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.fritz.common.FritzConnection", + side_effect=fc_class_mock, + ), patch("homeassistant.components.fritz.common.FritzStatus"): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_ssdp_already_in_progress_host(hass: HomeAssistant, fc_class_mock): + """Test starting a flow from discovery twice.""" + with patch( + "homeassistant.components.fritz.common.FritzConnection", + side_effect=fc_class_mock, + ), patch("homeassistant.components.fritz.common.FritzStatus"): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + + MOCK_NO_UNIQUE_ID = MOCK_SSDP_DATA.copy() + del MOCK_NO_UNIQUE_ID[ATTR_UPNP_UDN] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_NO_UNIQUE_ID + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_in_progress" + + +async def test_ssdp(hass: HomeAssistant, fc_class_mock): + """Test starting a flow from discovery.""" + with patch( + "homeassistant.components.fritz.common.FritzConnection", + side_effect=fc_class_mock, + ), patch("homeassistant.components.fritz.common.FritzStatus"), patch( + "homeassistant.components.fritz.async_setup_entry" + ) as mock_setup_entry: + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_user", + CONF_PASSWORD: "fake_pass", + }, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_PASSWORD] == "fake_pass" + assert result["data"][CONF_USERNAME] == "fake_user" + + assert mock_setup_entry.called + + +async def test_ssdp_exception(hass: HomeAssistant): + """Test starting a flow from discovery but no device found.""" + with patch( + "homeassistant.components.fritz.common.FritzConnection", + side_effect=FritzConnectionException, + ), patch("homeassistant.components.fritz.common.FritzStatus"): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_user", + CONF_PASSWORD: "fake_pass", + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + + +async def test_import(hass: HomeAssistant, fc_class_mock): + """Test importing.""" + with patch( + "homeassistant.components.fritz.common.FritzConnection", + side_effect=fc_class_mock, + ), patch("homeassistant.components.fritz.common.FritzStatus"), patch( + "homeassistant.components.fritz.async_setup_entry" + ) as mock_setup_entry: + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_IMPORT_CONFIG + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_PASSWORD] is None + assert result["data"][CONF_USERNAME] == "username" + await hass.async_block_till_done() + + assert mock_setup_entry.called