8000 Create common Time module and add time set cli command (#1157) · msz-coder/python-kasa@7fd8c14 · GitHub
[go: up one dir, main page]

Skip to content

Commit 7fd8c14

Browse files
authored
Create common Time module and add time set cli command (python-kasa#1157)
1 parent 885a04d commit 7fd8c14

18 files changed

+349
-68
lines changed

kasa/cli/common.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ def _handle_exception(debug, exc):
201201
# Handle exit request from click.
202202
if isinstance(exc, click.exceptions.Exit):
203203
sys.exit(exc.exit_code)
204+
if isinstance(exc, click.exceptions.Abort):
205+
sys.exit(0)
204206

205207
echo(f"Raised error: {exc}")
206208
if debug:

kasa/cli/time.py

Lines changed: 114 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@
55
from datetime import datetime
66

77
import asyncclick as click
8+
import zoneinfo
89

910
from kasa import (
1011
Device,
1112
Module,
1213
)
13-
from kasa.smart import SmartDevice
14+
from kasa.iot import IotDevice
15+
from kasa.iot.iottimezone import get_matching_timezones
1416

1517
from .common import (
1618
echo,
19+
error,
1720
pass_dev,
1821
)
1922

@@ -31,25 +34,127 @@ async def time(ctx: click.Context):
3134
async def time_get(dev: Device):
3235
"""Get the device time."""
3336
res = dev.time
34-
echo(f"Current time: {res}")
37+
echo(f"Current time: {dev.time} ({dev.timezone})")
3538
return res
3639

3740

3841
@time.command(name="sync")
42+
@click.option(
43+
"--timezone",
44+
type=str,
45+
required=False,
46+
default=None,
47+
help="IANA timezone name, will use current device timezone if not provided.",
48+
)
49+
@click.option(
50+
"--skip-confirm",
51+
type=str,
52+
required=False,
53+
default=False,
54+
is_flag=True,
55+
help="Do not ask to confirm the timezone if an exact match is not found.",
56+
)
3957
@pass_dev
40-
async def time_sync(dev: Device):
58+
async def time_sync(dev: Device, timezone: str | None, skip_confirm: bool):
4159
"""Set the device time to current time."""
42-
if not isinstance(dev, SmartDevice):
43-
raise NotImplementedError("setting time currently only implemented on smart")
60+
if (time := dev.modules.get(Module.Time)) is None:
61+
echo("Device does not have time module")
62+
return
63+
64+
now = datetime.now()
65+
66+
tzinfo: zoneinfo.ZoneInfo | None = None
67+
if timezone:
68+
tzinfo = await _get_timezone(dev, timezone, skip_confirm)
69+
if tzinfo.utcoffset(now) != now.astimezone().utcoffset():
70+
error(
71+
f"{timezone} has a different utc offset to local time,"
72+
+ "syncing will produce unexpected results."
73+
)
74+
now = now.replace(tzinfo=tzinfo)
75+
76+
echo(f"Old time: {time.time} ({time.timezone})")
77+
78+
await time.set_time(now)
79+
80+
await dev.update()
81+
echo(f"New time: {time.time} ({time.timezone})")
82+
4483

84+
@time.command(name="set")
85+
@click.argument("year", type=int)
86+
@click.argument("month", type=int)
87+
@click.argument("day", type=int)
88+
@click.argument("hour", type=int)
89+
@click.argument("minute", type=int)
90+
@click.argument("seconds", type=int, required=False, default=0)
91+
@click.option(
92+
"--timezone",
93+
type=str,
94+
required=False,
95+
default=None,
96+
help="IANA timezone name, will use current device timezone if not provided.",
97+
)
98+
@click.option(
99+
"--skip-confirm",
100+
type=bool,
101+
required=False,
102+
default=False,
103+
is_flag=True,
104+
help="Do not ask to confirm the timezone if an exact match is not found.",
105+
)
106+
@pass_dev
107+
async def time_set(
108+
dev: Device,
109+
year: int,
110+
month: int,
111+
day: int,
112+
hour: int,
113+
minute: int,
114+
seconds: int,
115+
timezone: str | None,
116+
skip_confirm: bool,
117+
):
118+
"""Set the device time to the provided time."""
45119
if (time := dev.modules.get(Module.Time)) is None:
46120
echo("Device does not have time module")
47121
return
48122

49-
echo("Old time: %s" % time.time)
123+
tzinfo: zoneinfo.ZoneInfo | None = None
124+
if timezone:
125+
tzinfo = await _get_timezone(dev, timezone, skip_confirm)
50126

51-
local_tz = datetime.now().astimezone().tzinfo
52-
await time.set_time(datetime.now(tz=local_tz))
127+
echo(f"Old time: {time.time} ({time.timezone})")
128+
129+
await time.set_time(datetime(year, month, day, hour, minute, seconds, 0, tzinfo))
53130

54131
await dev.update()
55-
echo("New time: %s" % time.time)
132+
echo(f"New time: {time.time} ({time.timezone})")
133+
134+
135+
async def _get_timezone(dev, timezone, skip_confirm) -> zoneinfo.ZoneInfo:
136+
"""Get the tzinfo from the timezone or return none."""
137+
tzinfo: zoneinfo.ZoneInfo | None = None
138+
139+
if timezone not in zoneinfo.available_timezones():
140+
error(f"{timezone} is not a valid IANA timezone.")
141+
142+
tzinfo = zoneinfo.ZoneInfo(timezone)
143+
if skip_confirm is False and isinstance(dev, IotDevice):
144+
matches = await get_matching_timezones(tzinfo)
145+
if not matches:
146+
error(f"Device cannot support {timezone} timezone.")
147+
first = matches[0]
148+
msg = (
149+
f"An exact match for {timezone} could not be found, "
150+
+ f"timezone will be set to {first}"
151+
)
152+
if len(matches) == 1:
153+
click.confirm(msg, abort=True)
154+
else:
155+
msg = (
156+
f"Supported timezones matching {timezone} are {', '.join(matches)}\n"
157+
+ msg
158+
)
159+
click.confirm(msg, abort=True)
160+
return tzinfo

kasa/device.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
schedule
5252
usage
5353
anti_theft
54-
time
54+
Time
5555
cloud
5656
Led
5757

kasa/interfaces/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .light import Light, LightState
77
from .lighteffect import LightEffect
88
from .lightpreset import LightPreset
9+
from .time import Time
910

1011
__all__ = [
1112
"Fan",
@@ -15,4 +16,5 @@
1516
"LightEffect",
1617
"LightState",
1718
"LightPreset",
19+
"Time",
1820
]

kasa/interfaces/time.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Module for time interface."""
2+
3+
from __future__ import annotations
4+
5+
from abc import ABC, abstractmethod
6+
from datetime import datetime, tzinfo
7+
8+
from ..module import Module
9+
10+
11+
class Time(Module, ABC):
12+
"""Base class for tplink time module."""
13+
14+
@property
15+
@abstractmethod
16+
def time(self) -> datetime:
17+
"""Return timezone aware current device time."""
18+
19+
@property
20+
@abstractmethod
21+
def timezone(self) -> tzinfo:
22+
"""Return current timezone."""
23+
24+
@abstractmethod
25+
async def set_time(self, dt: datetime) -> dict:
26+
"""Set the device time."""

kasa/iot/iotbulb.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ async def _initialize_modules(self):
219219
self.add_module(
220220
Module.IotAntitheft, Antitheft(self, "smartlife.iot.common.anti_theft")
221221
)
222-
self.add_module(Module.IotTime, Time(self, "smartlife.iot.common.timesetting"))
222+
self.add_module(Module.Time, Time(self, "smartlife.iot.common.timesetting"))
223223
self.add_module(Module.Energy, Emeter(self, self.emeter_type))
224224
self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
225225
self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud"))

kasa/iot/iotdevice.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from collections.abc import Mapping, Sequence
2121
from datetime import datetime, timedelta, tzinfo
2222
from typing import TYPE_CHECKING, Any, cast
23+
from warnings import warn
2324

2425
from ..device import Device, WifiNetwork
2526
from ..deviceconfig import DeviceConfig
@@ -460,27 +461,27 @@ async def set_alias(self, alias: str) -> None:
460461
@requires_update
461462
def time(self) -> datetime:
462463
"""Return current time from the device."""
463-
return self.modules[Module.IotTime].time
464+
return self.modules[Module.Time].time
464465

465466
@property
466467
@requires_update
467468
def timezone(self) -> tzinfo:
468469
"""Return the current timezone."""
469-
return self.modules[Module.IotTime].timezone
470+
return self.modules[Module.Time].timezone
470471

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

478-
async def get_timezone(self) -> dict:
478+
async def get_timezone(self) -> tzinfo:
479479
"""Return timezone information."""
480-
_LOGGER.warning(
480+
msg = (
481481
"Use `timezone` property instead, this call will be removed in the future."
482482
)
483-
return await self.modules[Module.IotTime].get_timezone()
483+
warn(msg, DeprecationWarning, stacklevel=1)
484+
return self.timezone
484485

485486
@property # type: ignore
486487
@requires_update

kasa/iot/iotplug.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ async def _initialize_modules(self):
6060
self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
6161
self.add_module(Module.IotUsage, Usage(self, "schedule"))
6262
self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft"))
63-
self.add_module(Module.IotTime, Time(self, "time"))
63+
self.add_module(Module.Time, Time(self, "time"))
6464
self.add_module(Module.IotCloud, Cloud(self, "cnCloud"))
6565
self.add_module(Module.Led, Led(self, "system"))
6666

kasa/iot/iotstrip.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ async def _initialize_modules(self):
105105
self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft"))
106106
self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
107107
self.add_module(Module.IotUsage, Usage(self, "schedule"))
108-
self.add_module(Module.IotTime, Time(self, "time"))
108+
self.add_module(Module.Time, Time(self, "time"))
109109
self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
110110
self.add_module(Module.Led, Led(self, "system"))
111111
self.add_module(Module.IotCloud, Cloud(self, "cnCloud"))

kasa/iot/iottimezone.py

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
from __future__ import annotations
44

55
import logging
6-
from datetime import datetime, tzinfo
6+
from datetime import datetime, timedelta, tzinfo
7+
from typing import cast
8+
9+
from zoneinfo import ZoneInfo
710

811
from ..cachedzoneinfo import CachedZoneInfo
912

@@ -22,26 +25,53 @@ async def get_timezone(index: int) -> tzinfo:
2225
return await CachedZoneInfo.get_cached_zone_info(name)
2326

2427

25-
async def get_timezone_index(name: str) -> int:
28+
async def get_timezone_index(tzone: tzinfo) -> int:
2629
"""Return the iot firmware index for a valid IANA timezone key."""
27-
rev = {val: key for key, val in TIMEZONE_INDEX.items()}
28-
if name in rev:
29-
return rev[name]
30+
if isinstance(tzone, ZoneInfo):
31+
name = tzone.key
32+
rev = {val: key for key, val in TIMEZONE_INDEX.items()}
33+
if name in rev:
34+
return rev[name]
3035

31-
# Try to find a supported timezone matching dst true/false
32-
zone = await CachedZoneInfo.get_cached_zone_info(name)
33-
now = datetime.now()
34-
winter = datetime(now.year, 1, 1, 12)
35-
summer = datetime(now.year, 7, 1, 12)
3636
for i in range(110):
37-
configured_zone = await get_timezone(i)
38-
if zone.utcoffset(winter) == configured_zone.utcoffset(
39-
winter
40-
) and zone.utcoffset(summer) == configured_zone.utcoffset(summer):
37+
if _is_same_timezone(tzone, await get_timezone(i)):
4138
return i
4239
raise ValueError("Device does not support timezone %s", name)
4340

4441

42+
async def get_matching_timezones(tzone: tzinfo) -> list[str]:
43+
"""Return the iot firmware index for a valid IANA timezone key."""
44+
matches = []
45+
if isinstance(tzone, ZoneInfo):
46+
name = tzone.key
47+
vals = {val for val in TIMEZONE_INDEX.values()}
48+
if name in vals:
49+
matches.append(name)
50+
51+
for i in range(110):
52+
fw_tz = await get_timezone(i)
53+
if _is_same_timezone(tzone, fw_tz):
54+
match_key = cast(ZoneInfo, fw_tz).key
55+
if match_key not in matches:
56+
matches.append(match_key)
57+
return matches
58+
59+
60+
def _is_same_timezone(tzone1: tzinfo, tzone2: tzinfo) -> bool:
61+
"""Return true if the timezones have the same utcffset and dst offset.
62+
63+
Iot devices only support a limited static list of IANA timezones; this is used to
64+
check if a static timezone matches the same utc offset and dst settings.
65+
"""
66+
now = datetime.now()
67+
start_day = datetime(now.year, 1, 1, 12)
68+
for i in range(365):
69+
the_day = start_day + timedelta(days=i)
70+
if tzone1.utcoffset(the_day) != tzone2.utcoffset(the_day):
71+
return False
72+
return True
73+
74+
4575
TIMEZONE_INDEX = {
4676
0: "Etc/GMT+12",
4777
1: "Pacific/Samoa",

0 commit comments

Comments
 (0)
0