core/homeassistant/components/august/activity.py

213 lines
8.0 KiB
Python

"""Consume the august activity stream."""
from __future__ import annotations
import asyncio
from datetime import datetime
from functools import partial
import logging
from aiohttp import ClientError
from yalexs.activity import Activity, ActivityType
from yalexs.api_async import ApiAsync
from yalexs.pubnub_async import AugustPubNub
from yalexs.util import get_latest_activity
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.event import async_call_later
from homeassistant.util.dt import utcnow
from .const import ACTIVITY_UPDATE_INTERVAL
from .gateway import AugustGateway
from .subscriber import AugustSubscriberMixin
_LOGGER = logging.getLogger(__name__)
ACTIVITY_STREAM_FETCH_LIMIT = 10
ACTIVITY_CATCH_UP_FETCH_LIMIT = 2500
# If there is a storm of activity (ie lock, unlock, door open, door close, etc)
# we want to debounce the updates so we don't hammer the activity api too much.
ACTIVITY_DEBOUNCE_COOLDOWN = 3
@callback
def _async_cancel_future_scheduled_updates(cancels: list[CALLBACK_TYPE]) -> None:
"""Cancel future scheduled updates."""
for cancel in cancels:
cancel()
cancels.clear()
class ActivityStream(AugustSubscriberMixin):
"""August activity stream handler."""
def __init__(
self,
hass: HomeAssistant,
api: ApiAsync,
august_gateway: AugustGateway,
house_ids: set[str],
pubnub: AugustPubNub,
) -> None:
"""Init August activity stream object."""
super().__init__(hass, ACTIVITY_UPDATE_INTERVAL)
self._hass = hass
self._schedule_updates: dict[str, list[CALLBACK_TYPE]] = {}
self._august_gateway = august_gateway
self._api = api
self._house_ids = house_ids
self._latest_activities: dict[str, dict[ActivityType, Activity]] = {}
self._did_first_update = False
self.pubnub = pubnub
self._update_debounce: dict[str, Debouncer] = {}
self._update_debounce_jobs: dict[str, HassJob] = {}
async def _async_update_house_id_later(
self, debouncer: Debouncer, _: datetime
) -> None:
"""Call a debouncer from async_call_later."""
await debouncer.async_call()
async def async_setup(self) -> None:
"""Token refresh check and catch up the activity stream."""
update_debounce = self._update_debounce
update_debounce_jobs = self._update_debounce_jobs
for house_id in self._house_ids:
debouncer = Debouncer(
self._hass,
_LOGGER,
cooldown=ACTIVITY_DEBOUNCE_COOLDOWN,
immediate=True,
function=partial(self._async_update_house_id, house_id),
)
update_debounce[house_id] = debouncer
update_debounce_jobs[house_id] = HassJob(
partial(self._async_update_house_id_later, debouncer),
f"debounced august activity update for {house_id}",
cancel_on_shutdown=True,
)
await self._async_refresh(utcnow())
self._did_first_update = True
@callback
def async_stop(self) -> None:
"""Cleanup any debounces."""
for debouncer in self._update_debounce.values():
debouncer.async_cancel()
for cancels in self._schedule_updates.values():
_async_cancel_future_scheduled_updates(cancels)
def get_latest_device_activity(
self, device_id: str, activity_types: set[ActivityType]
) -> Activity | None:
"""Return latest activity that is one of the activity_types."""
if not (latest_device_activities := self._latest_activities.get(device_id)):
return None
latest_activity: Activity | None = None
for activity_type in activity_types:
if activity := latest_device_activities.get(activity_type):
if (
latest_activity
and activity.activity_start_time
<= latest_activity.activity_start_time
):
continue
latest_activity = activity
return latest_activity
async def _async_refresh(self, time: datetime) -> None:
"""Update the activity stream from August."""
# This is the only place we refresh the api token
await self._august_gateway.async_refresh_access_token_if_needed()
if self.pubnub.connected:
_LOGGER.debug("Skipping update because pubnub is connected")
return
_LOGGER.debug("Start retrieving device activities")
await asyncio.gather(
*(debouncer.async_call() for debouncer in self._update_debounce.values())
)
@callback
def async_schedule_house_id_refresh(self, house_id: str) -> None:
"""Update for a house activities now and once in the future."""
if future_updates := self._schedule_updates.setdefault(house_id, []):
_async_cancel_future_scheduled_updates(future_updates)
debouncer = self._update_debounce[house_id]
self._hass.async_create_task(debouncer.async_call())
# Schedule two updates past the debounce time
# to ensure we catch the case where the activity
# api does not update right away and we need to poll
# it again. Sometimes the lock operator or a doorbell
# will not show up in the activity stream right away.
job = self._update_debounce_jobs[house_id]
for step in (1, 2):
future_updates.append(
async_call_later(
self._hass,
(step * ACTIVITY_DEBOUNCE_COOLDOWN) + 0.1,
job,
)
)
async def _async_update_house_id(self, house_id: str) -> None:
"""Update device activities for a house."""
if self._did_first_update:
limit = ACTIVITY_STREAM_FETCH_LIMIT
else:
limit = ACTIVITY_CATCH_UP_FETCH_LIMIT
_LOGGER.debug("Updating device activity for house id %s", house_id)
try:
activities = await self._api.async_get_house_activities(
self._august_gateway.access_token, house_id, limit=limit
)
except ClientError as ex:
_LOGGER.error(
"Request error trying to retrieve activity for house id %s: %s",
house_id,
ex,
)
# Make sure we process the next house if one of them fails
return
_LOGGER.debug(
"Completed retrieving device activities for house id %s", house_id
)
for device_id in self.async_process_newer_device_activities(activities):
_LOGGER.debug(
"async_signal_device_id_update (from activity stream): %s",
device_id,
)
self.async_signal_device_id_update(device_id)
def async_process_newer_device_activities(
self, activities: list[Activity]
) -> set[str]:
"""Process activities if they are newer than the last one."""
updated_device_ids = set()
latest_activities = self._latest_activities
for activity in activities:
device_id = activity.device_id
activity_type = activity.activity_type
device_activities = latest_activities.setdefault(device_id, {})
# Ignore activities that are older than the latest one unless it is a non
# locking or unlocking activity with the exact same start time.
last_activity = device_activities.get(activity_type)
# The activity stream can have duplicate activities. So we need
# to call get_latest_activity to figure out if if the activity
# is actually newer than the last one.
latest_activity = get_latest_activity(activity, last_activity)
if latest_activity != activity:
continue
device_activities[activity_type] = activity
updated_device_ids.add(device_id)
return updated_device_ids