Add support for Yale Home brand to august (#93214)

This commit is contained in:
J. Nick Koston 2023-05-20 09:42:19 -05:00 committed by GitHub
parent fa415480d6
commit 2a2b19ed7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 296 additions and 90 deletions

View File

@ -7,6 +7,7 @@ from itertools import chain
import logging import logging
from aiohttp import ClientError, ClientResponseError from aiohttp import ClientError, ClientResponseError
from yalexs.const import DEFAULT_BRAND
from yalexs.doorbell import Doorbell, DoorbellDetail from yalexs.doorbell import Doorbell, DoorbellDetail
from yalexs.exceptions import AugustApiAIOHTTPError from yalexs.exceptions import AugustApiAIOHTTPError
from yalexs.lock import Lock, LockDetail from yalexs.lock import Lock, LockDetail
@ -16,7 +17,7 @@ from yalexs_ble import YaleXSBLEDiscovery
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry
from homeassistant.const import CONF_PASSWORD from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ( from homeassistant.exceptions import (
ConfigEntryAuthFailed, ConfigEntryAuthFailed,
ConfigEntryNotReady, ConfigEntryNotReady,
@ -25,7 +26,7 @@ from homeassistant.exceptions import (
from homeassistant.helpers import device_registry as dr, discovery_flow from homeassistant.helpers import device_registry as dr, discovery_flow
from .activity import ActivityStream from .activity import ActivityStream
from .const import DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS from .const import CONF_BRAND, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS
from .exceptions import CannotConnect, InvalidAuth, RequireValidation from .exceptions import CannotConnect, InvalidAuth, RequireValidation
from .gateway import AugustGateway from .gateway import AugustGateway
from .subscriber import AugustSubscriberMixin from .subscriber import AugustSubscriberMixin
@ -122,19 +123,24 @@ def _async_trigger_ble_lock_discovery(
class AugustData(AugustSubscriberMixin): class AugustData(AugustSubscriberMixin):
"""August data object.""" """August data object."""
def __init__(self, hass, config_entry, august_gateway): def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
august_gateway: AugustGateway,
) -> None:
"""Init August data object.""" """Init August data object."""
super().__init__(hass, MIN_TIME_BETWEEN_DETAIL_UPDATES) super().__init__(hass, MIN_TIME_BETWEEN_DETAIL_UPDATES)
self._config_entry = config_entry self._config_entry = config_entry
self._hass = hass self._hass = hass
self._august_gateway = august_gateway self._august_gateway = august_gateway
self.activity_stream = None self.activity_stream: ActivityStream | None = None
self._api = august_gateway.api self._api = august_gateway.api
self._device_detail_by_id = {} self._device_detail_by_id: dict[str, LockDetail | DoorbellDetail] = {}
self._doorbells_by_id = {} self._doorbells_by_id: dict[str, Doorbell] = {}
self._locks_by_id = {} self._locks_by_id: dict[str, Lock] = {}
self._house_ids = set() self._house_ids: set[str] = set()
self._pubnub_unsub = None self._pubnub_unsub: CALLBACK_TYPE | None = None
async def async_setup(self): async def async_setup(self):
"""Async setup of august device data and activities.""" """Async setup of august device data and activities."""
@ -185,7 +191,11 @@ class AugustData(AugustSubscriberMixin):
) )
await self.activity_stream.async_setup() await self.activity_stream.async_setup()
pubnub.subscribe(self.async_pubnub_message) pubnub.subscribe(self.async_pubnub_message)
self._pubnub_unsub = async_create_pubnub(user_data["UserID"], pubnub) self._pubnub_unsub = async_create_pubnub(
user_data["UserID"],
pubnub,
self._config_entry.data.get(CONF_BRAND, DEFAULT_BRAND),
)
if self._locks_by_id: if self._locks_by_id:
# Do not prevent setup as the sync can timeout # Do not prevent setup as the sync can timeout

View File

@ -50,6 +50,7 @@ def _retrieve_online_state(data: AugustData, detail: DoorbellDetail) -> bool:
def _retrieve_motion_state(data: AugustData, detail: DoorbellDetail) -> bool: def _retrieve_motion_state(data: AugustData, detail: DoorbellDetail) -> bool:
assert data.activity_stream is not None
latest = data.activity_stream.get_latest_device_activity( latest = data.activity_stream.get_latest_device_activity(
detail.device_id, {ActivityType.DOORBELL_MOTION} detail.device_id, {ActivityType.DOORBELL_MOTION}
) )
@ -61,6 +62,7 @@ def _retrieve_motion_state(data: AugustData, detail: DoorbellDetail) -> bool:
def _retrieve_image_capture_state(data: AugustData, detail: DoorbellDetail) -> bool: def _retrieve_image_capture_state(data: AugustData, detail: DoorbellDetail) -> bool:
assert data.activity_stream is not None
latest = data.activity_stream.get_latest_device_activity( latest = data.activity_stream.get_latest_device_activity(
detail.device_id, {ActivityType.DOORBELL_IMAGE_CAPTURE} detail.device_id, {ActivityType.DOORBELL_IMAGE_CAPTURE}
) )
@ -72,6 +74,7 @@ def _retrieve_image_capture_state(data: AugustData, detail: DoorbellDetail) -> b
def _retrieve_ding_state(data: AugustData, detail: DoorbellDetail) -> bool: def _retrieve_ding_state(data: AugustData, detail: DoorbellDetail) -> bool:
assert data.activity_stream is not None
latest = data.activity_stream.get_latest_device_activity( latest = data.activity_stream.get_latest_device_activity(
detail.device_id, {ActivityType.DOORBELL_DING} detail.device_id, {ActivityType.DOORBELL_DING}
) )
@ -211,6 +214,7 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity):
@callback @callback
def _update_from_data(self): def _update_from_data(self):
"""Get the latest state of the sensor and update activity.""" """Get the latest state of the sensor and update activity."""
assert self._data.activity_stream is not None
door_activity = self._data.activity_stream.get_latest_device_activity( door_activity = self._data.activity_stream.get_latest_device_activity(
self._device_id, {ActivityType.DOOR_OPERATION} self._device_id, {ActivityType.DOOR_OPERATION}
) )

View File

@ -1,33 +1,45 @@
"""Config flow for August integration.""" """Config flow for August integration."""
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass
import logging import logging
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
from yalexs.authenticator import ValidationResult from yalexs.authenticator import ValidationResult
from yalexs.const import BRANDS, DEFAULT_BRAND
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from .const import CONF_LOGIN_METHOD, DOMAIN, LOGIN_METHODS, VERIFICATION_CODE_KEY from .const import (
CONF_ACCESS_TOKEN_CACHE_FILE,
CONF_BRAND,
CONF_LOGIN_METHOD,
DEFAULT_LOGIN_METHOD,
DOMAIN,
LOGIN_METHODS,
VERIFICATION_CODE_KEY,
)
from .exceptions import CannotConnect, InvalidAuth, RequireValidation from .exceptions import CannotConnect, InvalidAuth, RequireValidation
from .gateway import AugustGateway from .gateway import AugustGateway
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def async_validate_input(data, august_gateway): async def async_validate_input(
data: dict[str, Any], august_gateway: AugustGateway
) -> dict[str, Any]:
"""Validate the user input allows us to connect. """Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user. Data has the keys from DATA_SCHEMA with values provided by the user.
Request configuration steps from the user. Request configuration steps from the user.
""" """
assert august_gateway.authenticator is not None
authenticator = august_gateway.authenticator
if (code := data.get(VERIFICATION_CODE_KEY)) is not None: if (code := data.get(VERIFICATION_CODE_KEY)) is not None:
result = await august_gateway.authenticator.async_validate_verification_code( result = await authenticator.async_validate_verification_code(code)
code
)
_LOGGER.debug("Verification code validation: %s", result) _LOGGER.debug("Verification code validation: %s", result)
if result != ValidationResult.VALIDATED: if result != ValidationResult.VALIDATED:
raise RequireValidation raise RequireValidation
@ -50,6 +62,16 @@ async def async_validate_input(data, august_gateway):
} }
@dataclass
class ValidateResult:
"""Result from validation."""
validation_required: bool
info: dict[str, Any]
errors: dict[str, str]
description_placeholders: dict[str, str]
class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for August.""" """Handle a config flow for August."""
@ -57,9 +79,9 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def __init__(self): def __init__(self):
"""Store an AugustGateway().""" """Store an AugustGateway()."""
self._august_gateway = None self._august_gateway: AugustGateway | None = None
self._user_auth_details = {} self._user_auth_details: dict[str, Any] = {}
self._needs_reset = False self._needs_reset = True
self._mode = None self._mode = None
super().__init__() super().__init__()
@ -70,19 +92,30 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user_validate(self, user_input=None): async def async_step_user_validate(self, user_input=None):
"""Handle authentication.""" """Handle authentication."""
errors = {} errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
if user_input is not None: if user_input is not None:
result = await self._async_auth_or_validate(user_input, errors) self._user_auth_details.update(user_input)
if result is not None: validate_result = await self._async_auth_or_validate()
return result description_placeholders = validate_result.description_placeholders
if validate_result.validation_required:
return await self.async_step_validation()
if not (errors := validate_result.errors):
return await self._async_update_or_create_entry(validate_result.info)
return self.async_show_form( return self.async_show_form(
step_id="user_validate", step_id="user_validate",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(
CONF_BRAND,
default=self._user_auth_details.get(CONF_BRAND, DEFAULT_BRAND),
): vol.In(BRANDS),
vol.Required( vol.Required(
CONF_LOGIN_METHOD, CONF_LOGIN_METHOD,
default=self._user_auth_details.get(CONF_LOGIN_METHOD, "phone"), default=self._user_auth_details.get(
CONF_LOGIN_METHOD, DEFAULT_LOGIN_METHOD
),
): vol.In(LOGIN_METHODS), ): vol.In(LOGIN_METHODS),
vol.Required( vol.Required(
CONF_USERNAME, CONF_USERNAME,
@ -92,21 +125,27 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
} }
), ),
errors=errors, errors=errors,
description_placeholders=description_placeholders,
) )
async def async_step_validation(self, user_input=None): async def async_step_validation(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle validation (2fa) step.""" """Handle validation (2fa) step."""
if user_input: if user_input:
if self._mode == "reauth": if self._mode == "reauth":
return await self.async_step_reauth_validate(user_input) return await self.async_step_reauth_validate(user_input)
return await self.async_step_user_validate(user_input) return await self.async_step_user_validate(user_input)
previously_failed = VERIFICATION_CODE_KEY in self._user_auth_details
return self.async_show_form( return self.async_show_form(
step_id="validation", step_id="validation",
data_schema=vol.Schema( data_schema=vol.Schema(
{vol.Required(VERIFICATION_CODE_KEY): vol.All(str, vol.Strip)} {vol.Required(VERIFICATION_CODE_KEY): vol.All(str, vol.Strip)}
), ),
errors={"base": "invalid_verification_code"} if previously_failed else None,
description_placeholders={ description_placeholders={
CONF_BRAND: self._user_auth_details[CONF_BRAND],
CONF_USERNAME: self._user_auth_details[CONF_USERNAME], CONF_USERNAME: self._user_auth_details[CONF_USERNAME],
CONF_LOGIN_METHOD: self._user_auth_details[CONF_LOGIN_METHOD], CONF_LOGIN_METHOD: self._user_auth_details[CONF_LOGIN_METHOD],
}, },
@ -122,49 +161,84 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_reauth_validate(self, user_input=None): async def async_step_reauth_validate(self, user_input=None):
"""Handle reauth and validation.""" """Handle reauth and validation."""
errors = {} errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
if user_input is not None: if user_input is not None:
result = await self._async_auth_or_validate(user_input, errors) self._user_auth_details.update(user_input)
if result is not None: validate_result = await self._async_auth_or_validate()
return result description_placeholders = validate_result.description_placeholders
if validate_result.validation_required:
return await self.async_step_validation()
if not (errors := validate_result.errors):
return await self._async_update_or_create_entry(validate_result.info)
return self.async_show_form( return self.async_show_form(
step_id="reauth_validate", step_id="reauth_validate",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(
CONF_BRAND,
default=self._user_auth_details.get(CONF_BRAND, DEFAULT_BRAND),
): vol.In(BRANDS),
vol.Required(CONF_PASSWORD): str, vol.Required(CONF_PASSWORD): str,
} }
), ),
errors=errors, errors=errors,
description_placeholders={ description_placeholders=description_placeholders
| {
CONF_USERNAME: self._user_auth_details[CONF_USERNAME], CONF_USERNAME: self._user_auth_details[CONF_USERNAME],
}, },
) )
async def _async_auth_or_validate(self, user_input, errors): async def _async_reset_access_token_cache_if_needed(
self._user_auth_details.update(user_input) self, gateway: AugustGateway, username: str, access_token_cache_file: str | None
await self._august_gateway.async_setup(self._user_auth_details) ) -> None:
"""Reset the access token cache if needed."""
# We need to configure the access token cache file before we setup the gateway
# since we need to reset it if the brand changes BEFORE we setup the gateway
gateway.async_configure_access_token_cache_file(
username, access_token_cache_file
)
if self._needs_reset: if self._needs_reset:
self._needs_reset = False self._needs_reset = False
await self._august_gateway.async_reset_authentication() await gateway.async_reset_authentication()
try:
info = await async_validate_input( async def _async_auth_or_validate(self) -> ValidateResult:
self._user_auth_details, """Authenticate or validate."""
self._august_gateway, user_auth_details = self._user_auth_details
gateway = self._august_gateway
assert gateway is not None
await self._async_reset_access_token_cache_if_needed(
gateway,
user_auth_details[CONF_USERNAME],
user_auth_details.get(CONF_ACCESS_TOKEN_CACHE_FILE),
) )
await gateway.async_setup(user_auth_details)
errors: dict[str, str] = {}
info: dict[str, Any] = {}
description_placeholders: dict[str, str] = {}
validation_required = False
try:
info = await async_validate_input(user_auth_details, gateway)
except CannotConnect: except CannotConnect:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidAuth: except InvalidAuth:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except RequireValidation: except RequireValidation:
return await self.async_step_validation() validation_required = True
except Exception: # pylint: disable=broad-except except Exception as ex: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unhandled"
description_placeholders = {"error": str(ex)}
if errors: return ValidateResult(
return None validation_required, info, errors, description_placeholders
)
async def _async_update_or_create_entry(self, info: dict[str, Any]) -> FlowResult:
"""Update existing entry or create a new one."""
existing_entry = await self.async_set_unique_id( existing_entry = await self.async_set_unique_id(
self._user_auth_details[CONF_USERNAME] self._user_auth_details[CONF_USERNAME]
) )

