8000 Add common alarm interface (#1479) · rSffsE/python-kasa@656c887 · GitHub
[go: up one dir, main page]

Skip to content

Commit 656c887

Browse files
authored
Add common alarm interface (python-kasa#1479)
Add a common interface for the `alarm` module across `smart` and `smartcam` devices.
1 parent d857cc6 commit 656c887

File tree

7 files changed

+161
-30
lines changed

7 files changed

+161
-30
lines changed

kasa/interfaces/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Package for interfaces."""
22

3+
from .alarm import Alarm
34
from .childsetup import ChildSetup
45
from .energy import Energy
56
from .fan import Fan
@@ -11,6 +12,7 @@
1112
from .time import Time
1213

1314
__all__ = [
15+
"Alarm",
1416
"ChildSetup",
1517
"Fan",
1618
"Energy",

kasa/interfaces/alarm.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Module for base alarm module."""
2+
3+
from __future__ import annotations
4+
5+
from abc import ABC, abstractmethod
6+
from typing import Annotated
7+
8+
from ..module import FeatureAttribute, Module
9+
10+
11+
class Alarm(Module, ABC):
12+
"""Base interface to represent an alarm module."""
13+
14+
@property
15+
@abstractmethod
16+
def alarm_sound(self) -> Annotated[str, FeatureAttribute()]:
17+
"""Return current alarm sound."""
18+
19+
@abstractmethod
20+
async def set_alarm_sound(self, sound: str) -> Annotated[dict, FeatureAttribute()]:
21+
"""Set alarm sound.
22+
23+
See *alarm_sounds* for list of available sounds.
24+
"""
25+
26+
@property
27+
@abstractmethod
28+
def alarm_sounds(self) -> list[str]:
29+
"""Return list of available alarm sounds."""
30+
31+
@property
32+
@abstractmethod
33+
def alarm_volume(self) -> Annotated[int, FeatureAttribute()]:
34+
"""Return alarm volume."""
35+
36+
@abstractmethod
37+
async def set_alarm_volume(
38+
self, volume: int
39+
) -> Annotated[dict, FeatureAttribute()]:
40+
"""Set alarm volume."""
41+
42+
@property
43+
@abstractmethod
44+
def alarm_duration(self) -> Annotated[int, FeatureAttribute()]:
45+
"""Return alarm duration."""
46+
47+
@abstractmethod
48+
async def set_alarm_duration(
49+
self, duration: int
50+
) -> Annotated[dict, FeatureAttribute()]:
51+
"""Set alarm duration."""
52+
53+
@property
54+
@abstractmethod
55+
def active(self) -> bool:
56+
"""Return true if alarm is active."""
57+
58+
@abstractmethod
59+
async def play(
60+
self,
61+
*,
62+
duration: int | None = None,
63+
volume: int | None = None,
64+
sound: str | None = None,
65+
) -> dict:
66+
"""Play alarm.
67+
68+
The optional *duration*, *volume*, and *sound* to override the device settings.
69+
*duration* is in seconds.
70+
See *alarm_sounds* for the list of sounds available for the device.
71+
"""
72+
73+
@abstractmethod
74+
async def stop(self) -> dict:
75+
"""Stop alarm."""

kasa/module.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ class Module(ABC):
9696
"""
9797

9898
# Common Modules
99+
Alarm: Final[ModuleName[interfaces.Alarm]] = ModuleName("Alarm")
99100
ChildSetup: Final[ModuleName[interfaces.ChildSetup]] = ModuleName("ChildSetup")
100101
Energy: Final[ModuleName[interfaces.Energy]] = ModuleName("Energy")
101102
Fan: Final[ModuleName[interfaces.Fan]] = ModuleName("Fan")
@@ -116,7 +117,6 @@ class Module(ABC):
116117
IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud")
117118

118119
# SMART only Modules
119-
Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm")
120120
AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff")
121121
BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor")
122122
Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness")

kasa/smart/modules/alarm.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import TYPE_CHECKING, Annotated, Literal, TypeAlias
66

77
from ...feature import Feature
8+
from ...interfaces import Alarm as AlarmInterface
89
from ...module import FeatureAttribute
910
from ..smartmodule import SmartModule
1011

@@ -24,7 +25,7 @@
2425
AlarmVolume: TypeAlias = Literal["mute", "low", "normal", "high"]
2526

2627

27-
class Alarm(SmartModule):
28+
class Alarm(SmartModule, AlarmInterface):
2829
"""Implementation of alarm module."""
2930

3031
REQUIRED_COMPONENT = "alarm"

kasa/smartcam/modules/alarm.py

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from ...feature import Feature
6+
from ...interfaces import Alarm as AlarmInterface
67
from ...smart.smartmodule import allow_update_after
78
from ..smartcammodule import SmartCamModule
89

@@ -13,12 +14,9 @@
1314
VOLUME_MAX = 10
1415

1516

16-
class Alarm(SmartCamModule):
17+
class Alarm(SmartCamModule, AlarmInterface):
1718
"""Implementation of alarm module."""
1819

19-
# Needs a different name to avoid clashing with SmartAlarm
20-
NAME = "SmartCamAlarm"
21-
2220
REQUIRED_COMPONENT = "siren"
2321
QUERY_GETTER_NAME = "getSirenStatus"
2422
QUERY_MODULE_NAME = "siren"
@@ -117,11 +115,8 @@ async def set_alarm_sound(self, sound: str) -> dict:
117115
118116
See *alarm_sounds* for list of available sounds.
119117
"""
120-
if sound not in self.alarm_sounds:
121-
raise ValueError(
122-
f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}"
123-
)
124-
return await self.call("setSirenConfig", {"siren": {"siren_type": sound}})
118+
config = self._validate_and_get_config(sound=sound)
119+
return await self.call("setSirenConfig", {"siren": config})
125120

126121
@property
127122
def alarm_sounds(self) -> list[str]:
@@ -139,9 +134,8 @@ def alarm_volume(self) -> int:
139134
@allow_update_after
140135
async def set_alarm_volume(self, volume: int) -> dict:
141136
"""Set alarm volume."""
142-
if volume < VOLUME_MIN or volume > VOLUME_MAX:
143-
raise ValueError(f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}")
144-
return await self.call("setSirenConfig", {"siren": {"volume": str(volume)}})
137+
config = self._validate_and_get_config(volume=volume)
138+
return await self.call("setSirenConfig", {"siren": config})
145139

146140
@property
147141
def alarm_duration(self) -> int:
@@ -151,20 +145,65 @@ def alarm_duration(self) -> int:
151145
@allow_update_after
152146
async def set_alarm_duration(self, duration: int) -> dict:
153147
"""Set alarm volume."""
154-
if duration < DURATION_MIN or duration > DURATION_MAX:
155-
msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}"
156-
raise ValueError(msg)
157-
return await self.call("setSirenConfig", {"siren": {"duration": duration}})
148+
config = self._validate_and_get_config(duration=duration)
149+
return await self.call("setSirenConfig", {"siren": config})
158150

