Rework Fritz config_flow and device_tracker (#48287)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Simone Chemelli 2021-04-25 12:10:33 +02:00 committed by GitHub
parent 376b787e4d
commit b92f29997e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1407 additions and 83 deletions

View File

@ -329,6 +329,9 @@ omit =
homeassistant/components/freebox/router.py homeassistant/components/freebox/router.py
homeassistant/components/freebox/sensor.py homeassistant/components/freebox/sensor.py
homeassistant/components/freebox/switch.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/fritz/device_tracker.py
homeassistant/components/fritzbox_callmonitor/__init__.py homeassistant/components/fritzbox_callmonitor/__init__.py
homeassistant/components/fritzbox_callmonitor/const.py homeassistant/components/fritzbox_callmonitor/const.py

View File

@ -164,6 +164,7 @@ homeassistant/components/forked_daapd/* @uvjustin
homeassistant/components/fortios/* @kimfrellsen homeassistant/components/fortios/* @kimfrellsen
homeassistant/components/foscam/* @skgsergio homeassistant/components/foscam/* @skgsergio
homeassistant/components/freebox/* @hacf-fr @Quentame homeassistant/components/freebox/* @hacf-fr @Quentame
homeassistant/components/fritz/* @mammuth @AaronDavidSchneider @chemelli74
homeassistant/components/fronius/* @nielstron homeassistant/components/fronius/* @nielstron
homeassistant/components/frontend/* @home-assistant/frontend homeassistant/components/frontend/* @home-assistant/frontend
homeassistant/components/garmin_connect/* @cyberjunky homeassistant/components/garmin_connect/* @cyberjunky

View File

@ -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

View 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

View 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),
}
)

View 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

View File

@ -1,107 +1,195 @@
"""Support for FRITZ!Box routers.""" """Support for FRITZ!Box routers."""
import logging import logging
from typing import Dict
from fritzconnection.core import exceptions as fritzexceptions
from fritzconnection.lib.fritzhosts import FritzHosts
import voluptuous as vol import voluptuous as vol
from homeassistant.components.device_tracker import ( from homeassistant.components.device_tracker import (
DOMAIN, DOMAIN as DEVICE_TRACKER_DOMAIN,
PLATFORM_SCHEMA, 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.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.config_validation as cv 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__) _LOGGER = logging.getLogger(__name__)
DEFAULT_HOST = "169.254.1.1" # This IP is valid for all FRITZ!Box routers. YAML_DEFAULT_HOST = "169.254.1.1"
DEFAULT_USERNAME = "admin" YAML_DEFAULT_USERNAME = "admin"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = vol.All(
cv.deprecated(CONF_HOST),
cv.deprecated(CONF_USERNAME),
cv.deprecated(CONF_PASSWORD),
PLATFORM_SCHEMA.extend(
{ {
vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_HOST, default=YAML_DEFAULT_HOST): cv.string,
vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, vol.Optional(CONF_USERNAME, default=YAML_DEFAULT_USERNAME): cv.string,
vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(CONF_PASSWORD): cv.string,
} }
),
) )
def get_scanner(hass, config): async def async_get_scanner(hass: HomeAssistant, config: ConfigType):
"""Validate the configuration and return FritzBoxScanner.""" """Import legacy FRITZ!Box configuration."""
scanner = FritzBoxScanner(config[DOMAIN])
return scanner if scanner.success_init else None _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.""" """This class queries a FRITZ!Box router."""
def __init__(self, config): def __init__(self, router: FritzBoxTools, device):
"""Initialize the scanner.""" """Initialize a FRITZ!Box device."""
self.last_results = [] self._router = router
self.host = config[CONF_HOST] self._mac = device.mac_address
self.username = config[CONF_USERNAME] self._name = device.hostname or DEFAULT_DEVICE_NAME
self.password = config.get(CONF_PASSWORD) self._active = False
self.success_init = True self._attrs = {}
# Establish a connection to the FRITZ!Box. @property
try: def is_connected(self):
self.fritz_box = FritzHosts( """Return device status."""
address=self.host, user=self.username, password=self.password return self._active
)
except (ValueError, TypeError):
self.fritz_box = None
# At this point it is difficult to tell if a connection is established. @property
# So just check for null objects. def name(self):
if self.fritz_box is None or not self.fritz_box.modelname: """Return device name."""
self.success_init = False return self._name
if self.success_init: @property
_LOGGER.info("Successfully connected to %s", self.fritz_box.modelname) def unique_id(self):
self._update_info() """Return device unique id."""
else: return self._mac
_LOGGER.error(
"Failed to establish connection to FRITZ!Box with IP: %s", self.host
)
def scan_devices(self): @property
"""Scan for new devices and return a list of found device ids.""" def ip_address(self) -> str:
self._update_info() """Return the primary ip address of the device."""
active_hosts = [] return self._router.devices[self._mac].ip_address
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
def get_device_name(self, device): @property
"""Return the name of the given device or None if is not known.""" def mac_address(self) -> str:
ret = self.fritz_box.get_specific_host_entry(device).get("NewHostName") """Return the mac address of the device."""
if ret == {}: return self._mac
return None
return ret
def get_extra_attributes(self, device): @property
"""Return the attributes (ip, mac) of the given device or None if is not known.""" def hostname(self) -> str:
ip_device = None """Return hostname of the device."""
try: return self._router.devices[self._mac].hostname
ip_device = self.fritz_box.get_specific_host_entry(device).get(
"NewIPAddress"
)
except fritzexceptions.FritzLookUpError as fritz_lookup_error:
_LOGGER.warning(
"Host entry for %s not found: %s", device, fritz_lookup_error
)
if not ip_device: @property
return {} def source_type(self) -> str:
return {"ip": ip_device, "mac": device} """Return tracker source type."""
return SOURCE_TYPE_ROUTER
def _update_info(self): @property
"""Retrieve latest information from the FRITZ!Box.""" def device_info(self) -> Dict[str, any]:
if not self.success_init: """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 return False
_LOGGER.debug("Scanning") @property
self.last_results = self.fritz_box.get_hosts_info() def icon(self):
return True """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"
)
@callback
def async_on_demand_update(self):
"""Update state."""
self.async_process_update()
self.async_write_ha_state()
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,
)
)

View File

@ -1,8 +1,21 @@
{ {
"domain": "fritz", "domain": "fritz",
"name": "AVM FRITZ!Box", "name": "AVM FRITZ!Box Tools",
"documentation": "https://www.home-assistant.io/integrations/fritz", "documentation": "https://www.home-assistant.io/integrations/fritz",
"requirements": ["fritzconnection==1.4.2"], "requirements": [
"codeowners": [], "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" "iot_class": "local_polling"
} }

View 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%]"
}
}
}

View 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"
}
}
}
}

View File

@ -76,6 +76,7 @@ FLOWS = [
"forked_daapd", "forked_daapd",
"foscam", "foscam",
"freebox", "freebox",
"fritz",
"fritzbox", "fritzbox",
"fritzbox_callmonitor", "fritzbox_callmonitor",
"garmin_connect", "garmin_connect",

View File

@ -83,6 +83,11 @@ SSDP = {
"manufacturer": "DIRECTV" "manufacturer": "DIRECTV"
} }
], ],
"fritz": [
{
"st": "urn:schemas-upnp-org:device:fritzbox:1"
}
],
"fritzbox": [ "fritzbox": [
{ {
"st": "urn:schemas-upnp-org:device:fritzbox:1" "st": "urn:schemas-upnp-org:device:fritzbox:1"

View File

@ -2356,6 +2356,7 @@ xboxapi==2.0.1
xknx==0.18.1 xknx==0.18.1
# homeassistant.components.bluesound # homeassistant.components.bluesound
# homeassistant.components.fritz
# homeassistant.components.rest # homeassistant.components.rest
# homeassistant.components.startca # homeassistant.components.startca
# homeassistant.components.ted5000 # homeassistant.components.ted5000

View File

@ -1247,6 +1247,7 @@ xbox-webapi==2.0.8
xknx==0.18.1 xknx==0.18.1
# homeassistant.components.bluesound # homeassistant.components.bluesound
# homeassistant.components.fritz
# homeassistant.components.rest # homeassistant.components.rest
# homeassistant.components.startca # homeassistant.components.startca
# homeassistant.components.ted5000 # homeassistant.components.ted5000

View 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())

View 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