Move SimpliSafe REST API to DataUpdateCoordinator (#41919)

* Mirgrate SimpliSafe REST API to DataUpdateCoordinator

* Docstring

* More work

* Good to go

* Linting

* Restore previous initial event check

* Linting

* Comment

* Simplify listener

* Code review

* Cleanup
pull/41995/head^2
Aaron Bach 2020-10-17 13:40:34 -06:00 committed by GitHub
parent 183f94364a
commit 8b6336a91a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 105 additions and 109 deletions

View File

@ -35,15 +35,17 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.service import (
async_register_admin_service,
verify_domain_control,
)
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from .const import (
_LOGGER,
ATTR_ALARM_DURATION,
ATTR_ALARM_VOLUME,
ATTR_CHIME_VOLUME,
@ -56,11 +58,11 @@ from .const import (
DATA_CLIENT,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
LOGGER,
VOLUMES,
)
DATA_LISTENER = "listener"
TOPIC_UPDATE_REST_API = "simplisafe_update_rest_api_{0}"
TOPIC_UPDATE_WEBSOCKET = "simplisafe_update_websocket_{0}"
EVENT_SIMPLISAFE_EVENT = "SIMPLISAFE_EVENT"
@ -147,9 +149,11 @@ def _async_save_refresh_token(hass, config_entry, token):
async def async_get_client_id(hass):
"""Get a client ID (based on the HASS unique ID) for the SimpliSafe API."""
"""Get a client ID (based on the HASS unique ID) for the SimpliSafe API.
Note that SimpliSafe requires full, "dashed" versions of UUIDs.
"""
hass_id = await hass.helpers.instance_id.async_get()
# SimpliSafe requires full, "dashed" versions of UUIDs:
return str(UUID(hass_id))
@ -173,8 +177,6 @@ async def async_setup(hass, config):
async def async_setup_entry(hass, config_entry):
"""Set up SimpliSafe as config entry."""
hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = []
entry_updates = {}
if not config_entry.unique_id:
# If the config entry doesn't already have a unique ID, set one:
@ -201,17 +203,18 @@ async def async_setup_entry(hass, config_entry):
config_entry.data[CONF_TOKEN], client_id=client_id, session=websession
)
except InvalidCredentialsError:
_LOGGER.error("Invalid credentials provided")
LOGGER.error("Invalid credentials provided")
return False
except SimplipyError as err:
_LOGGER.error("Config entry failed: %s", err)
LOGGER.error("Config entry failed: %s", err)
raise ConfigEntryNotReady from err
_async_save_refresh_token(hass, config_entry, api.refresh_token)
simplisafe = SimpliSafe(hass, api, config_entry)
simplisafe = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = SimpliSafe(
hass, api, config_entry
)
await simplisafe.async_init()
hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = simplisafe
for platform in SUPPORTED_PLATFORMS:
hass.async_create_task(
@ -226,7 +229,7 @@ async def async_setup_entry(hass, config_entry):
"""Decorate."""
system_id = int(call.data[ATTR_SYSTEM_ID])
if system_id not in simplisafe.systems:
_LOGGER.error("Unknown system ID in service call: %s", system_id)
LOGGER.error("Unknown system ID in service call: %s", system_id)
return
await coro(call)
@ -240,7 +243,7 @@ async def async_setup_entry(hass, config_entry):
"""Decorate."""
system = simplisafe.systems[int(call.data[ATTR_SYSTEM_ID])]
if system.version != 3:
_LOGGER.error("Service only available on V3 systems")
LOGGER.error("Service only available on V3 systems")
return
await coro(call)
@ -254,7 +257,7 @@ async def async_setup_entry(hass, config_entry):
try:
await system.clear_notifications()
except SimplipyError as err:
_LOGGER.error("Error during service call: %s", err)
LOGGER.error("Error during service call: %s", err)
return
@verify_system_exists
@ -265,7 +268,7 @@ async def async_setup_entry(hass, config_entry):
try:
await system.remove_pin(call.data[ATTR_PIN_LABEL_OR_VALUE])
except SimplipyError as err:
_LOGGER.error("Error during service call: %s", err)
LOGGER.error("Error during service call: %s", err)
return
@verify_system_exists
@ -276,7 +279,7 @@ async def async_setup_entry(hass, config_entry):
try:
await system.set_pin(call.data[ATTR_PIN_LABEL], call.data[ATTR_PIN_VALUE])
except SimplipyError as err:
_LOGGER.error("Error during service call: %s", err)
LOGGER.error("Error during service call: %s", err)
return
@verify_system_exists
@ -294,7 +297,7 @@ async def async_setup_entry(hass, config_entry):
}
)
except SimplipyError as err:
_LOGGER.error("Error during service call: %s", err)
LOGGER.error("Error during service call: %s", err)
return
for service, method, schema in [
@ -326,9 +329,8 @@ async def async_unload_entry(hass, entry):
)
if unload_ok:
hass.data[DOMAIN][DATA_CLIENT].pop(entry.entry_id)
for remove_listener in hass.data[DOMAIN][DATA_LISTENER][entry.entry_id]:
remove_listener()
hass.data[DOMAIN][DATA_LISTENER].pop(entry.entry_id)
remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(entry.entry_id)
remove_listener()
return unload_ok
@ -349,16 +351,16 @@ class SimpliSafeWebsocket:
@staticmethod
def _on_connect():
"""Define a handler to fire when the websocket is connected."""
_LOGGER.info("Connected to websocket")
LOGGER.info("Connected to websocket")
@staticmethod
def _on_disconnect():
"""Define a handler to fire when the websocket is disconnected."""
_LOGGER.info("Disconnected from websocket")
LOGGER.info("Disconnected from websocket")
def _on_event(self, event):
"""Define a handler to fire when a new SimpliSafe event arrives."""
_LOGGER.debug("New websocket event: %s", event)
LOGGER.debug("New websocket event: %s", event)
async_dispatcher_send(
self._hass, TOPIC_UPDATE_WEBSOCKET.format(event.system_id), event
)
@ -408,6 +410,7 @@ class SimpliSafe:
self._hass = hass
self._system_notifications = {}
self.config_entry = config_entry
self.coordinator = None
self.initial_event_to_use = {}
self.systems = {}
self.websocket = SimpliSafeWebsocket(hass, api.websocket)
@ -430,7 +433,7 @@ class SimpliSafe:
if not to_add:
return
_LOGGER.debug("New system notifications: %s", to_add)
LOGGER.debug("New system notifications: %s", to_add)
self._system_notifications[system.system_id].update(to_add)
@ -457,10 +460,10 @@ class SimpliSafe:
"""Define an event handler to disconnect from the websocket."""
await self.websocket.async_disconnect()
self._hass.data[DOMAIN][DATA_LISTENER][self.config_entry.entry_id].append(
self._hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, async_websocket_disconnect
)
self._hass.data[DOMAIN][DATA_LISTENER][
self.config_entry.entry_id
] = self._hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, async_websocket_disconnect
)
self.systems = await self._api.get_systems()
@ -481,34 +484,28 @@ class SimpliSafe:
system.system_id
] = await system.get_latest_event()
except SimplipyError as err:
_LOGGER.error("Error while fetching initial event: %s", err)
LOGGER.error("Error while fetching initial event: %s", err)
self.initial_event_to_use[system.system_id] = {}
async def refresh(event_time):
"""Refresh data from the SimpliSafe account."""
await self.async_update()
self._hass.data[DOMAIN][DATA_LISTENER][self.config_entry.entry_id].append(
async_track_time_interval(self._hass, refresh, DEFAULT_SCAN_INTERVAL)
self.coordinator = DataUpdateCoordinator(
self._hass,
LOGGER,
name=self.config_entry.data[CONF_USERNAME],
update_interval=DEFAULT_SCAN_INTERVAL,
update_method=self.async_update,
)
await self.async_update()
async def async_update(self):
"""Get updated data from SimpliSafe."""
async def update_system(system):
async def async_update_system(system):
"""Update a system."""
await system.update(cached=False)
self._async_process_new_notifications(system)
_LOGGER.debug('Updated REST API data for "%s"', system.address)
async_dispatcher_send(
self._hass, TOPIC_UPDATE_REST_API.format(system.system_id)
)
tasks = [update_system(system) for system in self.systems.values()]
tasks = [async_update_system(system) for system in self.systems.values()]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, InvalidCredentialsError):
if self._emergency_refresh_token_used:
@ -532,9 +529,10 @@ class SimpliSafe:
)
)
return
LOGGER.error("Update failed with stored refresh token")
raise UpdateFailed from result
_LOGGER.warning("SimpliSafe cloud error; trying stored refresh token")
LOGGER.warning("SimpliSafe cloud error; trying stored refresh token")
self._emergency_refresh_token_used = True
try:
@ -543,23 +541,23 @@ class SimpliSafe:
)
return
except SimplipyError as err:
_LOGGER.error("Error while using stored refresh token: %s", err)
return
LOGGER.error("Error while using stored refresh token: %s", err)
raise UpdateFailed from err
if isinstance(result, EndpointUnavailable):
# In case the user attempt an action not allowed in their current plan,
# we merely log that message at INFO level (so the user is aware,
# but not spammed with ERROR messages that they cannot change):
_LOGGER.info(result)
return
LOGGER.info(result)
raise UpdateFailed from result
if isinstance(result, SimplipyError):
_LOGGER.error("SimpliSafe error while updating: %s", result)
return
LOGGER.error("SimpliSafe error while updating: %s", result)
raise UpdateFailed from result
if isinstance(result, Exception):
_LOGGER.error("Unknown error while updating: %s", result)
return
LOGGER.error("Unknown error while updating: %s", result)
raise UpdateFailed from result
if self._api.refresh_token != self.config_entry.data[CONF_TOKEN]:
_async_save_refresh_token(
@ -572,11 +570,12 @@ class SimpliSafe:
self._emergency_refresh_token_used = False
class SimpliSafeEntity(Entity):
class SimpliSafeEntity(CoordinatorEntity):
"""Define a base SimpliSafe entity."""
def __init__(self, simplisafe, system, name, *, serial=None):
"""Initialize."""
super().__init__(simplisafe.coordinator)
self._name = name
self._online = True
self._simplisafe = simplisafe
@ -619,11 +618,15 @@ class SimpliSafeEntity(Entity):
def available(self):
"""Return whether the entity is available."""
# We can easily detect if the V3 system is offline, but no simple check exists
# for the V2 system. Therefore, we mark the entity as available if:
# for the V2 system. Therefore, assuming the coordinator hasn't failed, we mark
# the entity as available if:
# 1. We can verify that the system is online (assuming True if we can't)
# 2. We can verify that the entity is online
system_offline = self._system.version == 3 and self._system.offline
return not system_offline and self._online
return (
self.coordinator.last_update_success
and not (self._system.version == 3 and self._system.offline)
and self._online
)
@property
def device_info(self):
@ -675,50 +678,43 @@ class SimpliSafeEntity(Entity):
self.async_update_from_websocket_event(event)
@callback
def _handle_coordinator_update(self):
"""Update the entity with new REST API data."""
self.async_update_from_rest_api()
self.async_write_ha_state()
@callback
def _handle_websocket_update(self, event):
"""Update the entity with new websocket data."""
# Ignore this event if it belongs to a system other than this one:
if event.system_id != self._system.system_id:
return
# Ignore this event if this entity hasn't expressed interest in its type:
if event.event_type not in self.websocket_events_to_listen_for:
return
# Ignore this event if it belongs to a entity with a different serial
# number from this one's:
if (
event.event_type in WEBSOCKET_EVENTS_REQUIRING_SERIAL
and event.sensor_serial != self._serial
):
return
self._async_internal_update_from_websocket_event(event)
self.async_write_ha_state()
async def async_added_to_hass(self):
"""Register callbacks."""
@callback
def rest_api_update():
"""Update the entity with new REST API data."""
self.async_update_from_rest_api()
self.async_write_ha_state()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
TOPIC_UPDATE_REST_API.format(self._system.system_id),
rest_api_update,
)
)
@callback
def websocket_update(event):
"""Update the entity with new websocket data."""
# Ignore this event if it belongs to a system other than this one:
if event.system_id != self._system.system_id:
return
# Ignore this event if this entity hasn't expressed interest in its type:
if event.event_type not in self.websocket_events_to_listen_for:
return
# Ignore this event if it belongs to a entity with a different serial
# number from this one's:
if (
event.event_type in WEBSOCKET_EVENTS_REQUIRING_SERIAL
and event.sensor_serial != self._serial
):
return
self._async_internal_update_from_websocket_event(event)
self.async_write_ha_state()
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
TOPIC_UPDATE_WEBSOCKET.format(self._system.system_id),
websocket_update,
self._handle_websocket_update,
)
)

