1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Add per-camera ring volume control for UniFi Protect chimes (#161031)

Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
This commit is contained in:
Raphael Hehl
2026-01-16 08:29:35 +01:00
committed by GitHub
parent ad47eccf5f
commit 2cf813758e
3 changed files with 265 additions and 14 deletions

View File

@@ -5,9 +5,11 @@ from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import timedelta
import logging
from uiprotect.data import (
Camera,
Chime,
Doorlock,
Light,
ModelType,
@@ -30,6 +32,8 @@ from .entity import (
)
from .utils import async_ufp_instance_command
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@@ -245,6 +249,51 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = {
}
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,
@@ -255,23 +304,26 @@ async def async_setup_entry(
@callback
def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
async_add_entities(
async_all_device_entities(
data,
ProtectNumbers,
model_descriptions=_MODEL_DESCRIPTIONS,
ufp_device=device,
)
)
data.async_subscribe_adopt(_add_new_device)
async_add_entities(
async_all_device_entities(
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):
@@ -302,3 +354,62 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity):
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
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
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
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))

View File

@@ -323,6 +323,9 @@
"chime_duration": {
"name": "Chime duration"
},
"chime_ring_volume": {
"name": "Ring volume ({camera_name})"
},
"doorbell_ring_volume": {
"name": "Doorbell ring volume"
},

View File

@@ -3,10 +3,10 @@
from __future__ import annotations
from datetime import timedelta
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock
import pytest
from uiprotect.data import Camera, Doorlock, IRLEDMode, Light
from uiprotect.data import Camera, Chime, Doorlock, IRLEDMode, Light, RingSetting
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION
from homeassistant.components.unifiprotect.number import (
@@ -264,3 +264,140 @@ async def test_number_lock_auto_close(
)
mock_method.assert_called_once_with(timedelta(seconds=15.0))
def _setup_chime_with_doorbell(
chime: Chime, doorbell: Camera, volume: int = 50
) -> None:
"""Set up chime with paired doorbell for testing."""
chime.camera_ids = [doorbell.id]
chime.ring_settings = [
RingSetting(
camera_id=doorbell.id,
repeat_times=1,
ringtone_id="test-ringtone-id",
volume=volume,
)
]
async def test_chime_ring_volume_setup(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
ufp: MockUFPFixture,
chime: Chime,
doorbell: Camera,
) -> None:
"""Test chime ring volume number entity setup."""
_setup_chime_with_doorbell(chime, doorbell, volume=75)
await init_entry(hass, ufp, [chime, doorbell], regenerate_ids=False)
entity_id = "number.test_chime_ring_volume_test_camera"
entity = entity_registry.async_get(entity_id)
assert entity is not None
assert entity.unique_id == f"{chime.mac}_ring_volume_{doorbell.id}"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "75"
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
async def test_chime_ring_volume_set_value(
hass: HomeAssistant,
ufp: MockUFPFixture,
chime: Chime,
doorbell: Camera,
) -> None:
"""Test setting chime ring volume."""
_setup_chime_with_doorbell(chime, doorbell)
await init_entry(hass, ufp, [chime, doorbell], regenerate_ids=False)
entity_id = "number.test_chime_ring_volume_test_camera"
with patch_ufp_method(
chime, "set_volume_for_camera_public", new_callable=AsyncMock
) as mock_method:
await hass.services.async_call(
"number",
"set_value",
{ATTR_ENTITY_ID: entity_id, "value": 80.0},
blocking=True,
)
mock_method.assert_called_once_with(doorbell, 80)
async def test_chime_ring_volume_multiple_cameras(
hass: HomeAssistant,
ufp: MockUFPFixture,
chime: Chime,
doorbell: Camera,
) -> None:
"""Test chime ring volume with multiple paired cameras."""
doorbell2 = doorbell.model_copy()
doorbell2.id = "test-doorbell-2"
doorbell2.name = "Test Doorbell 2"
doorbell2.mac = "aa:bb:cc:dd:ee:02"
chime.camera_ids = [doorbell.id, doorbell2.id]
chime.ring_settings = [
RingSetting(
camera_id=doorbell.id,
repeat_times=1,
ringtone_id="test-ringtone-id",
volume=60,
),
RingSetting(
camera_id=doorbell2.id,
repeat_times=2,
ringtone_id="test-ringtone-id-2",
volume=80,
),
]
await init_entry(hass, ufp, [chime, doorbell, doorbell2], regenerate_ids=False)
state1 = hass.states.get("number.test_chime_ring_volume_test_camera")
assert state1 is not None
assert state1.state == "60"
state2 = hass.states.get("number.test_chime_ring_volume_test_doorbell_2")
assert state2 is not None
assert state2.state == "80"
async def test_chime_ring_volume_unavailable_when_unpaired(
hass: HomeAssistant,
ufp: MockUFPFixture,
chime: Chime,
doorbell: Camera,
) -> None:
"""Test chime ring volume becomes unavailable when camera is unpaired."""
_setup_chime_with_doorbell(chime, doorbell)
await init_entry(hass, ufp, [chime, doorbell], regenerate_ids=False)
entity_id = "number.test_chime_ring_volume_test_camera"
state = hass.states.get(entity_id)
assert state
assert state.state == "50"
# Simulate removing the camera pairing
new_chime = chime.model_copy()
new_chime.ring_settings = []
ufp.api.bootstrap.chimes = {new_chime.id: new_chime}
ufp.api.bootstrap.nvr.system_info.ustorage = None
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = new_chime
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state == "unavailable"