Skip to content
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 kasa/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ class Module(ABC):
Clean: Final[ModuleName[smart.Clean]] = ModuleName("Clean")
Dustbin: Final[ModuleName[smart.Dustbin]] = ModuleName("Dustbin")
Speaker: Final[ModuleName[smart.Speaker]] = ModuleName("Speaker")
Mop: Final[ModuleName[smart.Mop]] = ModuleName("Mop")

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 @@ -27,6 +27,7 @@
from .lightstripeffect import LightStripEffect
from .lighttransition import LightTransition
from .matter import Matter
from .mop import Mop
from .motionsensor import MotionSensor
from .overheatprotection import OverheatProtection
from .reportmode import ReportMode
Expand Down Expand Up @@ -76,4 +77,5 @@
"HomeKit",
"Matter",
"Dustbin",
"Mop",
]
90 changes: 90 additions & 0 deletions kasa/smart/modules/mop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Implementation of vacuum mop."""

from __future__ import annotations

import logging
from enum import IntEnum
from typing import Annotated

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

_LOGGER = logging.getLogger(__name__)


class Waterlevel(IntEnum):
"""Water level for mopping."""

Disable = 0
Low = 1
Medium = 2
High = 3


class Mop(SmartModule):
"""Implementation of vacuum mop."""

REQUIRED_COMPONENT = "mop"

def _initialize_features(self) -> None:
"""Initialize features."""
self._add_feature(
Feature(
self._device,
id="mop_attached",
name="Mop attached",
container=self,
icon="mdi:square-rounded",
attribute_getter="mop_attached",
category=Feature.Category.Info,
type=Feature.BinarySensor,
)
)

self._add_feature(
Feature(
self._device,
id="mop_waterlevel",
name="Mop water level",
container=self,
attribute_getter="waterlevel",
attribute_setter="set_waterlevel",
icon="mdi:water",
choices_getter=lambda: list(Waterlevel.__members__),
category=Feature.Category.Config,
type=Feature.Type.Choice,
)
)

def query(self) -> dict:
"""Query to execute during the update cycle."""
return {
"getMopState": {},
"getCleanAttr": {"type": "global"},
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same query as used by clean module, but it probably shouldn't matter. If we ever add support for spot cleaning, we may need to consider how to handle that (as its query only differs by type).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will there always be a vaccum module? Could this data come from that module to avoid querying it twice?

Copy link
Member Author

@rytilahti rytilahti Jan 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, good point. I think there is no mop-only devices, but if there is, this will still keep working.

And as we construct the query using update(), there are no duplicates here for the time being, so this is fine.

We will become problems if we want to add zone/spot cleaning though, as they will use type: pose for their settings.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would indeed be a challenge as we'd overwrite the other module query as things currently stand.

Copy link
Member Author

@rytilahti rytilahti Jan 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we can tackle that whenever it comes an issue. But it is not necessary for this PR, so I think we can merge it as it is? Assuming the selected categories, identifier names and user visible strings are fine, that is.

}

@property
def mop_attached(self) -> bool:
"""Return True if mop is attached."""
return self.data["getMopState"]["mop_state"]

@property
def _settings(self) -> dict:
"""Return settings settings."""
return self.data["getCleanAttr"]

@property
def waterlevel(self) -> Annotated[str, FeatureAttribute()]:
"""Return water level."""
return Waterlevel(int(self._settings["cistern"])).name

async def set_waterlevel(self, mode: str) -> Annotated[dict, FeatureAttribute()]:
"""Set waterlevel mode."""
name_to_value = {x.name: x.value for x in Waterlevel}
if mode not in name_to_value:
raise ValueError("Invalid waterlevel %s, available %s", mode, name_to_value)

settings = self._settings.copy()
settings["cistern"] = name_to_value[mode]
return await self.call("setCleanAttr", settings)
58 changes: 58 additions & 0 deletions tests/smart/modules/test_mop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from __future__ import annotations

import pytest
from pytest_mock import MockerFixture

from kasa import Module
from kasa.smart import SmartDevice
from kasa.smart.modules.mop import Waterlevel

from ...device_fixtures import get_parent_and_child_modules, parametrize

mop = parametrize("has mop", component_filter="mop", protocol_filter={"SMART"})


@mop
@pytest.mark.parametrize(
("feature", "prop_name", "type"),
[
("mop_attached", "mop_attached", bool),
("mop_waterlevel", "waterlevel", str),
],
)
async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type):
"""Test that features are registered and work as expected."""
mod = next(get_parent_and_child_modules(dev, Module.Mop))
assert mod is not None

prop = getattr(mod, prop_name)
assert isinstance(prop, type)

feat = mod._device.features[feature]
assert feat.value == prop
assert isinstance(feat.value, type)


@mop
async def test_mop_waterlevel(dev: SmartDevice, mocker: MockerFixture):
"""Test dust mode."""
mop_module = next(get_parent_and_child_modules(dev, Module.Mop))
call = mocker.spy(mop_module, "call")

waterlevel = mop_module._device.features["mop_waterlevel"]
assert mop_module.waterlevel == waterlevel.value

new_level = Waterlevel.High
await mop_module.set_waterlevel(new_level.name)

params = mop_module._settings.copy()
params["cistern"] = new_level.value

call.assert_called_with("setCleanAttr", params)

await dev.update()

assert mop_module.waterlevel == new_level.name

with pytest.raises(ValueError, match="Invalid waterlevel"):
await mop_module.set_waterlevel("invalid")
Loading