Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
"""
2 changes: 1 addition & 1 deletion 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
84 changes: 47 additions & 37 deletions kasa/smart/modules/cleanrecords.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,18 +24,20 @@
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 = 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
Expand All @@ -45,36 +49,51 @@ 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)
Comment on lines -51 to -59
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This doesn't work with the timezone dialect so moved this logic to __pre_deserialize__.

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(
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):
Expand All @@ -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."""
Expand Down Expand Up @@ -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:
Expand All @@ -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
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)
Comment on lines +451 to +454
Copy link
Member

Choose a reason for hiding this comment

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

Tbh, this feels rather icky and I would rather like to avoid it, but I suppose it is what it is :-)


async def _initialize_features(self) -> None:
"""Initialize device features."""
self._add_feature(
Expand Down
20 changes: 20 additions & 0 deletions tests/smart/modules/test_cleanrecords.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

import pytest

Expand Down Expand Up @@ -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)
3 changes: 2 additions & 1 deletion tests/smart/test_smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = {}
Expand Down