Add support for tplink siren turn on parameters (#136642)

Add support for tplink siren parameters - Allow passing tone, volume, and duration for siren's play action.

---------

Co-authored-by: Teemu Rytilahti <tpr@iki.fi>
This commit is contained in:
Steven B. 2025-01-27 20:27:29 +00:00 committed by GitHub
parent b79221e666
commit c12fa34e33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 182 additions and 14 deletions

View File

@ -4,20 +4,27 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import math
from typing import TYPE_CHECKING, Any, cast
from kasa import Device, Module
from homeassistant.components.siren import (
ATTR_DURATION,
ATTR_TONE,
ATTR_VOLUME_LEVEL,
DOMAIN as SIREN_DOMAIN,
SirenEntity,
SirenEntityDescription,
SirenEntityFeature,
SirenTurnOnServiceParameters,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import TPLinkConfigEntry, legacy_device_id
from .const import DOMAIN
from .coordinator import TPLinkDataUpdateCoordinator
from .entity import (
CoordinatedTPLinkModuleEntity,
@ -86,7 +93,13 @@ class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity):
"""Representation of a tplink siren entity."""
_attr_name = None
_attr_supported_features = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON
_attr_supported_features = (
SirenEntityFeature.TURN_OFF
| SirenEntityFeature.TURN_ON
| SirenEntityFeature.TONES
| SirenEntityFeature.DURATION
| SirenEntityFeature.VOLUME_SET
)
entity_description: TPLinkSirenEntityDescription
@ -102,10 +115,38 @@ class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity):
super().__init__(device, coordinator, description, parent=parent)
self._alarm_module = device.modules[Module.Alarm]
alarm_vol_feat = self._alarm_module.get_feature("alarm_volume")
alarm_duration_feat = self._alarm_module.get_feature("alarm_duration")
if TYPE_CHECKING:
assert alarm_vol_feat
assert alarm_duration_feat
self._alarm_volume_max = alarm_vol_feat.maximum_value
self._alarm_duration_max = alarm_duration_feat.maximum_value
@async_refresh_after
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the siren on."""
await self._alarm_module.play()
turn_on_params = cast(SirenTurnOnServiceParameters, kwargs)
if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None:
# service parameter is a % so we round up to the nearest int
volume = math.ceil(volume * self._alarm_volume_max)
if (duration := kwargs.get(ATTR_DURATION)) is not None:
if duration < 1 or duration > self._alarm_duration_max:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_alarm_duration",
translation_placeholders={
"duration": str(duration),
"duration_max": str(self._alarm_duration_max),
},
)
await self._alarm_module.play(
duration=turn_on_params.get(ATTR_DURATION),
volume=volume,
sound=kwargs.get(ATTR_TONE),
)
@async_refresh_after
async def async_turn_off(self, **kwargs: Any) -> None:
@ -116,4 +157,8 @@ class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity):
def _async_update_attrs(self) -> bool:
"""Update the entity's attributes."""
self._attr_is_on = self._alarm_module.active
# alarm_sounds returns list[str], so we need to widen the type
self._attr_available_tones = cast(
list[str | int], self._alarm_module.alarm_sounds
)
return True

View File

@ -367,6 +367,9 @@
},
"unsupported_mode": {
"message": "Tried to set unsupported mode: {mode}"
},
"invalid_alarm_duration": {
"message": "Invalid duration {duration} available: 1-{duration_max}s"
}
},
"issues": {

View File

@ -178,12 +178,6 @@ def _mocked_device(
device_config.host = ip_address
device.host = ip_address
if modules:
device.modules = {
module_name: MODULE_TO_MOCK_GEN[module_name](device)
for module_name in modules
}
device_features = {}
if features:
device_features = {
@ -201,6 +195,13 @@ def _mocked_device(
)
device.features = device_features
# Add modules after features so modules can add required features
if modules:
device.modules = {
module_name: MODULE_TO_MOCK_GEN[module_name](device)
for module_name in modules
}
for mod in device.modules.values():
mod.get_feature.side_effect = device_features.get
mod.has_feature.side_effect = lambda id: id in device_features
@ -251,7 +252,10 @@ def _mocked_feature(
feature.id = id
feature.name = name or id.upper()
feature.set_value = AsyncMock()
if not (fixture := FEATURES_FIXTURE.get(id)):
if fixture := FEATURES_FIXTURE.get(id):
# copy the fixture so tests do not interfere with each other
fixture = dict(fixture)
else:
assert require_fixture is False, (
f"No fixture defined for feature {id} and require_fixture is True"
)
@ -259,7 +263,8 @@ def _mocked_feature(
f"Value must be provided if feature {id} not defined in features.json"
)
fixture = {"value": value, "category": "Primary", "type": "Sensor"}
elif value is not UNDEFINED:
if value is not UNDEFINED:
fixture["value"] = value
feature.value = fixture["value"]
@ -352,9 +357,23 @@ def _mocked_fan_module(effect) -> Fan:
def _mocked_alarm_module(device):
alarm = MagicMock(auto_spec=Alarm, name="Mocked alarm")
alarm.active = False
alarm.alarm_sounds = "Foo", "Bar"
alarm.play = AsyncMock()
alarm.stop = AsyncMock()
device.features["alarm_volume"] = _mocked_feature(
"alarm_volume",
minimum_value=0,
maximum_value=3,
value=None,
)
device.features["alarm_duration"] = _mocked_feature(
"alarm_duration",
minimum_value=0,
maximum_value=300,
value=None,
)
return alarm

View File

@ -40,7 +40,12 @@
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'capabilities': dict({
'available_tones': tuple(
'Foo',
'Bar',
),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
@ -62,7 +67,7 @@
'original_name': None,
'platform': 'tplink',
'previous_unique_id': None,
'supported_features': <SirenEntityFeature: 3>,
'supported_features': <SirenEntityFeature: 31>,
'translation_key': None,
'unique_id': '123456789ABCDEFGH',
'unit_of_measurement': None,
@ -71,8 +76,12 @@
# name: test_states[siren.hub-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'available_tones': tuple(
'Foo',
'Bar',
),
'friendly_name': 'hub',
'supported_features': <SirenEntityFeature: 3>,
'supported_features': <SirenEntityFeature: 31>,
}),
'context': <ANY>,
'entity_id': 'siren.hub',

View File

@ -7,12 +7,16 @@ import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.siren import (
ATTR_DURATION,
ATTR_TONE,
ATTR_VOLUME_LEVEL,
DOMAIN as SIREN_DOMAIN,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import _mocked_device, setup_platform_for_device, snapshot_platform
@ -74,3 +78,91 @@ async def test_turn_on_and_off(
)
alarm_module.play.assert_called()
@pytest.mark.parametrize(
("max_volume", "volume_level", "expected_volume"),
[
pytest.param(3, 0.1, 1, id="smart-10%"),
pytest.param(3, 0.3, 1, id="smart-30%"),
pytest.param(3, 0.99, 3, id="smart-99%"),
pytest.param(3, 1, 3, id="smart-100%"),
pytest.param(10, 0.1, 1, id="smartcam-10%"),
pytest.param(10, 0.3, 3, id="smartcam-30%"),
pytest.param(10, 0.99, 10, id="smartcam-99%"),
pytest.param(10, 1, 10, id="smartcam-100%"),
],
)
async def test_turn_on_with_volume(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mocked_hub: Device,
max_volume: int,
volume_level: float,
expected_volume: int,
) -> None:
"""Test that turn_on volume parameters work as expected."""
alarm_module = mocked_hub.modules[Module.Alarm]
alarm_volume_feat = alarm_module.get_feature("alarm_volume")
assert alarm_volume_feat
alarm_volume_feat.maximum_value = max_volume
await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub)
await hass.services.async_call(
SIREN_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: [ENTITY_ID], ATTR_VOLUME_LEVEL: volume_level},
blocking=True,
)
alarm_module.play.assert_called_with(
volume=expected_volume, duration=None, sound=None
)
async def test_turn_on_with_duration_and_sound(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mocked_hub: Device,
) -> None:
"""Test that turn_on tone and duration parameters work as expected."""
alarm_module = mocked_hub.modules[Module.Alarm]
await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub)
await hass.services.async_call(
SIREN_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: [ENTITY_ID], ATTR_DURATION: 5, ATTR_TONE: "Foo"},
blocking=True,
)
alarm_module.play.assert_called_with(volume=None, duration=5, sound="Foo")
@pytest.mark.parametrize(("duration"), [0, 301])
async def test_turn_on_with_invalid_duration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mocked_hub: Device,
duration: int,
) -> None:
"""Test that turn_on with invalid_duration raises an error."""
await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub)
msg = f"Invalid duration {duration} available: 1-300s"
with pytest.raises(ServiceValidationError, match=msg):
await hass.services.async_call(
SIREN_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: [ENTITY_ID],
ATTR_DURATION: duration,
},
blocking=True,
)