From b2206841dd573a8a7701e63e1657c459b0c4123a Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:38:12 -0400 Subject: [PATCH] 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. --- kasa/smartcam/modules/time.py | 50 ++++++++--- tests/fakeprotocol_smartcam.py | 6 +- tests/smartcam/test_smartcamdevice.py | 120 +++++++++++++++++++++++++- tests/test_cli.py | 1 + tests/test_common_modules.py | 4 + 5 files changed, 160 insertions(+), 21 deletions(-) diff --git a/kasa/smartcam/modules/time.py b/kasa/smartcam/modules/time.py index 54ee30e5..8d850dca 100644 --- a/kasa/smartcam/modules/time.py +++ b/kasa/smartcam/modules/time.py @@ -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}" diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index 17160233..faaf423f 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -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 diff --git a/tests/smartcam/test_smartcamdevice.py b/tests/smartcam/test_smartcamdevice.py index e2c46161..d3e38e54 100644 --- a/tests/smartcam/test_smartcamdevice.py +++ b/tests/smartcam/test_smartcamdevice.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index dd68031d..54b34515 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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] diff --git a/tests/test_common_modules.py b/tests/test_common_modules.py index f6b35a81..4eb63ac9 100644 --- a/tests/test_common_modules.py +++ b/tests/test_common_modules.py @@ -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]