1 """Support for SimpliSafe alarm systems."""
3 from __future__
import annotations
6 from collections.abc
import Callable, Coroutine
7 from datetime
import timedelta
8 from typing
import Any, cast
10 from simplipy
import API
11 from simplipy.errors
import (
12 EndpointUnavailableError,
13 InvalidCredentialsError,
17 from simplipy.system
import SystemNotification
18 from simplipy.system.v3
import (
30 from simplipy.websocket
import (
32 EVENT_CAMERA_MOTION_DETECTED,
34 EVENT_DOORBELL_DETECTED,
35 EVENT_SECRET_ALERT_TRIGGERED,
36 EVENT_SENSOR_PAIRED_AND_NAMED,
37 EVENT_USER_INITIATED_TEST,
40 import voluptuous
as vol
49 EVENT_HOMEASSISTANT_STOP,
54 ConfigEntryAuthFailed,
60 config_validation
as cv,
61 device_registry
as dr,
65 async_register_admin_service,
66 verify_domain_control,
74 ATTR_ENTRY_DELAY_AWAY,
75 ATTR_ENTRY_DELAY_HOME,
79 ATTR_LAST_EVENT_SENSOR_NAME,
80 ATTR_LAST_EVENT_SENSOR_TYPE,
81 ATTR_LAST_EVENT_TIMESTAMP,
84 ATTR_VOICE_PROMPT_VOLUME,
85 DISPATCHER_TOPIC_WEBSOCKET_EVENT,
89 from .typing
import SystemType
91 ATTR_CATEGORY =
"category"
92 ATTR_LAST_EVENT_CHANGED_BY =
"last_event_changed_by"
93 ATTR_LAST_EVENT_SENSOR_SERIAL =
"last_event_sensor_serial"
94 ATTR_LAST_EVENT_TYPE =
"last_event_type"
95 ATTR_LAST_EVENT_TYPE =
"last_event_type"
96 ATTR_MESSAGE =
"message"
97 ATTR_PIN_LABEL =
"label"
98 ATTR_PIN_LABEL_OR_VALUE =
"label_or_pin"
99 ATTR_PIN_VALUE =
"pin"
100 ATTR_TIMESTAMP =
"timestamp"
103 DEFAULT_SOCKET_MIN_RETRY = 15
106 EVENT_SIMPLISAFE_EVENT =
"SIMPLISAFE_EVENT"
107 EVENT_SIMPLISAFE_NOTIFICATION =
"SIMPLISAFE_NOTIFICATION"
110 Platform.ALARM_CONTROL_PANEL,
111 Platform.BINARY_SENSOR,
120 "medium": Volume.MEDIUM,
124 SERVICE_NAME_REMOVE_PIN =
"remove_pin"
125 SERVICE_NAME_SET_PIN =
"set_pin"
126 SERVICE_NAME_SET_SYSTEM_PROPERTIES =
"set_system_properties"
129 SERVICE_NAME_REMOVE_PIN,
130 SERVICE_NAME_SET_PIN,
131 SERVICE_NAME_SET_SYSTEM_PROPERTIES,
134 SERVICE_REMOVE_PIN_SCHEMA = vol.Schema(
136 vol.Required(ATTR_DEVICE_ID): cv.string,
137 vol.Required(ATTR_PIN_LABEL_OR_VALUE): cv.string,
141 SERVICE_SET_PIN_SCHEMA = vol.Schema(
143 vol.Required(ATTR_DEVICE_ID): cv.string,
144 vol.Required(ATTR_PIN_LABEL): cv.string,
145 vol.Required(ATTR_PIN_VALUE): cv.string,
149 SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA = vol.Schema(
151 vol.Required(ATTR_DEVICE_ID): cv.string,
152 vol.Optional(ATTR_ALARM_DURATION): vol.All(
154 lambda value: value.total_seconds(),
155 vol.Range(min=MIN_ALARM_DURATION, max=MAX_ALARM_DURATION),
157 vol.Optional(ATTR_ALARM_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get),
158 vol.Optional(ATTR_CHIME_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get),
159 vol.Optional(ATTR_ENTRY_DELAY_AWAY): vol.All(
161 lambda value: value.total_seconds(),
162 vol.Range(min=MIN_ENTRY_DELAY_AWAY, max=MAX_ENTRY_DELAY_AWAY),
164 vol.Optional(ATTR_ENTRY_DELAY_HOME): vol.All(
166 lambda value: value.total_seconds(),
167 vol.Range(max=MAX_ENTRY_DELAY_HOME),
169 vol.Optional(ATTR_EXIT_DELAY_AWAY): vol.All(
171 lambda value: value.total_seconds(),
172 vol.Range(min=MIN_EXIT_DELAY_AWAY, max=MAX_EXIT_DELAY_AWAY),
174 vol.Optional(ATTR_EXIT_DELAY_HOME): vol.All(
176 lambda value: value.total_seconds(),
177 vol.Range(max=MAX_EXIT_DELAY_HOME),
179 vol.Optional(ATTR_LIGHT): cv.boolean,
180 vol.Optional(ATTR_VOICE_PROMPT_VOLUME): vol.All(
181 vol.In(VOLUME_MAP), VOLUME_MAP.get
186 WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT = [
187 EVENT_AUTOMATIC_TEST,
188 EVENT_CAMERA_MOTION_DETECTED,
189 EVENT_DOORBELL_DETECTED,
191 EVENT_SECRET_ALERT_TRIGGERED,
192 EVENT_SENSOR_PAIRED_AND_NAMED,
193 EVENT_USER_INITIATED_TEST,
199 hass: HomeAssistant, call: ServiceCall
201 """Get the SimpliSafe system related to a service call (by device ID)."""
202 device_id = call.data[ATTR_DEVICE_ID]
203 device_registry = dr.async_get(hass)
206 alarm_control_panel_device_entry := device_registry.async_get(device_id)
208 raise vol.Invalid(
"Invalid device ID specified")
210 assert alarm_control_panel_device_entry.via_device_id
213 base_station_device_entry := device_registry.async_get(
214 alarm_control_panel_device_entry.via_device_id
217 raise ValueError(
"No base station registered for alarm control panel")
221 for identity
in base_station_device_entry.identifiers
222 if identity[0] == DOMAIN
224 system_id =
int(system_id_str)
226 for entry_id
in base_station_device_entry.config_entries:
227 if (simplisafe := hass.data[DOMAIN].
get(entry_id))
is None:
229 return cast(SystemType, simplisafe.systems[system_id])
231 raise ValueError(f
"No system for device ID: {device_id}")
236 hass: HomeAssistant, entry: ConfigEntry, system: SystemType
238 """Register a new bridge."""
239 device_registry = dr.async_get(hass)
241 base_station = device_registry.async_get_or_create(
242 config_entry_id=entry.entry_id,
243 identifiers={(DOMAIN,
str(system.system_id))},
244 manufacturer=
"SimpliSafe",
245 model=system.version,
250 if old_base_station := device_registry.async_get_device(
251 identifiers={(DOMAIN, system.system_id)}
255 device_registry.async_update_device(
257 area_id=old_base_station.area_id,
258 disabled_by=old_base_station.disabled_by,
259 name_by_user=old_base_station.name_by_user,
261 device_registry.async_remove_device(old_base_station.id)
266 """Bring a config entry up to current standards."""
267 if CONF_TOKEN
not in entry.data:
269 "SimpliSafe OAuth standard requires re-authentication"
273 if not entry.unique_id:
275 entry_updates[
"unique_id"] = entry.data[CONF_USERNAME]
276 if CONF_CODE
in entry.data:
279 data = {**entry.data}
280 entry_updates[
"data"] = data
281 entry_updates[
"options"] = {
283 CONF_CODE: data.pop(CONF_CODE),
286 hass.config_entries.async_update_entry(entry, **entry_updates)
290 """Set up SimpliSafe as config entry."""
294 websession = aiohttp_client.async_get_clientsession(hass)
297 api = await API.async_from_refresh_token(
298 entry.data[CONF_TOKEN], session=websession
300 except InvalidCredentialsError
as err:
301 raise ConfigEntryAuthFailed
from err
302 except SimplipyError
as err:
303 LOGGER.error(
"Config entry failed: %s", err)
304 raise ConfigEntryNotReady
from err
309 await simplisafe.async_init()
310 except SimplipyError
as err:
311 raise ConfigEntryNotReady
from err
313 hass.data.setdefault(DOMAIN, {})
314 hass.data[DOMAIN][entry.entry_id] = simplisafe
316 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
320 func: Callable[[ServiceCall, SystemType], Coroutine[Any, Any,
None]],
321 ) -> Callable[[ServiceCall], Coroutine[Any, Any,
None]]:
322 """Define a decorator to get the correct system for a service call."""
324 async
def wrapper(call: ServiceCall) ->
None:
325 """Wrap the service function."""
329 await func(call, system)
330 except SimplipyError
as err:
332 f
'Error while executing "{call.service}": {err}'
337 @_verify_domain_control
339 async
def async_remove_pin(call: ServiceCall, system: SystemType) ->
None:
341 await system.async_remove_pin(call.data[ATTR_PIN_LABEL_OR_VALUE])
343 @_verify_domain_control
345 async
def async_set_pin(call: ServiceCall, system: SystemType) ->
None:
347 await system.async_set_pin(call.data[ATTR_PIN_LABEL], call.data[ATTR_PIN_VALUE])
349 @_verify_domain_control
351 async
def async_set_system_properties(
352 call: ServiceCall, system: SystemType
354 """Set one or more system parameters."""
355 if not isinstance(system, SystemV3):
358 await system.async_set_properties(
359 {prop: value
for prop, value
in call.data.items()
if prop != ATTR_DEVICE_ID}
362 for service, method, schema
in (
363 (SERVICE_NAME_REMOVE_PIN, async_remove_pin, SERVICE_REMOVE_PIN_SCHEMA),
364 (SERVICE_NAME_SET_PIN, async_set_pin, SERVICE_SET_PIN_SCHEMA),
366 SERVICE_NAME_SET_SYSTEM_PROPERTIES,
367 async_set_system_properties,
368 SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA,
371 if hass.services.has_service(DOMAIN, service):
375 current_options = {**entry.options}
378 """Handle an options update.
380 This method will get called in two scenarios:
381 1. When SimpliSafeOptionsFlowHandler is initiated
382 2. When a new refresh token is saved to the config entry data
384 We only want #1 to trigger an actual reload.
386 nonlocal current_options
387 updated_options = {**updated_entry.options}
389 if updated_options == current_options:
392 await hass.config_entries.async_reload(entry.entry_id)
394 entry.async_on_unload(entry.add_update_listener(async_reload_entry))
400 """Unload a SimpliSafe config entry."""
401 unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
403 hass.data[DOMAIN].pop(entry.entry_id)
407 for entry
in hass.config_entries.async_entries(DOMAIN)
408 if entry.state == ConfigEntryState.LOADED
410 if len(loaded_entries) == 1:
413 for service_name
in SERVICES:
414 hass.services.async_remove(DOMAIN, service_name)
420 """Define a SimpliSafe data object."""
422 def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: API) ->
None:
426 self._system_notifications: dict[int, set[SystemNotification]] = {}
429 self.initial_event_to_use: dict[int, dict[str, Any]] = {}
430 self.subscription_data: dict[int, Any] = api.subscription_data
431 self.
systemssystems: dict[int, SystemType] = {}
434 self.
coordinatorcoordinator: DataUpdateCoordinator[
None] |
None =
None
438 """Act on any new system notifications."""
439 if self.
_hass_hass.state
is not CoreState.running:
445 latest_notifications = set(system.notifications)
447 to_add = latest_notifications.difference(
448 self._system_notifications[system.system_id]
454 LOGGER.debug(
"New system notifications: %s", to_add)
456 for notification
in to_add:
457 text = notification.text
458 if notification.link:
459 text = f
"{text} For more information: {notification.link}"
461 self.
_hass_hass.bus.async_fire(
462 EVENT_SIMPLISAFE_NOTIFICATION,
464 ATTR_CATEGORY: notification.category,
465 ATTR_CODE: notification.code,
467 ATTR_TIMESTAMP: notification.timestamp,
471 self._system_notifications[system.system_id] = latest_notifications
474 """Start a websocket reconnection loop."""
475 assert self.
_api_api.websocket
478 await self.
_api_api.websocket.async_connect()
479 await self.
_api_api.websocket.async_listen()
480 except asyncio.CancelledError:
481 LOGGER.debug(
"Request to cancel websocket loop received")
483 except WebsocketError
as err:
484 LOGGER.error(
"Failed to connect to websocket: %s", err)
485 except Exception
as err:
486 LOGGER.error(
"Unknown exception while connecting to websocket: %s", err)
488 LOGGER.warning(
"Reconnecting to websocket")
495 """Stop any existing websocket reconnection loop."""
500 except asyncio.CancelledError:
501 LOGGER.debug(
"Websocket reconnection task successfully canceled")
504 assert self.
_api_api.websocket
505 await self.
_api_api.websocket.async_disconnect()
509 """Define a callback for receiving a websocket event."""
510 LOGGER.debug(
"New websocket event: %s", event)
513 self.
_hass_hass, DISPATCHER_TOPIC_WEBSOCKET_EVENT.format(event.system_id), event
516 if event.event_type
not in WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT:
519 sensor_type: str |
None
520 if event.sensor_type:
521 sensor_type = event.sensor_type.name
525 self.
_hass_hass.bus.async_fire(
526 EVENT_SIMPLISAFE_EVENT,
528 ATTR_LAST_EVENT_CHANGED_BY: event.changed_by,
529 ATTR_LAST_EVENT_TYPE: event.event_type,
530 ATTR_LAST_EVENT_INFO: event.info,
531 ATTR_LAST_EVENT_SENSOR_NAME: event.sensor_name,
532 ATTR_LAST_EVENT_SENSOR_SERIAL: event.sensor_serial,
533 ATTR_LAST_EVENT_SENSOR_TYPE: sensor_type,
534 ATTR_SYSTEM_ID: event.system_id,
535 ATTR_LAST_EVENT_TIMESTAMP: event.timestamp,
540 """Initialize the SimpliSafe "manager" class."""
541 assert self.
_api_api.refresh_token
542 assert self.
_api_api.websocket
549 async
def async_websocket_disconnect_listener(_: Event) ->
None:
550 """Define an event handler to disconnect from the websocket."""
551 assert self.
_api_api.websocket
554 self.
entryentry.async_on_unload(
555 self.
_hass_hass.bus.async_listen_once(
556 EVENT_HOMEASSISTANT_STOP, async_websocket_disconnect_listener
561 for system
in self.
systemssystems.values():
562 self._system_notifications[system.system_id] = set()
570 self.initial_event_to_use[
572 ] = await system.async_get_latest_event()
573 except SimplipyError
as err:
574 LOGGER.error(
"Error while fetching initial event: %s", err)
575 self.initial_event_to_use[system.system_id] = {}
580 name=self.
entryentry.title,
581 update_interval=DEFAULT_SCAN_INTERVAL,
586 def async_save_refresh_token(token: str) ->
None:
587 """Save a refresh token to the config entry."""
588 LOGGER.debug(
"Saving new refresh token to HASS storage")
589 self.
_hass_hass.config_entries.async_update_entry(
591 data={**self.
entryentry.data, CONF_TOKEN: token},
594 async
def async_handle_refresh_token(token: str) ->
None:
595 """Handle a new refresh token."""
596 async_save_refresh_token(token)
599 assert self.
_api_api.websocket
605 self.
entryentry.async_on_unload(
606 self.
_api_api.add_refresh_token_callback(async_handle_refresh_token)
610 async_save_refresh_token(self.
_api_api.refresh_token)
613 """Get updated data from SimpliSafe."""
615 async
def async_update_system(system: SystemType) ->
None:
616 """Update a system."""
617 await system.async_update(cached=system.version != 3)
620 tasks = [async_update_system(system)
for system
in self.
systemssystems.values()]
621 results = await asyncio.gather(*tasks, return_exceptions=
True)
623 for result
in results:
624 if isinstance(result, InvalidCredentialsError):
627 if isinstance(result, EndpointUnavailableError):
633 if isinstance(result, SimplipyError):
634 raise UpdateFailed(f
"SimpliSafe error while updating: {result}")
_websocket_reconnect_task
None _async_start_websocket_loop(self)
None __init__(self, HomeAssistant hass, ConfigEntry entry, API api)
None _async_websocket_on_event(self, WebsocketEvent event)
None _async_process_new_notifications(self, SystemType system)
None _async_cancel_websocket_loop(self)
web.Response get(self, web.Request request, str config_key)
None _async_register_base_station(HomeAssistant hass, ConfigEntry entry, SystemType system)
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
None _async_standardize_config_entry(HomeAssistant hass, ConfigEntry entry)
SystemType _async_get_system_for_service_call(HomeAssistant hass, ServiceCall call)
None async_reload_entry(HomeAssistant hass, ConfigEntry entry)
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Callable[[Callable[[ServiceCall], Any]], Callable[[ServiceCall], Any]] verify_domain_control(HomeAssistant hass, str domain)
None async_register_admin_service(HomeAssistant hass, str domain, str service, Callable[[ServiceCall], Awaitable[None]|None] service_func, VolSchemaType schema=vol.Schema({}, extra=vol.PREVENT_EXTRA))