Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Yale Home brand to august #93214

Merged
merged 30 commits into from May 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
30 changes: 20 additions & 10 deletions homeassistant/components/august/__init__.py
Expand Up @@ -7,6 +7,7 @@
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
Expand All @@ -16,7 +17,7 @@

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,
Expand All @@ -25,7 +26,7 @@
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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -185,7 +191,11 @@ async def async_setup(self):
)
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
Expand Down
4 changes: 4 additions & 0 deletions homeassistant/components/august/binary_sensor.py
Expand Up @@ -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}
)
Expand All @@ -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}
)
Expand All @@ -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}
)
Expand Down Expand Up @@ -211,6 +214,7 @@ def __init__(
@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}
)
Expand Down
138 changes: 106 additions & 32 deletions homeassistant/components/august/config_flow.py
@@ -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
Expand All @@ -50,16 +62,26 @@ 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."""

VERSION = 1

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__()

Expand All @@ -70,19 +92,30 @@ async def async_step_user(self, user_input=None):

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,
Expand All @@ -92,21 +125,27 @@ async def async_step_user_validate(self, user_input=None):
}
),
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],
},
Expand All @@ -122,49 +161,84 @@ async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:

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]
)
Expand Down
2 changes: 2 additions & 0 deletions homeassistant/components/august/const.py
Expand Up @@ -7,6 +7,7 @@
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"

Expand Down Expand Up @@ -42,6 +43,7 @@
ACTIVITY_UPDATE_INTERVAL = timedelta(seconds=10)

LOGIN_METHODS = ["phone", "email"]
DEFAULT_LOGIN_METHOD = "email"

PLATFORMS = [
Platform.BUTTON,
Expand Down
5 changes: 4 additions & 1 deletion homeassistant/components/august/diagnostics.py
Expand Up @@ -3,12 +3,14 @@

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",
Expand Down Expand Up @@ -44,4 +46,5 @@ async def async_get_config_entry_diagnostics(
)
for doorbell in data.doorbells
},
"brand": entry.data.get(CONF_BRAND, DEFAULT_BRAND),
}