8000 Add support for tplink siren turn on parameters (#136642) · home-assistant/core@c12fa34 · GitHub
[go: up one dir, main page]

Skip to content

Commit c12fa34

Browse files
sdb9696rytilahti
andauthored
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>
1 parent b79221e commit c12fa34

File tree

5 files changed

+182
-14
lines changed

5 files changed

+182
-14
lines changed

homeassistant/components/tplink/siren.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,27 @@
44

55
from collections.abc import Callable
66
from dataclasses import dataclass
7-
from typing import Any
7+
import math
8+
from typing import TYPE_CHECKING, Any, cast
89

910
from kasa import Device, Module
1011

1112
from homeassistant.components.siren import (
13+
ATTR_DURATION,
14+
ATTR_TONE,
15+
ATTR_VOLUME_LEVEL,
1216
DOMAIN as SIREN_DOMAIN,
1317
SirenEntity,
1418
SirenEntityDescription,
1519
SirenEntityFeature,
20+
SirenTurnOnServiceParameters,
1621
)
1722
from homeassistant.core import HomeAssistant, callback
23+
from homeassistant.exceptions import ServiceValidationError
1824
from homeassistant.helpers.entity_platform import AddEntitiesCallback
1925

2026
from . import TPLinkConfigEntry, legacy_device_id
27+
from .const import DOMAIN
2128
from .coordinator import TPLinkDataUpdateCoordinator
2229
from .entity import (
2330
CoordinatedTPLinkModuleEntity,
@@ -86,7 +93,13 @@ class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity):
8693
"""Representation of a tplink siren entity."""
8794

8895
_attr_name = None
89-
_attr_supported_features = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON
96+
_attr_supported_features = (
97+
SirenEntityFeature.TURN_OFF
98+
| SirenEntityFeature.TURN_ON
99+
| SirenEntityFeature.TONES
100+
| SirenEntityFeature.DURATION
101+
| SirenEntityFeature.VOLUME_SET
102+
)
90103

91104
entity_description: TPLinkSirenEntityDescription
92105

@@ -102,10 +115,38 @@ def __init__(
102115
super().__init__(device, coordinator, description, parent=parent)
103116
self._alarm_module = device.modules[Module.Alarm]
104117

118+
alarm_vol_feat = self._alarm_module.get_feature("alarm_volume")
119+
alarm_duration_feat = self._alarm_module.get_feature("alarm_duration")
120+
if TYPE_CHECKING:
121+
assert alarm_vol_feat
122+
assert alarm_duration_feat
123+
self._alarm_volume_max = alarm_vol_feat.maximum_value
124+
self._alarm_duration_max = alarm_duration_feat.maximum_value
125+
105126
@async_refresh_after
106127
async def async_turn_on(self, **kwargs: Any) -> None:
107128
"""Turn the siren on."""
108-
await self._alarm_module.play()
129+
turn_on_params = cast(SirenTurnOnServiceParameters, kwargs)
130+
if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None:
131+
# service parameter is a % so we round up to the nearest int
132+
volume = math.ceil(volume * self._alarm_volume_max)
133+
134+
if (duration := kwargs.get(ATTR_DURATION)) is not None:
135+
if duration < 1 or duration > self._alarm_duration_max:
136+
raise ServiceValidationError(
137+
translation_domain=DOMAIN,
138+
translation_key="invalid_alarm_duration",
139+
translation_placeholders={
140+
"duration": str(duration),
141+
"duration_max": str(self._alarm_duration_max),
142+
},
143+
)
144+
145+
await self._alarm_module.play(
146+
duration=turn_on_params.get(ATTR_DURATION),
147+
volume=volume,
148+
sound=kwargs.get(ATTR_TONE),
149+
)
109150

110151
@async_refresh_after
111152
async def async_turn_off(self, **kwargs: Any) -> None:
@@ -116,4 +157,8 @@ async def async_turn_off(self, **kwargs: Any) -> None:
116157
def _async_update_attrs(self) -> bool:
117158
"""Update the entity's attributes."""
118159
self._attr_is_on = self._alarm_module.active
160+
# alarm_sounds returns list[str], so we need to widen the type
161+
self._attr_available_tones = cast(
162+
list[str | int], self._alarm_module.alarm_sounds
163+
)
119164
return True

homeassistant/components/tplink/strings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,9 @@
367367
},
368368
"unsupported_mode": {
369369
"message": "Tried to set unsupported mode: {mode}"
370+
},
371+
"invalid_alarm_duration": {
372+
"message": "Invalid duration {duration} available: 1-{duration_max}s"
370373
}
371374
},
372375
"issues": {

tests/components/tplink/__init__.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,6 @@ def _mocked_device(
178178
device_config.host = ip_address
179179
device.host = ip_address
180180

181-
if modules:
182-
device.modules = {
183-
module_name: MODULE_TO_MOCK_GEN[module_name](device)
184-
for module_name in modules
185-
}
186-
187181
device_features = {}
188182
if features:
189183
device_features = {
@@ -201,6 +195,13 @@ def _mocked_device(
201195
)
202196
device.features = device_features
203197

198+
# Add modules after features so modules can add required features
199+
if modules:
200+
device.modules = {
201+
module_name: MODULE_TO_MOCK_GEN[module_name](device)
202+
for module_name in modules
203+
}
204+
204205
for mod in device.modules.values():
205206
mod.get_feature.side_effect = device_features.get
206207
mod.has_feature.side_effect = lambda id: id in device_features
@@ -251,15 +252,19 @@ def _mocked_feature(
251252
feature.id = id
252253
feature.name = name or id.upper()
253254
feature.set_value = AsyncMock()
254-
if not (fixture := FEATURES_FIXTURE.get(id)):
255+
if fixture := FEATURES_FIXTURE.get(id):
256+
# copy the fixture so tests do not interfere with each other
257+
fixture = dict(fixture)
258+
else:
255259
assert require_fixture is False, (
256260
f"No fixture defined for feature {id} and require_fixture is True"
257261
)
258262
assert value is not UNDEFINED, (
259263
f"Value must be provided if feature {id} not defined in features.json"
260264
)
261265
fixture = {"value": value, "category": "Primary", "type": "Sensor"}
262-
elif value is not UNDEFINED:
266+
267+
if value is not UNDEFINED:
263268
fixture["value"] = value
264269
feature.value = fixture["value"]
265270

@@ -352,9 +357,23 @@ def _mocked_fan_module(effect) -> Fan:
352357
def _mocked_alarm_module(device):
353358
alarm = MagicMock(auto_spec=Alarm, name="Mocked alarm")
354359
alarm.active = False
360+
alarm.alarm_sounds = "Foo", "Bar"
355361
alarm.play = AsyncMock()
356362
alarm.stop = AsyncMock()
357363

364+
device.features["alarm_volume"] = _mocked_feature(
365+
"alarm_volume",
366+
minimum_value=0,
367+
maximum_value=3,
368+
value=None,
369+
)
370+
device.features["alarm_duration"] = _mocked_feature(
371+
"alarm_duration",
372+
minimum_value=0,
373+
maximum_value=300,
374+
value=None,
375+
)
376+
358377
return alarm
359378

360379

tests/components/tplink/snapshots/test_siren.ambr

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@
4040
'aliases': set({
4141
}),
4242
'area_id': None,
43-
'capabilities': None,
43+
'capabilities': dict({
44+
'available_tones': tuple(
45+
'Foo',
46+
'Bar',
47+
),
48+
}),
4449
'config_entry_id': <ANY>,
4550
'device_class': None,
4651
'device_id': <ANY>,
@@ -62,7 +67,7 @@
6267
'original_name': None,
6368
'platform': 'tplink',
6469
'previous_unique_id': None,
65-
'supported_features': <SirenEntityFeature: 3>,
70+
'supported_features': <SirenEntityFeature: 31>,
6671
'translation_key': None,
6772
'unique_id': '123456789ABCDEFGH',
6873
'unit_of_measurement': None,
@@ -71,8 +76,12 @@
7176
# name: test_states[siren.hub-state]
7277
StateSnapshot({
7378
'attributes': ReadOnlyDict({
79+
'available_tones': tuple(
80+
'Foo',
81+
'Bar',
82+
),
7483
'friendly_name': 'hub',
75-
'supported_features': <SirenEntityFeature: 3>,
84+
'supported_features': <SirenEntityFeature: 31>,
7685
}),
7786
'context': <ANY>,
7887
'entity_id': 'siren.hub',

tests/components/tplink/test_siren.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77
from syrupy.assertion import SnapshotAssertion
88

99
from homeassistant.components.siren import (
10+
ATTR_DURATION,
11+
ATTR_TONE,
12+
ATTR_VOLUME_LEVEL,
1013
DOMAIN as SIREN_DOMAIN,
1114
SERVICE_TURN_OFF,
1215
SERVICE_TURN_ON,
1316
)
1417
from homeassistant.const import ATTR_ENTITY_ID, Platform
1518
from homeassistant.core import HomeAssistant
19+
from homeassistant.exceptions import ServiceValidationError
1620
from homeassistant.helpers import device_registry as dr, entity_registry as er
1721

1822
from . import _mocked_device, setup_platform_for_device, snapshot_platform
@@ -74,3 +78,91 @@ async def test_turn_on_and_off(
7478
)
7579

7680
alarm_module.play.assert_called()
81+
82+
83+
@pytest.mark.parametrize(
84+
("max_volume", "volume_level", "expected_volume"),
85+
[
86+
pytest.param(3, 0.1, 1, id="smart-10%"),
87+
pytest.param(3, 0.3, 1, id="smart-30%"),
88+
pytest.param(3, 0.99, 3, id="smart-99%"),
89+
pytest.param(3, 1, 3, id="smart-100%"),
90+
pytest.param(10, 0.1, 1, id="smartcam-10%"),
91+
pytest.param(10, 0.3, 3, id="smartcam-30%"),
92+
pytest.param(10, 0.99, 10, id="smartcam-99%"),
93+
pytest.param(10, 1, 10, id="smartcam-100%"),
94+
],
95+
)
96+
async def test_turn_on_with_volume(
97+
hass: HomeAssistant,
98+
mock_config_entry: MockConfigEntry,
99+
mocked_hub: Device,
100+
max_volume: int,
101+
volume_level: float,
102+
expected_volume: int,
103+
) -> None:
104+
"""Test that turn_on volume parameters work as expected."""
105+
106+
alarm_module = mocked_hub.modules[Module.Alarm]
107+
alarm_volume_feat = alarm_module.get_feature("alarm_volume")
108+
assert alarm_volume_feat
109+
alarm_volume_feat.maximum_value = max_volume
110+
111+
await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub)
112+
113+
await hass.services.async_call(
114+
SIREN_DOMAIN,
115+
SERVICE_TURN_ON,
116+
{ATTR_ENTITY_ID: [ENTITY_ID], ATTR_VOLUME_LEVEL: volume_level},
117+
blocking=True,
118+
)
119+
120+
alarm_module.play.assert_called_with(
121+
volume=expected_volume, duration=None, sound=None
122+
)
123+
124+
125+
async def test_turn_on_with_duration_and_sound(
126+
hass: HomeAssistant,
127+
mock_config_entry: MockConfigEntry,
128+
mocked_hub: Device,
129+
) -> None:
130+
"""Test that turn_on tone and duration parameters work as expected."""
131+
132+
alarm_module = mocked_hub.modules[Module.Alarm]
133+
134+
await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub)
135+
136+
await hass.services.async_call(
137+
SIREN_DOMAIN,
138+
SERVICE_TURN_ON,
139+
{ATTR_ENTITY_ID: [ENTITY_ID], ATTR_DURATION: 5, ATTR_TONE: "Foo"},
140+
blocking=True,
141+
)
142+
143+
alarm_module.play.assert_called_with(volume=None, duration=5, sound="Foo")
144+
145+
146+
@pytest.mark.parametrize(("duration"), [0, 301])
147+
async def test_turn_on_with_invalid_duration(
148+
hass: HomeAssistant,
149+
mock_config_entry: MockConfigEntry,
150+
mocked_hub: Device,
151+
duration: int,
152+
) -> None:
153+
"""Test that turn_on with invalid_duration raises an error."""
154+
155+
await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub)
156+
157+
msg = f"Invalid duration {duration} available: 1-300s"
158+
159+
with pytest.raises(ServiceValidationError, match=msg):
160+
await hass.services.async_call(
161+
SIREN_DOMAIN,
162+
SERVICE_TURN_ON,
163+
{
164+
ATTR_ENTITY_ID: [ENTITY_ID],
165+
ATTR_DURATION: duration,
166+
},
167+
blocking=True,
168+
)

0 commit comments

Comments
 (0)
0