From b450d4c135f17f866c627118ac2f28e4306e1cb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Jan 2021 09:12:21 -1000 Subject: [PATCH] Improve unifi performance with many devices (#45006) With 250 clients, there were about 18000 timers updated every minute. To avoid this, we check which entities should be set to not_home only once every second. --- homeassistant/components/unifi/controller.py | 35 +++++++++ .../components/unifi/device_tracker.py | 72 +++++++++---------- tests/components/unifi/test_device_tracker.py | 40 +++++++---- 3 files changed, 95 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 30b82c65c85..9f264e6cd1c 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -32,6 +32,9 @@ from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval +import homeassistant.util.dt as dt_util from .const import ( CONF_ALLOW_BANDWIDTH_SENSORS, @@ -64,6 +67,7 @@ from .const import ( from .errors import AuthenticationRequired, CannotConnect RETRY_TIMER = 15 +CHECK_DISCONNECTED_INTERVAL = timedelta(seconds=1) SUPPORTED_PLATFORMS = [TRACKER_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN] CLIENT_CONNECTED = ( @@ -94,6 +98,9 @@ class UniFiController: self._site_name = None self._site_role = None + self._cancel_disconnected_check = None + self._watch_disconnected_entites = [] + self.entities = {} @property @@ -375,8 +382,32 @@ class UniFiController: self.config_entry.add_update_listener(self.async_config_entry_updated) + self._cancel_disconnected_check = async_track_time_interval( + self.hass, self._async_check_for_disconnected, CHECK_DISCONNECTED_INTERVAL + ) + return True + @callback + def add_disconnected_check(self, entity: Entity) -> None: + """Add an entity to watch for disconnection.""" + self._watch_disconnected_entites.append(entity) + + @callback + def remove_disconnected_check(self, entity: Entity) -> None: + """Remove an entity to watch for disconnection.""" + self._watch_disconnected_entites.remove(entity) + + @callback + def _async_check_for_disconnected(self, *_) -> None: + """Check for any devices scheduled to be marked disconnected.""" + now = dt_util.utcnow() + + for entity in self._watch_disconnected_entites: + disconnected_time = entity.disconnected_time + if disconnected_time is not None and now > disconnected_time: + entity.make_disconnected() + @staticmethod async def async_config_entry_updated(hass, config_entry) -> None: """Handle signals of config entry being updated.""" @@ -430,6 +461,10 @@ class UniFiController: unsub_dispatcher() self.listeners = [] + if self._cancel_disconnected_check: + self._cancel_disconnected_check() + self._cancel_disconnected_check = None + return True diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index a3352631885..9f7726e1ba1 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -21,7 +21,6 @@ from homeassistant.components.device_tracker.const import SOURCE_TYPE_ROUTER from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.event import async_track_point_in_utc_time import homeassistant.util.dt as dt_util from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN @@ -53,6 +52,8 @@ CLIENT_STATIC_ATTRIBUTES = [ "oui", ] +CLIENT_CONNECTED_ALL_ATTRIBUTES = CLIENT_CONNECTED_ATTRIBUTES + CLIENT_STATIC_ATTRIBUTES + DEVICE_UPGRADED = (ACCESS_POINT_UPGRADED, GATEWAY_UPGRADED, SWITCH_UPGRADED) WIRED_CONNECTION = (WIRED_CLIENT_CONNECTED,) @@ -142,7 +143,7 @@ class UniFiClientTracker(UniFiClient, ScannerEntity): super().__init__(client, controller) self.schedule_update = False - self.cancel_scheduled_update = None + self.disconnected_time = None self._is_connected = False if client.last_seen: self._is_connected = ( @@ -154,10 +155,14 @@ class UniFiClientTracker(UniFiClient, ScannerEntity): if self._is_connected: self.schedule_update = True + async def async_added_to_hass(self) -> None: + """Watch object when added.""" + self.controller.add_disconnected_check(self) + await super().async_added_to_hass() + async def async_will_remove_from_hass(self) -> None: """Disconnect object when removed.""" - if self.cancel_scheduled_update: - self.cancel_scheduled_update() + self.controller.remove_disconnected_check(self) await super().async_will_remove_from_hass() @callback @@ -170,12 +175,10 @@ class UniFiClientTracker(UniFiClient, ScannerEntity): ): self._is_connected = True self.schedule_update = False - if self.cancel_scheduled_update: - self.cancel_scheduled_update() - self.cancel_scheduled_update = None + self.disconnected_time = None # Ignore extra scheduled update from wired bug - elif not self.cancel_scheduled_update: + elif not self.disconnected_time: self.schedule_update = True elif not self.client.event and self.client.last_updated == SOURCE_DATA: @@ -185,23 +188,16 @@ class UniFiClientTracker(UniFiClient, ScannerEntity): if self.schedule_update: self.schedule_update = False - - if self.cancel_scheduled_update: - self.cancel_scheduled_update() - - self.cancel_scheduled_update = async_track_point_in_utc_time( - self.hass, - self._make_disconnected, - dt_util.utcnow() + self.controller.option_detection_time, + self.disconnected_time = ( + dt_util.utcnow() + self.controller.option_detection_time ) super().async_update_callback() @callback - def _make_disconnected(self, _): + def make_disconnected(self, *_): """Mark client as disconnected.""" self._is_connected = False - self.cancel_scheduled_update = None self.async_write_ha_state() @property @@ -230,16 +226,16 @@ class UniFiClientTracker(UniFiClient, ScannerEntity): @property def device_state_attributes(self): """Return the client state attributes.""" - attributes = {"is_wired": self.is_wired} + raw = self.client.raw if self.is_connected: - for variable in CLIENT_CONNECTED_ATTRIBUTES: - if variable in self.client.raw: - attributes[variable] = self.client.raw[variable] + attributes = { + k: raw[k] for k in CLIENT_CONNECTED_ALL_ATTRIBUTES if k in raw + } + else: + attributes = {k: raw[k] for k in CLIENT_STATIC_ATTRIBUTES if k in raw} - for variable in CLIENT_STATIC_ATTRIBUTES: - if variable in self.client.raw: - attributes[variable] = self.client.raw[variable] + attributes["is_wired"] = self.is_wired return attributes @@ -270,17 +266,21 @@ class UniFiDeviceTracker(UniFiBase, ScannerEntity): super().__init__(device, controller) self._is_connected = device.state == 1 - self.cancel_scheduled_update = None + self.disconnected_time = None @property def device(self): """Wrap item.""" return self._item + async def async_added_to_hass(self) -> None: + """Watch object when added.""" + self.controller.add_disconnected_check(self) + await super().async_added_to_hass() + async def async_will_remove_from_hass(self) -> None: - """Disconnect device object when removed.""" - if self.cancel_scheduled_update: - self.cancel_scheduled_update() + """Disconnect object when removed.""" + self.controller.remove_disconnected_check(self) await super().async_will_remove_from_hass() @callback @@ -288,16 +288,9 @@ class UniFiDeviceTracker(UniFiBase, ScannerEntity): """Update the devices' state.""" if self.device.last_updated == SOURCE_DATA: - self._is_connected = True - - if self.cancel_scheduled_update: - self.cancel_scheduled_update() - - self.cancel_scheduled_update = async_track_point_in_utc_time( - self.hass, - self._no_heartbeat, - dt_util.utcnow() + timedelta(seconds=self.device.next_interval + 60), + self.disconnected_time = dt_util.utcnow() + timedelta( + seconds=self.device.next_interval + 60 ) elif ( @@ -310,10 +303,9 @@ class UniFiDeviceTracker(UniFiBase, ScannerEntity): super().async_update_callback() @callback - def _no_heartbeat(self, _): + def make_disconnected(self, *_): """No heart beat by device.""" self._is_connected = False - self.cancel_scheduled_update = None self.async_write_ha_state() @property diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 3fef8a16d68..6bfe8f44b5c 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -1,6 +1,7 @@ """The tests for the UniFi device tracker platform.""" from copy import copy from datetime import timedelta +from unittest.mock import patch from aiounifi.controller import ( MESSAGE_CLIENT, @@ -200,8 +201,10 @@ async def test_tracked_wireless_clients(hass): client_1 = hass.states.get("device_tracker.client_1") assert client_1.state == "home" - async_fire_time_changed(hass, dt_util.utcnow() + controller.option_detection_time) - await hass.async_block_till_done() + new_time = dt_util.utcnow() + controller.option_detection_time + with patch("homeassistant.util.dt.utcnow", return_value=new_time): + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() client_1 = hass.states.get("device_tracker.client_1") assert client_1.state == "not_home" @@ -294,8 +297,10 @@ async def test_tracked_devices(hass): device_2 = hass.states.get("device_tracker.device_2") assert device_2.state == "home" - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=90)) - await hass.async_block_till_done() + new_time = dt_util.utcnow() + timedelta(seconds=90) + with patch("homeassistant.util.dt.utcnow", return_value=new_time): + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() device_1 = hass.states.get("device_tracker.device_1") assert device_1.state == "not_home" @@ -609,8 +614,10 @@ async def test_option_ssid_filter(hass): client_3 = hass.states.get("device_tracker.client_3") assert client_3.state == "home" - async_fire_time_changed(hass, dt_util.utcnow() + controller.option_detection_time) - await hass.async_block_till_done() + new_time = dt_util.utcnow() + controller.option_detection_time + with patch("homeassistant.util.dt.utcnow", return_value=new_time): + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() client_1 = hass.states.get("device_tracker.client_1") assert client_1.state == "not_home" @@ -622,8 +629,13 @@ async def test_option_ssid_filter(hass): # Trigger update to get client marked as away event = {"meta": {"message": MESSAGE_CLIENT}, "data": [CLIENT_3]} controller.api.message_handler(event) - async_fire_time_changed(hass, dt_util.utcnow() + controller.option_detection_time) - await hass.async_block_till_done() + + new_time = ( + dt_util.utcnow() + controller.option_detection_time + timedelta(seconds=1) + ) + with patch("homeassistant.util.dt.utcnow", return_value=new_time): + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() client_3 = hass.states.get("device_tracker.client_3") assert client_3.state == "not_home" @@ -658,8 +670,10 @@ async def test_wireless_client_go_wired_issue(hass): assert client_1.attributes["is_wired"] is False # Pass time - async_fire_time_changed(hass, dt_util.utcnow() + controller.option_detection_time) - await hass.async_block_till_done() + new_time = dt_util.utcnow() + controller.option_detection_time + with patch("homeassistant.util.dt.utcnow", return_value=new_time): + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() # Marked as home according to the timer client_1 = hass.states.get("device_tracker.client_1") @@ -716,8 +730,10 @@ async def test_option_ignore_wired_bug(hass): assert client_1.attributes["is_wired"] is True # pass time - async_fire_time_changed(hass, dt_util.utcnow() + controller.option_detection_time) - await hass.async_block_till_done() + new_time = dt_util.utcnow() + controller.option_detection_time + with patch("homeassistant.util.dt.utcnow", return_value=new_time): + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() # Timer marks client as away client_1 = hass.states.get("device_tracker.client_1")