diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index e11b47eeecc7..3c48385fb762 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -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: diff --git a/homeassistant/components/simplisafe/entity.py b/homeassistant/components/simplisafe/entity.py index 5984d453bb81..e3d03bfa047f 100644 --- a/homeassistant/components/simplisafe/entity.py +++ b/homeassistant/components/simplisafe/entity.py @@ -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, diff --git a/homeassistant/components/simplisafe/event.py b/homeassistant/components/simplisafe/event.py new file mode 100644 index 000000000000..7dd08d2fed9b --- /dev/null +++ b/homeassistant/components/simplisafe/event.py @@ -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, + ) diff --git a/homeassistant/components/simplisafe/icons.json b/homeassistant/components/simplisafe/icons.json index 8552993210f9..6b3ba1b1e45a 100644 --- a/homeassistant/components/simplisafe/icons.json +++ b/homeassistant/components/simplisafe/icons.json @@ -1,4 +1,11 @@ { + "entity": { + "event": { + "system_events": { + "default": "mdi:shield-home" + } + } + }, "services": { "remove_pin": { "service": "mdi:alarm-panel-outline" diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json index 1f128522e0f4..09ee57808099 100644 --- a/homeassistant/components/simplisafe/strings.json +++ b/homeassistant/components/simplisafe/strings.json @@ -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": { diff --git a/tests/components/simplisafe/fixtures/subscription_data.json b/tests/components/simplisafe/fixtures/subscription_data.json index f0f6f84b17b9..80e57352103c 100644 --- a/tests/components/simplisafe/fixtures/subscription_data.json +++ b/tests/components/simplisafe/fixtures/subscription_data.json @@ -133,6 +133,7 @@ "upgradeWhitelisted": false, "model": "SS001", "uuid": "1234567890", + "serial": "1234567890", "uid": 12345, "sid": 12345, "cameraSettings": { diff --git a/tests/components/simplisafe/snapshots/test_event.ambr b/tests/components/simplisafe/snapshots/test_event.ambr new file mode 100644 index 000000000000..35e22ea23d50 --- /dev/null +++ b/tests/components/simplisafe/snapshots/test_event.ambr @@ -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({ + : list([ + 'automatic_test', + 'device_test', + 'secret_alert_triggered', + 'sensor_paired_and_named', + 'user_initiated_test', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + '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({ + : None, + : list([ + 'automatic_test', + 'device_test', + 'secret_alert_triggered', + 'sensor_paired_and_named', + 'user_initiated_test', + ]), + : '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': , + 'entity_id': 'event.alarm_control_panel_system_events', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_event_entities[event.camera_camera_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'camera_motion_detected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.camera_camera_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Motion', + 'options': dict({ + }), + 'original_device_class': , + '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({ + : 'motion', + : None, + : list([ + 'camera_motion_detected', + ]), + : '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': , + 'entity_id': 'event.camera_camera_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/simplisafe/test_event.py b/tests/components/simplisafe/test_event.py new file mode 100644 index 000000000000..5071c6353996 --- /dev/null +++ b/tests/components/simplisafe/test_event.py @@ -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 diff --git a/tests/components/simplisafe/test_init.py b/tests/components/simplisafe/test_init.py index e9a3906dced6..f80640d49ed0 100644 --- a/tests/components/simplisafe/test_init.py +++ b/tests/components/simplisafe/test_init.py @@ -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,