Migrate august to use yalexs 5.2.0 (#119178)
This commit is contained in:
parent
ff493a8a9d
commit
d9f1d40805
13 changed files with 65 additions and 535 deletions
|
@ -7,33 +7,37 @@ from collections.abc import Callable, Coroutine, Iterable, ValuesView
|
|||
from datetime import datetime
|
||||
from itertools import chain
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
from path import Path
|
||||
from yalexs.activity import ActivityTypes
|
||||
from yalexs.const import DEFAULT_BRAND
|
||||
from yalexs.doorbell import Doorbell, DoorbellDetail
|
||||
from yalexs.exceptions import AugustApiAIOHTTPError
|
||||
from yalexs.lock import Lock, LockDetail
|
||||
from yalexs.manager.activity import ActivityStream
|
||||
from yalexs.manager.const import CONF_BRAND
|
||||
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
|
||||
from yalexs.manager.gateway import Config as YaleXSConfig
|
||||
from yalexs.manager.subscriber import SubscriberMixin
|
||||
from yalexs.pubnub_activity import activities_from_pubnub_message
|
||||
from yalexs.pubnub_async import AugustPubNub, async_create_pubnub
|
||||
from yalexs_ble import YaleXSBLEDiscovery
|
||||
|
||||
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.const import CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr, discovery_flow
|
||||
from homeassistant.util.async_ import create_eager_task
|
||||
|
||||
from .activity import ActivityStream
|
||||
from .const import CONF_BRAND, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS
|
||||
from .exceptions import CannotConnect, InvalidAuth, RequireValidation
|
||||
from .const import DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS
|
||||
from .gateway import AugustGateway
|
||||
from .subscriber import AugustSubscriberMixin
|
||||
from .util import async_create_august_clientsession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
@ -52,10 +56,8 @@ type AugustConfigEntry = ConfigEntry[AugustData]
|
|||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up August from a config entry."""
|
||||
session = async_create_august_clientsession(hass)
|
||||
august_gateway = AugustGateway(hass, session)
|
||||
|
||||
august_gateway = AugustGateway(Path(hass.config.config_dir), session)
|
||||
try:
|
||||
await august_gateway.async_setup(entry.data)
|
||||
return await async_setup_august(hass, entry, august_gateway)
|
||||
except (RequireValidation, InvalidAuth) as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
|
@ -67,7 +69,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
entry.runtime_data.async_stop()
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
|
@ -75,6 +76,8 @@ async def async_setup_august(
|
|||
hass: HomeAssistant, config_entry: AugustConfigEntry, august_gateway: AugustGateway
|
||||
) -> bool:
|
||||
"""Set up the August component."""
|
||||
config = cast(YaleXSConfig, config_entry.data)
|
||||
await august_gateway.async_setup(config)
|
||||
|
||||
if CONF_PASSWORD in config_entry.data:
|
||||
# We no longer need to store passwords since we do not
|
||||
|
@ -116,7 +119,7 @@ def _async_trigger_ble_lock_discovery(
|
|||
)
|
||||
|
||||
|
||||
class AugustData(AugustSubscriberMixin):
|
||||
class AugustData(SubscriberMixin):
|
||||
"""August data object."""
|
||||
|
||||
def __init__(
|
||||
|
@ -126,17 +129,17 @@ class AugustData(AugustSubscriberMixin):
|
|||
august_gateway: AugustGateway,
|
||||
) -> None:
|
||||
"""Init August data object."""
|
||||
super().__init__(hass, MIN_TIME_BETWEEN_DETAIL_UPDATES)
|
||||
super().__init__(MIN_TIME_BETWEEN_DETAIL_UPDATES)
|
||||
self._config_entry = config_entry
|
||||
self._hass = hass
|
||||
self._august_gateway = august_gateway
|
||||
self.activity_stream: ActivityStream = None # type: ignore[assignment]
|
||||
self.activity_stream: ActivityStream = None
|
||||
self._api = august_gateway.api
|
||||
self._device_detail_by_id: dict[str, LockDetail | DoorbellDetail] = {}
|
||||
self._doorbells_by_id: dict[str, Doorbell] = {}
|
||||
self._locks_by_id: dict[str, Lock] = {}
|
||||
self._house_ids: set[str] = set()
|
||||
self._pubnub_unsub: CALLBACK_TYPE | None = None
|
||||
self._pubnub_unsub: Callable[[], Coroutine[Any, Any, None]] | None = None
|
||||
|
||||
@property
|
||||
def brand(self) -> str:
|
||||
|
@ -148,13 +151,8 @@ class AugustData(AugustSubscriberMixin):
|
|||
token = self._august_gateway.access_token
|
||||
# This used to be a gather but it was less reliable with august's recent api changes.
|
||||
user_data = await self._api.async_get_user(token)
|
||||
locks: list[Lock] = await self._api.async_get_operable_locks(token)
|
||||
doorbells: list[Doorbell] = await self._api.async_get_doorbells(token)
|
||||
if not doorbells:
|
||||
doorbells = []
|
||||
if not locks:
|
||||
locks = []
|
||||
|
||||
locks: list[Lock] = await self._api.async_get_operable_locks(token) or []
|
||||
doorbells: list[Doorbell] = await self._api.async_get_doorbells(token) or []
|
||||
self._doorbells_by_id = {device.device_id: device for device in doorbells}
|
||||
self._locks_by_id = {device.device_id: device for device in locks}
|
||||
self._house_ids = {device.house_id for device in chain(locks, doorbells)}
|
||||
|
@ -175,9 +173,14 @@ class AugustData(AugustSubscriberMixin):
|
|||
pubnub.register_device(device)
|
||||
|
||||
self.activity_stream = ActivityStream(
|
||||
self._hass, self._api, self._august_gateway, self._house_ids, pubnub
|
||||
self._api, self._august_gateway, self._house_ids, pubnub
|
||||
)
|
||||
self._config_entry.async_on_unload(
|
||||
self._hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self.async_stop)
|
||||
)
|
||||
self._config_entry.async_on_unload(self.async_stop)
|
||||
await self.activity_stream.async_setup()
|
||||
|
||||
pubnub.subscribe(self.async_pubnub_message)
|
||||
self._pubnub_unsub = async_create_pubnub(
|
||||
user_data["UserID"],
|
||||
|
@ -200,8 +203,10 @@ class AugustData(AugustSubscriberMixin):
|
|||
# awake when they come back online
|
||||
for result in await asyncio.gather(
|
||||
*[
|
||||
self.async_status_async(
|
||||
device_id, bool(detail.bridge and detail.bridge.hyper_bridge)
|
||||
create_eager_task(
|
||||
self.async_status_async(
|
||||
device_id, bool(detail.bridge and detail.bridge.hyper_bridge)
|
||||
)
|
||||
)
|
||||
for device_id, detail in self._device_detail_by_id.items()
|
||||
if device_id in self._locks_by_id
|
||||
|
@ -231,11 +236,10 @@ class AugustData(AugustSubscriberMixin):
|
|||
self.async_signal_device_id_update(device.device_id)
|
||||
activity_stream.async_schedule_house_id_refresh(device.house_id)
|
||||
|
||||
@callback
|
||||
def async_stop(self) -> None:
|
||||
async def async_stop(self, event: Event | None = None) -> None:
|
||||
"""Stop the subscriptions."""
|
||||
if self._pubnub_unsub:
|
||||
self._pubnub_unsub()
|
||||
await self._pubnub_unsub()
|
||||
self.activity_stream.async_stop()
|
||||
|
||||
@property
|
||||
|
|
|
@ -1,231 +0,0 @@
|
|||
"""Consume the august activity stream."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
import logging
|
||||
from time import monotonic
|
||||
|
||||
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
|
||||
|
||||
INITIAL_LOCK_RESYNC_TIME = 60
|
||||
|
||||
# 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 = 4
|
||||
|
||||
|
||||
@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] = {}
|
||||
self._start_time: float | None = None
|
||||
|
||||
@callback
|
||||
def _async_update_house_id_later(self, debouncer: Debouncer, _: datetime) -> None:
|
||||
"""Call a debouncer from async_call_later."""
|
||||
debouncer.async_schedule_call()
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Token refresh check and catch up the activity stream."""
|
||||
self._start_time = monotonic()
|
||||
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),
|
||||
background=True,
|
||||
)
|
||||
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 in sequence to avoid hammering the API
|
||||
for debouncer in self._update_debounce.values():
|
||||
await debouncer.async_call()
|
||||
|
||||
@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]
|
||||
debouncer.async_schedule_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.
|
||||
# Only do additional polls if we are past
|
||||
# the initial lock resync time to avoid a storm
|
||||
# of activity at setup.
|
||||
if (
|
||||
not self._start_time
|
||||
or monotonic() - self._start_time < INITIAL_LOCK_RESYNC_TIME
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Skipping additional updates due to ongoing initial lock resync time"
|
||||
)
|
||||
return
|
||||
|
||||
_LOGGER.debug("Scheduling additional updates for house id %s", house_id)
|
||||
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
|
|
@ -3,12 +3,14 @@
|
|||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
from yalexs.authenticator import ValidationResult
|
||||
from yalexs.const import BRANDS, DEFAULT_BRAND
|
||||
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
|
@ -23,7 +25,6 @@ from .const import (
|
|||
LOGIN_METHODS,
|
||||
VERIFICATION_CODE_KEY,
|
||||
)
|
||||
from .exceptions import CannotConnect, InvalidAuth, RequireValidation
|
||||
from .gateway import AugustGateway
|
||||
from .util import async_create_august_clientsession
|
||||
|
||||
|
@ -164,7 +165,9 @@ class AugustConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||
if self._august_gateway is not None:
|
||||
return self._august_gateway
|
||||
self._aiohttp_session = async_create_august_clientsession(self.hass)
|
||||
self._august_gateway = AugustGateway(self.hass, self._aiohttp_session)
|
||||
self._august_gateway = AugustGateway(
|
||||
Path(self.hass.config.config_dir), self._aiohttp_session
|
||||
)
|
||||
return self._august_gateway
|
||||
|
||||
@callback
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
"""Shared exceptions for the august integration."""
|
||||
|
||||
from homeassistant import exceptions
|
||||
|
||||
|
||||
class RequireValidation(exceptions.HomeAssistantError):
|
||||
"""Error to indicate we require validation (2fa)."""
|
||||
|
||||
|
||||
class CannotConnect(exceptions.HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(exceptions.HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
|
@ -1,56 +1,23 @@
|
|||
"""Handle August connection setup and authentication."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError, ClientResponseError, ClientSession
|
||||
from yalexs.api_async import ApiAsync
|
||||
from yalexs.authenticator_async import AuthenticationState, AuthenticatorAsync
|
||||
from yalexs.authenticator_common import Authentication
|
||||
from yalexs.const import DEFAULT_BRAND
|
||||
from yalexs.exceptions import AugustApiAIOHTTPError
|
||||
from yalexs.manager.gateway import Gateway
|
||||
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.const import CONF_USERNAME
|
||||
|
||||
from .const import (
|
||||
CONF_ACCESS_TOKEN_CACHE_FILE,
|
||||
CONF_BRAND,
|
||||
CONF_INSTALL_ID,
|
||||
CONF_LOGIN_METHOD,
|
||||
DEFAULT_AUGUST_CONFIG_FILE,
|
||||
DEFAULT_TIMEOUT,
|
||||
VERIFICATION_CODE_KEY,
|
||||
)
|
||||
from .exceptions import CannotConnect, InvalidAuth, RequireValidation
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AugustGateway:
|
||||
class AugustGateway(Gateway):
|
||||
"""Handle the connection to August."""
|
||||
|
||||
api: ApiAsync
|
||||
authenticator: AuthenticatorAsync
|
||||
authentication: Authentication
|
||||
_access_token_cache_file: str
|
||||
|
||||
def __init__(self, hass: HomeAssistant, aiohttp_session: ClientSession) -> None:
|
||||
"""Init the connection."""
|
||||
self._aiohttp_session = aiohttp_session
|
||||
self._token_refresh_lock = asyncio.Lock()
|
||||
self._hass: HomeAssistant = hass
|
||||
self._config: Mapping[str, Any] | None = None
|
||||
|
||||
@property
|
||||
def access_token(self) -> str:
|
||||
"""Access token for the api."""
|
||||
return self.authentication.access_token
|
||||
|
||||
def config_entry(self) -> dict[str, Any]:
|
||||
"""Config entry."""
|
||||
assert self._config is not None
|
||||
|
@ -61,101 +28,3 @@ class AugustGateway:
|
|||
CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID),
|
||||
CONF_ACCESS_TOKEN_CACHE_FILE: self._access_token_cache_file,
|
||||
}
|
||||
|
||||
@callback
|
||||
def async_configure_access_token_cache_file(
|
||||
self, username: str, access_token_cache_file: str | None
|
||||
) -> str:
|
||||
"""Configure the access token cache file."""
|
||||
file = access_token_cache_file or f".{username}{DEFAULT_AUGUST_CONFIG_FILE}"
|
||||
self._access_token_cache_file = file
|
||||
return self._hass.config.path(file)
|
||||
|
||||
async def async_setup(self, conf: Mapping[str, Any]) -> None:
|
||||
"""Create the api and authenticator objects."""
|
||||
if conf.get(VERIFICATION_CODE_KEY):
|
||||
return
|
||||
|
||||
access_token_cache_file_path = self.async_configure_access_token_cache_file(
|
||||
conf[CONF_USERNAME], conf.get(CONF_ACCESS_TOKEN_CACHE_FILE)
|
||||
)
|
||||
self._config = conf
|
||||
|
||||
self.api = ApiAsync(
|
||||
self._aiohttp_session,
|
||||
timeout=self._config.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
|
||||
brand=self._config.get(CONF_BRAND, DEFAULT_BRAND),
|
||||
)
|
||||
|
||||
self.authenticator = AuthenticatorAsync(
|
||||
self.api,
|
||||
self._config[CONF_LOGIN_METHOD],
|
||||
self._config[CONF_USERNAME],
|
||||
self._config.get(CONF_PASSWORD, ""),
|
||||
install_id=self._config.get(CONF_INSTALL_ID),
|
||||
access_token_cache_file=access_token_cache_file_path,
|
||||
)
|
||||
|
||||
await self.authenticator.async_setup_authentication()
|
||||
|
||||
async def async_authenticate(self) -> Authentication:
|
||||
"""Authenticate with the details provided to setup."""
|
||||
try:
|
||||
self.authentication = await self.authenticator.async_authenticate()
|
||||
if self.authentication.state == AuthenticationState.AUTHENTICATED:
|
||||
# Call the locks api to verify we are actually
|
||||
# authenticated because we can be authenticated
|
||||
# by have no access
|
||||
await self.api.async_get_operable_locks(self.access_token)
|
||||
except AugustApiAIOHTTPError as ex:
|
||||
if ex.auth_failed:
|
||||
raise InvalidAuth from ex
|
||||
raise CannotConnect from ex
|
||||
except ClientResponseError as ex:
|
||||
if ex.status == HTTPStatus.UNAUTHORIZED:
|
||||
raise InvalidAuth from ex
|
||||
|
||||
raise CannotConnect from ex
|
||||
except ClientError as ex:
|
||||
_LOGGER.error("Unable to connect to August service: %s", str(ex))
|
||||
raise CannotConnect from ex
|
||||
|
||||
if self.authentication.state == AuthenticationState.BAD_PASSWORD:
|
||||
raise InvalidAuth
|
||||
|
||||
if self.authentication.state == AuthenticationState.REQUIRES_VALIDATION:
|
||||
raise RequireValidation
|
||||
|
||||
if self.authentication.state != AuthenticationState.AUTHENTICATED:
|
||||
_LOGGER.error("Unknown authentication state: %s", self.authentication.state)
|
||||
raise InvalidAuth
|
||||
|
||||
return self.authentication
|
||||
|
||||
async def async_reset_authentication(self) -> None:
|
||||
"""Remove the cache file."""
|
||||
await self._hass.async_add_executor_job(self._reset_authentication)
|
||||
|
||||
def _reset_authentication(self) -> None:
|
||||
"""Remove the cache file."""
|
||||
path = self._hass.config.path(self._access_token_cache_file)
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
async def async_refresh_access_token_if_needed(self) -> None:
|
||||
"""Refresh the august access token if needed."""
|
||||
if not self.authenticator.should_refresh():
|
||||
return
|
||||
async with self._token_refresh_lock:
|
||||
refreshed_authentication = (
|
||||
await self.authenticator.async_refresh_access_token(force=False)
|
||||
)
|
||||
_LOGGER.info(
|
||||
(
|
||||
"Refreshed august access token. The old token expired at %s, and"
|
||||
" the new token expires at %s"
|
||||
),
|
||||
self.authentication.access_token_expires,
|
||||
refreshed_authentication.access_token_expires,
|
||||
)
|
||||
self.authentication = refreshed_authentication
|
||||
|
|
|
@ -28,5 +28,5 @@
|
|||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==3.1.0", "yalexs-ble==2.4.2"]
|
||||
"requirements": ["yalexs==5.2.0", "yalexs-ble==2.4.2"]
|
||||
}
|
||||
|
|
|
@ -1,98 +0,0 @@
|
|||
"""Base class for August entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
|
||||
class AugustSubscriberMixin:
|
||||
"""Base implementation for a subscriber."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, update_interval: timedelta) -> None:
|
||||
"""Initialize an subscriber."""
|
||||
super().__init__()
|
||||
self._hass = hass
|
||||
self._update_interval = update_interval
|
||||
self._subscriptions: dict[str, list[CALLBACK_TYPE]] = {}
|
||||
self._unsub_interval: CALLBACK_TYPE | None = None
|
||||
self._stop_interval: CALLBACK_TYPE | None = None
|
||||
|
||||
@callback
|
||||
def async_subscribe_device_id(
|
||||
self, device_id: str, update_callback: CALLBACK_TYPE
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Add an callback subscriber.
|
||||
|
||||
Returns a callable that can be used to unsubscribe.
|
||||
"""
|
||||
if not self._subscriptions:
|
||||
self._async_setup_listeners()
|
||||
|
||||
self._subscriptions.setdefault(device_id, []).append(update_callback)
|
||||
|
||||
def _unsubscribe() -> None:
|
||||
self.async_unsubscribe_device_id(device_id, update_callback)
|
||||
|
||||
return _unsubscribe
|
||||
|
||||
@abstractmethod
|
||||
async def _async_refresh(self, time: datetime) -> None:
|
||||
"""Refresh data."""
|
||||
|
||||
@callback
|
||||
def _async_scheduled_refresh(self, now: datetime) -> None:
|
||||
"""Call the refresh method."""
|
||||
self._hass.async_create_background_task(
|
||||
self._async_refresh(now), name=f"{self} schedule refresh", eager_start=True
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_cancel_update_interval(self, _: Event | None = None) -> None:
|
||||
"""Cancel the scheduled update."""
|
||||
if self._unsub_interval:
|
||||
self._unsub_interval()
|
||||
self._unsub_interval = None
|
||||
|
||||
@callback
|
||||
def _async_setup_listeners(self) -> None:
|
||||
"""Create interval and stop listeners."""
|
||||
self._async_cancel_update_interval()
|
||||
self._unsub_interval = async_track_time_interval(
|
||||
self._hass,
|
||||
self._async_scheduled_refresh,
|
||||
self._update_interval,
|
||||
name="august refresh",
|
||||
)
|
||||
|
||||
if not self._stop_interval:
|
||||
self._stop_interval = self._hass.bus.async_listen(
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
self._async_cancel_update_interval,
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_unsubscribe_device_id(
|
||||
self, device_id: str, update_callback: CALLBACK_TYPE
|
||||
) -> None:
|
||||
"""Remove a callback subscriber."""
|
||||
self._subscriptions[device_id].remove(update_callback)
|
||||
if not self._subscriptions[device_id]:
|
||||
del self._subscriptions[device_id]
|
||||
|
||||
if self._subscriptions:
|
||||
return
|
||||
self._async_cancel_update_interval()
|
||||
|
||||
@callback
|
||||
def async_signal_device_id_update(self, device_id: str) -> None:
|
||||
"""Call the callbacks for a device_id."""
|
||||
if not self._subscriptions.get(device_id):
|
||||
return
|
||||
|
||||
for update_callback in self._subscriptions[device_id]:
|
||||
update_callback()
|
|
@ -2933,7 +2933,7 @@ yalesmartalarmclient==0.3.9
|
|||
yalexs-ble==2.4.2
|
||||
|
||||
# homeassistant.components.august
|
||||
yalexs==3.1.0
|
||||
yalexs==5.2.0
|
||||
|
||||
# homeassistant.components.yeelight
|
||||
yeelight==0.7.14
|
||||
|
|
|
@ -2289,7 +2289,7 @@ yalesmartalarmclient==0.3.9
|
|||
yalexs-ble==2.4.2
|
||||
|
||||
# homeassistant.components.august
|
||||
yalexs==3.1.0
|
||||
yalexs==5.2.0
|
||||
|
||||
# homeassistant.components.yeelight
|
||||
yeelight==0.7.14
|
||||
|
|
|
@ -58,8 +58,8 @@ def _mock_authenticator(auth_state):
|
|||
return authenticator
|
||||
|
||||
|
||||
@patch("homeassistant.components.august.gateway.ApiAsync")
|
||||
@patch("homeassistant.components.august.gateway.AuthenticatorAsync.async_authenticate")
|
||||
@patch("yalexs.manager.gateway.ApiAsync")
|
||||
@patch("yalexs.manager.gateway.AuthenticatorAsync.async_authenticate")
|
||||
async def _mock_setup_august(
|
||||
hass, api_instance, pubnub_mock, authenticate_mock, api_mock, brand
|
||||
):
|
||||
|
@ -77,7 +77,10 @@ async def _mock_setup_august(
|
|||
)
|
||||
entry.add_to_hass(hass)
|
||||
with (
|
||||
patch("homeassistant.components.august.async_create_pubnub"),
|
||||
patch(
|
||||
"homeassistant.components.august.async_create_pubnub",
|
||||
return_value=AsyncMock(),
|
||||
),
|
||||
patch("homeassistant.components.august.AugustPubNub", return_value=pubnub_mock),
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from yalexs.authenticator import ValidationResult
|
||||
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.august.const import (
|
||||
|
@ -13,11 +14,6 @@ from homeassistant.components.august.const import (
|
|||
DOMAIN,
|
||||
VERIFICATION_CODE_KEY,
|
||||
)
|
||||
from homeassistant.components.august.exceptions import (
|
||||
CannotConnect,
|
||||
InvalidAuth,
|
||||
RequireValidation,
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
@ -151,7 +147,7 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None:
|
|||
side_effect=RequireValidation,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code",
|
||||
"yalexs.manager.gateway.AuthenticatorAsync.async_send_verification_code",
|
||||
return_value=True,
|
||||
) as mock_send_verification_code,
|
||||
):
|
||||
|
@ -176,11 +172,11 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None:
|
|||
side_effect=RequireValidation,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.august.gateway.AuthenticatorAsync.async_validate_verification_code",
|
||||
"yalexs.manager.gateway.AuthenticatorAsync.async_validate_verification_code",
|
||||
return_value=ValidationResult.INVALID_VERIFICATION_CODE,
|
||||
) as mock_validate_verification_code,
|
||||
patch(
|
||||
"homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code",
|
||||
"yalexs.manager.gateway.AuthenticatorAsync.async_send_verification_code",
|
||||
return_value=True,
|
||||
) as mock_send_verification_code,
|
||||
):
|
||||
|
@ -204,11 +200,11 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None:
|
|||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.august.gateway.AuthenticatorAsync.async_validate_verification_code",
|
||||
"yalexs.manager.gateway.AuthenticatorAsync.async_validate_verification_code",
|
||||
return_value=ValidationResult.VALIDATED,
|
||||
) as mock_validate_verification_code,
|
||||
patch(
|
||||
"homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code",
|
||||
"yalexs.manager.gateway.AuthenticatorAsync.async_send_verification_code",
|
||||
return_value=True,
|
||||
) as mock_send_verification_code,
|
||||
patch(
|
||||
|
@ -310,7 +306,7 @@ async def test_form_reauth_with_2fa(hass: HomeAssistant) -> None:
|
|||
side_effect=RequireValidation,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code",
|
||||
"yalexs.manager.gateway.AuthenticatorAsync.async_send_verification_code",
|
||||
return_value=True,
|
||||
) as mock_send_verification_code,
|
||||
):
|
||||
|
@ -334,11 +330,11 @@ async def test_form_reauth_with_2fa(hass: HomeAssistant) -> None:
|
|||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.august.gateway.AuthenticatorAsync.async_validate_verification_code",
|
||||
"yalexs.manager.gateway.AuthenticatorAsync.async_validate_verification_code",
|
||||
return_value=ValidationResult.VALIDATED,
|
||||
) as mock_validate_verification_code,
|
||||
patch(
|
||||
"homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code",
|
||||
"yalexs.manager.gateway.AuthenticatorAsync.async_send_verification_code",
|
||||
return_value=True,
|
||||
) as mock_send_verification_code,
|
||||
patch(
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
"""The gateway tests for the august platform."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from yalexs.authenticator_common import AuthenticationState
|
||||
|
@ -16,12 +17,10 @@ async def test_refresh_access_token(hass: HomeAssistant) -> None:
|
|||
await _patched_refresh_access_token(hass, "new_token", 5678)
|
||||
|
||||
|
||||
@patch("homeassistant.components.august.gateway.ApiAsync.async_get_operable_locks")
|
||||
@patch("homeassistant.components.august.gateway.AuthenticatorAsync.async_authenticate")
|
||||
@patch("homeassistant.components.august.gateway.AuthenticatorAsync.should_refresh")
|
||||
@patch(
|
||||
"homeassistant.components.august.gateway.AuthenticatorAsync.async_refresh_access_token"
|
||||
)
|
||||
@patch("yalexs.manager.gateway.ApiAsync.async_get_operable_locks")
|
||||
@patch("yalexs.manager.gateway.AuthenticatorAsync.async_authenticate")
|
||||
@patch("yalexs.manager.gateway.AuthenticatorAsync.should_refresh")
|
||||
@patch("yalexs.manager.gateway.AuthenticatorAsync.async_refresh_access_token")
|
||||
async def _patched_refresh_access_token(
|
||||
hass,
|
||||
new_token,
|
||||
|
@ -36,7 +35,7 @@ async def _patched_refresh_access_token(
|
|||
"original_token", 1234, AuthenticationState.AUTHENTICATED
|
||||
)
|
||||
)
|
||||
august_gateway = AugustGateway(hass, MagicMock())
|
||||
august_gateway = AugustGateway(Path(hass.config.config_dir), MagicMock())
|
||||
mocked_config = _mock_get_config()
|
||||
await august_gateway.async_setup(mocked_config[DOMAIN])
|
||||
await august_gateway.async_authenticate()
|
||||
|
|
|
@ -6,9 +6,9 @@ from unittest.mock import Mock
|
|||
from aiohttp import ClientResponseError
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from yalexs.manager.activity import INITIAL_LOCK_RESYNC_TIME
|
||||
from yalexs.pubnub_async import AugustPubNub
|
||||
|
||||
from homeassistant.components.august.activity import INITIAL_LOCK_RESYNC_TIME
|
||||
from homeassistant.components.lock import (
|
||||
DOMAIN as LOCK_DOMAIN,
|
||||
STATE_JAMMED,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue