From 2b2a24a65ce1a380313c592b3ad3539fbd4578c8 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sun, 2 Jun 2024 19:17:00 +0200 Subject: [PATCH 01/21] Initial support for cleaning records --- kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/vacuumrecords.py | 143 ++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 kasa/smart/modules/vacuumrecords.py diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 48378a575..53c53cba6 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -37,6 +37,7 @@ from .thermostat import Thermostat from .time import Time from .triggerlogs import TriggerLogs +from .vacuumrecords import VacuumRecords from .waterleaksensor import WaterleakSensor __all__ = [ @@ -71,6 +72,7 @@ "FrostProtection", "Thermostat", "Clean", + "VacuumRecords", "SmartLightEffect", "OverheatProtection", "Speaker", diff --git a/kasa/smart/modules/vacuumrecords.py b/kasa/smart/modules/vacuumrecords.py new file mode 100644 index 000000000..8ad211454 --- /dev/null +++ b/kasa/smart/modules/vacuumrecords.py @@ -0,0 +1,143 @@ +"""Implementation of vacuum records for experimental vacuum support.""" + +from __future__ import annotations + +import logging +from datetime import datetime + +from pydantic.v1 import BaseModel + +from ...feature import Feature +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +class LatestRecord(BaseModel): + """Stats from last clean. + + TODO: this is just a list-formatted Record, with only some fields being available. + """ + + timestamp: datetime + clean_time: int + clean_area: int + error: int # most likely + + +class Record(BaseModel): + """Historical cleanup result. + + Example: + { + "error": 1, + "clean_time": 19, + "clean_area": 11, + "dust_collection": false, + "timestamp": 1705156162, + "start_type": 1, + "task_type": 0, + "record_index": 9 + } + """ + + #: Error code from cleaning + error: int + #: Total time cleaned (in minutes) + clean_time: int + #: Total area cleaned (in sqm?) + clean_area: int + dust_collection: bool + timestamp: datetime + + start_type: int + task_type: int + record_index: int + + +class Records(BaseModel): + """Response payload for getCleanRecords. + + Example: + {"total_time": 185, + "total_area": 149, + "total_number": 10, + "record_list_num": 10, + "lastest_day_record": [ + 1705156162, + 19, + 11, + 1 + ], + "record_list": [ + , + ] + } + """ + + total_time: int + total_area: int + total_number: int + record_list_num: int + record_list: list[Record] + # TODO: conversion from list to dict/basemodel input TBD + # latest_clean: LatestRecord = Field(alias="lastest_day_record")) + + +class VacuumRecords(SmartModule): + """Implementation of vacuum records for experimental vacuum support.""" + + REQUIRED_COMPONENT = "consumables" + QUERY_GETTER_NAME = "getCleanRecords" + + def _initialize_features(self): + """Initialize features.""" + self._add_feature( + Feature( + self._device, + "total_clean_area", + "Total area cleaned", + container=self, + attribute_getter="total_clean_area", + unit="sqm", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + "total_clean_time", + "Total time cleaned", + container=self, + attribute_getter="total_clean_area", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + "total_clean_count", + "Total clean count", + container=self, + attribute_getter="total_clean_count", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + @property + def total_clean_area(self) -> int: + """Return total cleaning area.""" + return self.data["total_area"] + + @property + def total_clean_time(self) -> int: + """Return total cleaning time.""" + return self.data["total_time"] + + @property + def total_clean_count(self) -> int: + """Return total clean count.""" + return self.data["total_number"] From 15057714d98d738f8c2b490a70a6be053172a673 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sun, 2 Jun 2024 19:27:48 +0200 Subject: [PATCH 02/21] Fix incorrectly named attribute_getter --- kasa/smart/modules/vacuumrecords.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/smart/modules/vacuumrecords.py b/kasa/smart/modules/vacuumrecords.py index 8ad211454..dfb5c9685 100644 --- a/kasa/smart/modules/vacuumrecords.py +++ b/kasa/smart/modules/vacuumrecords.py @@ -110,7 +110,7 @@ def _initialize_features(self): "total_clean_time", "Total time cleaned", container=self, - attribute_getter="total_clean_area", + attribute_getter="total_clean_time", category=Feature.Category.Info, type=Feature.Type.Sensor, ) From bec0ae3104d8e35fad9f5b7174a5c73add7745d3 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 30 Nov 2024 16:54:47 +0100 Subject: [PATCH 03/21] Convert to mashumaro, use kwargs for features --- kasa/smart/modules/vacuumrecords.py | 32 ++++++++++++++++------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/kasa/smart/modules/vacuumrecords.py b/kasa/smart/modules/vacuumrecords.py index dfb5c9685..4f4be7c06 100644 --- a/kasa/smart/modules/vacuumrecords.py +++ b/kasa/smart/modules/vacuumrecords.py @@ -1,11 +1,12 @@ -"""Implementation of vacuum records for experimental vacuum support.""" +"""Implementation of vacuum cleaning records.""" from __future__ import annotations import logging +from dataclasses import dataclass from datetime import datetime -from pydantic.v1 import BaseModel +from mashumaro import DataClassDictMixin from ...feature import Feature from ..smartmodule import SmartModule @@ -13,7 +14,8 @@ _LOGGER = logging.getLogger(__name__) -class LatestRecord(BaseModel): +@dataclass +class LatestRecord(DataClassDictMixin): """Stats from last clean. TODO: this is just a list-formatted Record, with only some fields being available. @@ -25,7 +27,8 @@ class LatestRecord(BaseModel): error: int # most likely -class Record(BaseModel): +@dataclass +class Record(DataClassDictMixin): """Historical cleanup result. Example: @@ -55,7 +58,8 @@ class Record(BaseModel): record_index: int -class Records(BaseModel): +@dataclass +class Records(DataClassDictMixin): """Response payload for getCleanRecords. Example: @@ -85,21 +89,21 @@ class Records(BaseModel): class VacuumRecords(SmartModule): - """Implementation of vacuum records for experimental vacuum support.""" + """Implementation of vacuum cleaning records.""" REQUIRED_COMPONENT = "consumables" QUERY_GETTER_NAME = "getCleanRecords" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features.""" self._add_feature( Feature( self._device, - "total_clean_area", - "Total area cleaned", + id="total_clean_area", + name="Total area cleaned", container=self, attribute_getter="total_clean_area", - unit="sqm", + unit_getter=lambda: "sqm", category=Feature.Category.Info, type=Feature.Type.Sensor, ) @@ -107,8 +111,8 @@ def _initialize_features(self): self._add_feature( Feature( self._device, - "total_clean_time", - "Total time cleaned", + id="total_clean_time", + name="Total time cleaned", container=self, attribute_getter="total_clean_time", category=Feature.Category.Info, @@ -118,8 +122,8 @@ def _initialize_features(self): self._add_feature( Feature( self._device, - "total_clean_count", - "Total clean count", + id="total_clean_count", + name="Total clean count", container=self, attribute_getter="total_clean_count", category=Feature.Category.Debug, From 881a858a6eb21d74150670ed40218d029b3d3d8e Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sun, 12 Jan 2025 18:30:44 +0100 Subject: [PATCH 04/21] Add cli vacuum commands --- kasa/cli/main.py | 1 + kasa/cli/vacuum.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 kasa/cli/vacuum.py diff --git a/kasa/cli/main.py b/kasa/cli/main.py index debde60c4..ee6347f04 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", }, result_callback=json_formatter_cb, ) diff --git a/kasa/cli/vacuum.py b/kasa/cli/vacuum.py new file mode 100644 index 000000000..fd0e4610b --- /dev/null +++ b/kasa/cli/vacuum.py @@ -0,0 +1,50 @@ +"""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.VacuumRecords)): + error("This device does not support records.") + + data = rec.parsed_data + latest = data.latest_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.VacuumRecords)): + error("This device does not support records.") + + data = rec.parsed_data + for record in data.records: + click.echo(f"* {record}") From 11b56a3676ad8ce1457dc0ccb02bb604e127f3d4 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 14 Jan 2025 19:28:42 +0100 Subject: [PATCH 05/21] Add latest clean record and cleanup --- kasa/module.py | 1 + kasa/smart/modules/vacuumrecords.py | 123 ++++++++++++++++++++++------ 2 files changed, 100 insertions(+), 24 deletions(-) diff --git a/kasa/module.py b/kasa/module.py index c477dbedc..f01cc970e 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -166,6 +166,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") + VacuumRecords: Final[ModuleName[smart.VacuumRecords]] = ModuleName("VacuumRecords") def __init__(self, device: Device, module: str) -> None: self._device = device diff --git a/kasa/smart/modules/vacuumrecords.py b/kasa/smart/modules/vacuumrecords.py index 4f4be7c06..ddf3beb23 100644 --- a/kasa/smart/modules/vacuumrecords.py +++ b/kasa/smart/modules/vacuumrecords.py @@ -3,10 +3,12 @@ from __future__ import annotations import logging -from dataclasses import dataclass -from datetime import datetime +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from enum import IntEnum -from mashumaro import DataClassDictMixin +from mashumaro import DataClassDictMixin, field_options +from mashumaro.types import SerializationStrategy from ...feature import Feature from ..smartmodule import SmartModule @@ -14,6 +16,14 @@ _LOGGER = logging.getLogger(__name__) +class AreaUnit(IntEnum): + """Area unit.""" + + Sqm = 0 + Sqft = 1 + Ping = 2 + + @dataclass class LatestRecord(DataClassDictMixin): """Stats from last clean. @@ -44,18 +54,41 @@ class Record(DataClassDictMixin): } """ - #: Error code from cleaning - error: int #: Total time cleaned (in minutes) - clean_time: int - #: Total area cleaned (in sqm?) + clean_time: timedelta = field( + metadata=field_options(lambda x: timedelta(minutes=x)) + ) + #: Total area cleaned clean_area: int dust_collection: bool - timestamp: datetime + timestamp: datetime = field( + metadata=field_options( + deserialize=lambda x: datetime.fromtimestamp(x) if x else None + ) + ) + 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 - start_type: int - task_type: int - record_index: int + #: Error code from cleaning + error: int = field(default=0) + + +class LatestClean(SerializationStrategy): + """Strategy to deserialize list of maps into a dict.""" + + def deserialize(self, value: list[int]) -> Record: + """Deserialize list of maps into a dict.""" + data = { + "timestamp": value[0], + "clean_time": value[1], + "clean_area": value[2], + "dust_collection": value[3], + } + return Record.from_dict(data) @dataclass @@ -79,20 +112,29 @@ class Records(DataClassDictMixin): } """ - total_time: int + total_time: timedelta = field( + metadata=field_options(lambda x: timedelta(minutes=x)) + ) total_area: int - total_number: int - record_list_num: int - record_list: list[Record] - # TODO: conversion from list to dict/basemodel input TBD - # latest_clean: LatestRecord = Field(alias="lastest_day_record")) + total_count: int = field(metadata=field_options(alias="total_number")) + + records: list[Record] = field(metadata=field_options(alias="record_list")) + latest_clean: Record = field( + metadata=field_options( + serialization_strategy=LatestClean(), alias="lastest_day_record" + ) + ) class VacuumRecords(SmartModule): """Implementation of vacuum cleaning records.""" - REQUIRED_COMPONENT = "consumables" - QUERY_GETTER_NAME = "getCleanRecords" + 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["getCleanRecords"]) def _initialize_features(self) -> None: """Initialize features.""" @@ -103,7 +145,7 @@ def _initialize_features(self) -> None: name="Total area cleaned", container=self, attribute_getter="total_clean_area", - unit_getter=lambda: "sqm", + unit_getter="area_unit", category=Feature.Category.Info, type=Feature.Type.Sensor, ) @@ -115,6 +157,7 @@ def _initialize_features(self) -> None: name="Total time cleaned", container=self, attribute_getter="total_clean_time", + unit_getter=lambda: "min", # ha-friendly unit, see UnitOfTime.MINUTES category=Feature.Category.Info, type=Feature.Type.Sensor, ) @@ -131,17 +174,49 @@ def _initialize_features(self) -> None: ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return { + "getCleanRecords": {}, + "getAreaUnit": {}, + } + @property def total_clean_area(self) -> int: """Return total cleaning area.""" - return self.data["total_area"] + return self._parsed_data.total_area @property - def total_clean_time(self) -> int: + def total_clean_time(self) -> timedelta: """Return total cleaning time.""" - return self.data["total_time"] + return self._parsed_data.total_time @property def total_clean_count(self) -> int: """Return total clean count.""" - return self.data["total_number"] + return self._parsed_data.total_count + + @property + def latest_clean_area(self) -> int: + """Return latest cleaning area.""" + return self._parsed_data.latest_clean.clean_area + + @property + def latest_clean_time(self) -> timedelta: + """Return total cleaning time.""" + return self._parsed_data.latest_clean.clean_time + + @property + def latest_clean_timestamp(self) -> datetime: + """Return latest cleaning timestamp.""" + return self._parsed_data.latest_clean.timestamp + + @property + def area_unit(self) -> AreaUnit: + """Return area unit.""" + return AreaUnit(self.data["getAreaUnit"]["area_unit"]) + + @property + def parsed_data(self) -> Records: + """Return parsed records data.""" + return self._parsed_data From 4fc6b188689d267ba4e49c20844f741855feed49 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 14 Jan 2025 19:39:36 +0100 Subject: [PATCH 06/21] Expose last clean info as features --- kasa/smart/modules/vacuumrecords.py | 60 ++++++++++++++++------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/kasa/smart/modules/vacuumrecords.py b/kasa/smart/modules/vacuumrecords.py index ddf3beb23..dbd4588fe 100644 --- a/kasa/smart/modules/vacuumrecords.py +++ b/kasa/smart/modules/vacuumrecords.py @@ -26,10 +26,7 @@ class AreaUnit(IntEnum): @dataclass class LatestRecord(DataClassDictMixin): - """Stats from last clean. - - TODO: this is just a list-formatted Record, with only some fields being available. - """ + """Stats from last clean.""" timestamp: datetime clean_time: int @@ -138,37 +135,48 @@ async def _post_update_hook(self) -> None: def _initialize_features(self) -> None: """Initialize features.""" - self._add_feature( - Feature( - self._device, - id="total_clean_area", - name="Total area cleaned", - container=self, - attribute_getter="total_clean_area", - unit_getter="area_unit", - category=Feature.Category.Info, - type=Feature.Type.Sensor, + 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_time", - name="Total time cleaned", + id="total_clean_count", + name="Total clean count", container=self, - attribute_getter="total_clean_time", - unit_getter=lambda: "min", # ha-friendly unit, see UnitOfTime.MINUTES - category=Feature.Category.Info, + attribute_getter="total_clean_count", + category=Feature.Category.Debug, type=Feature.Type.Sensor, ) ) self._add_feature( Feature( self._device, - id="total_clean_count", - name="Total clean count", + id="last_clean_timestamp", + name="Last clean timestamp", container=self, - attribute_getter="total_clean_count", + attribute_getter="last_clean_timestamp", category=Feature.Category.Debug, type=Feature.Type.Sensor, ) @@ -197,17 +205,17 @@ def total_clean_count(self) -> int: return self._parsed_data.total_count @property - def latest_clean_area(self) -> int: + def last_clean_area(self) -> int: """Return latest cleaning area.""" return self._parsed_data.latest_clean.clean_area @property - def latest_clean_time(self) -> timedelta: + def last_clean_time(self) -> timedelta: """Return total cleaning time.""" return self._parsed_data.latest_clean.clean_time @property - def latest_clean_timestamp(self) -> datetime: + def last_clean_timestamp(self) -> datetime: """Return latest cleaning timestamp.""" return self._parsed_data.latest_clean.timestamp From 5567dee8a882fbfd44db82bca48aa5c6eee2ca49 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 14 Jan 2025 21:00:12 +0100 Subject: [PATCH 07/21] Update fixture to contain some data + add getAreaUnit response --- .../smart/RV20 Max Plus(EU)_1.0_1.0.7.json | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) 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 92b8e85b2..a52fc7cd6 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 @@ -171,20 +171,12 @@ "clean_area": 5, "clean_percent": 1, "clean_time": 5 - }, - "getCleanRecords": { - "lastest_day_record": [ - 0, - 0, - 0, - 0 - ], - "record_list": [], - "record_list_num": 0, - "total_area": 0, - "total_number": 0, - "total_time": 0 - }, + }, + "getCleanRecords": {"total_time": 77, "total_area": 47, "total_number": 3, "record_list_num": 3, "lastest_day_record": [1736797545, 25, 16, 1], "record_list": [{"map_id": 1736598799, "error": 0, "clean_time": 27, "clean_area": 17, +"dust_collection": false, "timestamp": 1736601522, "start_type": 1, "task_type": 0, "message": 1, "info_num": 1, "record_index": 0}, {"map_id": 1736598799, "error": 0, "clean_time": 25, "clean_area": 14, "dust_collection": false, "timestamp": +1736684961, "start_type": 1, "task_type": 0, "message": 0, "info_num": 0, "record_index": 1}, {"map_id": 1736598799, "error": 0, "clean_time": 25, "clean_area": 16, "dust_collection": true, "timestamp": 1736797545, "start_type": 1, "task_type": +0, "message": 0, "info_num": 3, "record_index": 2}]}, + "getCleanStatus": { "getCleanStatus": { "clean_status": 0, From 8c49bb50d52da1a4164d984c4998f3fc52aca19f Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 15 Jan 2025 00:47:36 +0100 Subject: [PATCH 08/21] Use areaunit from clean module --- kasa/smart/modules/vacuumrecords.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/kasa/smart/modules/vacuumrecords.py b/kasa/smart/modules/vacuumrecords.py index dbd4588fe..8609f7763 100644 --- a/kasa/smart/modules/vacuumrecords.py +++ b/kasa/smart/modules/vacuumrecords.py @@ -5,25 +5,18 @@ import logging from dataclasses import dataclass, field from datetime import datetime, timedelta -from enum import IntEnum +from typing import cast from mashumaro import DataClassDictMixin, field_options from mashumaro.types import SerializationStrategy from ...feature import Feature -from ..smartmodule import SmartModule +from ..smartmodule import Module, SmartModule +from .clean import AreaUnit, Clean _LOGGER = logging.getLogger(__name__) -class AreaUnit(IntEnum): - """Area unit.""" - - Sqm = 0 - Sqft = 1 - Ping = 2 - - @dataclass class LatestRecord(DataClassDictMixin): """Stats from last clean.""" @@ -131,7 +124,7 @@ class VacuumRecords(SmartModule): async def _post_update_hook(self) -> None: """Cache parsed data after an update.""" - self._parsed_data = Records.from_dict(self.data["getCleanRecords"]) + self._parsed_data = Records.from_dict(self.data) def _initialize_features(self) -> None: """Initialize features.""" @@ -186,7 +179,6 @@ def query(self) -> dict: """Query to execute during the update cycle.""" return { "getCleanRecords": {}, - "getAreaUnit": {}, } @property @@ -222,7 +214,8 @@ def last_clean_timestamp(self) -> datetime: @property def area_unit(self) -> AreaUnit: """Return area unit.""" - return AreaUnit(self.data["getAreaUnit"]["area_unit"]) + clean = cast(Clean, self._device._modules[Module.Clean]) + return clean.area_unit @property def parsed_data(self) -> Records: From 5747bbed8671621f69a4e0ca9962dd8f1c3940c2 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 15 Jan 2025 14:46:55 +0100 Subject: [PATCH 09/21] Cleanup and rename to vacuumrecords to cleanrecords --- kasa/cli/vacuum.py | 2 +- kasa/smart/modules/__init__.py | 4 +- .../{vacuumrecords.py => cleanrecords.py} | 58 +++---------------- 3 files changed, 12 insertions(+), 52 deletions(-) rename kasa/smart/modules/{vacuumrecords.py => cleanrecords.py} (81%) diff --git a/kasa/cli/vacuum.py b/kasa/cli/vacuum.py index fd0e4610b..b1ae38204 100644 --- a/kasa/cli/vacuum.py +++ b/kasa/cli/vacuum.py @@ -29,7 +29,7 @@ async def records_group(dev: Device) -> None: error("This device does not support records.") data = rec.parsed_data - latest = data.latest_clean + 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)" diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 53c53cba6..a8b892158 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -8,6 +8,7 @@ from .childdevice import ChildDevice from .childprotection import ChildProtection from .clean import Clean +from .cleanrecords import CleanRecords from .cloud import Cloud from .color import Color from .colortemperature import ColorTemperature @@ -37,7 +38,6 @@ from .thermostat import Thermostat from .time import Time from .triggerlogs import TriggerLogs -from .vacuumrecords import VacuumRecords from .waterleaksensor import WaterleakSensor __all__ = [ @@ -72,7 +72,7 @@ "FrostProtection", "Thermostat", "Clean", - "VacuumRecords", + "CleanRecords", "SmartLightEffect", "OverheatProtection", "Speaker", diff --git a/kasa/smart/modules/vacuumrecords.py b/kasa/smart/modules/cleanrecords.py similarity index 81% rename from kasa/smart/modules/vacuumrecords.py rename to kasa/smart/modules/cleanrecords.py index 8609f7763..28e8843f9 100644 --- a/kasa/smart/modules/vacuumrecords.py +++ b/kasa/smart/modules/cleanrecords.py @@ -17,32 +17,9 @@ _LOGGER = logging.getLogger(__name__) -@dataclass -class LatestRecord(DataClassDictMixin): - """Stats from last clean.""" - - timestamp: datetime - clean_time: int - clean_area: int - error: int # most likely - - @dataclass class Record(DataClassDictMixin): - """Historical cleanup result. - - Example: - { - "error": 1, - "clean_time": 19, - "clean_area": 11, - "dust_collection": false, - "timestamp": 1705156162, - "start_type": 1, - "task_type": 0, - "record_index": 9 - } - """ + """Historical cleanup result.""" #: Total time cleaned (in minutes) clean_time: timedelta = field( @@ -67,7 +44,7 @@ class Record(DataClassDictMixin): error: int = field(default=0) -class LatestClean(SerializationStrategy): +class LastCleanStrategy(SerializationStrategy): """Strategy to deserialize list of maps into a dict.""" def deserialize(self, value: list[int]) -> Record: @@ -83,24 +60,7 @@ def deserialize(self, value: list[int]) -> Record: @dataclass class Records(DataClassDictMixin): - """Response payload for getCleanRecords. - - Example: - {"total_time": 185, - "total_area": 149, - "total_number": 10, - "record_list_num": 10, - "lastest_day_record": [ - 1705156162, - 19, - 11, - 1 - ], - "record_list": [ - , - ] - } - """ + """Response payload for getCleanRecords.""" total_time: timedelta = field( metadata=field_options(lambda x: timedelta(minutes=x)) @@ -109,14 +69,14 @@ class Records(DataClassDictMixin): total_count: int = field(metadata=field_options(alias="total_number")) records: list[Record] = field(metadata=field_options(alias="record_list")) - latest_clean: Record = field( + last_clean: Record = field( metadata=field_options( - serialization_strategy=LatestClean(), alias="lastest_day_record" + serialization_strategy=LastCleanStrategy(), alias="lastest_day_record" ) ) -class VacuumRecords(SmartModule): +class CleanRecords(SmartModule): """Implementation of vacuum cleaning records.""" REQUIRED_COMPONENT = "clean_percent" @@ -199,17 +159,17 @@ def total_clean_count(self) -> int: @property def last_clean_area(self) -> int: """Return latest cleaning area.""" - return self._parsed_data.latest_clean.clean_area + return self._parsed_data.last_clean.clean_area @property def last_clean_time(self) -> timedelta: """Return total cleaning time.""" - return self._parsed_data.latest_clean.clean_time + return self._parsed_data.last_clean.clean_time @property def last_clean_timestamp(self) -> datetime: """Return latest cleaning timestamp.""" - return self._parsed_data.latest_clean.timestamp + return self._parsed_data.last_clean.timestamp @property def area_unit(self) -> AreaUnit: From c87574675cecd849def45e8811bf1e74e06bf408 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 15 Jan 2025 14:53:21 +0100 Subject: [PATCH 10/21] Add tests --- tests/smart/modules/test_cleanrecords.py | 39 ++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/smart/modules/test_cleanrecords.py diff --git a/tests/smart/modules/test_cleanrecords.py b/tests/smart/modules/test_cleanrecords.py new file mode 100644 index 000000000..f18c01e81 --- /dev/null +++ b/tests/smart/modules/test_cleanrecords.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from datetime import datetime, timedelta + +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) From 02f3df84cc6818b202c337829862dd7baa7744e3 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 15 Jan 2025 14:54:45 +0100 Subject: [PATCH 11/21] Add featureattribute annotations for area properties --- kasa/smart/modules/cleanrecords.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/kasa/smart/modules/cleanrecords.py b/kasa/smart/modules/cleanrecords.py index 28e8843f9..098fbe78b 100644 --- a/kasa/smart/modules/cleanrecords.py +++ b/kasa/smart/modules/cleanrecords.py @@ -5,12 +5,13 @@ import logging from dataclasses import dataclass, field from datetime import datetime, timedelta -from typing import cast +from typing import Annotated, cast from mashumaro import DataClassDictMixin, field_options from mashumaro.types import SerializationStrategy from ...feature import Feature +from ...module import FeatureAttribute from ..smartmodule import Module, SmartModule from .clean import AreaUnit, Clean @@ -142,7 +143,7 @@ def query(self) -> dict: } @property - def total_clean_area(self) -> int: + def total_clean_area(self) -> Annotated[int, FeatureAttribute()]: """Return total cleaning area.""" return self._parsed_data.total_area @@ -157,7 +158,7 @@ def total_clean_count(self) -> int: return self._parsed_data.total_count @property - def last_clean_area(self) -> int: + def last_clean_area(self) -> Annotated[int, FeatureAttribute()]: """Return latest cleaning area.""" return self._parsed_data.last_clean.clean_area From b7064949a1019b4ac01ebb5aeeb2b010bf38afd4 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 15 Jan 2025 15:01:56 +0100 Subject: [PATCH 12/21] Fix cli --- kasa/cli/vacuum.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/cli/vacuum.py b/kasa/cli/vacuum.py index b1ae38204..d4e20742f 100644 --- a/kasa/cli/vacuum.py +++ b/kasa/cli/vacuum.py @@ -25,7 +25,7 @@ async def vacuum(ctx: click.Context) -> None: @pass_dev_or_child async def records_group(dev: Device) -> None: """Access cleaning records.""" - if not (rec := dev.modules.get(Module.VacuumRecords)): + if not (rec := dev.modules.get(Module.CleanRecords)): error("This device does not support records.") data = rec.parsed_data @@ -42,7 +42,7 @@ async def records_group(dev: Device) -> None: @pass_dev_or_child async def records_list(dev: Device) -> None: """List all cleaning records.""" - if not (rec := dev.modules.get(Module.VacuumRecords)): + if not (rec := dev.modules.get(Module.CleanRecords)): error("This device does not support records.") data = rec.parsed_data From 2120ae3917b7d73795b4397dc128a89c55afc6ff Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 15 Jan 2025 15:05:58 +0100 Subject: [PATCH 13/21] repr()ify units --- kasa/feature.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index 456a3e631..4cd2dfc1d 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -298,8 +298,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})" From a01fa6202e43ff500bfb7eb8a9c73ceff59f83c6 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 15 Jan 2025 19:14:26 +0100 Subject: [PATCH 14/21] run pre-commit hooks --- .../smart/RV20 Max Plus(EU)_1.0_1.0.7.json | 60 +++++++++++++++++-- 1 file changed, 54 insertions(+), 6 deletions(-) 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 a52fc7cd6..927006abd 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 @@ -171,12 +171,60 @@ "clean_area": 5, "clean_percent": 1, "clean_time": 5 - }, - "getCleanRecords": {"total_time": 77, "total_area": 47, "total_number": 3, "record_list_num": 3, "lastest_day_record": [1736797545, 25, 16, 1], "record_list": [{"map_id": 1736598799, "error": 0, "clean_time": 27, "clean_area": 17, -"dust_collection": false, "timestamp": 1736601522, "start_type": 1, "task_type": 0, "message": 1, "info_num": 1, "record_index": 0}, {"map_id": 1736598799, "error": 0, "clean_time": 25, "clean_area": 14, "dust_collection": false, "timestamp": -1736684961, "start_type": 1, "task_type": 0, "message": 0, "info_num": 0, "record_index": 1}, {"map_id": 1736598799, "error": 0, "clean_time": 25, "clean_area": 16, "dust_collection": true, "timestamp": 1736797545, "start_type": 1, "task_type": -0, "message": 0, "info_num": 3, "record_index": 2}]}, - + }, + "getCleanRecords": { + "lastest_day_record": [ + 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_num": 3, + "total_area": 47, + "total_number": 3, + "total_time": 77 + }, "getCleanStatus": { "getCleanStatus": { "clean_status": 0, From 9e654b99923fc52abb70c87a94ea82e5f95abfe6 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 15 Jan 2025 19:18:57 +0100 Subject: [PATCH 15/21] Fix broken rebase --- kasa/module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/module.py b/kasa/module.py index f01cc970e..1ac8482c2 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -166,7 +166,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") - VacuumRecords: Final[ModuleName[smart.VacuumRecords]] = ModuleName("VacuumRecords") + CleanRecords: Final[ModuleName[smart.CleanRecords]] = ModuleName("CleanRecords") def __init__(self, device: Device, module: str) -> None: self._device = device From 79d8ad5d2b45a6dcbab25a431139a7190821b04e Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 16 Jan 2025 16:34:12 +0100 Subject: [PATCH 16/21] add timezone to timestamps, fix cleaning time deserialization --- kasa/smart/modules/cleanrecords.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/kasa/smart/modules/cleanrecords.py b/kasa/smart/modules/cleanrecords.py index 098fbe78b..3b8ecbded 100644 --- a/kasa/smart/modules/cleanrecords.py +++ b/kasa/smart/modules/cleanrecords.py @@ -24,7 +24,7 @@ class Record(DataClassDictMixin): #: Total time cleaned (in minutes) clean_time: timedelta = field( - metadata=field_options(lambda x: timedelta(minutes=x)) + metadata=field_options(deserialize=lambda x: timedelta(minutes=x)) ) #: Total area cleaned clean_area: int @@ -64,7 +64,7 @@ class Records(DataClassDictMixin): """Response payload for getCleanRecords.""" total_time: timedelta = field( - metadata=field_options(lambda x: timedelta(minutes=x)) + metadata=field_options(deserialize=lambda x: timedelta(minutes=x)) ) total_area: int total_count: int = field(metadata=field_options(alias="total_number")) @@ -175,10 +175,21 @@ def last_clean_timestamp(self) -> datetime: @property def area_unit(self) -> AreaUnit: """Return area unit.""" - clean = cast(Clean, self._device._modules[Module.Clean]) + clean = cast(Clean, self._device.modules[Module.Clean]) return clean.area_unit @property def parsed_data(self) -> Records: - """Return parsed records data.""" + """Return parsed records data. + + This will adjust the timezones before returning the data, as we do not + have the timezone information available when _post_update_hook is called. + """ + self._parsed_data.last_clean.timestamp = ( + self._parsed_data.last_clean.timestamp.astimezone(self._device.timezone) + ) + + for record in self._parsed_data.records: + record.timestamp = record.timestamp.astimezone(self._device.timezone) + return self._parsed_data From 3a71309961730ab3d3e8da67afb3464134738e83 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 16 Jan 2025 17:06:11 +0100 Subject: [PATCH 17/21] Fix last_clean_timestamp --- kasa/smart/modules/cleanrecords.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/smart/modules/cleanrecords.py b/kasa/smart/modules/cleanrecords.py index 3b8ecbded..981590f74 100644 --- a/kasa/smart/modules/cleanrecords.py +++ b/kasa/smart/modules/cleanrecords.py @@ -170,7 +170,7 @@ def last_clean_time(self) -> timedelta: @property def last_clean_timestamp(self) -> datetime: """Return latest cleaning timestamp.""" - return self._parsed_data.last_clean.timestamp + return self._parsed_data.last_clean.timestamp.astimezone(self._device.timezone) @property def area_unit(self) -> AreaUnit: From 97923daaca1bf47ea910aa138657caf6d648c111 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 16 Jan 2025 17:06:41 +0100 Subject: [PATCH 18/21] Improve records printout --- kasa/cli/vacuum.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/kasa/cli/vacuum.py b/kasa/cli/vacuum.py index d4e20742f..cb0aaad51 100644 --- a/kasa/cli/vacuum.py +++ b/kasa/cli/vacuum.py @@ -47,4 +47,7 @@ async def records_list(dev: Device) -> None: data = rec.parsed_data for record in data.records: - click.echo(f"* {record}") + click.echo( + f"* {record.timestamp}: cleaned {record.clean_area} {rec.area_unit}" + f" in {record.clean_time}" + ) From c6a12a4a549f549b7ccd482bc2bf9d5e29f23b6a Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 20 Jan 2025 10:36:29 +0000 Subject: [PATCH 19/21] Add better timestamp support to cleanrecords module (#1463) --- docs/tutorial.py | 2 +- kasa/feature.py | 2 +- kasa/smart/modules/cleanrecords.py | 84 +++++++++++++----------- kasa/smart/smartdevice.py | 10 ++- tests/smart/modules/test_cleanrecords.py | 20 ++++++ tests/smart/test_smartdevice.py | 3 +- 6 files changed, 80 insertions(+), 41 deletions(-) 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/feature.py b/kasa/feature.py index 4cd2dfc1d..2e069a057 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: diff --git a/kasa/smart/modules/cleanrecords.py b/kasa/smart/modules/cleanrecords.py index 981590f74..5286a9109 100644 --- a/kasa/smart/modules/cleanrecords.py +++ b/kasa/smart/modules/cleanrecords.py @@ -4,10 +4,12 @@ import logging from dataclasses import dataclass, field -from datetime import datetime, timedelta +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 @@ -22,6 +24,11 @@ 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)) @@ -29,11 +36,8 @@ class Record(DataClassDictMixin): #: Total area cleaned clean_area: int dust_collection: bool - timestamp: datetime = field( - metadata=field_options( - deserialize=lambda x: datetime.fromtimestamp(x) if x else None - ) - ) + timestamp: datetime + info_num: int | None = None message: int | None = None map_id: int | None = None @@ -45,24 +49,32 @@ class Record(DataClassDictMixin): error: int = field(default=0) -class LastCleanStrategy(SerializationStrategy): - """Strategy to deserialize list of maps into a dict.""" +class _DateTimeSerializationStrategy(SerializationStrategy): + def __init__(self, tz: tzinfo) -> None: + self.tz = tz - def deserialize(self, value: list[int]) -> Record: - """Deserialize list of maps into a dict.""" - data = { - "timestamp": value[0], - "clean_time": value[1], - "clean_area": value[2], - "dust_collection": value[3], - } - return Record.from_dict(data) + 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)) ) @@ -70,11 +82,18 @@ class Records(DataClassDictMixin): 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( - serialization_strategy=LastCleanStrategy(), alias="lastest_day_record" - ) - ) + 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): @@ -85,7 +104,9 @@ class CleanRecords(SmartModule): async def _post_update_hook(self) -> None: """Cache parsed data after an update.""" - self._parsed_data = Records.from_dict(self.data) + self._parsed_data = Records.from_dict( + self.data, dialect=_get_tz_strategy(self._device.timezone) + ) def _initialize_features(self) -> None: """Initialize features.""" @@ -170,7 +191,7 @@ def last_clean_time(self) -> timedelta: @property def last_clean_timestamp(self) -> datetime: """Return latest cleaning timestamp.""" - return self._parsed_data.last_clean.timestamp.astimezone(self._device.timezone) + return self._parsed_data.last_clean.timestamp @property def area_unit(self) -> AreaUnit: @@ -179,17 +200,6 @@ def area_unit(self) -> AreaUnit: return clean.area_unit @property - def parsed_data(self) -> Records: - """Return parsed records data. - - This will adjust the timezones before returning the data, as we do not - have the timezone information available when _post_update_hook is called. - """ - self._parsed_data.last_clean.timestamp = ( - self._parsed_data.last_clean.timestamp.astimezone(self._device.timezone) - ) - - for record in self._parsed_data.records: - record.timestamp = record.timestamp.astimezone(self._device.timezone) - + def clean_records(self) -> Records: + """Return parsed records data.""" return self._parsed_data diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 6c2e2227a..111b3da67 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/smart/modules/test_cleanrecords.py b/tests/smart/modules/test_cleanrecords.py index f18c01e81..b39fabaa9 100644 --- a/tests/smart/modules/test_cleanrecords.py +++ b/tests/smart/modules/test_cleanrecords.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime, timedelta +from zoneinfo import ZoneInfo import pytest @@ -37,3 +38,22 @@ async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: ty 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.clean_records.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 00d432724..ede86be04 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 = {} From e2c448f75d2bed415734d6669725f61473425c77 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 20 Jan 2025 10:45:52 +0000 Subject: [PATCH 20/21] Fix vacuum cli --- kasa/cli/vacuum.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/cli/vacuum.py b/kasa/cli/vacuum.py index cb0aaad51..8d9e4423f 100644 --- a/kasa/cli/vacuum.py +++ b/kasa/cli/vacuum.py @@ -28,7 +28,7 @@ async def records_group(dev: Device) -> None: if not (rec := dev.modules.get(Module.CleanRecords)): error("This device does not support records.") - data = rec.parsed_data + data = rec.clean_records latest = data.last_clean click.echo( f"Totals: {rec.total_clean_area} {rec.area_unit} in {rec.total_clean_time} " @@ -45,7 +45,7 @@ async def records_list(dev: Device) -> None: if not (rec := dev.modules.get(Module.CleanRecords)): error("This device does not support records.") - data = rec.parsed_data + data = rec.clean_records for record in data.records: click.echo( f"* {record.timestamp}: cleaned {record.clean_area} {rec.area_unit}" From 4b5e78cf0e7aeff5e7519a7219f03d53f2365fa3 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 20 Jan 2025 11:26:40 +0000 Subject: [PATCH 21/21] Add cli tests --- kasa/cli/vacuum.py | 4 +- kasa/smart/modules/cleanrecords.py | 2 +- tests/cli/test_vacuum.py | 61 ++++++++++++++++++++++++ tests/smart/modules/test_cleanrecords.py | 2 +- 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 tests/cli/test_vacuum.py diff --git a/kasa/cli/vacuum.py b/kasa/cli/vacuum.py index 8d9e4423f..cb0aaad51 100644 --- a/kasa/cli/vacuum.py +++ b/kasa/cli/vacuum.py @@ -28,7 +28,7 @@ async def records_group(dev: Device) -> None: if not (rec := dev.modules.get(Module.CleanRecords)): error("This device does not support records.") - data = rec.clean_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} " @@ -45,7 +45,7 @@ async def records_list(dev: Device) -> None: if not (rec := dev.modules.get(Module.CleanRecords)): error("This device does not support records.") - data = rec.clean_records + data = rec.parsed_data for record in data.records: click.echo( f"* {record.timestamp}: cleaned {record.clean_area} {rec.area_unit}" diff --git a/kasa/smart/modules/cleanrecords.py b/kasa/smart/modules/cleanrecords.py index 5286a9109..fdd0daeec 100644 --- a/kasa/smart/modules/cleanrecords.py +++ b/kasa/smart/modules/cleanrecords.py @@ -200,6 +200,6 @@ def area_unit(self) -> AreaUnit: return clean.area_unit @property - def clean_records(self) -> Records: + def parsed_data(self) -> Records: """Return parsed records data.""" return self._parsed_data 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/smart/modules/test_cleanrecords.py b/tests/smart/modules/test_cleanrecords.py index b39fabaa9..cef692868 100644 --- a/tests/smart/modules/test_cleanrecords.py +++ b/tests/smart/modules/test_cleanrecords.py @@ -53,7 +53,7 @@ async def test_timezone(dev: SmartDevice): # of utc before the time module is updated. assert isinstance(clean_records.last_clean_timestamp.tzinfo, ZoneInfo) - for record in clean_records.clean_records.records: + for record in clean_records.parsed_data.records: assert isinstance(record.timestamp, datetime) assert record.timestamp.tzinfo assert isinstance(record.timestamp.tzinfo, ZoneInfo)