From 0f7e007663922759a15fe9d4a574a02a8dab33d5 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 4 Dec 2024 21:41:08 +0100 Subject: [PATCH 01/17] Allow passing alarm parameter overrides --- kasa/smart/modules/alarm.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index f1bf72363..9373d1dda 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -136,9 +136,29 @@ def source(self) -> str | None: src = self._device.sys_info["in_alarm_source"] return src if src else None - async def play(self) -> dict: - """Play alarm.""" - return await self.call("play_alarm") + async def play( + self, + *, + duration: int | None = None, + volume: Literal["low", "normal", "high"] | None, + sound: str | None = None, + ) -> dict: + """Play alarm. + + The optional *duration*, *volume*, and *sound* to override the device settings. + *volume* can be set to 'low', 'normal', or 'high'. + *duration* is in seconds. + See *alarm_sounds* for the list of sounds available for the device. + """ + params: dict[str, str | int] = {} + if duration is not None: + params["alarm_duration"] = duration + if volume is not None: + params["alarm_volume"] = volume + if sound is not None: + params["alarm_type"] = sound + + return await self.call("play_alarm", params) async def stop(self) -> dict: """Stop alarm.""" From 83237537408ad58cec28e13489950698561ee0da Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 5 Dec 2024 15:22:22 +0100 Subject: [PATCH 02/17] Expose alarm_duration, fix play setter signature --- kasa/smart/modules/alarm.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index 9373d1dda..b6f03435d 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -21,10 +21,7 @@ def query(self) -> dict: } def _initialize_features(self) -> None: - """Initialize features. - - This is implemented as some features depend on device responses. - """ + """Initialize features.""" device = self._device self._add_feature( Feature( @@ -74,6 +71,20 @@ def _initialize_features(self) -> None: choices_getter=lambda: ["low", "normal", "high"], ) ) + self._add_feature( + Feature( + device, + id="alarm_duration", + name="Alarm duration", + container=self, + attribute_getter="alarm_duration", + attribute_setter="set_alarm_duration", + category=Feature.Category.Config, + type=Feature.Type.Number, + # TODO: needs testing the duration limits. + range_getter=lambda: (1, 60), + ) + ) self._add_feature( Feature( device, @@ -125,6 +136,17 @@ async def set_alarm_volume(self, volume: Literal["low", "normal", "high"]) -> di payload["volume"] = volume return await self.call("set_alarm_configure", payload) + @property + def alarm_duration(self) -> int: + """Return alarm duration.""" + return self.data["get_alarm_configure"]["duration"] + + async def set_alarm_duration(self, duration: int) -> dict: + """Set alarm duration.""" + payload = self.data["get_alarm_configure"].copy() + payload["duration"] = duration + return await self.call("set_alarm_configure", payload) + @property def active(self) -> bool: """Return true if alarm is active.""" @@ -140,7 +162,7 @@ async def play( self, *, duration: int | None = None, - volume: Literal["low", "normal", "high"] | None, + volume: Literal["low", "normal", "high"] | None = None, sound: str | None = None, ) -> dict: """Play alarm. From ee82c99b5471a9769c63904e50eaa9691d809bbc Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 5 Dec 2024 15:22:48 +0100 Subject: [PATCH 03/17] Add tests --- tests/fakeprotocol_smart.py | 2 +- tests/smart/modules/test_alarm.py | 90 +++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/smart/modules/test_alarm.py diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index 448729ca7..c6a29019f 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -544,7 +544,7 @@ async def _send_request(self, request_dict: dict): self.fixture_name, set() ).add(method) return retval - elif method in ["set_qs_info", "fw_download"]: + elif method in ["set_qs_info", "fw_download", "play_alarm", "stop_alarm"]: return {"error_code": 0} elif method == "set_dynamic_light_effect_rule_enable": self._set_dynamic_light_effect(info, params) diff --git a/tests/smart/modules/test_alarm.py b/tests/smart/modules/test_alarm.py new file mode 100644 index 000000000..e520d8a37 --- /dev/null +++ b/tests/smart/modules/test_alarm.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice +from kasa.smart.modules import Alarm + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +alarm = parametrize("has alarm", component_filter="alarm", protocol_filter={"SMART"}) + + +@alarm +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("alarm", "active", bool), + ("alarm_source", "source", str | None), + ("alarm_sound", "alarm_sound", str), + ("alarm_volume", "alarm_volume", str), + ], +) +async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type): + """Test that features are registered and work as expected.""" + alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) + assert alarm is not None + + prop = getattr(alarm, prop_name) + assert isinstance(prop, type) + + feat = alarm._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@alarm +@pytest.mark.parametrize( + ("kwargs", "request_params"), + [ + pytest.param({"volume": "low"}, {"alarm_volume": "low"}, id="volume"), + pytest.param({"duration": 1}, {"alarm_duration": 1}, id="duration"), + pytest.param({"sound": "Test"}, {"alarm_type": "Test"}, id="sound"), + ], +) +async def test_play(dev: SmartDevice, kwargs, request_params, mocker: MockerFixture): + """Test that play parameters are handled correctly.""" + alarm: Alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) + call_spy = mocker.spy(alarm, "call") + await alarm.play(**kwargs) + + call_spy.assert_called_with("play_alarm", request_params) + + +@alarm +async def test_stop(dev: SmartDevice, mocker: MockerFixture): + """Test that stop creates the correct call.""" + alarm: Alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) + call_spy = mocker.spy(alarm, "call") + await alarm.stop() + + call_spy.assert_called_with("stop_alarm") + + +@alarm +@pytest.mark.parametrize( + ("method", "value", "target_key"), + [ + pytest.param("set_alarm_sound", "Test", "type", id="set_alarm_sound"), + pytest.param("set_alarm_volume", "low", "volume", id="set_alarm_volume"), + pytest.param("set_alarm_duration", 10, "duration", id="set_alarm_duration"), + ], +) +async def test_set_alarm_configure( + dev: SmartDevice, + mocker: MockerFixture, + method: str, + value: str | int, + target_key: str, +): + """Test that set_alarm_sound creates the correct call.""" + alarm: Alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) + call_spy = mocker.spy(alarm, "call") + await getattr(alarm, method)(value) + + expected_params = {"duration": mocker.ANY, "type": mocker.ANY, "volume": mocker.ANY} + expected_params[target_key] = value + + call_spy.assert_called_with("set_alarm_configure", expected_params) From 8a2ed721ee88a5af8976883ec7e605cfc17f68d1 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 5 Dec 2024 15:57:38 +0100 Subject: [PATCH 04/17] De-nest get_alarm_configure in fake protocol --- tests/fakeprotocol_smart.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index c6a29019f..7dbfeface 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -121,11 +121,9 @@ def credentials_hash(self): "get_alarm_configure": ( "alarm", { - "get_alarm_configure": { - "duration": 10, - "type": "Doorbell Ring 2", - "volume": "low", - } + "duration": 10, + "type": "Doorbell Ring 2", + "volume": "low", }, ), "get_support_alarm_type_list": ( From 000ae509d27b6f978a1a766a2741d1ecd6ed17fb Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 5 Dec 2024 16:06:37 +0100 Subject: [PATCH 05/17] Add value checks --- kasa/smart/modules/alarm.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index b6f03435d..a6ba81816 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -81,8 +81,7 @@ def _initialize_features(self) -> None: attribute_setter="set_alarm_duration", category=Feature.Category.Config, type=Feature.Type.Number, - # TODO: needs testing the duration limits. - range_getter=lambda: (1, 60), + range_getter=lambda: (1, 10 * 60), ) ) self._add_feature( @@ -116,6 +115,7 @@ async def set_alarm_sound(self, sound: str) -> dict: See *alarm_sounds* for list of available sounds. """ + self._check_sound(sound) payload = self.data["get_alarm_configure"].copy() payload["type"] = sound return await self.call("set_alarm_configure", payload) @@ -132,6 +132,7 @@ def alarm_volume(self) -> Literal["low", "normal", "high"]: async def set_alarm_volume(self, volume: Literal["low", "normal", "high"]) -> dict: """Set alarm volume.""" + self._check_volume(volume) payload = self.data["get_alarm_configure"].copy() payload["volume"] = volume return await self.call("set_alarm_configure", payload) @@ -143,6 +144,7 @@ def alarm_duration(self) -> int: async def set_alarm_duration(self, duration: int) -> dict: """Set alarm duration.""" + self._check_duration(duration) payload = self.data["get_alarm_configure"].copy() payload["duration"] = duration return await self.call("set_alarm_configure", payload) @@ -173,11 +175,17 @@ async def play( See *alarm_sounds* for the list of sounds available for the device. """ params: dict[str, str | int] = {} + if duration is not None: + self._check_duration(duration) params["alarm_duration"] = duration + if volume is not None: + self._check_volume(volume) params["alarm_volume"] = volume + if sound is not None: + self._check_sound(sound) params["alarm_type"] = sound return await self.call("play_alarm", params) @@ -185,3 +193,18 @@ async def play( async def stop(self) -> dict: """Stop alarm.""" return await self.call("stop_alarm") + + def _check_volume(self, volume: str) -> None: + """Raise an exception on invalid volume.""" + if volume not in ["low", "normal", "high"]: + raise ValueError(f"Invalid volume {volume} [low, normal, high]") + + def _check_duration(self, duration: int) -> None: + """Raise an exception on invalid duration.""" + if duration < 1 or duration > 10 * 60: + raise ValueError(f"Invalid duration {duration} (wanted: 1-600)") + + def _check_sound(self, sound: str) -> None: + """Raise an exception on invalid sound.""" + if sound not in self.alarm_sounds: + raise ValueError(f"Invalid sound {sound} [{self.alarm_sounds}]") From 2fed0eaf9707891f6e98c4f710cca61032e00073 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 5 Dec 2024 16:11:09 +0100 Subject: [PATCH 06/17] Fix tests --- tests/smart/modules/test_alarm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/smart/modules/test_alarm.py b/tests/smart/modules/test_alarm.py index e520d8a37..2a8a046c1 100644 --- a/tests/smart/modules/test_alarm.py +++ b/tests/smart/modules/test_alarm.py @@ -41,7 +41,7 @@ async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: ty [ pytest.param({"volume": "low"}, {"alarm_volume": "low"}, id="volume"), pytest.param({"duration": 1}, {"alarm_duration": 1}, id="duration"), - pytest.param({"sound": "Test"}, {"alarm_type": "Test"}, id="sound"), + pytest.param({"sound": "Alarm 1"}, {"alarm_type": "Alarm 1"}, id="sound"), ], ) async def test_play(dev: SmartDevice, kwargs, request_params, mocker: MockerFixture): @@ -67,7 +67,7 @@ async def test_stop(dev: SmartDevice, mocker: MockerFixture): @pytest.mark.parametrize( ("method", "value", "target_key"), [ - pytest.param("set_alarm_sound", "Test", "type", id="set_alarm_sound"), + pytest.param("set_alarm_sound", "Alarm 1", "type", id="set_alarm_sound"), pytest.param("set_alarm_volume", "low", "volume", id="set_alarm_volume"), pytest.param("set_alarm_duration", 10, "duration", id="set_alarm_duration"), ], From 5a7ad6ec2e369263b518fab8762b72e2b2ced599 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 5 Dec 2024 16:15:29 +0100 Subject: [PATCH 07/17] One more try --- kasa/smart/modules/alarm.py | 6 +++--- tests/smart/modules/test_alarm.py | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index a6ba81816..7a945b9b8 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -197,14 +197,14 @@ async def stop(self) -> dict: def _check_volume(self, volume: str) -> None: """Raise an exception on invalid volume.""" if volume not in ["low", "normal", "high"]: - raise ValueError(f"Invalid volume {volume} [low, normal, high]") + raise ValueError(f"Invalid volume {volume} available: low, normal, high") def _check_duration(self, duration: int) -> None: """Raise an exception on invalid duration.""" if duration < 1 or duration > 10 * 60: - raise ValueError(f"Invalid duration {duration} (wanted: 1-600)") + raise ValueError(f"Invalid duration {duration} available: 1-600") def _check_sound(self, sound: str) -> None: """Raise an exception on invalid sound.""" if sound not in self.alarm_sounds: - raise ValueError(f"Invalid sound {sound} [{self.alarm_sounds}]") + raise ValueError(f"Invalid sound {sound} available: {self.alarm_sounds}") diff --git a/tests/smart/modules/test_alarm.py b/tests/smart/modules/test_alarm.py index 2a8a046c1..89b889b83 100644 --- a/tests/smart/modules/test_alarm.py +++ b/tests/smart/modules/test_alarm.py @@ -41,7 +41,9 @@ async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: ty [ pytest.param({"volume": "low"}, {"alarm_volume": "low"}, id="volume"), pytest.param({"duration": 1}, {"alarm_duration": 1}, id="duration"), - pytest.param({"sound": "Alarm 1"}, {"alarm_type": "Alarm 1"}, id="sound"), + pytest.param( + {"sound": "Alarm 1"}, {"alarm_type": "Doorbell Ring 1"}, id="sound" + ), ], ) async def test_play(dev: SmartDevice, kwargs, request_params, mocker: MockerFixture): @@ -67,7 +69,9 @@ async def test_stop(dev: SmartDevice, mocker: MockerFixture): @pytest.mark.parametrize( ("method", "value", "target_key"), [ - pytest.param("set_alarm_sound", "Alarm 1", "type", id="set_alarm_sound"), + pytest.param( + "set_alarm_sound", "Doorbell Ring 1", "type", id="set_alarm_sound" + ), pytest.param("set_alarm_volume", "low", "volume", id="set_alarm_volume"), pytest.param("set_alarm_duration", 10, "duration", id="set_alarm_duration"), ], From f6119e3c7fd01e19e840d69d9df08a14ebda0d0e Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 5 Dec 2024 16:19:48 +0100 Subject: [PATCH 08/17] update --- tests/smart/modules/test_alarm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/smart/modules/test_alarm.py b/tests/smart/modules/test_alarm.py index 89b889b83..aa0a21cc7 100644 --- a/tests/smart/modules/test_alarm.py +++ b/tests/smart/modules/test_alarm.py @@ -42,7 +42,7 @@ async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: ty pytest.param({"volume": "low"}, {"alarm_volume": "low"}, id="volume"), pytest.param({"duration": 1}, {"alarm_duration": 1}, id="duration"), pytest.param( - {"sound": "Alarm 1"}, {"alarm_type": "Doorbell Ring 1"}, id="sound" + {"sound": "Doorbell Ring 1"}, {"alarm_type": "Doorbell Ring 1"}, id="sound" ), ], ) From a2b1193dd8712c89788d2aa588f3e9d5b35a58ee Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 5 Dec 2024 16:25:56 +0100 Subject: [PATCH 09/17] test exceptions --- tests/smart/modules/test_alarm.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/smart/modules/test_alarm.py b/tests/smart/modules/test_alarm.py index aa0a21cc7..e5863ea47 100644 --- a/tests/smart/modules/test_alarm.py +++ b/tests/smart/modules/test_alarm.py @@ -54,6 +54,15 @@ async def test_play(dev: SmartDevice, kwargs, request_params, mocker: MockerFixt call_spy.assert_called_with("play_alarm", request_params) + with pytest.raises(ValueError, match="Invalid duration"): + await alarm.play(duration=-1) + + with pytest.raises(ValueError, match="Invalid sound"): + await alarm.play(sound="unknown") + + with pytest.raises(ValueError, match="Invalid volume"): + await alarm.play(volume="unknown") # type: ignore[arg-type] + @alarm async def test_stop(dev: SmartDevice, mocker: MockerFixture): From 0c58b5e53212047c42e118313a7ebcec132d87fb Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 5 Dec 2024 16:27:00 +0100 Subject: [PATCH 10/17] constify max duration --- kasa/smart/modules/alarm.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index 7a945b9b8..86e16d8c9 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -7,6 +7,8 @@ from ...feature import Feature from ..smartmodule import SmartModule +DURATION_MAX = 10 * 60 + class Alarm(SmartModule): """Implementation of alarm module.""" @@ -81,7 +83,7 @@ def _initialize_features(self) -> None: attribute_setter="set_alarm_duration", category=Feature.Category.Config, type=Feature.Type.Number, - range_getter=lambda: (1, 10 * 60), + range_getter=lambda: (1, DURATION_MAX), ) ) self._add_feature( @@ -201,7 +203,7 @@ def _check_volume(self, volume: str) -> None: def _check_duration(self, duration: int) -> None: """Raise an exception on invalid duration.""" - if duration < 1 or duration > 10 * 60: + if duration < 1 or duration > DURATION_MAX: raise ValueError(f"Invalid duration {duration} available: 1-600") def _check_sound(self, sound: str) -> None: From dbd80e6ee93e56115ce5c14800ed2b78cb071707 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Fri, 13 Dec 2024 18:22:33 +0100 Subject: [PATCH 11/17] Add mute volume and allow passing volume as integer --- kasa/smart/modules/alarm.py | 56 ++++++++++++++++++++++--------- tests/smart/modules/test_alarm.py | 4 +++ 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index 86e16d8c9..2209742ec 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -2,13 +2,16 @@ from __future__ import annotations -from typing import Literal +from typing import TYPE_CHECKING, Annotated, Literal, TypeAlias from ...feature import Feature +from ...module import FeatureAttribute from ..smartmodule import SmartModule DURATION_MAX = 10 * 60 +AlarmVolume: TypeAlias = Literal["mute", "low", "normal", "high"] + class Alarm(SmartModule): """Implementation of alarm module.""" @@ -70,7 +73,7 @@ def _initialize_features(self) -> None: attribute_setter="set_alarm_volume", category=Feature.Category.Config, type=Feature.Type.Choice, - choices_getter=lambda: ["low", "normal", "high"], + choices_getter=lambda: ["mute", "low", "normal", "high"], ) ) self._add_feature( @@ -108,11 +111,11 @@ def _initialize_features(self) -> None: ) @property - def alarm_sound(self) -> str: + def alarm_sound(self) -> Annotated[str, FeatureAttribute()]: """Return current alarm sound.""" return self.data["get_alarm_configure"]["type"] - async def set_alarm_sound(self, sound: str) -> dict: + async def set_alarm_sound(self, sound: str) -> Annotated[dict, FeatureAttribute()]: """Set alarm sound. See *alarm_sounds* for list of available sounds. @@ -128,23 +131,27 @@ def alarm_sounds(self) -> list[str]: return self.data["get_support_alarm_type_list"]["alarm_type_list"] @property - def alarm_volume(self) -> Literal["low", "normal", "high"]: + def alarm_volume(self) -> Annotated[AlarmVolume, FeatureAttribute()]: """Return alarm volume.""" return self.data["get_alarm_configure"]["volume"] - async def set_alarm_volume(self, volume: Literal["low", "normal", "high"]) -> dict: + async def set_alarm_volume( + self, volume: AlarmVolume | int + ) -> Annotated[dict, FeatureAttribute()]: """Set alarm volume.""" - self._check_volume(volume) + self._check_and_convert_volume(volume) payload = self.data["get_alarm_configure"].copy() payload["volume"] = volume return await self.call("set_alarm_configure", payload) @property - def alarm_duration(self) -> int: + def alarm_duration(self) -> Annotated[int, FeatureAttribute()]: """Return alarm duration.""" return self.data["get_alarm_configure"]["duration"] - async def set_alarm_duration(self, duration: int) -> dict: + async def set_alarm_duration( + self, duration: int + ) -> Annotated[dict, FeatureAttribute()]: """Set alarm duration.""" self._check_duration(duration) payload = self.data["get_alarm_configure"].copy() @@ -166,13 +173,13 @@ async def play( self, *, duration: int | None = None, - volume: Literal["low", "normal", "high"] | None = None, + volume: int | AlarmVolume | None = None, sound: str | None = None, ) -> dict: """Play alarm. The optional *duration*, *volume*, and *sound* to override the device settings. - *volume* can be set to 'low', 'normal', or 'high'. + *volume* can be set to 'mute', 'low', 'normal', or 'high'. *duration* is in seconds. See *alarm_sounds* for the list of sounds available for the device. """ @@ -183,8 +190,8 @@ async def play( params["alarm_duration"] = duration if volume is not None: - self._check_volume(volume) - params["alarm_volume"] = volume + target_volume = self._check_and_convert_volume(volume) + params["alarm_volume"] = target_volume if sound is not None: self._check_sound(sound) @@ -196,10 +203,27 @@ async def stop(self) -> dict: """Stop alarm.""" return await self.call("stop_alarm") - def _check_volume(self, volume: str) -> None: + def _check_and_convert_volume(self, volume: str | int) -> str: """Raise an exception on invalid volume.""" - if volume not in ["low", "normal", "high"]: - raise ValueError(f"Invalid volume {volume} available: low, normal, high") + volume_int_to_str = { + 0: "mute", + 1: "low", + 2: "normal", + 3: "high", + } + if isinstance(volume, int): + volume = volume_int_to_str.get(volume, "invalid") + + if TYPE_CHECKING: + assert isinstance(volume, str) + + if volume not in volume_int_to_str.values(): + raise ValueError( + f"Invalid volume {volume} " + f"available: {volume_int_to_str.keys()}, {volume_int_to_str.values()}" + ) + + return volume def _check_duration(self, duration: int) -> None: """Raise an exception on invalid duration.""" diff --git a/tests/smart/modules/test_alarm.py b/tests/smart/modules/test_alarm.py index e5863ea47..2879c8e0b 100644 --- a/tests/smart/modules/test_alarm.py +++ b/tests/smart/modules/test_alarm.py @@ -40,6 +40,7 @@ async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: ty ("kwargs", "request_params"), [ pytest.param({"volume": "low"}, {"alarm_volume": "low"}, id="volume"), + pytest.param({"volume": 0}, {"alarm_volume": "mute"}, id="volume-integer"), pytest.param({"duration": 1}, {"alarm_duration": 1}, id="duration"), pytest.param( {"sound": "Doorbell Ring 1"}, {"alarm_type": "Doorbell Ring 1"}, id="sound" @@ -63,6 +64,9 @@ async def test_play(dev: SmartDevice, kwargs, request_params, mocker: MockerFixt with pytest.raises(ValueError, match="Invalid volume"): await alarm.play(volume="unknown") # type: ignore[arg-type] + with pytest.raises(ValueError, match="Invalid volume"): + await alarm.play(volume=-1) + @alarm async def test_stop(dev: SmartDevice, mocker: MockerFixture): From b1f7754f36d0a16248ca483e8576f092d8c9eb4d Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 23 Jan 2025 12:40:06 +0000 Subject: [PATCH 12/17] Add range to alarm_volume feature --- kasa/smart/modules/alarm.py | 25 +++++++++++++++---------- tests/smart/modules/test_alarm.py | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index 2209742ec..1297f9549 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -10,6 +10,16 @@ DURATION_MAX = 10 * 60 +VOLUME_INT_TO_STR = { + 0: "mute", + 1: "low", + 2: "normal", + 3: "high", +} + +VOLUME_STR_LIST = [v for v in VOLUME_INT_TO_STR.values()] +VOLUME_INT_RANGE = (min(VOLUME_INT_TO_STR.keys()), max(VOLUME_INT_TO_STR.keys())) + AlarmVolume: TypeAlias = Literal["mute", "low", "normal", "high"] @@ -73,7 +83,8 @@ def _initialize_features(self) -> None: attribute_setter="set_alarm_volume", category=Feature.Category.Config, type=Feature.Type.Choice, - choices_getter=lambda: ["mute", "low", "normal", "high"], + choices_getter=lambda: VOLUME_STR_LIST, + range_getter=lambda: VOLUME_INT_RANGE, ) ) self._add_feature( @@ -205,22 +216,16 @@ async def stop(self) -> dict: def _check_and_convert_volume(self, volume: str | int) -> str: """Raise an exception on invalid volume.""" - volume_int_to_str = { - 0: "mute", - 1: "low", - 2: "normal", - 3: "high", - } if isinstance(volume, int): - volume = volume_int_to_str.get(volume, "invalid") + volume = VOLUME_INT_TO_STR.get(volume, "invalid") if TYPE_CHECKING: assert isinstance(volume, str) - if volume not in volume_int_to_str.values(): + if volume not in VOLUME_INT_TO_STR.values(): raise ValueError( f"Invalid volume {volume} " - f"available: {volume_int_to_str.keys()}, {volume_int_to_str.values()}" + f"available: {VOLUME_INT_TO_STR.keys()}, {VOLUME_INT_TO_STR.values()}" ) return volume diff --git a/tests/smart/modules/test_alarm.py b/tests/smart/modules/test_alarm.py index 2879c8e0b..6b551c990 100644 --- a/tests/smart/modules/test_alarm.py +++ b/tests/smart/modules/test_alarm.py @@ -35,6 +35,21 @@ async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: ty assert isinstance(feat.value, type) +@alarm +async def test_volume_feature(dev: SmartDevice): + """Test that volume feature has correct choices and range.""" + alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) + assert alarm is not None + + volume_feat = alarm.get_feature("alarm_volume") + assert volume_feat + + assert volume_feat.minimum_value == 0 + assert volume_feat.maximum_value == 3 + + assert volume_feat.choices == ["mute", "low", "normal", "high"] + + @alarm @pytest.mark.parametrize( ("kwargs", "request_params"), From 74cf44f013bbd628a2a57ac741347b6e3855d54d Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 23 Jan 2025 14:14:49 +0000 Subject: [PATCH 13/17] Add common alarm interface --- kasa/interfaces/__init__.py | 2 + kasa/interfaces/alarm.py | 70 +++++++++++++++++++++++++ kasa/module.py | 18 +++++-- kasa/smart/modules/alarm.py | 13 +++-- kasa/smartcam/modules/alarm.py | 76 +++++++++++++++++++++------- tests/fakeprotocol_smartcam.py | 12 +++-- tests/smart/modules/test_alarm.py | 2 +- tests/smartcam/modules/test_alarm.py | 22 ++++++-- 8 files changed, 178 insertions(+), 37 deletions(-) create mode 100644 kasa/interfaces/alarm.py diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index e5fd4caee..29313b622 100644 --- a/kasa/interfaces/__init__.py +++ b/kasa/interfaces/__init__.py @@ -1,5 +1,6 @@ """Package for interfaces.""" +from .alarm import Alarm from .energy import Energy from .fan import Fan from .led import Led @@ -10,6 +11,7 @@ from .time import Time __all__ = [ + "Alarm", "Fan", "Energy", "Led", diff --git a/kasa/interfaces/alarm.py b/kasa/interfaces/alarm.py new file mode 100644 index 000000000..8d33b9dde --- /dev/null +++ b/kasa/interfaces/alarm.py @@ -0,0 +1,70 @@ +"""Module for base alarm module.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Annotated + +from ..module import FeatureAttribute, Module + + +class Alarm(Module, ABC): + """Base interface to represent an alarm module.""" + + @property + @abstractmethod + def alarm_sound(self) -> Annotated[str, FeatureAttribute()]: + """Return current alarm sound.""" + + @abstractmethod + async def set_alarm_sound(self, sound: str) -> Annotated[dict, FeatureAttribute()]: + """Set alarm sound. + + See *alarm_sounds* for list of available sounds. + """ + + @property + @abstractmethod + def alarm_sounds(self) -> list[str]: + """Return list of available alarm sounds.""" + + @property + @abstractmethod + def alarm_volume(self) -> Annotated[int, FeatureAttribute()]: + """Return alarm volume.""" + + @abstractmethod + async def set_alarm_volume( + self, volume: int + ) -> Annotated[dict, FeatureAttribute()]: + """Set alarm volume.""" + + @property + @abstractmethod + def alarm_duration(self) -> Annotated[int, FeatureAttribute()]: + """Return alarm duration.""" + + @abstractmethod + async def set_alarm_duration( + self, duration: int + ) -> Annotated[dict, FeatureAttribute()]: + """Set alarm duration.""" + + @property + @abstractmethod + def active(self) -> bool: + """Return true if alarm is active.""" + + @abstractmethod + async def play( + self, + *, + duration: int | None = None, + volume: int | None = None, + sound: str | None = None, + ) -> dict: + """Play alarm.""" + + @abstractmethod + async def stop(self) -> dict: + """Stop alarm.""" diff --git a/kasa/module.py b/kasa/module.py index 6f188b305..59dc45548 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -81,6 +81,9 @@ class FeatureAttribute: """Class for annotating attributes bound to feature.""" + def __init__(self, feature_name: str | None = None) -> None: + self.feature_name = feature_name + def __repr__(self) -> str: return "FeatureAttribute" @@ -93,6 +96,7 @@ class Module(ABC): """ # Common Modules + Alarm: Final[ModuleName[interfaces.Alarm]] = ModuleName("Alarm") Energy: Final[ModuleName[interfaces.Energy]] = ModuleName("Energy") Fan: Final[ModuleName[interfaces.Fan]] = ModuleName("Fan") LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect") @@ -112,7 +116,6 @@ class Module(ABC): IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud") # SMART only Modules - Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm") AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff") BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor") Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness") @@ -234,7 +237,7 @@ def __repr__(self) -> str: ) -def _is_bound_feature(attribute: property | Callable) -> bool: +def _get_feature_attribute(attribute: property | Callable) -> FeatureAttribute | None: """Check if an attribute is bound to a feature with FeatureAttribute.""" if isinstance(attribute, property): hints = get_type_hints(attribute.fget, include_extras=True) @@ -245,9 +248,9 @@ def _is_bound_feature(attribute: property | Callable) -> bool: metadata = hints["return"].__metadata__ for meta in metadata: if isinstance(meta, FeatureAttribute): - return True + return meta - return False + return None @cache @@ -274,12 +277,17 @@ def _get_bound_feature( f"module {module.__class__.__name__}" ) - if not _is_bound_feature(attribute_callable): + if not (fa := _get_feature_attribute(attribute_callable)): raise KasaException( f"Attribute {attribute_name} of module {module.__class__.__name__}" " is not bound to a feature" ) + # If a feature_name was passed to the FeatureAttribute use that to check + # for the feature. Otherwise check the getters and setters in the features + if fa.feature_name: + return module._all_features.get(fa.feature_name) + check = {attribute_name, attribute_callable} for feature in module._all_features.values(): if (getter := feature.attribute_getter) and getter in check: diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index 1297f9549..45f6690a1 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Annotated, Literal, TypeAlias from ...feature import Feature +from ...interfaces import Alarm as AlarmInterface from ...module import FeatureAttribute from ..smartmodule import SmartModule @@ -19,11 +20,12 @@ VOLUME_STR_LIST = [v for v in VOLUME_INT_TO_STR.values()] VOLUME_INT_RANGE = (min(VOLUME_INT_TO_STR.keys()), max(VOLUME_INT_TO_STR.keys())) +VOLUME_STR_TO_INT = {v: k for k, v in VOLUME_INT_TO_STR.items()} AlarmVolume: TypeAlias = Literal["mute", "low", "normal", "high"] -class Alarm(SmartModule): +class Alarm(SmartModule, AlarmInterface): """Implementation of alarm module.""" REQUIRED_COMPONENT = "alarm" @@ -79,7 +81,7 @@ def _initialize_features(self) -> None: id="alarm_volume", name="Alarm volume", container=self, - attribute_getter="alarm_volume", + attribute_getter="_alarm_volume_str", attribute_setter="set_alarm_volume", category=Feature.Category.Config, type=Feature.Type.Choice, @@ -142,7 +144,12 @@ def alarm_sounds(self) -> list[str]: return self.data["get_support_alarm_type_list"]["alarm_type_list"] @property - def alarm_volume(self) -> Annotated[AlarmVolume, FeatureAttribute()]: + def alarm_volume(self) -> Annotated[int, FeatureAttribute("alarm_volume")]: + """Return alarm volume.""" + return VOLUME_STR_TO_INT[self._alarm_volume_str] + + @property + def _alarm_volume_str(self) -> str: """Return alarm volume.""" return self.data["get_alarm_configure"]["volume"] diff --git a/kasa/smartcam/modules/alarm.py b/kasa/smartcam/modules/alarm.py index 5330f309c..f82f4dc6a 100644 --- a/kasa/smartcam/modules/alarm.py +++ b/kasa/smartcam/modules/alarm.py @@ -3,6 +3,7 @@ from __future__ import annotations from ...feature import Feature +from ...interfaces import Alarm as AlarmInterface from ...smart.smartmodule import allow_update_after from ..smartcammodule import SmartCamModule @@ -13,12 +14,9 @@ VOLUME_MAX = 10 -class Alarm(SmartCamModule): +class Alarm(SmartCamModule, AlarmInterface): """Implementation of alarm module.""" - # Needs a different name to avoid clashing with SmartAlarm - NAME = "SmartCamAlarm" - REQUIRED_COMPONENT = "siren" QUERY_GETTER_NAME = "getSirenStatus" QUERY_MODULE_NAME = "siren" @@ -117,11 +115,8 @@ async def set_alarm_sound(self, sound: str) -> dict: See *alarm_sounds* for list of available sounds. """ - if sound not in self.alarm_sounds: - raise ValueError( - f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}" - ) - return await self.call("setSirenConfig", {"siren": {"siren_type": sound}}) + config = self._validate_and_get_config(sound=sound) + return await self.call("setSirenConfig", {"siren": config}) @property def alarm_sounds(self) -> list[str]: @@ -139,9 +134,8 @@ def alarm_volume(self) -> int: @allow_update_after async def set_alarm_volume(self, volume: int) -> dict: """Set alarm volume.""" - if volume < VOLUME_MIN or volume > VOLUME_MAX: - raise ValueError(f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}") - return await self.call("setSirenConfig", {"siren": {"volume": str(volume)}}) + config = self._validate_and_get_config(volume=volume) + return await self.call("setSirenConfig", {"siren": config}) @property def alarm_duration(self) -> int: @@ -151,20 +145,66 @@ def alarm_duration(self) -> int: @allow_update_after async def set_alarm_duration(self, duration: int) -> dict: """Set alarm volume.""" - if duration < DURATION_MIN or duration > DURATION_MAX: - msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}" - raise ValueError(msg) - return await self.call("setSirenConfig", {"siren": {"duration": duration}}) + config = self._validate_and_get_config(duration=duration) + return await self.call("setSirenConfig", {"siren": config}) @property def active(self) -> bool: """Return true if alarm is active.""" return self.data["getSirenStatus"]["status"] != "off" - async def play(self) -> dict: - """Play alarm.""" + async def play( + self, + *, + duration: int | None = None, + volume: int | None = None, + sound: str | None = None, + ) -> dict: + """Play alarm. + + The optional *duration*, *volume*, and *sound* to override the device settings. + *volume* can be set to 'mute', 'low', 'normal', or 'high'. + *duration* is in seconds. + See *alarm_sounds* for the list of sounds available for the device. + """ + if config := self._validate_and_get_config( + duration=duration, volume=volume, sound=sound + ): + await self.call("setSirenConfig", {"siren": config}) + return await self.call("setSirenStatus", {"siren": {"status": "on"}}) async def stop(self) -> dict: """Stop alarm.""" return await self.call("setSirenStatus", {"siren": {"status": "off"}}) + + def _validate_and_get_config( + self, + *, + duration: int | None = None, + volume: int | None = None, + sound: str | None = None, + ) -> dict: + if sound and sound not in self.alarm_sounds: + raise ValueError( + f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}" + ) + + if duration is not None and ( + duration < DURATION_MIN or duration > DURATION_MAX + ): + msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}" + raise ValueError(msg) + + if volume is not None and (volume < VOLUME_MIN or volume > VOLUME_MAX): + raise ValueError(f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}") + + config: dict[str, str | int] = {} + if sound: + config["siren_type"] = sound + if duration is not None: + config["duration"] = duration + if volume is not None: + config["volume"] = str(volume) + + return config diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index 5e4396261..1c5741e7f 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -276,12 +276,14 @@ async def _send_request(self, request_dict: dict): section = next(iter(val)) skey_val = val[section] if not isinstance(skey_val, dict): # single level query - section_key = section - section_val = skey_val - if (get_info := info.get(get_method)) and section_key in get_info: - get_info[section_key] = section_val - else: + updates = { + k: v for k, v in val.items() if k in info.get(get_method, {}) + } + if len(updates) != len(val): + # All keys to update must already be in the getter return {"error_code": -1} + info[get_method] = {**info[get_method], **updates} + break for skey, sval in skey_val.items(): section_key = skey diff --git a/tests/smart/modules/test_alarm.py b/tests/smart/modules/test_alarm.py index 6b551c990..1f311f925 100644 --- a/tests/smart/modules/test_alarm.py +++ b/tests/smart/modules/test_alarm.py @@ -19,7 +19,7 @@ ("alarm", "active", bool), ("alarm_source", "source", str | None), ("alarm_sound", "alarm_sound", str), - ("alarm_volume", "alarm_volume", str), + ("alarm_volume", "_alarm_volume_str", str), ], ) async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type): diff --git a/tests/smartcam/modules/test_alarm.py b/tests/smartcam/modules/test_alarm.py index 50e0b5b3a..0a176650f 100644 --- a/tests/smartcam/modules/test_alarm.py +++ b/tests/smartcam/modules/test_alarm.py @@ -4,14 +4,13 @@ import pytest -from kasa import Device +from kasa import Device, Module from kasa.smartcam.modules.alarm import ( DURATION_MAX, DURATION_MIN, VOLUME_MAX, VOLUME_MIN, ) -from kasa.smartcam.smartcammodule import SmartCamModule from ...conftest import hub_smartcam @@ -19,7 +18,7 @@ @hub_smartcam async def test_alarm(dev: Device): """Test device alarm.""" - alarm = dev.modules.get(SmartCamModule.SmartCamAlarm) + alarm = dev.modules.get(Module.Alarm) assert alarm original_duration = alarm.alarm_duration @@ -63,6 +62,19 @@ async def test_alarm(dev: Device): await dev.update() assert alarm.alarm_sound == new_sound + # Test play parameters + await alarm.play( + duration=original_duration, volume=original_volume, sound=original_sound + ) + await dev.update() + assert alarm.active + assert alarm.alarm_sound == original_sound + assert alarm.alarm_duration == original_duration + assert alarm.alarm_volume == original_volume + await alarm.stop() + await dev.update() + assert not alarm.active + finally: await alarm.set_alarm_volume(original_volume) await alarm.set_alarm_duration(original_duration) @@ -73,7 +85,7 @@ async def test_alarm(dev: Device): @hub_smartcam async def test_alarm_invalid_setters(dev: Device): """Test device alarm invalid setter values.""" - alarm = dev.modules.get(SmartCamModule.SmartCamAlarm) + alarm = dev.modules.get(Module.Alarm) assert alarm # test set sound invalid @@ -95,7 +107,7 @@ async def test_alarm_invalid_setters(dev: Device): @hub_smartcam async def test_alarm_features(dev: Device): """Test device alarm features.""" - alarm = dev.modules.get(SmartCamModule.SmartCamAlarm) + alarm = dev.modules.get(Module.Alarm) assert alarm original_duration = alarm.alarm_duration From 7f5046b48de99a83c1302820ef01c5927bd4a879 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sun, 26 Jan 2025 10:52:36 +0000 Subject: [PATCH 14/17] Add alarm_volume_number feature --- kasa/smart/modules/alarm.py | 24 ++++++++++++++++++++++-- tests/smart/modules/test_alarm.py | 3 ++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index 1297f9549..d57687212 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -19,6 +19,7 @@ VOLUME_STR_LIST = [v for v in VOLUME_INT_TO_STR.values()] VOLUME_INT_RANGE = (min(VOLUME_INT_TO_STR.keys()), max(VOLUME_INT_TO_STR.keys())) +VOLUME_STR_TO_INT = {v: k for k, v in VOLUME_INT_TO_STR.items()} AlarmVolume: TypeAlias = Literal["mute", "low", "normal", "high"] @@ -79,11 +80,23 @@ def _initialize_features(self) -> None: id="alarm_volume", name="Alarm volume", container=self, - attribute_getter="alarm_volume", + attribute_getter="_alarm_volume_str", attribute_setter="set_alarm_volume", category=Feature.Category.Config, type=Feature.Type.Choice, choices_getter=lambda: VOLUME_STR_LIST, + ) + ) + self._add_feature( + Feature( + device, + id="alarm_volume_number", + name="Alarm volume", + container=self, + attribute_getter="alarm_volume", + attribute_setter="set_alarm_volume", + category=Feature.Category.Config, + type=Feature.Type.Number, range_getter=lambda: VOLUME_INT_RANGE, ) ) @@ -142,7 +155,14 @@ def alarm_sounds(self) -> list[str]: return self.data["get_support_alarm_type_list"]["alarm_type_list"] @property - def alarm_volume(self) -> Annotated[AlarmVolume, FeatureAttribute()]: + def alarm_volume(self) -> Annotated[int, FeatureAttribute("alarm_volume_number")]: + """Return alarm volume.""" + return VOLUME_STR_TO_INT[self._alarm_volume_str] + + @property + def _alarm_volume_str( + self, + ) -> Annotated[AlarmVolume, FeatureAttribute("alarm_volume")]: """Return alarm volume.""" return self.data["get_alarm_configure"]["volume"] diff --git a/tests/smart/modules/test_alarm.py b/tests/smart/modules/test_alarm.py index 6b551c990..7b0cf18ff 100644 --- a/tests/smart/modules/test_alarm.py +++ b/tests/smart/modules/test_alarm.py @@ -19,7 +19,8 @@ ("alarm", "active", bool), ("alarm_source", "source", str | None), ("alarm_sound", "alarm_sound", str), - ("alarm_volume", "alarm_volume", str), + ("alarm_volume", "_alarm_volume_str", str), + ("alarm_volume_number", "alarm_volume", int), ], ) async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type): From 233940a858cd41d62764364c3366826d582d924c Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sun, 26 Jan 2025 11:02:57 +0000 Subject: [PATCH 15/17] Fix tests --- tests/smart/modules/test_alarm.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/smart/modules/test_alarm.py b/tests/smart/modules/test_alarm.py index 7b0cf18ff..cfc01183e 100644 --- a/tests/smart/modules/test_alarm.py +++ b/tests/smart/modules/test_alarm.py @@ -38,17 +38,18 @@ async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: ty @alarm async def test_volume_feature(dev: SmartDevice): - """Test that volume feature has correct choices and range.""" + """Test that volume features have correct choices and range.""" alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) assert alarm is not None - volume_feat = alarm.get_feature("alarm_volume") - assert volume_feat + volume_str_feat = alarm.get_feature("_alarm_volume_str") + assert volume_str_feat - assert volume_feat.minimum_value == 0 - assert volume_feat.maximum_value == 3 + assert volume_str_feat.choices == ["mute", "low", "normal", "high"] - assert volume_feat.choices == ["mute", "low", "normal", "high"] + volume_int_feat = alarm.get_feature("alarm_volume") + assert volume_int_feat.minimum_value == 0 + assert volume_int_feat.maximum_value == 3 @alarm From 5be43759c36e4e3c1a1393ef41dd026c4dc9bc03 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sun, 26 Jan 2025 13:08:40 +0000 Subject: [PATCH 16/17] Rename feature --- kasa/smart/modules/alarm.py | 4 ++-- tests/smart/modules/test_alarm.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index d57687212..d645d3c95 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -90,7 +90,7 @@ def _initialize_features(self) -> None: self._add_feature( Feature( device, - id="alarm_volume_number", + id="alarm_volume_level", name="Alarm volume", container=self, attribute_getter="alarm_volume", @@ -155,7 +155,7 @@ def alarm_sounds(self) -> list[str]: return self.data["get_support_alarm_type_list"]["alarm_type_list"] @property - def alarm_volume(self) -> Annotated[int, FeatureAttribute("alarm_volume_number")]: + def alarm_volume(self) -> Annotated[int, FeatureAttribute("alarm_volume_level")]: """Return alarm volume.""" return VOLUME_STR_TO_INT[self._alarm_volume_str] diff --git a/tests/smart/modules/test_alarm.py b/tests/smart/modules/test_alarm.py index cfc01183e..25d24a588 100644 --- a/tests/smart/modules/test_alarm.py +++ b/tests/smart/modules/test_alarm.py @@ -20,7 +20,7 @@ ("alarm_source", "source", str | None), ("alarm_sound", "alarm_sound", str), ("alarm_volume", "_alarm_volume_str", str), - ("alarm_volume_number", "alarm_volume", int), + ("alarm_volume_level", "alarm_volume", int), ], ) async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type): From d5fbb7c6249e7f7e97797c04a4fa3c58e9273028 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sun, 26 Jan 2025 13:17:13 +0000 Subject: [PATCH 17/17] Update docstrings --- kasa/interfaces/alarm.py | 7 ++++++- kasa/smartcam/modules/alarm.py | 1 - 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/kasa/interfaces/alarm.py b/kasa/interfaces/alarm.py index 8d33b9dde..1a50b1ef7 100644 --- a/kasa/interfaces/alarm.py +++ b/kasa/interfaces/alarm.py @@ -63,7 +63,12 @@ async def play( volume: int | None = None, sound: str | None = None, ) -> dict: - """Play alarm.""" + """Play alarm. + + The optional *duration*, *volume*, and *sound* to override the device settings. + *duration* is in seconds. + See *alarm_sounds* for the list of sounds available for the device. + """ @abstractmethod async def stop(self) -> dict: diff --git a/kasa/smartcam/modules/alarm.py b/kasa/smartcam/modules/alarm.py index f82f4dc6a..18833d822 100644 --- a/kasa/smartcam/modules/alarm.py +++ b/kasa/smartcam/modules/alarm.py @@ -163,7 +163,6 @@ async def play( """Play alarm. The optional *duration*, *volume*, and *sound* to override the device settings. - *volume* can be set to 'mute', 'low', 'normal', or 'high'. *duration* is in seconds. See *alarm_sounds* for the list of sounds available for the device. """