From 19be4a5d6de1356d84ff1ff81a5b72f50b8bb4e7 Mon Sep 17 00:00:00 2001
From: Quentame <polletquentin74@me.com>
Date: Wed, 11 Mar 2020 22:15:59 +0100
Subject: [PATCH] Refactor Freebox : add config flow + temperature sensor +
 signal dispatch (#30334)

* Add config flow to Freebox

* Add manufacturer in device_tracker info

* Add device_info to sensor + switch

* Add device_info: connections

* Add config_flow test + update .coveragerc

* Typing

* Add device_type icon

* Remove one error log

* Fix pylint

* Add myself as CODEOWNER

* Handle sync in one place

* Separate the Freebox[Router/Device/Sensor] from __init__.py

* Add link step to config flow

* Make temperature sensors auto-discovered

* Use device activity instead of reachablility for device_tracker

* Store token file in .storage

Depending on host if list of Freebox integration on the future without breaking change

* Remove IP sensors + add Freebox router as a device with attrs : IPs, conection type, uptime, version & serial

* Add sensor should_poll=False

* Test typing

* Handle devices with no name

* None is the default for data

* Fix comment

* Use config_entry.unique_id

* Add async_unload_entry with asyncio

* Add and use bunch of data size and rate related constants (#31781)

* Review

* Remove useless "already_configured" error string

* Review : merge 2 device & 2 sensor classes

* Entities from platforms

* Fix unload + add device after setup + clean loggers

* async_add_entities True

* Review

* Use pathlib + refactor get_api

* device_tracker set + tests with CoroutineMock()

* Removing active & reachable from tracker attrs

* Review

* Fix pipeline

* typing

* typing

* typing

* Raise ConfigEntryNotReady when HttpRequestError at setup

* Review

* Multiple Freebox s

* Review: store sensors in router

* Freebox: a sensor story
---
 .coveragerc                                   |   6 +-
 CODEOWNERS                                    |   2 +-
 .../components/freebox/.translations/en.json  |  26 +++
 homeassistant/components/freebox/__init__.py  | 109 +++++-----
 .../components/freebox/config_flow.py         | 110 ++++++++++
 homeassistant/components/freebox/const.py     |  75 +++++++
 .../components/freebox/device_tracker.py      | 181 +++++++++++-----
 .../components/freebox/manifest.json          |   3 +-
 homeassistant/components/freebox/router.py    | 193 ++++++++++++++++++
 homeassistant/components/freebox/sensor.py    | 152 +++++++++-----
 homeassistant/components/freebox/strings.json |  26 +++
 homeassistant/components/freebox/switch.py    |  49 +++--
 homeassistant/generated/config_flows.py       |   1 +
 requirements_test_all.txt                     |   3 +
 tests/components/freebox/__init__.py          |   1 +
 tests/components/freebox/conftest.py          |  11 +
 tests/components/freebox/test_config_flow.py  | 144 +++++++++++++
 17 files changed, 922 insertions(+), 170 deletions(-)
 create mode 100644 homeassistant/components/freebox/.translations/en.json
 create mode 100644 homeassistant/components/freebox/config_flow.py
 create mode 100644 homeassistant/components/freebox/const.py
 create mode 100644 homeassistant/components/freebox/router.py
 create mode 100644 homeassistant/components/freebox/strings.json
 create mode 100644 tests/components/freebox/__init__.py
 create mode 100644 tests/components/freebox/conftest.py
 create mode 100644 tests/components/freebox/test_config_flow.py

diff --git a/.coveragerc b/.coveragerc
index 2716a1fed44..c94199d6451 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -242,7 +242,11 @@ omit =
     homeassistant/components/foscam/const.py
     homeassistant/components/foursquare/*
     homeassistant/components/free_mobile/notify.py
-    homeassistant/components/freebox/*
+    homeassistant/components/freebox/__init__.py
+    homeassistant/components/freebox/device_tracker.py
+    homeassistant/components/freebox/router.py
+    homeassistant/components/freebox/sensor.py
+    homeassistant/components/freebox/switch.py
     homeassistant/components/fritz/device_tracker.py
     homeassistant/components/fritzbox/*
     homeassistant/components/fritzbox_callmonitor/sensor.py
diff --git a/CODEOWNERS b/CODEOWNERS
index 97b347b8415..8b85278b4bb 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -122,7 +122,7 @@ homeassistant/components/fortigate/* @kifeo
 homeassistant/components/fortios/* @kimfrellsen
 homeassistant/components/foscam/* @skgsergio
 homeassistant/components/foursquare/* @robbiet480
-homeassistant/components/freebox/* @snoof85
+homeassistant/components/freebox/* @snoof85 @Quentame
 homeassistant/components/fronius/* @nielstron
 homeassistant/components/frontend/* @home-assistant/frontend
 homeassistant/components/garmin_connect/* @cyberjunky
diff --git a/homeassistant/components/freebox/.translations/en.json b/homeassistant/components/freebox/.translations/en.json
new file mode 100644
index 00000000000..75d925e2f7a
--- /dev/null
+++ b/homeassistant/components/freebox/.translations/en.json
@@ -0,0 +1,26 @@
+{
+    "config": {
+        "abort": {
+            "already_configured": "Host already configured"
+        },
+        "error": {
+            "connection_failed": "Failed to connect, please try again",
+            "register_failed": "Failed to register, please try again",
+            "unknown": "Unknown error: please retry later"
+        },
+        "step": {
+            "link": {
+                "description": "Click \"Submit\", then touch the right arrow on the router to register Freebox with Home Assistant.\n\n![Location of button on the router](/static/images/config_freebox.png)",
+                "title": "Link Freebox router"
+            },
+            "user": {
+                "data": {
+                    "host": "Host",
+                    "port": "Port"
+                },
+                "title": "Freebox"
+            }
+        },
+        "title": "Freebox"
+    }
+}
\ No newline at end of file
diff --git a/homeassistant/components/freebox/__init__.py b/homeassistant/components/freebox/__init__.py
index 58426334dea..9e303c75e7a 100644
--- a/homeassistant/components/freebox/__init__.py
+++ b/homeassistant/components/freebox/__init__.py
@@ -1,29 +1,26 @@
 """Support for Freebox devices (Freebox v6 and Freebox mini 4K)."""
+import asyncio
 import logging
-import socket
 
-from aiofreepybox import Freepybox
-from aiofreepybox.exceptions import HttpRequestError
 import voluptuous as vol
 
 from homeassistant.components.discovery import SERVICE_FREEBOX
+from homeassistant.config_entries import SOURCE_DISCOVERY, SOURCE_IMPORT, ConfigEntry
 from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP
 from homeassistant.helpers import config_validation as cv, discovery
-from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.typing import HomeAssistantType
 
-_LOGGER = logging.getLogger(__name__)
+from .const import DOMAIN, PLATFORMS
+from .router import FreeboxRouter
 
-DOMAIN = "freebox"
-DATA_FREEBOX = DOMAIN
+_LOGGER = logging.getLogger(__name__)
 
-FREEBOX_CONFIG_FILE = "freebox.conf"
+FREEBOX_SCHEMA = vol.Schema(
+    {vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port}
+)
 
 CONFIG_SCHEMA = vol.Schema(
-    {
-        DOMAIN: vol.Schema(
-            {vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port}
-        )
-    },
+    {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [FREEBOX_SCHEMA]))},
     extra=vol.ALLOW_EXTRA,
 )
 
@@ -37,54 +34,70 @@ async def async_setup(hass, config):
             host = discovery_info.get("properties", {}).get("api_domain")
             port = discovery_info.get("properties", {}).get("https_port")
             _LOGGER.info("Discovered Freebox server: %s:%s", host, port)
-            await async_setup_freebox(hass, config, host, port)
+            hass.async_create_task(
+                hass.config_entries.flow.async_init(
+                    DOMAIN,
+                    context={"source": SOURCE_DISCOVERY},
+                    data={CONF_HOST: host, CONF_PORT: port},
+                )
+            )
 
     discovery.async_listen(hass, SERVICE_FREEBOX, discovery_dispatch)
 
-    if conf is not None:
-        host = conf.get(CONF_HOST)
-        port = conf.get(CONF_PORT)
-        await async_setup_freebox(hass, config, host, port)
+    if conf is None:
+        return True
+
+    for freebox_conf in conf:
+        hass.async_create_task(
+            hass.config_entries.flow.async_init(
+                DOMAIN, context={"source": SOURCE_IMPORT}, data=freebox_conf,
+            )
+        )
 
     return True
 
 
-async def async_setup_freebox(hass, config, host, port):
-    """Start up the Freebox component platforms."""
+async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
+    """Set up Freebox component."""
+    router = FreeboxRouter(hass, entry)
+    await router.setup()
 
-    app_desc = {
-        "app_id": "hass",
-        "app_name": "Home Assistant",
-        "app_version": "0.65",
-        "device_name": socket.gethostname(),
-    }
+    hass.data.setdefault(DOMAIN, {})
+    hass.data[DOMAIN][entry.unique_id] = router
 
-    token_file = hass.config.path(FREEBOX_CONFIG_FILE)
-    api_version = "v6"
+    for platform in PLATFORMS:
+        hass.async_create_task(
+            hass.config_entries.async_forward_entry_setup(entry, platform)
+        )
 
-    fbx = Freepybox(app_desc=app_desc, token_file=token_file, api_version=api_version)
+    # Services
+    async def async_reboot(call):
+        """Handle reboot service call."""
+        await router.reboot()
 
-    try:
-        await fbx.open(host, port)
-    except HttpRequestError:
-        _LOGGER.exception("Failed to connect to Freebox")
-    else:
-        hass.data[DATA_FREEBOX] = fbx
+    hass.services.async_register(DOMAIN, "reboot", async_reboot)
 
-        async def async_freebox_reboot(call):
-            """Handle reboot service call."""
-            await fbx.system.reboot()
+    async def async_close_connection(event):
+        """Close Freebox connection on HA Stop."""
+        await router.close()
 
-        hass.services.async_register(DOMAIN, "reboot", async_freebox_reboot)
+    hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_close_connection)
+
+    return True
 
-        hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config))
-        hass.async_create_task(
-            async_load_platform(hass, "device_tracker", DOMAIN, {}, config)
-        )
-        hass.async_create_task(async_load_platform(hass, "switch", DOMAIN, {}, config))
 
-        async def close_fbx(event):
-            """Close Freebox connection on HA Stop."""
-            await fbx.close()
+async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry):
+    """Unload a config entry."""
+    unload_ok = all(
+        await asyncio.gather(
+            *[
+                hass.config_entries.async_forward_entry_unload(entry, platform)
+                for platform in PLATFORMS
+            ]
+        )
+    )
+    if unload_ok:
+        router = hass.data[DOMAIN].pop(entry.unique_id)
+        await router.close()
 
-        hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_fbx)
+    return unload_ok
diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py
new file mode 100644
index 00000000000..b2d1a0ab771
--- /dev/null
+++ b/homeassistant/components/freebox/config_flow.py
@@ -0,0 +1,110 @@
+"""Config flow to configure the Freebox integration."""
+import logging
+
+from aiofreepybox.exceptions import AuthorizationError, HttpRequestError
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_HOST, CONF_PORT
+
+from .const import DOMAIN  # pylint: disable=unused-import
+from .router import get_api
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
+    """Handle a config flow."""
+
+    VERSION = 1
+    CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+    def __init__(self):
+        """Initialize Freebox config flow."""
+        self._host = None
+        self._port = None
+
+    def _show_setup_form(self, user_input=None, errors=None):
+        """Show the setup form to the user."""
+
+        if user_input is None:
+            user_input = {}
+
+        return self.async_show_form(
+            step_id="user",
+            data_schema=vol.Schema(
+                {
+                    vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
+                    vol.Required(CONF_PORT, default=user_input.get(CONF_PORT, "")): int,
+                }
+            ),
+            errors=errors or {},
+        )
+
+    async def async_step_user(self, user_input=None):
+        """Handle a flow initiated by the user."""
+        errors = {}
+
+        if user_input is None:
+            return self._show_setup_form(user_input, errors)
+
+        self._host = user_input[CONF_HOST]
+        self._port = user_input[CONF_PORT]
+
+        # Check if already configured
+        await self.async_set_unique_id(self._host)
+        self._abort_if_unique_id_configured()
+
+        return await self.async_step_link()
+
+    async def async_step_link(self, user_input=None):
+        """Attempt to link with the Freebox router.
+
+        Given a configured host, will ask the user to press the button
+        to connect to the router.
+        """
+        if user_input is None:
+            return self.async_show_form(step_id="link")
+
+        errors = {}
+
+        fbx = await get_api(self.hass, self._host)
+        try:
+            # Open connection and check authentication
+            await fbx.open(self._host, self._port)
+
+            # Check permissions
+            await fbx.system.get_config()
+            await fbx.lan.get_hosts_list()
+            await self.hass.async_block_till_done()
+
+            # Close connection
+            await fbx.close()
+
+            return self.async_create_entry(
+                title=self._host, data={CONF_HOST: self._host, CONF_PORT: self._port},
+            )
+
+        except AuthorizationError as error:
+            _LOGGER.error(error)
+            errors["base"] = "register_failed"
+
+        except HttpRequestError:
+            _LOGGER.error("Error connecting to the Freebox router at %s", self._host)
+            errors["base"] = "connection_failed"
+
+        except Exception:  # pylint: disable=broad-except
+            _LOGGER.exception(
+                "Unknown error connecting with Freebox router at %s", self._host
+            )
+            errors["base"] = "unknown"
+
+        return self.async_show_form(step_id="link", errors=errors)
+
+    async def async_step_import(self, user_input=None):
+        """Import a config entry."""
+        return await self.async_step_user(user_input)
+
+    async def async_step_discovery(self, user_input=None):
+        """Initialize step from discovery."""
+        return await self.async_step_user(user_input)
diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py
new file mode 100644
index 00000000000..0612e4e76f1
--- /dev/null
+++ b/homeassistant/components/freebox/const.py
@@ -0,0 +1,75 @@
+"""Freebox component constants."""
+import socket
+
+from homeassistant.const import (
+    DATA_RATE_KILOBYTES_PER_SECOND,
+    DEVICE_CLASS_TEMPERATURE,
+    TEMP_CELSIUS,
+)
+
+DOMAIN = "freebox"
+
+APP_DESC = {
+    "app_id": "hass",
+    "app_name": "Home Assistant",
+    "app_version": "0.106",
+    "device_name": socket.gethostname(),
+}
+API_VERSION = "v6"
+
+PLATFORMS = ["device_tracker", "sensor", "switch"]
+
+DEFAULT_DEVICE_NAME = "Unknown device"
+
+# to store the cookie
+STORAGE_KEY = DOMAIN
+STORAGE_VERSION = 1
+
+# Sensor
+SENSOR_NAME = "name"
+SENSOR_UNIT = "unit"
+SENSOR_ICON = "icon"
+SENSOR_DEVICE_CLASS = "device_class"
+
+CONNECTION_SENSORS = {
+    "rate_down": {
+        SENSOR_NAME: "Freebox download speed",
+        SENSOR_UNIT: DATA_RATE_KILOBYTES_PER_SECOND,
+        SENSOR_ICON: "mdi:download-network",
+        SENSOR_DEVICE_CLASS: None,
+    },
+    "rate_up": {
+        SENSOR_NAME: "Freebox upload speed",
+        SENSOR_UNIT: DATA_RATE_KILOBYTES_PER_SECOND,
+        SENSOR_ICON: "mdi:upload-network",
+        SENSOR_DEVICE_CLASS: None,
+    },
+}
+
+TEMPERATURE_SENSOR_TEMPLATE = {
+    SENSOR_NAME: None,
+    SENSOR_UNIT: TEMP_CELSIUS,
+    SENSOR_ICON: "mdi:thermometer",
+    SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
+}
+
+# Icons
+DEVICE_ICONS = {
+    "freebox_delta": "mdi:television-guide",
+    "freebox_hd": "mdi:television-guide",
+    "freebox_mini": "mdi:television-guide",
+    "freebox_player": "mdi:television-guide",
+    "ip_camera": "mdi:cctv",
+    "ip_phone": "mdi:phone-voip",
+    "laptop": "mdi:laptop",
+    "multimedia_device": "mdi:play-network",
+    "nas": "mdi:nas",
+    "networking_device": "mdi:network",
+    "printer": "mdi:printer",
+    "router": "mdi:router-wireless",
+    "smartphone": "mdi:cellphone",
+    "tablet": "mdi:tablet",
+    "television": "mdi:television",
+    "vg_console": "mdi:gamepad-variant",
+    "workstation": "mdi:desktop-tower-monitor",
+}
diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py
index 63cf869990d..ea9919f5742 100644
--- a/homeassistant/components/freebox/device_tracker.py
+++ b/homeassistant/components/freebox/device_tracker.py
@@ -1,65 +1,148 @@
 """Support for Freebox devices (Freebox v6 and Freebox mini 4K)."""
-from collections import namedtuple
+from datetime import datetime
 import logging
+from typing import Dict
 
-from homeassistant.components.device_tracker import DeviceScanner
+from homeassistant.components.device_tracker import SOURCE_TYPE_ROUTER
+from homeassistant.components.device_tracker.config_entry import ScannerEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import callback
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.typing import HomeAssistantType
 
-from . import DATA_FREEBOX
+from .const import DEFAULT_DEVICE_NAME, DEVICE_ICONS, DOMAIN
+from .router import FreeboxRouter
 
 _LOGGER = logging.getLogger(__name__)
 
 
-async def async_get_scanner(hass, config):
-    """Validate the configuration and return a Freebox scanner."""
-    scanner = FreeboxDeviceScanner(hass.data[DATA_FREEBOX])
-    await scanner.async_connect()
-    return scanner if scanner.success_init else None
+async def async_setup_entry(
+    hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
+) -> None:
+    """Set up device tracker for Freebox component."""
+    router = hass.data[DOMAIN][entry.unique_id]
+    tracked = set()
 
+    @callback
+    def update_router():
+        """Update the values of the router."""
+        add_entities(router, async_add_entities, tracked)
 
-Device = namedtuple("Device", ["id", "name", "ip"])
-
-
-def _build_device(device_dict):
-    return Device(
-        device_dict["l2ident"]["id"],
-        device_dict["primary_name"],
-        device_dict["l3connectivities"][0]["addr"],
+    router.listeners.append(
+        async_dispatcher_connect(hass, router.signal_device_new, update_router)
     )
 
-
-class FreeboxDeviceScanner(DeviceScanner):
-    """Queries the Freebox device."""
-
-    def __init__(self, fbx):
-        """Initialize the scanner."""
-        self.last_results = {}
-        self.success_init = False
-        self.connection = fbx
-
-    async def async_connect(self):
-        """Initialize connection to the router."""
-        # Test the router is accessible.
-        data = await self.connection.lan.get_hosts_list()
-        self.success_init = data is not None
-
-    async def async_scan_devices(self):
-        """Scan for new devices and return a list with found device IDs."""
-        await self.async_update_info()
-        return [device.id for device in self.last_results]
-
-    async def get_device_name(self, device):
-        """Return the name of the given device or None if we don't know."""
-        name = next(
-            (result.name for result in self.last_results if result.id == device), None
+    update_router()
+
+
+@callback
+def 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(FreeboxDevice(router, device))
+        tracked.add(mac)
+
+    if new_tracked:
+        async_add_entities(new_tracked, True)
+
+
+class FreeboxDevice(ScannerEntity):
+    """Representation of a Freebox device."""
+
+    def __init__(self, router: FreeboxRouter, device: Dict[str, any]) -> None:
+        """Initialize a Freebox device."""
+        self._router = router
+        self._name = device["primary_name"].strip() or DEFAULT_DEVICE_NAME
+        self._mac = device["l2ident"]["id"]
+        self._manufacturer = device["vendor_name"]
+        self._icon = icon_for_freebox_device(device)
+        self._active = False
+        self._attrs = {}
+
+        self._unsub_dispatcher = None
+
+    def update(self) -> None:
+        """Update the Freebox device."""
+        device = self._router.devices[self._mac]
+        self._active = device["active"]
+        if device.get("attrs") is None:
+            # device
+            self._attrs = {
+                "last_time_reachable": datetime.fromtimestamp(
+                    device["last_time_reachable"]
+                ),
+                "last_time_activity": datetime.fromtimestamp(device["last_activity"]),
+            }
+        else:
+            # router
+            self._attrs = device["attrs"]
+
+    @property
+    def unique_id(self) -> str:
+        """Return a unique ID."""
+        return self._mac
+
+    @property
+    def name(self) -> str:
+        """Return the name."""
+        return self._name
+
+    @property
+    def is_connected(self):
+        """Return true if the device is connected to the network."""
+        return self._active
+
+    @property
+    def source_type(self) -> str:
+        """Return the source type."""
+        return SOURCE_TYPE_ROUTER
+
+    @property
+    def icon(self) -> str:
+        """Return the icon."""
+        return self._icon
+
+    @property
+    def device_state_attributes(self) -> Dict[str, any]:
+        """Return the attributes."""
+        return self._attrs
+
+    @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": self._manufacturer,
+        }
+
+    @property
+    def should_poll(self) -> bool:
+        """No polling needed."""
+        return False
+
+    async def async_on_demand_update(self):
+        """Update state."""
+        self.async_schedule_update_ha_state(True)
+
+    async def async_added_to_hass(self):
+        """Register state update callback."""
+        self._unsub_dispatcher = async_dispatcher_connect(
+            self.hass, self._router.signal_device_update, self.async_on_demand_update
         )
-        return name
-
-    async def async_update_info(self):
-        """Ensure the information from the Freebox router is up to date."""
-        _LOGGER.debug("Checking Devices")
 
-        hosts = await self.connection.lan.get_hosts_list()
+    async def async_will_remove_from_hass(self):
+        """Clean up after entity before removal."""
+        self._unsub_dispatcher()
 
-        last_results = [_build_device(device) for device in hosts if device["active"]]
 
-        self.last_results = last_results
+def icon_for_freebox_device(device) -> str:
+    """Return a host icon from his type."""
+    return DEVICE_ICONS.get(device["host_type"], "mdi:help-network")
diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json
index 7a66490c90d..1bfb4924a78 100644
--- a/homeassistant/components/freebox/manifest.json
+++ b/homeassistant/components/freebox/manifest.json
@@ -1,9 +1,10 @@
 {
   "domain": "freebox",
   "name": "Freebox",
+  "config_flow": true,
   "documentation": "https://www.home-assistant.io/integrations/freebox",
   "requirements": ["aiofreepybox==0.0.8"],
   "dependencies": [],
   "after_dependencies": ["discovery"],
-  "codeowners": ["@snoof85"]
+  "codeowners": ["@snoof85", "@Quentame"]
 }
diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py
new file mode 100644
index 00000000000..7b4784c6ca4
--- /dev/null
+++ b/homeassistant/components/freebox/router.py
@@ -0,0 +1,193 @@
+"""Represent the Freebox router and its devices and sensors."""
+from datetime import datetime, timedelta
+import logging
+from pathlib import Path
+from typing import Dict, Optional
+
+from aiofreepybox import Freepybox
+from aiofreepybox.api.wifi import Wifi
+from aiofreepybox.exceptions import HttpRequestError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.util import slugify
+
+from .const import (
+    API_VERSION,
+    APP_DESC,
+    CONNECTION_SENSORS,
+    DOMAIN,
+    STORAGE_KEY,
+    STORAGE_VERSION,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+
+class FreeboxRouter:
+    """Representation of a Freebox router."""
+
+    def __init__(self, hass: HomeAssistantType, entry: ConfigEntry) -> None:
+        """Initialize a Freebox router."""
+        self.hass = hass
+        self._entry = entry
+        self._host = entry.data[CONF_HOST]
+        self._port = entry.data[CONF_PORT]
+
+        self._api: Freepybox = None
+        self._name = None
+        self.mac = None
+        self._sw_v = None
+        self._attrs = {}
+
+        self.devices: Dict[str, any] = {}
+        self.sensors_temperature: Dict[str, int] = {}
+        self.sensors_connection: Dict[str, float] = {}
+
+        self.listeners = []
+
+    async def setup(self) -> None:
+        """Set up a Freebox router."""
+        self._api = await get_api(self.hass, self._host)
+
+        try:
+            await self._api.open(self._host, self._port)
+        except HttpRequestError:
+            _LOGGER.exception("Failed to connect to Freebox")
+            return ConfigEntryNotReady
+
+        # System
+        fbx_config = await self._api.system.get_config()
+        self.mac = fbx_config["mac"]
+        self._name = fbx_config["model_info"]["pretty_name"]
+        self._sw_v = fbx_config["firmware_version"]
+
+        # Devices & sensors
+        await self.update_all()
+        async_track_time_interval(self.hass, self.update_all, SCAN_INTERVAL)
+
+    async def update_all(self, now: Optional[datetime] = None) -> None:
+        """Update all Freebox platforms."""
+        await self.update_sensors()
+        await self.update_devices()
+
+    async def update_devices(self) -> None:
+        """Update Freebox devices."""
+        new_device = False
+        fbx_devices: Dict[str, any] = await self._api.lan.get_hosts_list()
+
+        # Adds the Freebox itself
+        fbx_devices.append(
+            {
+                "primary_name": self._name,
+                "l2ident": {"id": self.mac},
+                "vendor_name": "Freebox SAS",
+                "host_type": "router",
+                "active": True,
+                "attrs": self._attrs,
+            }
+        )
+
+        for fbx_device in fbx_devices:
+            device_mac = fbx_device["l2ident"]["id"]
+
+            if self.devices.get(device_mac) is None:
+                new_device = True
+
+            self.devices[device_mac] = fbx_device
+
+        async_dispatcher_send(self.hass, self.signal_device_update)
+
+        if new_device:
+            async_dispatcher_send(self.hass, self.signal_device_new)
+
+    async def update_sensors(self) -> None:
+        """Update Freebox sensors."""
+        # System sensors
+        syst_datas: Dict[str, any] = await self._api.system.get_config()
+
+        # According to the doc `syst_datas["sensors"]` is temperature sensors in celsius degree.
+        # Name and id of sensors may vary under Freebox devices.
+        for sensor in syst_datas["sensors"]:
+            self.sensors_temperature[sensor["name"]] = sensor["value"]
+
+        # Connection sensors
+        connection_datas: Dict[str, any] = await self._api.connection.get_status()
+        for sensor_key in CONNECTION_SENSORS:
+            self.sensors_connection[sensor_key] = connection_datas[sensor_key]
+
+        self._attrs = {
+            "IPv4": connection_datas.get("ipv4"),
+            "IPv6": connection_datas.get("ipv6"),
+            "connection_type": connection_datas["media"],
+            "uptime": datetime.fromtimestamp(
+                round(datetime.now().timestamp()) - syst_datas["uptime_val"]
+            ),
+            "firmware_version": self._sw_v,
+            "serial": syst_datas["serial"],
+        }
+
+        async_dispatcher_send(self.hass, self.signal_sensor_update)
+
+    async def reboot(self) -> None:
+        """Reboot the Freebox."""
+        await self._api.system.reboot()
+
+    async def close(self) -> None:
+        """Close the connection."""
+        if self._api is not None:
+            await self._api.close()
+        self._api = None
+
+    @property
+    def device_info(self) -> Dict[str, any]:
+        """Return the device information."""
+        return {
+            "connections": {(CONNECTION_NETWORK_MAC, self.mac)},
+            "identifiers": {(DOMAIN, self.mac)},
+            "name": self._name,
+            "manufacturer": "Freebox SAS",
+            "sw_version": self._sw_v,
+        }
+
+    @property
+    def signal_device_new(self) -> str:
+        """Event specific per Freebox entry to signal new device."""
+        return f"{DOMAIN}-{self._host}-device-new"
+
+    @property
+    def signal_device_update(self) -> str:
+        """Event specific per Freebox entry to signal updates in devices."""
+        return f"{DOMAIN}-{self._host}-device-update"
+
+    @property
+    def signal_sensor_update(self) -> str:
+        """Event specific per Freebox entry to signal updates in sensors."""
+        return f"{DOMAIN}-{self._host}-sensor-update"
+
+    @property
+    def sensors(self) -> Wifi:
+        """Return the wifi."""
+        return {**self.sensors_temperature, **self.sensors_connection}
+
+    @property
+    def wifi(self) -> Wifi:
+        """Return the wifi."""
+        return self._api.wifi
+
+
+async def get_api(hass: HomeAssistantType, host: str) -> Freepybox:
+    """Get the Freebox API."""
+    freebox_path = Path(hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY).path)
+    freebox_path.mkdir(exist_ok=True)
+
+    token_file = Path(f"{freebox_path}/{slugify(host)}.conf")
+
+    return Freepybox(APP_DESC, token_file, API_VERSION)
diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py
index 0653120b49c..a3c5c32901c 100644
--- a/homeassistant/components/freebox/sensor.py
+++ b/homeassistant/components/freebox/sensor.py
@@ -1,81 +1,127 @@
 """Support for Freebox devices (Freebox v6 and Freebox mini 4K)."""
 import logging
+from typing import Dict
 
+from homeassistant.config_entries import ConfigEntry
 from homeassistant.const import DATA_RATE_KILOBYTES_PER_SECOND
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
 from homeassistant.helpers.entity import Entity
-
-from . import DATA_FREEBOX
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import (
+    CONNECTION_SENSORS,
+    DOMAIN,
+    SENSOR_DEVICE_CLASS,
+    SENSOR_ICON,
+    SENSOR_NAME,
+    SENSOR_UNIT,
+    TEMPERATURE_SENSOR_TEMPLATE,
+)
+from .router import FreeboxRouter
 
 _LOGGER = logging.getLogger(__name__)
 
 
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+async def async_setup_entry(
+    hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
+) -> None:
     """Set up the sensors."""
-    fbx = hass.data[DATA_FREEBOX]
-    async_add_entities([FbxRXSensor(fbx), FbxTXSensor(fbx)], True)
+    router = hass.data[DOMAIN][entry.unique_id]
+    entities = []
+
+    for sensor_name in router.sensors_temperature:
+        entities.append(
+            FreeboxSensor(
+                router,
+                sensor_name,
+                {**TEMPERATURE_SENSOR_TEMPLATE, SENSOR_NAME: f"Freebox {sensor_name}"},
+            )
+        )
+
+    for sensor_key in CONNECTION_SENSORS:
+        entities.append(
+            FreeboxSensor(router, sensor_key, CONNECTION_SENSORS[sensor_key])
+        )
 
+    async_add_entities(entities, True)
 
-class FbxSensor(Entity):
-    """Representation of a freebox sensor."""
 
-    _name = "generic"
-    _unit = None
-    _icon = None
+class FreeboxSensor(Entity):
+    """Representation of a Freebox sensor."""
 
-    def __init__(self, fbx):
-        """Initialize the sensor."""
-        self._fbx = fbx
+    def __init__(
+        self, router: FreeboxRouter, sensor_type: str, sensor: Dict[str, any]
+    ) -> None:
+        """Initialize a Freebox sensor."""
         self._state = None
-        self._datas = None
+        self._router = router
+        self._sensor_type = sensor_type
+        self._name = sensor[SENSOR_NAME]
+        self._unit = sensor[SENSOR_UNIT]
+        self._icon = sensor[SENSOR_ICON]
+        self._device_class = sensor[SENSOR_DEVICE_CLASS]
+        self._unique_id = f"{self._router.mac} {self._name}"
+
+        self._unsub_dispatcher = None
+
+    def update(self) -> None:
+        """Update the Freebox sensor."""
+        state = self._router.sensors[self._sensor_type]
+        if self._unit == DATA_RATE_KILOBYTES_PER_SECOND:
+            self._state = round(state / 1000, 2)
+        else:
+            self._state = state
+
+    @property
+    def unique_id(self) -> str:
+        """Return a unique ID."""
+        return self._unique_id
 
     @property
-    def name(self):
-        """Return the name of the sensor."""
+    def name(self) -> str:
+        """Return the name."""
         return self._name
 
     @property
-    def unit_of_measurement(self):
-        """Return the unit of the sensor."""
+    def state(self) -> str:
+        """Return the state."""
+        return self._state
+
+    @property
+    def unit_of_measurement(self) -> str:
+        """Return the unit."""
         return self._unit
 
     @property
-    def icon(self):
-        """Return the icon of the sensor."""
+    def icon(self) -> str:
+        """Return the icon."""
         return self._icon
 
     @property
-    def state(self):
-        """Return the state of the sensor."""
-        return self._state
+    def device_class(self) -> str:
+        """Return the device_class."""
+        return self._device_class
 
-    async def async_update(self):
-        """Fetch status from freebox."""
-        self._datas = await self._fbx.connection.get_status()
-
-
-class FbxRXSensor(FbxSensor):
-    """Update the Freebox RxSensor."""
-
-    _name = "Freebox download speed"
-    _unit = DATA_RATE_KILOBYTES_PER_SECOND
-    _icon = "mdi:download-network"
-
-    async def async_update(self):
-        """Get the value from fetched datas."""
-        await super().async_update()
-        if self._datas is not None:
-            self._state = round(self._datas["rate_down"] / 1000, 2)
-
-
-class FbxTXSensor(FbxSensor):
-    """Update the Freebox TxSensor."""
-
-    _name = "Freebox upload speed"
-    _unit = DATA_RATE_KILOBYTES_PER_SECOND
-    _icon = "mdi:upload-network"
+    @property
+    def device_info(self) -> Dict[str, any]:
+        """Return the device information."""
+        return self._router.device_info
 
-    async def async_update(self):
-        """Get the value from fetched datas."""
-        await super().async_update()
-        if self._datas is not None:
-            self._state = round(self._datas["rate_up"] / 1000, 2)
+    @property
+    def should_poll(self) -> bool:
+        """No polling needed."""
+        return False
+
+    async def async_on_demand_update(self):
+        """Update state."""
+        self.async_schedule_update_ha_state(True)
+
+    async def async_added_to_hass(self):
+        """Register state update callback."""
+        self._unsub_dispatcher = async_dispatcher_connect(
+            self.hass, self._router.signal_sensor_update, self.async_on_demand_update
+        )
+
+    async def async_will_remove_from_hass(self):
+        """Clean up after entity before removal."""
+        self._unsub_dispatcher()
diff --git a/homeassistant/components/freebox/strings.json b/homeassistant/components/freebox/strings.json
new file mode 100644
index 00000000000..867a497d02f
--- /dev/null
+++ b/homeassistant/components/freebox/strings.json
@@ -0,0 +1,26 @@
+{
+    "config": {
+        "title": "Freebox",
+        "step": {
+            "user": {
+                "title": "Freebox",
+                "data": {
+                    "host": "Host",
+                    "port": "Port"
+                }
+            },
+            "link": {
+                "title": "Link Freebox router",
+                "description": "Click \"Submit\", then touch the right arrow on the router to register Freebox with Home Assistant.\n\n![Location of button on the router](/static/images/config_freebox.png)"
+            }
+        },
+        "error":{
+            "register_failed": "Failed to register, please try again",
+            "connection_failed": "Failed to connect, please try again",
+            "unknown": "Unknown error: please retry later"
+        },
+        "abort":{
+            "already_configured": "Host already configured"
+        }
+    }
+}
diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py
index 062d6a699fe..9e1011d5d3c 100644
--- a/homeassistant/components/freebox/switch.py
+++ b/homeassistant/components/freebox/switch.py
@@ -1,50 +1,65 @@
 """Support for Freebox Delta, Revolution and Mini 4K."""
 import logging
+from typing import Dict
+
+from aiofreepybox.exceptions import InsufficientPermissionsError
 
 from homeassistant.components.switch import SwitchDevice
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers.typing import HomeAssistantType
 
-from . import DATA_FREEBOX
+from .const import DOMAIN
+from .router import FreeboxRouter
 
 _LOGGER = logging.getLogger(__name__)
 
 
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+async def async_setup_entry(
+    hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
+) -> None:
     """Set up the switch."""
-    fbx = hass.data[DATA_FREEBOX]
-    async_add_entities([FbxWifiSwitch(fbx)], True)
+    router = hass.data[DOMAIN][entry.unique_id]
+    async_add_entities([FreeboxWifiSwitch(router)], True)
 
 
-class FbxWifiSwitch(SwitchDevice):
+class FreeboxWifiSwitch(SwitchDevice):
     """Representation of a freebox wifi switch."""
 
-    def __init__(self, fbx):
+    def __init__(self, router: FreeboxRouter) -> None:
         """Initialize the Wifi switch."""
         self._name = "Freebox WiFi"
         self._state = None
-        self._fbx = fbx
+        self._router = router
+        self._unique_id = f"{self._router.mac} {self._name}"
+
+    @property
+    def unique_id(self) -> str:
+        """Return a unique ID."""
+        return self._unique_id
 
     @property
-    def name(self):
+    def name(self) -> str:
         """Return the name of the switch."""
         return self._name
 
     @property
-    def is_on(self):
+    def is_on(self) -> bool:
         """Return true if device is on."""
         return self._state
 
-    async def _async_set_state(self, enabled):
-        """Turn the switch on or off."""
-        from aiofreepybox.exceptions import InsufficientPermissionsError
+    @property
+    def device_info(self) -> Dict[str, any]:
+        """Return the device information."""
+        return self._router.device_info
 
+    async def _async_set_state(self, enabled: bool):
+        """Turn the switch on or off."""
         wifi_config = {"enabled": enabled}
         try:
-            await self._fbx.wifi.set_global_config(wifi_config)
+            await self._router.wifi.set_global_config(wifi_config)
         except InsufficientPermissionsError:
             _LOGGER.warning(
-                "Home Assistant does not have permissions to"
-                " modify the Freebox settings. Please refer"
-                " to documentation."
+                "Home Assistant does not have permissions to modify the Freebox settings. Please refer to documentation."
             )
 
     async def async_turn_on(self, **kwargs):
@@ -57,6 +72,6 @@ class FbxWifiSwitch(SwitchDevice):
 
     async def async_update(self):
         """Get the state and update it."""
-        datas = await self._fbx.wifi.get_global_config()
+        datas = await self._router.wifi.get_global_config()
         active = datas["enabled"]
         self._state = bool(active)
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index b281a322b23..a7e9b63c1a5 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -29,6 +29,7 @@ FLOWS = [
     "elgato",
     "emulated_roku",
     "esphome",
+    "freebox",
     "garmin_connect",
     "gdacs",
     "geofency",
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 71aa2004eef..23caf750147 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -61,6 +61,9 @@ aiobotocore==0.11.1
 # homeassistant.components.esphome
 aioesphomeapi==2.6.1
 
+# homeassistant.components.freebox
+aiofreepybox==0.0.8
+
 # homeassistant.components.homekit_controller
 aiohomekit[IP]==0.2.29
 
diff --git a/tests/components/freebox/__init__.py b/tests/components/freebox/__init__.py
new file mode 100644
index 00000000000..727b60ae78a
--- /dev/null
+++ b/tests/components/freebox/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Freebox component."""
diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py
new file mode 100644
index 00000000000..e813469cbbf
--- /dev/null
+++ b/tests/components/freebox/conftest.py
@@ -0,0 +1,11 @@
+"""Test helpers for Freebox."""
+from unittest.mock import patch
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def mock_path():
+    """Mock path lib."""
+    with patch("homeassistant.components.freebox.router.Path"):
+        yield
diff --git a/tests/components/freebox/test_config_flow.py b/tests/components/freebox/test_config_flow.py
new file mode 100644
index 00000000000..68e787e1ba0
--- /dev/null
+++ b/tests/components/freebox/test_config_flow.py
@@ -0,0 +1,144 @@
+"""Tests for the Freebox config flow."""
+from aiofreepybox.exceptions import (
+    AuthorizationError,
+    HttpRequestError,
+    InvalidTokenError,
+)
+from asynctest import CoroutineMock, patch
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components.freebox.const import DOMAIN
+from homeassistant.config_entries import SOURCE_DISCOVERY, SOURCE_IMPORT, SOURCE_USER
+from homeassistant.const import CONF_HOST, CONF_PORT
+
+from tests.common import MockConfigEntry
+
+HOST = "myrouter.freeboxos.fr"
+PORT = 1234
+
+
+@pytest.fixture(name="connect")
+def mock_controller_connect():
+    """Mock a successful connection."""
+    with patch("homeassistant.components.freebox.router.Freepybox") as service_mock:
+        service_mock.return_value.open = CoroutineMock()
+        service_mock.return_value.system.get_config = CoroutineMock()
+        service_mock.return_value.lan.get_hosts_list = CoroutineMock()
+        service_mock.return_value.connection.get_status = CoroutineMock()
+        service_mock.return_value.close = CoroutineMock()
+        yield service_mock
+
+
+async def test_user(hass):
+    """Test user config."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": SOURCE_USER}
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+    assert result["step_id"] == "user"
+
+    # test with all provided
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_USER},
+        data={CONF_HOST: HOST, CONF_PORT: PORT},
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+    assert result["step_id"] == "link"
+
+
+async def test_import(hass):
+    """Test import step."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_IMPORT},
+        data={CONF_HOST: HOST, CONF_PORT: PORT},
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+    assert result["step_id"] == "link"
+
+
+async def test_discovery(hass):
+    """Test discovery step."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_DISCOVERY},
+        data={CONF_HOST: HOST, CONF_PORT: PORT},
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+    assert result["step_id"] == "link"
+
+
+async def test_link(hass, connect):
+    """Test linking."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_USER},
+        data={CONF_HOST: HOST, CONF_PORT: PORT},
+    )
+
+    result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+    assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+    assert result["result"].unique_id == HOST
+    assert result["title"] == HOST
+    assert result["data"][CONF_HOST] == HOST
+    assert result["data"][CONF_PORT] == PORT
+
+
+async def test_abort_if_already_setup(hass):
+    """Test we abort if component is already setup."""
+    MockConfigEntry(
+        domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}, unique_id=HOST
+    ).add_to_hass(hass)
+
+    # Should fail, same HOST (import)
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_IMPORT},
+        data={CONF_HOST: HOST, CONF_PORT: PORT},
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+    assert result["reason"] == "already_configured"
+
+    # Should fail, same HOST (flow)
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_USER},
+        data={CONF_HOST: HOST, CONF_PORT: PORT},
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+    assert result["reason"] == "already_configured"
+
+
+async def test_on_link_failed(hass):
+    """Test when we have errors during linking the router."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_USER},
+        data={CONF_HOST: HOST, CONF_PORT: PORT},
+    )
+
+    with patch(
+        "homeassistant.components.freebox.router.Freepybox.open",
+        side_effect=AuthorizationError(),
+    ):
+        result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+        assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+        assert result["errors"] == {"base": "register_failed"}
+
+    with patch(
+        "homeassistant.components.freebox.router.Freepybox.open",
+        side_effect=HttpRequestError(),
+    ):
+        result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+        assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+        assert result["errors"] == {"base": "connection_failed"}
+
+    with patch(
+        "homeassistant.components.freebox.router.Freepybox.open",
+        side_effect=InvalidTokenError(),
+    ):
+        result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+        assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+        assert result["errors"] == {"base": "unknown"}
-- 
GitLab