From 5f86388f1c4b24bdacf09ce7539ad81cc8c908f5 Mon Sep 17 00:00:00 2001
From: starkillerOG <starkiller.og@gmail.com>
Date: Mon, 13 Sep 2021 18:18:21 +0200
Subject: [PATCH] Netgear config flow (#54479)

* Original work from Quentame

* Small adjustments

* Add properties and method_version

* fix unknown name

* add consider_home functionality

* fix typo

* fix key

* swao setup order

* use formatted mac

* add tracked_list option

* add options flow

* add config flow

* add config flow

* clean up registries

* only remove if no other integration has that device

* tracked_list formatting

* convert tracked list

* add import

* move imports

* use new tracked list on update

* use update_device instead of remove

* add strings

* initialize already known devices

* Update router.py

* Update router.py

* Update router.py

* small fixes

* styling

* fix typing

* fix spelling

* Update router.py

* get model of router

* add router device info

* fix api

* add listeners

* update router device info

* remove method version option

* Update __init__.py

* fix styling

* ignore typing

* remove typing

* fix mypy config

* Update mypy.ini

* add options flow tests

* Update .coveragerc

* fix styling

* Update homeassistant/components/netgear/__init__.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/netgear/__init__.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/netgear/__init__.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/netgear/config_flow.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/netgear/router.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* add ConfigEntryNotReady

* Update router.py

* use entry.async_on_unload

* Update homeassistant/components/netgear/device_tracker.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* use cv.ensure_list_csv

* add hostname property

* Update device_tracker.py

* fix typo

* fix isort

* add myself to codeowners

* clean config flow

* further clean config flow

* deprecate old netgear discovery

* split out _async_remove_untracked_registries

* Update homeassistant/components/netgear/config_flow.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/netgear/config_flow.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* cleanup

* fix rename

* fix typo

* remove URL option

* fixes

* add sensor platform

* fixes

* fix removing multiple entities

* remove extra attributes

* initialize sensors correctly

* extra sensors disabled by default

* fix styling and unused imports

* fix tests

* Update .coveragerc

* fix requirements

* remove tracked list

* remove tracked registry editing

* fix styling

* fix discovery test

* simplify unload

* Update homeassistant/components/netgear/router.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* add typing

Co-authored-by: J. Nick Koston <nick@koston.org>

* add typing

Co-authored-by: J. Nick Koston <nick@koston.org>

* add typing

Co-authored-by: J. Nick Koston <nick@koston.org>

* condense NetgearSensorEntities

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/netgear/router.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/netgear/router.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/netgear/router.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/netgear/router.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* add typing

* styling

* add typing

* use ForwardRefrence for typing

* Update homeassistant/components/netgear/device_tracker.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* add typing

* Apply suggestions from code review

Thanks!

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* process review comments

* fix styling

* fix devicename not available on all models

* ensure DeviceName is not needed

* Update homeassistant/components/netgear/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/netgear/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update __init__.py

* fix styling

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
---
 .coveragerc                                   |   3 +
 CODEOWNERS                                    |   1 +
 .../components/discovery/__init__.py          |   2 +-
 homeassistant/components/netgear/__init__.py  |  60 +++-
 .../components/netgear/config_flow.py         | 184 +++++++++++
 homeassistant/components/netgear/const.py     |  60 ++++
 .../components/netgear/device_tracker.py      | 233 ++++++--------
 homeassistant/components/netgear/errors.py    |  10 +
 .../components/netgear/manifest.json          |  13 +-
 homeassistant/components/netgear/router.py    | 292 ++++++++++++++++++
 homeassistant/components/netgear/sensor.py    |  83 +++++
 homeassistant/components/netgear/strings.json |  34 ++
 .../components/netgear/translations/en.json   |  34 ++
 homeassistant/generated/config_flows.py       |   1 +
 homeassistant/generated/ssdp.py               |   6 +
 mypy.ini                                      |   3 +
 requirements_all.txt                          |   2 +-
 requirements_test_all.txt                     |   3 +
 script/hassfest/mypy_config.py                |   1 +
 tests/components/discovery/test_init.py       |   6 +-
 tests/components/netgear/__init__.py          |   1 +
 tests/components/netgear/conftest.py          |  14 +
 tests/components/netgear/test_config_flow.py  | 284 +++++++++++++++++
 23 files changed, 1186 insertions(+), 144 deletions(-)
 create mode 100644 homeassistant/components/netgear/config_flow.py
 create mode 100644 homeassistant/components/netgear/const.py
 create mode 100644 homeassistant/components/netgear/errors.py
 create mode 100644 homeassistant/components/netgear/router.py
 create mode 100644 homeassistant/components/netgear/sensor.py
 create mode 100644 homeassistant/components/netgear/strings.json
 create mode 100644 homeassistant/components/netgear/translations/en.json
 create mode 100644 tests/components/netgear/__init__.py
 create mode 100644 tests/components/netgear/conftest.py
 create mode 100644 tests/components/netgear/test_config_flow.py

diff --git a/.coveragerc b/.coveragerc
index a9fc9c433b8..6ac8779ad75 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -699,7 +699,10 @@ omit =
     homeassistant/components/nello/lock.py
     homeassistant/components/nest/legacy/*
     homeassistant/components/netdata/sensor.py
+    homeassistant/components/netgear/__init__.py
     homeassistant/components/netgear/device_tracker.py
+    homeassistant/components/netgear/router.py
+    homeassistant/components/netgear/sensor.py
     homeassistant/components/netgear_lte/*
     homeassistant/components/netio/switch.py
     homeassistant/components/neurio_energy/sensor.py
diff --git a/CODEOWNERS b/CODEOWNERS
index 54ce1818ce4..8127c30d357 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -335,6 +335,7 @@ homeassistant/components/ness_alarm/* @nickw444
 homeassistant/components/nest/* @allenporter
 homeassistant/components/netatmo/* @cgtobi
 homeassistant/components/netdata/* @fabaff
+homeassistant/components/netgear/* @hacf-fr @Quentame @starkillerOG
 homeassistant/components/nexia/* @bdraco
 homeassistant/components/nextbus/* @vividboarder
 homeassistant/components/nextcloud/* @meichthys
diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py
index 8bf31a94aef..99106ef63a8 100644
--- a/homeassistant/components/discovery/__init__.py
+++ b/homeassistant/components/discovery/__init__.py
@@ -46,7 +46,6 @@ CONFIG_ENTRY_HANDLERS = {
 
 # These have no config flows
 SERVICE_HANDLERS = {
-    SERVICE_NETGEAR: ("device_tracker", None),
     SERVICE_ENIGMA2: ("media_player", "enigma2"),
     SERVICE_SABNZBD: ("sabnzbd", None),
     "yamaha": ("media_player", "yamaha"),
@@ -76,6 +75,7 @@ MIGRATED_SERVICE_HANDLERS = [
     "kodi",
     SERVICE_KONNECTED,
     SERVICE_MOBILE_APP,
+    SERVICE_NETGEAR,
     SERVICE_OCTOPRINT,
     "philips_hue",
     SERVICE_SAMSUNG_PRINTER,
diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py
index 1b55d01b463..395773c5fe3 100644
--- a/homeassistant/components/netgear/__init__.py
+++ b/homeassistant/components/netgear/__init__.py
@@ -1 +1,59 @@
-"""The netgear component."""
+"""Support for Netgear routers."""
+import logging
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import DOMAIN, PLATFORMS
+from .errors import CannotLoginException
+from .router import NetgearRouter
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
+    """Set up Netgear component."""
+    router = NetgearRouter(hass, entry)
+    try:
+        await router.async_setup()
+    except CannotLoginException as ex:
+        raise ConfigEntryNotReady from ex
+
+    hass.data.setdefault(DOMAIN, {})
+    hass.data[DOMAIN][entry.unique_id] = router
+
+    entry.async_on_unload(entry.add_update_listener(update_listener))
+
+    device_registry = await dr.async_get_registry(hass)
+    device_registry.async_get_or_create(
+        config_entry_id=entry.entry_id,
+        identifiers={(DOMAIN, entry.unique_id)},
+        manufacturer="Netgear",
+        name=router.device_name,
+        model=router.model,
+        sw_version=router.firmware_version,
+    )
+
+    hass.config_entries.async_setup_platforms(entry, PLATFORMS)
+
+    return True
+
+
+async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
+    """Unload a config entry."""
+    unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+    if unload_ok:
+        hass.data[DOMAIN].pop(entry.unique_id)
+        if not hass.data[DOMAIN]:
+            hass.data.pop(DOMAIN)
+
+    return unload_ok
+
+
+async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+    """Handle options update."""
+    await hass.config_entries.async_reload(config_entry.entry_id)
diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py
new file mode 100644
index 00000000000..18813ac27cd
--- /dev/null
+++ b/homeassistant/components/netgear/config_flow.py
@@ -0,0 +1,184 @@
+"""Config flow to configure the Netgear integration."""
+from urllib.parse import urlparse
+
+from pynetgear import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USER
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.components import ssdp
+from homeassistant.const import (
+    CONF_HOST,
+    CONF_PASSWORD,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_USERNAME,
+)
+from homeassistant.core import callback
+from homeassistant.data_entry_flow import FlowResult
+
+from .const import CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, DEFAULT_NAME, DOMAIN
+from .errors import CannotLoginException
+from .router import get_api
+
+
+def _discovery_schema_with_defaults(discovery_info):
+    return vol.Schema(_ordered_shared_schema(discovery_info))
+
+
+def _user_schema_with_defaults(user_input):
+    user_schema = {
+        vol.Optional(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
+        vol.Optional(CONF_PORT, default=user_input.get(CONF_PORT, DEFAULT_PORT)): int,
+        vol.Optional(CONF_SSL, default=user_input.get(CONF_SSL, False)): bool,
+    }
+    user_schema.update(_ordered_shared_schema(user_input))
+
+    return vol.Schema(user_schema)
+
+
+def _ordered_shared_schema(schema_input):
+    return {
+        vol.Optional(CONF_USERNAME, default=schema_input.get(CONF_USERNAME, "")): str,
+        vol.Required(CONF_PASSWORD, default=schema_input.get(CONF_PASSWORD, "")): str,
+    }
+
+
+class OptionsFlowHandler(config_entries.OptionsFlow):
+    """Options for the component."""
+
+    def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
+        """Init object."""
+        self.config_entry = config_entry
+
+    async def async_step_init(self, user_input=None):
+        """Manage the options."""
+        if user_input is not None:
+            return self.async_create_entry(title="", data=user_input)
+
+        settings_schema = vol.Schema(
+            {
+                vol.Optional(
+                    CONF_CONSIDER_HOME,
+                    default=self.config_entry.options.get(
+                        CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds()
+                    ),
+                ): int,
+            }
+        )
+
+        return self.async_show_form(step_id="init", data_schema=settings_schema)
+
+
+class NetgearFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
+    """Handle a config flow."""
+
+    VERSION = 1
+
+    def __init__(self):
+        """Initialize the netgear config flow."""
+        self.placeholders = {
+            CONF_HOST: DEFAULT_HOST,
+            CONF_PORT: DEFAULT_PORT,
+            CONF_USERNAME: DEFAULT_USER,
+            CONF_SSL: False,
+        }
+        self.discovered = False
+
+    @staticmethod
+    @callback
+    def async_get_options_flow(
+        config_entry: config_entries.ConfigEntry,
+    ) -> OptionsFlowHandler:
+        """Get the options flow."""
+        return OptionsFlowHandler(config_entry)
+
+    async def _show_setup_form(self, user_input=None, errors=None):
+        """Show the setup form to the user."""
+        if not user_input:
+            user_input = {}
+
+        if self.discovered:
+            data_schema = _discovery_schema_with_defaults(user_input)
+        else:
+            data_schema = _user_schema_with_defaults(user_input)
+
+        return self.async_show_form(
+            step_id="user",
+            data_schema=data_schema,
+            errors=errors or {},
+            description_placeholders=self.placeholders,
+        )
+
+    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_ssdp(self, discovery_info: dict) -> FlowResult:
+        """Initialize flow from ssdp."""
+        updated_data = {}
+
+        device_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION])
+        if device_url.hostname:
+            updated_data[CONF_HOST] = device_url.hostname
+        if device_url.port:
+            updated_data[CONF_PORT] = device_url.port
+        if device_url.scheme == "https":
+            updated_data[CONF_SSL] = True
+        else:
+            updated_data[CONF_SSL] = False
+
+        await self.async_set_unique_id(discovery_info[ssdp.ATTR_UPNP_SERIAL])
+        self._abort_if_unique_id_configured(updates=updated_data)
+        self.placeholders.update(updated_data)
+        self.discovered = True
+
+        return await self.async_step_user()
+
+    async def async_step_user(self, user_input=None):
+        """Handle a flow initiated by the user."""
+        errors = {}
+
+        if user_input is None:
+            return await self._show_setup_form()
+
+        host = user_input.get(CONF_HOST, self.placeholders[CONF_HOST])
+        port = user_input.get(CONF_PORT, self.placeholders[CONF_PORT])
+        ssl = user_input.get(CONF_SSL, self.placeholders[CONF_SSL])
+        username = user_input.get(CONF_USERNAME, self.placeholders[CONF_USERNAME])
+        password = user_input[CONF_PASSWORD]
+        if not username:
+            username = self.placeholders[CONF_USERNAME]
+
+        # Open connection and check authentication
+        try:
+            api = await self.hass.async_add_executor_job(
+                get_api, password, host, username, port, ssl
+            )
+        except CannotLoginException:
+            errors["base"] = "config"
+
+        if errors:
+            return await self._show_setup_form(user_input, errors)
+
+        # Check if already configured
+        info = await self.hass.async_add_executor_job(api.get_info)
+        await self.async_set_unique_id(info["SerialNumber"], raise_on_progress=False)
+        self._abort_if_unique_id_configured()
+
+        config_data = {
+            CONF_USERNAME: username,
+            CONF_PASSWORD: password,
+            CONF_HOST: host,
+            CONF_PORT: port,
+            CONF_SSL: ssl,
+        }
+
+        if info.get("ModelName") is not None and info.get("DeviceName") is not None:
+            name = f"{info['ModelName']} - {info['DeviceName']}"
+        else:
+            name = info.get("ModelName", DEFAULT_NAME)
+
+        return self.async_create_entry(
+            title=name,
+            data=config_data,
+        )
diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py
new file mode 100644
index 00000000000..8b520485e1e
--- /dev/null
+++ b/homeassistant/components/netgear/const.py
@@ -0,0 +1,60 @@
+"""Netgear component constants."""
+from datetime import timedelta
+
+DOMAIN = "netgear"
+
+PLATFORMS = ["device_tracker", "sensor"]
+
+CONF_CONSIDER_HOME = "consider_home"
+
+DEFAULT_CONSIDER_HOME = timedelta(seconds=180)
+DEFAULT_NAME = "Netgear router"
+
+# update method V2 models
+MODELS_V2 = ["Orbi"]
+
+# Icons
+DEVICE_ICONS = {
+    0: "mdi:access-point-network",  # Router (Orbi ...)
+    1: "mdi:book-open-variant",  # Amazon Kindle
+    2: "mdi:android",  # Android Device
+    3: "mdi:cellphone-android",  # Android Phone
+    4: "mdi:tablet-android",  # Android Tablet
+    5: "mdi:router-wireless",  # Apple Airport Express
+    6: "mdi:disc-player",  # Blu-ray Player
+    7: "mdi:router-network",  # Bridge
+    8: "mdi:play-network",  # Cable STB
+    9: "mdi:camera",  # Camera
+    10: "mdi:router-network",  # Router
+    11: "mdi:play-network",  # DVR
+    12: "mdi:gamepad-variant",  # Gaming Console
+    13: "mdi:desktop-mac",  # iMac
+    14: "mdi:tablet-ipad",  # iPad
+    15: "mdi:tablet-ipad",  # iPad Mini
+    16: "mdi:cellphone-iphone",  # iPhone 5/5S/5C
+    17: "mdi:cellphone-iphone",  # iPhone
+    18: "mdi:ipod",  # iPod Touch
+    19: "mdi:linux",  # Linux PC
+    20: "mdi:apple-finder",  # Mac Mini
+    21: "mdi:desktop-tower",  # Mac Pro
+    22: "mdi:laptop-mac",  # MacBook
+    23: "mdi:play-network",  # Media Device
+    24: "mdi:network",  # Network Device
+    25: "mdi:play-network",  # Other STB
+    26: "mdi:power-plug",  # Powerline
+    27: "mdi:printer",  # Printer
+    28: "mdi:access-point",  # Repeater
+    29: "mdi:play-network",  # Satellite STB
+    30: "mdi:scanner",  # Scanner
+    31: "mdi:play-network",  # SlingBox
+    32: "mdi:cellphone",  # Smart Phone
+    33: "mdi:nas",  # Storage (NAS)
+    34: "mdi:switch",  # Switch
+    35: "mdi:television",  # TV
+    36: "mdi:tablet",  # Tablet
+    37: "mdi:desktop-classic",  # UNIX PC
+    38: "mdi:desktop-tower-monitor",  # Windows PC
+    39: "mdi:laptop-windows",  # Surface
+    40: "mdi:access-point-network",  # Wifi Extender
+    41: "mdi:apple-airplay",  # Apple TV
+}
diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py
index 504faef70eb..f568a506552 100644
--- a/homeassistant/components/netgear/device_tracker.py
+++ b/homeassistant/components/netgear/device_tracker.py
@@ -1,15 +1,14 @@
 """Support for Netgear routers."""
 import logging
-from pprint import pformat
 
-from pynetgear import Netgear
 import voluptuous as vol
 
 from homeassistant.components.device_tracker import (
-    DOMAIN,
     PLATFORM_SCHEMA as PARENT_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_DEVICES,
     CONF_EXCLUDE,
@@ -19,7 +18,13 @@ from homeassistant.const import (
     CONF_SSL,
     CONF_USERNAME,
 )
+from homeassistant.core import callback
 import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import DEVICE_ICONS, DOMAIN
+from .router import NetgearDeviceEntity, NetgearRouter, async_setup_netgear_entry
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -27,9 +32,9 @@ CONF_APS = "accesspoints"
 
 PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend(
     {
-        vol.Optional(CONF_HOST, default=""): cv.string,
-        vol.Optional(CONF_SSL, default=False): cv.boolean,
-        vol.Optional(CONF_USERNAME, default=""): cv.string,
+        vol.Optional(CONF_HOST): cv.string,
+        vol.Optional(CONF_SSL): cv.boolean,
+        vol.Optional(CONF_USERNAME): cv.string,
         vol.Required(CONF_PASSWORD): cv.string,
         vol.Optional(CONF_PORT): cv.port,
         vol.Optional(CONF_DEVICES, default=[]): vol.All(cv.ensure_list, [cv.string]),
@@ -39,132 +44,88 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend(
 )
 
 
-def get_scanner(hass, config):
-    """Validate the configuration and returns a Netgear scanner."""
-    info = config[DOMAIN]
-    host = info[CONF_HOST]
-    ssl = info[CONF_SSL]
-    username = info[CONF_USERNAME]
-    password = info[CONF_PASSWORD]
-    port = info.get(CONF_PORT)
-    devices = info[CONF_DEVICES]
-    excluded_devices = info[CONF_EXCLUDE]
-    accesspoints = info[CONF_APS]
-
-    api = Netgear(password, host, username, port, ssl)
-    scanner = NetgearDeviceScanner(api, devices, excluded_devices, accesspoints)
-
-    _LOGGER.debug("Logging in")
-
-    results = scanner.get_attached_devices()
-
-    if results is not None:
-        scanner.last_results = results
-    else:
-        _LOGGER.error("Failed to Login")
-        return None
-
-    return scanner
-
-
-class NetgearDeviceScanner(DeviceScanner):
-    """Queries a Netgear wireless router using the SOAP-API."""
-
-    def __init__(
-        self,
-        api,
-        devices,
-        excluded_devices,
-        accesspoints,
-    ):
-        """Initialize the scanner."""
-        self.tracked_devices = devices
-        self.excluded_devices = excluded_devices
-        self.tracked_accesspoints = accesspoints
-        self.last_results = []
-        self._api = api
-
-    def scan_devices(self):
-        """Scan for new devices and return a list with found device IDs."""
-        self._update_info()
-
-        devices = []
-
-        for dev in self.last_results:
-            tracked = (
-                not self.tracked_devices
-                or dev.mac in self.tracked_devices
-                or dev.name in self.tracked_devices
-            )
-            tracked = tracked and (
-                not self.excluded_devices
-                or not (
-                    dev.mac in self.excluded_devices
-                    or dev.name in self.excluded_devices
-                )
-            )
-            if tracked:
-                devices.append(dev.mac)
-                if (
-                    self.tracked_accesspoints
-                    and dev.conn_ap_mac in self.tracked_accesspoints
-                ):
-                    devices.append(f"{dev.mac}_{dev.conn_ap_mac}")
-
-        return devices
-
-    def get_device_name(self, device):
-        """Return the name of the given device or the MAC if we don't know."""
-        parts = device.split("_")
-        mac = parts[0]
-        ap_mac = None
-        if len(parts) > 1:
-            ap_mac = parts[1]
-
-        name = None
-        for dev in self.last_results:
-            if dev.mac == mac:
-                name = dev.name
-                break
-
-        if not name or name == "--":
-            name = mac
-
-        if ap_mac:
-            ap_name = "Router"
-            for dev in self.last_results:
-                if dev.mac == ap_mac:
-                    ap_name = dev.name
-                    break
-
-            return f"{name} on {ap_name}"
-
-        return name
-
-    def _update_info(self):
-        """Retrieve latest information from the Netgear router.
-
-        Returns boolean if scanning successful.
-        """
-        _LOGGER.debug("Scanning")
-
-        results = self.get_attached_devices()
-
-        if _LOGGER.isEnabledFor(logging.DEBUG):
-            _LOGGER.debug("Scan result: \n%s", pformat(results))
-
-        if results is None:
-            _LOGGER.warning("Error scanning devices")
-
-        self.last_results = results or []
-
-    def get_attached_devices(self):
-        """List attached devices with pynetgear.
-
-        The v2 method takes more time and is more heavy on the router
-        so we only use it if we need connected AP info.
-        """
-        if self.tracked_accesspoints:
-            return self._api.get_attached_devices_2()
-
-        return self._api.get_attached_devices()
+async def async_get_scanner(hass, config):
+    """Import Netgear configuration from YAML."""
+    hass.async_create_task(
+        hass.config_entries.flow.async_init(
+            DOMAIN,
+            context={"source": SOURCE_IMPORT},
+            data=config,
+        )
+    )
+
+    _LOGGER.warning(
+        "Your Netgear configuration has been imported into the UI, "
+        "please remove it from configuration.yaml. "
+        "Loading Netgear via platform setup is now deprecated"
+    )
+
+    return None
+
+
+async def async_setup_entry(
+    hass: HomeAssistantType, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+    """Set up device tracker for Netgear component."""
+
+    def generate_classes(router: NetgearRouter, device: dict):
+        return [NetgearScannerEntity(router, device)]
+
+    async_setup_netgear_entry(hass, entry, async_add_entities, generate_classes)
+
+
+class NetgearScannerEntity(NetgearDeviceEntity, ScannerEntity):
+    """Representation of a device connected to a Netgear router."""
+
+    def __init__(self, router: NetgearRouter, device: dict) -> None:
+        """Initialize a Netgear device."""
+        super().__init__(router, device)
+        self._hostname = self.get_hostname()
+        self._icon = DEVICE_ICONS.get(device["device_type"], "mdi:help-network")
+
+    def get_hostname(self):
+        """Return the hostname of the given device or None if we don't know."""
+        hostname = self._device["name"]
+        if hostname == "--":
+            return None
+
+        return hostname
+
+    @callback
+    def async_update_device(self) -> None:
+        """Update the Netgear device."""
+        self._device = self._router.devices[self._mac]
+        self._active = self._device["active"]
+        self._icon = DEVICE_ICONS.get(self._device["device_type"], "mdi:help-network")
+
+        self.async_write_ha_state()
+
+    @property
+    def is_connected(self):
+        """Return true if the device is connected to the router."""
+        return self._active
+
+    @property
+    def source_type(self) -> str:
+        """Return the source type."""
+        return SOURCE_TYPE_ROUTER
+
+    @property
+    def ip_address(self) -> str:
+        """Return the IP address."""
+        return self._device["ip"]
+
+    @property
+    def mac_address(self) -> str:
+        """Return the mac address."""
+        return self._mac
+
+    @property
+    def hostname(self) -> str:
+        """Return the hostname."""
+        return self._hostname
+
+    @property
+    def icon(self) -> str:
+        """Return the icon."""
+        return self._icon
diff --git a/homeassistant/components/netgear/errors.py b/homeassistant/components/netgear/errors.py
new file mode 100644
index 00000000000..2ac1ed18224
--- /dev/null
+++ b/homeassistant/components/netgear/errors.py
@@ -0,0 +1,10 @@
+"""Errors for the Netgear component."""
+from homeassistant.exceptions import HomeAssistantError
+
+
+class NetgearException(HomeAssistantError):
+    """Base class for Netgear exceptions."""
+
+
+class CannotLoginException(NetgearException):
+    """Unable to login to the router."""
diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json
index 713101f657f..aa4c57ecdde 100644
--- a/homeassistant/components/netgear/manifest.json
+++ b/homeassistant/components/netgear/manifest.json
@@ -2,7 +2,14 @@
   "domain": "netgear",
   "name": "NETGEAR",
   "documentation": "https://www.home-assistant.io/integrations/netgear",
-  "requirements": ["pynetgear==0.6.1"],
-  "codeowners": [],
-  "iot_class": "local_polling"
+  "requirements": ["pynetgear==0.7.0"],
+  "codeowners": ["@hacf-fr", "@Quentame", "@starkillerOG"],
+  "iot_class": "local_polling",
+  "config_flow": true,
+  "ssdp": [
+    {
+      "manufacturer": "NETGEAR, Inc.",
+      "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
+    }
+  ]
 }
diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py
new file mode 100644
index 00000000000..a500bffb966
--- /dev/null
+++ b/homeassistant/components/netgear/router.py
@@ -0,0 +1,292 @@
+"""Represent the Netgear router and its devices."""
+from abc import abstractmethod
+from datetime import timedelta
+import logging
+from typing import Callable
+
+from pynetgear import Netgear
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+    CONF_HOST,
+    CONF_PASSWORD,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_USERNAME,
+)
+from homeassistant.core import callback
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
+from homeassistant.helpers.dispatcher import (
+    async_dispatcher_connect,
+    async_dispatcher_send,
+)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.util import dt as dt_util
+
+from .const import (
+    CONF_CONSIDER_HOME,
+    DEFAULT_CONSIDER_HOME,
+    DEFAULT_NAME,
+    DOMAIN,
+    MODELS_V2,
+)
+from .errors import CannotLoginException
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def get_api(
+    password: str,
+    host: str = None,
+    username: str = None,
+    port: int = None,
+    ssl: bool = False,
+) -> Netgear:
+    """Get the Netgear API and login to it."""
+    api: Netgear = Netgear(password, host, username, port, ssl)
+
+    if not api.login():
+        raise CannotLoginException
+
+    return api
+
+
+@callback
+def async_setup_netgear_entry(
+    hass: HomeAssistantType,
+    entry: ConfigEntry,
+    async_add_entities: AddEntitiesCallback,
+    entity_class_generator: Callable[["NetgearRouter", dict], list],
+) -> None:
+    """Set up device tracker for Netgear component."""
+    router = hass.data[DOMAIN][entry.unique_id]
+    tracked = set()
+
+    @callback
+    def _async_router_updated():
+        """Update the values of the router."""
+        async_add_new_entities(
+            router, async_add_entities, tracked, entity_class_generator
+        )
+
+    entry.async_on_unload(
+        async_dispatcher_connect(hass, router.signal_device_new, _async_router_updated)
+    )
+
+    _async_router_updated()
+
+
+@callback
+def async_add_new_entities(router, async_add_entities, tracked, entity_class_generator):
+    """Add new tracker entities from the router."""
+    new_tracked = []
+
+    for mac, device in router.devices.items():
+        if mac in tracked:
+            continue
+
+        new_tracked.extend(entity_class_generator(router, device))
+        tracked.add(mac)
+
+    if new_tracked:
+        async_add_entities(new_tracked, True)
+
+
+class NetgearRouter:
+    """Representation of a Netgear router."""
+
+    def __init__(self, hass: HomeAssistantType, entry: ConfigEntry) -> None:
+        """Initialize a Netgear router."""
+        self.hass = hass
+        self.entry = entry
+        self.entry_id = entry.entry_id
+        self.unique_id = entry.unique_id
+        self._host = entry.data.get(CONF_HOST)
+        self._port = entry.data.get(CONF_PORT)
+        self._ssl = entry.data.get(CONF_SSL)
+        self._username = entry.data.get(CONF_USERNAME)
+        self._password = entry.data[CONF_PASSWORD]
+
+        self._info = None
+        self.model = None
+        self.device_name = None
+        self.firmware_version = None
+
+        self._method_version = 1
+        consider_home_int = entry.options.get(
+            CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds()
+        )
+        self._consider_home = timedelta(seconds=consider_home_int)
+
+        self._api: Netgear = None
+        self._attrs = {}
+
+        self.devices = {}
+
+    def _setup(self) -> None:
+        """Set up a Netgear router sync portion."""
+        self._api = get_api(
+            self._password,
+            self._host,
+            self._username,
+            self._port,
+            self._ssl,
+        )
+
+        self._info = self._api.get_info()
+        self.device_name = self._info.get("DeviceName", DEFAULT_NAME)
+        self.model = self._info.get("ModelName")
+        self.firmware_version = self._info.get("Firmwareversion")
+
+        if self.model in MODELS_V2:
+            self._method_version = 2
+
+    async def async_setup(self) -> None:
+        """Set up a Netgear router."""
+        await self.hass.async_add_executor_job(self._setup)
+
+        # set already known devices to away instead of unavailable
+        device_registry = dr.async_get(self.hass)
+        devices = dr.async_entries_for_config_entry(device_registry, self.entry_id)
+        for device_entry in devices:
+            if device_entry.via_device_id is None:
+                continue  # do not add the router itself
+
+            device_mac = dict(device_entry.connections).get(dr.CONNECTION_NETWORK_MAC)
+            self.devices[device_mac] = {
+                "mac": device_mac,
+                "name": device_entry.name,
+                "active": False,
+                "last_seen": dt_util.utcnow() - timedelta(days=365),
+                "device_model": None,
+                "device_type": None,
+                "type": None,
+                "link_rate": None,
+                "signal": None,
+                "ip": None,
+            }
+
+        await self.async_update_device_trackers()
+        self.entry.async_on_unload(
+            async_track_time_interval(
+                self.hass, self.async_update_device_trackers, SCAN_INTERVAL
+            )
+        )
+
+        async_dispatcher_send(self.hass, self.signal_device_new)
+
+    async def async_get_attached_devices(self) -> list:
+        """Get the devices connected to the router."""
+        if self._method_version == 1:
+            return await self.hass.async_add_executor_job(
+                self._api.get_attached_devices
+            )
+
+        return await self.hass.async_add_executor_job(self._api.get_attached_devices_2)
+
+    async def async_update_device_trackers(self, now=None) -> None:
+        """Update Netgear devices."""
+        new_device = False
+        ntg_devices = await self.async_get_attached_devices()
+        now = dt_util.utcnow()
+
+        for ntg_device in ntg_devices:
+            device_mac = format_mac(ntg_device.mac)
+
+            if self._method_version == 2 and not ntg_device.link_rate:
+                continue
+
+            if not self.devices.get(device_mac):
+                new_device = True
+
+            # ntg_device is a namedtuple from the collections module that needs conversion to a dict through ._asdict method
+            self.devices[device_mac] = ntg_device._asdict()
+            self.devices[device_mac]["mac"] = device_mac
+            self.devices[device_mac]["last_seen"] = now
+
+        for device in self.devices.values():
+            device["active"] = now - device["last_seen"] <= self._consider_home
+
+        async_dispatcher_send(self.hass, self.signal_device_update)
+
+        if new_device:
+            _LOGGER.debug("Netgear tracker: new device found")
+            async_dispatcher_send(self.hass, self.signal_device_new)
+
+    @property
+    def signal_device_new(self) -> str:
+        """Event specific per Netgear entry to signal new device."""
+        return f"{DOMAIN}-{self._host}-device-new"
+
+    @property
+    def signal_device_update(self) -> str:
+        """Event specific per Netgear entry to signal updates in devices."""
+        return f"{DOMAIN}-{self._host}-device-update"
+
+
+class NetgearDeviceEntity(Entity):
+    """Base class for a device connected to a Netgear router."""
+
+    def __init__(self, router: NetgearRouter, device: dict) -> None:
+        """Initialize a Netgear device."""
+        self._router = router
+        self._device = device
+        self._mac = device["mac"]
+        self._name = self.get_device_name()
+        self._device_name = self._name
+        self._unique_id = self._mac
+        self._active = device["active"]
+
+    def get_device_name(self):
+        """Return the name of the given device or the MAC if we don't know."""
+        name = self._device["name"]
+        if not name or name == "--":
+            name = self._mac
+
+        return name
+
+    @abstractmethod
+    @callback
+    def async_update_device(self) -> None:
+        """Update the Netgear device."""
+
+    @property
+    def unique_id(self) -> str:
+        """Return a unique ID."""
+        return self._unique_id
+
+    @property
+    def name(self) -> str:
+        """Return the name."""
+        return self._name
+
+    @property
+    def device_info(self):
+        """Return the device information."""
+        return {
+            "connections": {(CONNECTION_NETWORK_MAC, self._mac)},
+            "name": self._device_name,
+            "model": self._device["device_model"],
+            "via_device": (DOMAIN, self._router.unique_id),
+        }
+
+    @property
+    def should_poll(self) -> bool:
+        """No polling needed."""
+        return False
+
+    async def async_added_to_hass(self):
+        """Register state update callback."""
+        self.async_on_remove(
+            async_dispatcher_connect(
+                self.hass,
+                self._router.signal_device_update,
+                self.async_update_device,
+            )
+        )
diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py
new file mode 100644
index 00000000000..62867383d6e
--- /dev/null
+++ b/homeassistant/components/netgear/sensor.py
@@ -0,0 +1,83 @@
+"""Support for Netgear routers."""
+import logging
+
+from homeassistant.components.sensor import (
+    DEVICE_CLASS_SIGNAL_STRENGTH,
+    SensorEntity,
+    SensorEntityDescription,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import PERCENTAGE
+from homeassistant.core import callback
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .router import NetgearDeviceEntity, NetgearRouter, async_setup_netgear_entry
+
+_LOGGER = logging.getLogger(__name__)
+
+
+SENSOR_TYPES = {
+    "type": SensorEntityDescription(
+        key="type",
+        name="link type",
+        native_unit_of_measurement=None,
+        device_class=None,
+    ),
+    "link_rate": SensorEntityDescription(
+        key="link_rate",
+        name="link rate",
+        native_unit_of_measurement="Mbps",
+        device_class=None,
+    ),
+    "signal": SensorEntityDescription(
+        key="signal",
+        name="signal strength",
+        native_unit_of_measurement=PERCENTAGE,
+        device_class=DEVICE_CLASS_SIGNAL_STRENGTH,
+    ),
+}
+
+
+async def async_setup_entry(
+    hass: HomeAssistantType, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+    """Set up device tracker for Netgear component."""
+
+    def generate_sensor_classes(router: NetgearRouter, device: dict):
+        return [
+            NetgearSensorEntity(router, device, attribute)
+            for attribute in ("type", "link_rate", "signal")
+        ]
+
+    async_setup_netgear_entry(hass, entry, async_add_entities, generate_sensor_classes)
+
+
+class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity):
+    """Representation of a device connected to a Netgear router."""
+
+    _attr_entity_registry_enabled_default = False
+
+    def __init__(self, router: NetgearRouter, device: dict, attribute: str) -> None:
+        """Initialize a Netgear device."""
+        super().__init__(router, device)
+        self._attribute = attribute
+        self.entity_description = SENSOR_TYPES[self._attribute]
+        self._name = f"{self.get_device_name()} {self.entity_description.name}"
+        self._unique_id = f"{self._mac}-{self._attribute}"
+        self._state = self._device[self._attribute]
+
+    @property
+    def native_value(self):
+        """Return the state of the sensor."""
+        return self._state
+
+    @callback
+    def async_update_device(self) -> None:
+        """Update the Netgear device."""
+        self._device = self._router.devices[self._mac]
+        self._active = self._device["active"]
+        if self._device[self._attribute] is not None:
+            self._state = self._device[self._attribute]
+
+        self.async_write_ha_state()
diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json
new file mode 100644
index 00000000000..9fdd548d992
--- /dev/null
+++ b/homeassistant/components/netgear/strings.json
@@ -0,0 +1,34 @@
+{
+  "config": {
+    "step": {
+      "user": {
+        "title": "Netgear",
+        "description": "Default host: {host}\n Default port: {port}\n Default username: {username}",
+        "data": {
+          "host": "[%key:common::config_flow::data::host%] (Optional)",
+          "port": "[%key:common::config_flow::data::port%] (Optional)",
+          "ssl": "[%key:common::config_flow::data::ssl%]",
+          "username": "[%key:common::config_flow::data::username%] (Optional)",
+          "password": "[%key:common::config_flow::data::password%]"
+        }
+      }
+    },
+    "error": {
+      "config": "Connection or login error: please check your configuration"
+    },
+    "abort": {
+      "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+    }
+  },
+  "options": {
+    "step": {
+      "init": {
+        "title": "Netgear",
+        "description": "Specify optional settings",
+        "data": {
+          "consider_home": "Consider home time (seconds)"
+        }
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/homeassistant/components/netgear/translations/en.json b/homeassistant/components/netgear/translations/en.json
new file mode 100644
index 00000000000..b3c14648fb1
--- /dev/null
+++ b/homeassistant/components/netgear/translations/en.json
@@ -0,0 +1,34 @@
+{
+    "config": {
+        "abort": {
+            "already_configured": "Host already configured"
+        },
+        "error": {
+            "config": "Connection or login error: please check your configuration"
+        },
+        "step": {
+            "user": {
+                "data": {
+                    "host": "Host (Optional)",
+                    "password": "Password",
+                    "port": "Port (Optional)",
+                    "ssl": "Use SSL (Optional)",
+                    "username": "Username (Optional)"
+                },
+                "description": "Default host: {host}\n Default port: {port}\n Default username: {username}",
+                "title": "Netgear"
+            }
+        }
+    },
+    "options": {
+        "step": {
+          "init": {
+            "title": "Netgear",
+            "description": "Specify optional settings",
+            "data": {
+              "consider_home": "Consider home time (seconds)"
+            }
+          }
+        }
+    }
+}
\ No newline at end of file
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 57f152bb5a2..f6fac775b2d 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -181,6 +181,7 @@ FLOWS = [
     "neato",
     "nest",
     "netatmo",
+    "netgear",
     "nexia",
     "nfandroidtv",
     "nightscout",
diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py
index 1638d932e89..e5e823b404a 100644
--- a/homeassistant/generated/ssdp.py
+++ b/homeassistant/generated/ssdp.py
@@ -151,6 +151,12 @@ SSDP = {
             "manufacturer": "konnected.io"
         }
     ],
+    "netgear": [
+        {
+            "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1",
+            "manufacturer": "NETGEAR, Inc."
+        }
+    ],
     "roku": [
         {
             "deviceType": "urn:roku-com:device:player:1-0",
diff --git a/mypy.ini b/mypy.ini
index f048a3d473f..7c195414135 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -1500,6 +1500,9 @@ ignore_errors = true
 [mypy-homeassistant.components.nest.legacy.*]
 ignore_errors = true
 
+[mypy-homeassistant.components.netgear.*]
+ignore_errors = true
+
 [mypy-homeassistant.components.nightscout.*]
 ignore_errors = true
 
diff --git a/requirements_all.txt b/requirements_all.txt
index f50e5e0384d..84acb3f0b8f 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -1641,7 +1641,7 @@ pynanoleaf==0.1.0
 pynello==2.0.3
 
 # homeassistant.components.netgear
-pynetgear==0.6.1
+pynetgear==0.7.0
 
 # homeassistant.components.netio
 pynetio==0.1.9.1
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 34c36b36251..47ff575035b 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -950,6 +950,9 @@ pymysensors==0.21.0
 # homeassistant.components.nanoleaf
 pynanoleaf==0.1.0
 
+# homeassistant.components.netgear
+pynetgear==0.7.0
+
 # homeassistant.components.nuki
 pynuki==1.4.1
 
diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py
index a66e880544c..f799b3fdb20 100644
--- a/script/hassfest/mypy_config.py
+++ b/script/hassfest/mypy_config.py
@@ -85,6 +85,7 @@ IGNORED_MODULES: Final[list[str]] = [
     "homeassistant.components.mullvad.*",
     "homeassistant.components.ness_alarm.*",
     "homeassistant.components.nest.legacy.*",
+    "homeassistant.components.netgear.*",
     "homeassistant.components.nightscout.*",
     "homeassistant.components.nilu.*",
     "homeassistant.components.nsw_fuel_station.*",
diff --git a/tests/components/discovery/test_init.py b/tests/components/discovery/test_init.py
index 8be837bb16e..2b004135286 100644
--- a/tests/components/discovery/test_init.py
+++ b/tests/components/discovery/test_init.py
@@ -16,8 +16,10 @@ from tests.common import async_fire_time_changed, mock_coro
 SERVICE = "yamaha"
 SERVICE_COMPONENT = "media_player"
 
-SERVICE_NO_PLATFORM = "netgear_router"
-SERVICE_NO_PLATFORM_COMPONENT = "device_tracker"
+# sabnzbd is the last no platform integration to be migrated
+# drop these tests once it is migrated
+SERVICE_NO_PLATFORM = "sabnzbd"
+SERVICE_NO_PLATFORM_COMPONENT = "sabnzbd"
 SERVICE_INFO = {"key": "value"}  # Can be anything
 
 UNKNOWN_SERVICE = "this_service_will_never_be_supported"
diff --git a/tests/components/netgear/__init__.py b/tests/components/netgear/__init__.py
new file mode 100644
index 00000000000..7ef2f96cced
--- /dev/null
+++ b/tests/components/netgear/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Netgear component."""
diff --git a/tests/components/netgear/conftest.py b/tests/components/netgear/conftest.py
new file mode 100644
index 00000000000..f60b9be62a5
--- /dev/null
+++ b/tests/components/netgear/conftest.py
@@ -0,0 +1,14 @@
+"""Configure Netgear tests."""
+from unittest.mock import patch
+
+import pytest
+
+
+@pytest.fixture(name="bypass_setup", autouse=True)
+def bypass_setup_fixture():
+    """Mock component setup."""
+    with patch(
+        "homeassistant.components.netgear.device_tracker.async_get_scanner",
+        return_value=None,
+    ):
+        yield
diff --git a/tests/components/netgear/test_config_flow.py b/tests/components/netgear/test_config_flow.py
new file mode 100644
index 00000000000..de4f4fba510
--- /dev/null
+++ b/tests/components/netgear/test_config_flow.py
@@ -0,0 +1,284 @@
+"""Tests for the Netgear config flow."""
+import logging
+from unittest.mock import Mock, patch
+
+from pynetgear import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USER
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components import ssdp
+from homeassistant.components.netgear.const import CONF_CONSIDER_HOME, DOMAIN
+from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP, SOURCE_USER
+from homeassistant.const import (
+    CONF_HOST,
+    CONF_PASSWORD,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_USERNAME,
+)
+
+from tests.common import MockConfigEntry
+
+_LOGGER = logging.getLogger(__name__)
+
+URL = "http://routerlogin.net"
+SERIAL = "5ER1AL0000001"
+
+ROUTER_INFOS = {
+    "Description": "Netgear Smart Wizard 3.0, specification 1.6 version",
+    "SignalStrength": "-4",
+    "SmartAgentversion": "3.0",
+    "FirewallVersion": "net-wall 2.0",
+    "VPNVersion": None,
+    "OthersoftwareVersion": "N/A",
+    "Hardwareversion": "N/A",
+    "Otherhardwareversion": "N/A",
+    "FirstUseDate": "Sunday, 30 Sep 2007 01:10:03",
+    "DeviceMode": "0",
+    "ModelName": "RBR20",
+    "SerialNumber": SERIAL,
+    "Firmwareversion": "V2.3.5.26",
+    "DeviceName": "Desk",
+    "DeviceNameUserSet": "true",
+    "FirmwareDLmethod": "HTTPS",
+    "FirmwareLastUpdate": "2019_10.5_18:42:58",
+    "FirmwareLastChecked": "2020_5.3_1:33:0",
+    "DeviceModeCapability": "0;1",
+}
+TITLE = f"{ROUTER_INFOS['ModelName']} - {ROUTER_INFOS['DeviceName']}"
+
+HOST = "10.0.0.1"
+SERIAL_2 = "5ER1AL0000002"
+PORT = 80
+SSL = False
+USERNAME = "Home_Assistant"
+PASSWORD = "password"
+SSDP_URL = f"http://{HOST}:{PORT}/rootDesc.xml"
+SSDP_URL_SLL = f"https://{HOST}:{PORT}/rootDesc.xml"
+
+
+@pytest.fixture(name="service")
+def mock_controller_service():
+    """Mock a successful service."""
+    with patch(
+        "homeassistant.components.netgear.async_setup_entry", return_value=True
+    ), patch("homeassistant.components.netgear.router.Netgear") as service_mock:
+        service_mock.return_value.get_info = Mock(return_value=ROUTER_INFOS)
+        yield service_mock
+
+
+@pytest.fixture(name="service_failed")
+def mock_controller_service_failed():
+    """Mock a failed service."""
+    with patch("homeassistant.components.netgear.router.Netgear") as service_mock:
+        service_mock.return_value.login = Mock(return_value=None)
+        service_mock.return_value.get_info = Mock(return_value=None)
+        yield service_mock
+
+
+async def test_user(hass, service):
+    """Test user step."""
+    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"
+
+    # Have to provide all config
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        {
+            CONF_HOST: HOST,
+            CONF_PORT: PORT,
+            CONF_SSL: SSL,
+            CONF_USERNAME: USERNAME,
+            CONF_PASSWORD: PASSWORD,
+        },
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+    assert result["result"].unique_id == SERIAL
+    assert result["title"] == TITLE
+    assert result["data"].get(CONF_HOST) == HOST
+    assert result["data"].get(CONF_PORT) == PORT
+    assert result["data"].get(CONF_SSL) == SSL
+    assert result["data"].get(CONF_USERNAME) == USERNAME
+    assert result["data"][CONF_PASSWORD] == PASSWORD
+
+
+async def test_import_required(hass, service):
+    """Test import step, with required config only."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_PASSWORD: PASSWORD}
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+    assert result["result"].unique_id == SERIAL
+    assert result["title"] == TITLE
+    assert result["data"].get(CONF_HOST) == DEFAULT_HOST
+    assert result["data"].get(CONF_PORT) == DEFAULT_PORT
+    assert result["data"].get(CONF_SSL) is False
+    assert result["data"].get(CONF_USERNAME) == DEFAULT_USER
+    assert result["data"][CONF_PASSWORD] == PASSWORD
+
+
+async def test_import_required_login_failed(hass, service_failed):
+    """Test import step, with required config only, while wrong password or connection issue."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_PASSWORD: PASSWORD}
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+    assert result["step_id"] == "user"
+    assert result["errors"] == {"base": "config"}
+
+
+async def test_import_all(hass, service):
+    """Test import step, with all config provided."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_IMPORT},
+        data={
+            CONF_HOST: HOST,
+            CONF_PORT: PORT,
+            CONF_SSL: SSL,
+            CONF_USERNAME: USERNAME,
+            CONF_PASSWORD: PASSWORD,
+        },
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+    assert result["result"].unique_id == SERIAL
+    assert result["title"] == TITLE
+    assert result["data"].get(CONF_HOST) == HOST
+    assert result["data"].get(CONF_PORT) == PORT
+    assert result["data"].get(CONF_SSL) == SSL
+    assert result["data"].get(CONF_USERNAME) == USERNAME
+    assert result["data"][CONF_PASSWORD] == PASSWORD
+
+
+async def test_import_all_connection_failed(hass, service_failed):
+    """Test import step, with all config provided, while wrong host."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_IMPORT},
+        data={
+            CONF_HOST: HOST,
+            CONF_PORT: PORT,
+            CONF_SSL: SSL,
+            CONF_USERNAME: USERNAME,
+            CONF_PASSWORD: PASSWORD,
+        },
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+    assert result["step_id"] == "user"
+    assert result["errors"] == {"base": "config"}
+
+
+async def test_abort_if_already_setup(hass, service):
+    """Test we abort if the router is already setup."""
+    MockConfigEntry(
+        domain=DOMAIN,
+        data={CONF_PASSWORD: PASSWORD},
+        unique_id=SERIAL,
+    ).add_to_hass(hass)
+
+    # Should fail, same SERIAL (import)
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_IMPORT},
+        data={CONF_PASSWORD: PASSWORD},
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+    assert result["reason"] == "already_configured"
+
+    # Should fail, same SERIAL (flow)
+    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"
+
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        {CONF_PASSWORD: PASSWORD},
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+    assert result["reason"] == "already_configured"
+
+
+async def test_ssdp_already_configured(hass):
+    """Test ssdp abort when the router is already configured."""
+    MockConfigEntry(
+        domain=DOMAIN,
+        data={CONF_PASSWORD: PASSWORD},
+        unique_id=SERIAL,
+    ).add_to_hass(hass)
+
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_SSDP},
+        data={
+            ssdp.ATTR_SSDP_LOCATION: SSDP_URL_SLL,
+            ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20",
+            ssdp.ATTR_UPNP_PRESENTATION_URL: URL,
+            ssdp.ATTR_UPNP_SERIAL: SERIAL,
+        },
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+    assert result["reason"] == "already_configured"
+
+
+async def test_ssdp(hass, service):
+    """Test ssdp step."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_SSDP},
+        data={
+            ssdp.ATTR_SSDP_LOCATION: SSDP_URL,
+            ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20",
+            ssdp.ATTR_UPNP_PRESENTATION_URL: URL,
+            ssdp.ATTR_UPNP_SERIAL: SERIAL,
+        },
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+    assert result["step_id"] == "user"
+
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"], {CONF_PASSWORD: PASSWORD}
+    )
+    assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+    assert result["result"].unique_id == SERIAL
+    assert result["title"] == TITLE
+    assert result["data"].get(CONF_HOST) == HOST
+    assert result["data"].get(CONF_PORT) == PORT
+    assert result["data"].get(CONF_SSL) == SSL
+    assert result["data"].get(CONF_USERNAME) == DEFAULT_USER
+    assert result["data"][CONF_PASSWORD] == PASSWORD
+
+
+async def test_options_flow(hass, service):
+    """Test specifying non default settings using options flow."""
+    config_entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={CONF_PASSWORD: PASSWORD},
+        unique_id=SERIAL,
+        title=TITLE,
+    )
+    config_entry.add_to_hass(hass)
+
+    assert await hass.config_entries.async_setup(config_entry.entry_id)
+    await hass.async_block_till_done()
+
+    result = await hass.config_entries.options.async_init(config_entry.entry_id)
+
+    assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+    assert result["step_id"] == "init"
+
+    result = await hass.config_entries.options.async_configure(
+        result["flow_id"],
+        user_input={
+            CONF_CONSIDER_HOME: 1800,
+        },
+    )
+
+    assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+    assert config_entry.options == {
+        CONF_CONSIDER_HOME: 1800,
+    }
-- 
GitLab