diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index b3d031577c9..dba17e6ef93 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -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, ) ) diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index ef6781d14b3..11f794eef5d 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -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 diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 234eeb63288..0437c309039 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -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"} ) diff --git a/homeassistant/components/simplisafe/const.py b/homeassistant/components/simplisafe/const.py index 884afdcfa37..36d191d0ab8 100644 --- a/homeassistant/components/simplisafe/const.py +++ b/homeassistant/components/simplisafe/const.py @@ -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" diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index cb4ca9b88ca..82177fb4387 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -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