"""Component providing sensors for UniFi Protect.""" from __future__ import annotations from collections.abc import Callable, Sequence from dataclasses import dataclass from datetime import datetime from functools import partial import logging from typing import Any from uiprotect.data import ( NVR, Camera, Light, ModelType, ProtectAdoptableDeviceModel, ProtectDeviceModel, Sensor, SmartDetectObjectType, ) from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, UnitOfDataRate, UnitOfElectricPotential, UnitOfInformation, UnitOfTemperature, UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ( BaseProtectEntity, EventEntityMixin, ProtectDeviceEntity, ProtectNVREntity, async_all_device_entities, ) from .models import PermRequired, ProtectEntityDescription, ProtectEventMixin, T from .utils import async_get_light_motion_current _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" @dataclass(frozen=True, kw_only=True) class ProtectSensorEntityDescription( ProtectEntityDescription[T], SensorEntityDescription ): """Describes UniFi Protect Sensor entity.""" precision: int | None = None def __post_init__(self) -> None: """Ensure values are rounded if precision is set.""" super().__post_init__() if precision := self.precision: object.__setattr__( self, "get_ufp_value", partial(self._rounded_value, precision, self.get_ufp_value), ) def _rounded_value(self, precision: int, getter: Callable[[T], Any], obj: T) -> Any: """Round value to precision if set.""" return None if (v := getter(obj)) is None else round(v, precision) @dataclass(frozen=True, kw_only=True) class ProtectSensorEventEntityDescription( ProtectEventMixin[T], SensorEntityDescription ): """Describes UniFi Protect Sensor entity.""" def _get_uptime(obj: ProtectDeviceModel) -> datetime | None: if obj.up_since is None: return None # up_since can vary slightly over time # truncate to ensure no extra state_change events fire return obj.up_since.replace(second=0, microsecond=0) def _get_nvr_recording_capacity(obj: NVR) -> int: if obj.storage_stats.capacity is None: return 0 return int(obj.storage_stats.capacity.total_seconds()) def _get_nvr_memory(obj: NVR) -> float | None: memory = obj.system_info.memory if memory.available is None or memory.total is None: return None return (1 - memory.available / memory.total) * 100 def _get_alarm_sound(obj: Sensor) -> str: alarm_type = OBJECT_TYPE_NONE if ( obj.is_alarm_detected and obj.last_alarm_event is not None and obj.last_alarm_event.metadata is not None ): alarm_type = obj.last_alarm_event.metadata.alarm_type or OBJECT_TYPE_NONE return alarm_type.lower() ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="uptime", name="Uptime", icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ufp_value_fn=_get_uptime, ), ProtectSensorEntityDescription( key="ble_signal", name="Bluetooth signal strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ufp_value="bluetooth_connection_state.signal_strength", ufp_required_field="bluetooth_connection_state.signal_strength", ), ProtectSensorEntityDescription( key="phy_rate", name="Link speed", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ufp_value="wired_connection_state.phy_rate", ufp_required_field="wired_connection_state.phy_rate", ), ProtectSensorEntityDescription( key="wifi_signal", name="WiFi signal strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="wifi_connection_state.signal_strength", ufp_required_field="wifi_connection_state.signal_strength", ), ) CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="oldest_recording", name="Oldest recording", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ufp_value="stats.video.recording_start", ), ProtectSensorEntityDescription( key="storage_used", name="Storage used", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.storage.used", suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, suggested_display_precision=2, ), ProtectSensorEntityDescription( key="write_rate", name="Disk write rate", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.storage.rate_per_second", precision=2, suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, suggested_display_precision=2, ), ProtectSensorEntityDescription( key="voltage", name="Voltage", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="voltage", # no feature flag, but voltage will be null if device does not have # voltage sensor (i.e. is not G4 Doorbell or not on 1.20.1+) ufp_required_field="voltage", precision=2, ), ProtectSensorEntityDescription( key="doorbell_last_trip_time", name="Last doorbell ring", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:doorbell-video", ufp_required_field="feature_flags.is_doorbell", ufp_value="last_ring", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="lens_type", name="Lens type", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:camera-iris", ufp_required_field="has_removable_lens", ufp_value="feature_flags.lens_type", ), ProtectSensorEntityDescription( key="mic_level", name="Microphone level", icon="mdi:microphone", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="has_mic", ufp_value="mic_volume", ufp_enabled="feature_flags.has_mic", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( key="recording_mode", name="Recording mode", icon="mdi:video-outline", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="recording_settings.mode", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( key="infrared", name="Infrared mode", icon="mdi:circle-opacity", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_led_ir", ufp_value="isp_settings.ir_led_mode", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( key="doorbell_text", name="Doorbell text", icon="mdi:card-text", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_lcd_screen", ufp_value="lcd_message.text", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( key="chime_type", name="Chime type", icon="mdi:bell", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ufp_required_field="feature_flags.has_chime", ufp_value="chime_type", ), ) CAMERA_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="stats_rx", name="Received data", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, ufp_value="stats.rx_bytes", suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, suggested_display_precision=2, ), ProtectSensorEntityDescription( key="stats_tx", name="Transferred data", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, ufp_value="stats.tx_bytes", suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, suggested_display_precision=2, ), ) SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="battery_level", name="Battery level", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="battery_status.percentage", ), ProtectSensorEntityDescription( key="light_level", name="Light level", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.light.value", ufp_enabled="is_light_sensor_enabled", ), ProtectSensorEntityDescription( key="humidity_level", name="Humidity level", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.humidity.value", ufp_enabled="is_humidity_sensor_enabled", ), ProtectSensorEntityDescription( key="temperature_level", name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.temperature.value", ufp_enabled="is_temperature_sensor_enabled", ), ProtectSensorEntityDescription[Sensor]( key="alarm_sound", name="Alarm sound detected", ufp_value_fn=_get_alarm_sound, ufp_enabled="is_alarm_sensor_enabled", ), ProtectSensorEntityDescription( key="door_last_trip_time", name="Last open", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="open_status_changed_at", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="motion_last_trip_time", name="Last motion detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="motion_detected_at", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="tampering_last_trip_time", name="Last tampering detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="tampering_detected_at", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="sensitivity", name="Motion sensitivity", icon="mdi:walk", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ufp_value="motion_settings.sensitivity", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( key="mount_type", name="Mount type", icon="mdi:screwdriver", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="mount_type", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( key="paired_camera", name="Paired camera", icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="camera.display_name", ufp_perm=PermRequired.NO_WRITE, ), ) DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="battery_level", name="Battery level", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="battery_status.percentage", ), ProtectSensorEntityDescription( key="paired_camera", name="Paired camera", icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="camera.display_name", ufp_perm=PermRequired.NO_WRITE, ), ) NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="uptime", name="Uptime", icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, ufp_value_fn=_get_uptime, ), ProtectSensorEntityDescription( key="storage_utilization", name="Storage utilization", native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.utilization", precision=2, ), ProtectSensorEntityDescription( key="record_rotating", name="Type: timelapse video", native_unit_of_measurement=PERCENTAGE, icon="mdi:server", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.timelapse_recordings.percentage", precision=2, ), ProtectSensorEntityDescription( key="record_timelapse", name="Type: continuous video", native_unit_of_measurement=PERCENTAGE, icon="mdi:server", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.continuous_recordings.percentage", precision=2, ), ProtectSensorEntityDescription( key="record_detections", name="Type: detections video", native_unit_of_measurement=PERCENTAGE, icon="mdi:server", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.detections_recordings.percentage", precision=2, ), ProtectSensorEntityDescription( key="resolution_HD", name="Resolution: HD video", native_unit_of_measurement=PERCENTAGE, icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.hd_usage.percentage", precision=2, ), ProtectSensorEntityDescription( key="resolution_4K", name="Resolution: 4K video", native_unit_of_measurement=PERCENTAGE, icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.uhd_usage.percentage", precision=2, ), ProtectSensorEntityDescription( key="resolution_free", name="Resolution: free space", native_unit_of_measurement=PERCENTAGE, icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.free.percentage", precision=2, ), ProtectSensorEntityDescription[NVR]( key="record_capacity", name="Recording capacity", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:record-rec", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value_fn=_get_nvr_recording_capacity, ), ) NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="cpu_utilization", name="CPU utilization", native_unit_of_measurement=PERCENTAGE, icon="mdi:speedometer", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="system_info.cpu.average_load", ), ProtectSensorEntityDescription( key="cpu_temperature", name="CPU temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="system_info.cpu.temperature", ), ProtectSensorEntityDescription[NVR]( key="memory_utilization", name="Memory utilization", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value_fn=_get_nvr_memory, precision=2, ), ) LICENSE_PLATE_EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = ( ProtectSensorEventEntityDescription( key="smart_obj_licenseplate", name="License plate detected", icon="mdi:car", translation_key="license_plate", ufp_obj_type=SmartDetectObjectType.LICENSE_PLATE, ufp_required_field="can_detect_license_plate", ufp_event_obj="last_license_plate_detect_event", ), ) LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="motion_last_trip_time", name="Last motion detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="last_motion", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="sensitivity", name="Motion sensitivity", icon="mdi:walk", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ufp_value="light_device_settings.pir_sensitivity", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription[Light]( key="light_motion", name="Light mode", icon="mdi:spotlight", entity_category=EntityCategory.DIAGNOSTIC, ufp_value_fn=async_get_light_motion_current, ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( key="paired_camera", name="Paired camera", icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="camera.display_name", ufp_perm=PermRequired.NO_WRITE, ), ) MOTION_TRIP_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="motion_last_trip_time", name="Last motion detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="last_motion", entity_registry_enabled_default=False, ), ) CHIME_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="last_ring", name="Last ring", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:bell", ufp_value="last_ring", ), ProtectSensorEntityDescription( key="volume", name="Volume", icon="mdi:speaker", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ufp_value="volume", ufp_perm=PermRequired.NO_WRITE, ), ) VIEWER_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="viewer", name="Liveview", icon="mdi:view-dashboard", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="liveview.name", ufp_perm=PermRequired.NO_WRITE, ), ) _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { ModelType.CAMERA: CAMERA_SENSORS + CAMERA_DISABLED_SENSORS, ModelType.SENSOR: SENSE_SENSORS, ModelType.LIGHT: LIGHT_SENSORS, ModelType.DOORLOCK: DOORLOCK_SENSORS, ModelType.CHIME: CHIME_SENSORS, ModelType.VIEWPORT: VIEWER_SENSORS, } async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for UniFi Protect integration.""" data = entry.runtime_data @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: entities = async_all_device_entities( data, ProtectDeviceSensor, all_descs=ALL_DEVICES_SENSORS, model_descriptions=_MODEL_DESCRIPTIONS, ufp_device=device, ) if device.is_adopted_by_us and isinstance(device, Camera): entities += _async_event_entities(data, ufp_device=device) async_add_entities(entities) data.async_subscribe_adopt(_add_new_device) entities = async_all_device_entities( data, ProtectDeviceSensor, all_descs=ALL_DEVICES_SENSORS, model_descriptions=_MODEL_DESCRIPTIONS, ) entities += _async_event_entities(data) entities += _async_nvr_entities(data) async_add_entities(entities) @callback def _async_event_entities( data: ProtectData, ufp_device: Camera | None = None, ) -> list[ProtectDeviceEntity]: entities: list[ProtectDeviceEntity] = [] cameras = data.get_cameras() if ufp_device is None else [ufp_device] for camera in cameras: for description in MOTION_TRIP_SENSORS: entities.append(ProtectDeviceSensor(data, camera, description)) _LOGGER.debug( "Adding trip sensor entity %s for %s", description.name, camera.display_name, ) if not camera.feature_flags.has_smart_detect: continue for event_desc in LICENSE_PLATE_EVENT_SENSORS: if not event_desc.has_required(camera): continue entities.append(ProtectLicensePlateEventSensor(data, camera, event_desc)) _LOGGER.debug( "Adding sensor entity %s for %s", description.name, camera.display_name, ) return entities @callback def _async_nvr_entities( data: ProtectData, ) -> list[BaseProtectEntity]: entities: list[BaseProtectEntity] = [] device = data.api.bootstrap.nvr for description in NVR_SENSORS + NVR_DISABLED_SENSORS: entities.append(ProtectNVRSensor(data, device, description)) _LOGGER.debug("Adding NVR sensor entity %s", description.name) return entities class BaseProtectSensor(BaseProtectEntity, SensorEntity): """A UniFi Protect Sensor Entity.""" entity_description: ProtectSensorEntityDescription _state_attrs = ("_attr_available", "_attr_native_value") def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) class ProtectDeviceSensor(BaseProtectSensor, ProtectDeviceEntity): """A Ubiquiti UniFi Protect Sensor.""" class ProtectNVRSensor(BaseProtectSensor, ProtectNVREntity): """A Ubiquiti UniFi Protect Sensor.""" class ProtectEventSensor(EventEntityMixin, SensorEntity): """A UniFi Protect Device Sensor with access tokens.""" entity_description: ProtectSensorEventEntityDescription _state_attrs = ( "_attr_available", "_attr_native_value", "_attr_extra_state_attributes", ) class ProtectLicensePlateEventSensor(ProtectEventSensor): """A UniFi Protect license plate sensor.""" device: Camera @callback def _set_event_done(self) -> None: self._attr_native_value = OBJECT_TYPE_NONE self._attr_extra_state_attributes = {} @callback def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: description = self.entity_description prev_event = self._event prev_event_end = self._event_end super()._async_update_device_from_protect(device) if event := description.get_event_obj(device): self._event = event self._event_end = event.end if not ( event and (metadata := event.metadata) and (license_plate := metadata.license_plate) and description.has_matching_smart(event) and not self._event_already_ended(prev_event, prev_event_end) ): self._set_event_done() return self._attr_native_value = license_plate.name self._set_event_attrs(event) if event.end: self._async_event_with_immediate_end()