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
This commit is contained in:
J. Nick Koston 2020-02-27 17:44:23 -10:00 committed by GitHub
parent fefbe02d44
commit 223c01d842
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 385 additions and 460 deletions

View file

@ -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 _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
)
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)
def _update_doorbells_detail(self):
self._doorbell_detail_by_id = self._update_device_detail(
"doorbell", self._doorbells, self._api.get_doorbell_detail
def _update_device_detail(self, device, api_call):
_LOGGER.debug(
"Started retrieving detail for %s (%s)",
device.device_name,
device.device_id,
)
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
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_type,
device.device_id,
device.device_name,
ex,
)
_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."""
_LOGGER.debug(
"async_dispatcher_send (from operation): AUGUST_DEVICE_UPDATE-%s", device_id
"Completed retrieving detail for %s (%s)",
device.device_name,
device.device_id,
)
async_dispatcher_send(self._hass, f"{AUGUST_DEVICE_UPDATE}-{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]

View file

@ -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

View file

@ -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
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:
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()

View file

@ -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"

View file

@ -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"

View file

@ -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
)

View file

@ -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,22 +50,21 @@ 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:
detail = self._detail
lock_status = detail.lock_status
self._available = detail.bridge_is_online
@ -84,28 +73,24 @@ class AugustLock(LockDevice):
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"

View file

@ -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}"

View file

@ -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)

View file

@ -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"

View file

@ -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

View file

@ -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):