Skip to content
Open
37 changes: 27 additions & 10 deletions kasa/smartcam/modules/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

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

Expand All @@ -12,6 +13,8 @@
from ...smart.smartmodule import allow_update_after
from ..smartcammodule import SmartCamModule

_LOGGER = logging.getLogger(__name__)


class Time(SmartCamModule, TimeInterface):
"""Implementation of device_local_time."""
Expand Down Expand Up @@ -77,16 +80,30 @@ def time(self) -> datetime:
@allow_update_after
async def set_time(self, dt: datetime) -> dict:
"""Set device time."""
_LOGGER.warning(
"SmartCam devices do not support setting clock time directly; "
"only timezone settings are updated."
)
if not dt.tzinfo:
timestamp = dt.replace(tzinfo=self.timezone).timestamp()
utc_offset = cast(timedelta | None, self.timezone.utcoffset(dt))
else:
timestamp = dt.timestamp()
utc_offset = cast(timedelta | None, dt.utcoffset())

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}})
params: dict[str, str] = {"timezone": self._format_utc_offset(utc_offset)}
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
params["zone_id"] = zinfo.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}"
Comment on lines +98 to +109
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add tests for this function to the relevant test_time.py file with a parametrized set of test cases.

6 changes: 3 additions & 3 deletions tests/fakeprotocol_smartcam.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,11 +337,11 @@ async def _send_request(self, request_dict: dict):
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
Expand Down
61 changes: 57 additions & 4 deletions tests/smartcam/test_smartcamdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
from __future__ import annotations

import base64
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta, tzinfo
from unittest.mock import AsyncMock, PropertyMock, patch
from zoneinfo import ZoneInfo

