Merge remote-tracking branch 'upstream/master' into feat/power_protection

This commit is contained in:
Steven B
2025-01-23 15:17:26 +00:00
302 changed files with 28083 additions and 3492 deletions

View File

@@ -6,16 +6,23 @@ from .autooff import AutoOff
from .batterysensor import BatterySensor
from .brightness import Brightness
from .childdevice import ChildDevice
from .childlock import ChildLock
from .childprotection import ChildProtection
from .childsetup import ChildSetup
from .clean import Clean
from .cleanrecords import CleanRecords
from .cloud import Cloud
from .color import Color
from .colortemperature import ColorTemperature
from .consumables import Consumables
from .contactsensor import ContactSensor
from .devicemodule import DeviceModule
from .dustbin import Dustbin
from .energy import Energy
from .fan import Fan
from .firmware import Firmware
from .frostprotection import FrostProtection
from .homekit import HomeKit
from .humiditysensor import HumiditySensor
from .led import Led
from .light import Light
@@ -23,9 +30,13 @@ from .lighteffect import LightEffect
from .lightpreset import LightPreset
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 .powerprotection import PowerProtection
from .reportmode import ReportMode
from .speaker import Speaker
from .temperaturecontrol import TemperatureControl
from .temperaturesensor import TemperatureSensor
from .thermostat import Thermostat
@@ -39,6 +50,8 @@ __all__ = [
"Energy",
"DeviceModule",
"ChildDevice",
"ChildLock",
"ChildSetup",
"BatterySensor",
"HumiditySensor",
"TemperatureSensor",
@@ -64,6 +77,15 @@ __all__ = [
"TriggerLogs",
"FrostProtection",
"Thermostat",
"Clean",
"Consumables",
"CleanRecords",
"SmartLightEffect",
"PowerProtection",
"OverheatProtection",
"Speaker",
"HomeKit",
"Matter",
"Dustbin",
"Mop",
]

View File

@@ -2,7 +2,11 @@
from __future__ import annotations
from typing import Annotated
from ...exceptions import KasaException
from ...feature import Feature
from ...module import FeatureAttribute
from ..smartmodule import SmartModule
@@ -14,18 +18,22 @@ class BatterySensor(SmartModule):
def _initialize_features(self) -> None:
"""Initialize features."""
self._add_feature(
Feature(
self._device,
"battery_low",
"Battery low",
container=self,
attribute_getter="battery_low",
icon="mdi:alert",
type=Feature.Type.BinarySensor,
category=Feature.Category.Debug,
if (
"at_low_battery" in self._device.sys_info
or "is_low" in self._device.sys_info
):
self._add_feature(
Feature(
self._device,
"battery_low",
"Battery low",
container=self,
attribute_getter="battery_low",
icon="mdi:alert",
type=Feature.Type.BinarySensor,
category=Feature.Category.Debug,
)
)
)
# Some devices, like T110 contact sensor do not report the battery percentage
if "battery_percentage" in self._device.sys_info:
@@ -48,11 +56,17 @@ class BatterySensor(SmartModule):
return {}
@property
def battery(self) -> int:
def battery(self) -> Annotated[int, FeatureAttribute()]:
"""Return battery level."""
return self._device.sys_info["battery_percentage"]
@property
def battery_low(self) -> bool:
def battery_low(self) -> Annotated[bool, FeatureAttribute()]:
"""Return True if battery is low."""
return self._device.sys_info["at_low_battery"]
is_low = self._device.sys_info.get(
"at_low_battery", self._device.sys_info.get("is_low")
)
if is_low is None:
raise KasaException("Device does not report battery low status")
return is_low

View File

@@ -38,6 +38,7 @@ Plug 3: False
True
"""
from ...device_type import DeviceType
from ..smartmodule import SmartModule
@@ -46,3 +47,10 @@ class ChildDevice(SmartModule):
REQUIRED_COMPONENT = "child_device"
QUERY_GETTER_NAME = "get_child_device_list"
def query(self) -> dict:
"""Query to execute during the update cycle."""
q = super().query()
if self._device.device_type is DeviceType.Hub:
q["get_child_device_component_list"] = None
return q

View File

@@ -0,0 +1,37 @@
"""Child lock module."""
from __future__ import annotations
from ...feature import Feature
from ..smartmodule import SmartModule
class ChildLock(SmartModule):
"""Implementation for child lock."""
REQUIRED_COMPONENT = "button_and_led"
QUERY_GETTER_NAME = "getChildLockInfo"
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
device=self._device,
id="child_lock",
name="Child lock",
container=self,
attribute_getter="enabled",
attribute_setter="set_enabled",
type=Feature.Type.Switch,
category=Feature.Category.Config,
)
)
@property
def enabled(self) -> bool:
"""Return True if child lock is enabled."""
return self.data["child_lock_status"]
async def set_enabled(self, enabled: bool) -> dict:
"""Set child lock."""
return await self.call("setChildLockInfo", {"child_lock_status": enabled})

View File

@@ -0,0 +1,87 @@
"""Implementation for child device setup.
This module allows pairing and disconnecting child devices.
"""
from __future__ import annotations
import asyncio
import logging
from ...feature import Feature
from ..smartmodule import SmartModule
_LOGGER = logging.getLogger(__name__)
class ChildSetup(SmartModule):
"""Implementation for child device setup."""
REQUIRED_COMPONENT = "child_quick_setup"
QUERY_GETTER_NAME = "get_support_child_device_category"
def _initialize_features(self) -> None:
"""Initialize features."""
self._add_feature(
Feature(
self._device,
id="pair",
name="Pair",
container=self,
attribute_setter="pair",
category=Feature.Category.Config,
type=Feature.Type.Action,
)
)
async def get_supported_device_categories(self) -> list[dict]:
"""Get supported device categories."""
categories = await self.call("get_support_child_device_category")
return categories["get_support_child_device_category"]["device_category_list"]
async def pair(self, *, timeout: int = 10) -> list[dict]:
"""Scan for new devices and pair after discovering first new device."""
await self.call("begin_scanning_child_device")
_LOGGER.info("Waiting %s seconds for discovering new devices", timeout)
await asyncio.sleep(timeout)
detected = await self._get_detected_devices()
if not detected["child_device_list"]:
_LOGGER.warning(
"No devices found, make sure to activate pairing "
"mode on the devices to be added."
)
return []
_LOGGER.info(
"Discovery done, found %s devices: %s",
len(detected["child_device_list"]),
detected,
)
await self._add_devices(detected)
return detected["child_device_list"]
async def unpair(self, device_id: str) -> dict:
"""Remove device from the hub."""
_LOGGER.info("Going to unpair %s from %s", device_id, self)
payload = {"child_device_list": [{"device_id": device_id}]}
return await self.call("remove_child_device_list", payload)
async def _add_devices(self, devices: dict) -> dict:
"""Add devices based on get_detected_device response.
Pass the output from :ref:_get_detected_devices: as a parameter.
"""
res = await self.call("add_child_device_list", devices)
return res
async def _get_detected_devices(self) -> dict:
"""Return list of devices detected during scanning."""
param = {"scan_list": await self.get_supported_device_categories()}
res = await self.call("get_scan_child_device_list", param)
_LOGGER.debug("Scan status: %s", res)
return res["get_scan_child_device_list"]

427
kasa/smart/modules/clean.py Normal file
View File

@@ -0,0 +1,427 @@
"""Implementation of vacuum clean module."""
from __future__ import annotations
import logging
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__)
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 CarpetCleanMode(StrEnum):
"""Carpet clean mode."""
Normal = "normal"
Boost = "boost"
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_clean_mode",
name="Carpet clean mode",
container=self,
attribute_getter="carpet_clean_mode",
attribute_setter="set_carpet_clean_mode",
icon="mdi:rug",
choices_getter=lambda: list(CarpetCleanMode.__members__),
category=Feature.Category.Config,
type=Feature.Type.Choice,
)
)
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"},
}
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": 0,
"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_clean_mode(self) -> Annotated[str, FeatureAttribute()]:
"""Return carpet clean mode."""
return CarpetCleanMode(self.data["getCarpetClean"]["carpet_clean_prefer"]).name
async def set_carpet_clean_mode(
self, mode: str
) -> Annotated[dict, FeatureAttribute()]:
"""Set carpet clean mode."""
name_to_value = {x.name: x.value for x in CarpetCleanMode}
if mode not in name_to_value:
raise ValueError(
"Invalid carpet clean mode %s, available %s", mode, name_to_value
)
return await self.call(
"setCarpetClean", {"carpet_clean_prefer": name_to_value[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)

View File

@@ -0,0 +1,205 @@
"""Implementation of vacuum cleaning records."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, tzinfo
from typing import Annotated, cast
from mashumaro import DataClassDictMixin, field_options
from mashumaro.config import ADD_DIALECT_SUPPORT
from mashumaro.dialect import Dialect
from mashumaro.types import SerializationStrategy
from ...feature import Feature
from ...module import FeatureAttribute
from ..smartmodule import Module, SmartModule
from .clean import AreaUnit, Clean
_LOGGER = logging.getLogger(__name__)
@dataclass
class Record(DataClassDictMixin):
"""Historical cleanup result."""
class Config:
"""Configuration class."""
code_generation_options = [ADD_DIALECT_SUPPORT]
#: Total time cleaned (in minutes)
clean_time: timedelta = field(
metadata=field_options(deserialize=lambda x: timedelta(minutes=x))
)
#: Total area cleaned
clean_area: int
dust_collection: bool
timestamp: datetime
info_num: int | None = None
message: int | None = None
map_id: int | None = None
start_type: int | None = None
task_type: int | None = None
record_index: int | None = None
#: Error code from cleaning
error: int = field(default=0)
class _DateTimeSerializationStrategy(SerializationStrategy):
def __init__(self, tz: tzinfo) -> None:
self.tz = tz
def deserialize(self, value: float) -> datetime:
return datetime.fromtimestamp(value, self.tz)
def _get_tz_strategy(tz: tzinfo) -> type[Dialect]:
"""Return a timezone aware de-serialization strategy."""
class TimezoneDialect(Dialect):
serialization_strategy = {datetime: _DateTimeSerializationStrategy(tz)}
return TimezoneDialect
@dataclass
class Records(DataClassDictMixin):
"""Response payload for getCleanRecords."""
class Config:
"""Configuration class."""
code_generation_options = [ADD_DIALECT_SUPPORT]
total_time: timedelta = field(
metadata=field_options(deserialize=lambda x: timedelta(minutes=x))
)
total_area: int
total_count: int = field(metadata=field_options(alias="total_number"))
records: list[Record] = field(metadata=field_options(alias="record_list"))
last_clean: Record = field(metadata=field_options(alias="lastest_day_record"))
@classmethod
def __pre_deserialize__(cls, d: dict) -> dict:
if ldr := d.get("lastest_day_record"):
d["lastest_day_record"] = {
"timestamp": ldr[0],
"clean_time": ldr[1],
"clean_area": ldr[2],
"dust_collection": ldr[3],
}
return d
class CleanRecords(SmartModule):
"""Implementation of vacuum cleaning records."""
REQUIRED_COMPONENT = "clean_percent"
_parsed_data: Records
async def _post_update_hook(self) -> None:
"""Cache parsed data after an update."""
self._parsed_data = Records.from_dict(
self.data, dialect=_get_tz_strategy(self._device.timezone)
)
def _initialize_features(self) -> None:
"""Initialize features."""
for type_ in ["total", "last"]:
self._add_feature(
Feature(
self._device,
id=f"{type_}_clean_area",
name=f"{type_.capitalize()} area cleaned",
container=self,
attribute_getter=f"{type_}_clean_area",
unit_getter="area_unit",
category=Feature.Category.Debug,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
self._device,
id=f"{type_}_clean_time",
name=f"{type_.capitalize()} time cleaned",
container=self,
attribute_getter=f"{type_}_clean_time",
category=Feature.Category.Debug,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
self._device,
id="total_clean_count",
name="Total clean count",
container=self,
attribute_getter="total_clean_count",
category=Feature.Category.Debug,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
self._device,
id="last_clean_timestamp",
name="Last clean timestamp",
container=self,
attribute_getter="last_clean_timestamp",
category=Feature.Category.Debug,
type=Feature.Type.Sensor,
)
)
def query(self) -> dict:
"""Query to execute during the update cycle."""
return {
"getCleanRecords": {},
}
@property
def total_clean_area(self) -> Annotated[int, FeatureAttribute()]:
"""Return total cleaning area."""
return self._parsed_data.total_area
@property
def total_clean_time(self) -> timedelta:
"""Return total cleaning time."""
return self._parsed_data.total_time
@property
def total_clean_count(self) -> int:
"""Return total clean count."""
return self._parsed_data.total_count
@property
def last_clean_area(self) -> Annotated[int, FeatureAttribute()]:
"""Return latest cleaning area."""
return self._parsed_data.last_clean.clean_area
@property
def last_clean_time(self) -> timedelta:
"""Return total cleaning time."""
return self._parsed_data.last_clean.clean_time
@property
def last_clean_timestamp(self) -> datetime:
"""Return latest cleaning timestamp."""
return self._parsed_data.last_clean.timestamp
@property
def area_unit(self) -> AreaUnit:
"""Return area unit."""
clean = cast(Clean, self._device.modules[Module.Clean])
return clean.area_unit
@property
def parsed_data(self) -> Records:
"""Return parsed records data."""
return self._parsed_data

View File

@@ -0,0 +1,170 @@
"""Implementation of vacuum consumables."""
from __future__ import annotations
import logging
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import timedelta
from ...feature import Feature
from ..smartmodule import SmartModule
_LOGGER = logging.getLogger(__name__)
@dataclass
class _ConsumableMeta:
"""Consumable meta container."""
#: Name of the consumable.
name: str
#: Internal id of the consumable
id: str
#: Data key in the device reported data
data_key: str
#: Lifetime
lifetime: timedelta
@dataclass
class Consumable:
"""Consumable container."""
#: Name of the consumable.
name: str
#: Id of the consumable
id: str
#: Lifetime
lifetime: timedelta
#: Used
used: timedelta
#: Remaining
remaining: timedelta
#: Device data key
_data_key: str
CONSUMABLE_METAS = [
_ConsumableMeta(
"Main brush",
id="main_brush",
data_key="roll_brush_time",
lifetime=timedelta(hours=400),
),
_ConsumableMeta(
"Side brush",
id="side_brush",
data_key="edge_brush_time",
lifetime=timedelta(hours=200),
),
_ConsumableMeta(
"Filter",
id="filter",
data_key="filter_time",
lifetime=timedelta(hours=200),
),
_ConsumableMeta(
"Sensor",
id="sensor",
data_key="sensor_time",
lifetime=timedelta(hours=30),
),
_ConsumableMeta(
"Charging contacts",
id="charging_contacts",
data_key="charge_contact_time",
lifetime=timedelta(hours=30),
),
# Unknown keys: main_brush_lid_time, rag_time
]
class Consumables(SmartModule):
"""Implementation of vacuum consumables."""
REQUIRED_COMPONENT = "consumables"
QUERY_GETTER_NAME = "getConsumablesInfo"
_consumables: dict[str, Consumable] = {}
def _initialize_features(self) -> None:
"""Initialize features."""
for c_meta in CONSUMABLE_METAS:
if c_meta.data_key not in self.data:
continue
self._add_feature(
Feature(
self._device,
id=f"{c_meta.id}_used",
name=f"{c_meta.name} used",
container=self,
attribute_getter=lambda _, c_id=c_meta.id: self._consumables[
c_id
].used,
category=Feature.Category.Debug,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
self._device,
id=f"{c_meta.id}_remaining",
name=f"{c_meta.name} remaining",
container=self,
attribute_getter=lambda _, c_id=c_meta.id: self._consumables[
c_id
].remaining,
category=Feature.Category.Info,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
self._device,
id=f"{c_meta.id}_reset",
name=f"Reset {c_meta.name.lower()} consumable",
container=self,
attribute_setter=lambda c_id=c_meta.id: self.reset_consumable(c_id),
category=Feature.Category.Debug,
type=Feature.Type.Action,
)
)
async def _post_update_hook(self) -> None:
"""Update the consumables."""
if not self._consumables:
for consumable_meta in CONSUMABLE_METAS:
if consumable_meta.data_key not in self.data:
continue
used = timedelta(minutes=self.data[consumable_meta.data_key])
consumable = Consumable(
id=consumable_meta.id,
name=consumable_meta.name,
lifetime=consumable_meta.lifetime,
used=used,
remaining=consumable_meta.lifetime - used,
_data_key=consumable_meta.data_key,
)
self._consumables[consumable_meta.id] = consumable
else:
for consumable in self._consumables.values():
consumable.used = timedelta(minutes=self.data[consumable._data_key])
consumable.remaining = consumable.lifetime - consumable.used
async def reset_consumable(self, consumable_id: str) -> dict:
"""Reset consumable stats."""
consumable_name = self._consumables[consumable_id]._data_key.removesuffix(
"_time"
)
return await self.call(
"resetConsumablesTime", {"reset_list": [consumable_name]}
)
@property
def consumables(self) -> Mapping[str, Consumable]:
"""Get list of consumables on the device."""
return self._consumables

View File

@@ -10,7 +10,7 @@ class ContactSensor(SmartModule):
"""Implementation of contact sensor module."""
REQUIRED_COMPONENT = None # we depend on availability of key
REQUIRED_KEY_ON_PARENT = "open"
SYSINFO_LOOKUP_KEYS = ["open"]
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""

View File

@@ -19,12 +19,15 @@ class DeviceModule(SmartModule):
def query(self) -> dict:
"""Query to execute during the update cycle."""
if self._device._is_hub_child:
# Child devices get their device info updated by the parent device.
return {}
query = {
"get_device_info": None,
}
# Device usage is not available on older firmware versions
# or child devices of hubs
if self.supported_version >= 2 and not self._device._is_hub_child:
if self.supported_version >= 2:
query["get_device_usage"] = None
return query

View File

@@ -0,0 +1,117 @@
"""Implementation of vacuum dustbin."""
from __future__ import annotations
import logging
from enum import IntEnum
from ...feature import Feature
from ..smartmodule import SmartModule
_LOGGER = logging.getLogger(__name__)
class Mode(IntEnum):
"""Dust collection modes."""
Smart = 0
Light = 1
Balanced = 2
Max = 3
class Dustbin(SmartModule):
"""Implementation of vacuum dustbin."""
REQUIRED_COMPONENT = "dust_bucket"
def _initialize_features(self) -> None:
"""Initialize features."""
self._add_feature(
Feature(
self._device,
id="dustbin_empty",
name="Empty dustbin",
container=self,
attribute_setter="start_emptying",
category=Feature.Category.Primary,
type=Feature.Action,
)
)
self._add_feature(
Feature(
self._device,
id="dustbin_autocollection_enabled",
name="Automatic emptying enabled",
container=self,
attribute_getter="auto_collection",
attribute_setter="set_auto_collection",
category=Feature.Category.Config,
type=Feature.Switch,
)
)
self._add_feature(
Feature(
self._device,
id="dustbin_mode",
name="Automatic emptying mode",
container=self,
attribute_getter="mode",
attribute_setter="set_mode",
icon="mdi:fan",
choices_getter=lambda: list(Mode.__members__),
category=Feature.Category.Config,
type=Feature.Type.Choice,
)
)
def query(self) -> dict:
"""Query to execute during the update cycle."""
return {
"getAutoDustCollection": {},
"getDustCollectionInfo": {},
}
async def start_emptying(self) -> dict:
"""Start emptying the bin."""
return await self.call(
"setSwitchDustCollection",
{
"switch_dust_collection": True,
},
)
@property
def _settings(self) -> dict:
"""Return auto-empty settings."""
return self.data["getDustCollectionInfo"]
@property
def mode(self) -> str:
"""Return auto-emptying mode."""
return Mode(self._settings["dust_collection_mode"]).name
async def set_mode(self, mode: str) -> dict:
"""Set auto-emptying mode."""
name_to_value = {x.name: x.value for x in Mode}
if mode not in name_to_value:
raise ValueError(
"Invalid auto/emptying mode speed %s, available %s", mode, name_to_value
)
settings = self._settings.copy()
settings["dust_collection_mode"] = name_to_value[mode]
return await self.call("setDustCollectionInfo", settings)
@property
def auto_collection(self) -> dict:
"""Return auto-emptying config."""
return self._settings["auto_dust_collection"]
async def set_auto_collection(self, on: bool) -> dict:
"""Toggle auto-emptying."""
settings = self._settings.copy()
settings["auto_dust_collection"] = on
return await self.call("setDustCollectionInfo", settings)

View File

@@ -2,10 +2,10 @@
from __future__ import annotations
from typing import NoReturn
from typing import Any, NoReturn
from ...emeterstatus import EmeterStatus
from ...exceptions import KasaException
from ...exceptions import DeviceError, KasaException
from ...interfaces.energy import Energy as EnergyInterface
from ..smartmodule import SmartModule, raise_if_update_error
@@ -15,12 +15,39 @@ class Energy(SmartModule, EnergyInterface):
REQUIRED_COMPONENT = "energy_monitoring"
_energy: dict[str, Any]
_current_consumption: float | None
async def _post_update_hook(self) -> None:
if "voltage_mv" in self.data.get("get_emeter_data", {}):
try:
data = self.data
except DeviceError as de:
self._energy = {}
self._current_consumption = None
raise de
# If version is 1 then data is get_energy_usage
self._energy = data.get("get_energy_usage", data)
if "voltage_mv" in data.get("get_emeter_data", {}):
self._supported = (
self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT
)
if (power := self._energy.get("current_power")) is not None or (
power := data.get("get_emeter_data", {}).get("power_mw")
) is not None:
self._current_consumption = power / 1_000
# Fallback if get_energy_usage does not provide current_power,
# which can happen on some newer devices (e.g. P304M).
# This may not be valid scenario as it pre-dates trying get_emeter_data
elif (
power := self.data.get("get_current_power", {}).get("current_power")
) is not None:
self._current_consumption = power
else:
self._current_consumption = None
def query(self) -> dict:
"""Query to execute during the update cycle."""
req = {
@@ -33,28 +60,21 @@ class Energy(SmartModule, EnergyInterface):
return req
@property
@raise_if_update_error
def current_consumption(self) -> float | None:
"""Current power in watts."""
if (power := self.energy.get("current_power")) is not None or (
power := self.data.get("get_emeter_data", {}).get("power_mw")
) is not None:
return power / 1_000
# Fallback if get_energy_usage does not provide current_power,
# which can happen on some newer devices (e.g. P304M).
elif (
power := self.data.get("get_current_power", {}).get("current_power")
) is not None:
return power
return None
def optional_response_keys(self) -> list[str]:
"""Return optional response keys for the module."""
if self.supported_version > 1:
return ["get_energy_usage"]
return []
@property
def current_consumption(self) -> float | None:
"""Current power in watts."""
return self._current_consumption
@property
@raise_if_update_error
def energy(self) -> dict:
"""Return get_energy_usage results."""
if en := self.data.get("get_energy_usage"):
return en
return self.data
return self._energy
def _get_status_from_energy(self, energy: dict) -> EmeterStatus:
return EmeterStatus(
@@ -83,16 +103,18 @@ class Energy(SmartModule, EnergyInterface):
return self._get_status_from_energy(res["get_energy_usage"])
@property
@raise_if_update_error
def consumption_this_month(self) -> float | None:
"""Get the emeter value for this month in kWh."""
return self.energy.get("month_energy", 0) / 1_000
if (month := self.energy.get("month_energy")) is not None:
return month / 1_000
return None
@property
@raise_if_update_error
def consumption_today(self) -> float | None:
"""Get the emeter value for today in kWh."""
return self.energy.get("today_energy", 0) / 1_000
if (today := self.energy.get("today_energy")) is not None:
return today / 1_000
return None
@property
@raise_if_update_error

View File

@@ -0,0 +1,32 @@
"""Implementation of homekit module."""
from __future__ import annotations
from ...feature import Feature
from ..smartmodule import SmartModule
class HomeKit(SmartModule):
"""Implementation of homekit module."""
QUERY_GETTER_NAME: str = "get_homekit_info"
REQUIRED_COMPONENT = "homekit"
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
id="homekit_setup_code",
name="Homekit setup code",
container=self,
attribute_getter=lambda x: x.info["mfi_setup_code"],
type=Feature.Type.Sensor,
category=Feature.Category.Debug,
)
)
@property
def info(self) -> dict[str, str]:
"""Homekit mfi setup info."""
return self.data

View File

@@ -7,7 +7,7 @@ from typing import Annotated
from ...exceptions import KasaException
from ...feature import Feature
from ...interfaces.light import HSV, ColorTempRange, LightState
from ...interfaces.light import HSV, LightState
from ...interfaces.light import Light as LightInterface
from ...module import FeatureAttribute, Module
from ..smartmodule import SmartModule
@@ -34,39 +34,13 @@ class Light(SmartModule, LightInterface):
"""Query to execute during the update cycle."""
return {}
@property
def is_color(self) -> bool:
"""Whether the bulb supports color changes."""
return Module.Color in self._device.modules
@property
def is_dimmable(self) -> bool:
"""Whether the bulb supports brightness changes."""
return Module.Brightness in self._device.modules
@property
def is_variable_color_temp(self) -> bool:
"""Whether the bulb supports color temperature changes."""
return Module.ColorTemperature in self._device.modules
@property
def valid_temperature_range(self) -> ColorTempRange:
"""Return the device-specific white temperature range (in Kelvin).
:return: White temperature range in Kelvin (minimum, maximum)
"""
if not self.is_variable_color_temp:
raise KasaException("Color temperature not supported")
return self._device.modules[Module.ColorTemperature].valid_temperature_range
@property
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
"""Return the current HSV state of the bulb.
:return: hue, saturation and value (degrees, %, %)
"""
if not self.is_color:
if Module.Color not in self._device.modules:
raise KasaException("Bulb does not support color.")
return self._device.modules[Module.Color].hsv
@@ -74,7 +48,7 @@ class Light(SmartModule, LightInterface):
@property
def color_temp(self) -> Annotated[int, FeatureAttribute()]:
"""Whether the bulb supports color temperature changes."""
if not self.is_variable_color_temp:
if Module.ColorTemperature not in self._device.modules:
raise KasaException("Bulb does not support colortemp.")
return self._device.modules[Module.ColorTemperature].color_temp
@@ -82,7 +56,7 @@ class Light(SmartModule, LightInterface):
@property
def brightness(self) -> Annotated[int, FeatureAttribute()]:
"""Return the current brightness in percentage."""
if not self.is_dimmable: # pragma: no cover
if Module.Brightness not in self._device.modules: # pragma: no cover
raise KasaException("Bulb is not dimmable.")
return self._device.modules[Module.Brightness].brightness
@@ -104,7 +78,7 @@ class Light(SmartModule, LightInterface):
:param int value: value between 1 and 100
:param int transition: transition in milliseconds.
"""
if not self.is_color:
if Module.Color not in self._device.modules:
raise KasaException("Bulb does not support color.")
return await self._device.modules[Module.Color].set_hsv(hue, saturation, value)
@@ -119,7 +93,7 @@ class Light(SmartModule, LightInterface):
:param int temp: The new color temperature, in Kelvin
:param int transition: transition in milliseconds.
"""
if not self.is_variable_color_temp:
if Module.ColorTemperature not in self._device.modules:
raise KasaException("Bulb does not support colortemp.")
return await self._device.modules[Module.ColorTemperature].set_color_temp(
temp, brightness=brightness
@@ -135,16 +109,11 @@ class Light(SmartModule, LightInterface):
:param int brightness: brightness in percent
:param int transition: transition in milliseconds.
"""
if not self.is_dimmable: # pragma: no cover
if Module.Brightness not in self._device.modules: # pragma: no cover
raise KasaException("Bulb is not dimmable.")
return await self._device.modules[Module.Brightness].set_brightness(brightness)
@property
def has_effects(self) -> bool:
"""Return True if the device supports effects."""
return Module.LightEffect in self._device.modules
async def set_state(self, state: LightState) -> dict:
"""Set the light state."""
state_dict = asdict(state)
@@ -167,16 +136,17 @@ class Light(SmartModule, LightInterface):
return self._light_state
async def _post_update_hook(self) -> None:
if self._device.is_on is False:
device = self._device
if device.is_on is False:
state = LightState(light_on=False)
else:
state = LightState(light_on=True)
if self.is_dimmable:
if Module.Brightness in device.modules:
state.brightness = self.brightness
if self.is_color:
if Module.Color in device.modules:
hsv = self.hsv
state.hue = hsv.hue
state.saturation = hsv.saturation
if self.is_variable_color_temp:
if Module.ColorTemperature in device.modules:
state.color_temp = self.color_temp
self._light_state = state

View File

@@ -96,13 +96,18 @@ class LightPreset(SmartModule, LightPresetInterface):
"""Return current preset name."""
light = self._device.modules[SmartModule.Light]
brightness = light.brightness
color_temp = light.color_temp if light.is_variable_color_temp else None
h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None)
color_temp = light.color_temp if light.has_feature("color_temp") else None
h, s = (
(light.hsv.hue, light.hsv.saturation)
if light.has_feature("hsv")
else (None, None)
)
for preset_name, preset in self._presets.items():
if (
preset.brightness == brightness
and (
preset.color_temp == color_temp or not light.is_variable_color_temp
preset.color_temp == color_temp
or not light.has_feature("color_temp")
)
and preset.hue == h
and preset.saturation == s
@@ -117,7 +122,7 @@ class LightPreset(SmartModule, LightPresetInterface):
"""Set a light preset for the device."""
light = self._device.modules[SmartModule.Light]
if preset_name == self.PRESET_NOT_SET:
if light.is_color:
if light.has_feature("hsv"):
preset = LightState(hue=0, saturation=0, brightness=100)
else:
preset = LightState(brightness=100)

View File

@@ -0,0 +1,43 @@
"""Implementation of matter module."""
from __future__ import annotations
from ...feature import Feature
from ..smartmodule import SmartModule
class Matter(SmartModule):
"""Implementation of matter module."""
QUERY_GETTER_NAME: str = "get_matter_setup_info"
REQUIRED_COMPONENT = "matter"
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
id="matter_setup_code",
name="Matter setup code",
container=self,
attribute_getter=lambda x: x.info["setup_code"],
type=Feature.Type.Sensor,
category=Feature.Category.Debug,
)
)
self._add_feature(
Feature(
self._device,
id="matter_setup_payload",
name="Matter setup payload",
container=self,
attribute_getter=lambda x: x.info["setup_payload"],
type=Feature.Type.Sensor,
category=Feature.Category.Debug,
)
)
@property
def info(self) -> dict[str, str]:
"""Matter setup info."""
return self.data

90
kasa/smart/modules/mop.py Normal file
View File

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

View File

@@ -0,0 +1,41 @@
"""Overheat module."""
from __future__ import annotations
from ...feature import Feature
from ..smartmodule import SmartModule
class OverheatProtection(SmartModule):
"""Implementation for overheat_protection."""
SYSINFO_LOOKUP_KEYS = ["overheated", "overheat_status"]
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
container=self,
id="overheated",
name="Overheated",
attribute_getter="overheated",
icon="mdi:heat-wave",
type=Feature.Type.BinarySensor,
category=Feature.Category.Info,
)
)
@property
def overheated(self) -> bool:
"""Return True if device reports overheating."""
if (value := self._device.sys_info.get("overheat_status")) is not None:
# Value can be normal, cooldown, or overheated.
# We report all but normal as overheated.
return value != "normal"
return self._device.sys_info["overheated"]
def query(self) -> dict:
"""Query to execute during the update cycle."""
return {}

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