Fix SMARTCAM Time module and update tests (#1659)

Update SmartCam time handling to apply timezone-only changes with explicit warnings, while keeping common time and CLI tests focused on devices that support setting clock time and adding SmartCam-specific coverage for the special behavior.
This commit is contained in:
ZeliardM
2026-07-10 20:38:12 -04:00
committed by GitHub
parent 8c66d0a29d
commit b2206841dd
5 changed files with 160 additions and 21 deletions

View File

@@ -2,16 +2,20 @@
from __future__ import annotations
from datetime import UTC, datetime, tzinfo
import logging
from datetime import UTC, datetime, timedelta, tzinfo
from typing import cast
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from ...cachedzoneinfo import CachedZoneInfo
from ...exceptions import KasaException
from ...feature import Feature
from ...interfaces import Time as TimeInterface
from ...smart.smartmodule import allow_update_after
from ..smartcammodule import SmartCamModule
_LOGGER = logging.getLogger(__name__)
class Time(SmartCamModule, TimeInterface):
"""Implementation of device_local_time."""
@@ -76,17 +80,35 @@ class Time(SmartCamModule, TimeInterface):
@allow_update_after
async def set_time(self, dt: datetime) -> dict:
"""Set device time."""
if not dt.tzinfo:
timestamp = dt.replace(tzinfo=self.timezone).timestamp()
else:
timestamp = dt.timestamp()
"""Set device timezone derived from the datetime."""
_LOGGER.warning(
"SmartCam devices do not support setting clock time directly; "
"only timezone settings will be updated."
)
timezone = dt.tzinfo or self.timezone
if not isinstance(timezone, ZoneInfo):
raise KasaException(
"SmartCam devices can only update timezone using zoneinfo "
"timezones; setting clock time is not supported."
)
lt = datetime.fromtimestamp(timestamp).isoformat().replace("T", " ")
params = {"seconds_from_1970": int(timestamp), "local_time": lt}
# Doesn't seem to update the time, perhaps because timing_mode is ntp
res = await self.call("setTimezone", {"system": {"clock_status": params}})
if (zinfo := dt.tzinfo) and isinstance(zinfo, ZoneInfo):
tz_params = {"zone_id": zinfo.key}
res = await self.call("setTimezone", {"system": {"basic": tz_params}})
return res
utc_offset = cast(timedelta | None, timezone.utcoffset(dt))
params: dict[str, str] = {
"timezone": self._format_utc_offset(utc_offset),
"zone_id": timezone.key,
}
return await self.call("setTimezone", {"system": {"basic": params}})
@staticmethod
def _format_utc_offset(offset: timedelta | None) -> str:
"""Format a timedelta offset as UTC+HH:MM/UTC-HH:MM."""
if offset is None:
offset = timedelta(0)
total_seconds = int(offset.total_seconds())
sign = "+" if total_seconds >= 0 else "-"
total_seconds = abs(total_seconds)
hours, remainder = divmod(total_seconds, 3600)
minutes = remainder // 60
return f"UTC{sign}{hours:02d}:{minutes:02d}"

View File

@@ -337,11 +337,11 @@ class FakeSmartCamTransport(BaseTransport):
if setter_keys := self.SETTERS.get((module, section, section_key)):
self._get_param_set_value(info, setter_keys, section_value)
elif (
section := info.get(get_method, {})
section_data := info.get(get_method, {})
.get(module, {})
.get(section, {})
) and section_key in section:
section[section_key] = section_value
) and section_key in section_data:
section_data[section_key] = section_value
else:
return {"error_code": -1}
break

View File

