Files
python-kasa/tests/smart/modules/test_clean.py
epg-pers aba457a729
Some checks failed
CI / Perform Lint Checks (3.14) (push) Has been cancelled
CodeQL Checks / Analyze (python) (push) Has been cancelled
CI / Python 3.11 on macos-latest (push) Has been cancelled
CI / Python 3.12 on macos-latest (push) Has been cancelled
CI / Python 3.13 on macos-latest (push) Has been cancelled
CI / Python 3.14 on macos-latest (push) Has been cancelled
CI / Python 3.11 on ubuntu-latest (push) Has been cancelled
CI / Python 3.12 on ubuntu-latest (push) Has been cancelled
CI / Python 3.13 on ubuntu-latest (push) Has been cancelled
CI / Python 3.14 on ubuntu-latest (push) Has been cancelled
CI / Python 3.11 on windows-latest (push) Has been cancelled
CI / Python 3.12 on windows-latest (push) Has been cancelled
CI / Python 3.13 on windows-latest (push) Has been cancelled
CI / Python 3.14 on windows-latest (push) Has been cancelled
Stale / stale (push) Has been cancelled
Add selective room cleaning to vacuum clean module (#1660)
Adds support for selective room cleaning to the existing `Clean` module.

Co-authored-by: davidefiocco <davidefiocco@users.noreply.github.com>
2026-07-11 15:02:21 +02:00

462 lines
13 KiB
Python

from __future__ import annotations
import logging
import pytest
from pytest_mock import MockerFixture
from kasa import Module
from kasa.smart import SmartDevice
from kasa.smart.modules.clean import CleanMode, ErrorCode, FanSpeed, RoomInfo, Status
from ...device_fixtures import get_parent_and_child_modules, parametrize
clean = parametrize("clean module", component_filter="clean", protocol_filter={"SMART"})
@clean
@pytest.mark.parametrize(
("feature", "prop_name", "type"),
[
("vacuum_status", "status", Status),
("vacuum_error", "error", ErrorCode),
("vacuum_fan_speed", "fan_speed_preset", str),
("carpet_boost", "carpet_boost", bool),
("battery_level", "battery", int),
],
)
async def test_features(
dev: SmartDevice, feature: str, prop_name: str, type: type
) -> None:
"""Test that features are registered and work as expected."""
clean = next(get_parent_and_child_modules(dev, Module.Clean))
assert clean is not None
prop = getattr(clean, prop_name)
assert isinstance(prop, type)
feat = clean._device.features[feature]
assert feat.value == prop
assert isinstance(feat.value, type)
@pytest.mark.parametrize(
("feature", "value", "method", "params"),
[
pytest.param(
"vacuum_start",
1,
"setSwitchClean",
{
"clean_mode": 0,
"clean_on": True,
"clean_order": True,
"force_clean": False,
},
id="vacuum_start",
),
pytest.param(
"vacuum_pause", 1, "setRobotPause", {"pause": True}, id="vacuum_pause"
),
pytest.param(
"vacuum_return_home",
1,
"setSwitchCharge",
{"switch_charge": True},
id="vacuum_return_home",
),
pytest.param(
"vacuum_fan_speed",
"Quiet",
"setCleanAttr",
{"suction": 1, "type": "global"},
id="vacuum_fan_speed",
),
pytest.param(
"carpet_boost",
True,
"setCarpetClean",
{"carpet_clean_prefer": "boost"},
id="carpet_boost",
),
pytest.param(
"clean_count",
2,
"setCleanAttr",
{"clean_number": 2, "type": "global"},
id="clean_count",
),
],
)
@clean
async def test_actions(
dev: SmartDevice,
mocker: MockerFixture,
feature: str,
value: str | int,
method: str,
params: dict,
) -> None:
"""Test the clean actions."""
clean = next(get_parent_and_child_modules(dev, Module.Clean))
call = mocker.spy(clean, "call")
await dev.features[feature].set_value(value)
call.assert_called_with(method, params)
@pytest.mark.parametrize(
("err_status", "error", "warning_msg"),
[
pytest.param([], ErrorCode.Ok, None, id="empty error"),
pytest.param([0], ErrorCode.Ok, None, id="no error"),
pytest.param([3], ErrorCode.MainBrushStuck, None, id="known error"),
pytest.param(
[123],
ErrorCode.UnknownInternal,
"Unknown error code, please create an issue describing the error: 123",
id="unknown error",
),
pytest.param(
[3, 4],
ErrorCode.MainBrushStuck,
"Multiple error codes, using the first one only: [3, 4]",
id="multi-error",
),
],
)
@clean
async def test_post_update_hook(
dev: SmartDevice,
err_status: list,
error: ErrorCode,
warning_msg: str | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that post update hook sets error states correctly."""
clean = next(get_parent_and_child_modules(dev, Module.Clean))
assert clean
caplog.set_level(logging.DEBUG)
# _post_update_hook will pop an item off the status list so create a copy.
err_status = [e for e in err_status]
clean.data["getVacStatus"]["err_status"] = err_status
await clean._post_update_hook()
assert clean._error_code is error
if error is not ErrorCode.Ok:
assert clean.status is Status.Error
if warning_msg:
assert warning_msg in caplog.text
# Check doesn't log twice
caplog.clear()
await clean._post_update_hook()
if warning_msg:
assert warning_msg not in caplog.text
@clean
async def test_resume(dev: SmartDevice, mocker: MockerFixture) -> None:
"""Test that start calls resume if the state is paused."""
clean = next(get_parent_and_child_modules(dev, Module.Clean))
call = mocker.spy(clean, "call")
resume = mocker.spy(clean, "resume")
mocker.patch.object(
type(clean),
"status",
new_callable=mocker.PropertyMock,
return_value=Status.Paused,
)
await clean.start()
call.assert_called_with("setRobotPause", {"pause": False})
resume.assert_awaited()
@clean
async def test_unknown_status(
dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that unknown status is logged."""
clean = next(get_parent_and_child_modules(dev, Module.Clean))
caplog.set_level(logging.DEBUG)
clean.data["getVacStatus"]["status"] = 123
assert clean.status is Status.UnknownInternal
assert "Got unknown status code: 123" in caplog.text
# Check only logs once
caplog.clear()
assert clean.status is Status.UnknownInternal
assert "Got unknown status code: 123" not in caplog.text
# Check logs again for other errors
caplog.clear()
clean.data["getVacStatus"]["status"] = 123456
assert clean.status is Status.UnknownInternal
assert "Got unknown status code: 123456" in caplog.text
@clean
@pytest.mark.parametrize(
("setting", "value", "exc", "exc_message"),
[
pytest.param(
"vacuum_fan_speed",
"invalid speed",
ValueError,
"Invalid fan speed",
id="vacuum_fan_speed",
),
],
)
async def test_invalid_settings(
dev: SmartDevice,
mocker: MockerFixture,
setting: str,
value: str,
exc: type[Exception],
exc_message: str,
) -> None:
"""Test invalid settings."""
clean = next(get_parent_and_child_modules(dev, Module.Clean))
# Not using feature.set_value() as it checks for valid values
setter_name = dev.features[setting].attribute_setter
assert isinstance(setter_name, str)
setter = getattr(clean, setter_name)
with pytest.raises(exc, match=exc_message):
await setter(value)
@clean
async def test_current_map_name(dev: SmartDevice, mocker: MockerFixture):
"""Test current_map_name decodes the base64 name of the active map."""
import base64
clean = next(get_parent_and_child_modules(dev, Module.Clean))
map_info = {
"current_map_id": 42,
"map_list": [
{"map_id": 42, "map_name": base64.b64encode(b"Upstairs").decode()},
{"map_id": 99, "map_name": base64.b64encode(b"Downstairs").decode()},
],
}
mocker.patch.object(
type(clean),
"data",
new_callable=mocker.PropertyMock,
return_value={**clean.data, "getMapInfo": map_info},
)
assert clean.current_map_name == "Upstairs"
@clean
async def test_current_map_name_missing(dev: SmartDevice, mocker: MockerFixture):
"""Test current_map_name returns None when map_list is empty."""
clean = next(get_parent_and_child_modules(dev, Module.Clean))
map_info = {"current_map_id": 0, "map_list": []}
mocker.patch.object(
type(clean),
"data",
new_callable=mocker.PropertyMock,
return_value={**clean.data, "getMapInfo": map_info},
)
assert clean.current_map_name is None
@clean
async def test_clean_rooms(dev: SmartDevice, mocker: MockerFixture):
"""Test clean_rooms sends the correct setSwitchClean payload."""
clean = next(get_parent_and_child_modules(dev, Module.Clean))
call = mocker.spy(clean, "call")
room_ids = [2, 3]
await clean.clean_rooms(room_ids)
call.assert_called_with(
"setSwitchClean",
{
"clean_mode": CleanMode.Room,
"clean_on": True,
"clean_order": True,
"force_clean": False,
"map_id": clean.current_map_id,
"room_list": room_ids,
"start_type": 1,
},
)
@clean
async def test_clean_rooms_explicit_map_id(dev: SmartDevice, mocker: MockerFixture):
"""Test clean_rooms uses the provided map_id when given."""
clean = next(get_parent_and_child_modules(dev, Module.Clean))
call = mocker.spy(clean, "call")
await clean.clean_rooms([5], map_id=12345)
call.assert_called_with(
"setSwitchClean",
{
"clean_mode": CleanMode.Room,
"clean_on": True,
"clean_order": True,
"force_clean": False,
"map_id": 12345,
"room_list": [5],
"start_type": 1,
},
)
@clean
async def test_clean_rooms_empty_raises(dev: SmartDevice):
"""Test clean_rooms raises ValueError when room_ids is empty."""
clean = next(get_parent_and_child_modules(dev, Module.Clean))
with pytest.raises(ValueError, match="room_ids must not be empty"):
await clean.clean_rooms([])
@clean
async def test_get_rooms(dev: SmartDevice, mocker: MockerFixture):
"""Test get_rooms returns RoomInfo objects with decoded names."""
import base64
clean = next(get_parent_and_child_modules(dev, Module.Clean))
map_data = {
"area_list": [
{
"id": 2,
"name": base64.b64encode(b"Kitchen").decode(),
"type": "room",
"color": 1,
"suction": 2,
"cistern": 1,
"clean_number": 1,
},
{
"id": 3,
"name": base64.b64encode(b"Living Room").decode(),
"type": "room",
"color": 2,
"suction": 3,
"cistern": 2,
"clean_number": 2,
},
{"id": 401, "type": "virtual_wall", "vertexs": []},
]
}
call_mock = mocker.patch.object(clean, "call", return_value=map_data)
rooms = await clean.get_rooms()
call_mock.assert_called_once_with(
"getMapData", {"map_id": clean.current_map_id, "type": 0}
)
assert len(rooms) == 2
assert all(isinstance(r, RoomInfo) for r in rooms)
assert rooms[0].id == 2
assert rooms[0].name == "Kitchen"
assert rooms[0].color == 1
assert rooms[0].suction is FanSpeed.Standard
assert rooms[1].id == 3
assert rooms[1].name == "Living Room"
assert rooms[1].suction is FanSpeed.Turbo
@clean
async def test_get_rooms_explicit_map_id(dev: SmartDevice, mocker: MockerFixture):
"""Test get_rooms uses the provided map_id when given."""
import base64
clean = next(get_parent_and_child_modules(dev, Module.Clean))
map_data = {
"area_list": [
{
"id": 1,
"name": base64.b64encode(b"Hall").decode(),
"type": "room",
},
]
}
call_mock = mocker.patch.object(clean, "call", return_value=map_data)
rooms = await clean.get_rooms(map_id=99999)
call_mock.assert_called_once_with("getMapData", {"map_id": 99999, "type": 0})
assert len(rooms) == 1
assert rooms[0].name == "Hall"
@clean
async def test_get_rooms_no_name(dev: SmartDevice, mocker: MockerFixture):
"""Test get_rooms handles rooms without names."""
clean = next(get_parent_and_child_modules(dev, Module.Clean))
map_data = {"area_list": [{"id": 5, "type": "room"}]}
mocker.patch.object(clean, "call", return_value=map_data)
rooms = await clean.get_rooms()
assert len(rooms) == 1
assert rooms[0].id == 5
assert rooms[0].name is None
assert rooms[0].suction is None
@clean
async def test_clean_type(dev: SmartDevice, mocker: MockerFixture):
"""Test clean_type returns the correct CleanMode."""
clean = next(get_parent_and_child_modules(dev, Module.Clean))
mocker.patch.object(
type(clean),
"data",
new_callable=mocker.PropertyMock,
return_value={**clean.data, "getCleanStatus": {"clean_status": 0}},
)
assert clean.clean_type is CleanMode.StandardHome
mocker.patch.object(
type(clean),
"data",
new_callable=mocker.PropertyMock,
return_value={**clean.data, "getCleanStatus": {"clean_status": 3}},
)
assert clean.clean_type is CleanMode.Room
@clean
async def test_clean_type_missing(dev: SmartDevice, mocker: MockerFixture):
"""Test clean_type returns None when clean_status is unavailable."""
clean = next(get_parent_and_child_modules(dev, Module.Clean))
data_without = {k: v for k, v in clean.data.items() if k != "getCleanStatus"}
mocker.patch.object(
type(clean),
"data",
new_callable=mocker.PropertyMock,
return_value=data_without,
)
assert clean.clean_type is None