8000 Create common Time module and add time set cli command by sdb9696 · Pull Request #1157 · python-kasa/python-kasa · GitHub
[go: up one dir, main page]

Skip to content

Create common Time module and add time set cli command #1157

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Oct 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions kasa/cli/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@
# Handle exit request from click.
if isinstance(exc, click.exceptions.Exit):
sys.exit(exc.exit_code)
if isinstance(exc, click.exceptions.Abort):
sys.exit(0)

Check warning on line 205 in kasa/cli/common.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/common.py#L205

Added line #L205 was not covered by tests

echo(f"Raised error: {exc}")
if debug:
Expand Down
123 changes: 114 additions & 9 deletions kasa/cli/time.py
minute: int,
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
from datetime import datetime

import asyncclick as click
import zoneinfo

from kasa import (
Device,
Module,
)
from kasa.smart import SmartDevice
from kasa.iot import IotDevice
from kasa.iot.iottimezone import get_matching_timezones

from .common import (
echo,
error,
pass_dev,
)

Expand All @@ -31,25 +34,127 @@
async def time_get(dev: Device):
"""Get the device time."""
res = dev.time
echo(f"Current time: {res}")
echo(f"Current time: {dev.time} ({dev.timezone})")
return res


@time.command(name="sync")
@click.option(
"--timezone",
type=str,
required=False,
default=None,
help="IANA timezone name, will use current device timezone if not provided.",
)
@click.option(
"--skip-confirm",
type=str,
required=False,
default=False,
is_flag=True,
help="Do not ask to confirm the timezone if an exact match is not found.",
)
@pass_dev
async def time_sync(dev: Device):
async def time_sync(dev: Device, timezone: str | None, skip_confirm: bool):
"""Set the device time to current time."""
if not isinstance(dev, SmartDevice):
raise NotImplementedError("setting time currently only implemented on smart")
if (time := dev.modules.get(Module.Time)) is None:
echo("Device does not have time module")
return

Check warning on line 62 in kasa/cli/time.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/time.py#L61-L62

Added lines #L61 - L62 were not covered by tests

now = datetime.now()

tzinfo: zoneinfo.ZoneInfo | None = None
if timezone:
tzinfo = await _get_timezone(dev, timezone, skip_confirm)

Check warning on line 68 in kasa/cli/time.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/time.py#L68

Added line #L68 was not covered by tests
if tzinfo.utcoffset(now) != now.astimezone().utcoffset():
error(

Check warning on line 70 in kasa/cli/time.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/time.py#L70

Added line #L70 was not covered by tests
f"{timezone} has a different utc offset to local time,"
+ "syncing will produce unexpected results."
)
now = now.replace(tzinfo=tzinfo)

Check warning on line 74 in kasa/cli/time.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/time.py#L74

Added line #L74 was not covered by tests

echo(f"Old time: {time.time} ({time.timezone})")

await time.set_time(now)

await dev.update()
echo(f"New time: {time.time} ({time.timezone})")


@time.command(name="set")
@click.argument("year", type=int)
@click.argument("month", type=int)
@click.argument("day", type=int)
@click.argument("hour", type=int)
@click.argument("minute", type=int)
@click.argument("seconds", type=int, required=False, default=0)
@click.option(
"--timezone",
type=str,
required=False,
default=None,
help="IANA timezone name, will use current device timezone if not provided.",
)
@click.option(
"--skip-confirm",
type=bool,
required=False,
default=False,
is_flag=True,
help="Do not ask to confirm the timezone if an exact match is not found.",
)
@pass_dev
async def time_set(
dev: Device,
year: int,
month: int,
day: int,
hour: int,
seconds: int,
timezone: str | None,
skip_confirm: bool,
):
"""Set the device time to the provided time."""
if (time := dev.modules.get(Module.Time)) is None:
echo("Device does not have time module")
return

echo("Old time: %s" % time.time)
tzinfo: zoneinfo.ZoneInfo | None = None
if timezone:
tzinfo = await _get_timezone(dev, timezone, skip_confirm)

local_tz = datetime.now().astimezone().tzinfo
await time.set_time(datetime.now(tz=local_tz))
echo(f"Old time: {time.time} ({time.timezone})")

await time.set_time(datetime(year, month, day, hour, minute, seconds, 0, tzinfo))

await dev.update()
echo("New time: %s" % time.time)
echo(f"New time: {time.time} ({time.timezone})")


