Allow setting volume on Ring devices (#125773)

* Turn Ring Doorbell and Chime volumes into number entities.

* turn RingOther volumes into numbers as well

* fix linter issues

* move other volume strings into `number` section

* add back old volume sensors but deprecate them

* add tests for `ring.number`

* add back strings for sensors that have just become deprecated

* remove deprecated volume sensors from test

* Revert "remove deprecated volume sensors from test"

This reverts commit fc95af66e7.

* create entities for deprecated sensors so that tests still run

* remove print

* add entities immediately

* move `RingNumberEntityDescription` above `RingNumber` and remove unused import

* remove irrelevant comment about history

* fix not using `setter_fn`

* add missing icons for other volume entities

* rename `entity` -> `entity_id` in number tests

* fix typing in number test

* use constants for `hass.services.async_call()`

* use `@refresh_after` decorator instead of delaying updates manually

* move descriptors above entity class

* Use snapshot to test states.

* add missing snapshot file for number platform

* Update homeassistant/components/ring/number.py

Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>

---------

Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>
pull/126128/head
Daniel Krebs 2024-09-17 15:41:51 +02:00 committed by GitHub
parent 9557386b6e
commit c8e2408f82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 2687 additions and 6 deletions

View File

@ -20,6 +20,7 @@ PLATFORMS = [
Platform.CAMERA, Platform.CAMERA,
Platform.EVENT, Platform.EVENT,
Platform.LIGHT, Platform.LIGHT,
Platform.NUMBER,
Platform.SENSOR, Platform.SENSOR,
Platform.SIREN, Platform.SIREN,
Platform.SWITCH, Platform.SWITCH,

View File

@ -1,5 +1,19 @@
{ {
"entity": { "entity": {
"number": {
"volume": {
"default": "mdi:bell-ring"
},
"doorbell_volume": {
"default": "mdi:bell-ring"
},
"mic_volume": {
"default": "mdi:microphone"
},
"voice_volume": {
"default": "mdi:account-voice"
}
},
"sensor": { "sensor": {
"last_activity": { "last_activity": {
"default": "mdi:history" "default": "mdi:history"

View File

@ -0,0 +1,150 @@
"""Component providing HA number support for Ring Door Bell/Chimes."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, Generic, cast
from ring_doorbell import RingChime, RingDoorBell, RingGeneric, RingOther
import ring_doorbell.const
from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import RingConfigEntry
from .coordinator import RingDataCoordinator
from .entity import RingDeviceT, RingEntity, refresh_after
async def async_setup_entry(
hass: HomeAssistant,
entry: RingConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up a numbers for a Ring device."""
ring_data = entry.runtime_data
devices_coordinator = ring_data.devices_coordinator
async_add_entities(
RingNumber(device, devices_coordinator, description)
for description in NUMBER_TYPES
for device in ring_data.devices.all_devices
if description.exists_fn(device)
)
@dataclass(frozen=True, kw_only=True)
class RingNumberEntityDescription(NumberEntityDescription, Generic[RingDeviceT]):
"""Describes Ring number entity."""
value_fn: Callable[[RingDeviceT], StateType]
setter_fn: Callable[[RingDeviceT, float], Awaitable[None]]
exists_fn: Callable[[RingGeneric], bool]
NUMBER_TYPES: tuple[RingNumberEntityDescription[Any], ...] = (
RingNumberEntityDescription[RingChime](
key="volume",
translation_key="volume",
mode=NumberMode.SLIDER,
native_min_value=ring_doorbell.const.CHIME_VOL_MIN,
native_max_value=ring_doorbell.const.CHIME_VOL_MAX,
native_step=1,
value_fn=lambda device: device.volume,
setter_fn=lambda device, value: device.async_set_volume(int(value)),
exists_fn=lambda device: isinstance(device, RingChime),
),
RingNumberEntityDescription[RingDoorBell](
key="volume",
translation_key="volume",
mode=NumberMode.SLIDER,
native_min_value=ring_doorbell.const.DOORBELL_VOL_MIN,
native_max_value=ring_doorbell.const.DOORBELL_VOL_MAX,
native_step=1,
value_fn=lambda device: device.volume,
setter_fn=lambda device, value: device.async_set_volume(int(value)),
exists_fn=lambda device: isinstance(device, RingDoorBell),
),
RingNumberEntityDescription[RingOther](
key="doorbell_volume",
translation_key="doorbell_volume",
mode=NumberMode.SLIDER,
native_min_value=ring_doorbell.const.OTHER_DOORBELL_VOL_MIN,
native_max_value=ring_doorbell.const.OTHER_DOORBELL_VOL_MAX,
native_step=1,
value_fn=lambda device: device.doorbell_volume,
setter_fn=lambda device, value: device.async_set_doorbell_volume(int(value)),
exists_fn=lambda device: isinstance(device, RingOther),
),
RingNumberEntityDescription[RingOther](
key="mic_volume",
translation_key="mic_volume",
mode=NumberMode.SLIDER,
native_min_value=ring_doorbell.const.MIC_VOL_MIN,
native_max_value=ring_doorbell.const.MIC_VOL_MAX,
native_step=1,
value_fn=lambda device: device.mic_volume,
setter_fn=lambda device, value: device.async_set_mic_volume(int(value)),
exists_fn=lambda device: isinstance(device, RingOther),
),
RingNumberEntityDescription[RingOther](
key="voice_volume",
translation_key="voice_volume",
mode=NumberMode.SLIDER,
native_min_value=ring_doorbell.const.VOICE_VOL_MIN,
native_max_value=ring_doorbell.const.VOICE_VOL_MAX,
native_step=1,
value_fn=lambda device: device.voice_volume,
setter_fn=lambda device, value: device.async_set_voice_volume(int(value)),
exists_fn=lambda device: isinstance(device, RingOther),
),
)
class RingNumber(RingEntity[RingDeviceT], NumberEntity):
"""A number implementation for Ring device."""
entity_description: RingNumberEntityDescription[RingDeviceT]
def __init__(
self,
device: RingDeviceT,
coordinator: RingDataCoordinator,
description: RingNumberEntityDescription[RingDeviceT],
) -> None:
"""Initialize a number for Ring device."""
super().__init__(device, coordinator)
self.entity_description = description
self._attr_unique_id = f"{device.id}-{description.key}"
self._update_native_value()
def _update_native_value(self) -> None:
native_value = self.entity_description.value_fn(self._device)
if native_value is not None:
self._attr_native_value = float(native_value)
@callback
def _handle_coordinator_update(self) -> None:
"""Call update method."""
self._device = cast(
RingDeviceT,
self._get_coordinator_data().get_device(self._device.device_api_id),
)
self._update_native_value()
super()._handle_coordinator_update()
@refresh_after
async def async_set_native_value(self, value: float) -> None:
"""Call setter on Ring device."""
await self.entity_description.setter_fn(self._device, value)
self._attr_native_value = value
self.async_write_ha_state()

View File

@ -215,24 +215,36 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription[Any], ...] = (
translation_key="volume", translation_key="volume",
value_fn=lambda device: device.volume, value_fn=lambda device: device.volume,
exists_fn=lambda device: isinstance(device, (RingDoorBell, RingChime)), exists_fn=lambda device: isinstance(device, (RingDoorBell, RingChime)),
deprecated_info=DeprecatedInfo(
new_platform=Platform.NUMBER, breaks_in_ha_version="2025.4.0"
),
), ),
RingSensorEntityDescription[RingOther]( RingSensorEntityDescription[RingOther](
key="doorbell_volume", key="doorbell_volume",
translation_key="doorbell_volume", translation_key="doorbell_volume",
value_fn=lambda device: device.doorbell_volume, value_fn=lambda device: device.doorbell_volume,
exists_fn=lambda device: isinstance(device, RingOther), exists_fn=lambda device: isinstance(device, RingOther),
deprecated_info=DeprecatedInfo(
new_platform=Platform.NUMBER, breaks_in_ha_version="2025.4.0"
),
), ),
RingSensorEntityDescription[RingOther]( RingSensorEntityDescription[RingOther](
key="mic_volume", key="mic_volume",
translation_key="mic_volume", translation_key="mic_volume",
value_fn=lambda device: device.mic_volume, value_fn=lambda device: device.mic_volume,
exists_fn=lambda device: isinstance(device, RingOther), exists_fn=lambda device: isinstance(device, RingOther),
deprecated_info=DeprecatedInfo(
new_platform=Platform.NUMBER, breaks_in_ha_version="2025.4.0"
),
), ),
RingSensorEntityDescription[RingOther]( RingSensorEntityDescription[RingOther](
key="voice_volume", key="voice_volume",
translation_key="voice_volume", translation_key="voice_volume",
value_fn=lambda device: device.voice_volume, value_fn=lambda device: device.voice_volume,
exists_fn=lambda device: isinstance(device, RingOther), exists_fn=lambda device: isinstance(device, RingOther),
deprecated_info=DeprecatedInfo(
new_platform=Platform.NUMBER, breaks_in_ha_version="2025.4.0"
),
), ),
RingSensorEntityDescription[RingGeneric]( RingSensorEntityDescription[RingGeneric](
key="wifi_signal_category", key="wifi_signal_category",

View File

@ -58,6 +58,20 @@
"name": "[%key:component::light::title%]" "name": "[%key:component::light::title%]"
} }
}, },
"number": {
"volume": {
"name": "Volume"
},
"doorbell_volume": {
"name": "Doorbell volume"
},
"mic_volume": {
"name": "Mic volume"
},
"voice_volume": {
"name": "Voice volume"
}
},
"siren": { "siren": {
"siren": { "siren": {
"name": "[%key:component::siren::title%]" "name": "[%key:component::siren::title%]"

View File

@ -8,6 +8,7 @@ Mocks the api calls on the devices such as history() and health().
""" """
from datetime import datetime from datetime import datetime
from functools import partial
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from ring_doorbell import ( from ring_doorbell import (
@ -153,6 +154,9 @@ def _mocked_ring_device(device_dict, device_family, device_class, capabilities):
"doorbell_volume", device_dict["settings"].get("volume") "doorbell_volume", device_dict["settings"].get("volume")
) )
) )
mock_device.async_set_volume.side_effect = lambda i: mock_device.configure_mock(
volume=i
)
if has_capability(RingCapability.SIREN): if has_capability(RingCapability.SIREN):
mock_device.configure_mock( mock_device.configure_mock(
@ -170,10 +174,14 @@ def _mocked_ring_device(device_dict, device_family, device_class, capabilities):
) )
if device_family == "other": if device_family == "other":
mock_device.configure_mock( for prop in ("doorbell_volume", "mic_volume", "voice_volume"):
doorbell_volume=device_dict["settings"].get("doorbell_volume"), mock_device.configure_mock(
mic_volume=device_dict["settings"].get("mic_volume"), **{
voice_volume=device_dict["settings"].get("voice_volume"), prop: device_dict["settings"].get(prop),
) f"async_set_{prop}.side_effect": partial(
setattr, mock_device, prop
),
}
)
return mock_device return mock_device

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,95 @@
"""The tests for the Ring number platform."""
from unittest.mock import Mock
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.number import (
ATTR_VALUE,
DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .common import MockConfigEntry, setup_platform
from tests.common import snapshot_platform
@pytest.mark.parametrize(
("entity_id", "unique_id"),
[
("number.downstairs_volume", "123456-volume"),
("number.front_door_volume", "987654-volume"),
("number.ingress_doorbell_volume", "185036587-doorbell_volume"),
("number.ingress_mic_volume", "185036587-mic_volume"),
("number.ingress_voice_volume", "185036587-voice_volume"),
],
)
async def test_entity_registry(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_ring_client: Mock,
entity_id: str,
unique_id: str,
) -> None:
"""Tests that the devices are registered in the entity registry."""
await setup_platform(hass, Platform.NUMBER)
entry = entity_registry.async_get(entity_id)
assert entry is not None and entry.unique_id == unique_id
async def test_states(
hass: HomeAssistant,
mock_ring_client: Mock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test states."""
mock_config_entry.add_to_hass(hass)
await setup_platform(hass, Platform.NUMBER)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("entity_id", "new_value"),
[
("number.downstairs_volume", "4.0"),
("number.front_door_volume", "3.0"),
("number.ingress_doorbell_volume", "7.0"),
("number.ingress_mic_volume", "2.0"),
("number.ingress_voice_volume", "5.0"),
],
)
async def test_volume_can_be_changed(
hass: HomeAssistant,
mock_ring_client: Mock,
entity_id: str,
new_value: str,
) -> None:
"""Tests the volume can be changed correctly."""
await setup_platform(hass, Platform.NUMBER)
state = hass.states.get(entity_id)
assert state is not None
old_value = state.state
# otherwise this test would be pointless
assert old_value != new_value
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: entity_id, ATTR_VALUE: new_value},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None and state.state == new_value

View File

@ -25,7 +25,41 @@ from .device_mocks import FRONT_DEVICE_ID, FRONT_DOOR_DEVICE_ID, INGRESS_DEVICE_
from tests.common import async_fire_time_changed from tests.common import async_fire_time_changed
async def test_sensor(hass: HomeAssistant, mock_ring_client) -> None: @pytest.fixture
def create_deprecated_sensor_entities(
hass: HomeAssistant,
mock_config_entry: ConfigEntry,
entity_registry: er.EntityRegistry,
):
"""Create the entity so it is not ignored by the deprecation check."""
mock_config_entry.add_to_hass(hass)
def create_entry(
device_name,
description,
device_id,
):
unique_id = f"{device_id}-{description}"
entity_registry.async_get_or_create(
domain=SENSOR_DOMAIN,
platform=DOMAIN,
unique_id=unique_id,
suggested_object_id=f"{device_name}_{description}",
config_entry=mock_config_entry,
)
create_entry("downstairs", "volume", 123456)
create_entry("front_door", "volume", 987654)
create_entry("ingress", "doorbell_volume", 185036587)
create_entry("ingress", "mic_volume", 185036587)
create_entry("ingress", "voice_volume", 185036587)
async def test_sensor(
hass: HomeAssistant,
mock_ring_client,
create_deprecated_sensor_entities,
) -> None:
"""Test the Ring sensors.""" """Test the Ring sensors."""
await setup_platform(hass, "sensor") await setup_platform(hass, "sensor")