Skip to content

Add support for cleaning records #945

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2b2a24a
Initial support for cleaning records
rytilahti Jun 2, 2024
1505771
Fix incorrectly named attribute_getter
rytilahti Jun 2, 2024
bec0ae3
Convert to mashumaro, use kwargs for features
rytilahti Nov 30, 2024
881a858
Add cli vacuum commands
rytilahti Jan 12, 2025
11b56a3
Add latest clean record and cleanup
rytilahti Jan 14, 2025
4fc6b18
Expose last clean info as features
rytilahti Jan 14, 2025
5567dee
Update fixture to contain some data + add getAreaUnit response
rytilahti Jan 14, 2025
8c49bb5
Use areaunit from clean module
rytilahti Jan 14, 2025
5747bbe
Cleanup and rename to vacuumrecords to cleanrecords
rytilahti Jan 15, 2025
c875746
Add tests
rytilahti Jan 15, 2025
02f3df8
Add featureattribute annotations for area properties
rytilahti Jan 15, 2025
b706494
Fix cli
rytilahti Jan 15, 2025
2120ae3
repr()ify units
rytilahti Jan 15, 2025
a01fa62
run pre-commit hooks
rytilahti Jan 15, 2025
9e654b9
Fix broken rebase
rytilahti Jan 15, 2025
8e3fba5
Merge remote-tracking branch 'upstream/master' into feat/vacuum_consu…
rytilahti Jan 15, 2025
79d8ad5
add timezone to timestamps, fix cleaning time deserialization
rytilahti Jan 16, 2025
3a71309
Fix last_clean_timestamp
rytilahti Jan 16, 2025
97923da
Improve records printout
rytilahti Jan 16, 2025
c6a12a4
Add better timestamp support to cleanrecords module (#1463)
sdb9696 Jan 20, 2025
6f70021
Merge remote-tracking branch 'upstream/master' into feat/vacuum_consu…
sdb9696 Jan 20, 2025
e2c448f
Fix vacuum cli
sdb9696 Jan 20, 2025
4b5e78c
Add cli tests
sdb9696 Jan 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/tutorial.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <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
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
"""
1 change: 1 addition & 0 deletions kasa/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 53 additions & 0 deletions kasa/cli/vacuum.py
Original file line number Diff line number Diff line change
@@ -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.")

Check warning on line 46 in kasa/cli/vacuum.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/vacuum.py#L46

Added line #L46 was not covered by tests

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}"
)
8 changes: 5 additions & 3 deletions kasa/feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
RSSI (rssi): -52
SSID (ssid): #MASKED_SSID#
Reboot (reboot): <Action>
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)
Expand All @@ -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:

Expand Down Expand Up @@ -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)
Comment on lines +303 to +304
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should have some helper to print out pretty units? Not necessarily in this PR, tho.

s += f" {unit}"

if self.type == Feature.Type.Number:
s += f" (range: {self.minimum_value}-{self.maximum_value})"
Expand Down
1 change: 1 addition & 0 deletions kasa/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions kasa/smart/modules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -75,6 +76,7 @@
"FrostProtection",
"Thermostat",
"Clean",
"CleanRecords",
"SmartLightEffect",
"OverheatProtection",
"Speaker",
Expand Down
205 changes: 205 additions & 0 deletions kasa/smart/modules/cleanrecords.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +41 to +46
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This are currently unused, perhaps avoid defining them in the dataclass at all for now?


#: 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"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may not be the correct component, but unsure what else should be used here. clean?

_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
10 changes: 9 additions & 1 deletion kasa/smart/smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading