1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-30 12:14:20 +01:00
Files
2026-04-30 21:14:48 +02:00

689 lines
24 KiB
Python

"""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
from uiprotect.data import (
NVR,
Camera,
Light,
ModelType,
ProtectAdoptableDeviceModel,
ProtectDeviceModel,
Sensor,
)
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 AddConfigEntryEntitiesCallback
from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
from .entity import (
BaseProtectEntity,
EventEntityMixin,
PermRequired,
ProtectDeviceEntity,
ProtectEntityDescription,
ProtectEventMixin,
ProtectNVREntity,
T,
async_all_device_entities,
)
from .utils import async_get_light_motion_current
_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
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",
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_value="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",
),
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",
),
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",
),
ProtectSensorEntityDescription[Sensor](
key="alarm_sound",
translation_key="alarm_sound_detected",
ufp_value_fn=_get_alarm_sound,
ufp_enabled="is_alarm_sensor_enabled",
),
ProtectSensorEntityDescription(
key="door_last_trip_time",
translation_key="last_open",
device_class=SensorDeviceClass.TIMESTAMP,
ufp_value="open_status_changed_at",
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",
entity_registry_enabled_default=False,
),
ProtectSensorEntityDescription(
key="tampering_last_trip_time",
translation_key="last_tampering_detected",
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_perm=PermRequired.NO_WRITE,
),
ProtectSensorEntityDescription(
key="mount_type",
translation_key="mount_type",
entity_category=EntityCategory.DIAGNOSTIC,
ufp_value="mount_type",
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,
),
)
DOORLOCK_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_value="battery_status.percentage",
),
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_value="last_motion",
entity_registry_enabled_default=False,
),
ProtectSensorEntityDescription(
key="sensitivity",
translation_key="motion_sensitivity",
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",
translation_key="light_mode",
entity_category=EntityCategory.DIAGNOSTIC,
ufp_value_fn=async_get_light_motion_current,
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.DOORLOCK: DOORLOCK_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
@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")
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",
)