mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-05-02 04:31:14 +00:00
Add support for cleaning records (#945)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>
This commit is contained in:
parent
bca5576425
commit
05085462d3
@ -91,5 +91,5 @@ False
|
|||||||
True
|
True
|
||||||
>>> for feat in dev.features.values():
|
>>> for feat in dev.features.values():
|
||||||
>>> print(f"{feat.name}: {feat.value}")
|
>>> 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
|
||||||
"""
|
"""
|
||||||
|
@ -93,6 +93,7 @@ def _legacy_type_to_class(_type: str) -> Any:
|
|||||||
"hsv": "light",
|
"hsv": "light",
|
||||||
"temperature": "light",
|
"temperature": "light",
|
||||||
"effect": "light",
|
"effect": "light",
|
||||||
|
"vacuum": "vacuum",
|
||||||
"hub": "hub",
|
"hub": "hub",
|
||||||
},
|
},
|
||||||
result_callback=json_formatter_cb,
|
result_callback=json_formatter_cb,
|
||||||
|
53
kasa/cli/vacuum.py
Normal file
53
kasa/cli/vacuum.py
Normal file
@ -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}"
|
||||||
|
)
|
@ -25,6 +25,7 @@ Signal Level (signal_level): 2
|
|||||||
RSSI (rssi): -52
|
RSSI (rssi): -52
|
||||||
SSID (ssid): #MASKED_SSID#
|
SSID (ssid): #MASKED_SSID#
|
||||||
Reboot (reboot): <Action>
|
Reboot (reboot): <Action>
|
||||||
|
Device time (device_time): 2024-02-23 02:40:15+01:00
|
||||||
Brightness (brightness): 100
|
Brightness (brightness): 100
|
||||||
Cloud connection (cloud_connection): True
|
Cloud connection (cloud_connection): True
|
||||||
HSV (hsv): HSV(hue=0, saturation=100, value=100)
|
HSV (hsv): HSV(hue=0, saturation=100, value=100)
|
||||||
@ -39,7 +40,6 @@ Light preset (light_preset): Not set
|
|||||||
Smooth transition on (smooth_transition_on): 2
|
Smooth transition on (smooth_transition_on): 2
|
||||||
Smooth transition off (smooth_transition_off): 2
|
Smooth transition off (smooth_transition_off): 2
|
||||||
Overheated (overheated): False
|
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:
|
To see whether a device supports a feature, check for the existence of it:
|
||||||
|
|
||||||
@ -299,8 +299,10 @@ class Feature:
|
|||||||
if isinstance(value, Enum):
|
if isinstance(value, Enum):
|
||||||
value = repr(value)
|
value = repr(value)
|
||||||
s = f"{self.name} ({self.id}): {value}"
|
s = f"{self.name} ({self.id}): {value}"
|
||||||
if self.unit is not None:
|
if (unit := self.unit) is not None:
|
||||||
s += f" {self.unit}"
|
if isinstance(unit, Enum):
|
||||||
|
unit = repr(unit)
|
||||||
|
s += f" {unit}"
|
||||||
|
|
||||||
if self.type == Feature.Type.Number:
|
if self.type == Feature.Type.Number:
|
||||||
s += f" (range: {self.minimum_value}-{self.maximum_value})"
|
s += f" (range: {self.minimum_value}-{self.maximum_value})"
|
||||||
|
@ -168,6 +168,7 @@ class Module(ABC):
|
|||||||
Dustbin: Final[ModuleName[smart.Dustbin]] = ModuleName("Dustbin")
|
Dustbin: Final[ModuleName[smart.Dustbin]] = ModuleName("Dustbin")
|
||||||
Speaker: Final[ModuleName[smart.Speaker]] = ModuleName("Speaker")
|
Speaker: Final[ModuleName[smart.Speaker]] = ModuleName("Speaker")
|
||||||
Mop: Final[ModuleName[smart.Mop]] = ModuleName("Mop")
|
Mop: Final[ModuleName[smart.Mop]] = ModuleName("Mop")
|
||||||
|
CleanRecords: Final[ModuleName[smart.CleanRecords]] = ModuleName("CleanRecords")
|
||||||
|
|
||||||
def __init__(self, device: Device, module: str) -> None:
|
def __init__(self, device: Device, module: str) -> None:
|
||||||
self._device = device
|
self._device = device
|
||||||
|
@ -10,6 +10,7 @@ from .childlock import ChildLock
|
|||||||
from .childprotection import ChildProtection
|
from .childprotection import ChildProtection
|
||||||
from .childsetup import ChildSetup
|
from .childsetup import ChildSetup
|
||||||
from .clean import Clean
|
from .clean import Clean
|
||||||
|
from .cleanrecords import CleanRecords
|
||||||
from .cloud import Cloud
|
from .cloud import Cloud
|
||||||
from .color import Color
|
from .color import Color
|
||||||
from .colortemperature import ColorTemperature
|
from .colortemperature import ColorTemperature
|
||||||
@ -75,6 +76,7 @@ __all__ = [
|
|||||||
"FrostProtection",
|
"FrostProtection",
|
||||||
"Thermostat",
|
"Thermostat",
|
||||||
"Clean",
|
"Clean",
|
||||||
|
"CleanRecords",
|
||||||
"SmartLightEffect",
|
"SmartLightEffect",
|
||||||
"OverheatProtection",
|
"OverheatProtection",
|
||||||
"Speaker",
|
"Speaker",
|
||||||
|
205
kasa/smart/modules/cleanrecords.py
Normal file
205
kasa/smart/modules/cleanrecords.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
"""Implementation of vacuum cleaning records."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta, tzinfo
|
||||||
|
from typing import Annotated, cast
|
||||||
|
|
||||||
|
from mashumaro import DataClassDictMixin, field_options
|
||||||
|
from mashumaro.config import ADD_DIALECT_SUPPORT
|
||||||
|
from mashumaro.dialect import Dialect
|
||||||
|
from mashumaro.types import SerializationStrategy
|
||||||
|
|
||||||
|
from ...feature import Feature
|
||||||
|
from ...module import FeatureAttribute
|
||||||
|
from ..smartmodule import Module, SmartModule
|
||||||
|
from .clean import AreaUnit, Clean
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Record(DataClassDictMixin):
|
||||||
|
"""Historical cleanup result."""
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Configuration class."""
|
||||||
|
|
||||||
|
code_generation_options = [ADD_DIALECT_SUPPORT]
|
||||||
|
|
||||||
|
#: Total time cleaned (in minutes)
|
||||||
|
clean_time: timedelta = field(
|
||||||
|
metadata=field_options(deserialize=lambda x: timedelta(minutes=x))
|
||||||
|
)
|
||||||
|
#: Total area cleaned
|
||||||
|
clean_area: int
|
||||||
|
dust_collection: bool
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
info_num: int | None = None
|
||||||
|
message: int | None = None
|
||||||
|
map_id: int | None = None
|
||||||
|
start_type: int | None = None
|
||||||
|
task_type: int | None = None
|
||||||
|
record_index: int | None = None
|
||||||
|
|
||||||
|
#: Error code from cleaning
|
||||||
|
error: int = field(default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class _DateTimeSerializationStrategy(SerializationStrategy):
|
||||||
|
def __init__(self, tz: tzinfo) -> None:
|
||||||
|
self.tz = tz
|
||||||
|
|
||||||
|
def deserialize(self, value: float) -> datetime:
|
||||||
|
return datetime.fromtimestamp(value, self.tz)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tz_strategy(tz: tzinfo) -> type[Dialect]:
|
||||||
|
"""Return a timezone aware de-serialization strategy."""
|
||||||
|
|
||||||
|
class TimezoneDialect(Dialect):
|
||||||
|
serialization_strategy = {datetime: _DateTimeSerializationStrategy(tz)}
|
||||||
|
|
||||||
|
return TimezoneDialect
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Records(DataClassDictMixin):
|
||||||
|
"""Response payload for getCleanRecords."""
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Configuration class."""
|
||||||
|
|
||||||
|
code_generation_options = [ADD_DIALECT_SUPPORT]
|
||||||
|
|
||||||
|
total_time: timedelta = field(
|
||||||
|
metadata=field_options(deserialize=lambda x: timedelta(minutes=x))
|
||||||
|
)
|
||||||
|
total_area: int
|
||||||
|
total_count: int = field(metadata=field_options(alias="total_number"))
|
||||||
|
|
||||||
|
records: list[Record] = field(metadata=field_options(alias="record_list"))
|
||||||
|
last_clean: Record = field(metadata=field_options(alias="lastest_day_record"))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __pre_deserialize__(cls, d: dict) -> dict:
|
||||||
|
if ldr := d.get("lastest_day_record"):
|
||||||
|
d["lastest_day_record"] = {
|
||||||
|
"timestamp": ldr[0],
|
||||||
|
"clean_time": ldr[1],
|
||||||
|
"clean_area": ldr[2],
|
||||||
|
"dust_collection": ldr[3],
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class CleanRecords(SmartModule):
|
||||||
|
"""Implementation of vacuum cleaning records."""
|
||||||
|
|
||||||
|
REQUIRED_COMPONENT = "clean_percent"
|
||||||
|
_parsed_data: Records
|
||||||
|
|
||||||
|
async def _post_update_hook(self) -> None:
|
||||||
|
"""Cache parsed data after an update."""
|
||||||
|
self._parsed_data = Records.from_dict(
|
||||||
|
self.data, dialect=_get_tz_strategy(self._device.timezone)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _initialize_features(self) -> None:
|
||||||
|
"""Initialize features."""
|
||||||
|
for type_ in ["total", "last"]:
|
||||||
|
self._add_feature(
|
||||||
|
Feature(
|
||||||
|
self._device,
|
||||||
|
id=f"{type_}_clean_area",
|
||||||
|
name=f"{type_.capitalize()} area cleaned",
|
||||||
|
container=self,
|
||||||
|
attribute_getter=f"{type_}_clean_area",
|
||||||
|
unit_getter="area_unit",
|
||||||
|
category=Feature.Category.Debug,
|
||||||
|
type=Feature.Type.Sensor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._add_feature(
|
||||||
|
Feature(
|
||||||
|
self._device,
|
||||||
|
id=f"{type_}_clean_time",
|
||||||
|
name=f"{type_.capitalize()} time cleaned",
|
||||||
|
container=self,
|
||||||
|
attribute_getter=f"{type_}_clean_time",
|
||||||
|
category=Feature.Category.Debug,
|
||||||
|
type=Feature.Type.Sensor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._add_feature(
|
||||||
|
Feature(
|
||||||
|
self._device,
|
||||||
|
id="total_clean_count",
|
||||||
|
name="Total clean count",
|
||||||
|
container=self,
|
||||||
|
attribute_getter="total_clean_count",
|
||||||
|
category=Feature.Category.Debug,
|
||||||
|
type=Feature.Type.Sensor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._add_feature(
|
||||||
|
Feature(
|
||||||
|
self._device,
|
||||||
|
id="last_clean_timestamp",
|
||||||
|
name="Last clean timestamp",
|
||||||
|
container=self,
|
||||||
|
attribute_getter="last_clean_timestamp",
|
||||||
|
category=Feature.Category.Debug,
|
||||||
|
type=Feature.Type.Sensor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def query(self) -> dict:
|
||||||
|
"""Query to execute during the update cycle."""
|
||||||
|
return {
|
||||||
|
"getCleanRecords": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_clean_area(self) -> Annotated[int, FeatureAttribute()]:
|
||||||
|
"""Return total cleaning area."""
|
||||||
|
return self._parsed_data.total_area
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_clean_time(self) -> timedelta:
|
||||||
|
"""Return total cleaning time."""
|
||||||
|
return self._parsed_data.total_time
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_clean_count(self) -> int:
|
||||||
|
"""Return total clean count."""
|
||||||
|
return self._parsed_data.total_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_clean_area(self) -> Annotated[int, FeatureAttribute()]:
|
||||||
|
"""Return latest cleaning area."""
|
||||||
|
return self._parsed_data.last_clean.clean_area
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_clean_time(self) -> timedelta:
|
||||||
|
"""Return total cleaning time."""
|
||||||
|
return self._parsed_data.last_clean.clean_time
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_clean_timestamp(self) -> datetime:
|
||||||
|
"""Return latest cleaning timestamp."""
|
||||||
|
return self._parsed_data.last_clean.timestamp
|
||||||
|
|
||||||
|
@property
|
||||||
|
def area_unit(self) -> AreaUnit:
|
||||||
|
"""Return area unit."""
|
||||||
|
clean = cast(Clean, self._device.modules[Module.Clean])
|
||||||
|
return clean.area_unit
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parsed_data(self) -> Records:
|
||||||
|
"""Return parsed records data."""
|
||||||
|
return self._parsed_data
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from collections import OrderedDict
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from datetime import UTC, datetime, timedelta, tzinfo
|
from datetime import UTC, datetime, timedelta, tzinfo
|
||||||
from typing import TYPE_CHECKING, Any, TypeAlias, cast
|
from typing import TYPE_CHECKING, Any, TypeAlias, cast
|
||||||
@ -66,7 +67,9 @@ class SmartDevice(Device):
|
|||||||
self._components_raw: ComponentsRaw | None = None
|
self._components_raw: ComponentsRaw | None = None
|
||||||
self._components: dict[str, int] = {}
|
self._components: dict[str, int] = {}
|
||||||
self._state_information: dict[str, Any] = {}
|
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._parent: SmartDevice | None = None
|
||||||
self._children: dict[str, SmartDevice] = {}
|
self._children: dict[str, SmartDevice] = {}
|
||||||
self._last_update_time: float | None = None
|
self._last_update_time: float | None = None
|
||||||
@ -445,6 +448,11 @@ class SmartDevice(Device):
|
|||||||
):
|
):
|
||||||
self._modules[Thermostat.__name__] = Thermostat(self, "thermostat")
|
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:
|
async def _initialize_features(self) -> None:
|
||||||
"""Initialize device features."""
|
"""Initialize device features."""
|
||||||
self._add_feature(
|
self._add_feature(
|
||||||
|
61
tests/cli/test_vacuum.py
Normal file
61
tests/cli/test_vacuum.py
Normal file
@ -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
|
@ -180,16 +180,56 @@
|
|||||||
},
|
},
|
||||||
"getCleanRecords": {
|
"getCleanRecords": {
|
||||||
"lastest_day_record": [
|
"lastest_day_record": [
|
||||||
0,
|
1736797545,
|
||||||
0,
|
25,
|
||||||
0,
|
16,
|
||||||
0
|
1
|
||||||
],
|
],
|
||||||
"record_list": [],
|
"record_list": [
|
||||||
"record_list_num": 0,
|
{
|
||||||
"total_area": 0,
|
"clean_area": 17,
|
||||||
"total_number": 0,
|
"clean_time": 27,
|
||||||
"total_time": 0
|
"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": {
|
||||||
"getCleanStatus": {
|
"getCleanStatus": {
|
||||||
|
59
tests/smart/modules/test_cleanrecords.py
Normal file
59
tests/smart/modules/test_cleanrecords.py
Normal file
@ -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)
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from collections import OrderedDict
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
from unittest.mock import patch
|
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
|
# As the fixture data is already initialized, we reset the state for testing
|
||||||
dev._components_raw = None
|
dev._components_raw = None
|
||||||
dev._components = {}
|
dev._components = {}
|
||||||
dev._modules = {}
|
dev._modules = OrderedDict()
|
||||||
dev._features = {}
|
dev._features = {}
|
||||||
dev._children = {}
|
dev._children = {}
|
||||||
dev._last_update = {}
|
dev._last_update = {}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user