diff --git a/homeassistant/components/august/.translations/en.json b/homeassistant/components/august/.translations/en.json new file mode 100644 index 00000000000..1695d33cd63 --- /dev/null +++ b/homeassistant/components/august/.translations/en.json @@ -0,0 +1,32 @@ +{ + "config" : { + "error" : { + "unknown" : "Unexpected error", + "cannot_connect" : "Failed to connect, please try again", + "invalid_auth" : "Invalid authentication" + }, + "abort" : { + "already_configured" : "Account is already configured" + }, + "step" : { + "validation" : { + "title" : "Two factor authentication", + "data" : { + "code" : "Verification code" + }, + "description" : "Please check your {login_method} ({username}) and enter the verification code below" + }, + "user" : { + "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'.", + "data" : { + "timeout" : "Timeout (seconds)", + "password" : "Password", + "username" : "Username", + "login_method" : "Login Method" + }, + "title" : "Setup an August account" + } + }, + "title" : "August" + } +} diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 95206b5bee1..c80101d5658 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -4,62 +4,45 @@ from datetime import timedelta from functools import partial import logging -from august.api import Api, AugustApiHTTPError -from august.authenticator import AuthenticationState, Authenticator, ValidationResult -from requests import RequestException, Session +from august.api import AugustApiHTTPError +from august.authenticator import ValidationResult +from august.doorbell import Doorbell +from august.lock import Lock +from requests import RequestException import voluptuous as vol -from homeassistant.const import ( - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, -) +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 HomeAssistantError -from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle +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_ACTIVITY_UPDATES, + MIN_TIME_BETWEEN_DOORBELL_DETAIL_UPDATES, + MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES, + VERIFICATION_CODE_KEY, +) +from .exceptions import InvalidAuth, RequireValidation +from .gateway import AugustGateway + _LOGGER = logging.getLogger(__name__) -_CONFIGURING = {} - -DEFAULT_TIMEOUT = 10 -ACTIVITY_FETCH_LIMIT = 10 -ACTIVITY_INITIAL_FETCH_LIMIT = 20 - -CONF_LOGIN_METHOD = "login_method" -CONF_INSTALL_ID = "install_id" - -NOTIFICATION_ID = "august_notification" -NOTIFICATION_TITLE = "August Setup" - -AUGUST_CONFIG_FILE = ".august.conf" - -DATA_AUGUST = "august" -DOMAIN = "august" -DEFAULT_ENTITY_NAMESPACE = "august" - -# Limit battery, online, and hardware updates to 1800 seconds -# in order to reduce the number of api requests and -# avoid hitting rate limits -MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES = timedelta(seconds=1800) - -# Doorbells need to update more frequently than locks -# since we get an image from the doorbell api. Once -# py-august 0.18.0 is released doorbell status updates -# can be reduced in the same was as locks have been -MIN_TIME_BETWEEN_DOORBELL_DETAIL_UPDATES = timedelta(seconds=20) - -# Activity needs to be checked more frequently as the -# doorbell motion and rings are included here -MIN_TIME_BETWEEN_ACTIVITY_UPDATES = timedelta(seconds=10) +TWO_FA_REVALIDATE = "verify_configurator" DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) - -LOGIN_METHODS = ["phone", "email"] - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -75,138 +58,159 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -AUGUST_COMPONENTS = ["camera", "binary_sensor", "lock"] +async def async_request_validation(hass, config_entry, august_gateway): + """Request a new verification code from the user.""" -def request_configuration(hass, config, api, authenticator, token_refresh_lock): - """Request configuration steps 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 - def august_configuration_callback(data): - """Run when the configuration callback is called.""" - - result = authenticator.validate_verification_code(data.get("verification_code")) + async def async_august_configuration_validation_callback(data): + code = data.get(VERIFICATION_CODE_KEY) + result = await hass.async_add_executor_job( + august_gateway.authenticator.validate_verification_code, code + ) if result == ValidationResult.INVALID_VERIFICATION_CODE: - configurator.notify_errors( - _CONFIGURING[DOMAIN], "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: - setup_august(hass, config, api, authenticator, token_refresh_lock) + return await async_setup_august(hass, config_entry, august_gateway) - if DOMAIN not in _CONFIGURING: - authenticator.send_verification_code() + return False - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - login_method = conf.get(CONF_LOGIN_METHOD) + if TWO_FA_REVALIDATE not in hass.data[DOMAIN][entry_id]: + await hass.async_add_executor_job( + august_gateway.authenticator.send_verification_code + ) - _CONFIGURING[DOMAIN] = configurator.request_config( - NOTIFICATION_TITLE, - august_configuration_callback, - description=f"Please check your {login_method} ({username}) and enter the verification code below", + 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. Please check your {} ({}) and enter the verification " + "code below".format(login_method, username), submit_caption="Verify", fields=[ - {"id": "verification_code", "name": "Verification code", "type": "string"} + {"id": VERIFICATION_CODE_KEY, "name": "Verification code", "type": "string"} ], ) + return -def setup_august(hass, config, api, authenticator, token_refresh_lock): +async def async_setup_august(hass, config_entry, august_gateway): """Set up the August component.""" - authentication = None + entry_id = config_entry.entry_id + hass.data[DOMAIN].setdefault(entry_id, {}) + try: - authentication = authenticator.authenticate() - except RequestException as ex: - _LOGGER.error("Unable to connect to August service: %s", str(ex)) - - hass.components.persistent_notification.create( - "Error: {ex}
You will need to restart hass after fixing.", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) - - state = authentication.state - - if state == AuthenticationState.AUTHENTICATED: - if DOMAIN in _CONFIGURING: - hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN)) - - hass.data[DATA_AUGUST] = AugustData( - hass, api, authentication, authenticator, token_refresh_lock - ) - - for component in AUGUST_COMPONENTS: - discovery.load_platform(hass, component, DOMAIN, {}, config) - - return True - if state == AuthenticationState.BAD_PASSWORD: - _LOGGER.error("Invalid password provided") + august_gateway.authenticate() + except RequireValidation: + await async_request_validation(hass, config_entry, august_gateway) return False - if state == AuthenticationState.REQUIRES_VALIDATION: - request_configuration(hass, config, api, authenticator, token_refresh_lock) + 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] = await hass.async_add_executor_job( + AugustData, hass, august_gateway + ) + + 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 - return False + 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(hass, config): - """Set up the August component.""" +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up August from a config entry.""" - conf = config[DOMAIN] - api_http_session = None - try: - api_http_session = Session() - except RequestException as ex: - _LOGGER.warning("Creating HTTP session failed with: %s", str(ex)) + august_gateway = AugustGateway(hass) + august_gateway.async_setup(entry.data) - api = Api(timeout=conf.get(CONF_TIMEOUT), http_session=api_http_session) + return await async_setup_august(hass, entry, august_gateway) - authenticator = Authenticator( - api, - conf.get(CONF_LOGIN_METHOD), - conf.get(CONF_USERNAME), - conf.get(CONF_PASSWORD), - install_id=conf.get(CONF_INSTALL_ID), - access_token_cache_file=hass.config.path(AUGUST_CONFIG_FILE), + +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 + ] + ) ) - def close_http_session(event): - """Close API sessions used to connect to August.""" - _LOGGER.debug("Closing August HTTP sessions") - if api_http_session: - try: - api_http_session.close() - except RequestException: - pass + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) - _LOGGER.debug("August HTTP session closed.") - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_http_session) - _LOGGER.debug("Registered for Home Assistant stop event") - - token_refresh_lock = asyncio.Lock() - - return await hass.async_add_executor_job( - setup_august, hass, config, api, authenticator, token_refresh_lock - ) + return unload_ok class AugustData: """August data object.""" - def __init__(self, hass, api, authentication, authenticator, token_refresh_lock): + DEFAULT_ACTIVITY_FETCH_LIMIT = 10 + + def __init__(self, hass, august_gateway): """Init August data object.""" self._hass = hass - self._api = api - self._authenticator = authenticator - self._access_token = authentication.access_token - self._access_token_expires = authentication.access_token_expires + self._august_gateway = august_gateway + self._api = august_gateway.api - self._token_refresh_lock = token_refresh_lock - self._doorbells = self._api.get_doorbells(self._access_token) or [] - self._locks = self._api.get_operable_locks(self._access_token) or [] + self._doorbells = ( + self._api.get_doorbells(self._august_gateway.access_token) or [] + ) + self._locks = ( + self._api.get_operable_locks(self._august_gateway.access_token) or [] + ) self._house_ids = set() for device in self._doorbells + self._locks: self._house_ids.add(device.house_id) @@ -218,7 +222,7 @@ class AugustData: # We check the locks right away so we can # remove inoperative ones self._update_locks_detail() - + self._update_doorbells_detail() self._filter_inoperative_locks() @property @@ -236,22 +240,6 @@ class AugustData: """Return a list of locks.""" return self._locks - async def _async_refresh_access_token_if_needed(self): - """Refresh the august access token if needed.""" - if self._authenticator.should_refresh(): - async with self._token_refresh_lock: - await self._hass.async_add_executor_job(self._refresh_access_token) - - def _refresh_access_token(self): - refreshed_authentication = self._authenticator.refresh_access_token(force=False) - _LOGGER.info( - "Refreshed august access token. The old token expired at %s, and the new token expires at %s", - self._access_token_expires, - refreshed_authentication.access_token_expires, - ) - self._access_token = refreshed_authentication.access_token - self._access_token_expires = refreshed_authentication.access_token_expires - async def async_get_device_activities(self, device_id, *activity_types): """Return a list of activities.""" _LOGGER.debug("Getting device activities for %s", device_id) @@ -268,22 +256,23 @@ class AugustData: return next(iter(activities or []), None) @Throttle(MIN_TIME_BETWEEN_ACTIVITY_UPDATES) - async def _async_update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): + async def _async_update_device_activities(self, limit=DEFAULT_ACTIVITY_FETCH_LIMIT): """Update data object with latest from August API.""" # This is the only place we refresh the api token - await self._async_refresh_access_token_if_needed() + await self._august_gateway.async_refresh_access_token_if_needed() + return await self._hass.async_add_executor_job( - partial(self._update_device_activities, limit=ACTIVITY_FETCH_LIMIT) + partial(self._update_device_activities, limit=limit) ) - def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): + def _update_device_activities(self, limit=DEFAULT_ACTIVITY_FETCH_LIMIT): _LOGGER.debug("Start retrieving device activities") for house_id in self.house_ids: _LOGGER.debug("Updating device activity for house id %s", house_id) activities = self._api.get_house_activities( - self._access_token, house_id, limit=limit + self._august_gateway.access_token, house_id, limit=limit ) device_ids = {a.device_id for a in activities} @@ -294,6 +283,14 @@ class AugustData: _LOGGER.debug("Completed retrieving device activities") + async def async_get_device_detail(self, device): + """Return the detail for a device.""" + if isinstance(device, Lock): + return await self.async_get_lock_detail(device.device_id) + if isinstance(device, Doorbell): + return await self.async_get_doorbell_detail(device.device_id) + raise ValueError + async def async_get_doorbell_detail(self, device_id): """Return doorbell detail.""" await self._async_update_doorbells_detail() @@ -342,8 +339,11 @@ class AugustData: _LOGGER.debug("Start retrieving %s detail", device_type) for device in devices: device_id = device.device_id + detail_by_id[device_id] = None try: - detail_by_id[device_id] = api_call(self._access_token, device_id) + detail_by_id[device_id] = api_call( + self._august_gateway.access_token, device_id + ) except RequestException as ex: _LOGGER.error( "Request error trying to retrieve %s details for %s. %s", @@ -351,10 +351,6 @@ class AugustData: device.device_name, ex, ) - detail_by_id[device_id] = None - except Exception: - detail_by_id[device_id] = None - raise _LOGGER.debug("Completed retrieving %s detail", device_type) return detail_by_id @@ -365,7 +361,7 @@ class AugustData: self.get_lock_name(device_id), "lock", self._api.lock_return_activities, - self._access_token, + self._august_gateway.access_token, device_id, ) @@ -375,7 +371,7 @@ class AugustData: self.get_lock_name(device_id), "unlock", self._api.unlock_return_activities, - self._access_token, + self._august_gateway.access_token, device_id, ) diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 41bf820319c..b5b65863eac 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -6,43 +6,44 @@ from august.activity import ActivityType from august.lock import LockDoorStatus from august.util import update_lock_detail_from_activity -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, + BinarySensorDevice, +) -from . import DATA_AUGUST +from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) -async def _async_retrieve_online_state(data, doorbell): +async def _async_retrieve_online_state(data, detail): """Get the latest state of the sensor.""" - detail = await data.async_get_doorbell_detail(doorbell.device_id) - if detail is None: - return None - - return detail.is_online + return detail.is_online or detail.status == "standby" -async def _async_retrieve_motion_state(data, doorbell): +async def _async_retrieve_motion_state(data, detail): return await _async_activity_time_based_state( - data, doorbell, [ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING] + data, + detail.device_id, + [ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING], ) -async def _async_retrieve_ding_state(data, doorbell): +async def _async_retrieve_ding_state(data, detail): return await _async_activity_time_based_state( - data, doorbell, [ActivityType.DOORBELL_DING] + data, detail.device_id, [ActivityType.DOORBELL_DING] ) -async def _async_activity_time_based_state(data, doorbell, activity_types): +async def _async_activity_time_based_state(data, device_id, activity_types): """Get the latest state of the sensor.""" - latest = await data.async_get_latest_device_activity( - doorbell.device_id, *activity_types - ) + latest = await data.async_get_latest_device_activity(device_id, *activity_types) if latest is not None: start = latest.activity_start_time @@ -57,15 +58,19 @@ SENSOR_STATE_PROVIDER = 2 # sensor_type: [name, device_class, async_state_provider] SENSOR_TYPES_DOORBELL = { - "doorbell_ding": ["Ding", "occupancy", _async_retrieve_ding_state], - "doorbell_motion": ["Motion", "motion", _async_retrieve_motion_state], - "doorbell_online": ["Online", "connectivity", _async_retrieve_online_state], + "doorbell_ding": ["Ding", DEVICE_CLASS_OCCUPANCY, _async_retrieve_ding_state], + "doorbell_motion": ["Motion", DEVICE_CLASS_MOTION, _async_retrieve_motion_state], + "doorbell_online": [ + "Online", + DEVICE_CLASS_CONNECTIVITY, + _async_retrieve_online_state, + ], } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the August binary sensors.""" - data = hass.data[DATA_AUGUST] + data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] devices = [] for door in data.locks: @@ -98,6 +103,7 @@ class AugustDoorBinarySensor(BinarySensorDevice): self._door = door self._state = None self._available = False + self._firmware_version = None @property def available(self): @@ -132,6 +138,7 @@ class AugustDoorBinarySensor(BinarySensorDevice): lock_door_state = None if detail is not None: lock_door_state = detail.door_state + self._firmware_version = detail.firmware_version self._available = lock_door_state != LockDoorStatus.UNKNOWN self._state = lock_door_state == LockDoorStatus.OPEN @@ -141,6 +148,16 @@ class AugustDoorBinarySensor(BinarySensorDevice): """Get the unique of the door open binary sensor.""" return f"{self._door.device_id}_open" + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._door.device_id)}, + "name": self._door.device_name, + "manufacturer": DEFAULT_NAME, + "sw_version": self._firmware_version, + } + class AugustDoorbellBinarySensor(BinarySensorDevice): """Representation of an August binary sensor.""" @@ -152,6 +169,7 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): self._doorbell = doorbell self._state = None self._available = False + self._firmware_version = None @property def available(self): @@ -178,11 +196,21 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): async_state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][ SENSOR_STATE_PROVIDER ] - self._state = await async_state_provider(self._data, self._doorbell) + detail = await self._data.async_get_doorbell_detail(self._doorbell.device_id) # The doorbell will go into standby mode when there is no motion # for a short while. It will wake by itself when needed so we need # to consider is available or we will not report motion or dings - self._available = self._doorbell.is_online or self._doorbell.status == "standby" + if self.device_class == DEVICE_CLASS_CONNECTIVITY: + self._available = True + else: + self._available = detail is not None and ( + detail.is_online or detail.status == "standby" + ) + + self._state = None + if detail is not None: + self._firmware_version = detail.firmware_version + self._state = await async_state_provider(self._data, detail) @property def unique_id(self) -> str: @@ -191,3 +219,13 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): f"{self._doorbell.device_id}_" f"{SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME].lower()}" ) + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._doorbell.device_id)}, + "name": self._doorbell.device_name, + "manufacturer": "August", + "sw_version": self._firmware_version, + } diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index 5426d9574dc..ad31cb4ddc6 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -5,14 +5,14 @@ import requests from homeassistant.components.camera import Camera -from . import DATA_AUGUST, DEFAULT_TIMEOUT +from .const import DATA_AUGUST, DEFAULT_NAME, DEFAULT_TIMEOUT, DOMAIN -SCAN_INTERVAL = timedelta(seconds=10) +SCAN_INTERVAL = timedelta(seconds=5) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up August cameras.""" - data = hass.data[DATA_AUGUST] + data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] devices = [] for doorbell in data.doorbells: @@ -29,9 +29,11 @@ class AugustCamera(Camera): super().__init__() self._data = data self._doorbell = doorbell + self._doorbell_detail = None self._timeout = timeout self._image_url = None self._image_content = None + self._firmware_version = None @property def name(self): @@ -51,7 +53,7 @@ class AugustCamera(Camera): @property def brand(self): """Return the camera brand.""" - return "August" + return DEFAULT_NAME @property def model(self): @@ -60,16 +62,30 @@ class AugustCamera(Camera): async def async_camera_image(self): """Return bytes of camera image.""" - latest = await self._data.async_get_doorbell_detail(self._doorbell.device_id) + self._doorbell_detail = await self._data.async_get_doorbell_detail( + self._doorbell.device_id + ) + if self._doorbell_detail is None: + return None - if self._image_url is not latest.image_url: - self._image_url = latest.image_url + if self._image_url is not self._doorbell_detail.image_url: + self._image_url = self._doorbell_detail.image_url self._image_content = await self.hass.async_add_executor_job( self._camera_image ) - return self._image_content + async def async_update(self): + """Update camera data.""" + self._doorbell_detail = await self._data.async_get_doorbell_detail( + self._doorbell.device_id + ) + + if self._doorbell_detail is None: + return None + + self._firmware_version = self._doorbell_detail.firmware_version + def _camera_image(self): """Return bytes of camera image via http get.""" # Move this to py-august: see issue#32048 @@ -79,3 +95,13 @@ class AugustCamera(Camera): def unique_id(self) -> str: """Get the unique id of the camera.""" return f"{self._doorbell.device_id:s}_camera" + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._doorbell.device_id)}, + "name": self._doorbell.device_name + " Camera", + "manufacturer": DEFAULT_NAME, + "sw_version": self._firmware_version, + } diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py new file mode 100644 index 00000000000..1fa446ea566 --- /dev/null +++ b/homeassistant/components/august/config_flow.py @@ -0,0 +1,135 @@ +"""Config flow for August integration.""" +import logging + +from august.authenticator import ValidationResult +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME + +from .const import ( + CONF_LOGIN_METHOD, + DEFAULT_TIMEOUT, + LOGIN_METHODS, + VERIFICATION_CODE_KEY, +) +from .const import DOMAIN # pylint:disable=unused-import +from .exceptions import CannotConnect, InvalidAuth, RequireValidation +from .gateway import AugustGateway + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LOGIN_METHOD, default="phone"): vol.In(LOGIN_METHODS), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int), + } +) + + +async def async_validate_input( + hass: core.HomeAssistant, data, august_gateway, +): + """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. + """ + + code = data.get(VERIFICATION_CODE_KEY) + + if code is not None: + result = await hass.async_add_executor_job( + august_gateway.authenticator.validate_verification_code, code + ) + _LOGGER.debug("Verification code validation: %s", result) + if result != ValidationResult.VALIDATED: + raise RequireValidation + + try: + august_gateway.authenticate() + except RequireValidation: + _LOGGER.debug( + "Requesting new verification code for %s via %s", + data.get(CONF_USERNAME), + data.get(CONF_LOGIN_METHOD), + ) + if code is None: + await hass.async_add_executor_job( + august_gateway.authenticator.send_verification_code + ) + raise + + return { + "title": data.get(CONF_USERNAME), + "data": august_gateway.config_entry(), + } + + +class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for August.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Store an AugustGateway().""" + self._august_gateway = None + self.user_auth_details = {} + super().__init__() + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if self._august_gateway is None: + self._august_gateway = AugustGateway(self.hass) + errors = {} + if user_input is not None: + self._august_gateway.async_setup(user_input) + + try: + info = await async_validate_input( + self.hass, user_input, self._august_gateway, + ) + await self.async_set_unique_id(user_input[CONF_USERNAME]) + return self.async_create_entry(title=info["title"], data=info["data"]) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except RequireValidation: + self.user_auth_details = user_input + + return await self.async_step_validation() + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_validation(self, user_input=None): + """Handle validation (2fa) step.""" + if user_input: + return await self.async_step_user({**self.user_auth_details, **user_input}) + + return self.async_show_form( + step_id="validation", + data_schema=vol.Schema( + {vol.Required(VERIFICATION_CODE_KEY): vol.All(str, vol.Strip)} + ), + description_placeholders={ + CONF_USERNAME: self.user_auth_details.get(CONF_USERNAME), + CONF_LOGIN_METHOD: self.user_auth_details.get(CONF_LOGIN_METHOD), + }, + ) + + async def async_step_import(self, user_input): + """Handle import.""" + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + + return await self.async_step_user(user_input) diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py new file mode 100644 index 00000000000..6e367e96ac5 --- /dev/null +++ b/homeassistant/components/august/const.py @@ -0,0 +1,42 @@ +"""Constants for August devices.""" + +from datetime import timedelta + +DEFAULT_TIMEOUT = 10 + +CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file" +CONF_LOGIN_METHOD = "login_method" +CONF_INSTALL_ID = "install_id" + +VERIFICATION_CODE_KEY = "verification_code" + +NOTIFICATION_ID = "august_notification" +NOTIFICATION_TITLE = "August" + +DEFAULT_AUGUST_CONFIG_FILE = ".august.conf" + +DATA_AUGUST = "data_august" + +DEFAULT_NAME = "August" +DOMAIN = "august" + +# Limit battery, online, and hardware updates to 1800 seconds +# in order to reduce the number of api requests and +# avoid hitting rate limits +MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES = timedelta(seconds=1800) + +# Doorbells need to update more frequently than locks +# since we get an image from the doorbell api. Once +# py-august 0.18.0 is released doorbell status updates +# can be reduced in the same was as locks have been +MIN_TIME_BETWEEN_DOORBELL_DETAIL_UPDATES = timedelta(seconds=20) + +# Activity needs to be checked more frequently as the +# doorbell motion and rings are included here +MIN_TIME_BETWEEN_ACTIVITY_UPDATES = timedelta(seconds=10) + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) + +LOGIN_METHODS = ["phone", "email"] + +AUGUST_COMPONENTS = ["camera", "binary_sensor", "lock", "sensor"] diff --git a/homeassistant/components/august/exceptions.py b/homeassistant/components/august/exceptions.py new file mode 100644 index 00000000000..78c467ab3a1 --- /dev/null +++ b/homeassistant/components/august/exceptions.py @@ -0,0 +1,15 @@ +"""Shared excecption for the august integration.""" + +from homeassistant import exceptions + + +class RequireValidation(exceptions.HomeAssistantError): + """Error to indicate we require validation (2fa).""" + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py new file mode 100644 index 00000000000..e01e2fb9a8f --- /dev/null +++ b/homeassistant/components/august/gateway.py @@ -0,0 +1,143 @@ +"""Handle August connection setup and authentication.""" + +import asyncio +import logging + +from august.api import Api +from august.authenticator import AuthenticationState, Authenticator +from requests import RequestException, Session + +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME +from homeassistant.core import callback + +from .const import ( + CONF_ACCESS_TOKEN_CACHE_FILE, + CONF_INSTALL_ID, + CONF_LOGIN_METHOD, + DEFAULT_AUGUST_CONFIG_FILE, + VERIFICATION_CODE_KEY, +) +from .exceptions import CannotConnect, InvalidAuth, RequireValidation + +_LOGGER = logging.getLogger(__name__) + + +class AugustGateway: + """Handle the connection to August.""" + + def __init__(self, hass): + """Init the connection.""" + self._api_http_session = Session() + self._token_refresh_lock = asyncio.Lock() + self._hass = hass + self._config = None + self._api = None + self._authenticator = None + self._authentication = None + + @property + def authenticator(self): + """August authentication object from py-august.""" + return self._authenticator + + @property + def authentication(self): + """August authentication object from py-august.""" + return self._authentication + + @property + def access_token(self): + """Access token for the api.""" + return self._authentication.access_token + + @property + def api(self): + """August api object from py-august.""" + return self._api + + def config_entry(self): + """Config entry.""" + return { + CONF_LOGIN_METHOD: self._config[CONF_LOGIN_METHOD], + CONF_USERNAME: self._config[CONF_USERNAME], + CONF_PASSWORD: self._config[CONF_PASSWORD], + CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID), + CONF_TIMEOUT: self._config.get(CONF_TIMEOUT), + CONF_ACCESS_TOKEN_CACHE_FILE: self._config[CONF_ACCESS_TOKEN_CACHE_FILE], + } + + @callback + def async_setup(self, conf): + """Create the api and authenticator objects.""" + if conf.get(VERIFICATION_CODE_KEY): + return + if conf.get(CONF_ACCESS_TOKEN_CACHE_FILE) is None: + conf[ + CONF_ACCESS_TOKEN_CACHE_FILE + ] = f".{conf[CONF_USERNAME]}{DEFAULT_AUGUST_CONFIG_FILE}" + self._config = conf + + self._api = Api( + timeout=self._config.get(CONF_TIMEOUT), http_session=self._api_http_session, + ) + + self._authenticator = Authenticator( + self._api, + self._config[CONF_LOGIN_METHOD], + self._config[CONF_USERNAME], + self._config[CONF_PASSWORD], + install_id=self._config.get(CONF_INSTALL_ID), + access_token_cache_file=self._hass.config.path( + self._config[CONF_ACCESS_TOKEN_CACHE_FILE] + ), + ) + + def authenticate(self): + """Authenticate with the details provided to setup.""" + self._authentication = None + try: + self._authentication = self.authenticator.authenticate() + except RequestException as ex: + _LOGGER.error("Unable to connect to August service: %s", str(ex)) + raise CannotConnect + + if self._authentication.state == AuthenticationState.BAD_PASSWORD: + raise InvalidAuth + + if self._authentication.state == AuthenticationState.REQUIRES_VALIDATION: + raise RequireValidation + + if self._authentication.state != AuthenticationState.AUTHENTICATED: + _LOGGER.error( + "Unknown authentication state: %s", self._authentication.state + ) + raise InvalidAuth + + return self._authentication + + async def async_refresh_access_token_if_needed(self): + """Refresh the august access token if needed.""" + if self.authenticator.should_refresh(): + async with self._token_refresh_lock: + await self._hass.async_add_executor_job(self._refresh_access_token) + + def _refresh_access_token(self): + refreshed_authentication = self.authenticator.refresh_access_token(force=False) + _LOGGER.info( + "Refreshed august access token. The old token expired at %s, and the new token expires at %s", + self.authentication.access_token_expires, + refreshed_authentication.access_token_expires, + ) + self._authentication = refreshed_authentication + + def _close_http_session(self): + """Close API sessions used to connect to August.""" + if self._api_http_session: + try: + self._api_http_session.close() + except RequestException: + pass + + def __del__(self): + """Close out the http session on destroy.""" + self._close_http_session() diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index a805fa2657a..2db1fe5eede 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -9,16 +9,16 @@ from august.util import update_lock_detail_from_activity from homeassistant.components.lock import LockDevice from homeassistant.const import ATTR_BATTERY_LEVEL -from . import DATA_AUGUST +from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up August locks.""" - data = hass.data[DATA_AUGUST] + data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] devices = [] for lock in data.locks: @@ -39,6 +39,7 @@ class AugustLock(LockDevice): self._lock_detail = None self._changed_by = None self._available = False + self._firmware_version = None async def async_lock(self, **kwargs): """Lock the device.""" @@ -59,12 +60,18 @@ class AugustLock(LockDevice): self.schedule_update_ha_state() def _update_lock_status_from_detail(self): - lock_status = self._lock_detail.lock_status - if self._lock_status != lock_status: - self._lock_status = lock_status + detail = self._lock_detail + lock_status = None + self._available = False + + if detail is not None: + lock_status = detail.lock_status self._available = ( lock_status is not None and lock_status != LockStatus.UNKNOWN ) + + if self._lock_status != lock_status: + self._lock_status = lock_status return True return False @@ -77,7 +84,11 @@ class AugustLock(LockDevice): if lock_activity is not None: self._changed_by = lock_activity.operated_by - update_lock_detail_from_activity(self._lock_detail, lock_activity) + if self._lock_detail is not None: + update_lock_detail_from_activity(self._lock_detail, lock_activity) + + if self._lock_detail is not None: + self._firmware_version = self._lock_detail.firmware_version self._update_lock_status_from_detail() @@ -94,7 +105,8 @@ class AugustLock(LockDevice): @property def is_locked(self): """Return true if device is on.""" - + if self._lock_status is None or self._lock_status is LockStatus.UNKNOWN: + return None return self._lock_status is LockStatus.LOCKED @property @@ -115,6 +127,16 @@ class AugustLock(LockDevice): return attributes + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._lock.device_id)}, + "name": self._lock.device_name, + "manufacturer": DEFAULT_NAME, + "sw_version": self._firmware_version, + } + @property def unique_id(self) -> str: """Get the unique id of the lock.""" diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 53bbdaaa33f..0523ed178aa 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -2,7 +2,14 @@ "domain": "august", "name": "August", "documentation": "https://www.home-assistant.io/integrations/august", - "requirements": ["py-august==0.17.0"], - "dependencies": ["configurator"], - "codeowners": ["@bdraco"] -} + "requirements": [ + "py-august==0.17.0" + ], + "dependencies": [ + "configurator" + ], + "codeowners": [ + "@bdraco" + ], + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py new file mode 100644 index 00000000000..f1bfd0ad8b4 --- /dev/null +++ b/homeassistant/components/august/sensor.py @@ -0,0 +1,158 @@ +"""Support for August sensors.""" +from datetime import timedelta +import logging + +from homeassistant.components.sensor import DEVICE_CLASS_BATTERY +from homeassistant.helpers.entity import Entity + +from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=5) + + +async def _async_retrieve_device_battery_state(detail): + """Get the latest state of the sensor.""" + if detail is None: + return None + + return detail.battery_level + + +async def _async_retrieve_linked_keypad_battery_state(detail): + """Get the latest state of the sensor.""" + if detail is None: + return None + + if detail.keypad is None: + return None + + battery_level = detail.keypad.battery_level + + _LOGGER.debug("keypad battery level: %s %s", battery_level, battery_level.lower()) + + if battery_level.lower() == "full": + return 100 + if battery_level.lower() == "medium": + return 60 + if battery_level.lower() == "low": + return 10 + + return 0 + + +SENSOR_TYPES_BATTERY = { + "device_battery": { + "name": "Battery", + "async_state_provider": _async_retrieve_device_battery_state, + }, + "linked_keypad_battery": { + "name": "Keypad Battery", + "async_state_provider": _async_retrieve_linked_keypad_battery_state, + }, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the August sensors.""" + data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] + devices = [] + + batteries = { + "device_battery": [], + "linked_keypad_battery": [], + } + for device in data.doorbells: + batteries["device_battery"].append(device) + for device in data.locks: + batteries["device_battery"].append(device) + batteries["linked_keypad_battery"].append(device) + + for sensor_type in SENSOR_TYPES_BATTERY: + for device in batteries[sensor_type]: + async_state_provider = SENSOR_TYPES_BATTERY[sensor_type][ + "async_state_provider" + ] + detail = await data.async_get_device_detail(device) + state = await async_state_provider(detail) + sensor_name = SENSOR_TYPES_BATTERY[sensor_type]["name"] + if state is None: + _LOGGER.debug( + "Not adding battery sensor %s for %s because it is not present", + sensor_name, + device.device_name, + ) + else: + _LOGGER.debug( + "Adding battery sensor %s for %s", sensor_name, device.device_name, + ) + devices.append(AugustBatterySensor(data, sensor_type, device)) + + async_add_entities(devices, True) + + +class AugustBatterySensor(Entity): + """Representation of an August sensor.""" + + def __init__(self, data, sensor_type, device): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._device = device + self._state = None + self._available = False + self._firmware_version = None + + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "%" # UNIT_PERCENTAGE will be available after PR#32094 + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_BATTERY + + @property + def name(self): + """Return the name of the sensor.""" + device_name = self._device.device_name + sensor_name = SENSOR_TYPES_BATTERY[self._sensor_type]["name"] + return f"{device_name} {sensor_name}" + + async def async_update(self): + """Get the latest state of the sensor.""" + async_state_provider = SENSOR_TYPES_BATTERY[self._sensor_type][ + "async_state_provider" + ] + detail = await self._data.async_get_device_detail(self._device) + self._state = await async_state_provider(detail) + self._available = self._state is not None + if detail is not None: + self._firmware_version = detail.firmware_version + + @property + def unique_id(self) -> str: + """Get the unique id of the device sensor.""" + return f"{self._device.device_id}_{self._sensor_type}" + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._device.device_id)}, + "name": self._device.device_name, + "manufacturer": DEFAULT_NAME, + "sw_version": self._firmware_version, + } diff --git a/homeassistant/components/august/strings.json b/homeassistant/components/august/strings.json new file mode 100644 index 00000000000..1695d33cd63 --- /dev/null +++ b/homeassistant/components/august/strings.json @@ -0,0 +1,32 @@ +{ + "config" : { + "error" : { + "unknown" : "Unexpected error", + "cannot_connect" : "Failed to connect, please try again", + "invalid_auth" : "Invalid authentication" + }, + "abort" : { + "already_configured" : "Account is already configured" + }, + "step" : { + "validation" : { + "title" : "Two factor authentication", + "data" : { + "code" : "Verification code" + }, + "description" : "Please check your {login_method} ({username}) and enter the verification code below" + }, + "user" : { + "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'.", + "data" : { + "timeout" : "Timeout (seconds)", + "password" : "Password", + "username" : "Username", + "login_method" : "Login Method" + }, + "title" : "Setup an August account" + } + }, + "title" : "August" + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 39a9bccf607..cb12b13afed 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -12,6 +12,7 @@ FLOWS = [ "almond", "ambiclimate", "ambient_station", + "august", "axis", "brother", "cast", diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py index d1c3efb00e8..7b7fcd9f28c 100644 --- a/tests/components/august/mocks.py +++ b/tests/components/august/mocks.py @@ -1,16 +1,13 @@ """Mocks for the august component.""" -import datetime import json import os import time from unittest.mock import MagicMock, PropertyMock from asynctest import mock -from august.activity import Activity, DoorOperationActivity, LockOperationActivity -from august.api import Api +from august.activity import DoorOperationActivity, LockOperationActivity from august.authenticator import AuthenticationState from august.doorbell import Doorbell, DoorbellDetail -from august.exceptions import AugustApiHTTPError from august.lock import Lock, LockDetail from homeassistant.components.august import ( @@ -18,10 +15,8 @@ from homeassistant.components.august import ( CONF_PASSWORD, CONF_USERNAME, DOMAIN, - AugustData, ) from homeassistant.setup import async_setup_component -from homeassistant.util import dt from tests.common import load_fixture @@ -37,8 +32,8 @@ def _mock_get_config(): } -@mock.patch("homeassistant.components.august.Api") -@mock.patch("homeassistant.components.august.Authenticator.authenticate") +@mock.patch("homeassistant.components.august.gateway.Api") +@mock.patch("homeassistant.components.august.gateway.Authenticator.authenticate") async def _mock_setup_august(hass, api_instance, authenticate_mock, api_mock): """Set up august integration.""" authenticate_mock.side_effect = MagicMock( @@ -84,6 +79,9 @@ async def _create_august_with_devices(hass, devices, api_call_side_effects=None) def get_lock_detail_side_effect(access_token, device_id): return _get_device_detail("locks", device_id) + def get_doorbell_detail_side_effect(access_token, device_id): + return _get_device_detail("doorbells", device_id) + def get_operable_locks_side_effect(access_token): return _get_base_devices("locks") @@ -109,6 +107,8 @@ async def _create_august_with_devices(hass, devices, api_call_side_effects=None) if "get_lock_detail" not in api_call_side_effects: api_call_side_effects["get_lock_detail"] = get_lock_detail_side_effect + if "get_doorbell_detail" not in api_call_side_effects: + api_call_side_effects["get_doorbell_detail"] = get_doorbell_detail_side_effect if "get_operable_locks" not in api_call_side_effects: api_call_side_effects["get_operable_locks"] = get_operable_locks_side_effect if "get_doorbells" not in api_call_side_effects: @@ -143,6 +143,11 @@ async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects): if api_call_side_effects["get_doorbells"]: api_instance.get_doorbells.side_effect = api_call_side_effects["get_doorbells"] + if api_call_side_effects["get_doorbell_detail"]: + api_instance.get_doorbell_detail.side_effect = api_call_side_effects[ + "get_doorbell_detail" + ] + if api_call_side_effects["get_house_activities"]: api_instance.get_house_activities.side_effect = api_call_side_effects[ "get_house_activities" @@ -160,106 +165,6 @@ async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects): return await _mock_setup_august(hass, api_instance) -class MockAugustApiFailing(Api): - """A mock for py-august Api class that always has an AugustApiHTTPError.""" - - def _call_api(self, *args, **kwargs): - """Mock the time activity started.""" - raise AugustApiHTTPError("This should bubble up as its user consumable") - - -class MockActivity(Activity): - """A mock for py-august Activity class.""" - - def __init__( - self, action=None, activity_start_timestamp=None, activity_end_timestamp=None - ): - """Init the py-august Activity class mock.""" - self._action = action - self._activity_start_timestamp = activity_start_timestamp - self._activity_end_timestamp = activity_end_timestamp - - @property - def activity_start_time(self): - """Mock the time activity started.""" - return datetime.datetime.fromtimestamp(self._activity_start_timestamp) - - @property - def activity_end_time(self): - """Mock the time activity ended.""" - return datetime.datetime.fromtimestamp(self._activity_end_timestamp) - - @property - def action(self): - """Mock the action.""" - return self._action - - -class MockAugustComponentData(AugustData): - """A wrapper to mock AugustData.""" - - # AugustData support multiple locks, however for the purposes of - # mocking we currently only mock one lockid - - def __init__( - self, - last_lock_status_update_timestamp=1, - last_door_state_update_timestamp=1, - api=MockAugustApiFailing(), - access_token="mocked_access_token", - locks=[], - doorbells=[], - ): - """Mock AugustData.""" - self._last_lock_status_update_time_utc = dt.as_utc( - datetime.datetime.fromtimestamp(last_lock_status_update_timestamp) - ) - self._last_door_state_update_time_utc = dt.as_utc( - datetime.datetime.fromtimestamp(last_lock_status_update_timestamp) - ) - self._api = api - self._access_token = access_token - self._locks = locks - self._doorbells = doorbells - self._lock_status_by_id = {} - self._lock_last_status_update_time_utc_by_id = {} - - def set_mocked_locks(self, locks): - """Set lock mocks.""" - self._locks = locks - - def set_mocked_doorbells(self, doorbells): - """Set doorbell mocks.""" - self._doorbells = doorbells - - def get_last_lock_status_update_time_utc(self, device_id): - """Mock to get last lock status update time.""" - return self._last_lock_status_update_time_utc - - def set_last_lock_status_update_time_utc(self, device_id, update_time): - """Mock to set last lock status update time.""" - self._last_lock_status_update_time_utc = update_time - - def get_last_door_state_update_time_utc(self, device_id): - """Mock to get last door state update time.""" - return self._last_door_state_update_time_utc - - def set_last_door_state_update_time_utc(self, device_id, update_time): - """Mock to set last door state update time.""" - self._last_door_state_update_time_utc = update_time - - -def _mock_august_authenticator(): - authenticator = MagicMock(name="august.authenticator") - authenticator.should_refresh = MagicMock( - name="august.authenticator.should_refresh", return_value=0 - ) - authenticator.refresh_access_token = MagicMock( - name="august.authenticator.refresh_access_token" - ) - return authenticator - - def _mock_august_authentication(token_text, token_timestamp): authentication = MagicMock(name="august.authentication") type(authentication).state = PropertyMock( @@ -321,20 +226,12 @@ def _mock_august_lock_data(lockid="mocklockid1", houseid="mockhouseid1"): } -def _mock_operative_august_lock_detail(lockid): - operative_lock_detail_data = _mock_august_lock_data(lockid=lockid) - return LockDetail(operative_lock_detail_data) +async def _mock_operative_august_lock_detail(hass): + return await _mock_lock_from_fixture(hass, "get_lock.online.json") -def _mock_inoperative_august_lock_detail(lockid): - inoperative_lock_detail_data = _mock_august_lock_data(lockid=lockid) - del inoperative_lock_detail_data["Bridge"] - return LockDetail(inoperative_lock_detail_data) - - -def _mock_doorsense_enabled_august_lock_detail(lockid): - doorsense_lock_detail_data = _mock_august_lock_data(lockid=lockid) - return LockDetail(doorsense_lock_detail_data) +async def _mock_inoperative_august_lock_detail(hass): + return await _mock_lock_from_fixture(hass, "get_lock.offline.json") async def _mock_lock_from_fixture(hass, path): @@ -354,10 +251,12 @@ async def _load_json_fixture(hass, path): return json.loads(fixture) -def _mock_doorsense_missing_august_lock_detail(lockid): - doorsense_lock_detail_data = _mock_august_lock_data(lockid=lockid) - del doorsense_lock_detail_data["LockStatus"]["doorState"] - return LockDetail(doorsense_lock_detail_data) +async def _mock_doorsense_enabled_august_lock_detail(hass): + return await _mock_lock_from_fixture(hass, "get_lock.online_with_doorsense.json") + + +async def _mock_doorsense_missing_august_lock_detail(hass): + return await _mock_lock_from_fixture(hass, "get_lock.online_missing_doorsense.json") def _mock_lock_operation_activity(lock, action): diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 47acfb59c72..260f86120f3 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -9,6 +9,7 @@ from homeassistant.const import ( SERVICE_UNLOCK, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, ) from tests.components.august.mocks import ( @@ -69,3 +70,21 @@ async def test_create_doorbell(hass): "binary_sensor.k98gidt45gul_name_ding" ) assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF + + +async def test_create_doorbell_offline(hass): + """Test creation of a doorbell that is offline.""" + doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") + doorbell_details = [doorbell_one] + await _create_august_with_devices(hass, doorbell_details) + + binary_sensor_tmt100_name_motion = hass.states.get( + "binary_sensor.tmt100_name_motion" + ) + assert binary_sensor_tmt100_name_motion.state == STATE_UNAVAILABLE + binary_sensor_tmt100_name_online = hass.states.get( + "binary_sensor.tmt100_name_online" + ) + assert binary_sensor_tmt100_name_online.state == STATE_OFF + binary_sensor_tmt100_name_ding = hass.states.get("binary_sensor.tmt100_name_ding") + assert binary_sensor_tmt100_name_ding.state == STATE_UNAVAILABLE diff --git a/tests/components/august/test_config_flow.py b/tests/components/august/test_config_flow.py new file mode 100644 index 00000000000..3e81986d9f4 --- /dev/null +++ b/tests/components/august/test_config_flow.py @@ -0,0 +1,195 @@ +"""Test the August config flow.""" +from asynctest import patch +from august.authenticator import ValidationResult + +from homeassistant import config_entries, setup +from homeassistant.components.august.const import ( + CONF_ACCESS_TOKEN_CACHE_FILE, + CONF_INSTALL_ID, + CONF_LOGIN_METHOD, + DOMAIN, + VERIFICATION_CODE_KEY, +) +from homeassistant.components.august.exceptions import ( + CannotConnect, + InvalidAuth, + RequireValidation, +) +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.august.config_flow.AugustGateway.authenticate", + return_value=True, + ), patch( + "homeassistant.components.august.async_setup", return_value=True + ) as mock_setup, 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_LOGIN_METHOD: "email", + CONF_USERNAME: "my@email.tld", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "my@email.tld" + assert result2["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", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.august.config_flow.AugustGateway.authenticate", + side_effect=InvalidAuth, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOGIN_METHOD: "email", + CONF_USERNAME: "my@email.tld", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.august.config_flow.AugustGateway.authenticate", + side_effect=CannotConnect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOGIN_METHOD: "email", + CONF_USERNAME: "my@email.tld", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_needs_validate(hass): + """Test we present validation when we need to validate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.august.config_flow.AugustGateway.authenticate", + side_effect=RequireValidation, + ), patch( + "homeassistant.components.august.gateway.Authenticator.send_verification_code", + return_value=True, + ) as mock_send_verification_code: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOGIN_METHOD: "email", + CONF_USERNAME: "my@email.tld", + CONF_PASSWORD: "test-password", + }, + ) + + assert len(mock_send_verification_code.mock_calls) == 1 + assert result2["type"] == "form" + assert result2["errors"] is None + assert result2["step_id"] == "validation" + + # Try with the WRONG verification code give us the form back again + with patch( + "homeassistant.components.august.config_flow.AugustGateway.authenticate", + side_effect=RequireValidation, + ), patch( + "homeassistant.components.august.gateway.Authenticator.validate_verification_code", + return_value=ValidationResult.INVALID_VERIFICATION_CODE, + ) as mock_validate_verification_code, patch( + "homeassistant.components.august.gateway.Authenticator.send_verification_code", + return_value=True, + ) as mock_send_verification_code, patch( + "homeassistant.components.august.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.august.async_setup_entry", return_value=True + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], {VERIFICATION_CODE_KEY: "incorrect"}, + ) + + # Make sure we do not resend the code again + # 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["step_id"] == "validation" + + # Try with the CORRECT verification code and we setup + with patch( + "homeassistant.components.august.config_flow.AugustGateway.authenticate", + return_value=True, + ), patch( + "homeassistant.components.august.gateway.Authenticator.validate_verification_code", + return_value=ValidationResult.VALIDATED, + ) as mock_validate_verification_code, patch( + "homeassistant.components.august.gateway.Authenticator.send_verification_code", + return_value=True, + ) as mock_send_verification_code, patch( + "homeassistant.components.august.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.august.async_setup_entry", return_value=True + ) as mock_setup_entry: + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], {VERIFICATION_CODE_KEY: "correct"}, + ) + + 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["title"] == "my@email.tld" + assert result4["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", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/august/test_gateway.py b/tests/components/august/test_gateway.py new file mode 100644 index 00000000000..38696d316ca --- /dev/null +++ b/tests/components/august/test_gateway.py @@ -0,0 +1,49 @@ +"""The gateway tests for the august platform.""" +from unittest.mock import MagicMock + +from asynctest import mock + +from homeassistant.components.august.const import DOMAIN +from homeassistant.components.august.gateway import AugustGateway + +from tests.components.august.mocks import _mock_august_authentication, _mock_get_config + + +async def test_refresh_access_token(hass): + """Test token refreshes.""" + await _patched_refresh_access_token(hass, "new_token", 5678) + + +@mock.patch("homeassistant.components.august.gateway.Authenticator.authenticate") +@mock.patch("homeassistant.components.august.gateway.Authenticator.should_refresh") +@mock.patch( + "homeassistant.components.august.gateway.Authenticator.refresh_access_token" +) +async def _patched_refresh_access_token( + hass, + new_token, + new_token_expire_time, + refresh_access_token_mock, + should_refresh_mock, + authenticate_mock, +): + authenticate_mock.side_effect = MagicMock( + return_value=_mock_august_authentication("original_token", 1234) + ) + august_gateway = AugustGateway(hass) + mocked_config = _mock_get_config() + august_gateway.async_setup(mocked_config[DOMAIN]) + august_gateway.authenticate() + + should_refresh_mock.return_value = False + await august_gateway.async_refresh_access_token_if_needed() + refresh_access_token_mock.assert_not_called() + + should_refresh_mock.return_value = True + refresh_access_token_mock.return_value = _mock_august_authentication( + new_token, new_token_expire_time + ) + await august_gateway.async_refresh_access_token_if_needed() + refresh_access_token_mock.assert_called() + assert august_gateway.access_token == new_token + assert august_gateway.authentication.access_token_expires == new_token_expire_time diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index eb50e37561e..4767f24e113 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -1,136 +1,146 @@ """The tests for the august platform.""" -import asyncio -from unittest.mock import MagicMock +from asynctest import patch +from august.exceptions import AugustApiHTTPError -from august.lock import LockDetail -from requests import RequestException - -from homeassistant.components import august +from homeassistant import setup +from homeassistant.components.august.const import ( + CONF_ACCESS_TOKEN_CACHE_FILE, + CONF_INSTALL_ID, + CONF_LOGIN_METHOD, + DEFAULT_AUGUST_CONFIG_FILE, +) +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_PASSWORD, + CONF_TIMEOUT, + CONF_USERNAME, + SERVICE_LOCK, + SERVICE_UNLOCK, + STATE_LOCKED, + STATE_ON, +) from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component from tests.components.august.mocks import ( - MockAugustApiFailing, - MockAugustComponentData, - _mock_august_authentication, - _mock_august_authenticator, - _mock_august_lock, + _create_august_with_devices, _mock_doorsense_enabled_august_lock_detail, _mock_doorsense_missing_august_lock_detail, + _mock_get_config, _mock_inoperative_august_lock_detail, _mock_operative_august_lock_detail, ) -def test_get_lock_name(): - """Get the lock name from August data.""" - data = MockAugustComponentData(last_lock_status_update_timestamp=1) - lock = _mock_august_lock() - data.set_mocked_locks([lock]) - assert data.get_lock_name("mocklockid1") == "mocklockid1 Name" +async def test_unlock_throws_august_api_http_error(hass): + """Test unlock throws correct error on http error.""" + mocked_lock_detail = await _mock_operative_august_lock_detail(hass) + def _unlock_return_activities_side_effect(access_token, device_id): + raise AugustApiHTTPError("This should bubble up as its user consumable") -def test_unlock_throws_august_api_http_error(): - """Test unlock.""" - data = MockAugustComponentData(api=MockAugustApiFailing()) - lock = _mock_august_lock() - data.set_mocked_locks([lock]) + await _create_august_with_devices( + hass, + [mocked_lock_detail], + api_call_side_effects={ + "unlock_return_activities": _unlock_return_activities_side_effect + }, + ) last_err = None + data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"} try: - data.unlock("mocklockid1") + await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) except HomeAssistantError as err: last_err = err assert ( str(last_err) - == "mocklockid1 Name: This should bubble up as its user consumable" + == "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user consumable" ) -def test_lock_throws_august_api_http_error(): - """Test lock.""" - data = MockAugustComponentData(api=MockAugustApiFailing()) - lock = _mock_august_lock() - data.set_mocked_locks([lock]) +async def test_lock_throws_august_api_http_error(hass): + """Test lock throws correct error on http error.""" + mocked_lock_detail = await _mock_operative_august_lock_detail(hass) + + def _lock_return_activities_side_effect(access_token, device_id): + raise AugustApiHTTPError("This should bubble up as its user consumable") + + await _create_august_with_devices( + hass, + [mocked_lock_detail], + api_call_side_effects={ + "lock_return_activities": _lock_return_activities_side_effect + }, + ) last_err = None + data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"} try: - data.unlock("mocklockid1") + await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True) except HomeAssistantError as err: last_err = err assert ( str(last_err) - == "mocklockid1 Name: This should bubble up as its user consumable" + == "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user consumable" ) -def test_inoperative_locks_are_filtered_out(): +async def test_inoperative_locks_are_filtered_out(hass): """Ensure inoperative locks do not get setup.""" - august_operative_lock = _mock_operative_august_lock_detail("oplockid1") - data = _create_august_data_with_lock_details( - [august_operative_lock, _mock_inoperative_august_lock_detail("inoplockid1")] + august_operative_lock = await _mock_operative_august_lock_detail(hass) + august_inoperative_lock = await _mock_inoperative_august_lock_detail(hass) + await _create_august_with_devices( + hass, [august_operative_lock, august_inoperative_lock] ) - assert len(data.locks) == 1 - assert data.locks[0].device_id == "oplockid1" + lock_abc_name = hass.states.get("lock.abc_name") + assert lock_abc_name is None + lock_a6697750d607098bae8d6baa11ef8063_name = hass.states.get( + "lock.a6697750d607098bae8d6baa11ef8063_name" + ) + assert lock_a6697750d607098bae8d6baa11ef8063_name.state == STATE_LOCKED -def test_lock_has_doorsense(): +async def test_lock_has_doorsense(hass): """Check to see if a lock has doorsense.""" - data = _create_august_data_with_lock_details( - [ - _mock_doorsense_enabled_august_lock_detail("doorsenselock1"), - _mock_doorsense_missing_august_lock_detail("nodoorsenselock1"), - RequestException("mocked request error"), - RequestException("mocked request error"), - ] + doorsenselock = await _mock_doorsense_enabled_august_lock_detail(hass) + nodoorsenselock = await _mock_doorsense_missing_august_lock_detail(hass) + await _create_august_with_devices(hass, [doorsenselock, nodoorsenselock]) + + binary_sensor_online_with_doorsense_name_open = hass.states.get( + "binary_sensor.online_with_doorsense_name_open" ) - - assert data.lock_has_doorsense("doorsenselock1") is True - assert data.lock_has_doorsense("nodoorsenselock1") is False - - # The api calls are mocked to fail on the second - # run of async_get_lock_detail - # - # This will be switched to await data.async_get_lock_detail("doorsenselock1") - # once we mock the full home assistant setup - data._update_locks_detail() - # doorsenselock1 should be false if we cannot tell due - # to an api error - assert data.lock_has_doorsense("doorsenselock1") is False - - -async def test__refresh_access_token(hass): - """Test refresh of the access token.""" - authentication = _mock_august_authentication("original_token", 1234) - authenticator = _mock_august_authenticator() - token_refresh_lock = asyncio.Lock() - - data = august.AugustData( - hass, MagicMock(name="api"), authentication, authenticator, token_refresh_lock + assert binary_sensor_online_with_doorsense_name_open.state == STATE_ON + binary_sensor_missing_doorsense_id_name_open = hass.states.get( + "binary_sensor.missing_doorsense_id_name_open" ) - await data._async_refresh_access_token_if_needed() - authenticator.refresh_access_token.assert_not_called() - - authenticator.should_refresh.return_value = 1 - authenticator.refresh_access_token.return_value = _mock_august_authentication( - "new_token", 5678 - ) - await data._async_refresh_access_token_if_needed() - authenticator.refresh_access_token.assert_called() - assert data._access_token == "new_token" - assert data._access_token_expires == 5678 + assert binary_sensor_missing_doorsense_id_name_open is None -def _create_august_data_with_lock_details(lock_details): - locks = [] - for lock in lock_details: - if isinstance(lock, LockDetail): - locks.append(_mock_august_lock(lock.device_id)) - authentication = _mock_august_authentication("original_token", 1234) - authenticator = _mock_august_authenticator() - token_refresh_lock = MagicMock() - api = MagicMock() - api.get_lock_detail = MagicMock(side_effect=lock_details) - api.get_operable_locks = MagicMock(return_value=locks) - api.get_doorbells = MagicMock(return_value=[]) - return august.AugustData( - MagicMock(), api, authentication, authenticator, token_refresh_lock - ) +async def test_set_up_from_yaml(hass): + """Test to make sure config is imported from yaml.""" + + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.august.async_setup_august", return_value=True, + ) as mock_setup_august, patch( + "homeassistant.components.august.config_flow.AugustGateway.authenticate", + return_value=True, + ): + mocked_config = _mock_get_config() + assert await async_setup_component(hass, "august", mocked_config) + await hass.async_block_till_done() + assert len(mock_setup_august.mock_calls) == 1 + call = mock_setup_august.call_args + args, kwargs = call + imported_config_entry = args[1] + # The import must use DEFAULT_AUGUST_CONFIG_FILE so they + # do not loose their token when config is migrated + assert imported_config_entry.data == { + CONF_ACCESS_TOKEN_CACHE_FILE: DEFAULT_AUGUST_CONFIG_FILE, + CONF_INSTALL_ID: None, + CONF_LOGIN_METHOD: "email", + CONF_PASSWORD: "mocked_password", + CONF_TIMEOUT: None, + CONF_USERNAME: "mocked_username", + } diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index b0b298690a5..104c93855be 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -6,45 +6,65 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_UNLOCK, STATE_LOCKED, + STATE_UNAVAILABLE, STATE_UNLOCKED, ) from tests.components.august.mocks import ( _create_august_with_devices, + _mock_doorsense_enabled_august_lock_detail, _mock_lock_from_fixture, ) async def test_one_lock_operation(hass): """Test creation of a lock with doorsense and bridge.""" - lock_one = await _mock_lock_from_fixture( - hass, "get_lock.online_with_doorsense.json" - ) + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) lock_details = [lock_one] await _create_august_with_devices(hass, lock_details) - lock_abc_name = hass.states.get("lock.abc_name") + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_abc_name.state == STATE_LOCKED + assert lock_online_with_doorsense_name.state == STATE_LOCKED - assert lock_abc_name.attributes.get("battery_level") == 92 - assert lock_abc_name.attributes.get("friendly_name") == "ABC Name" + assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 + assert ( + lock_online_with_doorsense_name.attributes.get("friendly_name") + == "online_with_doorsense Name" + ) data = {} - data[ATTR_ENTITY_ID] = "lock.abc_name" + data[ATTR_ENTITY_ID] = "lock.online_with_doorsense_name" assert await hass.services.async_call( LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True ) - lock_abc_name = hass.states.get("lock.abc_name") - assert lock_abc_name.state == STATE_UNLOCKED + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + assert lock_online_with_doorsense_name.state == STATE_UNLOCKED - assert lock_abc_name.attributes.get("battery_level") == 92 - assert lock_abc_name.attributes.get("friendly_name") == "ABC Name" + assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 + assert ( + lock_online_with_doorsense_name.attributes.get("friendly_name") + == "online_with_doorsense Name" + ) assert await hass.services.async_call( LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True ) - lock_abc_name = hass.states.get("lock.abc_name") - assert lock_abc_name.state == STATE_LOCKED + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + assert lock_online_with_doorsense_name.state == STATE_LOCKED + + +async def test_one_lock_unknown_state(hass): + """Test creation of a lock with doorsense and bridge.""" + lock_one = await _mock_lock_from_fixture( + hass, "get_lock.online.unknown_state.json", + ) + lock_details = [lock_one] + await _create_august_with_devices(hass, lock_details) + + lock_brokenid_name = hass.states.get("lock.brokenid_name") + # Once we have bridge_is_online support in py-august + # this can change to STATE_UNKNOWN + assert lock_brokenid_name.state == STATE_UNAVAILABLE diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py new file mode 100644 index 00000000000..a0c1a2ea7bb --- /dev/null +++ b/tests/components/august/test_sensor.py @@ -0,0 +1,84 @@ +"""The sensor tests for the august platform.""" + +from tests.components.august.mocks import ( + _create_august_with_devices, + _mock_doorbell_from_fixture, + _mock_lock_from_fixture, +) + + +async def test_create_doorbell(hass): + """Test creation of a doorbell.""" + doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") + await _create_august_with_devices(hass, [doorbell_one]) + + sensor_k98gidt45gul_name_battery = hass.states.get( + "sensor.k98gidt45gul_name_battery" + ) + assert sensor_k98gidt45gul_name_battery.state == "96" + assert sensor_k98gidt45gul_name_battery.attributes["unit_of_measurement"] == "%" + + +async def test_create_doorbell_offline(hass): + """Test creation of a doorbell that is offline.""" + doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") + await _create_august_with_devices(hass, [doorbell_one]) + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + sensor_tmt100_name_battery = hass.states.get("sensor.tmt100_name_battery") + assert sensor_tmt100_name_battery.state == "81" + assert sensor_tmt100_name_battery.attributes["unit_of_measurement"] == "%" + + entry = entity_registry.async_get("sensor.tmt100_name_battery") + assert entry + assert entry.unique_id == "tmt100_device_battery" + + +async def test_create_doorbell_hardwired(hass): + """Test creation of a doorbell that is hardwired without a battery.""" + doorbell_one = await _mock_doorbell_from_fixture( + hass, "get_doorbell.nobattery.json" + ) + await _create_august_with_devices(hass, [doorbell_one]) + + sensor_tmt100_name_battery = hass.states.get("sensor.tmt100_name_battery") + assert sensor_tmt100_name_battery is None + + +async def test_create_lock_with_linked_keypad(hass): + """Test creation of a lock with a linked keypad that both have a battery.""" + lock_one = await _mock_lock_from_fixture(hass, "get_lock.doorsense_init.json") + await _create_august_with_devices(hass, [lock_one]) + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + sensor_a6697750d607098bae8d6baa11ef8063_name_battery = hass.states.get( + "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" + ) + assert sensor_a6697750d607098bae8d6baa11ef8063_name_battery.state == "88" + assert ( + sensor_a6697750d607098bae8d6baa11ef8063_name_battery.attributes[ + "unit_of_measurement" + ] + == "%" + ) + entry = entity_registry.async_get( + "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" + ) + assert entry + assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_device_battery" + + sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery = hass.states.get( + "sensor.a6697750d607098bae8d6baa11ef8063_name_keypad_battery" + ) + assert sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery.state == "60" + assert ( + sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery.attributes[ + "unit_of_measurement" + ] + == "%" + ) + entry = entity_registry.async_get( + "sensor.a6697750d607098bae8d6baa11ef8063_name_keypad_battery" + ) + assert entry + assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_linked_keypad_battery" diff --git a/tests/fixtures/august/get_doorbell.nobattery.json b/tests/fixtures/august/get_doorbell.nobattery.json new file mode 100644 index 00000000000..e2a93a086cc --- /dev/null +++ b/tests/fixtures/august/get_doorbell.nobattery.json @@ -0,0 +1,80 @@ +{ + "status_timestamp" : 1512811834532, + "appID" : "august-iphone", + "LockID" : "BBBB1F5F11114C24CCCC97571DD6AAAA", + "recentImage" : { + "original_filename" : "file", + "placeholder" : false, + "bytes" : 24476, + "height" : 640, + "format" : "jpg", + "width" : 480, + "version" : 1512892814, + "resource_type" : "image", + "etag" : "54966926be2e93f77d498a55f247661f", + "tags" : [], + "public_id" : "qqqqt4ctmxwsysylaaaa", + "url" : "http://image.com/vmk16naaaa7ibuey7sar.jpg", + "created_at" : "2017-12-10T08:01:35Z", + "signature" : "75z47ca21b5e8ffda21d2134e478a2307c4625da", + "secure_url" : "https://image.com/vmk16naaaa7ibuey7sar.jpg", + "type" : "upload" + }, + "settings" : { + "keepEncoderRunning" : true, + "videoResolution" : "640x480", + "minACNoScaling" : 40, + "irConfiguration" : 8448272, + "directLink" : true, + "overlayEnabled" : true, + "notify_when_offline" : true, + "micVolume" : 100, + "bitrateCeiling" : 512000, + "initialBitrate" : 384000, + "IVAEnabled" : false, + "turnOffCamera" : false, + "ringSoundEnabled" : true, + "JPGQuality" : 70, + "motion_notifications" : true, + "speakerVolume" : 92, + "buttonpush_notifications" : true, + "ABREnabled" : true, + "debug" : false, + "batteryLowThreshold" : 3.1, + "batteryRun" : false, + "IREnabled" : true, + "batteryUseThreshold" : 3.4 + }, + "doorbellServerURL" : "https://doorbells.august.com", + "name" : "Front Door", + "createdAt" : "2016-11-26T22:27:11.176Z", + "installDate" : "2016-11-26T22:27:11.176Z", + "serialNumber" : "tBXZR0Z35E", + "dvrSubscriptionSetupDone" : true, + "caps" : [ + "reconnect" + ], + "doorbellID" : "K98GiDT45GUL", + "HouseID" : "3dd2accaea08", + "telemetry" : { + "signal_level" : -56, + "date" : "2017-12-10 08:05:12", + "steady_ac_in" : 22.196405, + "BSSID" : "88:ee:00:dd:aa:11", + "SSID" : "foo_ssid", + "updated_at" : "2017-12-10T08:05:13.650Z", + "temperature" : 28.25, + "wifi_freq" : 5745, + "load_average" : "0.50 0.47 0.35 1/154 9345", + "link_quality" : 54, + "uptime" : "16168.75 13830.49", + "ip_addr" : "10.0.1.11", + "doorbell_low_battery" : false, + "ac_in" : 23.856874 + }, + "installUserID" : "c3b2a94e-373e-aaaa-bbbb-36e996827777", + "status" : "doorbell_call_status_online", + "firmwareVersion" : "2.3.0-RC153+201711151527", + "pubsubChannel" : "7c7a6672-59c8-3333-ffff-dcd98705cccc", + "updatedAt" : "2017-12-10T08:05:13.650Z" +} diff --git a/tests/fixtures/august/get_doorbell.offline.json b/tests/fixtures/august/get_doorbell.offline.json new file mode 100644 index 00000000000..dec94374355 --- /dev/null +++ b/tests/fixtures/august/get_doorbell.offline.json @@ -0,0 +1,130 @@ +{ + "recentImage" : { + "tags" : [], + "height" : 576, + "public_id" : "fdsfds", + "bytes" : 50013, + "resource_type" : "image", + "original_filename" : "file", + "version" : 1582242766, + "format" : "jpg", + "signature" : "fdsfdsf", + "created_at" : "2020-02-20T23:52:46Z", + "type" : "upload", + "placeholder" : false, + "url" : "http://res.cloudinary.com/august-com/image/upload/ccc/ccccc.jpg", + "secure_url" : "https://res.cloudinary.com/august-com/image/upload/cc/cccc.jpg", + "etag" : "zds", + "width" : 720 + }, + "firmwareVersion" : "3.1.0-HYDRC75+201909251139", + "doorbellServerURL" : "https://doorbells.august.com", + "installUserID" : "mock", + "caps" : [ + "reconnect", + "webrtc", + "tcp_wakeup" + ], + "messagingProtocol" : "pubnub", + "createdAt" : "2020-02-12T03:52:28.719Z", + "invitations" : [], + "appID" : "august-iphone-v5", + "HouseID" : "houseid1", + "doorbellID" : "tmt100", + "name" : "Front Door", + "settings" : { + "batteryUseThreshold" : 3.4, + "brightness" : 50, + "batteryChargeCurrent" : 60, + "overCurrentThreshold" : -250, + "irLedBrightness" : 40, + "videoResolution" : "720x576", + "pirPulseCounter" : 1, + "contrast" : 50, + "micVolume" : 50, + "directLink" : true, + "auto_contrast_mode" : 0, + "saturation" : 50, + "motion_notifications" : true, + "pirSensitivity" : 20, + "pirBlindTime" : 7, + "notify_when_offline" : false, + "nightModeAlsThreshold" : 10, + "minACNoScaling" : 40, + "DVRRecordingTimeout" : 15, + "turnOffCamera" : false, + "debug" : false, + "keepEncoderRunning" : true, + "pirWindowTime" : 0, + "bitrateCeiling" : 2000000, + "backlight_comp" : false, + "buttonpush_notifications" : true, + "buttonpush_notifications_partners" : false, + "minimumSnapshotInterval" : 30, + "pirConfiguration" : 272, + "batteryLowThreshold" : 3.1, + "sharpness" : 50, + "ABREnabled" : true, + "hue" : 50, + "initialBitrate" : 1000000, + "ringSoundEnabled" : true, + "IVAEnabled" : false, + "overlayEnabled" : true, + "speakerVolume" : 92, + "ringRepetitions" : 3, + "powerProfilePreset" : -1, + "irConfiguration" : 16836880, + "JPGQuality" : 70, + "IREnabled" : true + }, + "updatedAt" : "2020-02-20T23:58:21.580Z", + "serialNumber" : "abc", + "installDate" : "2019-02-12T03:52:28.719Z", + "dvrSubscriptionSetupDone" : true, + "pubsubChannel" : "mock", + "chimes" : [ + { + "updatedAt" : "2020-02-12T03:55:38.805Z", + "_id" : "cccc", + "type" : 1, + "serialNumber" : "ccccc", + "doorbellID" : "tmt100", + "name" : "Living Room", + "chimeID" : "cccc", + "createdAt" : "2020-02-12T03:55:38.805Z", + "firmware" : "3.1.16" + } + ], + "telemetry" : { + "battery" : 3.985, + "battery_soc" : 81, + "load_average" : "0.45 0.18 0.07 4/98 831", + "ip_addr" : "192.168.100.174", + "BSSID" : "snp", + "uptime" : "96.55 70.59", + "SSID" : "bob", + "updated_at" : "2020-02-20T23:53:09.586Z", + "dtim_period" : 0, + "wifi_freq" : 2462, + "date" : "2020-02-20 11:47:36", + "BSSIDManufacturer" : "Ubiquiti - Ubiquiti Networks Inc.", + "battery_temp" : 22, + "battery_avg_cur" : -291, + "beacon_interval" : 0, + "signal_level" : -49, + "battery_soh" : 95, + "doorbell_low_battery" : false + }, + "secChipCertSerial" : "", + "tcpKeepAlive" : { + "keepAliveUUID" : "mock", + "wakeUp" : { + "token" : "wakemeup", + "lastUpdated" : 1582242723931 + } + }, + "statusUpdatedAtMs" : 1582243101579, + "status" : "doorbell_offline", + "type" : "hydra1", + "HouseName" : "housename" +} diff --git a/tests/fixtures/august/get_lock.online.unknown_state.json b/tests/fixtures/august/get_lock.online.unknown_state.json new file mode 100644 index 00000000000..ad455655902 --- /dev/null +++ b/tests/fixtures/august/get_lock.online.unknown_state.json @@ -0,0 +1,59 @@ +{ + "LockName": "Side Door", + "Type": 1001, + "Created": "2019-10-07T01:49:06.831Z", + "Updated": "2019-10-07T01:49:06.831Z", + "LockID": "BROKENID", + "HouseID": "abc", + "HouseName": "dog", + "Calibrated": false, + "timeZone": "America/Chicago", + "battery": 0.9524716174964851, + "hostLockInfo": { + "serialNumber": "YR", + "manufacturer": "yale", + "productID": 1536, + "productTypeID": 32770 + }, + "supportsEntryCodes": true, + "skuNumber": "AUG-MD01", + "macAddress": "MAC", + "SerialNumber": "M1FXZ00EZ9", + "LockStatus": { + "status": "unknown_error_during_connect", + "dateTime": "2020-02-22T02:48:11.741Z", + "isLockStatusChanged": true, + "valid": true, + "doorState": "closed" + }, + "currentFirmwareVersion": "undefined-4.3.0-1.8.14", + "homeKitEnabled": true, + "zWaveEnabled": false, + "isGalileo": false, + "Bridge": { + "_id": "id", + "mfgBridgeID": "id", + "deviceModel": "august-connect", + "firmwareVersion": "2.2.1", + "operative": true, + "status": { + "current": "online", + "updated": "2020-02-21T15:06:47.001Z", + "lastOnline": "2020-02-21T15:06:47.001Z", + "lastOffline": "2020-02-06T17:33:21.265Z" + }, + "hyperBridge": true + }, + "parametersToSet": {}, + "ruleHash": {}, + "cameras": [], + "geofenceLimits": { + "ios": { + "debounceInterval": 90, + "gpsAccuracyMultiplier": 2.5, + "maximumGeofence": 5000, + "minimumGeofence": 100, + "minGPSAccuracyRequired": 80 + } + } +} diff --git a/tests/fixtures/august/get_lock.online_missing_doorsense.json b/tests/fixtures/august/get_lock.online_missing_doorsense.json new file mode 100644 index 00000000000..46971c3bbd2 --- /dev/null +++ b/tests/fixtures/august/get_lock.online_missing_doorsense.json @@ -0,0 +1,50 @@ +{ + "Bridge" : { + "_id" : "bridgeid", + "deviceModel" : "august-connect", + "firmwareVersion" : "2.2.1", + "hyperBridge" : true, + "mfgBridgeID" : "C5WY200WSH", + "operative" : true, + "status" : { + "current" : "online", + "lastOffline" : "2000-00-00T00:00:00.447Z", + "lastOnline" : "2000-00-00T00:00:00.447Z", + "updated" : "2000-00-00T00:00:00.447Z" + } + }, + "Calibrated" : false, + "Created" : "2000-00-00T00:00:00.447Z", + "HouseID" : "123", + "HouseName" : "Test", + "LockID" : "missing_doorsense_id", + "LockName" : "Online door missing doorsense", + "LockStatus" : { + "dateTime" : "2017-12-10T04:48:30.272Z", + "isLockStatusChanged" : false, + "status" : "locked", + "valid" : true + }, + "SerialNumber" : "XY", + "Type" : 1001, + "Updated" : "2000-00-00T00:00:00.447Z", + "battery" : 0.922, + "currentFirmwareVersion" : "undefined-4.3.0-1.8.14", + "homeKitEnabled" : true, + "hostLockInfo" : { + "manufacturer" : "yale", + "productID" : 1536, + "productTypeID" : 32770, + "serialNumber" : "ABC" + }, + "isGalileo" : false, + "macAddress" : "12:22", + "pins" : { + "created" : [], + "loaded" : [] + }, + "skuNumber" : "AUG-MD01", + "supportsEntryCodes" : true, + "timeZone" : "Pacific/Hawaii", + "zWaveEnabled" : false +} diff --git a/tests/fixtures/august/get_lock.online_with_doorsense.json b/tests/fixtures/august/get_lock.online_with_doorsense.json index b0f9475c009..f7376570482 100644 --- a/tests/fixtures/august/get_lock.online_with_doorsense.json +++ b/tests/fixtures/august/get_lock.online_with_doorsense.json @@ -17,7 +17,7 @@ "Created" : "2000-00-00T00:00:00.447Z", "HouseID" : "123", "HouseName" : "Test", - "LockID" : "ABC", + "LockID" : "online_with_doorsense", "LockName" : "Online door with doorsense", "LockStatus" : { "dateTime" : "2017-12-10T04:48:30.272Z",