"""Component providing sensors for UniFi Protect.""" from collections.abc import Callable, Sequence from dataclasses import dataclass from datetime import datetime from functools import partial import logging from typing import Any, cast, override from uiprotect.data import ( NVR, Camera, Light, ModelType, ProtectAdoptableDeviceModel, ProtectDeviceModel, Sensor, ) from uiprotect.data.public_devices import ( PublicDeviceModel, PublicLight, SensorFeatureCapability, ) from uiprotect.utils import convert_to_datetime from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, Platform, UnitOfDataRate, UnitOfElectricPotential, UnitOfInformation, UnitOfTemperature, UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ( BaseProtectEntity, EventEntityMixin, PermRequired, ProtectDeviceEntity, ProtectEntityDescription, ProtectEventMixin, ProtectNVREntity, T, async_all_device_entities, async_remove_unsupported_sense_entities, ) from .utils import async_get_light_motion_current_public _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) class ProtectSensorEntityDescription( ProtectEntityDescription[T], SensorEntityDescription ): """Describes UniFi Protect Sensor entity.""" precision: int | None = None @override 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_last_motion_public(obj: PublicDeviceModel) -> datetime | None: # Public API reports last motion as a JS epoch (ms); private side a datetime. return convert_to_datetime(cast(PublicLight, obj).last_motion) 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", translation_key="uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ufp_value_fn=_get_uptime, ), ProtectSensorEntityDescription( key="ble_signal", translation_key="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", translation_key="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", translation_key="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", translation_key="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", translation_key="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", translation_key="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", 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", translation_key="last_doorbell_ring", device_class=SensorDeviceClass.TIMESTAMP, ufp_required_field="feature_flags.is_doorbell", ufp_value="last_ring", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="lens_type", translation_key="lens_type", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="has_removable_lens", ufp_value="feature_flags.lens_type", ), ProtectSensorEntityDescription( key="mic_level", translation_key="microphone_level", 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", translation_key="recording_mode", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="recording_settings.mode.value", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( key="infrared", translation_key="infrared_mode", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_led_ir", ufp_value="isp_settings.ir_led_mode.value", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( key="doorbell_text", translation_key="doorbell_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", translation_key="chime_type", 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", translation_key="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", translation_key="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", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_public_value="wireless_connection_state.battery_status.percentage", ), ProtectSensorEntityDescription( key="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", ufp_capability=SensorFeatureCapability.LIGHT, ), ProtectSensorEntityDescription( key="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", ufp_capability=SensorFeatureCapability.HUMIDITY, ), ProtectSensorEntityDescription( key="temperature_level", 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", ufp_capability=SensorFeatureCapability.TEMPERATURE, ), ProtectSensorEntityDescription[Sensor]( key="alarm_sound", translation_key="alarm_sound_detected", ufp_value_fn=_get_alarm_sound, ufp_enabled="is_alarm_sensor_enabled", ufp_capability=SensorFeatureCapability.SMOKE, ), ProtectSensorEntityDescription( key="door_last_trip_time", translation_key="last_open", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="open_status_changed_at", ufp_capability=SensorFeatureCapability.OPEN, entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="motion_last_trip_time", translation_key="last_motion_detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="motion_detected_at", ufp_capability=SensorFeatureCapability.MOTION, entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="tampering_last_trip_time", translation_key="last_tampering_detected", ufp_capability=SensorFeatureCapability.TAMPER, device_class=SensorDeviceClass.TIMESTAMP, ufp_value="tampering_detected_at", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="sensitivity", translation_key="sensitivity", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ufp_value="motion_settings.sensitivity", ufp_capability=SensorFeatureCapability.MOTION, ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( key="mount_type", translation_key="mount_type", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="mount_type", ufp_capability=SensorFeatureCapability.OPEN, ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( key="paired_camera", translation_key="paired_camera", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="camera.display_name", ufp_perm=PermRequired.NO_WRITE, ), ) NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="uptime", translation_key="uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, ufp_value_fn=_get_uptime, ), ProtectSensorEntityDescription( key="storage_utilization", translation_key="storage_utilization", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.utilization", precision=2, ), ProtectSensorEntityDescription( key="record_rotating", translation_key="type_timelapse_video", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.timelapse_recordings.percentage", precision=2, ), ProtectSensorEntityDescription( key="record_timelapse", translation_key="type_continuous_video", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.continuous_recordings.percentage", precision=2, ), ProtectSensorEntityDescription( key="record_detections", translation_key="type_detections_video", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.detections_recordings.percentage", precision=2, ), ProtectSensorEntityDescription( key="resolution_HD", translation_key="resolution_hd_video", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.hd_usage.percentage", precision=2, ), ProtectSensorEntityDescription( key="resolution_4K", translation_key="resolution_4k_video", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.uhd_usage.percentage", precision=2, ), ProtectSensorEntityDescription( key="resolution_free", translation_key="resolution_free_space", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="storage_stats.storage_distribution.free.percentage", precision=2, ), ProtectSensorEntityDescription[NVR]( key="record_capacity", translation_key="recording_capacity", native_unit_of_measurement=UnitOfTime.SECONDS, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value_fn=_get_nvr_recording_capacity, ), ) NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="cpu_utilization", translation_key="cpu_utilization", native_unit_of_measurement=PERCENTAGE, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="system_info.cpu.average_load", ), ProtectSensorEntityDescription( key="cpu_temperature", translation_key="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", translation_key="memory_utilization", native_unit_of_measurement=PERCENTAGE, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value_fn=_get_nvr_memory, precision=2, ), ) LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="motion_last_trip_time", translation_key="last_motion_detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_public_value_fn=_get_last_motion_public, entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="sensitivity", translation_key="motion_sensitivity", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ufp_public_value="light_device_settings.pir_sensitivity", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription[Light]( key="light_motion", translation_key="light_mode", entity_category=EntityCategory.DIAGNOSTIC, ufp_public_value_fn=async_get_light_motion_current_public, ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( key="paired_camera", translation_key="paired_camera", 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", translation_key="last_motion_detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="last_motion", entity_registry_enabled_default=False, ), ) CHIME_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="last_ring", translation_key="last_ring", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="last_ring", ), ProtectSensorEntityDescription( key="volume", translation_key="volume", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ufp_value="volume", ufp_perm=PermRequired.NO_WRITE, ), ) VIEWER_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="viewer", translation_key="liveview", 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.CHIME: CHIME_SENSORS, ModelType.VIEWPORT: VIEWER_SENSORS, } async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for UniFi Protect integration.""" data = entry.runtime_data async_remove_unsupported_sense_entities(hass, Platform.SENSOR, data, SENSE_SENSORS) @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, ) # AiPort inherits from Camera but should not create camera-specific entities if ( device.model is ModelType.CAMERA and isinstance(device, Camera) and device.is_adopted_by_us ): 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, ) 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") @override 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_value( self.device, self._ufp_public_obj ) 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", )