diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index fb6e09a202b..ae5c3cd9527 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -2,12 +2,12 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterable from datetime import timedelta -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from simplipy import API -from simplipy.device import Device +from simplipy.device import Device, DeviceTypes from simplipy.errors import ( EndpointUnavailableError, InvalidCredentialsError, @@ -22,17 +22,42 @@ from simplipy.system.v3 import ( VOLUME_OFF, SystemV3, ) +from simplipy.websocket import ( + EVENT_AUTOMATIC_TEST, + EVENT_CAMERA_MOTION_DETECTED, + EVENT_CONNECTION_LOST, + EVENT_CONNECTION_RESTORED, + EVENT_DEVICE_TEST, + EVENT_DOORBELL_DETECTED, + EVENT_LOCK_LOCKED, + EVENT_LOCK_UNLOCKED, + EVENT_POWER_OUTAGE, + EVENT_POWER_RESTORED, + EVENT_SECRET_ALERT_TRIGGERED, + EVENT_SENSOR_PAIRED_AND_NAMED, + EVENT_USER_INITIATED_TEST, + WebsocketEvent, +) import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_CODE, CONF_CODE, CONF_TOKEN -from homeassistant.core import CoreState, HomeAssistant, ServiceCall, callback +from homeassistant.const import ( + ATTR_CODE, + CONF_CODE, + CONF_TOKEN, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import CoreState, Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_validation as cv, device_registry as dr, ) +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.service import ( async_register_admin_service, @@ -60,13 +85,32 @@ from .const import ( LOGGER, ) -EVENT_SIMPLISAFE_NOTIFICATION = "SIMPLISAFE_NOTIFICATION" +ATTR_CATEGORY = "category" +ATTR_LAST_EVENT_CHANGED_BY = "last_event_changed_by" +ATTR_LAST_EVENT_INFO = "last_event_info" +ATTR_LAST_EVENT_SENSOR_NAME = "last_event_sensor_name" +ATTR_LAST_EVENT_SENSOR_SERIAL = "last_event_sensor_serial" +ATTR_LAST_EVENT_SENSOR_TYPE = "last_event_sensor_type" +ATTR_LAST_EVENT_TIMESTAMP = "last_event_timestamp" +ATTR_LAST_EVENT_TYPE = "last_event_type" +ATTR_LAST_EVENT_TYPE = "last_event_type" +ATTR_MESSAGE = "message" +ATTR_PIN_LABEL = "label" +ATTR_PIN_LABEL_OR_VALUE = "label_or_pin" +ATTR_PIN_VALUE = "pin" +ATTR_SYSTEM_ID = "system_id" +ATTR_TIMESTAMP = "timestamp" DEFAULT_ENTITY_MODEL = "alarm_control_panel" DEFAULT_ENTITY_NAME = "Alarm Control Panel" DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) DEFAULT_SOCKET_MIN_RETRY = 15 +DISPATCHER_TOPIC_WEBSOCKET_EVENT = "simplisafe_websocket_event_{0}" + +EVENT_SIMPLISAFE_EVENT = "SIMPLISAFE_EVENT" +EVENT_SIMPLISAFE_NOTIFICATION = "SIMPLISAFE_NOTIFICATION" + PLATFORMS = ( "alarm_control_panel", "binary_sensor", @@ -74,14 +118,6 @@ PLATFORMS = ( "sensor", ) -ATTR_CATEGORY = "category" -ATTR_MESSAGE = "message" -ATTR_PIN_LABEL = "label" -ATTR_PIN_LABEL_OR_VALUE = "label_or_pin" -ATTR_PIN_VALUE = "pin" -ATTR_SYSTEM_ID = "system_id" -ATTR_TIMESTAMP = "timestamp" - VOLUMES = [VOLUME_OFF, VOLUME_LOW, VOLUME_MEDIUM, VOLUME_HIGH] SERVICE_BASE_SCHEMA = vol.Schema({vol.Required(ATTR_SYSTEM_ID): cv.positive_int}) @@ -126,6 +162,17 @@ SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA = SERVICE_BASE_SCHEMA.extend( } ) +WEBSOCKET_EVENTS_REQUIRING_SERIAL = [EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED] +WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT = [ + EVENT_AUTOMATIC_TEST, + EVENT_CAMERA_MOTION_DETECTED, + EVENT_DOORBELL_DETECTED, + EVENT_DEVICE_TEST, + EVENT_SECRET_ALERT_TRIGGERED, + EVENT_SENSOR_PAIRED_AND_NAMED, + EVENT_USER_INITIATED_TEST, +] + CONFIG_SCHEMA = cv.deprecated(DOMAIN) @@ -317,6 +364,7 @@ class SimpliSafe: self._hass = hass self._system_notifications: dict[int, set[SystemNotification]] = {} self.entry = entry + self.initial_event_to_use: dict[int, dict[str, Any]] = {} self.systems: dict[int, SystemV2 | SystemV3] = {} # This will get filled in by async_init: @@ -359,8 +407,68 @@ class SimpliSafe: self._system_notifications[system.system_id] = latest_notifications + async def _async_websocket_on_connect(self) -> None: + """Define a callback for connecting to the websocket.""" + if TYPE_CHECKING: + assert self._api.websocket + await self._api.websocket.async_listen() + + @callback + def _async_websocket_on_event(self, event: WebsocketEvent) -> None: + """Define a callback for receiving a websocket event.""" + LOGGER.debug("New websocket event: %s", event) + + async_dispatcher_send( + self._hass, DISPATCHER_TOPIC_WEBSOCKET_EVENT.format(event.system_id), event + ) + + if event.event_type not in WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT: + return + + sensor_type: str | None + if event.sensor_type: + sensor_type = event.sensor_type.name + else: + sensor_type = None + + self._hass.bus.async_fire( + EVENT_SIMPLISAFE_EVENT, + event_data={ + ATTR_LAST_EVENT_CHANGED_BY: event.changed_by, + ATTR_LAST_EVENT_TYPE: event.event_type, + ATTR_LAST_EVENT_INFO: event.info, + ATTR_LAST_EVENT_SENSOR_NAME: event.sensor_name, + ATTR_LAST_EVENT_SENSOR_SERIAL: event.sensor_serial, + ATTR_LAST_EVENT_SENSOR_TYPE: sensor_type, + ATTR_SYSTEM_ID: event.system_id, + ATTR_LAST_EVENT_TIMESTAMP: event.timestamp, + }, + ) + async def async_init(self) -> None: - """Initialize the data class.""" + """Initialize the SimpliSafe "manager" class.""" + if TYPE_CHECKING: + assert self._api.refresh_token + assert self._api.websocket + + self._api.websocket.add_connect_listener(self._async_websocket_on_connect) + self._api.websocket.add_event_listener(self._async_websocket_on_event) + asyncio.create_task(self._api.websocket.async_connect()) + + async def async_websocket_disconnect_listener(_: Event) -> None: + """Define an event handler to disconnect from the websocket.""" + if TYPE_CHECKING: + assert self._api.websocket + + if self._api.websocket.connected: + await self._api.websocket.async_disconnect() + + self.entry.async_on_unload( + self._hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, async_websocket_disconnect_listener + ) + ) + self.systems = await self._api.async_get_systems() for system in self.systems.values(): self._system_notifications[system.system_id] = set() @@ -369,6 +477,17 @@ class SimpliSafe: async_register_base_station(self._hass, self.entry, system) ) + # Future events will come from the websocket, but since subscription to the + # websocket doesn't provide the most recent event, we grab it from the REST + # API to ensure event-related attributes aren't empty on startup: + try: + self.initial_event_to_use[ + system.system_id + ] = await system.async_get_latest_event() + except SimplipyError as err: + LOGGER.error("Error while fetching initial event: %s", err) + self.initial_event_to_use[system.system_id] = {} + self.coordinator = DataUpdateCoordinator( self._hass, LOGGER, @@ -390,8 +509,6 @@ class SimpliSafe: self._api.add_refresh_token_listener(async_save_refresh_token) ) - if TYPE_CHECKING: - assert self._api.refresh_token async_save_refresh_token(self._api.refresh_token) async def async_update(self) -> None: @@ -428,6 +545,7 @@ class SimpliSafeEntity(CoordinatorEntity): system: SystemV2 | SystemV3, *, device: Device | None = None, + additional_websocket_events: Iterable[str] | None = None, ) -> None: """Initialize.""" assert simplisafe.coordinator @@ -442,7 +560,23 @@ class SimpliSafeEntity(CoordinatorEntity): device_name = DEFAULT_ENTITY_NAME serial = system.serial - self._attr_extra_state_attributes = {ATTR_SYSTEM_ID: system.system_id} + try: + device_type = DeviceTypes( + simplisafe.initial_event_to_use[system.system_id].get("sensorType") + ) + except ValueError: + device_type = DeviceTypes.unknown + + event = simplisafe.initial_event_to_use[system.system_id] + + self._attr_extra_state_attributes = { + ATTR_LAST_EVENT_INFO: event.get("info"), + ATTR_LAST_EVENT_SENSOR_NAME: event.get("sensorName"), + ATTR_LAST_EVENT_SENSOR_TYPE: device_type.name, + ATTR_LAST_EVENT_TIMESTAMP: event.get("eventTimestamp"), + ATTR_SYSTEM_ID: system.system_id, + } + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, serial)}, manufacturer="SimpliSafe", @@ -450,12 +584,21 @@ class SimpliSafeEntity(CoordinatorEntity): name=device_name, via_device=(DOMAIN, system.system_id), ) + self._attr_name = f"{system.address} {device_name} {' '.join([w.title() for w in model.split('_')])}" self._attr_unique_id = serial self._device = device self._online = True self._simplisafe = simplisafe self._system = system + self._websocket_events_to_listen_for = [ + EVENT_CONNECTION_LOST, + EVENT_CONNECTION_RESTORED, + EVENT_POWER_OUTAGE, + EVENT_POWER_RESTORED, + ] + if additional_websocket_events: + self._websocket_events_to_listen_for += additional_websocket_events @property def available(self) -> bool: @@ -478,12 +621,75 @@ class SimpliSafeEntity(CoordinatorEntity): self.async_update_from_rest_api() self.async_write_ha_state() + @callback + def _handle_websocket_update(self, event: WebsocketEvent) -> None: + """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 ( + self._device + and event.event_type in WEBSOCKET_EVENTS_REQUIRING_SERIAL + and event.sensor_serial != self._device.serial + ): + return + + if event.event_type in (EVENT_CONNECTION_LOST, EVENT_POWER_OUTAGE): + self._online = False + elif event.event_type in (EVENT_CONNECTION_RESTORED, EVENT_POWER_RESTORED): + self._online = True + + # It's uncertain whether SimpliSafe events will still propagate down the + # websocket when the base station is offline. Just in case, we guard against + # further action until connection is restored: + if not self._online: + return + + sensor_type: str | None + if event.sensor_type: + sensor_type = event.sensor_type.name + else: + sensor_type = None + + self._attr_extra_state_attributes.update( + { + ATTR_LAST_EVENT_INFO: event.info, + ATTR_LAST_EVENT_SENSOR_NAME: event.sensor_name, + ATTR_LAST_EVENT_SENSOR_TYPE: sensor_type, + ATTR_LAST_EVENT_TIMESTAMP: event.timestamp, + } + ) + + self.async_update_from_websocket_event(event) + self.async_write_ha_state() + async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + DISPATCHER_TOPIC_WEBSOCKET_EVENT.format(self._system.system_id), + self._handle_websocket_update, + ) + ) + self.async_update_from_rest_api() @callback def async_update_from_rest_api(self) -> None: - """Update the entity with the provided REST API data.""" + """Update the entity when new data comes from the REST API.""" + raise NotImplementedError() + + @callback + def async_update_from_websocket_event(self, event: WebsocketEvent) -> None: + """Update the entity when new data comes from the websocket.""" raise NotImplementedError() diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 1355669129d..6643bd3a1a1 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -1,6 +1,8 @@ """Support for SimpliSafe alarm control panels.""" from __future__ import annotations +from typing import TYPE_CHECKING + from simplipy.errors import SimplipyError from simplipy.system import SystemStates from simplipy.system.v2 import SystemV2 @@ -11,6 +13,20 @@ from simplipy.system.v3 import ( VOLUME_OFF, SystemV3, ) +from simplipy.websocket import ( + EVENT_ALARM_CANCELED, + EVENT_ALARM_TRIGGERED, + EVENT_ARMED_AWAY, + EVENT_ARMED_AWAY_BY_KEYPAD, + EVENT_ARMED_AWAY_BY_REMOTE, + EVENT_ARMED_HOME, + EVENT_AWAY_EXIT_DELAY_BY_KEYPAD, + EVENT_AWAY_EXIT_DELAY_BY_REMOTE, + EVENT_DISARMED_BY_MASTER_PIN, + EVENT_DISARMED_BY_REMOTE, + EVENT_HOME_EXIT_DELAY, + WebsocketEvent, +) from homeassistant.components.alarm_control_panel import ( FORMAT_NUMBER, @@ -56,6 +72,8 @@ ATTR_RF_JAMMING = "rf_jamming" ATTR_WALL_POWER_LEVEL = "wall_power_level" ATTR_WIFI_STRENGTH = "wifi_strength" +DEFAULT_ERRORS_TO_ACCOMMODATE = 2 + VOLUME_STRING_MAP = { VOLUME_HIGH: "high", VOLUME_LOW: "low", @@ -63,6 +81,43 @@ VOLUME_STRING_MAP = { VOLUME_OFF: "off", } +STATE_MAP_FROM_REST_API = { + SystemStates.alarm: STATE_ALARM_TRIGGERED, + SystemStates.away: STATE_ALARM_ARMED_AWAY, + SystemStates.away_count: STATE_ALARM_ARMING, + SystemStates.exit_delay: STATE_ALARM_ARMING, + SystemStates.home: STATE_ALARM_ARMED_HOME, + SystemStates.off: STATE_ALARM_DISARMED, +} + +STATE_MAP_FROM_WEBSOCKET_EVENT = { + EVENT_ALARM_CANCELED: STATE_ALARM_DISARMED, + EVENT_ALARM_TRIGGERED: STATE_ALARM_TRIGGERED, + EVENT_ARMED_AWAY: STATE_ALARM_ARMED_AWAY, + EVENT_ARMED_AWAY_BY_KEYPAD: STATE_ALARM_ARMED_AWAY, + EVENT_ARMED_AWAY_BY_REMOTE: STATE_ALARM_ARMED_AWAY, + EVENT_ARMED_HOME: STATE_ALARM_ARMED_HOME, + EVENT_AWAY_EXIT_DELAY_BY_KEYPAD: STATE_ALARM_ARMING, + EVENT_AWAY_EXIT_DELAY_BY_REMOTE: STATE_ALARM_ARMING, + EVENT_DISARMED_BY_MASTER_PIN: STATE_ALARM_DISARMED, + EVENT_DISARMED_BY_REMOTE: STATE_ALARM_DISARMED, + EVENT_HOME_EXIT_DELAY: STATE_ALARM_ARMING, +} + +WEBSOCKET_EVENTS_TO_LISTEN_FOR = ( + EVENT_ALARM_CANCELED, + EVENT_ALARM_TRIGGERED, + EVENT_ARMED_AWAY, + EVENT_ARMED_AWAY_BY_KEYPAD, + EVENT_ARMED_AWAY_BY_REMOTE, + EVENT_ARMED_HOME, + EVENT_AWAY_EXIT_DELAY_BY_KEYPAD, + EVENT_AWAY_EXIT_DELAY_BY_REMOTE, + EVENT_DISARMED_BY_MASTER_PIN, + EVENT_DISARMED_BY_REMOTE, + EVENT_HOME_EXIT_DELAY, +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -80,7 +135,13 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): def __init__(self, simplisafe: SimpliSafe, system: SystemV2 | SystemV3) -> None: """Initialize the SimpliSafe alarm.""" - super().__init__(simplisafe, system) + super().__init__( + simplisafe, + system, + additional_websocket_events=WEBSOCKET_EVENTS_TO_LISTEN_FOR, + ) + + self._errors = 0 if code := self._simplisafe.entry.options.get(CONF_CODE): if code.isdigit(): @@ -192,15 +253,29 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): } ) - if self._system.state == SystemStates.alarm: - self._attr_state = STATE_ALARM_TRIGGERED - elif self._system.state == SystemStates.away: - self._attr_state = STATE_ALARM_ARMED_AWAY - elif self._system.state in (SystemStates.away_count, SystemStates.exit_delay): - self._attr_state = STATE_ALARM_ARMING - elif self._system.state == SystemStates.home: - self._attr_state = STATE_ALARM_ARMED_HOME - elif self._system.state == SystemStates.off: - self._attr_state = STATE_ALARM_DISARMED + # SimpliSafe can incorrectly return an error state when there isn't any + # error. This can lead to the system having an unknown state frequently. + # To protect against that, we measure how many "error states" we receive + # and only alter the state if we detect a few in a row: + if self._system.state == SystemStates.error: + if self._errors > DEFAULT_ERRORS_TO_ACCOMMODATE: + self._attr_state = None + else: + self._errors += 1 + return + + self._errors = 0 + + if state := STATE_MAP_FROM_REST_API.get(self._system.state): + self._attr_state = state else: + LOGGER.error("Unknown system state (REST API): %s", self._system.state) self._attr_state = None + + @callback + def async_update_from_websocket_event(self, event: WebsocketEvent) -> None: + """Update the entity when new data comes from the websocket.""" + self._attr_changed_by = event.changed_by + if TYPE_CHECKING: + assert event.event_type + self._attr_state = STATE_MAP_FROM_WEBSOCKET_EVENT.get(event.event_type) diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index 3375a413edb..dc09eb0b62e 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -1,11 +1,17 @@ """Support for SimpliSafe locks.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from simplipy.device.lock import Lock, LockStates from simplipy.errors import SimplipyError from simplipy.system.v3 import SystemV3 +from simplipy.websocket import ( + EVENT_LOCK_ERROR, + EVENT_LOCK_LOCKED, + EVENT_LOCK_UNLOCKED, + WebsocketEvent, +) from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry @@ -18,6 +24,14 @@ from .const import DATA_CLIENT, DOMAIN, LOGGER ATTR_LOCK_LOW_BATTERY = "lock_low_battery" ATTR_PIN_PAD_LOW_BATTERY = "pin_pad_low_battery" +STATE_MAP_FROM_WEBSOCKET_EVENT = { + EVENT_LOCK_ERROR: None, + EVENT_LOCK_LOCKED: True, + EVENT_LOCK_UNLOCKED: False, +} + +WEBSOCKET_EVENTS_TO_LISTEN_FOR = (EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -42,7 +56,12 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): def __init__(self, simplisafe: SimpliSafe, system: SystemV3, lock: Lock) -> None: """Initialize.""" - super().__init__(simplisafe, system, device=lock) + super().__init__( + simplisafe, + system, + device=lock, + additional_websocket_events=WEBSOCKET_EVENTS_TO_LISTEN_FOR, + ) self._device: Lock @@ -80,3 +99,10 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): self._attr_is_jammed = self._device.state == LockStates.jammed self._attr_is_locked = self._device.state == LockStates.locked + + @callback + def async_update_from_websocket_event(self, event: WebsocketEvent) -> None: + """Update the entity when new data comes from the websocket.""" + if TYPE_CHECKING: + assert event.event_type + self._attr_is_locked = STATE_MAP_FROM_WEBSOCKET_EVENT[event.event_type] diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 6f6025308eb..956157a237d 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==12.0.0"], + "requirements": ["simplisafe-python==12.0.2"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 273e48a3f1b..f04ff45af8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2140,7 +2140,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==12.0.0 +simplisafe-python==12.0.2 # homeassistant.components.sisyphus sisyphus-control==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0b1e78711f..2eb5501a527 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1239,7 +1239,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==12.0.0 +simplisafe-python==12.0.2 # homeassistant.components.slack slackclient==2.5.0