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 kasa/cli/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ async def state(ctx, dev: Device):
echo(f"Port: {dev.port}")
echo(f"Device state: {dev.is_on}")

echo(f"Time: {dev.time} (tz: {dev.timezone}")
echo(f"Time: {dev.time} (tz: {dev.timezone})")
echo(f"Hardware: {dev.hw_info['hw_ver']}")
echo(f"Software: {dev.hw_info['sw_ver']}")
echo(f"MAC (rssi): {dev.mac} ({dev.rssi})")
Expand Down
4 changes: 2 additions & 2 deletions kasa/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, tzinfo
from typing import TYPE_CHECKING, Any
from warnings import warn

Expand Down Expand Up @@ -377,7 +377,7 @@ def time(self) -> datetime:

@property
@abstractmethod
def timezone(self) -> dict:
def timezone(self) -> tzinfo:
"""Return the timezone and time_difference."""

@property
Expand Down
10 changes: 4 additions & 6 deletions kasa/iot/iotdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import inspect
import logging
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta, tzinfo
from typing import TYPE_CHECKING, Any, cast

from ..device import Device, WifiNetwork
Expand Down Expand Up @@ -299,7 +299,7 @@ async def update(self, update_children: bool = True):

self._set_sys_info(self._last_update["system"]["get_sysinfo"])
for module in self._modules.values():
module._post_update_hook()
await module._post_update_hook()

if not self._features:
await self._initialize_features()
Expand Down Expand Up @@ -464,7 +464,7 @@ def time(self) -> datetime:

@property
@requires_update
def timezone(self) -> dict:
def timezone(self) -> tzinfo:
"""Return the current timezone."""
return self.modules[Module.IotTime].timezone

Expand Down Expand Up @@ -606,9 +606,7 @@ def on_since(self) -> datetime | None:

on_time = self._sys_info["on_time"]

time = datetime.now(timezone.utc).astimezone().replace(microsecond=0)

on_since = time - timedelta(seconds=on_time)
on_since = self.time - timedelta(seconds=on_time)
if not self._on_since or timedelta(
seconds=0
) < on_since - self._on_since > timedelta(seconds=5):
Expand Down
6 changes: 3 additions & 3 deletions kasa/iot/iotstrip.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import logging
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
from typing import Any

from ..device_type import DeviceType
Expand Down Expand Up @@ -373,7 +373,7 @@ async def _update(self, update_children: bool = True):
"""
await self._modular_update({})
for module in self._modules.values():
module._post_update_hook()
await module._post_update_hook()

if not self._features:
await self._initialize_features()
Expand Down Expand Up @@ -445,7 +445,7 @@ def on_since(self) -> datetime | None:
info = self._get_child_info()
on_time = info["on_time"]

time = datetime.now(timezone.utc).astimezone().replace(microsecond=0)
time = self._parent.time

on_since = time - timedelta(seconds=on_time)
if not self._on_since or timedelta(
Expand Down
178 changes: 178 additions & 0 deletions kasa/iot/iottimezone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""Module for io device timezone lookups."""

from __future__ import annotations

import asyncio
import logging
from datetime import datetime, tzinfo

from zoneinfo import ZoneInfo

_LOGGER = logging.getLogger(__name__)


async def get_timezone(index: int) -> tzinfo:
"""Get the timezone from the index."""
if index > 109:
_LOGGER.error(
"Unexpected index %s not configured as a timezone, defaulting to UTC", index
)
return await _CachedZoneInfo.get_cached_zone_info("Etc/UTC")

name = TIMEZONE_INDEX[index]
return await _CachedZoneInfo.get_cached_zone_info(name)


async def get_timezone_index(name: str) -> int:
"""Return the iot firmware index for a valid IANA timezone key."""
rev = {val: key for key, val in TIMEZONE_INDEX.items()}
if name in rev:
return rev[name]

# Try to find a supported timezone matching dst true/false
zone = await _CachedZoneInfo.get_cached_zone_info(name)
now = datetime.now()
winter = datetime(now.year, 1, 1, 12)
summer = datetime(now.year, 7, 1, 12)
for i in range(110):
configured_zone = await get_timezone(i)
if zone.utcoffset(winter) == configured_zone.utcoffset(
winter
) and zone.utcoffset(summer) == configured_zone.utcoffset(summer):
return i
raise ValueError("Device does not support timezone %s", name)


