From 69a004a2f6a5f026fee519d3375805d6c5ae937b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 3 Feb 2022 12:28:04 +0100 Subject: [PATCH] Netgear coordinator (#65255) * implement coordinator * fix styling * fix async_uload_entry * use async_config_entry_first_refresh * use const * fix black * use KEY_ROUTER * review comments * fix black * ensure the coordinator keeps updating * fix flake8 * rework setup of entities using coordinator * styling * check for failed get_info call * fix * fix setup of entities * simplify * do not set unique_id and device_info on scanner entity * Update homeassistant/components/netgear/sensor.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/netgear/device_tracker.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/netgear/router.py Co-authored-by: Martin Hjelmare * use entry_id instead of unique_id * unused import Co-authored-by: Martin Hjelmare --- homeassistant/components/netgear/__init__.py | 33 ++++- homeassistant/components/netgear/const.py | 7 +- .../components/netgear/device_tracker.py | 42 ++++-- homeassistant/components/netgear/router.py | 134 ++++++------------ homeassistant/components/netgear/sensor.py | 54 +++++-- 5 files changed, 149 insertions(+), 121 deletions(-) diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 26c7f4f1a1a..919cf25ae82 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -1,4 +1,5 @@ """Support for Netgear routers.""" +from datetime import timedelta import logging from homeassistant.config_entries import ConfigEntry @@ -6,19 +7,23 @@ from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, PLATFORMS +from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER, PLATFORMS from .errors import CannotLoginException from .router import NetgearRouter _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=30) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Netgear component.""" router = NetgearRouter(hass, entry) try: - await router.async_setup() + if not await router.async_setup(): + raise ConfigEntryNotReady except CannotLoginException as ex: raise ConfigEntryNotReady from ex @@ -37,7 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.unique_id] = router entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -52,6 +56,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: configuration_url=f"http://{entry.data[CONF_HOST]}/", ) + async def async_update_data() -> bool: + """Fetch data from the router.""" + data = await router.async_update_device_trackers() + return data + + # Create update coordinator + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=router.device_name, + update_method=async_update_data, + update_interval=SCAN_INTERVAL, + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = { + KEY_ROUTER: router, + KEY_COORDINATOR: coordinator, + } + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -62,7 +87,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(entry.unique_id) + hass.data[DOMAIN].pop(entry.entry_id) if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py index 5f8944f96d6..b1d5dd22942 100644 --- a/homeassistant/components/netgear/const.py +++ b/homeassistant/components/netgear/const.py @@ -5,10 +5,13 @@ from homeassistant.const import Platform DOMAIN = "netgear" -PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] - CONF_CONSIDER_HOME = "consider_home" +KEY_ROUTER = "router" +KEY_COORDINATOR = "coordinator" + +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] + DEFAULT_CONSIDER_HOME = timedelta(seconds=180) DEFAULT_NAME = "Netgear router" diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index e3beb005845..72699768f84 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -8,9 +8,10 @@ from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DEVICE_ICONS -from .router import NetgearDeviceEntity, NetgearRouter, async_setup_netgear_entry +from .const import DEVICE_ICONS, DOMAIN, KEY_COORDINATOR, KEY_ROUTER +from .router import NetgearBaseEntity, NetgearRouter _LOGGER = logging.getLogger(__name__) @@ -19,19 +20,42 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up device tracker for Netgear component.""" + router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] + coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR] + tracked = set() - def generate_classes(router: NetgearRouter, device: dict): - return [NetgearScannerEntity(router, device)] + @callback + def new_device_callback() -> None: + """Add new devices if needed.""" + if not coordinator.data: + return - async_setup_netgear_entry(hass, entry, async_add_entities, generate_classes) + new_entities = [] + + for mac, device in router.devices.items(): + if mac in tracked: + continue + + new_entities.append(NetgearScannerEntity(coordinator, router, device)) + tracked.add(mac) + + if new_entities: + async_add_entities(new_entities) + + entry.async_on_unload(coordinator.async_add_listener(new_device_callback)) + + coordinator.data = True + new_device_callback() -class NetgearScannerEntity(NetgearDeviceEntity, ScannerEntity): +class NetgearScannerEntity(NetgearBaseEntity, ScannerEntity): """Representation of a device connected to a Netgear router.""" - def __init__(self, router: NetgearRouter, device: dict) -> None: + def __init__( + self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict + ) -> None: """Initialize a Netgear device.""" - super().__init__(router, device) + super().__init__(coordinator, router, device) self._hostname = self.get_hostname() self._icon = DEVICE_ICONS.get(device["device_type"], "mdi:help-network") @@ -49,8 +73,6 @@ class NetgearScannerEntity(NetgearDeviceEntity, ScannerEntity): 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.""" diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 5226218a623..d275aaf7fb2 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -2,7 +2,6 @@ from __future__ import annotations from abc import abstractmethod -from collections.abc import Callable from datetime import timedelta import logging @@ -19,13 +18,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, 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 DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, ) -from homeassistant.helpers.entity import DeviceInfo, Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import dt as dt_util from .const import ( @@ -37,8 +34,6 @@ from .const import ( ) from .errors import CannotLoginException -SCAN_INTERVAL = timedelta(seconds=30) - _LOGGER = logging.getLogger(__name__) @@ -58,47 +53,6 @@ def get_api( return api -@callback -def async_setup_netgear_entry( - hass: HomeAssistant, - 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.""" @@ -141,6 +95,9 @@ class NetgearRouter: ) self._info = self._api.get_info() + if self._info is None: + return False + self.device_name = self._info.get("DeviceName", DEFAULT_NAME) self.model = self._info.get("ModelName") self.firmware_version = self._info.get("Firmwareversion") @@ -157,9 +114,12 @@ class NetgearRouter: ) self.method_version = 1 - async def async_setup(self) -> None: + return True + + async def async_setup(self) -> bool: """Set up a Netgear router.""" - await self.hass.async_add_executor_job(self._setup) + if not await self.hass.async_add_executor_job(self._setup): + return False # set already known devices to away instead of unavailable device_registry = dr.async_get(self.hass) @@ -184,14 +144,7 @@ class NetgearRouter: "conn_ap_mac": 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) + return True async def async_get_attached_devices(self) -> list: """Get the devices connected to the router.""" @@ -228,21 +181,10 @@ class NetgearRouter: 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" + return new_device @property def port(self) -> int: @@ -255,17 +197,19 @@ class NetgearRouter: return self._api.ssl -class NetgearDeviceEntity(Entity): +class NetgearBaseEntity(CoordinatorEntity): """Base class for a device connected to a Netgear router.""" - def __init__(self, router: NetgearRouter, device: dict) -> None: + def __init__( + self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict + ) -> None: """Initialize a Netgear device.""" + super().__init__(coordinator) 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): @@ -281,16 +225,33 @@ class NetgearDeviceEntity(Entity): def async_update_device(self) -> None: """Update the Netgear device.""" - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_update_device() + super()._handle_coordinator_update() @property def name(self) -> str: """Return the name.""" return self._name + +class NetgearDeviceEntity(NetgearBaseEntity): + """Base class for a device connected to a Netgear router.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict + ) -> None: + """Initialize a Netgear device.""" + super().__init__(coordinator, router, device) + self._unique_id = self._mac + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + @property def device_info(self) -> DeviceInfo: """Return the device information.""" @@ -300,18 +261,3 @@ class NetgearDeviceEntity(Entity): default_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 index 227e6e28b09..0db0e4f19f4 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -9,8 +9,10 @@ from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .router import NetgearDeviceEntity, NetgearRouter, async_setup_netgear_entry +from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER +from .router import NetgearDeviceEntity, NetgearRouter SENSOR_TYPES = { "type": SensorEntityDescription( @@ -48,15 +50,41 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up device tracker for Netgear component.""" + router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] + coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR] + tracked = set() - def generate_sensor_classes(router: NetgearRouter, device: dict): - sensors = ["type", "link_rate", "signal"] - if router.method_version == 2: - sensors.extend(["ssid", "conn_ap_mac"]) + sensors = ["type", "link_rate", "signal"] + if router.method_version == 2: + sensors.extend(["ssid", "conn_ap_mac"]) - return [NetgearSensorEntity(router, device, attribute) for attribute in sensors] + @callback + def new_device_callback() -> None: + """Add new devices if needed.""" + if not coordinator.data: + return - async_setup_netgear_entry(hass, entry, async_add_entities, generate_sensor_classes) + new_entities = [] + + for mac, device in router.devices.items(): + if mac in tracked: + continue + + new_entities.extend( + [ + NetgearSensorEntity(coordinator, router, device, attribute) + for attribute in sensors + ] + ) + tracked.add(mac) + + if new_entities: + async_add_entities(new_entities) + + entry.async_on_unload(coordinator.async_add_listener(new_device_callback)) + + coordinator.data = True + new_device_callback() class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): @@ -64,9 +92,15 @@ class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): _attr_entity_registry_enabled_default = False - def __init__(self, router: NetgearRouter, device: dict, attribute: str) -> None: + def __init__( + self, + coordinator: DataUpdateCoordinator, + router: NetgearRouter, + device: dict, + attribute: str, + ) -> None: """Initialize a Netgear device.""" - super().__init__(router, device) + super().__init__(coordinator, router, device) self._attribute = attribute self.entity_description = SENSOR_TYPES[self._attribute] self._name = f"{self.get_device_name()} {self.entity_description.name}" @@ -85,5 +119,3 @@ class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): self._active = self._device["active"] if self._device.get(self._attribute) is not None: self._state = self._device[self._attribute] - - self.async_write_ha_state()