Add support for Yale Home brand to august (#93214)
This commit is contained in:
parent
fa415480d6
commit
2a2b19ed7c
14 changed files with 296 additions and 90 deletions
|
@ -7,6 +7,7 @@ from itertools import chain
|
|||
import logging
|
||||
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
from yalexs.const import DEFAULT_BRAND
|
||||
from yalexs.doorbell import Doorbell, DoorbellDetail
|
||||
from yalexs.exceptions import AugustApiAIOHTTPError
|
||||
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.const import CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
|
@ -25,7 +26,7 @@ from homeassistant.exceptions import (
|
|||
from homeassistant.helpers import device_registry as dr, discovery_flow
|
||||
|
||||
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 .gateway import AugustGateway
|
||||
from .subscriber import AugustSubscriberMixin
|
||||
|
@ -122,19 +123,24 @@ def _async_trigger_ble_lock_discovery(
|
|||
class AugustData(AugustSubscriberMixin):
|
||||
"""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."""
|
||||
super().__init__(hass, MIN_TIME_BETWEEN_DETAIL_UPDATES)
|
||||
self._config_entry = config_entry
|
||||
self._hass = hass
|
||||
self._august_gateway = august_gateway
|
||||
self.activity_stream = None
|
||||
self.activity_stream: ActivityStream | None = None
|
||||
self._api = august_gateway.api
|
||||
self._device_detail_by_id = {}
|
||||
self._doorbells_by_id = {}
|
||||
self._locks_by_id = {}
|
||||
self._house_ids = set()
|
||||
self._pubnub_unsub = None
|
||||
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
|
||||
|
||||
async def async_setup(self):
|
||||
"""Async setup of august device data and activities."""
|
||||
|
@ -185,7 +191,11 @@ class AugustData(AugustSubscriberMixin):
|
|||
)
|
||||
await self.activity_stream.async_setup()
|
||||
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:
|
||||
# Do not prevent setup as the sync can timeout
|
||||
|
|
|
@ -50,6 +50,7 @@ def _retrieve_online_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(
|
||||
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:
|
||||
assert data.activity_stream is not None
|
||||
latest = data.activity_stream.get_latest_device_activity(
|
||||
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:
|
||||
assert data.activity_stream is not None
|
||||
latest = data.activity_stream.get_latest_device_activity(
|
||||
detail.device_id, {ActivityType.DOORBELL_DING}
|
||||
)
|
||||
|
@ -211,6 +214,7 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity):
|
|||
@callback
|
||||
def _update_from_data(self):
|
||||
"""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(
|
||||
self._device_id, {ActivityType.DOOR_OPERATION}
|
||||
)
|
||||
|
|
|
@ -1,33 +1,45 @@
|
|||
"""Config flow for August integration."""
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from yalexs.authenticator import ValidationResult
|
||||
from yalexs.const import BRANDS, DEFAULT_BRAND
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
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 .gateway import AugustGateway
|
||||
|
||||
_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.
|
||||
|
||||
Data has the keys from DATA_SCHEMA with values provided by 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:
|
||||
result = await august_gateway.authenticator.async_validate_verification_code(
|
||||
code
|
||||
)
|
||||
result = await authenticator.async_validate_verification_code(code)
|
||||
_LOGGER.debug("Verification code validation: %s", result)
|
||||
if result != ValidationResult.VALIDATED:
|
||||
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):
|
||||
"""Handle a config flow for August."""
|
||||
|
||||
|
@ -57,9 +79,9 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
|
||||
def __init__(self):
|
||||
"""Store an AugustGateway()."""
|
||||
self._august_gateway = None
|
||||
self._user_auth_details = {}
|
||||
self._needs_reset = False
|
||||
self._august_gateway: AugustGateway | None = None
|
||||
self._user_auth_details: dict[str, Any] = {}
|
||||
self._needs_reset = True
|
||||
self._mode = None
|
||||
super().__init__()
|
||||
|
||||
|
@ -70,19 +92,30 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
|
||||
async def async_step_user_validate(self, user_input=None):
|
||||
"""Handle authentication."""
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
result = await self._async_auth_or_validate(user_input, errors)
|
||||
if result is not None:
|
||||
return result
|
||||
self._user_auth_details.update(user_input)
|
||||
validate_result = await self._async_auth_or_validate()
|
||||
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(
|
||||
step_id="user_validate",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_BRAND,
|
||||
default=self._user_auth_details.get(CONF_BRAND, DEFAULT_BRAND),
|
||||
): vol.In(BRANDS),
|
||||
vol.Required(
|
||||
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.Required(
|
||||
CONF_USERNAME,
|
||||
|
@ -92,21 +125,27 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
}
|
||||
),
|
||||
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."""
|
||||
if user_input:
|
||||
if self._mode == "reauth":
|
||||
return await self.async_step_reauth_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(
|
||||
step_id="validation",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(VERIFICATION_CODE_KEY): vol.All(str, vol.Strip)}
|
||||
),
|
||||
errors={"base": "invalid_verification_code"} if previously_failed else None,
|
||||
description_placeholders={
|
||||
CONF_BRAND: self._user_auth_details[CONF_BRAND],
|
||||
CONF_USERNAME: self._user_auth_details[CONF_USERNAME],
|
||||
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):
|
||||
"""Handle reauth and validation."""
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
result = await self._async_auth_or_validate(user_input, errors)
|
||||
if result is not None:
|
||||
return result
|
||||
self._user_auth_details.update(user_input)
|
||||
validate_result = await self._async_auth_or_validate()
|
||||
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(
|
||||
step_id="reauth_validate",
|
||||
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,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
description_placeholders=description_placeholders
|
||||
| {
|
||||
CONF_USERNAME: self._user_auth_details[CONF_USERNAME],
|
||||
},
|
||||
)
|
||||
|
||||
async def _async_auth_or_validate(self, user_input, errors):
|
||||
self._user_auth_details.update(user_input)
|
||||
await self._august_gateway.async_setup(self._user_auth_details)
|
||||
async def _async_reset_access_token_cache_if_needed(
|
||||
self, gateway: AugustGateway, username: str, access_token_cache_file: str | None
|
||||
) -> 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:
|
||||
self._needs_reset = False
|
||||
await self._august_gateway.async_reset_authentication()
|
||||
await gateway.async_reset_authentication()
|
||||
|
||||
async def _async_auth_or_validate(self) -> ValidateResult:
|
||||
"""Authenticate or validate."""
|
||||
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(
|
||||
self._user_auth_details,
|
||||
self._august_gateway,
|
||||
)
|
||||
info = await async_validate_input(user_auth_details, gateway)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except RequireValidation:
|
||||
return await self.async_step_validation()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
validation_required = True
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
errors["base"] = "unhandled"
|
||||
description_placeholders = {"error": str(ex)}
|
||||
|
||||
if errors:
|
||||
return None
|
||||
return ValidateResult(
|
||||
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(
|
||||
self._user_auth_details[CONF_USERNAME]
|
||||
)
|
||||
|
|
|
@ -7,6 +7,7 @@ from homeassistant.const import Platform
|
|||
DEFAULT_TIMEOUT = 25
|
||||
|
||||
CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file"
|
||||
CONF_BRAND = "brand"
|
||||
CONF_LOGIN_METHOD = "login_method"
|
||||
CONF_INSTALL_ID = "install_id"
|
||||
|
||||
|
@ -42,6 +43,7 @@ MIN_TIME_BETWEEN_DETAIL_UPDATES = timedelta(hours=1)
|
|||
ACTIVITY_UPDATE_INTERVAL = timedelta(seconds=10)
|
||||
|
||||
LOGIN_METHODS = ["phone", "email"]
|
||||
DEFAULT_LOGIN_METHOD = "email"
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
|
|
|
@ -3,12 +3,14 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any
|
||||
|
||||
from yalexs.const import DEFAULT_BRAND
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import AugustData
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_BRAND, DOMAIN
|
||||
|
||||
TO_REDACT = {
|
||||
"HouseID",
|
||||
|
@ -44,4 +46,5 @@ async def async_get_config_entry_diagnostics(
|
|||
)
|
||||
for doorbell in data.doorbells
|
||||
},
|
||||
"brand": entry.data.get(CONF_BRAND, DEFAULT_BRAND),
|
||||
}
|
||||
|
|
|
@ -1,19 +1,26 @@
|
|||
"""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
|
||||
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 homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
|
||||
from .const import (
|
||||
CONF_ACCESS_TOKEN_CACHE_FILE,
|
||||
CONF_BRAND,
|
||||
CONF_INSTALL_ID,
|
||||
CONF_LOGIN_METHOD,
|
||||
DEFAULT_AUGUST_CONFIG_FILE,
|
||||
|
@ -28,48 +35,59 @@ _LOGGER = logging.getLogger(__name__)
|
|||
class AugustGateway:
|
||||
"""Handle the connection to August."""
|
||||
|
||||
def __init__(self, hass):
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Init the connection."""
|
||||
# Create an aiohttp session instead of using the default one since the
|
||||
# default one is likely to trigger august's WAF if another integration
|
||||
# is also using Cloudflare
|
||||
self._aiohttp_session = aiohttp_client.async_create_clientsession(hass)
|
||||
self._token_refresh_lock = asyncio.Lock()
|
||||
self._access_token_cache_file = None
|
||||
self._hass = hass
|
||||
self._config = None
|
||||
self.api = None
|
||||
self.authenticator = None
|
||||
self.authentication = None
|
||||
self._access_token_cache_file: str | None = None
|
||||
self._hass: HomeAssistant = hass
|
||||
self._config: Mapping[str, Any] | None = None
|
||||
self.api: ApiAsync | None = None
|
||||
self.authenticator: AuthenticatorAsync | None = None
|
||||
self.authentication: Authentication | None = None
|
||||
|
||||
@property
|
||||
def access_token(self):
|
||||
"""Access token for the api."""
|
||||
return self.authentication.access_token
|
||||
|
||||
def config_entry(self):
|
||||
def config_entry(self) -> dict[str, Any]:
|
||||
"""Config entry."""
|
||||
assert self._config is not None
|
||||
return {
|
||||
CONF_BRAND: self._config.get(CONF_BRAND, DEFAULT_BRAND),
|
||||
CONF_LOGIN_METHOD: self._config[CONF_LOGIN_METHOD],
|
||||
CONF_USERNAME: self._config[CONF_USERNAME],
|
||||
CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID),
|
||||
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."""
|
||||
if conf.get(VERIFICATION_CODE_KEY):
|
||||
return
|
||||
|
||||
self._access_token_cache_file = conf.get(
|
||||
CONF_ACCESS_TOKEN_CACHE_FILE,
|
||||
f".{conf[CONF_USERNAME]}{DEFAULT_AUGUST_CONFIG_FILE}",
|
||||
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(
|
||||
|
@ -78,9 +96,7 @@ class AugustGateway:
|
|||
self._config[CONF_USERNAME],
|
||||
self._config.get(CONF_PASSWORD, ""),
|
||||
install_id=self._config.get(CONF_INSTALL_ID),
|
||||
access_token_cache_file=self._hass.config.path(
|
||||
self._access_token_cache_file
|
||||
),
|
||||
access_token_cache_file=access_token_cache_file_path,
|
||||
)
|
||||
|
||||
await self.authenticator.async_setup_authentication()
|
||||
|
@ -95,6 +111,10 @@ class AugustGateway:
|
|||
# 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
|
||||
|
@ -122,8 +142,9 @@ class AugustGateway:
|
|||
|
||||
def _reset_authentication(self):
|
||||
"""Remove the cache file."""
|
||||
if os.path.exists(self._access_token_cache_file):
|
||||
os.unlink(self._access_token_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):
|
||||
"""Refresh the august access token if needed."""
|
||||
|
|
|
@ -47,6 +47,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity):
|
|||
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Lock the device."""
|
||||
assert self._data.activity_stream is not None
|
||||
if self._data.activity_stream.pubnub.connected:
|
||||
await self._data.async_lock_async(self._device_id, self._hyper_bridge)
|
||||
return
|
||||
|
@ -54,6 +55,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity):
|
|||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock the device."""
|
||||
assert self._data.activity_stream is not None
|
||||
if self._data.activity_stream.pubnub.connected:
|
||||
await self._data.async_unlock_async(self._device_id, self._hyper_bridge)
|
||||
return
|
||||
|
|
|
@ -28,5 +28,5 @@
|
|||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==1.3.3", "yalexs-ble==2.1.17"]
|
||||
"requirements": ["yalexs==1.4.6", "yalexs-ble==2.1.17"]
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"config": {
|
||||
"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%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
|
@ -15,20 +16,22 @@
|
|||
"data": {
|
||||
"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": {
|
||||
"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": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"brand": "Brand",
|
||||
"login_method": "Login Method",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"login_method": "Login Method"
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"title": "Set up an August account"
|
||||
},
|
||||
"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": {
|
||||
"brand": "[%key:component::august::config::step::user_validate::data::brand%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"title": "Reauthenticate an August account"
|
||||
|
|
|
@ -2697,7 +2697,7 @@ yalesmartalarmclient==0.3.9
|
|||
yalexs-ble==2.1.17
|
||||
|
||||
# homeassistant.components.august
|
||||
yalexs==1.3.3
|
||||
yalexs==1.4.6
|
||||
|
||||
# homeassistant.components.yeelight
|
||||
yeelight==0.7.10
|
||||
|
|
|
@ -1964,7 +1964,7 @@ yalesmartalarmclient==0.3.9
|
|||
yalexs-ble==2.1.17
|
||||
|
||||
# homeassistant.components.august
|
||||
yalexs==1.3.3
|
||||
yalexs==1.4.6
|
||||
|
||||
# homeassistant.components.yeelight
|
||||
yeelight==0.7.10
|
||||
|
|
|
@ -6,6 +6,7 @@ from yalexs.authenticator import ValidationResult
|
|||
from homeassistant import config_entries
|
||||
from homeassistant.components.august.const import (
|
||||
CONF_ACCESS_TOKEN_CACHE_FILE,
|
||||
CONF_BRAND,
|
||||
CONF_INSTALL_ID,
|
||||
CONF_LOGIN_METHOD,
|
||||
DOMAIN,
|
||||
|
@ -18,6 +19,7 @@ from homeassistant.components.august.exceptions import (
|
|||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
@ -28,7 +30,7 @@ async def test_form(hass: HomeAssistant) -> None:
|
|||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {}
|
||||
|
||||
with patch(
|
||||
|
@ -41,6 +43,7 @@ async def test_form(hass: HomeAssistant) -> None:
|
|||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_BRAND: "august",
|
||||
CONF_LOGIN_METHOD: "email",
|
||||
CONF_USERNAME: "my@email.tld",
|
||||
CONF_PASSWORD: "test-password",
|
||||
|
@ -48,9 +51,10 @@ async def test_form(hass: HomeAssistant) -> None:
|
|||
)
|
||||
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["data"] == {
|
||||
CONF_BRAND: "august",
|
||||
CONF_LOGIN_METHOD: "email",
|
||||
CONF_USERNAME: "my@email.tld",
|
||||
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(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_BRAND: "august",
|
||||
CONF_LOGIN_METHOD: "email",
|
||||
CONF_USERNAME: "my@email.tld",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
|
||||
|
@ -90,19 +95,21 @@ async def test_user_unexpected_exception(hass: HomeAssistant) -> None:
|
|||
|
||||
with patch(
|
||||
"homeassistant.components.august.config_flow.AugustGateway.async_authenticate",
|
||||
side_effect=ValueError,
|
||||
side_effect=ValueError("something exploded"),
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_BRAND: "august",
|
||||
CONF_LOGIN_METHOD: "email",
|
||||
CONF_USERNAME: "my@email.tld",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {"base": "unknown"}
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["errors"] == {"base": "unhandled"}
|
||||
assert result2["description_placeholders"] == {"error": "something exploded"}
|
||||
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
|
@ -151,7 +158,7 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None:
|
|||
)
|
||||
|
||||
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["step_id"] == "validation"
|
||||
|
||||
|
@ -165,9 +172,7 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None:
|
|||
) as mock_validate_verification_code, patch(
|
||||
"homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code",
|
||||
return_value=True,
|
||||
) as mock_send_verification_code, patch(
|
||||
"homeassistant.components.august.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
) as mock_send_verification_code:
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{VERIFICATION_CODE_KEY: "incorrect"},
|
||||
|
@ -177,8 +182,8 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None:
|
|||
# so they have a chance to retry
|
||||
assert len(mock_send_verification_code.mock_calls) == 0
|
||||
assert len(mock_validate_verification_code.mock_calls) == 1
|
||||
assert result3["type"] == "form"
|
||||
assert result3["errors"] is None
|
||||
assert result3["type"] is FlowResultType.FORM
|
||||
assert result3["errors"] == {"base": "invalid_verification_code"}
|
||||
assert result3["step_id"] == "validation"
|
||||
|
||||
# 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_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["data"] == {
|
||||
CONF_BRAND: "august",
|
||||
CONF_LOGIN_METHOD: "email",
|
||||
CONF_USERNAME: "my@email.tld",
|
||||
CONF_INSTALL_ID: None,
|
||||
|
@ -233,7 +239,7 @@ async def test_form_reauth(hass: HomeAssistant) -> None:
|
|||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=entry.data
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {}
|
||||
|
||||
with patch(
|
||||
|
@ -251,7 +257,7 @@ async def test_form_reauth(hass: HomeAssistant) -> None:
|
|||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == "abort"
|
||||
assert result2["type"] is FlowResultType.ABORT
|
||||
assert result2["reason"] == "reauth_successful"
|
||||
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(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=entry.data
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {}
|
||||
|
||||
with patch(
|
||||
|
@ -295,7 +301,7 @@ async def test_form_reauth_with_2fa(hass: HomeAssistant) -> None:
|
|||
await hass.async_block_till_done()
|
||||
|
||||
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["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_send_verification_code.mock_calls) == 0
|
||||
assert result3["type"] == "abort"
|
||||
assert result3["type"] is FlowResultType.ABORT
|
||||
assert result3["reason"] == "reauth_successful"
|
||||
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"
|
||||
|
|
|
@ -141,4 +141,5 @@ async def test_diagnostics(
|
|||
"zWaveEnabled": False,
|
||||
}
|
||||
},
|
||||
"brand": "august",
|
||||
}
|
||||
|
|
|
@ -77,12 +77,42 @@ async def test_august_is_offline(hass: HomeAssistant) -> None:
|
|||
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:
|
||||
"""Test unlock throws correct error on http error."""
|
||||
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):
|
||||
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(
|
||||
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:
|
||||
"""Test lock throws correct error on http error."""
|
||||
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):
|
||||
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(
|
||||
hass,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue