mirror of
https://github.com/home-assistant/core.git
synced 2026-09-27 01:46:11 -04:00
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
404 lines
13 KiB
Python
404 lines
13 KiB
Python
"""Component providing number entities for UniFi Protect."""
|
|
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
from datetime import timedelta
|
|
import logging
|
|
from typing import cast, override
|
|
|
|
from uiprotect.data import Camera, Chime, Light, ModelType, ProtectAdoptableDeviceModel
|
|
from uiprotect.data.public_devices import (
|
|
PublicDeviceModel,
|
|
PublicLight,
|
|
SensorFeatureCapability,
|
|
)
|
|
|
|
from homeassistant.components.number import NumberEntity, NumberEntityDescription
|
|
from homeassistant.const import PERCENTAGE, EntityCategory, Platform, UnitOfTime
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
|
|
from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
|
|
from .entity import (
|
|
PermRequired,
|
|
ProtectDeviceEntity,
|
|
ProtectEntityDescription,
|
|
ProtectSettableKeysMixin,
|
|
T,
|
|
async_all_device_entities,
|
|
async_remove_unsupported_sense_entities,
|
|
)
|
|
from .utils import async_ufp_instance_command
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
PARALLEL_UPDATES = 0
|
|
|
|
|
|
@dataclass(frozen=True, kw_only=True)
|
|
class ProtectNumberEntityDescription(
|
|
ProtectSettableKeysMixin[T], NumberEntityDescription
|
|
):
|
|
"""Describes UniFi Protect Number entity."""
|
|
|
|
ufp_max: int | float
|
|
ufp_min: int | float
|
|
ufp_step: int | float
|
|
|
|
|
|
def _get_pir_duration_public(obj: PublicDeviceModel) -> int | None:
|
|
# Public API reports the PIR auto-shutoff duration in milliseconds.
|
|
duration = cast(PublicLight, obj).light_device_settings.pir_duration
|
|
return None if duration is None else round(duration / 1000)
|
|
|
|
|
|
async def _set_pir_duration(obj: Light, value: float) -> None:
|
|
await obj.set_duration_public(timedelta(seconds=value))
|
|
|
|
|
|
def _get_chime_duration(obj: Camera) -> int:
|
|
return int(obj.chime_duration_seconds)
|
|
|
|
|
|
async def _set_chime_volume(obj: Chime, value: float) -> None:
|
|
"""Set chime volume per paired camera via the public API."""
|
|
level = int(value)
|
|
ring_settings = [setting.to_api_dict(volume=level) for setting in obj.ring_settings]
|
|
if ring_settings:
|
|
await obj.set_ring_settings_public(ring_settings)
|
|
|
|
|
|
CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = (
|
|
ProtectNumberEntityDescription(
|
|
key="wdr_value",
|
|
translation_key="wide_dynamic_range",
|
|
entity_category=EntityCategory.CONFIG,
|
|
ufp_min=0,
|
|
ufp_max=3,
|
|
ufp_step=1,
|
|
ufp_required_field="feature_flags.has_wdr",
|
|
ufp_value="isp_settings.wdr",
|
|
ufp_set_method="set_wdr_level",
|
|
ufp_perm=PermRequired.WRITE,
|
|
),
|
|
ProtectNumberEntityDescription(
|
|
key="mic_level",
|
|
translation_key="microphone_level",
|
|
entity_category=EntityCategory.CONFIG,
|
|
native_unit_of_measurement=PERCENTAGE,
|
|
ufp_min=1,
|
|
ufp_max=100,
|
|
ufp_step=1,
|
|
ufp_required_field="has_mic",
|
|
ufp_public_value="mic_volume",
|
|
ufp_set_method="set_mic_volume_public",
|
|
ufp_perm=PermRequired.WRITE,
|
|
),
|
|
ProtectNumberEntityDescription(
|
|
key="system_sounds_volume",
|
|
translation_key="system_sounds_volume",
|
|
entity_category=EntityCategory.CONFIG,
|
|
native_unit_of_measurement=PERCENTAGE,
|
|
ufp_min=0,
|
|
ufp_max=100,
|
|
ufp_step=1,
|
|
ufp_required_field="feature_flags.has_speaker",
|
|
ufp_value="speaker_settings.volume",
|
|
ufp_enabled="feature_flags.has_speaker",
|
|
ufp_set_method="set_volume",
|
|
ufp_perm=PermRequired.WRITE,
|
|
),
|
|
ProtectNumberEntityDescription(
|
|
key="doorbell_ring_volume",
|
|
translation_key="doorbell_ring_volume",
|
|
entity_category=EntityCategory.CONFIG,
|
|
native_unit_of_measurement=PERCENTAGE,
|
|
ufp_min=0,
|
|
ufp_max=100,
|
|
ufp_step=1,
|
|
ufp_required_field="feature_flags.is_doorbell",
|
|
ufp_value="speaker_settings.ring_volume",
|
|
ufp_enabled="feature_flags.is_doorbell",
|
|
ufp_set_method="set_ring_volume",
|
|
ufp_perm=PermRequired.WRITE,
|
|
),
|
|
ProtectNumberEntityDescription(
|
|
key="zoom_position",
|
|
translation_key="zoom_level",
|
|
entity_category=EntityCategory.CONFIG,
|
|
native_unit_of_measurement=PERCENTAGE,
|
|
ufp_min=0,
|
|
ufp_max=100,
|
|
ufp_step=1,
|
|
ufp_required_field="feature_flags.can_optical_zoom",
|
|
ufp_value="isp_settings.zoom_position",
|
|
ufp_set_method="set_camera_zoom",
|
|
ufp_perm=PermRequired.WRITE,
|
|
),
|
|
ProtectNumberEntityDescription(
|
|
key="chime_duration",
|
|
translation_key="chime_duration",
|
|
entity_category=EntityCategory.CONFIG,
|
|
native_unit_of_measurement=UnitOfTime.SECONDS,
|
|
ufp_min=1,
|
|
ufp_max=10,
|
|
ufp_step=0.1,
|
|
ufp_required_field="feature_flags.has_chime",
|
|
ufp_enabled="is_digital_chime",
|
|
ufp_value_fn=_get_chime_duration,
|
|
ufp_set_method="set_chime_duration",
|
|
ufp_perm=PermRequired.WRITE,
|
|
),
|
|
ProtectNumberEntityDescription(
|
|
key="icr_lux",
|
|
translation_key="infrared_custom_lux_trigger",
|
|
entity_category=EntityCategory.CONFIG,
|
|
ufp_min=0,
|
|
ufp_max=30,
|
|
ufp_step=1,
|
|
ufp_required_field="feature_flags.has_led_ir",
|
|
ufp_value="icr_lux_display",
|
|
ufp_set_method="set_icr_custom_lux",
|
|
ufp_enabled="is_ir_led_slider_enabled",
|
|
ufp_perm=PermRequired.WRITE,
|
|
),
|
|
)
|
|
|
|
LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = (
|
|
ProtectNumberEntityDescription(
|
|
key="sensitivity",
|
|
translation_key="motion_sensitivity",
|
|
entity_category=EntityCategory.CONFIG,
|
|
native_unit_of_measurement=PERCENTAGE,
|
|
ufp_min=0,
|
|
ufp_max=100,
|
|
ufp_step=1,
|
|
ufp_public_value="light_device_settings.pir_sensitivity",
|
|
ufp_set_method="set_sensitivity_public",
|
|
ufp_perm=PermRequired.WRITE,
|
|
),
|
|
ProtectNumberEntityDescription[Light](
|
|
key="duration",
|
|
translation_key="auto_shutoff_duration",
|
|
entity_category=EntityCategory.CONFIG,
|
|
native_unit_of_measurement=UnitOfTime.SECONDS,
|
|
ufp_min=15,
|
|
ufp_max=900,
|
|
ufp_step=15,
|
|
ufp_public_value_fn=_get_pir_duration_public,
|
|
ufp_set_method_fn=_set_pir_duration,
|
|
ufp_perm=PermRequired.WRITE,
|
|
),
|
|
)
|
|
|
|
SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = (
|
|
ProtectNumberEntityDescription(
|
|
key="sensitivity",
|
|
translation_key="motion_sensitivity",
|
|
entity_category=EntityCategory.CONFIG,
|
|
native_unit_of_measurement=PERCENTAGE,
|
|
ufp_min=0,
|
|
ufp_max=100,
|
|
ufp_step=1,
|
|
ufp_public_value="motion_settings.sensitivity",
|
|
ufp_set_method="set_motion_sensitivity_public",
|
|
ufp_capability=SensorFeatureCapability.MOTION,
|
|
ufp_perm=PermRequired.WRITE,
|
|
),
|
|
)
|
|
|
|
CHIME_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = (
|
|
ProtectNumberEntityDescription[Chime](
|
|
key="volume",
|
|
translation_key="volume",
|
|
entity_category=EntityCategory.CONFIG,
|
|
native_unit_of_measurement=PERCENTAGE,
|
|
ufp_min=0,
|
|
ufp_max=100,
|
|
ufp_step=1,
|
|
ufp_value="volume",
|
|
ufp_set_method_fn=_set_chime_volume,
|
|
ufp_perm=PermRequired.WRITE,
|
|
),
|
|
)
|
|
_MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = {
|
|
ModelType.CAMERA: CAMERA_NUMBERS,
|
|
ModelType.LIGHT: LIGHT_NUMBERS,
|
|
ModelType.SENSOR: SENSE_NUMBERS,
|
|
ModelType.CHIME: CHIME_NUMBERS,
|
|
}
|
|
|
|
|
|
def _async_chime_ring_volume_entities(
|
|
data: ProtectData,
|
|
chime: Chime,
|
|
) -> list[ChimeRingVolumeNumber]:
|
|
"""Generate ring volume entities for each paired camera on a chime."""
|
|
entities: list[ChimeRingVolumeNumber] = []
|
|
|
|
if not chime.is_adopted_by_us:
|
|
return entities
|
|
|
|
auth_user = data.api.bootstrap.auth_user
|
|
if not chime.can_write(auth_user):
|
|
return entities
|
|
|
|
for ring_setting in chime.ring_settings:
|
|
camera = data.api.bootstrap.cameras.get(ring_setting.camera_id)
|
|
if camera is None:
|
|
_LOGGER.debug(
|
|
"Camera %s not found for chime %s ring volume",
|
|
ring_setting.camera_id,
|
|
chime.display_name,
|
|
)
|
|
continue
|
|
entities.append(ChimeRingVolumeNumber(data, chime, camera))
|
|
|
|
return entities
|
|
|
|
|
|
def _async_all_chime_ring_volume_entities(
|
|
data: ProtectData,
|
|
chime: Chime | None = None,
|
|
) -> list[ChimeRingVolumeNumber]:
|
|
"""Generate all ring volume entities for chimes."""
|
|
entities: list[ChimeRingVolumeNumber] = []
|
|
|
|
if chime is not None:
|
|
return _async_chime_ring_volume_entities(data, chime)
|
|
|
|
for device in data.get_by_types({ModelType.CHIME}):
|
|
if isinstance(device, Chime):
|
|
entities.extend(_async_chime_ring_volume_entities(data, device))
|
|
|
|
return entities
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: UFPConfigEntry,
|
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
) -> None:
|
|
"""Set up number entities for UniFi Protect integration."""
|
|
data = entry.runtime_data
|
|
async_remove_unsupported_sense_entities(hass, Platform.NUMBER, data, SENSE_NUMBERS)
|
|
|
|
@callback
|
|
def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
|
|
entities = async_all_device_entities(
|
|
data,
|
|
ProtectNumbers,
|
|
model_descriptions=_MODEL_DESCRIPTIONS,
|
|
ufp_device=device,
|
|
)
|
|
# Add ring volume entities for chimes
|
|
if isinstance(device, Chime):
|
|
entities += _async_all_chime_ring_volume_entities(data, device)
|
|
async_add_entities(entities)
|
|
|
|
data.async_subscribe_adopt(_add_new_device)
|
|
entities = async_all_device_entities(
|
|
data,
|
|
ProtectNumbers,
|
|
model_descriptions=_MODEL_DESCRIPTIONS,
|
|
)
|
|
# Add ring volume entities for all chimes
|
|
entities += _async_all_chime_ring_volume_entities(data)
|
|
async_add_entities(entities)
|
|
|
|
|
|
class ProtectNumbers(ProtectDeviceEntity, NumberEntity):
|
|
"""A UniFi Protect Number Entity."""
|
|
|
|
device: Camera | Light
|
|
entity_description: ProtectNumberEntityDescription
|
|
_state_attrs = ("_attr_available", "_attr_native_value")
|
|
|
|
def __init__(
|
|
self,
|
|
data: ProtectData,
|
|
device: Camera | Light,
|
|
description: ProtectNumberEntityDescription,
|
|
) -> None:
|
|
"""Initialize the Number Entities."""
|
|
super().__init__(data, device, description)
|
|
self._attr_native_max_value = self.entity_description.ufp_max
|
|
self._attr_native_min_value = self.entity_description.ufp_min
|
|
self._attr_native_step = self.entity_description.ufp_step
|
|
|
|
@callback
|
|
@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
|
|
)
|
|
|
|
@async_ufp_instance_command
|
|
@override
|
|
async def async_set_native_value(self, value: float) -> None:
|
|
"""Set new value."""
|
|
await self.entity_description.ufp_set(self.device, value)
|
|
|
|
|
|
class ChimeRingVolumeNumber(ProtectDeviceEntity, NumberEntity):
|
|
"""A UniFi Protect Number Entity for ring volume per camera on a chime."""
|
|
|
|
device: Chime
|
|
_state_attrs = ("_attr_available", "_attr_native_value")
|
|
_attr_native_max_value: float = 100
|
|
_attr_native_min_value: float = 0
|
|
_attr_native_step: float = 1
|
|
_attr_native_unit_of_measurement = PERCENTAGE
|
|
_attr_entity_category = EntityCategory.CONFIG
|
|
|
|
def __init__(
|
|
self,
|
|
data: ProtectData,
|
|
chime: Chime,
|
|
camera: Camera,
|
|
) -> None:
|
|
"""Initialize the ring volume number entity."""
|
|
self._camera_id = camera.id
|
|
# Use chime MAC and camera ID for unique ID
|
|
super().__init__(data, chime)
|
|
self._attr_unique_id = f"{chime.mac}_ring_volume_{camera.id}"
|
|
self._attr_translation_key = "chime_ring_volume"
|
|
self._attr_translation_placeholders = {"camera_name": camera.display_name}
|
|
# BaseProtectEntity sets _attr_name = None when no description is passed,
|
|
# which prevents translation_key from being used. Delete to enable translations.
|
|
del self._attr_name
|
|
|
|
@callback
|
|
@override
|
|
def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
|
|
"""Update entity from protect device."""
|
|
super()._async_update_device_from_protect(device)
|
|
self._attr_native_value = self._get_ring_volume()
|
|
|
|
def _get_ring_volume(self) -> int | None:
|
|
"""Get the ring volume for this camera from the chime's ring settings."""
|
|
for ring_setting in self.device.ring_settings:
|
|
if ring_setting.camera_id == self._camera_id:
|
|
return ring_setting.volume
|
|
return None
|
|
|
|
@property
|
|
@override
|
|
def available(self) -> bool:
|
|
"""Return if entity is available."""
|
|
# Entity is unavailable if the camera is no longer paired with the chime
|
|
return super().available and self._get_ring_volume() is not None
|
|
|
|
@async_ufp_instance_command
|
|
@override
|
|
async def async_set_native_value(self, value: float) -> None:
|
|
"""Set new ring volume value."""
|
|
camera = self.data.api.bootstrap.cameras.get(self._camera_id)
|
|
if camera is None:
|
|
_LOGGER.warning(
|
|
"Cannot set ring volume: camera %s not found", self._camera_id
|
|
)
|
|
return
|
|
await self.device.set_volume_for_camera_public(camera, int(value))
|