View File

@ -7,6 +7,7 @@ from homeassistant.const import Platform
DEFAULT_TIMEOUT = 25 DEFAULT_TIMEOUT = 25
CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file" CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file"
CONF_BRAND = "brand"
CONF_LOGIN_METHOD = "login_method" CONF_LOGIN_METHOD = "login_method"
CONF_INSTALL_ID = "install_id" CONF_INSTALL_ID = "install_id"
@ -42,6 +43,7 @@ MIN_TIME_BETWEEN_DETAIL_UPDATES = timedelta(hours=1)
ACTIVITY_UPDATE_INTERVAL = timedelta(seconds=10) ACTIVITY_UPDATE_INTERVAL = timedelta(seconds=10)
LOGIN_METHODS = ["phone", "email"] LOGIN_METHODS = ["phone", "email"]
DEFAULT_LOGIN_METHOD = "email"
PLATFORMS = [ PLATFORMS = [
Platform.BUTTON, Platform.BUTTON,

View File

@ -3,12 +3,14 @@ from __future__ import annotations
from typing import Any from typing import Any
from yalexs.const import DEFAULT_BRAND
from homeassistant.components.diagnostics import async_redact_data from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import AugustData from . import AugustData
from .const import DOMAIN from .const import CONF_BRAND, DOMAIN
TO_REDACT = { TO_REDACT = {
"HouseID", "HouseID",
@ -44,4 +46,5 @@ async def async_get_config_entry_diagnostics(
) )
for doorbell in data.doorbells for doorbell in data.doorbells
}, },
"brand": entry.data.get(CONF_BRAND, DEFAULT_BRAND),
} }

