Add event entity platform to SimpliSafe (#174367)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kendell
2026-08-26 18:13:02 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 5587b9883d
commit 2f286f58be
9 changed files with 530 additions and 0 deletions
@@ -4,6 +4,7 @@ import asyncio
from typing import Any
from simplipy import API
from simplipy.device.camera import Camera
from simplipy.errors import (
EndpointUnavailableError,
InvalidCredentialsError,
@@ -12,6 +13,7 @@ from simplipy.errors import (
WebsocketError,
)
from simplipy.system import SystemNotification
from simplipy.system.v3 import SystemV3
from simplipy.websocket import (
EVENT_AUTOMATIC_TEST,
EVENT_CAMERA_MOTION_DETECTED,
@@ -77,11 +79,15 @@ PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.EVENT,
Platform.LOCK,
Platform.SENSOR,
]
# These events are fired on the HA bus as SIMPLISAFE_EVENT for backwards
# compatibility. Do not use SIMPLISAFE_EVENT and do not copy this pattern when
# making new integrations.
WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT = [
EVENT_AUTOMATIC_TEST,
EVENT_CAMERA_MOTION_DETECTED,
@@ -114,6 +120,7 @@ def _async_register_base_station(
manufacturer="SimpliSafe",
model=str(system.version),
name=system.address,
serial_number=system.serial,
)
# Check for an old system ID format and remove it:
@@ -132,6 +139,34 @@ def _async_register_base_station(
device_registry.async_remove_device(old_base_station.id)
@callback
def _async_register_camera(
hass: HomeAssistant, entry: ConfigEntry, system: SystemType, camera: Camera
) -> None:
"""Register a camera device."""
if not isinstance(system, SystemV3):
return
device_registry = dr.async_get(hass)
model_name = camera.camera_type.name.capitalize().replace("_", " ")
model_id = system.camera_data[camera.serial]["model"]
device_name = f"{camera.name.capitalize()} {model_name}"
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, camera.serial)},
manufacturer="SimpliSafe",
model=model_name,
model_id=model_id,
name=device_name,
serial_number=camera.serial,
via_device_id=dr.async_get_device_id_by_identifier(
hass, (DOMAIN, str(system.system_id)), config_entry_id=entry.entry_id
),
)
@callback
def _async_standardize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Bring a config entry up to current standards."""
@@ -340,6 +375,9 @@ class SimpliSafe:
self._hass, DISPATCHER_TOPIC_WEBSOCKET_EVENT.format(event.system_id), event
)
# Deprecated: SIMPLISAFE_EVENT bus events are maintained for backwards
# compatibility. Use the system event entity and event.received trigger
# instead.
if event.event_type not in WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT:
return
@@ -377,6 +415,11 @@ class SimpliSafe:
_async_register_base_station(self._hass, self.entry, system)
# Register each camera as a device:
if isinstance(system, SystemV3):
for camera in system.cameras.values():
_async_register_camera(self._hass, self.entry, system, camera)
# 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:
@@ -88,6 +88,8 @@ class SimpliSafeEntity(CoordinatorEntity[SimpliSafeDataUpdateCoordinator]):
else:
device_type = DeviceTypes.UNKNOWN
# Deprecated: last_event_* attributes are maintained for backwards
# compatibility. Use the event entity's event attributes instead.
self._attr_extra_state_attributes = {
ATTR_LAST_EVENT_INFO: event.get("info"),
ATTR_LAST_EVENT_SENSOR_NAME: event.get("sensorName"),
@@ -102,6 +104,7 @@ class SimpliSafeEntity(CoordinatorEntity[SimpliSafeDataUpdateCoordinator]):
manufacturer="SimpliSafe",
model=model,
name=device_name,
serial_number=serial,
via_device_id=dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, str(system.system_id)),
@@ -181,6 +184,8 @@ class SimpliSafeEntity(CoordinatorEntity[SimpliSafeDataUpdateCoordinator]):
else:
sensor_type = None
# Deprecated: last_event_* attributes are maintained for backwards
# compatibility. Use the event entity's event attributes instead.
self._attr_extra_state_attributes.update(
{
ATTR_LAST_EVENT_INFO: event.info,
@@ -0,0 +1,180 @@
"""Support for SimpliSafe events."""
from dataclasses import dataclass
from typing import TYPE_CHECKING, override
from simplipy.device import Device
from simplipy.device.camera import CameraTypes
from simplipy.system.v3 import SystemV3
from simplipy.websocket import (
EVENT_CAMERA_MOTION_DETECTED,
EVENT_DOORBELL_DETECTED,
WebsocketEvent,
)
from homeassistant.components.event import (
DoorbellEventType,
EventDeviceClass,
EventEntity,
EventEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT, SimpliSafe, SimpliSafeConfigEntry
from .entity import SimpliSafeEntity
from .typing import SystemType
SYSTEM_EVENT_TYPES = [
event
for event in WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT
if event not in (EVENT_CAMERA_MOTION_DETECTED, EVENT_DOORBELL_DETECTED)
]
@dataclass(frozen=True, kw_only=True)
class SimpliSafeCameraEventDescription(EventEntityDescription):
"""Describe a SimpliSafe camera event entity."""
raw_event_type: str
CAMERA_EVENT_DESCRIPTIONS: dict[CameraTypes, list[SimpliSafeCameraEventDescription]] = {
CameraTypes.CAMERA: [
SimpliSafeCameraEventDescription(
key="motion",
device_class=EventDeviceClass.MOTION,
event_types=[EVENT_CAMERA_MOTION_DETECTED],
raw_event_type=EVENT_CAMERA_MOTION_DETECTED,
),
],
CameraTypes.OUTDOOR_CAMERA: [
SimpliSafeCameraEventDescription(
key="motion",
device_class=EventDeviceClass.MOTION,
event_types=[EVENT_CAMERA_MOTION_DETECTED],
raw_event_type=EVENT_CAMERA_MOTION_DETECTED,
),
],
CameraTypes.DOORBELL: [
SimpliSafeCameraEventDescription(
key="ring",
device_class=EventDeviceClass.DOORBELL,
event_types=[DoorbellEventType.RING],
raw_event_type=EVENT_DOORBELL_DETECTED,
),
SimpliSafeCameraEventDescription(
key="motion",
device_class=EventDeviceClass.MOTION,
event_types=[EVENT_CAMERA_MOTION_DETECTED],
raw_event_type=EVENT_CAMERA_MOTION_DETECTED,
),
],
}
async def async_setup_entry(
hass: HomeAssistant,
entry: SimpliSafeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SimpliSafe events based on a config entry."""
simplisafe = entry.runtime_data
entities: list[SimpliSafeEvent] = []
for system in simplisafe.systems.values():
entities.append(
SimpliSafeEvent(
simplisafe,
system,
entity_description=EventEntityDescription(
key="system_events",
event_types=SYSTEM_EVENT_TYPES,
),
unique_id=f"{system.serial}-system_events",
)
)
if not isinstance(system, SystemV3):
continue
if TYPE_CHECKING:
assert isinstance(system, SystemV3)
for uuid, camera in system.cameras.items():
ws_serial = system.camera_data[uuid]["serial"]
entities.extend(
SimpliSafeEvent(
simplisafe,
system,
entity_description=description,
device=camera,
ws_serial=ws_serial,
unique_id=f"{camera.serial}-{description.key}",
)
for description in CAMERA_EVENT_DESCRIPTIONS.get(
camera.camera_type, CAMERA_EVENT_DESCRIPTIONS[CameraTypes.CAMERA]
)
)
async_add_entities(entities)
class SimpliSafeEvent(SimpliSafeEntity, EventEntity):
"""Define a SimpliSafe event entity."""
def __init__(
self,
simplisafe: SimpliSafe,
system: SystemType,
*,
entity_description: EventEntityDescription,
device: Device | None = None,
ws_serial: str | None = None,
unique_id: str,
) -> None:
"""Initialize."""
self.entity_description = entity_description
self._attr_translation_key = entity_description.key
self._ws_serial = ws_serial
super().__init__(
simplisafe,
system,
device=device,
additional_websocket_events=(
[entity_description.raw_event_type]
if isinstance(entity_description, SimpliSafeCameraEventDescription)
else entity_description.event_types
),
)
self._attr_unique_id = unique_id
@callback
@override
def _handle_websocket_update(self, event: WebsocketEvent) -> None:
"""Update the entity with new websocket data."""
if self._ws_serial and event.sensor_serial != self._ws_serial:
return
super()._handle_websocket_update(event)
@callback
@override
def async_update_from_websocket_event(self, event: WebsocketEvent) -> None:
"""Update the entity when new data comes from the websocket."""
assert event.event_type is not None
event_attributes: dict[str, str | None] = {
"changed_by": event.changed_by,
"info": event.info,
}
if not self._ws_serial:
event_attributes["sensor_name"] = event.sensor_name
event_attributes["sensor_serial"] = event.sensor_serial
event_attributes["sensor_type"] = (
event.sensor_type.name if event.sensor_type else None
)
self._trigger_event(
DoorbellEventType.RING
if event.event_type == EVENT_DOORBELL_DETECTED
else event.event_type,
event_attributes=event_attributes,
)
@@ -1,4 +1,11 @@
{
"entity": {
"event": {
"system_events": {
"default": "mdi:shield-home"
}
}
},
"services": {
"remove_pin": {
"service": "mdi:alarm-panel-outline"
@@ -25,6 +25,42 @@
"clear_notifications": {
"name": "Clear notifications"
}
},
"event": {
"motion": {
"name": "Motion",
"state_attributes": {
"event_type": {
"state": {
"camera_motion_detected": "Motion detected"
}
}
}
},
"ring": {
"name": "Ring",
"state_attributes": {
"event_type": {
"state": {
"ring": "[%key:component::event::entity_component::doorbell::state_attributes::event_type::state::ring%]"
}
}
}
},
"system_events": {
"name": "System events",
"state_attributes": {
"event_type": {
"state": {
"automatic_test": "Automatic test",
"device_test": "Device test",
"secret_alert_triggered": "Secret alert triggered",
"sensor_paired_and_named": "Sensor paired and named",
"user_initiated_test": "User initiated test"
}
}
}
}
}
},
"exceptions": {
@@ -133,6 +133,7 @@
"upgradeWhitelisted": false,
"model": "SS001",
"uuid": "1234567890",
"serial": "1234567890",
"uid": 12345,
"sid": 12345,
"cameraSettings": {
@@ -0,0 +1,136 @@
# serializer version: 1
# name: test_event_entities[event.alarm_control_panel_system_events-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'automatic_test',
'device_test',
'secret_alert_triggered',
'sensor_paired_and_named',
'user_initiated_test',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': None,
'entity_id': 'event.alarm_control_panel_system_events',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'System events',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'System events',
'platform': 'simplisafe',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'system_events',
'unique_id': '1234ABCD-system_events',
'unit_of_measurement': None,
})
# ---
# name: test_event_entities[event.alarm_control_panel_system_events-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EventEntityStateAttribute.EVENT_TYPE: 'event_type'>: None,
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'automatic_test',
'device_test',
'secret_alert_triggered',
'sensor_paired_and_named',
'user_initiated_test',
]),
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Alarm control panel System events',
'last_event_info': 'System Disarmed by PIN 2',
'last_event_sensor_name': 'Kitchen',
'last_event_sensor_type': 'keypad',
'last_event_timestamp': 1564018073,
'system_id': 12345,
}),
'context': <ANY>,
'entity_id': 'event.alarm_control_panel_system_events',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_event_entities[event.camera_camera_motion-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'camera_motion_detected',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': None,
'entity_id': 'event.camera_camera_motion',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Motion',
'options': dict({
}),
'original_device_class': <EventDeviceClass.MOTION: 'motion'>,
'original_icon': None,
'original_name': 'Motion',
'platform': 'simplisafe',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'motion',
'unique_id': '1234567890-motion',
'unit_of_measurement': None,
})
# ---
# name: test_event_entities[event.camera_camera_motion-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'motion',
<EventEntityStateAttribute.EVENT_TYPE: 'event_type'>: None,
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'camera_motion_detected',
]),
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Camera Camera Motion',
'last_event_info': 'System Disarmed by PIN 2',
'last_event_sensor_name': 'Kitchen',
'last_event_sensor_type': 'keypad',
'last_event_timestamp': 1564018073,
'system_id': 12345,
}),
'context': <ANY>,
'entity_id': 'event.camera_camera_motion',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
+96
View File
@@ -0,0 +1,96 @@
"""Define tests for SimpliSafe event entities."""
from unittest.mock import Mock, patch
import pytest
from simplipy.websocket import EVENT_CAMERA_MOTION_DETECTED, WebsocketEvent
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
CAMERA_SERIAL = "1234567890"
CAMERA_EVENT_ENTITY_ID = "event.camera_camera_motion"
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_event_entities(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
patch_simplisafe_api,
snapshot: SnapshotAssertion,
) -> None:
"""Test that all event entities are created."""
with patch("homeassistant.components.simplisafe.PLATFORMS", [Platform.EVENT]):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
async def test_camera_event_triggers_on_matching_serial(
hass: HomeAssistant,
config_entry: MockConfigEntry,
patch_simplisafe_api,
websocket: Mock,
) -> None:
"""Test that camera event entity triggers for events with matching serial."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
event_callback = websocket.add_event_callback.call_args[0][0]
# Fire a camera motion event for the camera's serial:
event_callback(
WebsocketEvent(
event_cid=1170,
info="Camera motion detected",
system_id=12345,
_raw_timestamp=0,
_video=None,
_vid=None,
sensor_serial=CAMERA_SERIAL,
)
)
await hass.async_block_till_done()
state = hass.states.get(CAMERA_EVENT_ENTITY_ID)
assert state is not None
assert state.attributes.get("event_type") == EVENT_CAMERA_MOTION_DETECTED
async def test_camera_event_ignores_mismatched_serial(
hass: HomeAssistant,
config_entry: MockConfigEntry,
patch_simplisafe_api,
websocket: Mock,
) -> None:
"""Test that camera event entity ignores events for a different serial."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
event_callback = websocket.add_event_callback.call_args[0][0]
# Fire a camera motion event for a DIFFERENT serial:
event_callback(
WebsocketEvent(
event_cid=1170,
info="Camera motion detected",
system_id=12345,
_raw_timestamp=0,
_video=None,
_vid=None,
sensor_serial="different_serial",
)
)
await hass.async_block_till_done()
state = hass.states.get(CAMERA_EVENT_ENTITY_ID)
assert state is not None
# State should remain unknown and no event should have been triggered:
assert state.state == "unknown"
assert state.attributes.get("event_type") is None
+26
View File
@@ -127,6 +127,32 @@ async def test_coordinator_update_failure_keeps_entity_available(
assert hass.states.get("lock.front_door_lock").state != STATE_UNAVAILABLE
async def test_camera_device_registration(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
config_entry: MockConfigEntry,
patch_simplisafe_api,
) -> None:
"""Test that camera devices are registered in the device registry."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
camera_device = device_registry.async_get_device_by_identifier(
(DOMAIN, "1234567890"), config_entry.entry_id
)
assert camera_device is not None
assert camera_device.manufacturer == "SimpliSafe"
assert camera_device.model == "Camera"
assert camera_device.name == "Camera Camera"
# Verify via_device points to the base station:
base_station = device_registry.async_get_device_by_identifier(
(DOMAIN, "12345"), config_entry.entry_id
)
assert base_station is not None
assert camera_device.via_device_id == base_station.id
async def test_websocket_event_updates_entity_state(
hass: HomeAssistant,
config_entry: MockConfigEntry,