Files

246 lines
8.3 KiB
Python

"""Support for SimpliSafe alarm systems."""
from collections.abc import Iterable
from typing import override
from simplipy.device import Device, DeviceTypes
from simplipy.system.v3 import SystemV3
from simplipy.websocket import (
EVENT_CONNECTION_LOST,
EVENT_CONNECTION_RESTORED,
EVENT_LOCK_LOCKED,
EVENT_LOCK_UNLOCKED,
EVENT_POWER_OUTAGE,
EVENT_POWER_RESTORED,
EVENT_SECRET_ALERT_TRIGGERED,
WebsocketEvent,
)
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import SimpliSafe
from .const import (
ATTR_LAST_EVENT_INFO,
ATTR_LAST_EVENT_SENSOR_NAME,
ATTR_LAST_EVENT_SENSOR_TYPE,
ATTR_LAST_EVENT_TIMESTAMP,
ATTR_SYSTEM_ID,
DISPATCHER_TOPIC_WEBSOCKET_EVENT,
DOMAIN,
LOGGER,
)
from .coordinator import SimpliSafeDataUpdateCoordinator
from .typing import SystemType
DEFAULT_CONFIG_URL = "https://webapp.simplisafe.com/new/#/dashboard"
DEFAULT_ENTITY_MODEL = "Alarm control panel"
DEFAULT_ERROR_THRESHOLD = 2
WEBSOCKET_EVENTS_REQUIRING_SERIAL = [
EVENT_LOCK_LOCKED,
EVENT_LOCK_UNLOCKED,
EVENT_SECRET_ALERT_TRIGGERED,
]
class SimpliSafeEntity(CoordinatorEntity[SimpliSafeDataUpdateCoordinator]):
"""Define a base SimpliSafe entity."""
_attr_has_entity_name = True
def __init__(
self,
simplisafe: SimpliSafe,
system: SystemType,
*,
device: Device | None = None,
additional_websocket_events: Iterable[str] | None = None,
) -> None:
"""Initialize."""
assert simplisafe.coordinator
super().__init__(simplisafe.coordinator)
# SimpliSafe can incorrectly return an error state when there isn't any
# error. This can lead to entities having an unknown state frequently.
# To protect against that, we measure an error count for each entity and only
# mark the state as unavailable if we detect a few in a row:
self._error_count = 0
if device:
model = device.type.name.capitalize().replace("_", " ")
device_name = f"{device.name.capitalize()} {model}"
serial = device.serial
else:
model = device_name = DEFAULT_ENTITY_MODEL
serial = system.serial
event = simplisafe.initial_event_to_use[system.system_id]
if raw_type := event.get("sensorType"):
try:
device_type = DeviceTypes(raw_type)
except ValueError:
device_type = DeviceTypes.UNKNOWN
else:
device_type = DeviceTypes.UNKNOWN
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.lower(),
ATTR_LAST_EVENT_TIMESTAMP: event.get("eventTimestamp"),
ATTR_SYSTEM_ID: system.system_id,
}
self._attr_device_info = DeviceInfo(
configuration_url=DEFAULT_CONFIG_URL,
identifiers={(DOMAIN, serial)},
manufacturer="SimpliSafe",
model=model,
name=device_name,
via_device_id=dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, str(system.system_id)),
config_entry_id=self.coordinator.config_entry.entry_id,
),
)
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
@override
def available(self) -> bool:
"""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, 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
if isinstance(self._system, SystemV3):
system_offline = self._system.offline
else:
system_offline = False
return (
self._error_count < DEFAULT_ERROR_THRESHOLD
and self._online
and not system_offline
)
@callback
@override
def _handle_coordinator_update(self) -> None:
"""Update the entity with new REST API data."""
if self.coordinator.last_update_success:
self.async_reset_error_count()
else:
self.async_increment_error_count()
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
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,
}
)
# It's unknown whether these events reach the base station (since the connection
# is lost); we include this for completeness and coverage:
if event.event_type in (EVENT_CONNECTION_LOST, EVENT_POWER_OUTAGE):
self._online = False
return
# If the base station comes back online, set entities to available, but don't
# instruct the entities to update their state (since there won't be anything new
# until the next websocket event or REST API update:
if event.event_type in (EVENT_CONNECTION_RESTORED, EVENT_POWER_RESTORED):
self._online = True
return
self.async_update_from_websocket_event(event)
self.async_write_ha_state()
@override
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_increment_error_count(self) -> None:
"""Increment this entity's error count."""
LOGGER.debug('Error for entity "%s" (total: %s)', self.name, self._error_count)
self._error_count += 1
@callback
def async_reset_error_count(self) -> None:
"""Reset this entity's error count."""
if self._error_count == 0:
return
LOGGER.debug('Resetting error count for "%s"', self.name)
self._error_count = 0
@callback
def async_update_from_rest_api(self) -> None:
"""Update the entity when new data comes from the REST API."""
@callback
def async_update_from_websocket_event(self, event: WebsocketEvent) -> None:
"""Update the entity when new data comes from the websocket."""