View File

@ -1,19 +1,26 @@
"""Handle August connection setup and authentication.""" """Handle August connection setup and authentication."""
import asyncio import asyncio
from collections.abc import Mapping
from http import HTTPStatus from http import HTTPStatus
import logging import logging
import os import os
from typing import Any
from aiohttp import ClientError, ClientResponseError from aiohttp import ClientError, ClientResponseError
from yalexs.api_async import ApiAsync from yalexs.api_async import ApiAsync
from yalexs.authenticator_async import AuthenticationState, AuthenticatorAsync 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 homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
from .const import ( from .const import (
CONF_ACCESS_TOKEN_CACHE_FILE, CONF_ACCESS_TOKEN_CACHE_FILE,
CONF_BRAND,
CONF_INSTALL_ID, CONF_INSTALL_ID,
CONF_LOGIN_METHOD, CONF_LOGIN_METHOD,
DEFAULT_AUGUST_CONFIG_FILE, DEFAULT_AUGUST_CONFIG_FILE,
@ -28,48 +35,59 @@ _LOGGER = logging.getLogger(__name__)
class AugustGateway: class AugustGateway:
"""Handle the connection to August.""" """Handle the connection to August."""
def __init__(self, hass): def __init__(self, hass: HomeAssistant) -> None:
"""Init the connection.""" """Init the connection."""
# Create an aiohttp session instead of using the default one since the # Create an aiohttp session instead of using the default one since the
# default one is likely to trigger august's WAF if another integration # default one is likely to trigger august's WAF if another integration
# is also using Cloudflare # is also using Cloudflare
self._aiohttp_session = aiohttp_client.async_create_clientsession(hass) self._aiohttp_session = aiohttp_client.async_create_clientsession(hass)
self._token_refresh_lock = asyncio.Lock() self._token_refresh_lock = asyncio.Lock()
self._access_token_cache_file = None self._access_token_cache_file: str | None = None
self._hass = hass self._hass: HomeAssistant = hass
self._config = None self._config: Mapping[str, Any] | None = None
self.api = None self.api: ApiAsync | None = None
self.authenticator = None self.authenticator: AuthenticatorAsync | None = None
self.authentication = None self.authentication: Authentication | None = None
@property @property
def access_token(self): def access_token(self):
"""Access token for the api.""" """Access token for the api."""
return self.authentication.access_token return self.authentication.access_token
def config_entry(self): def config_entry(self) -> dict[str, Any]:
"""Config entry.""" """Config entry."""
assert self._config is not None
return { return {
CONF_BRAND: self._config.get(CONF_BRAND, DEFAULT_BRAND),
CONF_LOGIN_METHOD: self._config[CONF_LOGIN_METHOD], CONF_LOGIN_METHOD: self._config[CONF_LOGIN_METHOD],
CONF_USERNAME: self._config[CONF_USERNAME], CONF_USERNAME: self._config[CONF_USERNAME],
CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID), CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID),
CONF_ACCESS_TOKEN_CACHE_FILE: self._access_token_cache_file, CONF_ACCESS_TOKEN_CACHE_FILE: self._access_token_cache_file,
} }
async def async_setup(self, conf): @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.""" """Create the api and authenticator objects."""
if conf.get(VERIFICATION_CODE_KEY): if conf.get(VERIFICATION_CODE_KEY):
return return
self._access_token_cache_file = conf.get( access_token_cache_file_path = self.async_configure_access_token_cache_file(
CONF_ACCESS_TOKEN_CACHE_FILE, conf[CONF_USERNAME], conf.get(CONF_ACCESS_TOKEN_CACHE_FILE)
f".{conf[CONF_USERNAME]}{DEFAULT_AUGUST_CONFIG_FILE}",
) )
self._config = conf self._config = conf
self.api = ApiAsync( self.api = ApiAsync(
self._aiohttp_session, self._aiohttp_session,
timeout=self._config.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), timeout=self._config.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
brand=self._config.get(CONF_BRAND, DEFAULT_BRAND),
) )
self.authenticator = AuthenticatorAsync( self.authenticator = AuthenticatorAsync(
@ -78,9 +96,7 @@ class AugustGateway:
self._config[CONF_USERNAME], self._config[CONF_USERNAME],
self._config.get(CONF_PASSWORD, ""), self._config.get(CONF_PASSWORD, ""),
install_id=self._config.get(CONF_INSTALL_ID), install_id=self._config.get(CONF_INSTALL_ID),
access_token_cache_file=self._hass.config.path( access_token_cache_file=access_token_cache_file_path,
self._access_token_cache_file
),
) )
await self.authenticator.async_setup_authentication() await self.authenticator.async_setup_authentication()
@ -95,6 +111,10 @@ class AugustGateway:
# authenticated because we can be authenticated # authenticated because we can be authenticated
# by have no access # by have no access
await self.api.async_get_operable_locks(self.access_token) 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: except ClientResponseError as ex:
if ex.status == HTTPStatus.UNAUTHORIZED: if ex.status == HTTPStatus.UNAUTHORIZED:
raise InvalidAuth from ex raise InvalidAuth from ex
@ -122,8 +142,9 @@ class AugustGateway:
def _reset_authentication(self): def _reset_authentication(self):
"""Remove the cache file.""" """Remove the cache file."""
if os.path.exists(self._access_token_cache_file): path = self._hass.config.path(self._access_token_cache_file)
os.unlink(self._access_token_cache_file) if os.path.exists(path):
os.unlink(path)
async def async_refresh_access_token_if_needed(self): async def async_refresh_access_token_if_needed(self):
"""Refresh the august access token if needed.""" """Refresh the august access token if needed."""

View File

@ -47,6 +47,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity):
async def async_lock(self, **kwargs: Any) -> None: async def async_lock(self, **kwargs: Any) -> None:
"""Lock the device.""" """Lock the device."""
assert self._data.activity_stream is not None
if self._data.activity_stream.pubnub.connected: if self._data.activity_stream.pubnub.connected:
await self._data.async_lock_async(self._device_id, self._hyper_bridge) await self._data.async_lock_async(self._device_id, self._hyper_bridge)
return return
@ -54,6 +55,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity):
async def async_unlock(self, **kwargs: Any) -> None: async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the device.""" """Unlock the device."""
assert self._data.activity_stream is not None
if self._data.activity_stream.pubnub.connected: if self._data.activity_stream.pubnub.connected:
await self._data.async_unlock_async(self._device_id, self._hyper_bridge) await self._data.async_unlock_async(self._device_id, self._hyper_bridge)
return return

