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>
This commit is contained in:
starkillerOG 2021-09-13 18:18:21 +02:00 committed by GitHub
parent 37f263e2ac
commit 5f86388f1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1167 additions and 125 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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]
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,
)
)
api = Netgear(password, host, username, port, ssl)
scanner = NetgearDeviceScanner(api, devices, excluded_devices, accesspoints)
_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"
)
_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
return None
class NetgearDeviceScanner(DeviceScanner):
"""Queries a Netgear wireless router using the SOAP-API."""
async def async_setup_entry(
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up device tracker for Netgear component."""
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 generate_classes(router: NetgearRouter, device: dict):
return [NetgearScannerEntity(router, device)]
def scan_devices(self):
"""Scan for new devices and return a list with found device IDs."""
self._update_info()
async_setup_netgear_entry(hass, entry, async_add_entities, generate_classes)
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}")
class NetgearScannerEntity(NetgearDeviceEntity, ScannerEntity):
"""Representation of a device connected to a Netgear router."""
return devices
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_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]
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
name = None
for dev in self.last_results:
if dev.mac == mac:
name = dev.name
break
return hostname
if not name or name == "--":
name = mac
@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")
if ap_mac:
ap_name = "Router"
for dev in self.last_results:
if dev.mac == ap_mac:
ap_name = dev.name
break
self.async_write_ha_state()
return f"{name} on {ap_name}"
@property
def is_connected(self):
"""Return true if the device is connected to the router."""
return self._active
return name
@property
def source_type(self) -> str:
"""Return the source type."""
return SOURCE_TYPE_ROUTER
def _update_info(self):
"""Retrieve latest information from the Netgear router.
@property
def ip_address(self) -> str:
"""Return the IP address."""
return self._device["ip"]
Returns boolean if scanning successful.
"""
_LOGGER.debug("Scanning")
@property
def mac_address(self) -> str:
"""Return the mac address."""
return self._mac
results = self.get_attached_devices()
@property
def hostname(self) -> str:
"""Return the hostname."""
return self._hostname
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()
@property
def icon(self) -> str:
"""Return the icon."""
return self._icon

View file

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

View file

@ -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"
}
]
}

View file

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

View file

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

View file

@ -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)"
}
}
}
}
}

View file

@ -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)"
}
}
}
}
}

View file

@ -181,6 +181,7 @@ FLOWS = [
"neato",
"nest",
"netatmo",
"netgear",
"nexia",
"nfandroidtv",
"nightscout",

View file

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

View file

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

View file

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

View file

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

View file

@ -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.*",

View file

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

View file

@ -0,0 +1 @@
"""Tests for the Netgear component."""

View file

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

View file

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