async def _get_timezone(dev, timezone, skip_confirm) -> zoneinfo.ZoneInfo:
"""Get the tzinfo from the timezone or return none."""
tzinfo: zoneinfo.ZoneInfo | None = None

if timezone not in zoneinfo.available_timezones():
error(f"{timezone} is not a valid IANA timezone.")

Check warning on line 140 in kasa/cli/time.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/time.py#L140

Added line #L140 was not covered by tests

tzinfo = zoneinfo.ZoneInfo(timezone)
if skip_confirm is False and isinstance(dev, IotDevice):
matches = await get_matching_timezones(tzinfo)
if not matches:
error(f"Device cannot support {timezone} timezone.")

Check warning on line 146 in kasa/cli/time.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/time.py#L146

Added line #L146 was not covered by tests
first = matches[0]
msg = (
f"An exact match for {timezone} could not be found, "
+ f"timezone will be set to {first}"
)
if len(matches) == 1:
click.confirm(msg, abort=True)

Check warning on line 153 in kasa/cli/time.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/time.py#L153

Added line #L153 was not covered by tests
else:
msg = (
f"Supported timezones matching {timezone} are {', '.join(matches)}\n"
+ msg
)
click.confirm(msg, abort=True)
return tzinfo
2 changes: 1 addition & 1 deletion kasa/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
schedule
usage
anti_theft
time
Time
cloud
Led

Expand Down
2 changes: 2 additions & 0 deletions kasa/interfaces/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .light import Light, LightState
from .lighteffect import LightEffect
from .lightpreset import LightPreset
from .time import Time