View File

@ -28,5 +28,5 @@
"documentation": "https://www.home-assistant.io/integrations/august", "documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"], "loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==1.3.3", "yalexs-ble==2.1.17"] "requirements": ["yalexs==1.4.6", "yalexs-ble==2.1.17"]
} }

View File

@ -1,7 +1,8 @@
{ {
"config": { "config": {
"error": { "error": {
"unknown": "[%key:common::config_flow::error::unknown%]", "unhandled": "Unhandled error: {error}",
"invalid_verification_code": "Invalid verification code",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
}, },
@ -15,20 +16,22 @@
"data": { "data": {
"code": "Verification code" "code": "Verification code"
}, },
"description": "Please check your {login_method} ({username}) and enter the verification code below" "description": "Please check your {login_method} ({username}) and enter the verification code below. Codes may take a few minutes to arrive."
}, },
"user_validate": { "user_validate": {
"description": "If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'.", "description": "It is recommended to use the 'email' login method as some brands may not work with the 'phone' method. If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'. If you choose the wrong brand, you may be able to authenticate initially; however, you will not be able to operate devices. If you are unsure of the brand, create the integration again and try another brand.",
"data": { "data": {
"password": "[%key:common::config_flow::data::password%]", "brand": "Brand",
"login_method": "Login Method",
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"login_method": "Login Method" "password": "[%key:common::config_flow::data::password%]"
}, },
"title": "Set up an August account" "title": "Set up an August account"
}, },
"reauth_validate": { "reauth_validate": {
"description": "Enter the password for {username}.", "description": "Choose the correct brand for your device, and enter the password for {username}. If you choose the wrong brand, you may be able to authenticate initially; however, you will not be able to operate devices. If you are unsure of the brand, create the integration again and try another brand.",
"data": { "data": {
"brand": "[%key:component::august::config::step::user_validate::data::brand%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
}, },
"title": "Reauthenticate an August account" "title": "Reauthenticate an August account"

View File

@ -2697,7 +2697,7 @@ yalesmartalarmclient==0.3.9
yalexs-ble==2.1.17 yalexs-ble==2.1.17
# homeassistant.components.august # homeassistant.components.august
yalexs==1.3.3 yalexs==1.4.6
# homeassistant.components.yeelight # homeassistant.components.yeelight
yeelight==0.7.10 yeelight==0.7.10

View File

@ -1964,7 +1964,7 @@ yalesmartalarmclient==0.3.9
yalexs-ble==2.1.17 yalexs-ble==2.1.17
# homeassistant.components.august # homeassistant.components.august
yalexs==1.3.3 yalexs==1.4.6
# homeassistant.components.yeelight # homeassistant.components.yeelight
yeelight==0.7.10 yeelight==0.7.10

View File

@ -6,6 +6,7 @@ from yalexs.authenticator import ValidationResult
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.august.const import ( from homeassistant.components.august.const import (
CONF_ACCESS_TOKEN_CACHE_FILE, CONF_ACCESS_TOKEN_CACHE_FILE,
CONF_BRAND,
CONF_INSTALL_ID, CONF_INSTALL_ID,
CONF_LOGIN_METHOD, CONF_LOGIN_METHOD,
DOMAIN, DOMAIN,
@ -18,6 +19,7 @@ from homeassistant.components.august.exceptions import (
) )
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -28,7 +30,7 @@ async def test_form(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
assert result["type"] == "form" assert result["type"] is FlowResultType.FORM
assert result["errors"] == {} assert result["errors"] == {}
with patch( with patch(
@ -41,6 +43,7 @@ async def test_form(hass: HomeAssistant) -> None:
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
CONF_BRAND: "august",
CONF_LOGIN_METHOD: "email", CONF_LOGIN_METHOD: "email",
CONF_USERNAME: "my@email.tld", CONF_USERNAME: "my@email.tld",
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
@ -48,9 +51,10 @@ async def test_form(hass: HomeAssistant) -> None:
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert result2["type"] == "create_entry" assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "my@email.tld" assert result2["title"] == "my@email.tld"
assert result2["data"] == { assert result2["data"] == {
CONF_BRAND: "august",
CONF_LOGIN_METHOD: "email", CONF_LOGIN_METHOD: "email",
CONF_USERNAME: "my@email.tld", CONF_USERNAME: "my@email.tld",
CONF_INSTALL_ID: None, CONF_INSTALL_ID: None,
@ -72,13 +76,14 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None:
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
CONF_BRAND: "august",
CONF_LOGIN_METHOD: "email", CONF_LOGIN_METHOD: "email",
CONF_USERNAME: "my@email.tld", CONF_USERNAME: "my@email.tld",
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
}, },
) )
assert result2["type"] == "form" assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"} assert result2["errors"] == {"base": "invalid_auth"}
@ -90,19 +95,21 @@ async def test_user_unexpected_exception(hass: HomeAssistant) -> None:
with patch( with patch(
"homeassistant.components.august.config_flow.AugustGateway.async_authenticate", "homeassistant.components.august.config_flow.AugustGateway.async_authenticate",
side_effect=ValueError, side_effect=ValueError("something exploded"),
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
CONF_BRAND: "august",
CONF_LOGIN_METHOD: "email", CONF_LOGIN_METHOD: "email",
CONF_USERNAME: "my@email.tld", CONF_USERNAME: "my@email.tld",
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
}, },
) )
assert result2["type"] == "form" assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "unknown"} assert result2["errors"] == {"base": "unhandled"}
assert result2["description_placeholders"] == {"error": "something exploded"}
async def test_form_cannot_connect(hass: HomeAssistant) -> None: async def test_form_cannot_connect(hass: HomeAssistant) -> None:
@ -124,7 +131,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
}, },
) )
assert result2["type"] == "form" assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"} assert result2["errors"] == {"base": "cannot_connect"}
@ -151,7 +158,7 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None:
) )
assert len(mock_send_verification_code.mock_calls) == 1 assert len(mock_send_verification_code.mock_calls) == 1
assert result2["type"] == "form" assert result2["type"] is FlowResultType.FORM
assert result2["errors"] is None assert result2["errors"] is None
assert result2["step_id"] == "validation" assert result2["step_id"] == "validation"
@ -165,9 +172,7 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None:
) as mock_validate_verification_code, patch( ) as mock_validate_verification_code, patch(
"homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code", "homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code",
return_value=True, return_value=True,
) as mock_send_verification_code, patch( ) as mock_send_verification_code:
"homeassistant.components.august.async_setup_entry", return_value=True
) as mock_setup_entry:
result3 = await hass.config_entries.flow.async_configure( result3 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{VERIFICATION_CODE_KEY: "incorrect"}, {VERIFICATION_CODE_KEY: "incorrect"},
@ -177,8 +182,8 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None:
# so they have a chance to retry # so they have a chance to retry
assert len(mock_send_verification_code.mock_calls) == 0 assert len(mock_send_verification_code.mock_calls) == 0
assert len(mock_validate_verification_code.mock_calls) == 1 assert len(mock_validate_verification_code.mock_calls) == 1
assert result3["type"] == "form" assert result3["type"] is FlowResultType.FORM
assert result3["errors"] is None assert result3["errors"] == {"base": "invalid_verification_code"}
assert result3["step_id"] == "validation" assert result3["step_id"] == "validation"
# Try with the CORRECT verification code and we setup # Try with the CORRECT verification code and we setup
@ -202,9 +207,10 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None:
assert len(mock_send_verification_code.mock_calls) == 0 assert len(mock_send_verification_code.mock_calls) == 0
assert len(mock_validate_verification_code.mock_calls) == 1 assert len(mock_validate_verification_code.mock_calls) == 1
assert result4["type"] == "create_entry" assert result4["type"] is FlowResultType.CREATE_ENTRY
assert result4["title"] == "my@email.tld" assert result4["title"] == "my@email.tld"
assert result4["data"] == { assert result4["data"] == {
CONF_BRAND: "august",
CONF_LOGIN_METHOD: "email", CONF_LOGIN_METHOD: "email",
CONF_USERNAME: "my@email.tld", CONF_USERNAME: "my@email.tld",
CONF_INSTALL_ID: None, CONF_INSTALL_ID: None,
@ -233,7 +239,7 @@ async def test_form_reauth(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=entry.data DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=entry.data
) )
assert result["type"] == "form" assert result["type"] is FlowResultType.FORM
assert result["errors"] == {} assert result["errors"] == {}
with patch( with patch(
@ -251,7 +257,7 @@ async def test_form_reauth(hass: HomeAssistant) -> None:
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert result2["type"] == "abort" assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "reauth_successful" assert result2["reason"] == "reauth_successful"
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@ -276,7 +282,7 @@ async def test_form_reauth_with_2fa(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=entry.data DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=entry.data
) )
assert result["type"] == "form" assert result["type"] is FlowResultType.FORM
assert result["errors"] == {} assert result["errors"] == {}
with patch( with patch(
@ -295,7 +301,7 @@ async def test_form_reauth_with_2fa(hass: HomeAssistant) -> None:
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_send_verification_code.mock_calls) == 1 assert len(mock_send_verification_code.mock_calls) == 1
assert result2["type"] == "form" assert result2["type"] is FlowResultType.FORM
assert result2["errors"] is None assert result2["errors"] is None
assert result2["step_id"] == "validation" assert result2["step_id"] == "validation"
@ -320,6 +326,52 @@ async def test_form_reauth_with_2fa(hass: HomeAssistant) -> None:
assert len(mock_validate_verification_code.mock_calls) == 1 assert len(mock_validate_verification_code.mock_calls) == 1
assert len(mock_send_verification_code.mock_calls) == 0 assert len(mock_send_verification_code.mock_calls) == 0
assert result3["type"] == "abort" assert result3["type"] is FlowResultType.ABORT
assert result3["reason"] == "reauth_successful" assert result3["reason"] == "reauth_successful"
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
async def test_switching_brands(hass: HomeAssistant) -> None:
"""Test brands can be switched by setting up again."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_LOGIN_METHOD: "email",
CONF_USERNAME: "my@email.tld",
CONF_PASSWORD: "test-password",
CONF_INSTALL_ID: None,
CONF_TIMEOUT: 10,
CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf",
},
unique_id="my@email.tld",
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
with patch(
"homeassistant.components.august.config_flow.AugustGateway.async_authenticate",
return_value=True,
), patch(
"homeassistant.components.august.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_BRAND: "yale_home",
CONF_LOGIN_METHOD: "email",
CONF_USERNAME: "my@email.tld",
CONF_PASSWORD: "test-password",
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "reauth_successful"
assert len(mock_setup_entry.mock_calls) == 1
assert entry.data[CONF_BRAND] == "yale_home"

View File

@ -141,4 +141,5 @@ async def test_diagnostics(
"zWaveEnabled": False, "zWaveEnabled": False,
} }
}, },
"brand": "august",
} }

View File

@ -77,12 +77,42 @@ async def test_august_is_offline(hass: HomeAssistant) -> None:
assert config_entry.state is ConfigEntryState.SETUP_RETRY assert config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_august_late_auth_failure(hass: HomeAssistant) -> None:
"""Test we can detect a late auth failure."""
aiohttp_client_response_exception = ClientResponseError(None, None, status=401)
config_entry = MockConfigEntry(
domain=DOMAIN,
data=_mock_get_config()[DOMAIN],
title="August august",
)
config_entry.add_to_hass(hass)
with patch(
"yalexs.authenticator_async.AuthenticatorAsync.async_authenticate",
side_effect=AugustApiAIOHTTPError(
"This should bubble up as its user consumable",
aiohttp_client_response_exception,
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_ERROR
flows = hass.config_entries.flow.async_progress()
assert flows[0]["step_id"] == "reauth_validate"
async def test_unlock_throws_august_api_http_error(hass: HomeAssistant) -> None: async def test_unlock_throws_august_api_http_error(hass: HomeAssistant) -> None:
"""Test unlock throws correct error on http error.""" """Test unlock throws correct error on http error."""
mocked_lock_detail = await _mock_operative_august_lock_detail(hass) mocked_lock_detail = await _mock_operative_august_lock_detail(hass)
aiohttp_client_response_exception = ClientResponseError(None, None, status=400)
def _unlock_return_activities_side_effect(access_token, device_id): def _unlock_return_activities_side_effect(access_token, device_id):
raise AugustApiAIOHTTPError("This should bubble up as its user consumable") raise AugustApiAIOHTTPError(
"This should bubble up as its user consumable",
aiohttp_client_response_exception,
)
await _create_august_with_devices( await _create_august_with_devices(
hass, hass,
@ -106,9 +136,13 @@ async def test_unlock_throws_august_api_http_error(hass: HomeAssistant) -> None:
async def test_lock_throws_august_api_http_error(hass: HomeAssistant) -> None: async def test_lock_throws_august_api_http_error(hass: HomeAssistant) -> None:
"""Test lock throws correct error on http error.""" """Test lock throws correct error on http error."""
mocked_lock_detail = await _mock_operative_august_lock_detail(hass) mocked_lock_detail = await _mock_operative_august_lock_detail(hass)
aiohttp_client_response_exception = ClientResponseError(None, None, status=400)
def _lock_return_activities_side_effect(access_token, device_id): def _lock_return_activities_side_effect(access_token, device_id):
raise AugustApiAIOHTTPError("This should bubble up as its user consumable") raise AugustApiAIOHTTPError(
"This should bubble up as its user consumable",
aiohttp_client_response_exception,
)
await _create_august_with_devices( await _create_august_with_devices(
hass, hass,