mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
Add support for Yale Home brand to august (#93214)
This commit is contained in:
parent
fa415480d6
commit
2a2b19ed7c
@ -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
|
||||||
|
@ -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}
|
||||||
)
|
)
|
||||||
|
@ -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]
|
||||||
)
|
)
|
||||||
|
@ -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,
|
||||||
|
@ -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),
|
||||||
}
|
}
|
||||||
|
@ -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."""
|
||||||
|
@ -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
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
|
@ -141,4 +141,5 @@ async def test_diagnostics(
|
|||||||
"zWaveEnabled": False,
|
"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
|
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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user