__all__ = [
"Fan",
Expand All @@ -15,4 +16,5 @@
"LightEffect",
"LightState",
"LightPreset",
"Time",
]
26 changes: 26 additions & 0 deletions kasa/interfaces/time.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Module for time interface."""

from __future__ import annotations

from abc import ABC, abstractmethod
from datetime import datetime, tzinfo

from ..module import Module


class Time(Module, ABC):
"""Base class for tplink time module."""

@property
@abstractmethod
def time(self) -> datetime:
"""Return timezone aware current device time."""

@property
@abstractmethod
def timezone(self) -> tzinfo:
"""Return current timezone."""

@abstractmethod
async def set_time(self, dt: datetime) -> dict:
"""Set the device time."""
2 changes: 1 addition & 1 deletion kasa/iot/iotbulb.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ async def _initialize_modules(self):
self.add_module(
Module.IotAntitheft, Antitheft(self, "smartlife.iot.common.anti_theft")
)
self.add_module(Module.IotTime, Time(self, "smartlife.iot.common.timesetting"))
self.add_module(Module.Time, Time(self, "smartlife.iot.common.timesetting"))
self.add_module(Module.Energy, Emeter(self, self.emeter_type))
self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud"))
Expand Down
21 changes: 11 additions & 10 deletions kasa/iot/iotdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, tzinfo
from typing import TYPE_CHECKING, Any, cast
from warnings import warn

from ..device import Device, WifiNetwork
from ..deviceconfig import DeviceConfig
Expand Down Expand Up @@ -460,27 +461,27 @@
@requires_update
def time(self) -> datetime:
"""Return current time from the device."""
return self.modules[Module.IotTime].time
return self.modules[Module.Time].time

@property
@requires_update
def timezone(self) -> tzinfo:
"""Return the current timezone."""
return self.modules[Module.IotTime].timezone
return self.modules[Module.Time].timezone

async def get_time(self) -> datetime | None:
async def get_time(self) -> datetime:
"""Return current time from the device, if available."""
_LOGGER.warning(
"Use `time` property instead, this call will be removed in the future."
)
return await self.modules[Module.IotTime].get_time()
msg = "Use `time` property instead, this call will be removed in the future."
warn(msg, DeprecationWarning, stacklevel=1)
return self.time
Comment on lines +472 to +476
Copy link
Member

Choose a reason for hiding this comment

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

We should probably drop this and all other "this call will be removed in the future" marked functions, but that could be done in a separate PR.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I was thinking it could be about time to drop all of the deprecated support and making the next release 0.8.0. What do you think?

Copy link
Member

Choose a reason for hiding this comment

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

It hasn't been that long since the large refactor, so I'd tend to say that we keep them for the time being. Or is there some specific reason we should already go for a 0.8 and break things right away?


async def get_timezone(self) -> dict:
async def get_timezone(self) -> tzinfo:
"""Return timezone information."""
_LOGGER.warning(
msg = (

Check warning on line 480 in kasa/iot/iotdevice.py

View check run for this annotation

Codecov / codecov/patch

kasa/iot/iotdevice.py#L480

Added line #L480 was not covered by tests
"Use `timezone` property instead, this call will be removed in the future."
)
return await self.modules[Module.IotTime].get_timezone()
warn(msg, DeprecationWarning, stacklevel=1)
return self.timezone

Check warning on line 484 in kasa/iot/iotdevice.py

View check run for this annotation

Codecov / codecov/patch

kasa/iot/iotdevice.py#L483-L484

Added lines #L483 - L484 were not covered by tests

@property # type: ignore
@requires_update
Expand Down
2 changes: 1 addition & 1 deletion kasa/iot/iotplug.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ async def _initialize_modules(self):
self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
self.add_module(Module.IotUsage, Usage(self, "schedule"))
self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft"))
self.add_module(Module.IotTime, Time(self, "time"))
self.add_module(Module.Time, Time(self, "time"))
self.add_module(Module.IotCloud, Cloud(self, "cnCloud"))
self.add_module(Module.Led, Led(self, "system"))

Expand Down
2 changes: 1 addition & 1 deletion kasa/iot/iotstrip.py
F438
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ async def _initialize_modules(self):
self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft"))
self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
self.add_module(Module.IotUsage, Usage(self, "schedule"))
self.add_module(Module.IotTime, Time(self, "time"))
self.add_module(Module.Time, Time(self, "time"))
self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
self.add_module(Module.Led, Led(self, "system"))
self.add_module(Module.IotCloud, Cloud(self, "cnCloud"))
Expand Down
58 changes: 44 additions & 14 deletions kasa/iot/iottimezone.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
from __future__ import annotations

import logging
from datetime import datetime, tzinfo
from datetime import datetime, timedelta, tzinfo
from typing import cast

from zoneinfo import ZoneInfo

from ..cachedzoneinfo import CachedZoneInfo

Expand All @@ -22,26 +25,53 @@
return await CachedZoneInfo.get_cached_zone_info(name)


async def get_timezone_index(name: str) -> int:
async def get_timezone_index(tzone: tzinfo) -> int:
"""Return the iot firmware index for a valid IANA timezone key."""
rev = {val: key for key, val in TIMEZONE_INDEX.items()}
if name in rev:
return rev[name]
if isinstance(tzone, ZoneInfo):
name = tzone.key
rev = {val: key for key, val in TIMEZONE_INDEX.items()}
if name in rev:
return rev[name]

# Try to find a supported timezone matching dst true/false
zone = await CachedZoneInfo.get_cached_zone_info(name)
now = datetime.now()
winter = datetime(now.year, 1, 1, 12)
summer = datetime(now.year, 7, 1, 12)
for i in range(110):
configured_zone = await get_timezone(i)
if zone.utcoffset(winter) == configured_zone.utcoffset(
winter
) and zone.utcoffset(summer) == configured_zone.utcoffset(summer):
if _is_same_timezone(tzone, await get_timezone(i)):
return i
raise ValueError("Device does not support timezone %s", name)


async def get_matching_timezones(tzone: tzinfo) -> list[str]:
"""Return the iot firmware index for a valid IANA timezone key."""
matches = []
if isinstance(tzone, ZoneInfo):
name = tzone.key
vals = {val for val in TIMEZONE_INDEX.values()}
if name in vals:
matches.append(name)

Check warning on line 49 in kasa/iot/iottimezone.py

View check run for this annotation

Codecov / codecov/patch

kasa/iot/iottimezone.py#L49

Added line #L49 was not covered by tests

for i in range(110):
fw_tz = await get_timezone(i)
if _is_same_timezone(tzone, fw_tz):
match_key = cast(ZoneInfo, fw_tz).key
if match_key not in matches:
matches.append(match_key)
return matches


def _is_same_timezone(tzone1: tzinfo, tzone2: tzinfo) -> bool:
"""Return true if the timezones have the same utcffset and dst offset.

Iot devices only support a limited static list of IANA timezones; this is used to
check if a static timezone matches the same utc offset and dst settings.
"""
now = datetime.now()
start_day = datetime(now.year, 1, 1, 12)
for i in range(365):
the_day = start_day + timedelta(days=i)
if tzone1.utcoffset(the_day) != tzone2.utcoffset(the_day):
return False
return True


TIMEZONE_INDEX = {
0: "Etc/GMT+12",
1: "Pacific/Samoa",
Expand Down
Loading
Loading
0