mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-07-11 01:42:05 +00:00
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:
@@ -2,16 +2,20 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime, tzinfo
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta, tzinfo
|
||||||
from typing import cast
|
from typing import cast
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from ...cachedzoneinfo import CachedZoneInfo
|
from ...cachedzoneinfo import CachedZoneInfo
|
||||||
|
from ...exceptions import KasaException
|
||||||
from ...feature import Feature
|
from ...feature import Feature
|
||||||
from ...interfaces import Time as TimeInterface
|
from ...interfaces import Time as TimeInterface
|
||||||
from ...smart.smartmodule import allow_update_after
|
from ...smart.smartmodule import allow_update_after
|
||||||
from ..smartcammodule import SmartCamModule
|
from ..smartcammodule import SmartCamModule
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Time(SmartCamModule, TimeInterface):
|
class Time(SmartCamModule, TimeInterface):
|
||||||
"""Implementation of device_local_time."""
|
"""Implementation of device_local_time."""
|
||||||
@@ -76,17 +80,35 @@ class Time(SmartCamModule, TimeInterface):
|
|||||||
|
|
||||||
@allow_update_after
|
@allow_update_after
|
||||||
async def set_time(self, dt: datetime) -> dict:
|
async def set_time(self, dt: datetime) -> dict:
|
||||||
"""Set device time."""
|
"""Set device timezone derived from the datetime."""
|
||||||
if not dt.tzinfo:
|
_LOGGER.warning(
|
||||||
timestamp = dt.replace(tzinfo=self.timezone).timestamp()
|
"SmartCam devices do not support setting clock time directly; "
|
||||||
else:
|
"only timezone settings will be updated."
|
||||||
timestamp = dt.timestamp()
|
)
|
||||||
|
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", " ")
|
utc_offset = cast(timedelta | None, timezone.utcoffset(dt))
|
||||||
params = {"seconds_from_1970": int(timestamp), "local_time": lt}
|
params: dict[str, str] = {
|
||||||
# Doesn't seem to update the time, perhaps because timing_mode is ntp
|
"timezone": self._format_utc_offset(utc_offset),
|
||||||
res = await self.call("setTimezone", {"system": {"clock_status": params}})
|
"zone_id": timezone.key,
|
||||||
if (zinfo := dt.tzinfo) and isinstance(zinfo, ZoneInfo):
|
}
|
||||||
tz_params = {"zone_id": zinfo.key}
|
|
||||||
res = await self.call("setTimezone", {"system": {"basic": tz_params}})
|
return await self.call("setTimezone", {"system": {"basic": params}})
|
||||||
return res
|
|
||||||
|
@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}"
|
||||||
|
|||||||
@@ -337,11 +337,11 @@ class FakeSmartCamTransport(BaseTransport):
|
|||||||
if setter_keys := self.SETTERS.get((module, section, section_key)):
|
if setter_keys := self.SETTERS.get((module, section, section_key)):
|
||||||
self._get_param_set_value(info, setter_keys, section_value)
|
self._get_param_set_value(info, setter_keys, section_value)
|
||||||
elif (
|
elif (
|
||||||
section := info.get(get_method, {})
|
section_data := info.get(get_method, {})
|
||||||
.get(module, {})
|
.get(module, {})
|
||||||
.get(section, {})
|
.get(section, {})
|
||||||
) and section_key in section:
|
) and section_key in section_data:
|
||||||
section[section_key] = section_value
|
section_data[section_key] = section_value
|
||||||
else:
|
else:
|
||||||
return {"error_code": -1}
|
return {"error_code": -1}
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
from datetime import UTC, datetime
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
from unittest.mock import AsyncMock, PropertyMock, patch
|
from unittest.mock import AsyncMock, PropertyMock, patch
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from freezegun.api import FrozenDateTimeFactory
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
@@ -12,6 +14,7 @@ from freezegun.api import FrozenDateTimeFactory
|
|||||||
from kasa import Device, DeviceType, Module
|
from kasa import Device, DeviceType, Module
|
||||||
from kasa.exceptions import AuthenticationError, DeviceError, KasaException
|
from kasa.exceptions import AuthenticationError, DeviceError, KasaException
|
||||||
from kasa.smartcam import SmartCamDevice
|
from kasa.smartcam import SmartCamDevice
|
||||||
|
from kasa.smartcam.modules.time import Time
|
||||||
|
|
||||||
from ..conftest import device_smartcam, hub_smartcam
|
from ..conftest import device_smartcam, hub_smartcam
|
||||||
|
|
||||||
@@ -156,12 +159,121 @@ async def test_wifi_join_success_and_errors(dev: SmartCamDevice) -> None:
|
|||||||
@device_smartcam
|
@device_smartcam
|
||||||
async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory) -> None:
|
async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory) -> None:
|
||||||
"""Test a child device gets the time from it's parent module."""
|
"""Test a child device gets the time from it's parent module."""
|
||||||
fallback_time = datetime.now(UTC).astimezone().replace(microsecond=0)
|
original_time = dev.time
|
||||||
assert dev.time != fallback_time
|
fallback_time = datetime.now(UTC).replace(tzinfo=ZoneInfo("America/New_York"))
|
||||||
module = dev.modules[Module.Time]
|
module = dev.modules[Module.Time]
|
||||||
await module.set_time(fallback_time)
|
await module.set_time(fallback_time)
|
||||||
await dev.update()
|
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
|
@device_smartcam
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ async def test_time_sync(dev, mocker, runner):
|
|||||||
assert "New time: " in res.output
|
assert "New time: " in res.output
|
||||||
|
|
||||||
|
|
||||||
|
@parametrize_combine([device_smart, device_iot])
|
||||||
async def test_time_set(dev: Device, mocker, runner):
|
async def test_time_set(dev: Device, mocker, runner):
|
||||||
"""Test time set command."""
|
"""Test time set command."""
|
||||||
time_mod = dev.modules[Module.Time]
|
time_mod = dev.modules[Module.Time]
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from kasa.module import _get_feature_attribute
|
|||||||
from .device_fixtures import (
|
from .device_fixtures import (
|
||||||
bulb_iot,
|
bulb_iot,
|
||||||
bulb_smart,
|
bulb_smart,
|
||||||
|
device_iot,
|
||||||
|
device_smart,
|
||||||
dimmable_iot,
|
dimmable_iot,
|
||||||
dimmer_iot,
|
dimmer_iot,
|
||||||
get_parent_and_child_modules,
|
get_parent_and_child_modules,
|
||||||
@@ -63,6 +65,7 @@ light_preset_smart = parametrize(
|
|||||||
light_preset = parametrize_combine([light_preset_smart, bulb_iot])
|
light_preset = parametrize_combine([light_preset_smart, bulb_iot])
|
||||||
|
|
||||||
light = parametrize_combine([bulb_smart, bulb_iot, dimmable])
|
light = parametrize_combine([bulb_smart, bulb_iot, dimmable])
|
||||||
|
time = parametrize_combine([device_smart, device_iot])
|
||||||
|
|
||||||
temp_control_smart = parametrize(
|
temp_control_smart = parametrize(
|
||||||
"has temp control smart",
|
"has temp control smart",
|
||||||
@@ -422,6 +425,7 @@ async def test_thermostat(dev: Device, mocker: MockerFixture):
|
|||||||
assert therm_mod.temperature_unit == "fahrenheit"
|
assert therm_mod.temperature_unit == "fahrenheit"
|
||||||
|
|
||||||
|
|
||||||
|
@time
|
||||||
async def test_set_time(dev: Device):
|
async def test_set_time(dev: Device):
|
||||||
"""Test setting the device time."""
|
"""Test setting the device time."""
|
||||||
time_mod = dev.modules[Module.Time]
|
time_mod = dev.modules[Module.Time]
|
||||||
|
|||||||
Reference in New Issue
Block a user