View File

@ -38,7 +38,6 @@ from homeassistant.core import callback
from . import SimpliSafeEntity
from .const import (
_LOGGER,
ATTR_ALARM_DURATION,
ATTR_ALARM_VOLUME,
ATTR_CHIME_VOLUME,
@ -50,6 +49,7 @@ from .const import (
ATTR_VOICE_PROMPT_VOLUME,
DATA_CLIENT,
DOMAIN,
LOGGER,
VOLUME_STRING_MAP,
)
@ -144,7 +144,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity):
return True
if not code or code != self._simplisafe.config_entry.options[CONF_CODE]:
_LOGGER.warning(
LOGGER.warning(
"Incorrect alarm code entered (target state: %s): %s", state, code
)
return False
@ -159,7 +159,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity):
try:
await self._system.set_off()
except SimplipyError as err:
_LOGGER.error('Error while disarming "%s": %s', self._system.name, err)
LOGGER.error('Error while disarming "%s": %s', self._system.name, err)
return
self._state = STATE_ALARM_DISARMED
@ -172,7 +172,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity):
try:
await self._system.set_home()
except SimplipyError as err:
_LOGGER.error('Error while arming "%s" (home): %s', self._system.name, err)
LOGGER.error('Error while arming "%s" (home): %s', self._system.name, err)
return
self._state = STATE_ALARM_ARMED_HOME
@ -185,7 +185,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity):
try:
await self._system.set_away()
except SimplipyError as err:
_LOGGER.error('Error while arming "%s" (away): %s', self._system.name, err)
LOGGER.error('Error while arming "%s" (away): %s', self._system.name, err)
return
self._state = STATE_ALARM_ARMING