class _CachedZoneInfo(ZoneInfo):
"""Cache zone info objects."""

_cache: dict[str, ZoneInfo] = {}

@classmethod
async def get_cached_zone_info(cls, time_zone_str: str) -> ZoneInfo:
"""Get a cached zone info object."""
if cached := cls._cache.get(time_zone_str):
return cached
loop = asyncio.get_running_loop()
zinfo = await loop.run_in_executor(None, _get_zone_info, time_zone_str)
cls._cache[time_zone_str] = zinfo
return zinfo


def _get_zone_info(time_zone_str: str) -> ZoneInfo:
"""Get a time zone object for the given time zone string."""
return ZoneInfo(time_zone_str)


TIMEZONE_INDEX = {
0: "Etc/GMT+12",
1: "Pacific/Samoa",
2: "US/Hawaii",
3: "US/Alaska",
4: "Mexico/BajaNorte",
5: "Etc/GMT+8",
6: "PST8PDT",
7: "US/Arizona",
8: "America/Mazatlan",
9: "MST",
10: "MST7MDT",
11: "Mexico/General",
12: "Etc/GMT+6",
13: "CST6CDT",
14: "America/Monterrey",
15: "Canada/Saskatchewan",
16: "America/Bogota",
17: "Etc/GMT+5",
18: "EST",
19: "America/Indiana/Indianapolis",
20: "America/Caracas",
21: "America/Asuncion",
22: "Etc/GMT+4",
23: "Canada/Atlantic",
24: "America/Cuiaba",
25: "Brazil/West",
26: "America/Santiago",
27: "Canada/Newfoundland",
28: "America/Sao_Paulo",
29: "America/Argentina/Buenos_Aires",
30: "America/Cayenne",
31: "America/Miquelon",
32: "America/Montevideo",
33: "Chile/Continental",
34: "Etc/GMT+2",
35: "Atlantic/Azores",
36: "Atlantic/Cape_Verde",
37: "Africa/Casablanca",
38: "UCT",
39: "GB",
40: "Africa/Monrovia",
41: "Europe/Amsterdam",
42: "Europe/Belgrade",
43: "Europe/Brussels",
44: "Europe/Sarajevo",
45: "Africa/Lagos",
46: "Africa/Windhoek",
47: "Asia/Amman",
48: "Europe/Athens",
49: "Asia/Beirut",
50: "Africa/Cairo",
51: "Asia/Damascus",
52: "EET",
53: "Africa/Harare",
54: "Europe/Helsinki",
55: "Asia/Istanbul",
56: "Asia/Jerusalem",
57: "Europe/Kaliningrad",
58: "Africa/Tripoli",
59: "Asia/Baghdad",
60: "Asia/Kuwait",
61: "Europe/Minsk",
62: "Europe/Moscow",
63: "Africa/Nairobi",
64: "Asia/Tehran",
65: "Asia/Muscat",
66: "Asia/Baku",
67: "Europe/Samara",
68: "Indian/Mauritius",
69: "Asia/Tbilisi",
70: "Asia/Yerevan",
71: "Asia/Kabul",
72: "Asia/Ashgabat",
73: "Asia/Yekaterinburg",
74: "Asia/Karachi",
75: "Asia/Kolkata",
76: "Asia/Colombo",
77: "Asia/Kathmandu",
78: "Asia/Almaty",
79: "Asia/Dhaka",
80: "Asia/Novosibirsk",
81: "Asia/Rangoon",
82: "Asia/Bangkok",
83: "Asia/Krasnoyarsk",
84: "Asia/Chongqing",
85: "Asia/Irkutsk",
86: "Asia/Singapore",
87: "Australia/Perth",
88: "Asia/Taipei",
89: "Asia/Ulaanbaatar",
90: "Asia/Tokyo",
91: "Asia/Seoul",
92: "Asia/Yakutsk",
93: "Australia/Adelaide",
94: "Australia/Darwin",
95: "Australia/Brisbane",
96: "Australia/Canberra",
97: "Pacific/Guam",
98: "Australia/Hobart",
99: "Antarctica/DumontDUrville",
100: "Asia/Magadan",
101: "Asia/Srednekolymsk",
102: "Etc/GMT-11",
103: "Asia/Anadyr",
104: "Pacific/Auckland",
105: "Etc/GMT-12",
106: "Pacific/Fiji",
107: "Etc/GMT-13",
108: "Pacific/Apia",
109: "Etc/GMT-14",
}
2 changes: 1 addition & 1 deletion kasa/iot/modules/emeter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
class Emeter(Usage, EnergyInterface):
"""Emeter module."""

