mirror of
				https://github.com/python-kasa/python-kasa.git
				synced 2025-10-31 12:41:54 +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:
		| @@ -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})") | ||||
|   | ||||
| @@ -109,7 +109,7 @@ import logging | ||||
| 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 | ||||
|  | ||||
| @@ -377,7 +377,7 @@ class Device(ABC): | ||||
|  | ||||
|     @property | ||||
|     @abstractmethod | ||||
|     def timezone(self) -> dict: | ||||
|     def timezone(self) -> tzinfo: | ||||
|         """Return the timezone and time_difference.""" | ||||
|  | ||||
|     @property | ||||
|   | ||||
| @@ -18,7 +18,7 @@ import functools | ||||
| 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 | ||||
| @@ -299,7 +299,7 @@ class IotDevice(Device): | ||||
|  | ||||
|         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() | ||||
| @@ -464,7 +464,7 @@ class IotDevice(Device): | ||||
|  | ||||
|     @property | ||||
|     @requires_update | ||||
|     def timezone(self) -> dict: | ||||
|     def timezone(self) -> tzinfo: | ||||
|         """Return the current timezone.""" | ||||
|         return self.modules[Module.IotTime].timezone | ||||
|  | ||||
| @@ -606,9 +606,7 @@ class IotDevice(Device): | ||||
|  | ||||
|         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): | ||||
|   | ||||
| @@ -4,7 +4,7 @@ from __future__ import annotations | ||||
|  | ||||
| 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 | ||||
| @@ -373,7 +373,7 @@ class IotStripPlug(IotPlug): | ||||
|         """ | ||||
|         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() | ||||
| @@ -445,7 +445,7 @@ class IotStripPlug(IotPlug): | ||||
|         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( | ||||
|   | ||||
							
								
								
									
										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): | ||||
|     """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"] | ||||
|   | ||||
| @@ -239,7 +239,7 @@ class Light(IotModule, LightInterface): | ||||
|         """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: | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -1,14 +1,19 @@ | ||||
| """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") | ||||
| @@ -16,11 +21,16 @@ class Time(IotModule): | ||||
|         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"], | ||||
| @@ -28,12 +38,12 @@ class Time(IotModule): | ||||
|             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.""" | ||||
|   | ||||
| @@ -155,7 +155,7 @@ class Module(ABC): | ||||
|         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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -152,7 +152,7 @@ class Light(SmartModule, LightInterface): | ||||
|         """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: | ||||
|   | ||||
| @@ -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( | ||||
|   | ||||
| @@ -34,7 +34,7 @@ class LightPreset(SmartModule, LightPresetInterface): | ||||
|         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 = {} | ||||
|   | ||||
| @@ -90,7 +90,7 @@ class LightTransition(SmartModule): | ||||
|                 ) | ||||
|             ) | ||||
|  | ||||
|     def _post_update_hook(self) -> None: | ||||
|     async def _post_update_hook(self) -> None: | ||||
|         """Update the states.""" | ||||
|         # Assumes any device with state in sysinfo supports on and off and | ||||
|         # has maximum values for both. | ||||
|   | ||||
| @@ -2,10 +2,12 @@ | ||||
|  | ||||
| from __future__ import annotations | ||||
|  | ||||
| from datetime import datetime, timedelta, timezone | ||||
| from datetime import datetime, timedelta, timezone, tzinfo | ||||
| from time import mktime | ||||
| from typing import cast | ||||
|  | ||||
| from zoneinfo import ZoneInfo, ZoneInfoNotFoundError | ||||
|  | ||||
| from ...feature import Feature | ||||
| from ..smartmodule import SmartModule | ||||
|  | ||||
| @@ -31,18 +33,27 @@ class Time(SmartModule): | ||||
|         ) | ||||
|  | ||||
|     @property | ||||
|     def time(self) -> datetime: | ||||
|         """Return device's current datetime.""" | ||||
|     def timezone(self) -> tzinfo: | ||||
|         """Return current timezone.""" | ||||
|         td = timedelta(minutes=cast(float, self.data.get("time_diff"))) | ||||
|         if self.data.get("region"): | ||||
|             tz = timezone(td, str(self.data.get("region"))) | ||||
|         if region := self.data.get("region"): | ||||
|             try: | ||||
|                 # Zoneinfo will return a DST aware object | ||||
|                 tz: tzinfo = ZoneInfo(region) | ||||
|             except ZoneInfoNotFoundError: | ||||
|                 tz = timezone(td, region) | ||||
|         else: | ||||
|             # in case the device returns a blank region this will result in the | ||||
|             # tzname being a UTC offset | ||||
|             tz = timezone(td) | ||||
|         return tz | ||||
|  | ||||
|     @property | ||||
|     def time(self) -> datetime: | ||||
|         """Return device's current datetime.""" | ||||
|         return datetime.fromtimestamp( | ||||
|             cast(float, self.data.get("timestamp")), | ||||
|             tz=tz, | ||||
|             tz=self.timezone, | ||||
|         ) | ||||
|  | ||||
|     async def set_time(self, dt: datetime): | ||||
|   | ||||
| @@ -61,7 +61,7 @@ class SmartChildDevice(SmartDevice): | ||||
|             self._last_update = await self.protocol.query(req) | ||||
|  | ||||
|         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 | ||||
|             ) | ||||
|         self._last_update_time = now | ||||
|   | ||||
| @@ -6,8 +6,8 @@ import base64 | ||||
| import logging | ||||
| import time | ||||
| from collections.abc import Mapping, Sequence | ||||
| from datetime import datetime, timedelta, timezone | ||||
| from typing import Any, cast | ||||
| from datetime import datetime, timedelta, timezone, tzinfo | ||||
| from typing import TYPE_CHECKING, Any, cast | ||||
|  | ||||
| from ..aestransport import AesTransport | ||||
| from ..device import Device, WifiNetwork | ||||
| @@ -168,7 +168,7 @@ class SmartDevice(Device): | ||||
|             await self._initialize_modules() | ||||
|             # Run post update for the cloud module | ||||
|             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) | ||||
|  | ||||
| @@ -195,7 +195,7 @@ class SmartDevice(Device): | ||||
|             updated = self._last_update if first_update else resp | ||||
|             _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 | ||||
|     ): | ||||
|         if module.disabled: | ||||
| @@ -203,7 +203,7 @@ class SmartDevice(Device): | ||||
|         if had_query: | ||||
|             module._last_update_time = update_time | ||||
|         try: | ||||
|             module._post_update_hook() | ||||
|             await module._post_update_hook() | ||||
|             module._set_error(None) | ||||
|         except Exception as ex: | ||||
|             # 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 | ||||
|         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 | ||||
|             ) | ||||
|  | ||||
| @@ -516,10 +516,11 @@ class SmartDevice(Device): | ||||
|         return self._on_since | ||||
|  | ||||
|     @property | ||||
|     def timezone(self) -> dict: | ||||
|     def timezone(self) -> tzinfo: | ||||
|         """Return the timezone and time_difference.""" | ||||
|         ti = self.time | ||||
|         return {"timezone": ti.tzname()} | ||||
|         if TYPE_CHECKING: | ||||
|             assert self.time.tzinfo | ||||
|         return self.time.tzinfo | ||||
|  | ||||
|     @property | ||||
|     def hw_info(self) -> dict: | ||||
|   | ||||
| @@ -121,7 +121,7 @@ class SmartModule(Module): | ||||
|         """Name of the module.""" | ||||
|         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. | ||||
|  | ||||
|         Any modules overriding this should ensure that self.data is | ||||
|   | ||||
| @@ -10,10 +10,16 @@ from contextlib import AbstractContextManager | ||||
| from unittest.mock import AsyncMock, patch | ||||
|  | ||||
| import pytest | ||||
| import zoneinfo | ||||
|  | ||||
| import kasa | ||||
| from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module | ||||
| 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.smart import SmartChildDevice, SmartDevice | ||||
|  | ||||
| @@ -299,3 +305,29 @@ async def test_device_type_aliases(): | ||||
|         ) | ||||
|         assert isinstance(dev.config, DeviceConfig) | ||||
|         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", | ||||
|   "aiohttp>=3", | ||||
|   "typing-extensions>=4.12.2,<5.0", | ||||
|   "tzdata>=2024.2 ; platform_system == 'Windows'", | ||||
| ] | ||||
|  | ||||
| classifiers = [ | ||||
|   | ||||
							
								
								
									
										17
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										17
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							| @@ -1,8 +1,10 @@ | ||||
| version = 1 | ||||
| requires-python = ">=3.9, <4.0" | ||||
| resolution-markers = [ | ||||
|     "python_full_version < '3.13'", | ||||
|     "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'", | ||||
|     "python_full_version >= '3.13' and platform_system != 'Windows'", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -1351,6 +1353,7 @@ dependencies = [ | ||||
|     { name = "cryptography" }, | ||||
|     { name = "pydantic" }, | ||||
|     { name = "typing-extensions" }, | ||||
|     { name = "tzdata", marker = "platform_system == 'Windows'" }, | ||||
| ] | ||||
|  | ||||
| [package.optional-dependencies] | ||||
| @@ -1407,6 +1410,7 @@ requires-dist = [ | ||||
|     { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0" }, | ||||
|     { name = "sphinxcontrib-programoutput", marker = "extra == 'docs'", specifier = "~=0.0" }, | ||||
|     { name = "typing-extensions", specifier = ">=4.12.2,<5.0" }, | ||||
|     { name = "tzdata", marker = "platform_system == 'Windows'", specifier = ">=2024.2" }, | ||||
| ] | ||||
|  | ||||
| [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 }, | ||||
| ] | ||||
|  | ||||
| [[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]] | ||||
| name = "urllib3" | ||||
| version = "2.2.3" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Steven B.
					Steven B.