diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 26ee052f6dd..ab21d0a8670 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -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)) diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 0ebe3b5dd14..0d9812abcd3 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -323,6 +323,9 @@ "chime_duration": { "name": "Chime duration" }, + "chime_ring_volume": { + "name": "Ring volume ({camera_name})" + }, "doorbell_ring_volume": { "name": "Doorbell ring volume" }, diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index d308d0199d7..7ccb1705964 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -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"