diff --git a/docs/tutorial.py b/docs/tutorial.py index 76094abb9..fddcc79a6 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -91,5 +91,5 @@ True >>> for feat in dev.features.values(): >>> print(f"{feat.name}: {feat.value}") -Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nReboot: \nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: \nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nOverheated: False\nDevice time: 2024-02-23 02:40:15+01:00 +Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nReboot: \nDevice time: 2024-02-23 02:40:15+01:00\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: \nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nOverheated: False """ diff --git a/kasa/cli/main.py b/kasa/cli/main.py index 9e0487dab..4f1eccda9 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -93,6 +93,7 @@ def _legacy_type_to_class(_type: str) -> Any: "hsv": "light", "temperature": "light", "effect": "light", + "vacuum": "vacuum", "hub": "hub", }, result_callback=json_formatter_cb, diff --git a/kasa/cli/vacuum.py b/kasa/cli/vacuum.py new file mode 100644 index 000000000..cb0aaad51 --- /dev/null +++ b/kasa/cli/vacuum.py @@ -0,0 +1,53 @@ +"""Module for cli vacuum commands..""" + +from __future__ import annotations + +import asyncclick as click + +from kasa import ( + Device, + Module, +) + +from .common import ( + error, + pass_dev_or_child, +) + + +@click.group(invoke_without_command=False) +@click.pass_context +async def vacuum(ctx: click.Context) -> None: + """Vacuum commands.""" + + +@vacuum.group(invoke_without_command=True, name="records") +@pass_dev_or_child +async def records_group(dev: Device) -> None: + """Access cleaning records.""" + if not (rec := dev.modules.get(Module.CleanRecords)): + error("This device does not support records.") + + data = rec.parsed_data + latest = data.last_clean + click.echo( + f"Totals: {rec.total_clean_area} {rec.area_unit} in {rec.total_clean_time} " + f"(cleaned {rec.total_clean_count} times)" + ) + click.echo(f"Last clean: {latest.clean_area} {rec.area_unit} @ {latest.clean_time}") + click.echo("Execute `kasa vacuum records list` to list all records.") + + +@records_group.command(name="list") +@pass_dev_or_child +async def records_list(dev: Device) -> None: + """List all cleaning records.""" + if not (rec := dev.modules.get(Module.CleanRecords)): + error("This device does not support records.") + + data = rec.parsed_data + for record in data.records: + click.echo( + f"* {record.timestamp}: cleaned {record.clean_area} {rec.area_unit}" + f" in {record.clean_time}" + ) diff --git a/kasa/feature.py b/kasa/feature.py index ad9187392..3c6beb0de 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -25,6 +25,7 @@ RSSI (rssi): -52 SSID (ssid): #MASKED_SSID# Reboot (reboot): +Device time (device_time): 2024-02-23 02:40:15+01:00 Brightness (brightness): 100 Cloud connection (cloud_connection): True HSV (hsv): HSV(hue=0, saturation=100, value=100) @@ -39,7 +40,6 @@ Smooth transition on (smooth_transition_on): 2 Smooth transition off (smooth_transition_off): 2 Overheated (overheated): False -Device time (device_time): 2024-02-23 02:40:15+01:00 To see whether a device supports a feature, check for the existence of it: @@ -299,8 +299,10 @@ def __repr__(self) -> str: if isinstance(value, Enum): value = repr(value) s = f"{self.name} ({self.id}): {value}" - if self.unit is not None: - s += f" {self.unit}" + if (unit := self.unit) is not None: + if isinstance(unit, Enum): + unit = repr(unit) + s += f" {unit}" if self.type == Feature.Type.Number: s += f" (range: {self.minimum_value}-{self.maximum_value})" diff --git a/kasa/module.py b/kasa/module.py index 8a7603317..f18dc6b12 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -168,6 +168,7 @@ class Module(ABC): Dustbin: Final[ModuleName[smart.Dustbin]] = ModuleName("Dustbin") Speaker: Final[ModuleName[smart.Speaker]] = ModuleName("Speaker") Mop: Final[ModuleName[smart.Mop]] = ModuleName("Mop") + CleanRecords: Final[ModuleName[smart.CleanRecords]] = ModuleName("CleanRecords") def __init__(self, device: Device, module: str) -> None: self._device = device diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index e0da95a7a..6717fcc34 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -10,6 +10,7 @@ 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 @@ -75,6 +76,7 @@ "FrostProtection", "Thermostat", "Clean", + "CleanRecords", "SmartLightEffect", "OverheatProtection", "Speaker", diff --git a/kasa/smart/modules/cleanrecords.py b/kasa/smart/modules/cleanrecords.py new file mode 100644 index 000000000..fdd0daeec --- /dev/null +++ b/kasa/smart/modules/cleanrecords.py @@ -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 diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 6f9ebd80e..ee86b0e2a 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -5,6 +5,7 @@ import base64 import logging import time +from collections import OrderedDict from collections.abc import Sequence from datetime import UTC, datetime, timedelta, tzinfo from typing import TYPE_CHECKING, Any, TypeAlias, cast @@ -66,7 +67,9 @@ def __init__( self._components_raw: ComponentsRaw | None = None self._components: dict[str, int] = {} self._state_information: dict[str, Any] = {} - self._modules: dict[str | ModuleName[Module], SmartModule] = {} + self._modules: OrderedDict[str | ModuleName[Module], SmartModule] = ( + OrderedDict() + ) self._parent: SmartDevice | None = None self._children: dict[str, SmartDevice] = {} self._last_update_time: float | None = None @@ -445,6 +448,11 @@ async def _initialize_modules(self) -> None: ): self._modules[Thermostat.__name__] = Thermostat(self, "thermostat") + # We move time to the beginning so other modules can access the + # time and timezone after update if required. e.g. cleanrecords + if Time.__name__ in self._modules: + self._modules.move_to_end(Time.__name__, last=False) + async def _initialize_features(self) -> None: """Initialize device features.""" self._add_feature( diff --git a/tests/cli/test_vacuum.py b/tests/cli/test_vacuum.py new file mode 100644 index 000000000..e5f3e68ea --- /dev/null +++ b/tests/cli/test_vacuum.py @@ -0,0 +1,61 @@ +from pytest_mock import MockerFixture + +from kasa import DeviceType, Module +from kasa.cli.vacuum import vacuum + +from ..device_fixtures import plug_iot +from ..device_fixtures import vacuum as vacuum_devices + + +@vacuum_devices +async def test_vacuum_records_group(dev, mocker: MockerFixture, runner): + """Test that vacuum records calls the expected methods.""" + rec = dev.modules.get(Module.CleanRecords) + assert rec + + res = await runner.invoke(vacuum, ["records"], obj=dev, catch_exceptions=False) + + latest = rec.parsed_data.last_clean + expected = ( + f"Totals: {rec.total_clean_area} {rec.area_unit} in {rec.total_clean_time} " + f"(cleaned {rec.total_clean_count} times)\n" + f"Last clean: {latest.clean_area} {rec.area_unit} @ {latest.clean_time}" + ) + assert expected in res.output + assert res.exit_code == 0 + + +@vacuum_devices +async def test_vacuum_records_list(dev, mocker: MockerFixture, runner): + """Test that vacuum records list calls the expected methods.""" + rec = dev.modules.get(Module.CleanRecords) + assert rec + + res = await runner.invoke( + vacuum, ["records", "list"], obj=dev, catch_exceptions=False + ) + + data = rec.parsed_data + for record in data.records: + expected = ( + f"* {record.timestamp}: cleaned {record.clean_area} {rec.area_unit}" + f" in {record.clean_time}" + ) + assert expected in res.output + assert res.exit_code == 0 + + +@plug_iot +async def test_non_vacuum(dev, mocker: MockerFixture, runner): + """Test that vacuum commands return an error if executed on a non-vacuum.""" + assert dev.device_type is not DeviceType.Vacuum + + res = await runner.invoke(vacuum, ["records"], obj=dev, catch_exceptions=False) + assert "This device does not support records" in res.output + assert res.exit_code != 0 + + res = await runner.invoke( + vacuum, ["records", "list"], obj=dev, catch_exceptions=False + ) + assert "This device does not support records" in res.output + assert res.exit_code != 0 diff --git a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json index c978f89c9..5a09c155f 100644 --- a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json +++ b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json @@ -180,16 +180,56 @@ }, "getCleanRecords": { "lastest_day_record": [ - 0, - 0, - 0, - 0 + 1736797545, + 25, + 16, + 1 + ], + "record_list": [ + { + "clean_area": 17, + "clean_time": 27, + "dust_collection": false, + "error": 0, + "info_num": 1, + "map_id": 1736598799, + "message": 1, + "record_index": 0, + "start_type": 1, + "task_type": 0, + "timestamp": 1736601522 + }, + { + "clean_area": 14, + "clean_time": 25, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1736598799, + "message": 0, + "record_index": 1, + "start_type": 1, + "task_type": 0, + "timestamp": 1736684961 + }, + { + "clean_area": 16, + "clean_time": 25, + "dust_collection": true, + "error": 0, + "info_num": 3, + "map_id": 1736598799, + "message": 0, + "record_index": 2, + "start_type": 1, + "task_type": 0, + "timestamp": 1736797545 + } ], - "record_list": [], - "record_list_num": 0, - "total_area": 0, - "total_number": 0, - "total_time": 0 + "record_list_num": 3, + "total_area": 47, + "total_number": 3, + "total_time": 77 }, "getCleanStatus": { "getCleanStatus": { diff --git a/tests/smart/modules/test_cleanrecords.py b/tests/smart/modules/test_cleanrecords.py new file mode 100644 index 000000000..cef692868 --- /dev/null +++ b/tests/smart/modules/test_cleanrecords.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +import pytest + +from kasa import Module +from kasa.smart import SmartDevice + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +cleanrecords = parametrize( + "has clean records", component_filter="clean_percent", protocol_filter={"SMART"} +) + + +@cleanrecords +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("total_clean_area", "total_clean_area", int), + ("total_clean_time", "total_clean_time", timedelta), + ("last_clean_area", "last_clean_area", int), + ("last_clean_time", "last_clean_time", timedelta), + ("total_clean_count", "total_clean_count", int), + ("last_clean_timestamp", "last_clean_timestamp", datetime), + ], +) +async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type): + """Test that features are registered and work as expected.""" + records = next(get_parent_and_child_modules(dev, Module.CleanRecords)) + assert records is not None + + prop = getattr(records, prop_name) + assert isinstance(prop, type) + + feat = records._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@cleanrecords +async def test_timezone(dev: SmartDevice): + """Test that timezone is added to timestamps.""" + clean_records = next(get_parent_and_child_modules(dev, Module.CleanRecords)) + assert clean_records is not None + + assert isinstance(clean_records.last_clean_timestamp, datetime) + assert clean_records.last_clean_timestamp.tzinfo + + # Check for zone info to ensure that this wasn't picking upthe default + # of utc before the time module is updated. + assert isinstance(clean_records.last_clean_timestamp.tzinfo, ZoneInfo) + + for record in clean_records.parsed_data.records: + assert isinstance(record.timestamp, datetime) + assert record.timestamp.tzinfo + assert isinstance(record.timestamp.tzinfo, ZoneInfo) diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index 8a540e7d4..bb6f13934 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -5,6 +5,7 @@ import copy import logging import time +from collections import OrderedDict from typing import TYPE_CHECKING, Any, cast from unittest.mock import patch @@ -100,7 +101,7 @@ async def test_initial_update(dev: SmartDevice, mocker: MockerFixture): # As the fixture data is already initialized, we reset the state for testing dev._components_raw = None dev._components = {} - dev._modules = {} + dev._modules = OrderedDict() dev._features = {} dev._children = {} dev._last_update = {}