Files
python-kasa/kasa/smart/modules/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

580 lines
18 KiB
Python

"""Implementation of vacuum clean module."""
from __future__ import annotations
import base64
import logging
from dataclasses import dataclass
from datetime import timedelta
from enum import IntEnum, StrEnum
from typing import Annotated, Literal
from ...feature import Feature
from ...module import FeatureAttribute
from ..smartmodule import SmartModule
_LOGGER = logging.getLogger(__name__)
# Only known value for start_type in setSwitchClean; required for
# targeted cleaning modes (Room) but not for StandardHome.
_START_TYPE_DEFAULT = 1
# Only known value for the type parameter in getMapData.
_MAP_DATA_TYPE_DEFAULT = 0
class Status(IntEnum):
"""Status of vacuum."""
Idle = 0
Cleaning = 1
Mapping = 2
GoingHome = 4
Charging = 5
Charged = 6
Paused = 7
Undocked = 8
Error = 100
UnknownInternal = -1000
class ErrorCode(IntEnum):
"""Error codes for vacuum."""
Ok = 0
SideBrushStuck = 2
MainBrushStuck = 3
WheelBlocked = 4
Trapped = 6
TrappedCliff = 7
DustBinRemoved = 14
UnableToMove = 15
LidarBlocked = 16
UnableToFindDock = 21
BatteryLow = 22
UnknownInternal = -1000
class FanSpeed(IntEnum):
"""Fan speed level."""
Quiet = 1
Standard = 2
Turbo = 3
Max = 4
Ultra = 5
class CleanMode(IntEnum):
"""Clean mode for ``setSwitchClean`` and ``getCleanStatus``.
Used as ``clean_mode`` in commands and ``clean_status`` in status responses.
"""
#: Clean all rooms with uniform settings.
StandardHome = 0
#: Clean all rooms with per-room settings and custom order.
AdvancedHome = 1
#: Clean a small area around the vacuum's current position.
Spot = 2
#: Clean selected rooms only.
Room = 3
#: Clean user-defined rectangular areas.
Zone = 4
#: Run a saved custom cleaning preset.
Custom = 5
@dataclass
class CleanAreaSettings:
"""Per-area cleaning settings shared by rooms and zones."""
#: Suction power level, or ``None`` when not configured per-area.
suction: FanSpeed | None = None
#: Water level for mopping.
cistern: int = 0
#: Number of cleaning passes.
clean_number: int = 0
@dataclass
class RoomInfo(CleanAreaSettings):
"""Information about a room on the vacuum's map."""
#: Room ID used in cleaning commands.
id: int = 0
#: Human-readable room name (base64-decoded from the device).
name: str | None = None
#: Color index used for map rendering.
color: int = 0
class AreaType(StrEnum):
"""Type of area entry in map data."""
#: A named room.
Room = "room"
#: A user-defined rectangular cleaning zone.
Area = "area"
#: A virtual wall boundary.
VirtualWall = "virtual_wall"
#: A no-go zone.
Forbid = "forbid"
#: A detected carpet region.
CarpetRectangle = "carpet_rectangle"
class AreaUnit(IntEnum):
"""Area unit."""
#: Square meter
Sqm = 0
#: Square feet
Sqft = 1
#: Taiwanese unit: https://en.wikipedia.org/wiki/Taiwanese_units_of_measurement#Area
Ping = 2
class Clean(SmartModule):
"""Implementation of vacuum clean module."""
REQUIRED_COMPONENT = "clean"
_error_code = ErrorCode.Ok
_logged_error_code_warnings: set | None = None
_logged_status_code_warnings: set
def _initialize_features(self) -> None:
"""Initialize features."""
self._add_feature(
Feature(
self._device,
id="vacuum_return_home",
name="Return home",
container=self,
attribute_setter="return_home",
category=Feature.Category.Primary,
type=Feature.Action,
)
)
self._add_feature(
Feature(
self._device,
id="vacuum_start",
name="Start cleaning",
container=self,
attribute_setter="start",
category=Feature.Category.Primary,
type=Feature.Action,
)
)
self._add_feature(
Feature(
self._device,
id="vacuum_pause",
name="Pause",
container=self,
attribute_setter="pause",
category=Feature.Category.Primary,
type=Feature.Action,
)
)
self._add_feature(
Feature(
self._device,
id="vacuum_status",
name="Vacuum status",
container=self,
attribute_getter="status",
category=Feature.Category.Primary,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
self._device,
id="vacuum_error",
name="Error",
container=self,
attribute_getter="error",
category=Feature.Category.Info,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
self._device,
id="battery_level",
name="Battery level",
container=self,
attribute_getter="battery",
icon="mdi:battery",
unit_getter=lambda: "%",
category=Feature.Category.Info,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
self._device,
id="vacuum_fan_speed",
name="Fan speed",
container=self,
attribute_getter="fan_speed_preset",
attribute_setter="set_fan_speed_preset",
icon="mdi:fan",
choices_getter=lambda: list(FanSpeed.__members__),
category=Feature.Category.Primary,
type=Feature.Type.Choice,
)
)
self._add_feature(
Feature(
self._device,
id="clean_count",
name="Clean count",
container=self,
attribute_getter="clean_count",
attribute_setter="set_clean_count",
range_getter=lambda: (1, 3),
category=Feature.Category.Config,
type=Feature.Type.Number,
)
)
self._add_feature(
Feature(
self._device,
id="carpet_boost",
name="Carpet boost",
container=self,
attribute_getter="carpet_boost",
attribute_setter="set_carpet_boost",
icon="mdi:rug",
category=Feature.Category.Config,
type=Feature.Type.Switch,
)
)
self._add_feature(
Feature(
self._device,
id="clean_area",
name="Cleaning area",
container=self,
attribute_getter="clean_area",
unit_getter="area_unit",
category=Feature.Category.Info,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
self._device,
id="clean_time",
name="Cleaning time",
container=self,
attribute_getter="clean_time",
category=Feature.Category.Info,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
self._device,
id="clean_progress",
name="Cleaning progress",
container=self,
attribute_getter="clean_progress",
unit_getter=lambda: "%",
category=Feature.Category.Info,
type=Feature.Type.Sensor,
)
)
async def _post_update_hook(self) -> None:
"""Set error code after update."""
if self._logged_error_code_warnings is None:
self._logged_error_code_warnings = set()
self._logged_status_code_warnings = set()
errors = self._vac_status.get("err_status")
if errors is None or not errors:
self._error_code = ErrorCode.Ok
return
if len(errors) > 1 and "multiple" not in self._logged_error_code_warnings:
self._logged_error_code_warnings.add("multiple")
_LOGGER.warning(
"Multiple error codes, using the first one only: %s", errors
)
error = errors.pop(0)
try:
self._error_code = ErrorCode(error)
except ValueError:
if error not in self._logged_error_code_warnings:
self._logged_error_code_warnings.add(error)
_LOGGER.warning(
"Unknown error code, please create an issue "
"describing the error: %s",
error,
)
self._error_code = ErrorCode.UnknownInternal
def query(self) -> dict:
"""Query to execute during the update cycle."""
return {
"getVacStatus": {},
"getCleanInfo": {},
"getCarpetClean": {},
"getAreaUnit": {},
"getBatteryInfo": {},
"getCleanStatus": {},
"getCleanAttr": {"type": "global"},
"getMapInfo": {},
}
async def start(self) -> dict:
"""Start cleaning."""
# If we are paused, do not restart cleaning
if self.status is Status.Paused:
return await self.resume()
return await self.call(
"setSwitchClean",
{
"clean_mode": CleanMode.StandardHome,
"clean_on": True,
"clean_order": True,
"force_clean": False,
},
)
async def pause(self) -> dict:
"""Pause cleaning."""
if self.status is Status.GoingHome:
return await self.set_return_home(False)
return await self.set_pause(True)
async def resume(self) -> dict:
"""Resume cleaning."""
return await self.set_pause(False)
async def set_pause(self, enabled: bool) -> dict:
"""Pause or resume cleaning."""
return await self.call("setRobotPause", {"pause": enabled})
async def return_home(self) -> dict:
"""Return home."""
return await self.set_return_home(True)
async def set_return_home(self, enabled: bool) -> dict:
"""Return home / pause returning."""
return await self.call("setSwitchCharge", {"switch_charge": enabled})
@property
def error(self) -> ErrorCode:
"""Return error."""
return self._error_code
@property
def fan_speed_preset(self) -> Annotated[str, FeatureAttribute()]:
"""Return fan speed preset."""
return FanSpeed(self._settings["suction"]).name
async def set_fan_speed_preset(
self, speed: str
) -> Annotated[dict, FeatureAttribute]:
"""Set fan speed preset."""
name_to_value = {x.name: x.value for x in FanSpeed}
if speed not in name_to_value:
raise ValueError("Invalid fan speed %s, available %s", speed, name_to_value)
return await self._change_setting("suction", name_to_value[speed])
async def _change_setting(
self, name: str, value: int, *, scope: Literal["global", "pose"] = "global"
) -> dict:
"""Change device setting."""
params = {
name: value,
"type": scope,
}
return await self.call("setCleanAttr", params)
@property
def battery(self) -> int:
"""Return battery level."""
return self.data["getBatteryInfo"]["battery_percentage"]
@property
def _vac_status(self) -> dict:
"""Return vac status container."""
return self.data["getVacStatus"]
@property
def _info(self) -> dict:
"""Return current cleaning info."""
return self.data["getCleanInfo"]
@property
def _settings(self) -> dict:
"""Return cleaning settings."""
return self.data["getCleanAttr"]
@property
def status(self) -> Status:
"""Return current status."""
if self._error_code is not ErrorCode.Ok:
return Status.Error
status_code = self._vac_status["status"]
try:
return Status(status_code)
except ValueError:
if status_code not in self._logged_status_code_warnings:
self._logged_status_code_warnings.add(status_code)
_LOGGER.warning(
"Got unknown status code: %s (%s)", status_code, self.data
)
return Status.UnknownInternal
@property
def carpet_boost(self) -> bool:
"""Return carpet boost mode."""
return self.data["getCarpetClean"]["carpet_clean_prefer"] == "boost"
async def set_carpet_boost(self, on: bool) -> dict:
"""Set carpet clean mode."""
mode = "boost" if on else "normal"
return await self.call("setCarpetClean", {"carpet_clean_prefer": mode})
@property
def area_unit(self) -> AreaUnit:
"""Return area unit."""
return AreaUnit(self.data["getAreaUnit"]["area_unit"])
@property
def clean_area(self) -> Annotated[int, FeatureAttribute()]:
"""Return currently cleaned area."""
return self._info["clean_area"]
@property
def clean_time(self) -> timedelta:
"""Return current cleaning time."""
return timedelta(minutes=self._info["clean_time"])
@property
def clean_progress(self) -> int:
"""Return amount of currently cleaned area."""
return self._info["clean_percent"]
@property
def clean_count(self) -> Annotated[int, FeatureAttribute()]:
"""Return number of times to clean."""
return self._settings["clean_number"]
async def set_clean_count(self, count: int) -> Annotated[dict, FeatureAttribute()]:
"""Set number of times to clean."""
return await self._change_setting("clean_number", count)
@property
def current_map_id(self) -> int:
"""Return the ID of the currently active map."""
return self.data["getMapInfo"]["current_map_id"]
@property
def current_map_name(self) -> str | None:
"""Return the name of the currently active map, or ``None``."""
map_info = self.data["getMapInfo"]
current_id = map_info["current_map_id"]
for m in map_info.get("map_list", []):
if m.get("map_id") == current_id:
if raw_name := m.get("map_name"):
try:
return base64.b64decode(raw_name).decode()
except Exception:
return raw_name
return None
return None
@property
def clean_type(self) -> CleanMode | None:
"""Return the active cleaning mode, or ``None`` if unavailable."""
cs = self.data.get("getCleanStatus")
if cs is None or "clean_status" not in cs:
return None
return CleanMode(cs["clean_status"])
async def clean_rooms(
self, room_ids: list[int], *, map_id: int | None = None
) -> dict:
"""Start cleaning specific rooms.
Per-room settings are not supported; the device uses the global
suction / cistern / clean_number values for room cleaning.
:param room_ids: List of room IDs to clean.
:param map_id: Map ID to clean on. Defaults to the current active map.
"""
if not room_ids:
raise ValueError("room_ids must not be empty")
if map_id is None:
map_id = self.current_map_id
return await self.call(
"setSwitchClean",
{
"clean_mode": CleanMode.Room,
"clean_on": True,
"clean_order": True,
"force_clean": False,
"map_id": map_id,
"room_list": list(room_ids),
"start_type": _START_TYPE_DEFAULT,
},
)
async def get_rooms(self, map_id: int | None = None) -> list[RoomInfo]:
"""Return the list of rooms for the given map.
Room names are base64-decoded when present.
:param map_id: Map ID to query. Defaults to the current active map.
"""
if map_id is None:
map_id = self.current_map_id
resp = await self.call(
"getMapData", {"map_id": map_id, "type": _MAP_DATA_TYPE_DEFAULT}
)
map_data = resp.get("getMapData", resp)
rooms: list[RoomInfo] = []
for area in map_data.get("area_list", []):
if area.get("type") != AreaType.Room:
_LOGGER.debug(
"Skipping non-room area: type=%s, id=%s",
area.get("type"),
area.get("id"),
)
continue
name = None
if raw_name := area.get("name"):
try:
name = base64.b64decode(raw_name).decode()
except Exception:
name = raw_name
suction_val = area.get("suction")
rooms.append(
RoomInfo(
id=area["id"],
name=name,
color=area.get("color", 0),
suction=FanSpeed(suction_val) if suction_val else None,
cistern=area.get("cistern", 0),
clean_number=area.get("clean_number", 0),
)
)
return rooms