From 50b2e9d7943290e43db4c312bd32048bd5986510 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Jan 2022 21:19:34 -1000 Subject: [PATCH] Prevent lookin polling when push updates are coming in (#64687) Co-authored-by: Chris Talkington --- .coveragerc | 1 + homeassistant/components/lookin/__init__.py | 64 +++++++++++--- homeassistant/components/lookin/climate.py | 4 +- .../components/lookin/coordinator.py | 85 +++++++++++++++++++ homeassistant/components/lookin/entity.py | 16 ++-- .../components/lookin/media_player.py | 4 +- homeassistant/components/lookin/models.py | 6 +- 7 files changed, 154 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/lookin/coordinator.py diff --git a/.coveragerc b/.coveragerc index 4a46152ec01..c4e8b76abec 100644 --- a/.coveragerc +++ b/.coveragerc @@ -611,6 +611,7 @@ omit = homeassistant/components/logi_circle/sensor.py homeassistant/components/london_underground/sensor.py homeassistant/components/lookin/__init__.py + homeassistant/components/lookin/coordinator.py homeassistant/components/lookin/entity.py homeassistant/components/lookin/models.py homeassistant/components/lookin/sensor.py diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 8f0e303f3d0..4f5365f2c93 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -18,18 +18,22 @@ from aiolookin import ( ) from aiolookin.models import UDPCommandType, UDPEvent -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, PLATFORMS, TYPE_TO_PLATFORM +from .coordinator import LookinDataUpdateCoordinator, LookinPushCoordinator from .models import LookinData LOGGER = logging.getLogger(__name__) +UDP_LOCK = "udp_lock" +UDP_LISTENER = "udp_listener" +UDP_SUBSCRIPTIONS = "udp_subscriptions" + def _async_climate_updater( lookin_protocol: LookInHttpProtocol, @@ -55,9 +59,42 @@ def _async_remote_updater( return _async_update +async def async_start_udp_listener(hass: HomeAssistant) -> LookinUDPSubscriptions: + """Start the shared udp listener.""" + domain_data = hass.data[DOMAIN] + if UDP_LOCK not in domain_data: + udp_lock = domain_data[UDP_LOCK] = asyncio.Lock() + else: + udp_lock = domain_data[UDP_LOCK] + + async with udp_lock: + if UDP_LISTENER not in domain_data: + lookin_udp_subs = domain_data[UDP_SUBSCRIPTIONS] = LookinUDPSubscriptions() + domain_data[UDP_LISTENER] = await start_lookin_udp(lookin_udp_subs, None) + else: + lookin_udp_subs = domain_data[UDP_SUBSCRIPTIONS] + return lookin_udp_subs + + +async def async_stop_udp_listener(hass: HomeAssistant) -> None: + """Stop the shared udp listener.""" + domain_data = hass.data[DOMAIN] + async with domain_data[UDP_LOCK]: + loaded_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + if len(loaded_entries) > 1: + return + domain_data[UDP_LISTENER]() + del domain_data[UDP_LISTENER] + del domain_data[UDP_SUBSCRIPTIONS] + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up lookin from a config entry.""" - + hass.data.setdefault(DOMAIN, {}) host = entry.data[CONF_HOST] lookin_protocol = LookInHttpProtocol( api_uri=f"http://{host}", session=async_get_clientsession(hass) @@ -69,9 +106,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (asyncio.TimeoutError, aiohttp.ClientError) as ex: raise ConfigEntryNotReady from ex - meteo_coordinator: DataUpdateCoordinator = DataUpdateCoordinator( + push_coordinator = LookinPushCoordinator(entry.title) + + meteo_coordinator: LookinDataUpdateCoordinator = LookinDataUpdateCoordinator( hass, - LOGGER, + push_coordinator, name=entry.title, update_method=lookin_protocol.get_meteo_sensor, update_interval=timedelta( @@ -80,7 +119,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await meteo_coordinator.async_config_entry_first_refresh() - device_coordinators: dict[str, DataUpdateCoordinator] = {} + device_coordinators: dict[str, LookinDataUpdateCoordinator] = {} for remote in devices: if (platform := TYPE_TO_PLATFORM.get(remote["Type"])) is None: continue @@ -89,9 +128,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: updater = _async_climate_updater(lookin_protocol, uuid) else: updater = _async_remote_updater(lookin_protocol, uuid) - coordinator = DataUpdateCoordinator( + coordinator = LookinDataUpdateCoordinator( hass, - LOGGER, + push_coordinator, name=f"{entry.title} {uuid}", update_method=updater, update_interval=timedelta( @@ -109,16 +148,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: meteo.update_from_value(event.value) meteo_coordinator.async_set_updated_data(meteo) - lookin_udp_subs = LookinUDPSubscriptions() + lookin_udp_subs = await async_start_udp_listener(hass) + entry.async_on_unload( lookin_udp_subs.subscribe_event( lookin_device.id, UDPCommandType.meteo, None, _async_meteo_push_update ) ) - entry.async_on_unload(await start_lookin_udp(lookin_udp_subs, lookin_device.id)) - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = LookinData( + hass.data[DOMAIN][entry.entry_id] = LookinData( lookin_udp_subs=lookin_udp_subs, lookin_device=lookin_device, meteo_coordinator=meteo_coordinator, @@ -136,4 +174,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) + + await async_stop_udp_listener(hass) return unload_ok diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py index 4af87646bac..ab6b53978be 100644 --- a/homeassistant/components/lookin/climate.py +++ b/homeassistant/components/lookin/climate.py @@ -35,9 +35,9 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, TYPE_TO_PLATFORM +from .coordinator import LookinDataUpdateCoordinator from .entity import LookinCoordinatorEntity from .models import LookinData @@ -115,7 +115,7 @@ class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): uuid: str, device: Climate, lookin_data: LookinData, - coordinator: DataUpdateCoordinator, + coordinator: LookinDataUpdateCoordinator, ) -> None: """Init the ConditionerEntity.""" super().__init__(coordinator, uuid, device, lookin_data) diff --git a/homeassistant/components/lookin/coordinator.py b/homeassistant/components/lookin/coordinator.py new file mode 100644 index 00000000000..522132cdea6 --- /dev/null +++ b/homeassistant/components/lookin/coordinator.py @@ -0,0 +1,85 @@ +"""Coordinator for lookin devices.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from datetime import timedelta +import logging +import time +from typing import cast + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +NEVER_TIME = -120.0 # Time that will never match time.monotonic() +ACTIVE_UPDATES_INTERVAL = 3 # Consider active for 3x the update interval + + +class LookinPushCoordinator: + """Keep track of when the last push update was.""" + + def __init__(self, name: str) -> None: + """Init the push coordininator.""" + self.last_update = NEVER_TIME + self.name = name + + def update(self) -> None: + """Remember the last push time.""" + self.last_update = time.monotonic() + + def active(self, interval: timedelta) -> bool: + """Check if the last push update was recently.""" + time_since_last_update = time.monotonic() - self.last_update + is_active = ( + time_since_last_update < interval.total_seconds() * ACTIVE_UPDATES_INTERVAL + ) + _LOGGER.debug( + "%s: push updates active: %s (time_since_last_update=%s)", + self.name, + is_active, + time_since_last_update, + ) + return is_active + + +class LookinDataUpdateCoordinator(DataUpdateCoordinator): + """DataUpdateCoordinator to gather data for a specific lookin devices.""" + + def __init__( + self, + hass: HomeAssistant, + push_coordinator: LookinPushCoordinator, + name: str, + update_interval: timedelta | None = None, + update_method: Callable[[], Awaitable[dict]] | None = None, + ) -> None: + """Initialize DataUpdateCoordinator to gather data for specific device.""" + self.push_coordinator = push_coordinator + super().__init__( + hass, + _LOGGER, + name=name, + update_interval=update_interval, + update_method=update_method, + ) + + @callback + def async_set_updated_data(self, data: dict) -> None: + """Manually update data, notify listeners and reset refresh interval, and remember.""" + self.push_coordinator.update() + super().async_set_updated_data(data) + + async def _async_update_data(self) -> dict: + """Fetch data only if we have not received a push inside the interval.""" + interval = self.update_interval + if ( + interval is not None + and self.last_update_success + and self.data + and self.push_coordinator.active(interval) + ): + data = self.data + else: + data = await super()._async_update_data() + return cast(dict, data) diff --git a/homeassistant/components/lookin/entity.py b/homeassistant/components/lookin/entity.py index b544f2065d4..58eafd3843f 100644 --- a/homeassistant/components/lookin/entity.py +++ b/homeassistant/components/lookin/entity.py @@ -9,12 +9,10 @@ from aiolookin import POWER_CMD, POWER_OFF_CMD, POWER_ON_CMD, Climate, Remote from aiolookin.models import Device, UDPCommandType, UDPEvent from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MODEL_NAMES +from .coordinator import LookinDataUpdateCoordinator from .models import LookinData LOGGER = logging.getLogger(__name__) @@ -55,6 +53,8 @@ class LookinDeviceMixIn: class LookinDeviceCoordinatorEntity(LookinDeviceMixIn, CoordinatorEntity): """A lookin device entity on the device itself that uses the coordinator.""" + coordinator: LookinDataUpdateCoordinator + _attr_should_poll = False def __init__(self, lookin_data: LookinData) -> None: @@ -85,12 +85,14 @@ class LookinEntityMixIn: class LookinCoordinatorEntity(LookinDeviceMixIn, LookinEntityMixIn, CoordinatorEntity): """A lookin device entity for an external device that uses the coordinator.""" + coordinator: LookinDataUpdateCoordinator + _attr_should_poll = False _attr_assumed_state = True def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: LookinDataUpdateCoordinator, uuid: str, device: Remote | Climate, lookin_data: LookinData, @@ -117,7 +119,7 @@ class LookinPowerEntity(LookinCoordinatorEntity): def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: LookinDataUpdateCoordinator, uuid: str, device: Remote | Climate, lookin_data: LookinData, @@ -137,7 +139,7 @@ class LookinPowerPushRemoteEntity(LookinPowerEntity): def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: LookinDataUpdateCoordinator, uuid: str, device: Remote, lookin_data: LookinData, diff --git a/homeassistant/components/lookin/media_player.py b/homeassistant/components/lookin/media_player.py index 8c336b41da4..9d689b1d241 100644 --- a/homeassistant/components/lookin/media_player.py +++ b/homeassistant/components/lookin/media_player.py @@ -21,9 +21,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, STATE_STANDBY, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, TYPE_TO_PLATFORM +from .coordinator import LookinDataUpdateCoordinator from .entity import LookinPowerPushRemoteEntity from .models import LookinData @@ -80,7 +80,7 @@ class LookinMedia(LookinPowerPushRemoteEntity, MediaPlayerEntity): def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: LookinDataUpdateCoordinator, uuid: str, device: Remote, lookin_data: LookinData, diff --git a/homeassistant/components/lookin/models.py b/homeassistant/components/lookin/models.py index e25585348b1..3587136c4a2 100644 --- a/homeassistant/components/lookin/models.py +++ b/homeassistant/components/lookin/models.py @@ -6,7 +6,7 @@ from typing import Any from aiolookin import Device, LookInHttpProtocol, LookinUDPSubscriptions -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .coordinator import LookinDataUpdateCoordinator @dataclass @@ -15,7 +15,7 @@ class LookinData: lookin_udp_subs: LookinUDPSubscriptions lookin_device: Device - meteo_coordinator: DataUpdateCoordinator + meteo_coordinator: LookinDataUpdateCoordinator devices: list[dict[str, Any]] lookin_protocol: LookInHttpProtocol - device_coordinators: dict[str, DataUpdateCoordinator] + device_coordinators: dict[str, LookinDataUpdateCoordinator]