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.
This commit is contained in:
J. Nick Koston 2021-01-10 09:12:21 -10:00 committed by GitHub
parent 707a8e62f9
commit b450d4c135
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 95 additions and 52 deletions

View file

@ -32,6 +32,9 @@ from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.dispatcher import async_dispatcher_send 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 ( from .const import (
CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_BANDWIDTH_SENSORS,
@ -64,6 +67,7 @@ from .const import (
from .errors import AuthenticationRequired, CannotConnect from .errors import AuthenticationRequired, CannotConnect
RETRY_TIMER = 15 RETRY_TIMER = 15
CHECK_DISCONNECTED_INTERVAL = timedelta(seconds=1)
SUPPORTED_PLATFORMS = [TRACKER_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN] SUPPORTED_PLATFORMS = [TRACKER_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN]
CLIENT_CONNECTED = ( CLIENT_CONNECTED = (
@ -94,6 +98,9 @@ class UniFiController:
self._site_name = None self._site_name = None
self._site_role = None self._site_role = None
self._cancel_disconnected_check = None
self._watch_disconnected_entites = []
self.entities = {} self.entities = {}
@property @property
@ -375,8 +382,32 @@ class UniFiController:
self.config_entry.add_update_listener(self.async_config_entry_updated) 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 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 @staticmethod
async def async_config_entry_updated(hass, config_entry) -> None: async def async_config_entry_updated(hass, config_entry) -> None:
"""Handle signals of config entry being updated.""" """Handle signals of config entry being updated."""
@ -430,6 +461,10 @@ class UniFiController:
unsub_dispatcher() unsub_dispatcher()
self.listeners = [] self.listeners = []
if self._cancel_disconnected_check:
self._cancel_disconnected_check()
self._cancel_disconnected_check = None
return True return True

View file

@ -21,7 +21,6 @@ from homeassistant.components.device_tracker.const import SOURCE_TYPE_ROUTER
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import async_dispatcher_connect 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 import homeassistant.util.dt as dt_util
from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN
@ -53,6 +52,8 @@ CLIENT_STATIC_ATTRIBUTES = [
"oui", "oui",
] ]
CLIENT_CONNECTED_ALL_ATTRIBUTES = CLIENT_CONNECTED_ATTRIBUTES + CLIENT_STATIC_ATTRIBUTES
DEVICE_UPGRADED = (ACCESS_POINT_UPGRADED, GATEWAY_UPGRADED, SWITCH_UPGRADED) DEVICE_UPGRADED = (ACCESS_POINT_UPGRADED, GATEWAY_UPGRADED, SWITCH_UPGRADED)
WIRED_CONNECTION = (WIRED_CLIENT_CONNECTED,) WIRED_CONNECTION = (WIRED_CLIENT_CONNECTED,)
@ -142,7 +143,7 @@ class UniFiClientTracker(UniFiClient, ScannerEntity):
super().__init__(client, controller) super().__init__(client, controller)
self.schedule_update = False self.schedule_update = False
self.cancel_scheduled_update = None self.disconnected_time = None
self._is_connected = False self._is_connected = False
if client.last_seen: if client.last_seen:
self._is_connected = ( self._is_connected = (
@ -154,10 +155,14 @@ class UniFiClientTracker(UniFiClient, ScannerEntity):
if self._is_connected: if self._is_connected:
self.schedule_update = True 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: async def async_will_remove_from_hass(self) -> None:
"""Disconnect object when removed.""" """Disconnect object when removed."""
if self.cancel_scheduled_update: self.controller.remove_disconnected_check(self)
self.cancel_scheduled_update()
await super().async_will_remove_from_hass() await super().async_will_remove_from_hass()
@callback @callback
@ -170,12 +175,10 @@ class UniFiClientTracker(UniFiClient, ScannerEntity):
): ):
self._is_connected = True self._is_connected = True
self.schedule_update = False self.schedule_update = False
if self.cancel_scheduled_update: self.disconnected_time = None
self.cancel_scheduled_update()
self.cancel_scheduled_update = None
# Ignore extra scheduled update from wired bug # Ignore extra scheduled update from wired bug
elif not self.cancel_scheduled_update: elif not self.disconnected_time:
self.schedule_update = True self.schedule_update = True
elif not self.client.event and self.client.last_updated == SOURCE_DATA: elif not self.client.event and self.client.last_updated == SOURCE_DATA:
@ -185,23 +188,16 @@ class UniFiClientTracker(UniFiClient, ScannerEntity):
if self.schedule_update: if self.schedule_update:
self.schedule_update = False self.schedule_update = False
self.disconnected_time = (
if self.cancel_scheduled_update: dt_util.utcnow() + self.controller.option_detection_time
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,
) )
super().async_update_callback() super().async_update_callback()
@callback @callback
def _make_disconnected(self, _): def make_disconnected(self, *_):
"""Mark client as disconnected.""" """Mark client as disconnected."""
self._is_connected = False self._is_connected = False
self.cancel_scheduled_update = None
self.async_write_ha_state() self.async_write_ha_state()
@property @property
@ -230,16 +226,16 @@ class UniFiClientTracker(UniFiClient, ScannerEntity):
@property @property
def device_state_attributes(self): def device_state_attributes(self):
"""Return the client state attributes.""" """Return the client state attributes."""
attributes = {"is_wired": self.is_wired} raw = self.client.raw
if self.is_connected: if self.is_connected:
for variable in CLIENT_CONNECTED_ATTRIBUTES: attributes = {
if variable in self.client.raw: k: raw[k] for k in CLIENT_CONNECTED_ALL_ATTRIBUTES if k in raw
attributes[variable] = self.client.raw[variable] }
else:
attributes = {k: raw[k] for k in CLIENT_STATIC_ATTRIBUTES if k in raw}
for variable in CLIENT_STATIC_ATTRIBUTES: attributes["is_wired"] = self.is_wired
if variable in self.client.raw:
attributes[variable] = self.client.raw[variable]
return attributes return attributes
@ -270,17 +266,21 @@ class UniFiDeviceTracker(UniFiBase, ScannerEntity):
super().__init__(device, controller) super().__init__(device, controller)
self._is_connected = device.state == 1 self._is_connected = device.state == 1
self.cancel_scheduled_update = None self.disconnected_time = None
@property @property
def device(self): def device(self):
"""Wrap item.""" """Wrap item."""
return self._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: async def async_will_remove_from_hass(self) -> None:
"""Disconnect device object when removed.""" """Disconnect object when removed."""
if self.cancel_scheduled_update: self.controller.remove_disconnected_check(self)
self.cancel_scheduled_update()
await super().async_will_remove_from_hass() await super().async_will_remove_from_hass()
@callback @callback
@ -288,16 +288,9 @@ class UniFiDeviceTracker(UniFiBase, ScannerEntity):
"""Update the devices' state.""" """Update the devices' state."""
if self.device.last_updated == SOURCE_DATA: if self.device.last_updated == SOURCE_DATA:
self._is_connected = True self._is_connected = True
self.disconnected_time = dt_util.utcnow() + timedelta(
if self.cancel_scheduled_update: seconds=self.device.next_interval + 60
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),
) )
elif ( elif (
@ -310,10 +303,9 @@ class UniFiDeviceTracker(UniFiBase, ScannerEntity):
super().async_update_callback() super().async_update_callback()
@callback @callback
def _no_heartbeat(self, _): def make_disconnected(self, *_):
"""No heart beat by device.""" """No heart beat by device."""
self._is_connected = False self._is_connected = False
self.cancel_scheduled_update = None
self.async_write_ha_state() self.async_write_ha_state()
@property @property

View file

@ -1,6 +1,7 @@
"""The tests for the UniFi device tracker platform.""" """The tests for the UniFi device tracker platform."""
from copy import copy from copy import copy
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch
from aiounifi.controller import ( from aiounifi.controller import (
MESSAGE_CLIENT, MESSAGE_CLIENT,
@ -200,8 +201,10 @@ async def test_tracked_wireless_clients(hass):
client_1 = hass.states.get("device_tracker.client_1") client_1 = hass.states.get("device_tracker.client_1")
assert client_1.state == "home" assert client_1.state == "home"
async_fire_time_changed(hass, dt_util.utcnow() + controller.option_detection_time) new_time = dt_util.utcnow() + controller.option_detection_time
await hass.async_block_till_done() 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") client_1 = hass.states.get("device_tracker.client_1")
assert client_1.state == "not_home" 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") device_2 = hass.states.get("device_tracker.device_2")
assert device_2.state == "home" assert device_2.state == "home"
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=90)) new_time = dt_util.utcnow() + timedelta(seconds=90)
await hass.async_block_till_done() 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") device_1 = hass.states.get("device_tracker.device_1")
assert device_1.state == "not_home" 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") client_3 = hass.states.get("device_tracker.client_3")
assert client_3.state == "home" assert client_3.state == "home"
async_fire_time_changed(hass, dt_util.utcnow() + controller.option_detection_time) new_time = dt_util.utcnow() + controller.option_detection_time
await hass.async_block_till_done() 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") client_1 = hass.states.get("device_tracker.client_1")
assert client_1.state == "not_home" 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 # Trigger update to get client marked as away
event = {"meta": {"message": MESSAGE_CLIENT}, "data": [CLIENT_3]} event = {"meta": {"message": MESSAGE_CLIENT}, "data": [CLIENT_3]}
controller.api.message_handler(event) 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") client_3 = hass.states.get("device_tracker.client_3")
assert client_3.state == "not_home" 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 assert client_1.attributes["is_wired"] is False
# Pass time # Pass time
async_fire_time_changed(hass, dt_util.utcnow() + controller.option_detection_time) new_time = dt_util.utcnow() + controller.option_detection_time
await hass.async_block_till_done() 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 # Marked as home according to the timer
client_1 = hass.states.get("device_tracker.client_1") 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 assert client_1.attributes["is_wired"] is True
# pass time # pass time
async_fire_time_changed(hass, dt_util.utcnow() + controller.option_detection_time) new_time = dt_util.utcnow() + controller.option_detection_time
await hass.async_block_till_done() 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 # Timer marks client as away
client_1 = hass.states.get("device_tracker.client_1") client_1 = hass.states.get("device_tracker.client_1")