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