mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-22 11:13:34 +00:00
Make iot time timezone aware (#1147)
Also makes on_since for iot devices use device time. Changes the return value for device.timezone to be tzinfo instead of a dict.
This commit is contained in:
parent
8bb2cca7cf
commit
9641edcbc0
@ -40,7 +40,7 @@ async def state(ctx, dev: Device):
|
|||||||
echo(f"Port: {dev.port}")
|
echo(f"Port: {dev.port}")
|
||||||
echo(f"Device state: {dev.is_on}")
|
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"Hardware: {dev.hw_info['hw_ver']}")
|
||||||
echo(f"Software: {dev.hw_info['sw_ver']}")
|
echo(f"Software: {dev.hw_info['sw_ver']}")
|
||||||
echo(f"MAC (rssi): {dev.mac} ({dev.rssi})")
|
echo(f"MAC (rssi): {dev.mac} ({dev.rssi})")
|
||||||
|
@ -109,7 +109,7 @@ import logging
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime, tzinfo
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from warnings import warn
|
from warnings import warn
|
||||||
|
|
||||||
@ -377,7 +377,7 @@ class Device(ABC):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def timezone(self) -> dict:
|
def timezone(self) -> tzinfo:
|
||||||
"""Return the timezone and time_difference."""
|
"""Return the timezone and time_difference."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -18,7 +18,7 @@ import functools
|
|||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Mapping, Sequence
|
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 typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from ..device import Device, WifiNetwork
|
from ..device import Device, WifiNetwork
|
||||||
@ -299,7 +299,7 @@ class IotDevice(Device):
|
|||||||
|
|
||||||
self._set_sys_info(self._last_update["system"]["get_sysinfo"])
|
self._set_sys_info(self._last_update["system"]["get_sysinfo"])
|
||||||
for module in self._modules.values():
|
for module in self._modules.values():
|
||||||
module._post_update_hook()
|
await module._post_update_hook()
|
||||||
|
|
||||||
if not self._features:
|
if not self._features:
|
||||||
await self._initialize_features()
|
await self._initialize_features()
|
||||||
@ -464,7 +464,7 @@ class IotDevice(Device):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
@requires_update
|
@requires_update
|
||||||
def timezone(self) -> dict:
|
def timezone(self) -> tzinfo:
|
||||||
"""Return the current timezone."""
|
"""Return the current timezone."""
|
||||||
return self.modules[Module.IotTime].timezone
|
return self.modules[Module.IotTime].timezone
|
||||||
|
|
||||||
@ -606,9 +606,7 @@ class IotDevice(Device):
|
|||||||
|
|
||||||
on_time = self._sys_info["on_time"]
|
on_time = self._sys_info["on_time"]
|
||||||
|
|
||||||
time = datetime.now(timezone.utc).astimezone().replace(microsecond=0)
|
on_since = self.time - timedelta(seconds=on_time)
|
||||||
|
|
||||||
on_since = time - timedelta(seconds=on_time)
|
|
||||||
if not self._on_since or timedelta(
|
if not self._on_since or timedelta(
|
||||||
seconds=0
|
seconds=0
|
||||||
) < on_since - self._on_since > timedelta(seconds=5):
|
) < on_since - self._on_since > timedelta(seconds=5):
|
||||||
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..device_type import DeviceType
|
from ..device_type import DeviceType
|
||||||
@ -373,7 +373,7 @@ class IotStripPlug(IotPlug):
|
|||||||
"""
|
"""
|
||||||
await self._modular_update({})
|
await self._modular_update({})
|
||||||
for module in self._modules.values():
|
for module in self._modules.values():
|
||||||
module._post_update_hook()
|
await module._post_update_hook()
|
||||||
|
|
||||||
if not self._features:
|
if not self._features:
|
||||||
await self._initialize_features()
|
await self._initialize_features()
|
||||||
@ -445,7 +445,7 @@ class IotStripPlug(IotPlug):
|
|||||||
info = self._get_child_info()
|
info = self._get_child_info()
|
||||||
on_time = info["on_time"]
|
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)
|
on_since = time - timedelta(seconds=on_time)
|
||||||
if not self._on_since or timedelta(
|
if not self._on_since or timedelta(
|
||||||
|
178
kasa/iot/iottimezone.py
Normal file
178
kasa/iot/iottimezone.py
Normal file
@ -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",
|
||||||
|
}
|
@ -12,7 +12,7 @@ from .usage import Usage
|
|||||||
class Emeter(Usage, EnergyInterface):
|
class Emeter(Usage, EnergyInterface):
|
||||||
"""Emeter module."""
|
"""Emeter module."""
|
||||||
|
|
||||||
def _post_update_hook(self) -> None:
|
async def _post_update_hook(self) -> None:
|
||||||
self._supported = EnergyInterface.ModuleFeature.PERIODIC_STATS
|
self._supported = EnergyInterface.ModuleFeature.PERIODIC_STATS
|
||||||
if (
|
if (
|
||||||
"voltage_mv" in self.data["get_realtime"]
|
"voltage_mv" in self.data["get_realtime"]
|
||||||
|
@ -239,7 +239,7 @@ class Light(IotModule, LightInterface):
|
|||||||
"""Return the current light state."""
|
"""Return the current light state."""
|
||||||
return self._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:
|
if self._device.is_on is False:
|
||||||
state = LightState(light_on=False)
|
state = LightState(light_on=False)
|
||||||
else:
|
else:
|
||||||
|
@ -41,7 +41,7 @@ class LightPreset(IotModule, LightPresetInterface):
|
|||||||
_presets: dict[str, IotLightPreset]
|
_presets: dict[str, IotLightPreset]
|
||||||
_preset_list: list[str]
|
_preset_list: list[str]
|
||||||
|
|
||||||
def _post_update_hook(self):
|
async def _post_update_hook(self):
|
||||||
"""Update the internal presets."""
|
"""Update the internal presets."""
|
||||||
self._presets = {
|
self._presets = {
|
||||||
f"Light preset {index+1}": IotLightPreset(**vals)
|
f"Light preset {index+1}": IotLightPreset(**vals)
|
||||||
|
@ -1,14 +1,19 @@
|
|||||||
"""Provides the current time and timezone information."""
|
"""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 ...exceptions import KasaException
|
||||||
from ..iotmodule import IotModule, merge
|
from ..iotmodule import IotModule, merge
|
||||||
|
from ..iottimezone import get_timezone
|
||||||
|
|
||||||
|
|
||||||
class Time(IotModule):
|
class Time(IotModule):
|
||||||
"""Implements the timezone settings."""
|
"""Implements the timezone settings."""
|
||||||
|
|
||||||
|
_timezone: tzinfo = timezone.utc
|
||||||
|
|
||||||
def query(self):
|
def query(self):
|
||||||
"""Request time and timezone."""
|
"""Request time and timezone."""
|
||||||
q = self.query_for_command("get_time")
|
q = self.query_for_command("get_time")
|
||||||
@ -16,11 +21,16 @@ class Time(IotModule):
|
|||||||
merge(q, self.query_for_command("get_timezone"))
|
merge(q, self.query_for_command("get_timezone"))
|
||||||
return q
|
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
|
@property
|
||||||
def time(self) -> datetime:
|
def time(self) -> datetime:
|
||||||
"""Return current device time."""
|
"""Return current device time."""
|
||||||
res = self.data["get_time"]
|
res = self.data["get_time"]
|
||||||
return datetime(
|
time = datetime(
|
||||||
res["year"],
|
res["year"],
|
||||||
res["month"],
|
res["month"],
|
||||||
res["mday"],
|
res["mday"],
|
||||||
@ -28,12 +38,12 @@ class Time(IotModule):
|
|||||||
res["min"],
|
res["min"],
|
||||||
res["sec"],
|
res["sec"],
|
||||||
)
|
)
|
||||||
|
return time.astimezone(self.timezone)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def timezone(self):
|
def timezone(self) -> tzinfo:
|
||||||
"""Return current timezone."""
|
"""Return current timezone."""
|
||||||
res = self.data["get_timezone"]
|
return self._timezone
|
||||||
return res
|
|
||||||
|
|
||||||
async def get_time(self):
|
async def get_time(self):
|
||||||
"""Return current device time."""
|
"""Return current device time."""
|
||||||
|
@ -155,7 +155,7 @@ class Module(ABC):
|
|||||||
children's modules.
|
children's modules.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _post_update_hook(self): # noqa: B027
|
async def _post_update_hook(self): # noqa: B027
|
||||||
"""Perform actions after a device update.
|
"""Perform actions after a device update.
|
||||||
|
|
||||||
This can be implemented if a module needs to perform actions each time
|
This can be implemented if a module needs to perform actions each time
|
||||||
|
@ -10,7 +10,7 @@ class DeviceModule(SmartModule):
|
|||||||
|
|
||||||
REQUIRED_COMPONENT = "device"
|
REQUIRED_COMPONENT = "device"
|
||||||
|
|
||||||
def _post_update_hook(self):
|
async def _post_update_hook(self):
|
||||||
"""Perform actions after a device update.
|
"""Perform actions after a device update.
|
||||||
|
|
||||||
Overrides the default behaviour to disable a module if the query returns
|
Overrides the default behaviour to disable a module if the query returns
|
||||||
|
@ -152,7 +152,7 @@ class Light(SmartModule, LightInterface):
|
|||||||
"""Return the current light state."""
|
"""Return the current light state."""
|
||||||
return self._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:
|
if self._device.is_on is False:
|
||||||
state = LightState(light_on=False)
|
state = LightState(light_on=False)
|
||||||
else:
|
else:
|
||||||
|
@ -28,7 +28,7 @@ class LightEffect(SmartModule, SmartLightEffect):
|
|||||||
_effect_list: list[str]
|
_effect_list: list[str]
|
||||||
_scenes_names_to_id: dict[str, 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."""
|
"""Update internal effect state."""
|
||||||
# Copy the effects so scene name updates do not update the underlying dict.
|
# Copy the effects so scene name updates do not update the underlying dict.
|
||||||
effects = copy.deepcopy(
|
effects = copy.deepcopy(
|
||||||
|
@ -34,7 +34,7 @@ class LightPreset(SmartModule, LightPresetInterface):
|
|||||||
self._state_in_sysinfo = self.SYS_INFO_STATE_KEY in device.sys_info
|
self._state_in_sysinfo = self.SYS_INFO_STATE_KEY in device.sys_info
|
||||||
self._brightness_only: bool = False
|
self._brightness_only: bool = False
|
||||||
|
|
||||||
def _post_update_hook(self):
|
async def _post_update_hook(self):
|
||||||
"""Update the internal presets."""
|
"""Update the internal presets."""
|
||||||
index = 0
|
index = 0
|
||||||
self._presets = {}
|
self._presets = {}
|
||||||
|
@ -90,7 +90,7 @@ class LightTransition(SmartModule):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _post_update_hook(self) -> None:
|
async def _post_update_hook(self) -> None:
|
||||||
"""Update the states."""
|
"""Update the states."""
|
||||||
# Assumes any device with state in sysinfo supports on and off and
|
# Assumes any device with state in sysinfo supports on and off and
|
||||||
# has maximum values for both.
|
# has maximum values for both.
|
||||||
|
@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone, tzinfo
|
||||||
from time import mktime
|
from time import mktime
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from ...feature import Feature
|
from ...feature import Feature
|
||||||
from ..smartmodule import SmartModule
|
from ..smartmodule import SmartModule
|
||||||
|
|
||||||
@ -31,18 +33,27 @@ class Time(SmartModule):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def time(self) -> datetime:
|
def timezone(self) -> tzinfo:
|
||||||
"""Return device's current datetime."""
|
"""Return current timezone."""
|
||||||
td = timedelta(minutes=cast(float, self.data.get("time_diff")))
|
td = timedelta(minutes=cast(float, self.data.get("time_diff")))
|
||||||
if self.data.get("region"):
|
if region := self.data.get("region"):
|
||||||
tz = timezone(td, str(self.data.get("region")))
|
try:
|
||||||
|
# Zoneinfo will return a DST aware object
|
||||||
|
tz: tzinfo = ZoneInfo(region)
|
||||||
|
except ZoneInfoNotFoundError:
|
||||||
|
tz = timezone(td, region)
|
||||||
else:
|
else:
|
||||||
# in case the device returns a blank region this will result in the
|
# in case the device returns a blank region this will result in the
|
||||||
# tzname being a UTC offset
|
# tzname being a UTC offset
|
||||||
tz = timezone(td)
|
tz = timezone(td)
|
||||||
|
return tz
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time(self) -> datetime:
|
||||||
|
"""Return device's current datetime."""
|
||||||
return datetime.fromtimestamp(
|
return datetime.fromtimestamp(
|
||||||
cast(float, self.data.get("timestamp")),
|
cast(float, self.data.get("timestamp")),
|
||||||
tz=tz,
|
tz=self.timezone,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_time(self, dt: datetime):
|
async def set_time(self, dt: datetime):
|
||||||
|
@ -61,7 +61,7 @@ class SmartChildDevice(SmartDevice):
|
|||||||
self._last_update = await self.protocol.query(req)
|
self._last_update = await self.protocol.query(req)
|
||||||
|
|
||||||
for module in self.modules.values():
|
for module in self.modules.values():
|
||||||
self._handle_module_post_update(
|
await self._handle_module_post_update(
|
||||||
module, now, had_query=module in module_queries
|
module, now, had_query=module in module_queries
|
||||||
)
|
)
|
||||||
self._last_update_time = now
|
self._last_update_time = now
|
||||||
|
@ -6,8 +6,8 @@ import base64
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone, tzinfo
|
||||||
from typing import Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from ..aestransport import AesTransport
|
from ..aestransport import AesTransport
|
||||||
from ..device import Device, WifiNetwork
|
from ..device import Device, WifiNetwork
|
||||||
@ -168,7 +168,7 @@ class SmartDevice(Device):
|
|||||||
await self._initialize_modules()
|
await self._initialize_modules()
|
||||||
# Run post update for the cloud module
|
# Run post update for the cloud module
|
||||||
if cloud_mod := self.modules.get(Module.Cloud):
|
if cloud_mod := self.modules.get(Module.Cloud):
|
||||||
self._handle_module_post_update(cloud_mod, now, had_query=True)
|
await self._handle_module_post_update(cloud_mod, now, had_query=True)
|
||||||
|
|
||||||
resp = await self._modular_update(first_update, now)
|
resp = await self._modular_update(first_update, now)
|
||||||
|
|
||||||
@ -195,7 +195,7 @@ class SmartDevice(Device):
|
|||||||
updated = self._last_update if first_update else resp
|
updated = self._last_update if first_update else resp
|
||||||
_LOGGER.debug("Update completed %s: %s", self.host, list(updated.keys()))
|
_LOGGER.debug("Update completed %s: %s", self.host, list(updated.keys()))
|
||||||
|
|
||||||
def _handle_module_post_update(
|
async def _handle_module_post_update(
|
||||||
self, module: SmartModule, update_time: float, had_query: bool
|
self, module: SmartModule, update_time: float, had_query: bool
|
||||||
):
|
):
|
||||||
if module.disabled:
|
if module.disabled:
|
||||||
@ -203,7 +203,7 @@ class SmartDevice(Device):
|
|||||||
if had_query:
|
if had_query:
|
||||||
module._last_update_time = update_time
|
module._last_update_time = update_time
|
||||||
try:
|
try:
|
||||||
module._post_update_hook()
|
await module._post_update_hook()
|
||||||
module._set_error(None)
|
module._set_error(None)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
# Only set the error if a query happened.
|
# Only set the error if a query happened.
|
||||||
@ -260,7 +260,7 @@ class SmartDevice(Device):
|
|||||||
|
|
||||||
# Call handle update for modules that want to update internal data
|
# Call handle update for modules that want to update internal data
|
||||||
for module in self._modules.values():
|
for module in self._modules.values():
|
||||||
self._handle_module_post_update(
|
await self._handle_module_post_update(
|
||||||
module, update_time, had_query=module in module_queries
|
module, update_time, had_query=module in module_queries
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -516,10 +516,11 @@ class SmartDevice(Device):
|
|||||||
return self._on_since
|
return self._on_since
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def timezone(self) -> dict:
|
def timezone(self) -> tzinfo:
|
||||||
"""Return the timezone and time_difference."""
|
"""Return the timezone and time_difference."""
|
||||||
ti = self.time
|
if TYPE_CHECKING:
|
||||||
return {"timezone": ti.tzname()}
|
assert self.time.tzinfo
|
||||||
|
return self.time.tzinfo
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hw_info(self) -> dict:
|
def hw_info(self) -> dict:
|
||||||
|
@ -121,7 +121,7 @@ class SmartModule(Module):
|
|||||||
"""Name of the module."""
|
"""Name of the module."""
|
||||||
return getattr(self, "NAME", self.__class__.__name__)
|
return getattr(self, "NAME", self.__class__.__name__)
|
||||||
|
|
||||||
def _post_update_hook(self): # noqa: B027
|
async def _post_update_hook(self): # noqa: B027
|
||||||
"""Perform actions after a device update.
|
"""Perform actions after a device update.
|
||||||
|
|
||||||
Any modules overriding this should ensure that self.data is
|
Any modules overriding this should ensure that self.data is
|
||||||
|
@ -10,10 +10,16 @@ from contextlib import AbstractContextManager
|
|||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import zoneinfo
|
||||||
|
|
||||||
import kasa
|
import kasa
|
||||||
from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module
|
from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module
|
||||||
from kasa.iot import IotDevice
|
from kasa.iot import IotDevice
|
||||||
|
from kasa.iot.iottimezone import (
|
||||||
|
TIMEZONE_INDEX,
|
||||||
|
get_timezone,
|
||||||
|
get_timezone_index,
|
||||||
|
)
|
||||||
from kasa.iot.modules import IotLightPreset
|
from kasa.iot.modules import IotLightPreset
|
||||||
from kasa.smart import SmartChildDevice, SmartDevice
|
from kasa.smart import SmartChildDevice, SmartDevice
|
||||||
|
|
||||||
@ -299,3 +305,29 @@ async def test_device_type_aliases():
|
|||||||
)
|
)
|
||||||
assert isinstance(dev.config, DeviceConfig)
|
assert isinstance(dev.config, DeviceConfig)
|
||||||
assert DeviceType.Dimmer == Device.Type.Dimmer
|
assert DeviceType.Dimmer == Device.Type.Dimmer
|
||||||
|
|
||||||
|
|
||||||
|
async def test_device_timezones():
|
||||||
|
"""Test the timezone data is good."""
|
||||||
|
# Check all indexes return a zoneinfo
|
||||||
|
for i in range(110):
|
||||||
|
tz = await get_timezone(i)
|
||||||
|
assert tz
|
||||||
|
assert tz != zoneinfo.ZoneInfo("Etc/UTC"), f"{i} is default Etc/UTC"
|
||||||
|
|
||||||
|
# Check an unexpected index returns a UTC default.
|
||||||
|
tz = await get_timezone(110)
|
||||||
|
assert tz == zoneinfo.ZoneInfo("Etc/UTC")
|
||||||
|
|
||||||
|
# Get an index from a timezone
|
||||||
|
for index, zone in TIMEZONE_INDEX.items():
|
||||||
|
found_index = await get_timezone_index(zone)
|
||||||
|
assert found_index == index
|
||||||
|
|
||||||
|
# Try a timezone not hardcoded finds another match
|
||||||
|
index = await get_timezone_index("Asia/Katmandu")
|
||||||
|
assert index == 77
|
||||||
|
|
||||||
|
# Try a timezone not hardcoded no match
|
||||||
|
with pytest.raises(zoneinfo.ZoneInfoNotFoundError):
|
||||||
|
await get_timezone_index("Foo/bar")
|
||||||
|
@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"async-timeout>=3.0.0",
|
"async-timeout>=3.0.0",
|
||||||
"aiohttp>=3",
|
"aiohttp>=3",
|
||||||
"typing-extensions>=4.12.2,<5.0",
|
"typing-extensions>=4.12.2,<5.0",
|
||||||
|
"tzdata>=2024.2 ; platform_system == 'Windows'",
|
||||||
]
|
]
|
||||||
|
|
||||||
classifiers = [
|
classifiers = [
|
||||||
|
17
uv.lock
17
uv.lock
@ -1,8 +1,10 @@
|
|||||||
version = 1
|
version = 1
|
||||||
requires-python = ">=3.9, <4.0"
|
requires-python = ">=3.9, <4.0"
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version < '3.13'",
|
"python_full_version < '3.13' and platform_system == 'Windows'",
|
||||||
"python_full_version >= '3.13'",
|
"python_full_version < '3.13' and platform_system != 'Windows'",
|
||||||
|
"python_full_version >= '3.13' and platform_system == 'Windows'",
|
||||||
|
"python_full_version >= '3.13' and platform_system != 'Windows'",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1351,6 +1353,7 @@ dependencies = [
|
|||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "tzdata", marker = "platform_system == 'Windows'" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@ -1407,6 +1410,7 @@ requires-dist = [
|
|||||||
{ name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0" },
|
{ name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0" },
|
||||||
{ name = "sphinxcontrib-programoutput", marker = "extra == 'docs'", specifier = "~=0.0" },
|
{ name = "sphinxcontrib-programoutput", marker = "extra == 'docs'", specifier = "~=0.0" },
|
||||||
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0" },
|
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0" },
|
||||||
|
{ name = "tzdata", marker = "platform_system == 'Windows'", specifier = ">=2024.2" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
@ -1693,6 +1697,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
|
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzdata"
|
||||||
|
version = "2024.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e1/34/943888654477a574a86a98e9896bae89c7aa15078ec29f490fef2f1e5384/tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc", size = 193282 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.2.3"
|
version = "2.2.3"
|
||||||
|
Loading…
Reference in New Issue
Block a user