import pytest
from freezegun.api import FrozenDateTimeFactory
Expand Down Expand Up @@ -156,12 +157,64 @@ async def test_wifi_join_success_and_errors(dev: SmartCamDevice):
@device_smartcam
async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory):
"""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()


@pytest.mark.parametrize(
("set_time_value", "expected_timezone"),
[
pytest.param(datetime(2024, 1, 15, 12, 0, tzinfo=UTC), "UTC+00:00", id="utc"),
pytest.param(
datetime(2024, 1, 15, 12, 0, tzinfo=ZoneInfo("America/New_York")),
"UTC-05:00",
id="negative-offset",
),
],
)
@device_smartcam
async def test_set_time_formats_timezone_parametrized(
dev: Device, set_time_value: datetime, expected_timezone: str
):
"""Test SmartCam set_time formats timezone offsets consistently."""
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"
assert call.args[1]["system"]["basic"]["timezone"] == expected_timezone


@device_smartcam
async def test_set_time_format_none_offset_defaults_to_utc(dev: Device):
"""Test SmartCam set_time handles None offset as UTC+00:00."""

class NoneOffsetTz(tzinfo):
def utcoffset(self, dt: datetime | None) -> timedelta | None:
return None

def dst(self, dt: datetime | None) -> timedelta | None:
return None

def tzname(self, dt: datetime | None) -> str | None:
return "NoneOffset"

module = dev.modules[Module.Time]
with patch.object(module, "call", AsyncMock(return_value={})) as call_mock:
await module.set_time(datetime(2024, 1, 15, 12, 0, tzinfo=NoneOffsetTz()))

call_mock.assert_awaited_once()
call = call_mock.await_args_list[0]
assert call.args[0] == "setTimezone"
assert call.args[1]["system"]["basic"]["timezone"] == "UTC+00:00"


@device_smartcam
Expand Down
14 changes: 12 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,13 +488,19 @@ async def test_time_set(dev: Device, mocker, runner):
time_mod = dev.modules[Module.Time]
set_time_mock = mocker.spy(time_mod, "set_time")
dt = datetime(2024, 10, 15, 8, 15)
initial_timezone = time_mod.timezone
res = await runner.invoke(
time,
["set", str(dt.year), str(dt.month), str(dt.day), str(dt.hour), str(dt.minute)],
obj=dev,
)
set_time_mock.assert_called()
assert time_mod.time == dt.replace(tzinfo=time_mod.timezone)
if isinstance(dev, SmartCamDevice):
assert time_mod.timezone.utcoffset(time_mod.time) == initial_timezone.utcoffset(
time_mod.time
)
else:
assert time_mod.time == dt.replace(tzinfo=time_mod.timezone)
Comment on lines +498 to +503
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should be no need for special handling in the cli tests. Or it should be covered with a separate, explicit test.


assert res.exit_code == 0
assert "Old time: " in res.output
Expand All @@ -518,7 +524,11 @@ async def test_time_set(dev: Device, mocker, runner):
obj=dev,
)

assert time_mod.time == dt
if isinstance(dev, SmartCamDevice):
assert isinstance(time_mod.timezone, ZoneInfo)
assert time_mod.timezone.key == zone.key
else:
assert time_mod.time == dt

assert res.exit_code == 0
assert "Old time: " in res.output
Expand Down
27 changes: 22 additions & 5 deletions tests/test_common_modules.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import importlib
import inspect
import logging
import pkgutil
import sys
from datetime import UTC, datetime, timedelta, timezone
Expand All @@ -12,6 +13,7 @@
import kasa.interfaces
from kasa import Device, KasaException, LightState, Module, ThermostatState
from kasa.module import _get_feature_attribute
from kasa.smartcam import SmartCamDevice

from .device_fixtures import (
bulb_iot,
Expand Down Expand Up @@ -422,7 +424,7 @@ async def test_thermostat(dev: Device, mocker: MockerFixture):
assert therm_mod.temperature_unit == "fahrenheit"


async def test_set_time(dev: Device):
async def test_set_time(dev: Device, caplog: pytest.LogCaptureFixture):
"""Test setting the device time."""
time_mod = dev.modules[Module.Time]

Expand All @@ -435,9 +437,18 @@ async def test_set_time(dev: Device):
try:
assert time_mod.time != test_time

await time_mod.set_time(test_time)
caplog.clear()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to clear() when the log shouldn't have captured anything yet.

with caplog.at_level(logging.WARNING):
await time_mod.set_time(test_time)
await dev.update()
assert time_mod.time == test_time
if isinstance(dev, SmartCamDevice):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do really like clean code, so adding a separate test for handling the special behavior would be a better approach.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-worked the tests here to work with smartcam devices only for testing this.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nicer to add a separate test for the special "logs a warning" case that to make it explicit, and skip this test for smartcam devices.

This way we can cover both paths without having a need for conditionals in tests which are most of the time a bad idea in tests.

assert (
"SmartCam devices do not support setting clock time directly; "
"only timezone settings are updated."
) in caplog.text
assert time_mod.timezone == test_time.tzinfo
else:
assert time_mod.time == test_time

if (
isinstance(original_timezone, ZoneInfo)
Expand All @@ -451,12 +462,18 @@ async def test_set_time(dev: Device):
new_time = time_mod.time.astimezone(test_zonezone)
await time_mod.set_time(new_time)
await dev.update()
assert time_mod.time == new_time
if isinstance(dev, SmartCamDevice):
assert time_mod.timezone == new_time.tzinfo
else:
assert time_mod.time == new_time
Comment on lines +465 to +468
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my comment above; it's better to add separate tests for the special cases than adding conditonals with isinstance.

finally:
# Reset back to the original
await time_mod.set_time(original_time)
await dev.update()
assert time_mod.time == original_time
if isinstance(dev, SmartCamDevice):
assert time_mod.timezone == original_timezone
else:
assert time_mod.time == original_time


async def test_time_post_update_no_time_uses_utc_unit(monkeypatch: pytest.MonkeyPatch):
Expand Down
Loading