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
Fall back to UTC for iot time when the device clock is not set
An unprovisioned device can report a clock far in the past (e.g. year
2000). When its timezone has to be guessed from the offset to the host
time, the resulting delta is years long and timezone(offset) raises
"offset must be a timedelta strictly between -timedelta(hours=24) and
timedelta(hours=24)", which breaks update() and so every cli command.

Real UTC offsets range from -12h to +14h, so treat anything beyond that
as an unset clock and use UTC instead of guessing.

Fixes #1767

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
  • Loading branch information
tk1475 and claude committed Sep 25, 2026
commit 448aca3cd65649774cfc6f9a3dc2d3e3982cabd4
7 changes: 6 additions & 1 deletion kasa/iot/modules/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async def _post_update_hook(self) -> None:
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.
use a fixed-offset timezone. If the device clock is not set, use UTC.
"""
if res := self.data.get("get_timezone"):
idx = res.get("index")
Expand All @@ -58,6 +58,11 @@ async def _post_update_hook(self) -> None:
now_utc = datetime.now(UTC)
delta = device_local - now_utc.replace(tzinfo=None)
rounded = timedelta(seconds=60 * round(delta.total_seconds() / 60))
# UTC offsets range from -12h to +14h, anything beyond that means the
# device clock is not set (e.g. before provisioning).
if abs(rounded) > timedelta(hours=14):
self._timezone = UTC
return

dst_expected = None
if res := self.data.get("get_timezone"):
Expand Down
34 changes: 34 additions & 0 deletions tests/test_common_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,40 @@ async def test_time_post_update_uses_offset_when_index_missing_unit(
assert inst.timezone.utcoffset(now) == timedelta(0)


@device_iot
async def test_time_post_update_unsynced_clock_uses_utc(
dev: Device, mocker: MockerFixture
):
"""Fall back to UTC when the device clock is not set.

An unprovisioned device can report e.g. year 2000, which is no valid UTC offset
away from the host time, so the offset-based guess must not be attempted.
"""
from zoneinfo import ZoneInfoNotFoundError

proto = dev.protocol._transport.proto # type: ignore[attr-defined]
for target in ("time", "smartlife.iot.common.timesetting"):
if target in proto:
proto[target]["get_time"] = {
"year": 2000,
"month": 1,
"mday": 1,
"hour": 2,
"min": 45,
"sec": 0,
}
# Force the offset-based path, as when the zone is not available on the host
mocker.patch(
"kasa.iot.modules.time.get_timezone",
new=AsyncMock(side_effect=ZoneInfoNotFoundError("missing on host")),
)

await dev.update()
time_mod = dev.modules[Module.Time]
assert time_mod.timezone is UTC
assert time_mod.time.year == 2000


async def test_time_get_time_exception_returns_none_unit(mocker: MockerFixture):
"""Cover Time.get_time exception path (unit test of iot Time)."""
from kasa.iot.modules.time import Time as TimeModule
Expand Down
Loading