Skip to content

Add vacuum speaker controls #1332

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions devtools/helpers/smartrequests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions kasa/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions kasa/smart/modules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
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
Expand Down Expand Up @@ -71,6 +72,7 @@
"Clean",
"SmartLightEffect",
"OverheatProtection",
"Speaker",
"HomeKit",
"Matter",
"Dustbin",
Expand Down
67 changes: 67 additions & 0 deletions kasa/smart/modules/speaker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Implementation of vacuum speaker."""

from __future__ import annotations

import logging
from typing import Annotated

from ...feature import Feature
from ...module import FeatureAttribute
from ..smartmodule import SmartModule

_LOGGER = logging.getLogger(__name__)


class Speaker(SmartModule):
"""Implementation of vacuum speaker."""

REQUIRED_COMPONENT = "speaker"

def _initialize_features(self) -> None:
"""Initialize features."""
self._add_feature(
Feature(
self._device,
id="locate",
name="Locate device",
container=self,
attribute_setter="locate",
category=Feature.Category.Primary,
type=Feature.Action,
)
)
self._add_feature(
Feature(
self._device,
id="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) -> Annotated[str, FeatureAttribute()]:
"""Return volume."""
return self.data["volume"]

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")

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"})
3 changes: 3 additions & 0 deletions tests/fakeprotocol_smart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@
"name": "2",
"version": 1
},
"getVolume": {
"volume": 84
},
"getDoNotDisturb": {
"do_not_disturb": true,
"e_min": 480,
Expand Down
71 changes: 71 additions & 0 deletions tests/smart/modules/test_speaker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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))
assert speaker is not None

call = mocker.spy(speaker, "call")

volume = speaker._device.features["volume"]
assert speaker.volume == volume.value

new_volume = 15
await speaker.set_volume(new_volume)

call.assert_called_with("setVolume", {"volume": new_volume})

await dev.update()

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):
"""Test the locate method."""
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"})
Loading