From 90957dfedb5b3431bb3c8c81998443dc490c13c6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 22 Sep 2024 15:59:23 +0200 Subject: [PATCH] Add Reolink hub volume number entities (#126389) * Add Home Hub alarm and message volume * fix styling * Add tests * Update homeassistant/components/reolink/number.py * Update test_diagnostics.ambr --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/reolink/icons.json | 12 +++ homeassistant/components/reolink/number.py | 80 ++++++++++++++++++- homeassistant/components/reolink/strings.json | 6 ++ .../reolink/snapshots/test_diagnostics.ambr | 2 +- tests/components/reolink/test_number.py | 44 ++++++++++ 5 files changed, 142 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index c8cc6f60f09..5815e165607 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -106,6 +106,18 @@ "0": "mdi:volume-off" } }, + "alarm_volume": { + "default": "mdi:volume-high", + "state": { + "0": "mdi:volume-off" + } + }, + "message_volume": { + "default": "mdi:volume-high", + "state": { + "0": "mdi:volume-off" + } + }, "guard_return_time": { "default": "mdi:crosshairs-gps" }, diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index ff523b559d6..8ce568d4bd0 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -24,6 +24,8 @@ from .entity import ( ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, ReolinkChimeEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, ) from .util import ReolinkConfigEntry, ReolinkData @@ -42,6 +44,18 @@ class ReolinkNumberEntityDescription( value: Callable[[Host, int], float | None] +@dataclass(frozen=True, kw_only=True) +class ReolinkHostNumberEntityDescription( + NumberEntityDescription, + ReolinkHostEntityDescription, +): + """A class that describes number entities for the host.""" + + method: Callable[[Host, float], Any] + mode: NumberMode = NumberMode.AUTO + value: Callable[[Host], float | None] + + @dataclass(frozen=True, kw_only=True) class ReolinkChimeNumberEntityDescription( NumberEntityDescription, @@ -474,6 +488,33 @@ NUMBER_ENTITIES = ( ), ) +HOST_NUMBER_ENTITIES = ( + ReolinkHostNumberEntityDescription( + key="alarm_volume", + cmd_key="GetDeviceAudioCfg", + translation_key="alarm_volume", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api: api.supported(None, "hub_audio"), + value=lambda api: api.alarm_volume, + method=lambda api, value: api.set_hub_audio(alarm_volume=int(value)), + ), + ReolinkHostNumberEntityDescription( + key="message_volume", + cmd_key="GetDeviceAudioCfg", + translation_key="message_volume", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api: api.supported(None, "hub_audio"), + value=lambda api: api.message_volume, + method=lambda api, value: api.set_hub_audio(message_volume=int(value)), + ), +) + CHIME_NUMBER_ENTITIES = ( ReolinkChimeNumberEntityDescription( key="volume", @@ -497,12 +538,17 @@ async def async_setup_entry( """Set up a Reolink number entities.""" reolink_data: ReolinkData = config_entry.runtime_data - entities: list[ReolinkNumberEntity | ReolinkChimeNumberEntity] = [ + entities: list[NumberEntity] = [ ReolinkNumberEntity(reolink_data, channel, entity_description) for entity_description in NUMBER_ENTITIES for channel in reolink_data.host.api.channels if entity_description.supported(reolink_data.host.api, channel) ] + entities.extend( + ReolinkHostNumberEntity(reolink_data, entity_description) + for entity_description in HOST_NUMBER_ENTITIES + if entity_description.supported(reolink_data.host.api) + ) entities.extend( ReolinkChimeNumberEntity(reolink_data, chime, entity_description) for entity_description in CHIME_NUMBER_ENTITIES @@ -552,6 +598,38 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): self.async_write_ha_state() +class ReolinkHostNumberEntity(ReolinkHostCoordinatorEntity, NumberEntity): + """Base number entity class for Reolink Host.""" + + entity_description: ReolinkHostNumberEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + entity_description: ReolinkHostNumberEntityDescription, + ) -> None: + """Initialize Reolink number entity.""" + self.entity_description = entity_description + super().__init__(reolink_data) + + self._attr_mode = entity_description.mode + + @property + def native_value(self) -> float | None: + """State of the number entity.""" + return self.entity_description.value(self._host.api) + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + try: + await self.entity_description.method(self._host.api, value) + except InvalidParameterError as err: + raise ServiceValidationError(err) from err + except ReolinkError as err: + raise HomeAssistantError(err) from err + self.async_write_ha_state() + + class ReolinkChimeNumberEntity(ReolinkChimeCoordinatorEntity, NumberEntity): """Base number entity class for Reolink IP cameras.""" diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 4326c6ace9d..6dde5efa2ec 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -395,6 +395,12 @@ "volume": { "name": "Volume" }, + "alarm_volume": { + "name": "Alarm volume" + }, + "message_volume": { + "name": "Message volume" + }, "guard_return_time": { "name": "Guard return time" }, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 542df064f5d..33e9c78c550 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -79,7 +79,7 @@ }), 'GetDeviceAudioCfg': dict({ '0': 2, - 'null': 2, + 'null': 4, }), 'GetEmail': dict({ '0': 1, diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index e9abcec946c..89b6935de5b 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -65,6 +65,50 @@ async def test_number( ) +async def test_host_number( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test number entity with volume.""" + reolink_connect.alarm_volume = 85 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_alarm_volume" + + assert hass.states.get(entity_id).state == "85" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 45}, + blocking=True, + ) + reolink_connect.set_hub_audio.assert_called_with(alarm_volume=45) + + reolink_connect.set_hub_audio.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 45}, + blocking=True, + ) + + reolink_connect.set_hub_audio.side_effect = InvalidParameterError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 45}, + blocking=True, + ) + + async def test_chime_number( hass: HomeAssistant, config_entry: MockConfigEntry,