From 68eeb2fee37e5943108a043a1e2cbe1369e4a0de Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Mon, 2 Dec 2024 14:35:11 +0100 Subject: [PATCH 1/8] Add vacuum speaker controls --- kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/vacuumspeaker.py | 70 +++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 kasa/smart/modules/vacuumspeaker.py diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 2945ffdd2..fc054e953 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -35,6 +35,7 @@ from .thermostat import Thermostat from .time import Time from .triggerlogs import TriggerLogs +from .vacuumspeaker import VacuumSpeaker from .waterleaksensor import WaterleakSensor __all__ = [ @@ -71,6 +72,7 @@ "Clean", "SmartLightEffect", "OverheatProtection", + "VacuumSpeaker", "HomeKit", "Matter", "Dustbin", diff --git a/kasa/smart/modules/vacuumspeaker.py b/kasa/smart/modules/vacuumspeaker.py new file mode 100644 index 000000000..5730364cc --- /dev/null +++ b/kasa/smart/modules/vacuumspeaker.py @@ -0,0 +1,70 @@ +"""Implementation of vacuum speaker.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from ...feature import Feature +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +_LOGGER = logging.getLogger(__name__) + + +class VacuumSpeaker(SmartModule): + """Implementation of vacuum speaker.""" + + REQUIRED_COMPONENT = "speaker" + + def __init__(self, device: SmartDevice, module: str) -> None: + super().__init__(device, module) + self._add_feature( + Feature( + device, + id="vacuum_locate", + name="Locate vacuum", + container=self, + attribute_setter="locate", + category=Feature.Category.Primary, + type=Feature.Action, + ) + ) + self._add_feature( + Feature( + device, + id="vacuum_volume", + name="Volume", + container=self, + attribute_getter="volume", + attribute_setter="set_volume", + range_getter=lambda: (0, 100), + category=Feature.Category.Config, + type=Feature.Type.Number, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return { + "getVolume": None, + } + + @property + def volume(self) -> str: + """Return volume.""" + return self.data["volume"] + + async def set_volume(self, volume: int) -> dict: + """Set volume.""" + if volume < 0 or volume > 100: + raise ValueError("Volume must be between 0 and 100") + + return await self.call("setVolume", {"volume": volume}) + + async def locate(self) -> dict: + """Play sound to locate the device.""" + return await self.call("playSelectAudio", {"audio_type": "seek_me"}) From ea0f1c898411ef8d76e616b8633ecf2eb49c91f5 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 11 Jan 2025 15:50:33 +0100 Subject: [PATCH 2/8] Cleanup --- kasa/smart/modules/vacuumspeaker.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/kasa/smart/modules/vacuumspeaker.py b/kasa/smart/modules/vacuumspeaker.py index 5730364cc..1cbcc355b 100644 --- a/kasa/smart/modules/vacuumspeaker.py +++ b/kasa/smart/modules/vacuumspeaker.py @@ -3,15 +3,12 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import Annotated from ...feature import Feature +from ...module import FeatureAttribute from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - - _LOGGER = logging.getLogger(__name__) @@ -20,13 +17,13 @@ class VacuumSpeaker(SmartModule): REQUIRED_COMPONENT = "speaker" - def __init__(self, device: SmartDevice, module: str) -> None: - super().__init__(device, module) + def _initialize_features(self) -> None: + """Initialize features.""" self._add_feature( Feature( - device, - id="vacuum_locate", - name="Locate vacuum", + self._device, + id="locate", + name="Locate device", container=self, attribute_setter="locate", category=Feature.Category.Primary, @@ -35,8 +32,8 @@ def __init__(self, device: SmartDevice, module: str) -> None: ) self._add_feature( Feature( - device, - id="vacuum_volume", + self._device, + id="volume", name="Volume", container=self, attribute_getter="volume", @@ -54,11 +51,11 @@ def query(self) -> dict: } @property - def volume(self) -> str: + def volume(self) -> Annotated[str, FeatureAttribute()]: """Return volume.""" return self.data["volume"] - async def set_volume(self, volume: int) -> dict: + async def set_volume(self, volume: int) -> Annotated[dict, FeatureAttribute()]: """Set volume.""" if volume < 0 or volume > 100: raise ValueError("Volume must be between 0 and 100") From 37c0b194819190ef949324af2934367a78974981 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 14 Jan 2025 15:58:45 +0100 Subject: [PATCH 3/8] rename to speaker --- kasa/module.py | 1 + kasa/smart/modules/__init__.py | 4 ++-- kasa/smart/modules/{vacuumspeaker.py => speaker.py} | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) rename kasa/smart/modules/{vacuumspeaker.py => speaker.py} (98%) diff --git a/kasa/module.py b/kasa/module.py index cda8188b7..0c5a0489f 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -164,6 +164,7 @@ class Module(ABC): # Vacuum modules Clean: Final[ModuleName[smart.Clean]] = ModuleName("Clean") Dustbin: Final[ModuleName[smart.Dustbin]] = ModuleName("Dustbin") + Speaker: Final[ModuleName[smart.Speaker]] = ModuleName("Speaker") def __init__(self, device: Device, module: str) -> None: self._device = device diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index fc054e953..deb09f4f4 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -30,12 +30,12 @@ from .motionsensor import MotionSensor from .overheatprotection import OverheatProtection from .reportmode import ReportMode +from .speaker import Speaker from .temperaturecontrol import TemperatureControl from .temperaturesensor import TemperatureSensor from .thermostat import Thermostat from .time import Time from .triggerlogs import TriggerLogs -from .vacuumspeaker import VacuumSpeaker from .waterleaksensor import WaterleakSensor __all__ = [ @@ -72,7 +72,7 @@ "Clean", "SmartLightEffect", "OverheatProtection", - "VacuumSpeaker", + "Speaker", "HomeKit", "Matter", "Dustbin", diff --git a/kasa/smart/modules/vacuumspeaker.py b/kasa/smart/modules/speaker.py similarity index 98% rename from kasa/smart/modules/vacuumspeaker.py rename to kasa/smart/modules/speaker.py index 1cbcc355b..e36758b40 100644 --- a/kasa/smart/modules/vacuumspeaker.py +++ b/kasa/smart/modules/speaker.py @@ -12,7 +12,7 @@ _LOGGER = logging.getLogger(__name__) -class VacuumSpeaker(SmartModule): +class Speaker(SmartModule): """Implementation of vacuum speaker.""" REQUIRED_COMPONENT = "speaker" From e0a8061449d8ae62b1d9411d456b1e90baac476c Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 11 Jan 2025 16:00:18 +0100 Subject: [PATCH 4/8] add tests --- tests/smart/modules/test_speaker.py | 63 +++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/smart/modules/test_speaker.py diff --git a/tests/smart/modules/test_speaker.py b/tests/smart/modules/test_speaker.py new file mode 100644 index 000000000..715637f19 --- /dev/null +++ b/tests/smart/modules/test_speaker.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +speaker = parametrize( + "has speaker", component_filter="speaker", protocol_filter={"SMART"} +) + + +@speaker +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("volume", "volume", int), + ], +) +async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type): + """Test that features are registered and work as expected.""" + speaker = next(get_parent_and_child_modules(dev, Module.Speaker)) + assert speaker is not None + + prop = getattr(speaker, prop_name) + assert isinstance(prop, type) + + feat = speaker._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@speaker +async def test_set_volume(dev: SmartDevice, mocker: MockerFixture): + """Test speaker settings.""" + speaker = next(get_parent_and_child_modules(dev, Module.Speaker)) + call = mocker.spy(speaker, "call") + + volume = speaker._device.features["volume"] + assert speaker.volume == volume.volume + + new_volume = 15 + await speaker.set_volume(new_volume) + + await dev.update() + + call.assert_called_with("setVolume", {"volume": new_volume}) + + await dev.update() + + assert speaker.volume == new_volume + + +@speaker +async def test_locate(dev: SmartDevice, mocker: MockerFixture): + """Test the locate method.""" + speaker = next(get_parent_and_child_modules(dev, Module.Speaker)) + call = mocker.spy(speaker, "call") + + call.assert_called_with("playSelectAudio", {"audio_type": "seek_me"}) From ef2b55c0e52c23026d3669edaed1ca0dc228f8f4 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 14 Jan 2025 16:34:44 +0100 Subject: [PATCH 5/8] Add getVolume to the fixture, fix tests --- tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json | 3 +++ tests/smart/modules/test_speaker.py | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json index cc3b3331a..c321488c1 100644 --- a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json +++ b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json @@ -187,6 +187,9 @@ "name": "2", "version": 1 }, + "getVolume": { + "volume": 84 + }, "getDoNotDisturb": { "do_not_disturb": true, "e_min": 480, diff --git a/tests/smart/modules/test_speaker.py b/tests/smart/modules/test_speaker.py index 715637f19..8f5d6494a 100644 --- a/tests/smart/modules/test_speaker.py +++ b/tests/smart/modules/test_speaker.py @@ -37,16 +37,16 @@ async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: ty async def test_set_volume(dev: SmartDevice, mocker: MockerFixture): """Test speaker settings.""" speaker = next(get_parent_and_child_modules(dev, Module.Speaker)) + assert speaker is not None + call = mocker.spy(speaker, "call") volume = speaker._device.features["volume"] - assert speaker.volume == volume.volume + assert speaker.volume == volume.value new_volume = 15 await speaker.set_volume(new_volume) - await dev.update() - call.assert_called_with("setVolume", {"volume": new_volume}) await dev.update() From 1af0aa2d75a8c6483c509b33f7193e5747187cb5 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 14 Jan 2025 16:35:32 +0100 Subject: [PATCH 6/8] Add getVolume --- devtools/helpers/smartrequests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 3cc82aa8c..ffaa73fb6 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -449,6 +449,7 @@ def get_component_requests(component_id, ver_code): "speaker": [ SmartRequest.get_raw_request("getSupportVoiceLanguage"), SmartRequest.get_raw_request("getCurrentVoiceLanguage"), + SmartRequest.get_raw_request("getVolume"), ], "map": [ SmartRequest.get_raw_request("getMapInfo"), From 350fe198a05de2537244e8054e66a796682fd6e5 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 14 Jan 2025 17:09:48 +0100 Subject: [PATCH 7/8] Fix tests --- tests/fakeprotocol_smart.py | 3 +++ tests/smart/modules/test_speaker.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index bebe68e75..393b5f318 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -637,6 +637,9 @@ async def _send_request(self, request_dict: dict): return self._set_on_off_gradually_info(info, params) elif method == "set_child_protection": return self._update_sysinfo_key(info, "child_protection", params["enable"]) + # Vacuum special actions + elif method in ["playSelectAudio"]: + return {"error_code": 0} elif method[:3] == "set": target_method = f"get{method[3:]}" # Some vacuum commands do not have a getter diff --git a/tests/smart/modules/test_speaker.py b/tests/smart/modules/test_speaker.py index 8f5d6494a..4416aece6 100644 --- a/tests/smart/modules/test_speaker.py +++ b/tests/smart/modules/test_speaker.py @@ -60,4 +60,6 @@ async def test_locate(dev: SmartDevice, mocker: MockerFixture): speaker = next(get_parent_and_child_modules(dev, Module.Speaker)) call = mocker.spy(speaker, "call") + await speaker.locate() + call.assert_called_with("playSelectAudio", {"audio_type": "seek_me"}) From 02ba52d04a3697382bf304e49e655bb875a283c0 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 14 Jan 2025 17:17:29 +0100 Subject: [PATCH 8/8] Add test for invalid volume --- tests/smart/modules/test_speaker.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/smart/modules/test_speaker.py b/tests/smart/modules/test_speaker.py index 4416aece6..e11741da0 100644 --- a/tests/smart/modules/test_speaker.py +++ b/tests/smart/modules/test_speaker.py @@ -53,6 +53,12 @@ async def test_set_volume(dev: SmartDevice, mocker: MockerFixture): assert speaker.volume == new_volume + with pytest.raises(ValueError, match="Volume must be between 0 and 100"): + await speaker.set_volume(-10) + + with pytest.raises(ValueError, match="Volume must be between 0 and 100"): + await speaker.set_volume(110) + @speaker async def test_locate(dev: SmartDevice, mocker: MockerFixture):