def _post_update_hook(self) -> None:
async def _post_update_hook(self) -> None:
self._supported = EnergyInterface.ModuleFeature.PERIODIC_STATS
if (
"voltage_mv" in self.data["get_realtime"]
Expand Down
2 changes: 1 addition & 1 deletion kasa/iot/modules/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ def state(self) -> LightState:
"""Return the current light state."""
return self._light_state

def _post_update_hook(self) -> None:
async def _post_update_hook(self) -> None:
if self._device.is_on is False:
state = LightState(light_on=False)
else:
Expand Down
2 changes: 1 addition & 1 deletion kasa/iot/modules/lightpreset.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class LightPreset(IotModule, LightPresetInterface):
_presets: dict[str, IotLightPreset]
_preset_list: list[str]

def _post_update_hook(self):
async def _post_update_hook(self):
"""Update the internal presets."""
self._presets = {
f"Light preset {index+1}": IotLightPreset(**vals)
Expand Down
20 changes: 15 additions & 5 deletions kasa/iot/modules/time.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,49 @@
"""Provides the current time and timezone information."""

from datetime import datetime
from __future__ import annotations

from datetime import datetime, timezone, tzinfo

from ...exceptions import KasaException
from ..iotmodule import IotModule, merge
from ..iottimezone import get_timezone


class Time(IotModule):
"""Implements the timezone settings."""

_timezone: tzinfo = timezone.utc

def query(self):
"""Request time and timezone."""
q = self.query_for_command("get_time")

merge(q, self.query_for_command("get_timezone"))
return q

async def _post_update_hook(self):
"""Perform actions after a device update."""
if res := self.data.get("get_timezone"):
self._timezone = await get_timezone(res.get("index"))

@property
def time(self) -> datetime:
"""Return current device time."""
res = self.data["get_time"]
return datetime(
time = datetime(
res["year"],
res["month"],
res["mday"],
res["hour"],
res["min"],
res["sec"],
)
return time.astimezone(self.timezone)

@property
def timezone(self):
def timezone(self) -> tzinfo:
"""Return current timezone."""
res = self.data["get_timezone"]
return res
return self._timezone

async def get_time(self):
"""Return current device time."""
Expand Down
2 changes: 1 addition & 1 deletion kasa/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def _initialize_features(self): # noqa: B027
children's modules.
"""

def _post_update_hook(self): # noqa: B027
async def _post_update_hook(self): # noqa: B027
"""Perform actions after a device update.

This can be implemented if a module needs to perform actions each time
Expand Down
2 changes: 1 addition & 1 deletion kasa/smart/modules/devicemodule.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class DeviceModule(SmartModule):

REQUIRED_COMPONENT = "device"

def _post_update_hook(self):
async def _post_update_hook(self):
"""Perform actions after a device update.

Overrides the default behaviour to disable a module if the query returns
Expand Down
2 changes: 1 addition & 1 deletion kasa/smart/modules/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ def state(self) -> LightState:
"""Return the current light state."""
return self._light_state

def _post_update_hook(self) -> None:
async def _post_update_hook(self) -> None:
if self._device.is_on is False:
state = LightState(light_on=False)
else:
Expand Down
2 changes: 1 addition & 1 deletion kasa/smart/modules/lighteffect.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class LightEffect(SmartModule, SmartLightEffect):
_effect_list: list[str]
_scenes_names_to_id: dict[str, str]

def _post_update_hook(self) -> None:
async def _post_update_hook(self) -> None:
"""Update internal effect state."""
# Copy the effects so scene name updates do not update the underlying dict.
effects = copy.deepcopy(
Expand Down
2 changes: 1 addition & 1 deletion kasa/smart/modules/lightpreset.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def __init__(self, device: SmartDevice, module: str):
self._state_in_sysinfo = self.SYS_INFO_STATE_KEY in device.sys_info
self._brightness_only: bool = False

def _post_update_hook(self):
async def _post_update_hook(self):
"""Update the internal presets."""
index = 0
self._presets = {}
Expand Down
Loading