FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Fix SMARTCAM Time module and update tests by ZeliardM · Pull Request #1659 · python-kasa/python-kasa · GitHub

Repository navigation

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .py  (5) All 1 file type selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
52 changes: 37 additions & 15 deletions kasa/smartcam/modules/time.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -76,17 +80,35 @@ def time(self) -> datetime:

@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()

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
"""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."
)

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}"
Comment thread
ZeliardM marked this conversation as resolved.
6 changes: 3 additions & 3 deletions tests/fakeprotocol_smartcam.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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, {})
Comment thread
rytilahti marked this conversation as resolved.
.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
120 changes: 116 additions & 4 deletions tests/smartcam/test_smartcamdevice.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
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

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

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/test_cli.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions tests/test_common_modules.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from .device_fixtures import (
bulb_iot,
bulb_smart,
device_iot,
device_smart,
dimmable_iot,
dimmer_iot,
get_parent_and_child_modules,
Expand Down Expand Up @@ -63,6 +65,7 @@
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",
Expand Down Expand Up @@ -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]
Expand Down
Loading

Back | FazBrowse Home | New Git URL