8000 Add support for cleaning records (#945) · rSffsE/python-kasa@0508546 · GitHub
[go: up one dir, main page]

Skip to content

Commit 0508546

Browse files
rytilahtisdb9696
andauthored
Add support for cleaning records (python-kasa#945)
Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>
1 parent bca5576 commit 0508546

File tree

12 files changed

+448
-15
lines changed

12 files changed

+448
-15
lines changed

docs/tutorial.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,5 +91,5 @@
9191
True
9292
>>> for feat in dev.features.values():
9393
>>> print(f"{feat.name}: {feat.value}")
94-
Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nReboot: <Action>\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: <Action>\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
94+
Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nReboot: <Action>\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: <Action>\nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nOverheated: False
9595
"""

kasa/cli/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def _legacy_type_to_class(_type: str) -> Any:
9393
"hsv": "light",
9494
"temperature": "light",
9595
"effect": "light",
96+
"vacuum": "vacuum",
9697
"hub": "hub",
9798
},
9899
result_callback=json_formatter_cb,

kasa/cli/vacuum.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Module for cli vacuum commands.."""
2+
3+
from __future__ import annotations
4+
5+
import asyncclick as click
6+
7+
from kasa import (
8+
Device,
9+
Module,
10+
)
11+
12+
from .common import (
13+
error,
14+
pass_dev_or_child,
15+
)
16+
17+
18+
@click.group(invoke_without_command=False)
19+
@click.pass_context
20+
async def vacuum(ctx: click.Context) -> None:
21+
"""Vacuum commands."""
22+
23+
24+
@vacuum.group(invoke_without_command=True, name="records")
25+
@pass_dev_or_child
26+
async def records_group(dev: Device) -> None:
27+
"""Access cleaning records."""
28+
if not (rec := dev.modules.get(Module.CleanRecords)):
29+
error("This device does not support records.")
30+
31+
data = rec.parsed_data
32+
latest = data.last_clean
33+
click.echo(
34+
f"Totals: {rec.total_clean_area} {rec.area_unit} in {rec.total_clean_time} "
35+
f"(cleaned {rec.total_clean_count} times)"
36+
)
37+
click.echo(f"Last clean: {latest.clean_area} {rec.area_unit} @ {latest.clean_time}")
38+
click.echo("Execute `kasa vacuum records list` to list all records.")
39+
40+
41+
@records_group.command(name="list")
42+
@pass_dev_or_child
43+
async def records_list(dev: Device) -> None:
44+
"""List all cleaning records."""
45+
if not (rec := dev.modules.get(Module.CleanRecords)):
46+
error("This device does not support records.")
47+
48+
data = rec.parsed_data
49+
for record in data.records:
50+
click.echo(
51+
f"* {record.timestamp}: cleaned {record.clean_area} {rec.area_unit}"
52+
f" in {record.clean_time}"
53+
)

kasa/feature.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
RSSI (rssi): -52
2626
SSID (ssid): #MASKED_SSID#
2727
Reboot (reboot): <Action>
28+
Device time (device_time): 2024-02-23 02:40:15+01:00
2829
Brightness (brightness): 100
2930
Cloud connection (cloud_connection): True
3031
HSV (hsv): HSV(hue=0, saturation=100, value=100)
@@ -39,7 +40,6 @@
3940
Smooth transition on (smooth_transition_on): 2
4041
Smooth transition off (smooth_transition_off): 2
4142
Overheated (overheated): False
42-
Device time (device_time): 2024-02-23 02:40:15+01:00
4343
4444
To see whether a device supports a feature, check for the existence of it:
4545
@@ -299,8 +299,10 @@ def __repr__(self) -> str:
299299
if isinstance(value, Enum):
300300
value = repr(value)
301301
s = f"{self.name} ({self.id}): {value}"
302-
if self.unit is not None:
303-
s += f" {self.unit}"
302+
if (unit := self.unit) is not None:
303+
if isinstance(unit, Enum):
304+
unit = repr(unit)
305+
s += f" {unit}"
304306

305307
if self.type == Feature.Type.Number:
306308
s += f" (range: {self.minimum_value}-{self.maximum_value})"

kasa/module.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ class Module(ABC):
168168
Dustbin: Final[ModuleName[smart.Dustbin]] = ModuleName("Dustbin")
169169
Speaker: Final[ModuleName[smart.Speaker]] = ModuleName("Speaker")
170170
Mop: Final[ModuleName[smart.Mop]] = ModuleName("Mop")
171+
CleanRecords: Final[ModuleName[smart.CleanRecords]] = ModuleName("CleanRecords")
171172

172173
def __init__(self, device: Device, module: str) -> None:
173174
self._device = device

kasa/smart/modules/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .childprotection import ChildProtection
1111
from .childsetup import ChildSetup
1212
from .clean import Clean
13+
from .cleanrecords import CleanRecords
1314
from .cloud import Cloud
1415
from .color import Color
1516
from .colortemperature import ColorTemperature
@@ -75,6 +76,7 @@
7576
"FrostProtection",
7677
"Thermostat",
7778
"Clean",
79+
"CleanRecords",
7880
"SmartLightEffect",
7981
"OverheatProtection",
8082
"Speaker",

kasa/smart/modules/cleanrecords.py

Lines changed: 205 additions & 0 deletions
+
map_id: int | None = None
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""Implementation of vacuum cleaning records."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from dataclasses import dataclass, field
7+
from datetime import datetime, timedelta, tzinfo
8+
from typing import Annotated, cast
9+
10+
from mashumaro import DataClassDictMixin, field_options
11+
from mashumaro.config import ADD_DIALECT_SUPPORT
12+
from mashumaro.dialect import Dialect
13+
from mashumaro.types import SerializationStrategy
14+
15+
from ...feature import Feature
16+
from ...module import FeatureAttribute
17+
from ..smartmodule import Module, SmartModule
18+
from .clean import AreaUnit, Clean
19+
20+
_LOGGER = logging.getLogger(__name__)
21+
22+
23+
@dataclass
24+
class Record(DataClassDictMixin):
25+
"""Historical cleanup result."""
26+
27+
class Config:
28+
"""Configuration class."""
29+
30+
code_generation_options = [ADD_DIALECT_SUPPORT]
31+
32+
#: Total time cleaned (in minutes)
33+
clean_time: timedelta = field(
34+
metadata=field_options(deserialize=lambda x: timedelta(minutes=x))
35+
)
36+
#: Total area cleaned
37+
clean_area: int
38+
dust_collection: bool
39+
timestamp: datetime
40+
41+
info_num: int | None = None
42+
message: int | None = None
43
44+
start_type: int | None = None
45+
task_type: int | None = None
46+
record_index: int | None = None
47+
48+
#: Error code from cleaning
49+
error: int = field(default=0)
50+
51+
52+
class _DateTimeSerializationStrategy(SerializationStrategy):
53+
def __init__(self, tz: tzinfo) -> None:
54+
self.tz = tz
55+
56+
def deserialize(self, value: float) -> datetime:
57+
return datetime.fromtimestamp(value, self.tz)
58+
59+
60+
def _get_tz_strategy(tz: tzinfo) -> type[Dialect]:
61+
"""Return a timezone aware de-serialization strategy."""
62+
63+
class TimezoneDialect(Dialect):
64+
serialization_strategy = {datetime: _DateTimeSerializationStrategy(tz)}
65+
66+
return TimezoneDialect
67+
68+
69+
@dataclass
70+
class Records(DataClassDictMixin):
71+
"""Response payload for getCleanRecords."""
72+
73+
class Config:
74+
"""Configuration class."""
75+
76+
code_generation_options = [ADD_DIALECT_SUPPORT]
77+
78+
total_time: timedelta = field(
79+
metadata=field_options(deserialize=lambda x: timedelta(minutes=x))
80+
)
81+
total_area: int
82+
total_count: int = field(metadata=field_options(alias="total_number"))
83+
84+
records: list[Record] = field(metadata=field_options(alias="record_list"))
85+
last_clean: Record = field(metadata=field_options(alias="lastest_day_record"))
86+
87+
@classmethod
88+
def __pre_deserialize__(cls, d: dict) -> dict:
89+
if ldr := d.get("lastest_day_record"):
90+
d["lastest_day_record"] = {
91+
"timestamp": ldr[0],
92+
"clean_time": ldr[1],
93+
"clean_area": ldr[2],
94+
"dust_collection": ldr[3],
95+
}
96+
return d
97+
98+
99+
class CleanRecords(SmartModule):
100+
"""Implementation of vacuum cleaning records."""
101+
102+
REQUIRED_COMPONENT = "clean_percent"
103+
_parsed_data: Records
104+
105+
async def _post_update_hook(self) -> None:
106+
"""Cache parsed data after an update."""
107+
self._parsed_data = Records.from_dict(
108+
self.data, dialect=_get_tz_strategy(self._device.timezone)
109+
)
110+
111+
def _initialize_features(self) -> None:
112+
"""Initialize features."""
113+
for type_ in ["total", "last"]:
114+
self._add_feature(
115+
Feature(
116+
self._device,
117+
id=f"{type_}_clean_area",
118+
name=f"{type_.capitalize()} area cleaned",
119+
container=self,
120+
attribute_getter=f"{type_}_clean_area",
121+
unit_getter="area_unit",
122+
category=Feature.Category.Debug,
123+
type=Feature.Type.Sensor,
124+
)
125+
)
126+
self._add_feature(
127+
Feature(
128+
self._device,
129+
id=f"{type_}_clean_time",
130+
name=f"{type_.capitalize()} time cleaned",
131+
container=self,
132+
attribute_getter=f"{type_}_clean_time",
133+
category=Feature.Category.Debug,
134+
type=Feature.Type.Sensor,
135+
)
136+
)
137+
self._add_feature(
138+
Feature(
139+
self._device,
140+
id="total_clean_count",
141+
name="Total clean count",
142+
container=self,
143+
attribute_getter="total_clean_count",
144+
category=Feature.Category.Debug,
145+
type=Feature.Type.Sensor,
146+
)
147+
)
148+
self._add_feature(
149+
Feature(
150+
self._device,
151+
id="last_clean_timestamp",
152+
name="Last clean timestamp",
153+
container=self,
154+
attribute_getter="last_clean_timestamp",
155+
category=Feature.Category.Debug,
156+
type=Feature.Type.Sensor,
157+
)
158+
)
159+
160+
def query(self) -> dict:
161+
"""Query to execute during the update cycle."""
162+
return {
163+
"getCleanRecords": {},
164+
}
165+
166+
@property
167+
def total_clean_area(self) -> Annotated[int, FeatureAttribute()]:
168+
"""Return total cleaning area."""
169+
return self._parsed_data.total_area
170+
171+
@property
172+
def total_clean_time(self) -> timedelta:
173+
"""Return total cleaning time."""
174+
return self._parsed_data.total_time
175+
176+
@property
177+
def total_clean_count(self) -> int:
178+
"""Return total clean count."""
179+
return self._parsed_data.total_count
180+
181+
@property
182+
def last_clean_area(self) -> Annotated[int, FeatureAttribute()]:
183+
"""Return latest cleaning area."""
184+
return self._parsed_data.last_clean.clean_area
185+
186+
@property
187+
def last_clean_time(self) -> timedelta:
188+
"""Return total cleaning time."""
189+
return self._parsed_data.last_clean.clean_time
190+
191+
@property
192+
def last_clean_timestamp(self) -> datetime:
193+
"""Return latest cleaning timestamp."""
194+
return self._parsed_data.last_clean.timestamp
195+
196+
@property
197+
def area_unit(self) -> AreaUnit:
198+
"""Return area unit."""
199+
clean = cast(Clean, self._device.modules[Module.Clean])
200+
return clean.area_unit
201+
202+
@property
203+
def parsed_data(self) -> Records:
204+
"""Return parsed records data."""
205+
return self._parsed_data

kasa/smart/smartdevice.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import base64
66
import logging
77
import time
8+
from collections import OrderedDict
89
from collections.abc import Sequence
910
from datetime import UTC, datetime, timedelta, tzinfo
1011
from typing import TYPE_CHECKING, Any, TypeAlias, cast
@@ -66,7 +67,9 @@ def __init__(
6667
self._components_raw: ComponentsRaw | None = None
6768
self._components: dict[str, int] = {}
6869
self._state_information: dict[str, Any] = {}
69-
self._modules: dict[str | ModuleName[Module], SmartModule] = {}
70+
self._modules: OrderedDict[str | ModuleName[Module], SmartModule] = (
71+
OrderedDict()
72+
)
7073
self._parent: SmartDevice | None = None
7174
self._children: dict[str, SmartDevice] = {}
7275
self._last_update_time: float | None = None
@@ -445,6 +448,11 @@ async def _initialize_modules(self) -> None:
445448
):
446449
self._modules[Thermostat.__name__] = Thermostat(self, "thermostat")
447450

451+
# We move time to the beginning so other modules can access the
452+
# time and timezone after update if required. e.g. cleanrecords
453+
if Time.__name__ in self._modules:
454+
self._modules.move_to_end(Time.__name__, last=False)
455+
448456
async def _initialize_features(self) -> None:
449457
"""Initialize device features."""
450458
self._add_feature(

0 commit comments

Comments
 (0)
0