mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 08:47:57 +00:00
Rework Fritz config_flow and device_tracker (#48287)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
376b787e4d
commit
b92f29997e
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
235
homeassistant/components/fritz/common.py
Normal file
235
homeassistant/components/fritz/common.py
Normal file
@ -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
|
248
homeassistant/components/fritz/config_flow.py
Normal file
248
homeassistant/components/fritz/config_flow.py
Normal file
@ -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),
|
||||
}
|
||||
)
|
18
homeassistant/components/fritz/const.py
Normal file
18
homeassistant/components/fritz/const.py
Normal file
@ -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
|
@ -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
|
||||
)
|
||||
|
@ -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"
|
||||
}
|
||||
|
44
homeassistant/components/fritz/strings.json
Normal file
44
homeassistant/components/fritz/strings.json
Normal file
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
44
homeassistant/components/fritz/translations/en.json
Normal file
44
homeassistant/components/fritz/translations/en.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -76,6 +76,7 @@ FLOWS = [
|
||||
"forked_daapd",
|
||||
"foscam",
|
||||
"freebox",
|
||||
"fritz",
|
||||
"fritzbox",
|
||||
"fritzbox_callmonitor",
|
||||
"garmin_connect",
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
128
tests/components/fritz/__init__.py
Normal file
128
tests/components/fritz/__init__.py
Normal file
@ -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())
|
416
tests/components/fritz/test_config_flow.py
Normal file
416
tests/components/fritz/test_config_flow.py
Normal file
@ -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
|
Loading…
x
Reference in New Issue
Block a user