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:
@@ -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))
|
||||
|
||||
@@ -323,6 +323,9 @@
|
||||
"chime_duration": {
|
||||
"name": "Chime duration"
|
||||
},
|
||||
"chime_ring_volume": {
|
||||
"name": "Ring volume ({camera_name})"
|
||||
},
|
||||
"doorbell_ring_volume": {
|
||||
"name": "Doorbell ring volume"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user