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
parent
9557386b6e
commit
c8e2408f82
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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()
|
|
@ -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",
|
||||||
|
|
|
@ -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%]"
|
||||||
|
|
|
@ -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
|
@ -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
|
|
@ -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")
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue