core/homeassistant/components/august/__init__.py

395 lines
14 KiB
Python

"""Support for August devices."""
import asyncio
import itertools
import logging
from aiohttp import ClientError
from august.authenticator import ValidationResult
from august.exceptions import AugustApiAIOHTTPError
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
import homeassistant.helpers.config_validation as cv
from .activity import ActivityStream
from .const import (
AUGUST_COMPONENTS,
CONF_ACCESS_TOKEN_CACHE_FILE,
CONF_INSTALL_ID,
CONF_LOGIN_METHOD,
DATA_AUGUST,
DEFAULT_AUGUST_CONFIG_FILE,
DEFAULT_NAME,
DEFAULT_TIMEOUT,
DOMAIN,
LOGIN_METHODS,
MIN_TIME_BETWEEN_DETAIL_UPDATES,
VERIFICATION_CODE_KEY,
)
from .exceptions import InvalidAuth, RequireValidation
from .gateway import AugustGateway
from .subscriber import AugustSubscriberMixin
_LOGGER = logging.getLogger(__name__)
TWO_FA_REVALIDATE = "verify_configurator"
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_LOGIN_METHOD): vol.In(LOGIN_METHODS),
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_INSTALL_ID): cv.string,
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
}
)
},
extra=vol.ALLOW_EXTRA,
)
async def async_request_validation(hass, config_entry, august_gateway):
"""Request a new verification code from the user."""
#
# In the future this should start a new config flow
# instead of using the legacy configurator
#
_LOGGER.error("Access token is no longer valid.")
configurator = hass.components.configurator
entry_id = config_entry.entry_id
async def async_august_configuration_validation_callback(data):
code = data.get(VERIFICATION_CODE_KEY)
result = await august_gateway.authenticator.async_validate_verification_code(
code
)
if result == ValidationResult.INVALID_VERIFICATION_CODE:
configurator.async_notify_errors(
hass.data[DOMAIN][entry_id][TWO_FA_REVALIDATE],
"Invalid verification code, please make sure you are using the latest code and try again.",
)
elif result == ValidationResult.VALIDATED:
return await async_setup_august(hass, config_entry, august_gateway)
return False
if TWO_FA_REVALIDATE not in hass.data[DOMAIN][entry_id]:
await august_gateway.authenticator.async_send_verification_code()
entry_data = config_entry.data
login_method = entry_data.get(CONF_LOGIN_METHOD)
username = entry_data.get(CONF_USERNAME)
hass.data[DOMAIN][entry_id][TWO_FA_REVALIDATE] = configurator.async_request_config(
f"{DEFAULT_NAME} ({username})",
async_august_configuration_validation_callback,
description=(
"August must be re-verified. "
f"Please check your {login_method} ({username}) "
"and enter the verification code below"
),
submit_caption="Verify",
fields=[
{"id": VERIFICATION_CODE_KEY, "name": "Verification code", "type": "string"}
],
)
return
async def async_setup_august(hass, config_entry, august_gateway):
"""Set up the August component."""
entry_id = config_entry.entry_id
hass.data[DOMAIN].setdefault(entry_id, {})
try:
await august_gateway.async_authenticate()
except RequireValidation:
await async_request_validation(hass, config_entry, august_gateway)
return False
except InvalidAuth:
_LOGGER.error("Password is no longer valid. Please set up August again")
return False
# We still use the configurator to get a new 2fa code
# when needed since config_flow doesn't have a way
# to re-request if it expires
if TWO_FA_REVALIDATE in hass.data[DOMAIN][entry_id]:
hass.components.configurator.async_request_done(
hass.data[DOMAIN][entry_id].pop(TWO_FA_REVALIDATE)
)
hass.data[DOMAIN][entry_id][DATA_AUGUST] = AugustData(hass, august_gateway)
await hass.data[DOMAIN][entry_id][DATA_AUGUST].async_setup()
for component in AUGUST_COMPONENTS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, component)
)
return True
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the August component from YAML."""
conf = config.get(DOMAIN)
hass.data.setdefault(DOMAIN, {})
if not conf:
return True
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_LOGIN_METHOD: conf.get(CONF_LOGIN_METHOD),
CONF_USERNAME: conf.get(CONF_USERNAME),
CONF_PASSWORD: conf.get(CONF_PASSWORD),
CONF_INSTALL_ID: conf.get(CONF_INSTALL_ID),
CONF_ACCESS_TOKEN_CACHE_FILE: DEFAULT_AUGUST_CONFIG_FILE,
},
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up August from a config entry."""
august_gateway = AugustGateway(hass)
try:
await august_gateway.async_setup(entry.data)
return await async_setup_august(hass, entry, august_gateway)
except asyncio.TimeoutError:
raise ConfigEntryNotReady
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in AUGUST_COMPONENTS
]
)
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class AugustData(AugustSubscriberMixin):
"""August data object."""
def __init__(self, hass, august_gateway):
"""Init August data object."""
super().__init__(hass, MIN_TIME_BETWEEN_DETAIL_UPDATES)
self._hass = hass
self._august_gateway = august_gateway
self.activity_stream = None
self._api = august_gateway.api
self._device_detail_by_id = {}
self._doorbells_by_id = {}
self._locks_by_id = {}
self._house_ids = set()
async def async_setup(self):
"""Async setup of august device data and activities."""
locks = (
await self._api.async_get_operable_locks(self._august_gateway.access_token)
or []
)
doorbells = (
await self._api.async_get_doorbells(self._august_gateway.access_token) or []
)
self._doorbells_by_id = {device.device_id: device for device in doorbells}
self._locks_by_id = {device.device_id: device for device in locks}
self._house_ids = {
device.house_id for device in itertools.chain(locks, doorbells)
}
await self._async_refresh_device_detail_by_ids(
[device.device_id for device in itertools.chain(locks, doorbells)]
)
# We remove all devices that we are missing
# detail as we cannot determine if they are usable.
# This also allows us to avoid checking for
# detail being None all over the place
self._remove_inoperative_locks()
self._remove_inoperative_doorbells()
self.activity_stream = ActivityStream(
self._hass, self._api, self._august_gateway, self._house_ids
)
await self.activity_stream.async_setup()
@property
def doorbells(self):
"""Return a list of py-august Doorbell objects."""
return self._doorbells_by_id.values()
@property
def locks(self):
"""Return a list of py-august Lock objects."""
return self._locks_by_id.values()
def get_device_detail(self, device_id):
"""Return the py-august LockDetail or DoorbellDetail object for a device."""
return self._device_detail_by_id[device_id]
async def _async_refresh(self, time):
await self._async_refresh_device_detail_by_ids(self._subscriptions.keys())
async def _async_refresh_device_detail_by_ids(self, device_ids_list):
for device_id in device_ids_list:
if device_id in self._locks_by_id:
await self._async_update_device_detail(
self._locks_by_id[device_id], self._api.async_get_lock_detail
)
# keypads are always attached to locks
if (
device_id in self._device_detail_by_id
and self._device_detail_by_id[device_id].keypad is not None
):
keypad = self._device_detail_by_id[device_id].keypad
self._device_detail_by_id[keypad.device_id] = keypad
elif device_id in self._doorbells_by_id:
await self._async_update_device_detail(
self._doorbells_by_id[device_id],
self._api.async_get_doorbell_detail,
)
_LOGGER.debug(
"async_signal_device_id_update (from detail updates): %s", device_id
)
self.async_signal_device_id_update(device_id)
async def _async_update_device_detail(self, device, api_call):
_LOGGER.debug(
"Started retrieving detail for %s (%s)",
device.device_name,
device.device_id,
)
try:
self._device_detail_by_id[device.device_id] = await api_call(
self._august_gateway.access_token, device.device_id
)
except ClientError as ex:
_LOGGER.error(
"Request error trying to retrieve %s details for %s. %s",
device.device_id,
device.device_name,
ex,
)
_LOGGER.debug(
"Completed retrieving detail for %s (%s)",
device.device_name,
device.device_id,
)
def _get_device_name(self, device_id):
"""Return doorbell or lock name as August has it stored."""
if self._locks_by_id.get(device_id):
return self._locks_by_id[device_id].device_name
if self._doorbells_by_id.get(device_id):
return self._doorbells_by_id[device_id].device_name
async def async_lock(self, device_id):
"""Lock the device."""
return await self._async_call_api_op_requires_bridge(
device_id,
self._api.async_lock_return_activities,
self._august_gateway.access_token,
device_id,
)
async def async_unlock(self, device_id):
"""Unlock the device."""
return await self._async_call_api_op_requires_bridge(
device_id,
self._api.async_unlock_return_activities,
self._august_gateway.access_token,
device_id,
)
async def _async_call_api_op_requires_bridge(
self, device_id, func, *args, **kwargs
):
"""Call an API that requires the bridge to be online and will change the device state."""
ret = None
try:
ret = await func(*args, **kwargs)
except AugustApiAIOHTTPError as err:
device_name = self._get_device_name(device_id)
if device_name is None:
device_name = f"DeviceID: {device_id}"
raise HomeAssistantError(f"{device_name}: {err}")
return ret
def _remove_inoperative_doorbells(self):
doorbells = list(self.doorbells)
for doorbell in doorbells:
device_id = doorbell.device_id
doorbell_is_operative = False
doorbell_detail = self._device_detail_by_id.get(device_id)
if doorbell_detail is None:
_LOGGER.info(
"The doorbell %s could not be setup because the system could not fetch details about the doorbell.",
doorbell.device_name,
)
else:
doorbell_is_operative = True
if not doorbell_is_operative:
del self._doorbells_by_id[device_id]
del self._device_detail_by_id[device_id]
def _remove_inoperative_locks(self):
# Remove non-operative locks as there must
# be a bridge (August Connect) for them to
# be usable
locks = list(self.locks)
for lock in locks:
device_id = lock.device_id
lock_is_operative = False
lock_detail = self._device_detail_by_id.get(device_id)
if lock_detail is None:
_LOGGER.info(
"The lock %s could not be setup because the system could not fetch details about the lock.",
lock.device_name,
)
elif lock_detail.bridge is None:
_LOGGER.info(
"The lock %s could not be setup because it does not have a bridge (Connect).",
lock.device_name,
)
elif not lock_detail.bridge.operative:
_LOGGER.info(
"The lock %s could not be setup because the bridge (Connect) is not operative.",
lock.device_name,
)
else:
lock_is_operative = True
if not lock_is_operative:
del self._locks_by_id[device_id]
del self._device_detail_by_id[device_id]