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 2 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
73 changes: 64 additions & 9 deletions kasa/cli/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
from datetime import datetime

import asyncclick as click
import zoneinfo

from kasa import (
Device,
Module,
)
from kasa.smart import SmartDevice

from .common import (
echo,
Expand All @@ -31,25 +31,80 @@ async def time(ctx: click.Context):
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 default to local if not provided.",
)
@pass_dev
async def time_sync(dev: Device):
async def time_sync(dev: Device, timezone: str | None):
"""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

if not timezone:
tzinfo = datetime.now().astimezone().tzinfo
elif timezone not in zoneinfo.available_timezones():
echo(f"{timezone} is not a valid IANA timezone.")
return
else:
tzinfo = zoneinfo.ZoneInfo(timezone)

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

await time.set_time(datetime.now(tz=tzinfo))

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.option(
"--timezone",
type=str,
required=False,
default=None,
help="IANA timezone name, will default to local if not provided.",
)
@pass_dev
async def time_set(
dev: Device,
year: int,
month: int,
day: int,
hour: int,
minute: int,
timezone: str | None,
):
"""Set the device time to current time."""
if (time := dev.modules.get(Module.Time)) is None:
echo("Device does not have time module")
return

echo("Old time: %s" % time.time)
if not timezone:
tzinfo = datetime.now().astimezone().tzinfo
elif timezone not in zoneinfo.available_timezones():
echo(f"{timezone} is not a valid IANA timezone.")
return
else:
tzinfo = zoneinfo.ZoneInfo(timezone)

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

local_tz = datetime.now().astimezone().tzinfo
await time.set_time(datetime.now(tz=local_tz))
await time.set_time(datetime(year, month, day, hour, minute, 0, 0, tzinfo))

await dev.update()
echo("New time: %s" % time.time)
echo(f"New time: {time.time} ({time.timezone})")
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 @@ async def set_alias(self, alias: str) -> None:
@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 = (
"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

@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
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
32 changes: 18 additions & 14 deletions kasa/iot/iottimezone.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import asyncio
import logging
from datetime import datetime, tzinfo
from datetime import datetime, timedelta, tzinfo

from zoneinfo import ZoneInfo

Expand All @@ -23,26 +23,30 @@ async def get_timezone(index: int) -> tzinfo:
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)


def _is_same_timezone(tzone1: tzinfo, tzone2: tzinfo) -> bool:
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


class _CachedZoneInfo(ZoneInfo):
"""Cache zone info objects."""

Expand Down
35 changes: 32 additions & 3 deletions kasa/iot/modules/time.py
< F438 td class="blob-code blob-code-addition js-file-line"> except Exception as ex:
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
from datetime import datetime, timezone, tzinfo

from ...exceptions import KasaException
from ...interfaces import Time as TimeInterface
from ..iotmodule import IotModule, merge
from ..iottimezone import get_timezone
from ..iottimezone import get_timezone, get_timezone_index


class Time(IotModule):
class Time(IotModule, TimeInterface):
"""Implements the timezone settings."""

_timezone: tzinfo = timezone.utc
Expand Down Expand Up @@ -37,8 +38,9 @@ def time(self) -> datetime:
res["hour"],
res["min"],
res["sec"],
tzinfo=self.timezone,
)
return time.astimezone(self.timezone)
return time

@property
def timezone(self) -> tzinfo:
Expand All @@ -56,10 +58,37 @@ async def get_time(self):
res["hour"],
res["min"],
res["sec"],
tzinfo=self.timezone,
)
except KasaException:
return None

async def set_time(self, dt: datetime) -> dict:
"""Set the device time."""
if not dt.tzinfo:
raise KasaException(
"Time must be set using a timezone aware datetime object"
)
params = {
"year": dt.year,
"month": dt.month,
"mday": dt.day,
"hour": dt.hour,
"min": dt.minute,
"sec": 0,
2851
}
index = await get_timezone_index(dt.tzinfo)
current_index = self.data.get("get_timezone", {}).get("index", -1)
if current_index != -1 and current_index != index:
params["index"] = index
method = "set_timezone"
else:
method = "set_time"
try:
return await self.call(method, params)
raise KasaException(ex) from ex

async def get_timezone(self):
"""Request timezone information from the device."""
return await self.call("get_timezone")
3 changes: 1 addition & 2 deletions kasa/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class Module(ABC):
Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led")
Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light")
LightPreset: Final[ModuleName[interfaces.LightPreset]] = ModuleName("LightPreset")
Time: Final[ModuleName[interfaces.Time]] = ModuleName("Time")

# IOT only Modules
IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient")
Expand All @@ -86,7 +87,6 @@ class Module(ABC):
IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule")
IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage")
IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud")
IotTime: Final[ModuleName[iot.Time]] = ModuleName("time")

# SMART only Modules
Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm")
Expand Down Expand Up @@ -123,7 +123,6 @@ class Module(ABC):
TemperatureControl: Final[ModuleName[smart.TemperatureControl]] = ModuleName(
"TemperatureControl"
)
Time: Final[ModuleName[smart.Time]] = ModuleName("Time")
WaterleakSensor: Final[ModuleName[smart.WaterleakSensor]] = ModuleName(
"WaterleakSensor"
)
Expand Down
10 changes: 8 additions & 2 deletions kasa/smart/modules/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@

from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from ...exceptions import KasaException
from ...feature import Feature
from ...interfaces import Time as TimeInterface
from ..smartmodule import SmartModule


class Time(SmartModule):
class Time(SmartModule, TimeInterface):
"""Implementation of device_local_time."""

REQUIRED_COMPONENT = "time"
Expand Down Expand Up @@ -56,8 +58,12 @@ def time(self) -> datetime:
tz=self.timezone,
)

async def set_time(self, dt: datetime):
async def set_time(self, dt: datetime) -> dict:
"""Set device time."""
if not dt.tzinfo:
raise KasaException(
"Time must be set using a timezone aware datetime object"
)
unixtime = mktime(dt.timetuple())
offset = cast(timedelta, dt.utcoffset())
diff = offset / timedelta(minutes=1)
Expand Down
Loading
0