mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-02-27 13:20:05 +00:00
Implement IOT Time Module Failover (#1583)
Some checks failed
CI / Perform Lint Checks (3.13) (push) Has been cancelled
CodeQL Checks / Analyze (python) (push) Has been cancelled
CI / Python 3.11 on macos-latest (push) Has been cancelled
CI / Python 3.12 on macos-latest (push) Has been cancelled
CI / Python 3.13 on macos-latest (push) Has been cancelled
CI / Python 3.11 on ubuntu-latest (push) Has been cancelled
CI / Python 3.12 on ubuntu-latest (push) Has been cancelled
CI / Python 3.13 on ubuntu-latest (push) Has been cancelled
CI / Python 3.11 on windows-latest (push) Has been cancelled
CI / Python 3.12 on windows-latest (push) Has been cancelled
CI / Python 3.13 on windows-latest (push) Has been cancelled
Stale / stale (push) Has been cancelled
Some checks failed
CI / Perform Lint Checks (3.13) (push) Has been cancelled
CodeQL Checks / Analyze (python) (push) Has been cancelled
CI / Python 3.11 on macos-latest (push) Has been cancelled
CI / Python 3.12 on macos-latest (push) Has been cancelled
CI / Python 3.13 on macos-latest (push) Has been cancelled
CI / Python 3.11 on ubuntu-latest (push) Has been cancelled
CI / Python 3.12 on ubuntu-latest (push) Has been cancelled
CI / Python 3.13 on ubuntu-latest (push) Has been cancelled
CI / Python 3.11 on windows-latest (push) Has been cancelled
CI / Python 3.12 on windows-latest (push) Has been cancelled
CI / Python 3.13 on windows-latest (push) Has been cancelled
Stale / stale (push) Has been cancelled
Adds a failover in the IOT Time Module to handle issues where the system default time zone files don't have the correct time zone info.
This commit is contained in:
@@ -3,9 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, tzinfo
|
||||
from datetime import UTC, datetime, timedelta, timezone, tzinfo
|
||||
from typing import cast
|
||||
from zoneinfo import ZoneInfo
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from ..cachedzoneinfo import CachedZoneInfo
|
||||
|
||||
@@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def get_timezone(index: int) -> tzinfo:
|
||||
"""Get the timezone from the index."""
|
||||
if index > 109:
|
||||
if index < 0 or index > 109:
|
||||
_LOGGER.error(
|
||||
"Unexpected index %s not configured as a timezone, defaulting to UTC", index
|
||||
)
|
||||
@@ -25,7 +25,12 @@ async def get_timezone(index: int) -> tzinfo:
|
||||
|
||||
|
||||
async def get_timezone_index(tzone: tzinfo) -> int:
|
||||
"""Return the iot firmware index for a valid IANA timezone key."""
|
||||
"""Return the iot firmware index for a valid IANA timezone key.
|
||||
|
||||
If tzinfo is a ZoneInfo and its key is in TIMEZONE_INDEX, return that index.
|
||||
Otherwise, compare annual offset behavior to find the best match.
|
||||
Indices that cannot be loaded on this host are skipped.
|
||||
"""
|
||||
if isinstance(tzone, ZoneInfo):
|
||||
name = tzone.key
|
||||
rev = {val: key for key, val in TIMEZONE_INDEX.items()}
|
||||
@@ -33,14 +38,23 @@ async def get_timezone_index(tzone: tzinfo) -> int:
|
||||
return rev[name]
|
||||
|
||||
for i in range(110):
|
||||
if _is_same_timezone(tzone, await get_timezone(i)):
|
||||
try:
|
||||
cand = await get_timezone(i)
|
||||
except ZoneInfoNotFoundError:
|
||||
continue
|
||||
if _is_same_timezone(tzone, cand):
|
||||
return i
|
||||
raise ValueError("Device does not support timezone %s", name)
|
||||
raise ValueError(
|
||||
f"Device does not support timezone {getattr(tzone, 'key', tzone)!r}"
|
||||
)
|
||||
|
||||
|
||||
async def get_matching_timezones(tzone: tzinfo) -> list[str]:
|
||||
"""Return the iot firmware index for a valid IANA timezone key."""
|
||||
matches = []
|
||||
"""Return available IANA keys from TIMEZONE_INDEX that match the given tzinfo.
|
||||
|
||||
Skips zones that cannot be resolved on the host.
|
||||
"""
|
||||
matches: list[str] = []
|
||||
if isinstance(tzone, ZoneInfo):
|
||||
name = tzone.key
|
||||
vals = {val for val in TIMEZONE_INDEX.values()}
|
||||
@@ -48,7 +62,10 @@ async def get_matching_timezones(tzone: tzinfo) -> list[str]:
|
||||
matches.append(name)
|
||||
|
||||
for i in range(110):
|
||||
fw_tz = await get_timezone(i)
|
||||
try:
|
||||
fw_tz = await get_timezone(i)
|
||||
except ZoneInfoNotFoundError:
|
||||
continue
|
||||
if _is_same_timezone(tzone, fw_tz):
|
||||
match_key = cast(ZoneInfo, fw_tz).key
|
||||
if match_key not in matches:
|
||||
@@ -57,11 +74,7 @@ async def get_matching_timezones(tzone: tzinfo) -> list[str]:
|
||||
|
||||
|
||||
def _is_same_timezone(tzone1: tzinfo, tzone2: tzinfo) -> bool:
|
||||
"""Return true if the timezones have the same utcffset and dst offset.
|
||||
|
||||
Iot devices only support a limited static list of IANA timezones; this is used to
|
||||
check if a static timezone matches the same utc offset and dst settings.
|
||||
"""
|
||||
"""Return true if the timezones have the same UTC offset each day of the year."""
|
||||
now = datetime.now()
|
||||
start_day = datetime(now.year, 1, 1, 12)
|
||||
for i in range(365):
|
||||
@@ -71,6 +84,83 @@ def _is_same_timezone(tzone1: tzinfo, tzone2: tzinfo) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _dst_expected_from_key(key: str) -> bool | None:
|
||||
"""Infer if a zone key implies DST behavior (heuristic, no manual map).
|
||||
|
||||
- Posix-style keys with two abbreviations like 'CST6CDT', 'MST7MDT' -> True
|
||||
- Fixed abbreviation keys like 'EST', 'MST', 'HST' -> False
|
||||
- 'Etc/*' zones are fixed-offset -> False
|
||||
- Otherwise unknown -> None
|
||||
"""
|
||||
k = key.upper()
|
||||
if k.startswith("ETC/"):
|
||||
return False
|
||||
# Two abbreviations with a number in between (e.g., CST6CDT)
|
||||
if any(ch.isdigit() for ch in k) and any(
|
||||
x in k for x in ("CDT", "PDT", "MDT", "EDT")
|
||||
):
|
||||
return True
|
||||
if k in {"UTC", "UCT", "GMT", "EST", "MST", "HST", "PST"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _expected_dst_behavior_for_index(index: int) -> bool | None:
|
||||
"""Return whether the given index implies a DST-observing zone."""
|
||||
key = TIMEZONE_INDEX[index]
|
||||
return _dst_expected_from_key(key)
|
||||
|
||||
|
||||
async def _guess_timezone_by_offset(
|
||||
offset: timedelta, when_utc: datetime, dst_expected: bool | None = None
|
||||
) -> tzinfo:
|
||||
"""Pick a ZoneInfo from TIMEZONE_INDEX that exists on this host and matches.
|
||||
|
||||
- offset: device's UTC offset at 'when_utc'
|
||||
- when_utc: reference instant; naive is treated as UTC
|
||||
- dst_expected: if True/False, prefer candidates that do/do not observe DST annually
|
||||
|
||||
Returns the lowest-index matching ZoneInfo for determinism.
|
||||
If none match, returns a fixed-offset timezone as a last resort.
|
||||
"""
|
||||
if when_utc.tzinfo is None:
|
||||
when_utc = when_utc.replace(tzinfo=UTC)
|
||||
else:
|
||||
when_utc = when_utc.astimezone(UTC)
|
||||
|
||||
year = when_utc.year
|
||||
# Reference mid-winter and mid-summer dates to detect DST-observing candidates
|
||||
jan_ref = datetime(year, 1, 15, 12, tzinfo=UTC)
|
||||
jul_ref = datetime(year, 7, 15, 12, tzinfo=UTC)
|
||||
|
||||
candidates: list[tuple[int, tzinfo, bool]] = []
|
||||
for idx, name in TIMEZONE_INDEX.items():
|
||||
try:
|
||||
tz = await CachedZoneInfo.get_cached_zone_info(name)
|
||||
except ZoneInfoNotFoundError:
|
||||
continue
|
||||
|
||||
cand_offset_now = when_utc.astimezone(tz).utcoffset()
|
||||
if cand_offset_now != offset:
|
||||
continue
|
||||
|
||||
# Determine if this candidate observes DST (offset differs between Jan and Jul)
|
||||
jan_off = jan_ref.astimezone(tz).utcoffset()
|
||||
jul_off = jul_ref.astimezone(tz).utcoffset()
|
||||
cand_observes_dst = jan_off != jul_off
|
||||
|
||||
if dst_expected is None or cand_observes_dst == dst_expected:
|
||||
candidates.append((idx, tz, cand_observes_dst))
|
||||
|
||||
if candidates:
|
||||
candidates.sort(key=lambda it: it[0])
|
||||
chosen = candidates[0][1]
|
||||
return chosen
|
||||
|
||||
# No ZoneInfo matched; return fixed offset as a last resort
|
||||
return timezone(offset)
|
||||
|
||||
|
||||
TIMEZONE_INDEX = {
|
||||
0: "Etc/GMT+12",
|
||||
1: "Pacific/Samoa",
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, tzinfo
|
||||
import contextlib
|
||||
from datetime import UTC, datetime, timedelta, tzinfo
|
||||
from zoneinfo import ZoneInfoNotFoundError
|
||||
|
||||
from ...exceptions import KasaException
|
||||
from ...interfaces import Time as TimeInterface
|
||||
from ..iotmodule import IotModule, merge
|
||||
from ..iottimezone import get_timezone, get_timezone_index
|
||||
from ..iottimezone import (
|
||||
_expected_dst_behavior_for_index,
|
||||
_guess_timezone_by_offset,
|
||||
get_timezone,
|
||||
get_timezone_index,
|
||||
)
|
||||
|
||||
|
||||
class Time(IotModule, TimeInterface):
|
||||
@@ -23,9 +30,46 @@ class Time(IotModule, TimeInterface):
|
||||
return q
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
"""Perform actions after a device update."""
|
||||
"""Perform actions after a device update.
|
||||
|
||||
If the configured zone is not available on this host, compute the device's
|
||||
current UTC offset and choose a best-match available zone, preferring DST-
|
||||
observing candidates when the original index implies DST. As a last resort,
|
||||
use a fixed-offset timezone.
|
||||
"""
|
||||
if res := self.data.get("get_timezone"):
|
||||
self._timezone = await get_timezone(res.get("index"))
|
||||
idx = res.get("index")
|
||||
try:
|
||||
self._timezone = await get_timezone(idx)
|
||||
return
|
||||
except ZoneInfoNotFoundError:
|
||||
pass # fall through to offset-based match
|
||||
|
||||
gt = self.data.get("get_time")
|
||||
if gt:
|
||||
device_local = datetime(
|
||||
gt["year"],
|
||||
gt["month"],
|
||||
gt["mday"],
|
||||
gt["hour"],
|
||||
gt["min"],
|
||||
gt["sec"],
|
||||
)
|
||||
now_utc = datetime.now(UTC)
|
||||
delta = device_local - now_utc.replace(tzinfo=None)
|
||||
rounded = timedelta(seconds=60 * round(delta.total_seconds() / 60))
|
||||
|
||||
dst_expected = None
|
||||
if res := self.data.get("get_timezone"):
|
||||
idx = res.get("index")
|
||||
with contextlib.suppress(KeyError):
|
||||
dst_expected = _expected_dst_behavior_for_index(idx)
|
||||
|
||||
self._timezone = await _guess_timezone_by_offset(
|
||||
rounded, when_utc=now_utc, dst_expected=dst_expected
|
||||
)
|
||||
else:
|
||||
self._timezone = UTC
|
||||
|
||||
@property
|
||||
def time(self) -> datetime:
|
||||
|
||||
Reference in New Issue
Block a user