@@ -3,8 +3,10 @@
from __future__ import annotations
import base64
from datetime import UTC, datetime
import logging
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, PropertyMock, patch
from zoneinfo import ZoneInfo
import pytest
from freezegun.api import FrozenDateTimeFactory
@@ -12,6 +14,7 @@ from freezegun.api import FrozenDateTimeFactory
from kasa import Device, DeviceType, Module
from kasa.exceptions import AuthenticationError, DeviceError, KasaException
from kasa.smartcam import SmartCamDevice
from kasa.smartcam.modules.time import Time
from ..conftest import device_smartcam, hub_smartcam
@@ -156,12 +159,121 @@ async def test_wifi_join_success_and_errors(dev: SmartCamDevice) -> None:
@device_smartcam
async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory) -> None:
"""Test a child device gets the time from it's parent module."""
fallback_time = datetime.now(UTC).astimezone().replace(microsecond=0)
assert dev.time != fallback_time
original_time = dev.time
fallback_time = datetime.now(UTC).replace(tzinfo=ZoneInfo("America/New_York"))
module = dev.modules[Module.Time]
await module.set_time(fallback_time)
await dev.update()
assert dev.time == fallback_time
assert dev.timezone == fallback_time.tzinfo
# SmartCam set_time updates timezone only; device clock remains unchanged.
assert dev.time.timestamp() == original_time.timestamp()
@device_smartcam
async def test_set_time_updates_timezone_only(
dev: Device, caplog: pytest.LogCaptureFixture
) -> None:
"""Test SmartCam set_time updates timezone without changing clock time."""
original_time = dev.time
set_time_value = datetime(2024, 1, 15, 12, 0, tzinfo=ZoneInfo("Europe/Berlin"))
module = dev.modules[Module.Time]
with caplog.at_level(logging.WARNING):
await module.set_time(set_time_value)
await dev.update()
assert (
"SmartCam devices do not support setting clock time directly; only timezone settings will be updated."
in caplog.text
)
assert dev.timezone == set_time_value.tzinfo
assert dev.time.timestamp() == original_time.timestamp()
@device_smartcam
async def test_set_time_uses_current_timezone_for_naive_datetime(dev: Device) -> None:
"""Test SmartCam set_time uses the current timezone for naive datetimes."""
module = dev.modules[Module.Time]
set_time_value = datetime(2024, 1, 15, 12, 0)
expected_timezone = module.timezone
assert isinstance(expected_timezone, ZoneInfo)
with patch.object(module, "call", AsyncMock(return_value={})) as call_mock:
await module.set_time(set_time_value)
call_mock.assert_awaited_once()
call = call_mock.await_args_list[0]
params = call.args[1]["system"]["basic"]
assert params["timezone"] == Time._format_utc_offset(
expected_timezone.utcoffset(set_time_value)
)
assert params["zone_id"] == expected_timezone.key
@pytest.mark.parametrize(
("set_time_value", "expected_timezone", "expected_zone_id"),
[
pytest.param(
datetime(2024, 1, 15, 12, 0, tzinfo=ZoneInfo("UTC")),
"UTC+00:00",
"UTC",
id="utc",
),
pytest.param(
datetime(2024, 1, 15, 12, 0, tzinfo=ZoneInfo("America/New_York")),
"UTC-05:00",
"America/New_York",
id="negative-offset",
),
pytest.param(
datetime(2024, 1, 15, 12, 0, tzinfo=ZoneInfo("Asia/Kathmandu")),
"UTC+05:45",
"Asia/Kathmandu",
id="partial-hour-offset",
),
],
)
@device_smartcam
async def test_set_time_formats_timezone_params(
dev: Device,
set_time_value: datetime,
expected_timezone: str,
expected_zone_id: str,
) -> None:
"""Test SmartCam set_time sends both offset and zone id."""
module = dev.modules[Module.Time]
with patch.object(module, "call", AsyncMock(return_value={})) as call_mock:
await module.set_time(set_time_value)
call_mock.assert_awaited_once()
call = call_mock.await_args_list[0]
assert call.args[0] == "setTimezone"
params = call.args[1]["system"]["basic"]
assert params["timezone"] == expected_timezone
assert params["zone_id"] == expected_zone_id
@device_smartcam
async def test_set_time_rejects_fixed_offset_timezone(dev: Device) -> None:
"""Test SmartCam set_time rejects offsets that cannot update zone_id."""
module = dev.modules[Module.Time]
with pytest.raises(KasaException, match="zoneinfo timezones"):
await module.set_time(datetime(2024, 1, 15, 12, 0, tzinfo=UTC))
@pytest.mark.parametrize(
("offset", "expected"),
[
pytest.param(timedelta(0), "UTC+00:00", id="utc"),
pytest.param(timedelta(hours=-5), "UTC-05:00", id="negative"),
pytest.param(timedelta(hours=5, minutes=45), "UTC+05:45", id="partial-hour"),
pytest.param(None, "UTC+00:00", id="none"),
],
)
def test_format_utc_offset(offset: timedelta | None, expected: str) -> None:
"""Test SmartCam UTC offset formatting."""
assert Time._format_utc_offset(offset) == expected
@device_smartcam

View File

@@ -481,6 +481,7 @@ async def test_time_sync(dev, mocker, runner):
assert "New time: " in res.output
@parametrize_combine([device_smart, device_iot])
async def test_time_set(dev: Device, mocker, runner):
"""Test time set command."""
time_mod = dev.modules[Module.Time]

View File

@@ -16,6 +16,8 @@ from kasa.module import _get_feature_attribute
from .device_fixtures import (
bulb_iot,
bulb_smart,
device_iot,
device_smart,
dimmable_iot,
dimmer_iot,
get_parent_and_child_modules,
@@ -63,6 +65,7 @@ light_preset_smart = parametrize(
light_preset = parametrize_combine([light_preset_smart, bulb_iot])
light = parametrize_combine([bulb_smart, bulb_iot, dimmable])
time = parametrize_combine([device_smart, device_iot])
temp_control_smart = parametrize(
"has temp control smart",
@@ -422,6 +425,7 @@ async def test_thermostat(dev: Device, mocker: MockerFixture):
assert therm_mod.temperature_unit == "fahrenheit"
@time
async def test_set_time(dev: Device):
"""Test setting the device time."""
time_mod = dev.modules[Module.Time]