Add vacuum speaker controls (#1332)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run

Implements `speaker` and adds the following features:
* `volume`  to control the speaker volume
* `locate` to play "I'm here sound"
This commit is contained in:
Teemu R. 2025-01-14 17:48:34 +01:00 committed by GitHub
parent 3c98efb015
commit 2542516009
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 148 additions and 0 deletions

View File

@ -449,6 +449,7 @@ COMPONENT_REQUESTS = {
"speaker": [
SmartRequest.get_raw_request("getSupportVoiceLanguage"),
SmartRequest.get_raw_request("getCurrentVoiceLanguage"),
SmartRequest.get_raw_request("getVolume"),
],
"map": [
SmartRequest.get_raw_request("getMapInfo"),

View File

@ -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

View File

@ -30,6 +30,7 @@ from .matter import Matter
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
@ -71,6 +72,7 @@ __all__ = [
"Clean",
"SmartLightEffect",
"OverheatProtection",
"Speaker",
"HomeKit",
"Matter",
"Dustbin",

View File

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

View File

@ -637,6 +637,9 @@ class FakeSmartTransport(BaseTransport):
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

View File

@ -187,6 +187,9 @@
"name": "2",
"version": 1
},
"getVolume": {
"volume": 84
},
"getDoNotDisturb": {
"do_not_disturb": true,
"e_min": 480,

View File

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