159151
@property
160152
def active(self) -> bool:
161153
"""Return true if alarm is active."""
162154
return self.data["getSirenStatus"]["status"] != "off"
163155

164-
async def play(self) -> dict:
165-
"""Play alarm."""
156+
async def play(
157+
self,
158+
*,
159+
duration: int | None = None,
160+
volume: int | None = None,
161+
sound: str | None = None,
162+
) -> dict:
163+
"""Play alarm.
164+
165+
The optional *duration*, *volume*, and *sound* to override the device settings.
166+
*duration* is in seconds.
167+
See *alarm_sounds* for the list of sounds available for the device.
168+
"""
169+
if config := self._validate_and_get_config(
170+
duration=duration, volume=volume, sound=sound
171+
):
172+
await self.call("setSirenConfig", {"siren": config})
173+
166174
return await self.call("setSirenStatus", {"siren": {"status": "on"}})
167175

168176
async def stop(self) -> dict:
169177
"""Stop alarm."""
170178
return await self.call("setSirenStatus", {"siren": {"status": "off"}})
179+
180+
def _validate_and_get_config(
181+
self,
182+
*,
183+
duration: int | None = None,
184+
volume: int | None = None,
185+
sound: str | None = None,
186+
) -> dict:
187+
if sound and sound not in self.alarm_sounds:
188+
raise ValueError(
189+
f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}"
190+
)
191+
192+
if duration is not None and (
193+
duration < DURATION_MIN or duration > DURATION_MAX
194+
):
195+
msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}"
196+
raise ValueError(msg)
197+
198+
if volume is not None and (volume < VOLUME_MIN or volume > VOLUME_MAX):
199+
raise ValueError(f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}")
200+
201+
config: dict[str, str | int] = {}
202+
if sound:
203+
config["siren_type"] = sound
204+
if duration is not None:
205+
config["duration"] = duration
206+
if volume is not None:
207+
config["volume"] = str(volume)
208+
209+
return config

