From 223c01d8428d15611f168ed08e25c674f3ce6d42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2020 17:44:23 -1000 Subject: [PATCH] Coordinate all august detail and activity updates (#32249) * Removing polling from august * Now using subscribers to the detail and activity * Fix hash to list keys * continue to the next house if one fails * Add async_signal_device_id_update * Fix double initial update * Handle self.hass not being available until after async_added_to_hass * Remove not needed await * Fix regression with device name --- homeassistant/components/august/__init__.py | 213 ++++++++---------- homeassistant/components/august/activity.py | 43 ++-- .../components/august/binary_sensor.py | 211 +++++++---------- homeassistant/components/august/camera.py | 100 ++------ homeassistant/components/august/const.py | 2 - homeassistant/components/august/entity.py | 67 ++++++ homeassistant/components/august/lock.py | 102 +++------ homeassistant/components/august/sensor.py | 42 +--- homeassistant/components/august/subscriber.py | 53 +++++ tests/components/august/test_binary_sensor.py | 6 +- tests/components/august/test_camera.py | 4 +- tests/components/august/test_lock.py | 2 + 12 files changed, 385 insertions(+), 460 deletions(-) create mode 100644 homeassistant/components/august/entity.py create mode 100644 homeassistant/components/august/subscriber.py diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 5774b0b9e9a..b3cbc161dda 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -5,8 +5,6 @@ import logging 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 @@ -15,13 +13,10 @@ from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.util import Throttle from .activity import ActivityStream from .const import ( AUGUST_COMPONENTS, - AUGUST_DEVICE_UPDATE, CONF_ACCESS_TOKEN_CACHE_FILE, CONF_INSTALL_ID, CONF_LOGIN_METHOD, @@ -36,13 +31,12 @@ from .const import ( ) from .exceptions import InvalidAuth, RequireValidation from .gateway import AugustGateway +from .subscriber import AugustSubscriberMixin _LOGGER = logging.getLogger(__name__) TWO_FA_REVALIDATE = "verify_configurator" -DEFAULT_SCAN_INTERVAL = MIN_TIME_BETWEEN_DETAIL_UPDATES - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -134,7 +128,7 @@ async def async_setup_august(hass, config_entry, august_gateway): hass.data[DOMAIN][entry_id][DATA_AUGUST] = await hass.async_add_executor_job( AugustData, hass, august_gateway ) - await hass.data[DOMAIN][entry_id][DATA_AUGUST].activity_stream.async_start() + await hass.data[DOMAIN][entry_id][DATA_AUGUST].activity_stream.async_setup() for component in AUGUST_COMPONENTS: hass.async_create_task( @@ -180,8 +174,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" - hass.data[DOMAIN][entry.entry_id][DATA_AUGUST].activity_stream.async_stop() - unload_ok = all( await asyncio.gather( *[ @@ -197,131 +189,103 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): return unload_ok -class AugustData: +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._api = august_gateway.api + self._device_detail_by_id = {} - self._doorbells = ( - self._api.get_doorbells(self._august_gateway.access_token) or [] + locks = self._api.get_operable_locks(self._august_gateway.access_token) or [] + doorbells = self._api.get_doorbells(self._august_gateway.access_token) or [] + + self._doorbells_by_id = dict((device.device_id, device) for device in doorbells) + self._locks_by_id = dict((device.device_id, device) for device in locks) + self._house_ids = set( + device.house_id for device in itertools.chain(locks, doorbells) ) - self._locks = ( - self._api.get_operable_locks(self._august_gateway.access_token) or [] + + self._refresh_device_detail_by_ids( + [device.device_id for device in itertools.chain(locks, doorbells)] ) - self._house_ids = set() - for device in itertools.chain(self._doorbells, self._locks): - self._house_ids.add(device.house_id) - self._doorbell_detail_by_id = {} - self._lock_detail_by_id = {} - - # We check the locks right away so we can - # remove inoperative ones - self._update_locks_detail() - self._update_doorbells_detail() - self._filter_inoperative_locks() + # 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( hass, self._api, self._august_gateway, self._house_ids ) - @property - def house_ids(self): - """Return a list of house_ids.""" - return self._house_ids - @property def doorbells(self): - """Return a list of doorbells.""" - return self._doorbells + """Return a list of py-august Doorbell objects.""" + return self._doorbells_by_id.values() @property def locks(self): - """Return a list of locks.""" - return self._locks + """Return a list of py-august Lock objects.""" + return self._locks_by_id.values() - 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 + 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_get_doorbell_detail(self, device_id): - """Return doorbell detail.""" - await self._async_update_doorbells_detail() - return self._doorbell_detail_by_id.get(device_id) + def _refresh(self, time): + self._refresh_device_detail_by_ids(self._subscriptions.keys()) - @Throttle(MIN_TIME_BETWEEN_DETAIL_UPDATES) - async def _async_update_doorbells_detail(self): - await self._hass.async_add_executor_job(self._update_doorbells_detail) - - def _update_doorbells_detail(self): - self._doorbell_detail_by_id = self._update_device_detail( - "doorbell", self._doorbells, self._api.get_doorbell_detail - ) - - def lock_has_doorsense(self, device_id): - """Determine if a lock has doorsense installed and can tell when the door is open or closed.""" - # We do not update here since this is not expected - # to change until restart - if self._lock_detail_by_id[device_id] is None: - return False - return self._lock_detail_by_id[device_id].doorsense - - async def async_get_lock_detail(self, device_id): - """Return lock detail.""" - await self._async_update_locks_detail() - return self._lock_detail_by_id[device_id] - - def get_device_name(self, device_id): - """Return doorbell or lock name as August has it stored.""" - for device in itertools.chain(self._locks, self._doorbells): - if device.device_id == device_id: - return device.device_name - - @Throttle(MIN_TIME_BETWEEN_DETAIL_UPDATES) - async def _async_update_locks_detail(self): - await self._hass.async_add_executor_job(self._update_locks_detail) - - def _update_locks_detail(self): - self._lock_detail_by_id = self._update_device_detail( - "lock", self._locks, self._api.get_lock_detail - ) - - def _update_device_detail(self, device_type, devices, api_call): - detail_by_id = {} - - _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._august_gateway.access_token, device_id + def _refresh_device_detail_by_ids(self, device_ids_list): + for device_id in device_ids_list: + if device_id in self._locks_by_id: + self._update_device_detail( + self._locks_by_id[device_id], self._api.get_lock_detail ) - except RequestException as ex: - _LOGGER.error( - "Request error trying to retrieve %s details for %s. %s", - device_type, - device.device_name, - ex, + elif device_id in self._doorbells_by_id: + self._update_device_detail( + self._doorbells_by_id[device_id], self._api.get_doorbell_detail ) + _LOGGER.debug( + "signal_device_id_update (from detail updates): %s", device_id, + ) + self.signal_device_id_update(device_id) - _LOGGER.debug("Completed retrieving %s detail", device_type) - return detail_by_id - - async def async_signal_operation_changed_device_state(self, device_id): - """Signal a device update when an operation changes state.""" + def _update_device_detail(self, device, api_call): _LOGGER.debug( - "async_dispatcher_send (from operation): AUGUST_DEVICE_UPDATE-%s", device_id + "Started retrieving detail for %s (%s)", + device.device_name, + device.device_id, ) - async_dispatcher_send(self._hass, f"{AUGUST_DEVICE_UPDATE}-{device_id}") + + try: + self._device_detail_by_id[device.device_id] = api_call( + self._august_gateway.access_token, device.device_id + ) + except RequestException 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 def lock(self, device_id): """Lock the device.""" @@ -347,20 +311,41 @@ class AugustData: try: ret = func(*args, **kwargs) except AugustApiHTTPError as err: - device_name = self.get_device_name(device_id) + 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 _filter_inoperative_locks(self): + 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 - operative_locks = [] - for lock in self._locks: - lock_detail = self._lock_detail_by_id.get(lock.device_id) + 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.", @@ -377,6 +362,8 @@ class AugustData: lock.device_name, ) else: - operative_locks.append(lock) + lock_is_operative = True - self._locks = operative_locks + if not lock_is_operative: + del self._locks_by_id[device_id] + del self._device_detail_by_id[device_id] diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index e3d313dc527..c65083363a4 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -4,12 +4,10 @@ import logging from requests import RequestException -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.dt import utcnow -from .const import ACTIVITY_UPDATE_INTERVAL, AUGUST_DEVICE_UPDATE +from .const import ACTIVITY_UPDATE_INTERVAL +from .subscriber import AugustSubscriberMixin _LOGGER = logging.getLogger(__name__) @@ -17,11 +15,12 @@ ACTIVITY_STREAM_FETCH_LIMIT = 10 ACTIVITY_CATCH_UP_FETCH_LIMIT = 200 -class ActivityStream: +class ActivityStream(AugustSubscriberMixin): """August activity stream handler.""" def __init__(self, hass, api, august_gateway, house_ids): """Init August activity stream object.""" + super().__init__(hass, ACTIVITY_UPDATE_INTERVAL) self._hass = hass self._august_gateway = august_gateway self._api = api @@ -30,22 +29,11 @@ class ActivityStream: self._last_update_time = None self._abort_async_track_time_interval = None - async def async_start(self): - """Start fetching updates from the activity stream.""" - await self._async_update(utcnow) - self._abort_async_track_time_interval = async_track_time_interval( - self._hass, self._async_update, ACTIVITY_UPDATE_INTERVAL - ) + async def async_setup(self): + """Token refresh check and catch up the activity stream.""" + await self._refresh(utcnow) - @callback - def async_stop(self): - """Stop fetching updates from the activity stream.""" - if self._abort_async_track_time_interval is None: - return - self._abort_async_track_time_interval() - - @callback - def async_get_latest_device_activity(self, device_id, activity_types): + def get_latest_device_activity(self, device_id, activity_types): """Return latest activity that is one of the acitivty_types.""" if device_id not in self._latest_activities_by_id_type: return None @@ -65,14 +53,14 @@ class ActivityStream: return latest_activity - async def _async_update(self, time): + async def _refresh(self, time): """Update the activity stream from August.""" # This is the only place we refresh the api token await self._august_gateway.async_refresh_access_token_if_needed() - await self._update_device_activities(time) + await self._async_update_device_activities(time) - async def _update_device_activities(self, time): + async def _async_update_device_activities(self, time): _LOGGER.debug("Start retrieving device activities") limit = ( @@ -98,6 +86,9 @@ class ActivityStream: house_id, ex, ) + # Make sure we process the next house if one of them fails + continue + _LOGGER.debug( "Completed retrieving device activities for house id %s", house_id ) @@ -107,12 +98,10 @@ class ActivityStream: if updated_device_ids: for device_id in updated_device_ids: _LOGGER.debug( - "async_dispatcher_send (from activity stream): AUGUST_DEVICE_UPDATE-%s", + "async_signal_device_id_update (from activity stream): %s", device_id, ) - async_dispatcher_send( - self._hass, f"{AUGUST_DEVICE_UPDATE}-{device_id}" - ) + self.async_signal_device_id_update(device_id) self._last_update_time = time diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index ea9acd600b2..109ed425157 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -13,51 +13,45 @@ from homeassistant.components.binary_sensor import ( BinarySensorDevice, ) from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow -from .const import ( - AUGUST_DEVICE_UPDATE, - DATA_AUGUST, - DEFAULT_NAME, - DOMAIN, - MIN_TIME_BETWEEN_DETAIL_UPDATES, -) +from .const import DATA_AUGUST, DOMAIN +from .entity import AugustEntityMixin _LOGGER = logging.getLogger(__name__) TIME_TO_DECLARE_DETECTION = timedelta(seconds=60) -SCAN_INTERVAL = MIN_TIME_BETWEEN_DETAIL_UPDATES - -async def _async_retrieve_online_state(data, detail): +def _retrieve_online_state(data, detail): """Get the latest state of the sensor.""" + # 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 + return detail.is_online or detail.is_standby -async def _async_retrieve_motion_state(data, detail): +def _retrieve_motion_state(data, detail): - return await _async_activity_time_based_state( + return _activity_time_based_state( data, detail.device_id, [ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING], ) -async def _async_retrieve_ding_state(data, detail): +def _retrieve_ding_state(data, detail): - return await _async_activity_time_based_state( + return _activity_time_based_state( data, detail.device_id, [ActivityType.DOORBELL_DING] ) -async def _async_activity_time_based_state(data, device_id, activity_types): +def _activity_time_based_state(data, device_id, activity_types): """Get the latest state of the sensor.""" - latest = data.activity_stream.async_get_latest_device_activity( - device_id, activity_types - ) + latest = data.activity_stream.get_latest_device_activity(device_id, activity_types) if latest is not None: start = latest.activity_start_time @@ -69,15 +63,17 @@ async def _async_activity_time_based_state(data, device_id, activity_types): SENSOR_NAME = 0 SENSOR_DEVICE_CLASS = 1 SENSOR_STATE_PROVIDER = 2 +SENSOR_STATE_IS_TIME_BASED = 3 -# sensor_type: [name, device_class, async_state_provider] +# sensor_type: [name, device_class, state_provider, is_time_based] SENSOR_TYPES_DOORBELL = { - "doorbell_ding": ["Ding", DEVICE_CLASS_OCCUPANCY, _async_retrieve_ding_state], - "doorbell_motion": ["Motion", DEVICE_CLASS_MOTION, _async_retrieve_motion_state], + "doorbell_ding": ["Ding", DEVICE_CLASS_OCCUPANCY, _retrieve_ding_state, True], + "doorbell_motion": ["Motion", DEVICE_CLASS_MOTION, _retrieve_motion_state, True], "doorbell_online": [ "Online", DEVICE_CLASS_CONNECTIVITY, - _async_retrieve_online_state, + _retrieve_online_state, + False, ], } @@ -88,8 +84,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): devices = [] for door in data.locks: - if not data.lock_has_doorsense(door.device_id): - _LOGGER.debug("Not adding sensor class door for lock %s ", door.device_name) + detail = data.get_device_detail(door.device_id) + if not detail.doorsense: + _LOGGER.debug( + "Not adding sensor class door for lock %s because it does not have doorsense.", + door.device_name, + ) continue _LOGGER.debug("Adding sensor class door for %s", door.device_name) @@ -107,19 +107,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(devices, True) -class AugustDoorBinarySensor(BinarySensorDevice): +class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorDevice): """Representation of an August Door binary sensor.""" - def __init__(self, data, sensor_type, door): + def __init__(self, data, sensor_type, device): """Initialize the sensor.""" - self._undo_dispatch_subscription = None + super().__init__(data, device) self._data = data self._sensor_type = sensor_type - self._door = door + self._device = device self._state = None self._available = False - self._firmware_version = None - self._model = None + self._update_from_data() @property def available(self): @@ -139,76 +138,43 @@ class AugustDoorBinarySensor(BinarySensorDevice): @property def name(self): """Return the name of the binary sensor.""" - return f"{self._door.device_name} Open" + return f"{self._device.device_name} Open" - async def async_update(self): + @callback + def _update_from_data(self): """Get the latest state of the sensor and update activity.""" - door_activity = self._data.activity_stream.async_get_latest_device_activity( - self._door.device_id, [ActivityType.DOOR_OPERATION] + door_activity = self._data.activity_stream.get_latest_device_activity( + self._device_id, [ActivityType.DOOR_OPERATION] ) - detail = await self._data.async_get_lock_detail(self._door.device_id) + detail = self._detail if door_activity is not None: update_lock_detail_from_activity(detail, door_activity) - lock_door_state = None - self._available = False - if detail is not None: - lock_door_state = detail.door_state - self._available = detail.bridge_is_online - self._firmware_version = detail.firmware_version - self._model = detail.model + lock_door_state = detail.door_state + self._available = detail.bridge_is_online self._state = lock_door_state == LockDoorStatus.OPEN @property def unique_id(self) -> str: """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, - "model": self._model, - } - - async def async_added_to_hass(self): - """Register callbacks.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - - self._undo_dispatch_subscription = async_dispatcher_connect( - self.hass, f"{AUGUST_DEVICE_UPDATE}-{self._door.device_id}", update - ) - - async def async_will_remove_from_hass(self): - """Undo subscription.""" - if self._undo_dispatch_subscription: - self._undo_dispatch_subscription() + return f"{self._device_id}_open" -class AugustDoorbellBinarySensor(BinarySensorDevice): +class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorDevice): """Representation of an August binary sensor.""" - def __init__(self, data, sensor_type, doorbell): + def __init__(self, data, sensor_type, device): """Initialize the sensor.""" - self._undo_dispatch_subscription = None + super().__init__(data, device) self._check_for_off_update_listener = None self._data = data self._sensor_type = sensor_type - self._doorbell = doorbell + self._device = device self._state = None self._available = False - self._firmware_version = None - self._model = None + self._update_from_data() @property def available(self): @@ -228,42 +194,47 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): @property def name(self): """Return the name of the binary sensor.""" - return f"{self._doorbell.device_name} {SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME]}" + return f"{self._device.device_name} {SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME]}" - async def async_update(self): + @property + def _state_provider(self): + """Return the state provider for the binary sensor.""" + return SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_STATE_PROVIDER] + + @property + def _is_time_based(self): + """Return true of false if the sensor is time based.""" + return SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_STATE_IS_TIME_BASED] + + @callback + def _update_from_data(self): """Get the latest state of the sensor.""" self._cancel_any_pending_updates() - async_state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][ - SENSOR_STATE_PROVIDER - ] - 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 - if self.device_class == DEVICE_CLASS_CONNECTIVITY: - self._available = True - else: - self._available = detail is not None and ( - detail.is_online or detail.is_standby - ) + self._state = self._state_provider(self._data, self._detail) - self._state = None - if detail is not None: - self._firmware_version = detail.firmware_version - self._model = detail.model - self._state = await async_state_provider(self._data, detail) - if self._state and self.device_class != DEVICE_CLASS_CONNECTIVITY: - self._schedule_update_to_recheck_turn_off_sensor() + if self._is_time_based: + self._available = _retrieve_online_state(self._data, self._detail) + self._schedule_update_to_recheck_turn_off_sensor() + else: + self._available = True def _schedule_update_to_recheck_turn_off_sensor(self): """Schedule an update to recheck the sensor to see if it is ready to turn off.""" + # If the sensor is already off there is nothing to do + if not self._state: + return + + # self.hass is only available after setup is completed + # and we will recheck in async_added_to_hass + if not self.hass: + return + @callback def _scheduled_update(now): """Timer callback for sensor update.""" - _LOGGER.debug("%s: executing scheduled update", self.entity_id) - self.async_schedule_update_ha_state(True) self._check_for_off_update_listener = None + self._update_from_data() self._check_for_off_update_listener = async_track_point_in_utc_time( self.hass, _scheduled_update, utcnow() + TIME_TO_DECLARE_DETECTION @@ -272,41 +243,19 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): def _cancel_any_pending_updates(self): """Cancel any updates to recheck a sensor to see if it is ready to turn off.""" if self._check_for_off_update_listener: + _LOGGER.debug("%s: canceled pending update", self.entity_id) self._check_for_off_update_listener() self._check_for_off_update_listener = None + async def async_added_to_hass(self): + """Call the mixin to subscribe and setup an async_track_point_in_utc_time to turn off the sensor if needed.""" + self._schedule_update_to_recheck_turn_off_sensor() + await super().async_added_to_hass() + @property def unique_id(self) -> str: """Get the unique id of the doorbell sensor.""" return ( - f"{self._doorbell.device_id}_" + f"{self._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, - "model": self._model, - } - - async def async_added_to_hass(self): - """Register callbacks.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - - self._undo_dispatch_subscription = async_dispatcher_connect( - self.hass, f"{AUGUST_DEVICE_UPDATE}-{self._doorbell.device_id}", update - ) - - async def async_will_remove_from_hass(self): - """Undo subscription.""" - if self._undo_dispatch_subscription: - self._undo_dispatch_subscription() diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index a499c43f0cf..ecadbb931c0 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -5,18 +5,9 @@ from august.util import update_doorbell_image_from_activity from homeassistant.components.camera import Camera from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import ( - AUGUST_DEVICE_UPDATE, - DATA_AUGUST, - DEFAULT_NAME, - DEFAULT_TIMEOUT, - DOMAIN, - MIN_TIME_BETWEEN_DETAIL_UPDATES, -) - -SCAN_INTERVAL = MIN_TIME_BETWEEN_DETAIL_UPDATES +from .const import DATA_AUGUST, DEFAULT_NAME, DEFAULT_TIMEOUT, DOMAIN +from .entity import AugustEntityMixin async def async_setup_entry(hass, config_entry, async_add_entities): @@ -30,31 +21,27 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(devices, True) -class AugustCamera(Camera): +class AugustCamera(AugustEntityMixin, Camera): """An implementation of a August security camera.""" - def __init__(self, data, doorbell, timeout): + def __init__(self, data, device, timeout): """Initialize a August security camera.""" - super().__init__() - self._undo_dispatch_subscription = None + super().__init__(data, device) self._data = data - self._doorbell = doorbell - self._doorbell_detail = None + self._device = device self._timeout = timeout self._image_url = None self._image_content = None - self._firmware_version = None - self._model = None @property def name(self): """Return the name of this device.""" - return self._doorbell.device_name + return f"{self._device.device_name} Camera" @property def is_recording(self): """Return true if the device is recording.""" - return self._doorbell.has_subscription + return self._device.has_subscription @property def motion_detection_enabled(self): @@ -69,77 +56,34 @@ class AugustCamera(Camera): @property def model(self): """Return the camera model.""" - return self._model + return self._detail.model - async def async_camera_image(self): - """Return bytes of camera image.""" - self._doorbell_detail = await self._data.async_get_doorbell_detail( - self._doorbell.device_id - ) - doorbell_activity = self._data.activity_stream.async_get_latest_device_activity( - self._doorbell.device_id, [ActivityType.DOORBELL_MOTION] + @callback + def _update_from_data(self): + """Get the latest state of the sensor.""" + doorbell_activity = self._data.activity_stream.get_latest_device_activity( + self._device_id, [ActivityType.DOORBELL_MOTION] ) if doorbell_activity is not None: - update_doorbell_image_from_activity( - self._doorbell_detail, doorbell_activity - ) + update_doorbell_image_from_activity(self._detail, doorbell_activity) - if self._doorbell_detail is None: - return None + async def async_camera_image(self): + """Return bytes of camera image.""" + self._update_from_data() - if self._image_url is not self._doorbell_detail.image_url: - self._image_url = self._doorbell_detail.image_url + if self._image_url is not self._detail.image_url: + self._image_url = self._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 - self._model = self._doorbell_detail.model - def _camera_image(self): """Return bytes of camera image.""" - return self._doorbell_detail.get_doorbell_image(timeout=self._timeout) + return self._detail.get_doorbell_image(timeout=self._timeout) @property 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, - "model": self._model, - } - - async def async_added_to_hass(self): - """Register callbacks.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - - self._undo_dispatch_subscription = async_dispatcher_connect( - self.hass, f"{AUGUST_DEVICE_UPDATE}-{self._doorbell.device_id}", update - ) - - async def async_will_remove_from_hass(self): - """Undo subscription.""" - if self._undo_dispatch_subscription: - self._undo_dispatch_subscription() + return f"{self._device_id:s}_camera" diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py index 79ed2d903af..923f90c331e 100644 --- a/homeassistant/components/august/const.py +++ b/homeassistant/components/august/const.py @@ -8,8 +8,6 @@ CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file" CONF_LOGIN_METHOD = "login_method" CONF_INSTALL_ID = "install_id" -AUGUST_DEVICE_UPDATE = "august_devices_update" - VERIFICATION_CODE_KEY = "verification_code" NOTIFICATION_ID = "august_notification" diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py new file mode 100644 index 00000000000..32e2e7acd10 --- /dev/null +++ b/homeassistant/components/august/entity.py @@ -0,0 +1,67 @@ +"""Base class for August entity.""" + +import logging + +from homeassistant.core import callback + +from . import DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AugustEntityMixin: + """Base implementation for August device.""" + + def __init__(self, data, device): + """Initialize an August device.""" + super().__init__() + self._data = data + self._device = device + self._undo_dispatch_subscription = None + + @property + def should_poll(self): + """Return False, updates are controlled via the hub.""" + return False + + @property + def _device_id(self): + return self._device.device_id + + @property + def _detail(self): + return self._data.get_device_detail(self._device.device_id) + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._device_id)}, + "name": self._device.device_name, + "manufacturer": DEFAULT_NAME, + "sw_version": self._detail.firmware_version, + "model": self._detail.model, + } + + @callback + def _update_from_data_and_write_state(self): + self._update_from_data() + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self._data.async_subscribe_device_id( + self._device_id, self._update_from_data_and_write_state + ) + self._data.activity_stream.async_subscribe_device_id( + self._device_id, self._update_from_data_and_write_state + ) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + self._data.async_unsubscribe_device_id( + self._device_id, self._update_from_data_and_write_state + ) + self._data.activity_stream.async_unsubscribe_device_id( + self._device_id, self._update_from_data_and_write_state + ) diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index bad5fd78fae..c072a589f6c 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -8,20 +8,12 @@ from august.util import update_lock_detail_from_activity from homeassistant.components.lock import LockDevice from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import ( - AUGUST_DEVICE_UPDATE, - DATA_AUGUST, - DEFAULT_NAME, - DOMAIN, - MIN_TIME_BETWEEN_DETAIL_UPDATES, -) +from .const import DATA_AUGUST, DOMAIN +from .entity import AugustEntityMixin _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = MIN_TIME_BETWEEN_DETAIL_UPDATES - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up August locks.""" @@ -35,20 +27,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(devices, True) -class AugustLock(LockDevice): +class AugustLock(AugustEntityMixin, LockDevice): """Representation of an August lock.""" - def __init__(self, data, lock): + def __init__(self, data, device): """Initialize the lock.""" - self._undo_dispatch_subscription = None + super().__init__(data, device) self._data = data - self._lock = lock + self._device = device self._lock_status = None - self._lock_detail = None self._changed_by = None self._available = False - self._firmware_version = None - self._model = None + self._update_from_data() async def async_lock(self, **kwargs): """Lock the device.""" @@ -60,52 +50,47 @@ class AugustLock(LockDevice): async def _call_lock_operation(self, lock_operation): activities = await self.hass.async_add_executor_job( - lock_operation, self._lock.device_id + lock_operation, self._device_id ) + detail = self._detail for lock_activity in activities: - update_lock_detail_from_activity(self._lock_detail, lock_activity) + update_lock_detail_from_activity(detail, lock_activity) if self._update_lock_status_from_detail(): - await self._data.async_signal_operation_changed_device_state( - self._lock.device_id + _LOGGER.debug( + "async_signal_device_id_update (from lock operation): %s", + self._device_id, ) + self._data.async_signal_device_id_update(self._device_id) def _update_lock_status_from_detail(self): - detail = self._lock_detail - lock_status = None - self._available = False - - if detail is not None: - lock_status = detail.lock_status - self._available = detail.bridge_is_online + detail = self._detail + lock_status = detail.lock_status + self._available = detail.bridge_is_online if self._lock_status != lock_status: self._lock_status = lock_status return True return False - async def async_update(self): + @callback + def _update_from_data(self): """Get the latest state of the sensor and update activity.""" - self._lock_detail = await self._data.async_get_lock_detail(self._lock.device_id) - lock_activity = self._data.activity_stream.async_get_latest_device_activity( - self._lock.device_id, [ActivityType.LOCK_OPERATION] + lock_detail = self._detail + lock_activity = self._data.activity_stream.get_latest_device_activity( + self._device_id, [ActivityType.LOCK_OPERATION] ) if lock_activity is not None: self._changed_by = lock_activity.operated_by - 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._model = self._lock_detail.model + update_lock_detail_from_activity(lock_detail, lock_activity) self._update_lock_status_from_detail() @property def name(self): """Return the name of this device.""" - return self._lock.device_name + return self._device.device_name @property def available(self): @@ -127,45 +112,14 @@ class AugustLock(LockDevice): @property def device_state_attributes(self): """Return the device specific state attributes.""" - if self._lock_detail is None: - return None + attributes = {ATTR_BATTERY_LEVEL: self._detail.battery_level} - attributes = {ATTR_BATTERY_LEVEL: self._lock_detail.battery_level} - - if self._lock_detail.keypad is not None: - attributes["keypad_battery_level"] = self._lock_detail.keypad.battery_level + if self._detail.keypad is not None: + attributes["keypad_battery_level"] = self._detail.keypad.battery_level 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, - "model": self._model, - } - @property def unique_id(self) -> str: """Get the unique id of the lock.""" - return f"{self._lock.device_id:s}_lock" - - async def async_added_to_hass(self): - """Register callbacks.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - - self._undo_dispatch_subscription = async_dispatcher_connect( - self.hass, f"{AUGUST_DEVICE_UPDATE}-{self._lock.device_id}", update - ) - - async def async_will_remove_from_hass(self): - """Undo subscription.""" - if self._undo_dispatch_subscription: - self._undo_dispatch_subscription() + return f"{self._device_id:s}_lock" diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 3e9bfc5d8de..6c7af3c0c7e 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -2,9 +2,11 @@ import logging from homeassistant.components.sensor import DEVICE_CLASS_BATTERY +from homeassistant.core import callback from homeassistant.helpers.entity import Entity -from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES +from .const import DATA_AUGUST, DOMAIN +from .entity import AugustEntityMixin BATTERY_LEVEL_FULL = "Full" BATTERY_LEVEL_MEDIUM = "Medium" @@ -12,22 +14,14 @@ BATTERY_LEVEL_LOW = "Low" _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = MIN_TIME_BETWEEN_DETAIL_UPDATES - def _retrieve_device_battery_state(detail): """Get the latest state of the sensor.""" - if detail is None: - return None - return detail.battery_level def _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 @@ -73,7 +67,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for sensor_type in SENSOR_TYPES_BATTERY: for device in batteries[sensor_type]: state_provider = SENSOR_TYPES_BATTERY[sensor_type]["state_provider"] - detail = await data.async_get_device_detail(device) + detail = data.get_device_detail(device.device_id) state = state_provider(detail) sensor_name = SENSOR_TYPES_BATTERY[sensor_type]["name"] if state is None: @@ -91,18 +85,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(devices, True) -class AugustBatterySensor(Entity): +class AugustBatterySensor(AugustEntityMixin, Entity): """Representation of an August sensor.""" def __init__(self, data, sensor_type, device): """Initialize the sensor.""" + super().__init__(data, device) self._data = data self._sensor_type = sensor_type self._device = device self._state = None self._available = False - self._firmware_version = None - self._model = None + self._update_from_data() @property def available(self): @@ -131,28 +125,14 @@ class AugustBatterySensor(Entity): sensor_name = SENSOR_TYPES_BATTERY[self._sensor_type]["name"] return f"{device_name} {sensor_name}" - async def async_update(self): + @callback + def _update_from_data(self): """Get the latest state of the sensor.""" state_provider = SENSOR_TYPES_BATTERY[self._sensor_type]["state_provider"] - detail = await self._data.async_get_device_detail(self._device) - self._state = state_provider(detail) + self._state = state_provider(self._detail) self._available = self._state is not None - if detail is not None: - self._firmware_version = detail.firmware_version - self._model = detail.model @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, - "model": self._model, - } + return f"{self._device_id}_{self._sensor_type}" diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py new file mode 100644 index 00000000000..62861270c30 --- /dev/null +++ b/homeassistant/components/august/subscriber.py @@ -0,0 +1,53 @@ +"""Base class for August entity.""" + + +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_time_interval + + +class AugustSubscriberMixin: + """Base implementation for a subscriber.""" + + def __init__(self, hass, update_interval): + """Initialize an subscriber.""" + super().__init__() + self._hass = hass + self._update_interval = update_interval + self._subscriptions = {} + self._unsub_interval = None + + @callback + def async_subscribe_device_id(self, device_id, update_callback): + """Add an callback subscriber.""" + if not self._subscriptions: + self._unsub_interval = async_track_time_interval( + self._hass, self._refresh, self._update_interval + ) + self._subscriptions.setdefault(device_id, []).append(update_callback) + + @callback + def async_unsubscribe_device_id(self, device_id, update_callback): + """Remove a callback subscriber.""" + self._subscriptions[device_id].remove(update_callback) + if not self._subscriptions[device_id]: + del self._subscriptions[device_id] + if not self._subscriptions: + self._unsub_interval() + self._unsub_interval = None + + @callback + def async_signal_device_id_update(self, device_id): + """Call the callbacks for a device_id.""" + if not self._subscriptions.get(device_id): + return + + for update_callback in self._subscriptions[device_id]: + update_callback() + + def signal_device_id_update(self, device_id): + """Call the callbacks for a device_id.""" + if not self._subscriptions.get(device_id): + return + + for update_callback in self._subscriptions[device_id]: + self._hass.loop.call_soon_threadsafe(update_callback) diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 565e082f841..30b70c3c397 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -130,5 +130,7 @@ async def test_doorbell_device_registry(hass): reg_device = device_registry.async_get_device( identifiers={("august", "tmt100")}, connections=set() ) - assert "hydra1" == reg_device.model - assert "3.1.0-HYDRC75+201909251139" == reg_device.sw_version + assert reg_device.model == "hydra1" + assert reg_device.name == "tmt100 Name" + assert reg_device.manufacturer == "August" + assert reg_device.sw_version == "3.1.0-HYDRC75+201909251139" diff --git a/tests/components/august/test_camera.py b/tests/components/august/test_camera.py index 9ed97ecbc29..4d9d48b0825 100644 --- a/tests/components/august/test_camera.py +++ b/tests/components/august/test_camera.py @@ -14,5 +14,5 @@ async def test_create_doorbell(hass): doorbell_details = [doorbell_one] await _create_august_with_devices(hass, doorbell_details) - camera_k98gidt45gul_name = hass.states.get("camera.k98gidt45gul_name") - assert camera_k98gidt45gul_name.state == STATE_IDLE + camera_k98gidt45gul_name_camera = hass.states.get("camera.k98gidt45gul_name_camera") + assert camera_k98gidt45gul_name_camera.state == STATE_IDLE diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 850d9677cfd..a620bdd1080 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -30,6 +30,8 @@ async def test_lock_device_registry(hass): ) assert reg_device.model == "AUG-MD01" assert reg_device.sw_version == "undefined-4.3.0-1.8.14" + assert reg_device.name == "online_with_doorsense Name" + assert reg_device.manufacturer == "August" async def test_one_lock_operation(hass):