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.EVENT,
Platform.LIGHT,
Platform.NUMBER,
Platform.SENSOR,
Platform.SIREN,
Platform.SWITCH,

View File

@ -1,5 +1,19 @@
{
"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": {
"last_activity": {
"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",
value_fn=lambda device: device.volume,
exists_fn=lambda device: isinstance(device, (RingDoorBell, RingChime)),
deprecated_info=DeprecatedInfo(
new_platform=Platform.NUMBER, breaks_in_ha_version="2025.4.0"
),
),
RingSensorEntityDescription[RingOther](
key="doorbell_volume",
translation_key="doorbell_volume",
value_fn=lambda device: device.doorbell_volume,
exists_fn=lambda device: isinstance(device, RingOther),
deprecated_info=DeprecatedInfo(
new_platform=Platform.NUMBER, breaks_in_ha_version="2025.4.0"
),
),
RingSensorEntityDescription[RingOther](
key="mic_volume",
translation_key="mic_volume",
value_fn=lambda device: device.mic_volume,
exists_fn=lambda device: isinstance(device, RingOther),
deprecated_info=DeprecatedInfo(
new_platform=Platform.NUMBER, breaks_in_ha_version="2025.4.0"
),
),
RingSensorEntityDescription[RingOther](
key="voice_volume",
translation_key="voice_volume",
value_fn=lambda device: device.voice_volume,
exists_fn=lambda device: isinstance(device, RingOther),
deprecated_info=DeprecatedInfo(
new_platform=Platform.NUMBER, breaks_in_ha_version="2025.4.0"
),
),
RingSensorEntityDescription[RingGeneric](
key="wifi_signal_category",

View File

@ -58,6 +58,20 @@
"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": {
"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 functools import partial
from unittest.mock import AsyncMock, MagicMock
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")
)
)
mock_device.async_set_volume.side_effect = lambda i: mock_device.configure_mock(
volume=i
)
if has_capability(RingCapability.SIREN):
mock_device.configure_mock(
@ -170,10 +174,14 @@ def _mocked_ring_device(device_dict, device_family, device_class, capabilities):
)
if device_family == "other":
mock_device.configure_mock(
doorbell_volume=device_dict["settings"].get("doorbell_volume"),
mic_volume=device_dict["settings"].get("mic_volume"),
voice_volume=device_dict["settings"].get("voice_volume"),
)
for prop in ("doorbell_volume", "mic_volume", "voice_volume"):
mock_device.configure_mock(
**{
prop: device_dict["settings"].get(prop),
f"async_set_{prop}.side_effect": partial(
setattr, mock_device, prop
),
}
)
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
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."""
await setup_platform(hass, "sensor")