tests/fakeprotocol_smartcam.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,12 +276,14 @@ async def _send_request(self, request_dict: dict):
276276
section = next(iter(val))
277277
skey_val = val[section]
278278
if not isinstance(skey_val, dict): # single level query
279-
section_key = section
280-
section_val = skey_val
281-
if (get_info := info.get(get_method)) and section_key in get_info:
282-
get_info[section_key] = section_val
283-
else:
279+
updates = {
280+
k: v for k, v in val.items() if k in info.get(get_method, {})
281+
}
282+
if len(updates) != len(val):
283+
# All keys to update must already be in the getter
284284
return {"error_code": -1}
285+
info[get_method] = {**info[get_method], **updates}
286+
285287
break
286288
for skey, sval in skey_val.items():
287289
section_key = skey

tests/smartcam/modules/test_alarm.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,21 @@
44

55
import pytest
66

7-
from kasa import Device
7+
from kasa import Device, Module
88
from kasa.smartcam.modules.alarm import (
99
DURATION_MAX,
1010
DURATION_MIN,
1111
VOLUME_MAX,
1212
VOLUME_MIN,
1313
)
14-
from kasa.smartcam.smartcammodule import SmartCamModule
1514

1615
from ...conftest import hub_smartcam
1716

1817

1918
@hub_smartcam
2019
async def test_alarm(dev: Device):
2120
"""Test device alarm."""
22-
alarm = dev.modules.get(SmartCamModule.SmartCamAlarm)
21+
alarm = dev.modules.get(Module.Alarm)
2322
assert alarm
2423

2524
original_duration = alarm.alarm_duration
@@ -63,6 +62,19 @@ async def test_alarm(dev: Device):
6362
await dev.update()
6463
assert alarm.alarm_sound == new_sound
6564

65+
# Test play parameters
66+
await alarm.play(
67+
duration=original_duration, volume=original_volume, sound=original_sound
68+
)
69+
await dev.update()
70+
assert alarm.active
71+
assert alarm.alarm_sound == original_sound
72+
assert alarm.alarm_duration == original_duration
73+
assert alarm.alarm_volume == original_volume
74+
await alarm.stop()
75+
await dev.update()
76+
assert not alarm.active
77+
6678
finally:
6779
await alarm.set_alarm_volume(original_volume)
6880
await alarm.set_alarm_duration(original_duration)
@@ -73,7 +85,7 @@ async def test_alarm(dev: Device):
7385
@hub_smartcam
7486
async def test_alarm_invalid_setters(dev: Device):
7587
"""Test device alarm invalid setter values."""
76-
alarm = dev.modules.get(SmartCamModule.SmartCamAlarm)
88+
alarm = dev.modules.get(Module.Alarm)
7789
assert alarm
7890

7991
# test set sound invalid
@@ -95,7 +107,7 @@ async def test_alarm_invalid_setters(dev: Device):
95107
@hub_smartcam
96108
async def test_alarm_features(dev: Device):
97109
"""Test device alarm features."""
98-
alarm = dev.modules.get(SmartCamModule.SmartCamAlarm)
110+
alarm = dev.modules.get(Module.Alarm)
99111
assert alarm
100112

101113
original_duration = alarm.alarm_duration

0 commit comments

Comments
 (0)
0