From f091e0412fe25d3e84305454cc3ce3380645d5d7 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 13 Feb 2020 12:30:38 -0700 Subject: [PATCH] Add support for real-time data from SimpliSafe (#31424) * Add support for real-time data from SimpliSafe * Updated requirements * Linting * Ensure dispatcher topic contains the domain * Don't bother with a partial * Websovket dataclass and other code review * Ensure initial_event_to_use works with error * Don't inline methods * Don't abuse loop variable * Simplify initial event retrieval * Add connection lost and restored events * Revert "Add connection lost and restored events" This reverts commit e7ffe05938e6cd13a5426f8a605260056fa04de0. * Make _on_disconnect a static method * Code review comments * Allow entities to opt out of REST and/or websocket API updates * Revert "Allow entities to opt out of REST and/or websocket API updates" This reverts commit 1989f2e00e0b95dd466bcc803e7c83afab6d2763. * Code review comments * Fix issues with events not triggering correct entities * Bug fixes --- .../components/simplisafe/__init__.py | 289 ++++++++++++++++-- .../simplisafe/alarm_control_panel.py | 221 ++++++++------ homeassistant/components/simplisafe/const.py | 20 +- homeassistant/components/simplisafe/lock.py | 52 +++- .../components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 451 insertions(+), 137 deletions(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 7cd1fd1bb2d080..74c7c3fd079c1e 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -1,10 +1,18 @@ """Support for SimpliSafe alarm systems.""" import asyncio +from dataclasses import dataclass, field +from datetime import datetime import logging +from typing import Optional from simplipy import API -from simplipy.errors import InvalidCredentialsError, SimplipyError -from simplipy.system.v3 import VOLUME_HIGH, VOLUME_LOW, VOLUME_MEDIUM, VOLUME_OFF +from simplipy.entity import EntityTypes +from simplipy.errors import InvalidCredentialsError, SimplipyError, WebsocketError +from simplipy.websocket import ( + EVENT_LOCK_LOCKED, + EVENT_LOCK_UNLOCKED, + get_event_type_from_payload, +) import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT @@ -21,36 +29,50 @@ async_dispatcher_send, ) from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.service import ( async_register_admin_service, verify_domain_control, ) +from homeassistant.util.dt import utc_from_timestamp from .config_flow import configured_instances -from .const import DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, TOPIC_UPDATE +from .const import ( + ATTR_ALARM_DURATION, + ATTR_ALARM_VOLUME, + ATTR_CHIME_VOLUME, + ATTR_ENTRY_DELAY_AWAY, + ATTR_ENTRY_DELAY_HOME, + ATTR_EXIT_DELAY_AWAY, + ATTR_EXIT_DELAY_HOME, + ATTR_LIGHT, + ATTR_VOICE_PROMPT_VOLUME, + DATA_CLIENT, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + VOLUMES, +) _LOGGER = logging.getLogger(__name__) CONF_ACCOUNTS = "accounts" DATA_LISTENER = "listener" +TOPIC_UPDATE = "simplisafe_update_data_{0}" + +DEFAULT_SOCKET_MIN_RETRY = 15 +DEFAULT_WATCHDOG_SECONDS = 5 * 60 + +WEBSOCKET_EVENTS_REQUIRING_SERIAL = [EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED] -ATTR_ALARM_DURATION = "alarm_duration" -ATTR_ALARM_VOLUME = "alarm_volume" -ATTR_CHIME_VOLUME = "chime_volume" -ATTR_ENTRY_DELAY_AWAY = "entry_delay_away" -ATTR_ENTRY_DELAY_HOME = "entry_delay_home" -ATTR_EXIT_DELAY_AWAY = "exit_delay_away" -ATTR_EXIT_DELAY_HOME = "exit_delay_home" -ATTR_LIGHT = "light" +ATTR_LAST_EVENT_INFO = "last_event_info" +ATTR_LAST_EVENT_SENSOR_NAME = "last_event_sensor_name" +ATTR_LAST_EVENT_SENSOR_TYPE = "last_event_sensor_type" +ATTR_LAST_EVENT_TIMESTAMP = "last_event_timestamp" ATTR_PIN_LABEL = "label" ATTR_PIN_LABEL_OR_VALUE = "label_or_pin" ATTR_PIN_VALUE = "pin" ATTR_SYSTEM_ID = "system_id" -ATTR_VOICE_PROMPT_VOLUME = "voice_prompt_volume" - -VOLUMES = [VOLUME_OFF, VOLUME_LOW, VOLUME_MEDIUM, VOLUME_HIGH] SERVICE_BASE_SCHEMA = vol.Schema({vol.Required(ATTR_SYSTEM_ID): cv.positive_int}) @@ -283,8 +305,133 @@ async def async_unload_entry(hass, entry): return True +@dataclass(frozen=True) +class SimpliSafeWebsocketEvent: + """Define a representation of a parsed websocket event.""" + + event_data: dict + + changed_by: Optional[str] = field(init=False) + event_type: Optional[str] = field(init=False) + info: str = field(init=False) + sensor_name: str = field(init=False) + sensor_serial: str = field(init=False) + sensor_type: EntityTypes = field(init=False) + system_id: int = field(init=False) + timestamp: datetime = field(init=False) + + def __post_init__(self): + """Initialize.""" + object.__setattr__(self, "changed_by", self.event_data["pinName"]) + object.__setattr__( + self, "event_type", get_event_type_from_payload(self.event_data) + ) + object.__setattr__(self, "info", self.event_data["info"]) + object.__setattr__(self, "sensor_name", self.event_data["sensorName"]) + object.__setattr__(self, "sensor_serial", self.event_data["sensorSerial"]) + try: + object.__setattr__( + self, "sensor_type", EntityTypes(self.event_data["sensorType"]).name + ) + except ValueError: + _LOGGER.warning( + 'Encountered unknown entity type: %s ("%s"). Please report it at' + "https://github.com/home-assistant/home-assistant/issues.", + self.event_data["sensorType"], + self.event_data["sensorName"], + ) + object.__setattr__(self, "sensor_type", None) + object.__setattr__(self, "system_id", self.event_data["sid"]) + object.__setattr__( + self, "timestamp", utc_from_timestamp(self.event_data["eventTimestamp"]) + ) + + +class SimpliSafeWebsocket: + """Define a SimpliSafe websocket "manager" object.""" + + def __init__(self, hass, websocket): + """Initialize.""" + self._hass = hass + self._websocket = websocket + self._websocket_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY + self._websocket_reconnect_underway = False + self._websocket_watchdog_listener = None + self.last_events = {} + + async def _async_attempt_websocket_connect(self): + """Attempt to connect to the websocket (retrying later on fail).""" + self._websocket_reconnect_underway = True + + try: + await self._websocket.async_connect() + except WebsocketError as err: + _LOGGER.error("Error with the websocket connection: %s", err) + self._websocket_reconnect_delay = min( + 2 * self._websocket_reconnect_delay, 480 + ) + async_call_later( + self._hass, + self._websocket_reconnect_delay, + self.async_websocket_connect, + ) + else: + self._websocket_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY + self._websocket_reconnect_underway = False + + async def _async_websocket_reconnect(self, event_time): + """Forcibly disconnect from and reconnect to the websocket.""" + _LOGGER.debug("Websocket watchdog expired; forcing socket reconnection") + await self.async_websocket_disconnect() + await self._async_attempt_websocket_connect() + + def _on_connect(self): + """Define a handler to fire when the websocket is connected.""" + _LOGGER.info("Connected to websocket") + _LOGGER.debug("Websocket watchdog starting") + if self._websocket_watchdog_listener is not None: + self._websocket_watchdog_listener() + self._websocket_watchdog_listener = async_call_later( + self._hass, DEFAULT_WATCHDOG_SECONDS, self._async_websocket_reconnect + ) + + @staticmethod + def _on_disconnect(): + """Define a handler to fire when the websocket is disconnected.""" + _LOGGER.info("Disconnected from websocket") + + def _on_event(self, data): + """Define a handler to fire when a new SimpliSafe event arrives.""" + event = SimpliSafeWebsocketEvent(data) + _LOGGER.debug("New websocket event: %s", event) + self.last_events[data["sid"]] = event + async_dispatcher_send(self._hass, TOPIC_UPDATE.format(data["sid"])) + + _LOGGER.debug("Resetting websocket watchdog") + self._websocket_watchdog_listener() + self._websocket_watchdog_listener = async_call_later( + self._hass, DEFAULT_WATCHDOG_SECONDS, self._async_websocket_reconnect + ) + self._websocket_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY + + async def async_websocket_connect(self): + """Register handlers and connect to the websocket.""" + if self._websocket_reconnect_underway: + return + + self._websocket.on_connect(self._on_connect) + self._websocket.on_disconnect(self._on_disconnect) + self._websocket.on_event(self._on_event) + + await self._async_attempt_websocket_connect() + + async def async_websocket_disconnect(self): + """Disconnect from the websocket.""" + await self._websocket.async_disconnect() + + class SimpliSafe: - """Define a SimpliSafe API object.""" + """Define a SimpliSafe data object.""" def __init__(self, hass, api, config_entry): """Initialize.""" @@ -292,14 +439,15 @@ def __init__(self, hass, api, config_entry): self._config_entry = config_entry self._emergency_refresh_token_used = False self._hass = hass - self.last_event_data = {} + self.initial_event_to_use = {} self.systems = None + self.websocket = SimpliSafeWebsocket(hass, api.websocket) async def async_init(self): """Initialize the data class.""" - self.systems = await self._api.get_systems() + asyncio.create_task(self.websocket.async_websocket_connect()) - # Register the base station for each system: + self.systems = await self._api.get_systems() for system in self.systems.values(): self._hass.async_create_task( async_register_base_station( @@ -307,6 +455,17 @@ async def async_init(self): ) ) + # 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.get_latest_event() + except SimplipyError as 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() @@ -323,7 +482,8 @@ async def async_update(self): async def update_system(system): """Update a system.""" await system.update() - self.last_event_data[system.system_id] = await system.get_latest_event() + _LOGGER.debug('Updated REST API data for "%s"', system.address) + async_dispatcher_send(self._hass, TOPIC_UPDATE.format(system.system_id)) tasks = [update_system(system) for system in self.systems.values()] @@ -371,26 +531,41 @@ def cancel_tasks(): if self._emergency_refresh_token_used: self._emergency_refresh_token_used = False - _LOGGER.debug("Updated data for all SimpliSafe systems") - async_dispatcher_send(self._hass, TOPIC_UPDATE) - class SimpliSafeEntity(Entity): """Define a base SimpliSafe entity.""" - def __init__(self, system, name, *, serial=None): + def __init__(self, simplisafe, system, name, *, serial=None): """Initialize.""" self._async_unsub_dispatcher_connect = None - self._attrs = {ATTR_SYSTEM_ID: system.system_id} + self._last_processed_websocket_event = None self._name = name self._online = True + self._simplisafe = simplisafe self._system = system + self.websocket_events_to_listen_for = [] if serial: self._serial = serial else: self._serial = system.serial + self._attrs = { + ATTR_LAST_EVENT_INFO: simplisafe.initial_event_to_use[system.system_id].get( + "info" + ), + ATTR_LAST_EVENT_SENSOR_NAME: simplisafe.initial_event_to_use[ + system.system_id + ].get("sensorName"), + ATTR_LAST_EVENT_SENSOR_TYPE: simplisafe.initial_event_to_use[ + system.system_id + ].get("sensorType"), + ATTR_LAST_EVENT_TIMESTAMP: simplisafe.initial_event_to_use[ + system.system_id + ].get("eventTimestamp"), + ATTR_SYSTEM_ID: system.system_id, + } + @property def available(self): """Return whether the entity is available.""" @@ -427,6 +602,36 @@ def unique_id(self): """Return the unique ID of the entity.""" return self._serial + @callback + def _async_should_ignore_websocket_event(self, event): + """Return whether this entity should ignore a particular websocket event. + + Note that we can't check for a final condition – whether the event belongs to + a particular entity, like a lock – because some events (like arming the system + from a keypad _or_ from the website) should impact the same entity. + """ + # We've already processed this event: + if self._last_processed_websocket_event == event: + return True + + # This is an event for a system other than the one this entity belongs to: + if event.system_id != self._system.system_id: + return True + + # This isn't an event that this entity cares about: + if event.event_type not in self.websocket_events_to_listen_for: + return True + + # This event is targeted at a specific entity whose serial number is different + # from this one's: + if ( + event.event_type in WEBSOCKET_EVENTS_REQUIRING_SERIAL + and event.sensor_serial != self._serial + ): + return True + + return False + async def async_added_to_hass(self): """Register callbacks.""" @@ -436,9 +641,41 @@ def update(): self.async_schedule_update_ha_state(True) self._async_unsub_dispatcher_connect = async_dispatcher_connect( - self.hass, TOPIC_UPDATE, update + self.hass, TOPIC_UPDATE.format(self._system.system_id), update ) + async def async_update(self): + """Update the entity.""" + self.async_update_from_rest_api() + + last_websocket_event = self._simplisafe.websocket.last_events.get( + self._system.system_id + ) + + if self._async_should_ignore_websocket_event(last_websocket_event): + return + + self._last_processed_websocket_event = last_websocket_event + self._attrs.update( + { + ATTR_LAST_EVENT_INFO: last_websocket_event.info, + ATTR_LAST_EVENT_SENSOR_NAME: last_websocket_event.sensor_name, + ATTR_LAST_EVENT_SENSOR_TYPE: last_websocket_event.sensor_type, + ATTR_LAST_EVENT_TIMESTAMP: last_websocket_event.timestamp, + } + ) + self.async_update_from_websocket_event(last_websocket_event) + + @callback + def async_update_from_rest_api(self): + """Update the entity with the provided REST API data.""" + pass + + @callback + def async_update_from_websocket_event(self, event): + """Update the entity with the provided websocket API data.""" + pass + async def async_will_remove_from_hass(self) -> None: """Disconnect dispatcher listener when removed.""" if self._async_unsub_dispatcher_connect: diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 362c0244749d37..c675f9c2748907 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -2,9 +2,21 @@ import logging import re -from simplipy.entity import EntityTypes +from simplipy.errors import SimplipyError from simplipy.system import SystemStates -from simplipy.system.v3 import VOLUME_HIGH, VOLUME_LOW, VOLUME_MEDIUM, VOLUME_OFF +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, +) from homeassistant.components.alarm_control_panel import ( FORMAT_NUMBER, @@ -23,40 +35,33 @@ STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) -from homeassistant.util.dt import utc_from_timestamp +from homeassistant.core import callback from . import SimpliSafeEntity -from .const import DATA_CLIENT, DOMAIN +from .const import ( + ATTR_ALARM_DURATION, + ATTR_ALARM_VOLUME, + ATTR_CHIME_VOLUME, + ATTR_ENTRY_DELAY_AWAY, + ATTR_ENTRY_DELAY_HOME, + ATTR_EXIT_DELAY_AWAY, + ATTR_EXIT_DELAY_HOME, + ATTR_LIGHT, + ATTR_VOICE_PROMPT_VOLUME, + DATA_CLIENT, + DOMAIN, + VOLUME_STRING_MAP, +) _LOGGER = logging.getLogger(__name__) -ATTR_ALARM_DURATION = "alarm_duration" -ATTR_ALARM_VOLUME = "alarm_volume" ATTR_BATTERY_BACKUP_POWER_LEVEL = "battery_backup_power_level" -ATTR_CHIME_VOLUME = "chime_volume" -ATTR_ENTRY_DELAY_AWAY = "entry_delay_away" -ATTR_ENTRY_DELAY_HOME = "entry_delay_home" -ATTR_EXIT_DELAY_AWAY = "exit_delay_away" -ATTR_EXIT_DELAY_HOME = "exit_delay_home" ATTR_GSM_STRENGTH = "gsm_strength" -ATTR_LAST_EVENT_INFO = "last_event_info" -ATTR_LAST_EVENT_SENSOR_NAME = "last_event_sensor_name" -ATTR_LAST_EVENT_SENSOR_TYPE = "last_event_sensor_type" -ATTR_LAST_EVENT_TIMESTAMP = "last_event_timestamp" -ATTR_LAST_EVENT_TYPE = "last_event_type" -ATTR_LIGHT = "light" +ATTR_PIN_NAME = "pin_name" ATTR_RF_JAMMING = "rf_jamming" -ATTR_VOICE_PROMPT_VOLUME = "voice_prompt_volume" ATTR_WALL_POWER_LEVEL = "wall_power_level" ATTR_WIFI_STRENGTH = "wifi_strength" -VOLUME_STRING_MAP = { - VOLUME_HIGH: "high", - VOLUME_LOW: "low", - VOLUME_MEDIUM: "medium", - VOLUME_OFF: "off", -} - async def async_setup_entry(hass, entry, async_add_entities): """Set up a SimpliSafe alarm control panel based on a config entry.""" @@ -75,33 +80,42 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanel): def __init__(self, simplisafe, system, code): """Initialize the SimpliSafe alarm.""" - super().__init__(system, "Alarm Control Panel") + super().__init__(simplisafe, system, "Alarm Control Panel") self._changed_by = None self._code = code - self._simplisafe = simplisafe - self._state = None + self._last_event = None - if self._system.version == 3: - self._attrs.update( - { - ATTR_ALARM_DURATION: self._system.alarm_duration, - ATTR_ALARM_VOLUME: VOLUME_STRING_MAP[self._system.alarm_volume], - ATTR_BATTERY_BACKUP_POWER_LEVEL: self._system.battery_backup_power_level, - ATTR_CHIME_VOLUME: VOLUME_STRING_MAP[self._system.chime_volume], - ATTR_ENTRY_DELAY_AWAY: self._system.entry_delay_away, - ATTR_ENTRY_DELAY_HOME: self._system.entry_delay_home, - ATTR_EXIT_DELAY_AWAY: self._system.exit_delay_away, - ATTR_EXIT_DELAY_HOME: self._system.exit_delay_home, - ATTR_GSM_STRENGTH: self._system.gsm_strength, - ATTR_LIGHT: self._system.light, - ATTR_RF_JAMMING: self._system.rf_jamming, - ATTR_VOICE_PROMPT_VOLUME: VOLUME_STRING_MAP[ - self._system.voice_prompt_volume - ], - ATTR_WALL_POWER_LEVEL: self._system.wall_power_level, - ATTR_WIFI_STRENGTH: self._system.wifi_strength, - } - ) + if system.alarm_going_off: + self._state = STATE_ALARM_TRIGGERED + elif system.state == SystemStates.away: + self._state = STATE_ALARM_ARMED_AWAY + elif system.state in ( + SystemStates.away_count, + SystemStates.exit_delay, + SystemStates.home_count, + ): + self._state = STATE_ALARM_ARMING + elif system.state == SystemStates.home: + self._state = STATE_ALARM_ARMED_HOME + elif system.state == SystemStates.off: + self._state = STATE_ALARM_DISARMED + else: + self._state = None + + for event_type in ( + 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, + ): + self.websocket_events_to_listen_for.append(event_type) @property def changed_by(self): @@ -139,71 +153,96 @@ async def async_alarm_disarm(self, code=None): if not self._validate_code(code, "disarming"): return - await self._system.set_off() + try: + await self._system.set_off() + except SimplipyError as err: + _LOGGER.error('Error while disarming "%s": %s', self._system.name, err) + return + + self._state = STATE_ALARM_DISARMED async def async_alarm_arm_home(self, code=None): """Send arm home command.""" if not self._validate_code(code, "arming home"): return - await self._system.set_home() + try: + await self._system.set_home() + except SimplipyError as err: + _LOGGER.error('Error while arming "%s" (home): %s', self._system.name, err) + return + + self._state = STATE_ALARM_ARMED_HOME async def async_alarm_arm_away(self, code=None): """Send arm away command.""" if not self._validate_code(code, "arming away"): return - await self._system.set_away() - - async def async_update(self): - """Update alarm status.""" - last_event = self._simplisafe.last_event_data[self._system.system_id] + try: + await self._system.set_away() + except SimplipyError as err: + _LOGGER.error('Error while arming "%s" (away): %s', self._system.name, err) + return - if last_event.get("pinName"): - self._changed_by = last_event["pinName"] + self._state = STATE_ALARM_ARMING + @callback + def async_update_from_rest_api(self): + """Update the entity with the provided REST API data.""" if self._system.state == SystemStates.error: self._online = False return - self._online = True - if self._system.alarm_going_off: + if self._system.version == 3: + self._attrs.update( + { + ATTR_ALARM_DURATION: self._system.alarm_duration, + ATTR_ALARM_VOLUME: VOLUME_STRING_MAP[self._system.alarm_volume], + ATTR_BATTERY_BACKUP_POWER_LEVEL: self._system.battery_backup_power_level, + ATTR_CHIME_VOLUME: VOLUME_STRING_MAP[self._system.chime_volume], + ATTR_ENTRY_DELAY_AWAY: self._system.entry_delay_away, + ATTR_ENTRY_DELAY_HOME: self._system.entry_delay_home, + ATTR_EXIT_DELAY_AWAY: self._system.exit_delay_away, + ATTR_EXIT_DELAY_HOME: self._system.exit_delay_home, + ATTR_GSM_STRENGTH: self._system.gsm_strength, + ATTR_LIGHT: self._system.light, + ATTR_RF_JAMMING: self._system.rf_jamming, + ATTR_VOICE_PROMPT_VOLUME: VOLUME_STRING_MAP[ + self._system.voice_prompt_volume + ], + ATTR_WALL_POWER_LEVEL: self._system.wall_power_level, + ATTR_WIFI_STRENGTH: self._system.wifi_strength, + } + ) + + @callback + def async_update_from_websocket_event(self, event): + """Update the entity with the provided websocket API event data.""" + if event.event_type in ( + EVENT_ALARM_CANCELED, + EVENT_DISARMED_BY_MASTER_PIN, + EVENT_DISARMED_BY_REMOTE, + ): + self._state = STATE_ALARM_DISARMED + elif event.event_type == EVENT_ALARM_TRIGGERED: self._state = STATE_ALARM_TRIGGERED - elif self._system.state == SystemStates.away: + elif event.event_type in ( + EVENT_ARMED_AWAY, + EVENT_ARMED_AWAY_BY_KEYPAD, + EVENT_ARMED_AWAY_BY_REMOTE, + ): self._state = STATE_ALARM_ARMED_AWAY - elif self._system.state in ( - SystemStates.away_count, - SystemStates.exit_delay, - SystemStates.home_count, + elif event.event_type == EVENT_ARMED_HOME: + self._state = STATE_ALARM_ARMED_HOME + elif event.event_type in ( + EVENT_AWAY_EXIT_DELAY_BY_KEYPAD, + EVENT_AWAY_EXIT_DELAY_BY_REMOTE, + EVENT_HOME_EXIT_DELAY, ): self._state = STATE_ALARM_ARMING - elif self._system.state == SystemStates.home: - self._state = STATE_ALARM_ARMED_HOME - elif self._system.state == SystemStates.off: - self._state = STATE_ALARM_DISARMED else: self._state = None - try: - last_event_sensor_type = EntityTypes(last_event["sensorType"]).name - except ValueError: - _LOGGER.warning( - 'Encountered unknown entity type: %s ("%s"). Please report it at' - "https://github.com/home-assistant/home-assistant/issues.", - last_event["sensorType"], - last_event["sensorName"], - ) - last_event_sensor_type = None - - self._attrs.update( - { - ATTR_LAST_EVENT_INFO: last_event["info"], - ATTR_LAST_EVENT_SENSOR_NAME: last_event["sensorName"], - ATTR_LAST_EVENT_SENSOR_TYPE: last_event_sensor_type, - ATTR_LAST_EVENT_TIMESTAMP: utc_from_timestamp( - last_event["eventTimestamp"] - ), - ATTR_LAST_EVENT_TYPE: last_event["eventType"], - } - ) + self._changed_by = event.changed_by diff --git a/homeassistant/components/simplisafe/const.py b/homeassistant/components/simplisafe/const.py index 4dfef39de464c3..6ca5f8323a7886 100644 --- a/homeassistant/components/simplisafe/const.py +++ b/homeassistant/components/simplisafe/const.py @@ -1,10 +1,28 @@ """Define constants for the SimpliSafe component.""" from datetime import timedelta +from simplipy.system.v3 import VOLUME_HIGH, VOLUME_LOW, VOLUME_MEDIUM, VOLUME_OFF + DOMAIN = "simplisafe" DATA_CLIENT = "client" DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) -TOPIC_UPDATE = "update" +ATTR_ALARM_DURATION = "alarm_duration" +ATTR_ALARM_VOLUME = "alarm_volume" +ATTR_CHIME_VOLUME = "chime_volume" +ATTR_ENTRY_DELAY_AWAY = "entry_delay_away" +ATTR_ENTRY_DELAY_HOME = "entry_delay_home" +ATTR_EXIT_DELAY_AWAY = "exit_delay_away" +ATTR_EXIT_DELAY_HOME = "exit_delay_home" +ATTR_LIGHT = "light" +ATTR_VOICE_PROMPT_VOLUME = "voice_prompt_volume" + +VOLUMES = [VOLUME_OFF, VOLUME_LOW, VOLUME_MEDIUM, VOLUME_HIGH] +VOLUME_STRING_MAP = { + VOLUME_HIGH: "high", + VOLUME_LOW: "low", + VOLUME_MEDIUM: "medium", + VOLUME_OFF: "off", +} diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index 10c5d310e73eee..58448ec4599649 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -1,10 +1,12 @@ """Support for SimpliSafe locks.""" import logging +from simplipy.errors import SimplipyError from simplipy.lock import LockStates +from simplipy.websocket import EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED from homeassistant.components.lock import LockDevice -from homeassistant.const import STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED +from homeassistant.core import callback from . import SimpliSafeEntity from .const import DATA_CLIENT, DOMAIN @@ -15,19 +17,13 @@ ATTR_JAMMED = "jammed" ATTR_PIN_PAD_LOW_BATTERY = "pin_pad_low_battery" -STATE_MAP = { - LockStates.locked: STATE_LOCKED, - LockStates.unknown: STATE_UNKNOWN, - LockStates.unlocked: STATE_UNLOCKED, -} - async def async_setup_entry(hass, entry, async_add_entities): """Set up SimpliSafe locks based on a config entry.""" simplisafe = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] async_add_entities( [ - SimpliSafeLock(system, lock) + SimpliSafeLock(simplisafe, system, lock) for system in simplisafe.systems.values() for lock in system.locks.values() ] @@ -37,32 +33,48 @@ async def async_setup_entry(hass, entry, async_add_entities): class SimpliSafeLock(SimpliSafeEntity, LockDevice): """Define a SimpliSafe lock.""" - def __init__(self, system, lock): + def __init__(self, simplisafe, system, lock): """Initialize.""" - super().__init__(system, lock.name, serial=lock.serial) + super().__init__(simplisafe, system, lock.name, serial=lock.serial) + self._is_locked = False self._lock = lock + for event_type in (EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED): + self.websocket_events_to_listen_for.append(event_type) + @property def is_locked(self): """Return true if the lock is locked.""" - return STATE_MAP.get(self._lock.state) == STATE_LOCKED + return self._is_locked async def async_lock(self, **kwargs): """Lock the lock.""" - await self._lock.lock() + try: + await self._lock.lock() + except SimplipyError as err: + _LOGGER.error('Error while locking "%s": %s', self._lock.name, err) + return + + self._is_locked = True async def async_unlock(self, **kwargs): """Unlock the lock.""" - await self._lock.unlock() + try: + await self._lock.unlock() + except SimplipyError as err: + _LOGGER.error('Error while unlocking "%s": %s', self._lock.name, err) + return + + self._is_locked = False - async def async_update(self): - """Update lock status.""" + @callback + def async_update_from_rest_api(self): + """Update the entity with the provided REST API data.""" if self._lock.offline or self._lock.disabled: self._online = False return self._online = True - self._attrs.update( { ATTR_LOCK_LOW_BATTERY: self._lock.lock_low_battery, @@ -70,3 +82,11 @@ async def async_update(self): ATTR_PIN_PAD_LOW_BATTERY: self._lock.pin_pad_low_battery, } ) + + @callback + def async_update_from_websocket_event(self, event): + """Update the entity with the provided websocket event data.""" + if event.event_type == EVENT_LOCK_LOCKED: + self._is_locked = True + else: + self._is_locked = False diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index f95db72d45a424..3b04d26732ca8d 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==6.1.0"], + "requirements": ["simplisafe-python==7.1.0"], "dependencies": [], "codeowners": ["@bachya"] } diff --git a/requirements_all.txt b/requirements_all.txt index ac811f0889fb90..fd11709ec5e727 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1829,7 +1829,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==6.1.0 +simplisafe-python==7.1.0 # homeassistant.components.sisyphus sisyphus-control==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 130f9c4530da39..37fa61b323c8b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -622,7 +622,7 @@ sentry-sdk==0.13.5 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==6.1.0 +simplisafe-python==7.1.0 # homeassistant.components.sleepiq sleepyq==0.7