View File

@ -13,7 +13,7 @@ from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client
from . import async_get_client_id
from .const import _LOGGER, DOMAIN # pylint: disable=unused-import
from .const import DOMAIN, LOGGER # pylint: disable=unused-import
class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
@ -62,12 +62,12 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
try:
simplisafe = await self._async_get_simplisafe_api()
except PendingAuthorizationError:
_LOGGER.info("Awaiting confirmation of MFA email click")
LOGGER.info("Awaiting confirmation of MFA email click")
return await self.async_step_mfa()
except InvalidCredentialsError:
errors = {"base": "invalid_credentials"}
except SimplipyError as err:
_LOGGER.error("Unknown error while logging into SimpliSafe: %s", err)
LOGGER.error("Unknown error while logging into SimpliSafe: %s", err)
errors = {"base": "unknown"}
if errors:
@ -101,7 +101,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
try:
simplisafe = await self._async_get_simplisafe_api()
except PendingAuthorizationError:
_LOGGER.error("Still awaiting confirmation of MFA email click")
LOGGER.error("Still awaiting confirmation of MFA email click")
return self.async_show_form(
step_id="mfa", errors={"base": "still_awaiting_mfa"}
)

View File

@ -4,7 +4,7 @@ import logging
from simplipy.system.v3 import VOLUME_HIGH, VOLUME_LOW, VOLUME_MEDIUM, VOLUME_OFF
_LOGGER = logging.getLogger(__package__)
LOGGER = logging.getLogger(__package__)
DOMAIN = "simplisafe"

View File

@ -7,7 +7,7 @@ from homeassistant.components.lock import LockEntity
from homeassistant.core import callback
from . import SimpliSafeEntity
from .const import _LOGGER, DATA_CLIENT, DOMAIN
from .const import DATA_CLIENT, DOMAIN, LOGGER
ATTR_LOCK_LOW_BATTERY = "lock_low_battery"
ATTR_JAMMED = "jammed"
@ -48,7 +48,7 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity):
try:
await self._lock.lock()
except SimplipyError as err:
_LOGGER.error('Error while locking "%s": %s', self._lock.name, err)
LOGGER.error('Error while locking "%s": %s', self._lock.name, err)
return
self._is_locked = True
@ -58,7 +58,7 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity):
try:
await self._lock.unlock()
except SimplipyError as err:
_LOGGER.error('Error while unlocking "%s": %s', self._lock.name, err)
LOGGER.error('Error while unlocking "%s": %s', self._lock.name, err)
return
self._is_locked = False