8000 Implement IOT Time Module Failover by ZeliardM · Pull Request #1583 · python-kasa/python-kasa · GitHub
[go: up one dir, main page]

Skip to content
8000
Open
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
121 changes: 107 additions & 14 deletions kasa/iot/iottimezone.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -14,7 +14,7 @@

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
)
Expand All @@ -25,30 +25,47 @@ 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()}
if name in rev:
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()}
if name in vals:
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:
Expand All @@ -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):
Expand All @@ -71,6 +84,86 @@ 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."""
try:
key = TIMEZONE_INDEX[index]
except KeyError:
return None
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",
Expand Down
50 changes: 46 additions & 4 deletions kasa/iot/modules/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@

from __future__ import annotations

from datetime import UTC, datetime, tzinfo
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,
get_timezone,
get_timezone_index,
guess_timezone_by_offset,
)


class Time(IotModule, TimeInterface):
Expand All @@ -23,9 +29,45 @@ def query(self) -> dict:
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")
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:
Expand Down
Loading
Loading
0