mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-22 19:23:34 +00:00
Create common Time module and add time set cli command (#1157)
This commit is contained in:
parent
885a04d24f
commit
7fd8c14c1f
@ -201,6 +201,8 @@ def CatchAllExceptions(cls):
|
|||||||
# Handle exit request from click.
|
# Handle exit request from click.
|
||||||
if isinstance(exc, click.exceptions.Exit):
|
if isinstance(exc, click.exceptions.Exit):
|
||||||
sys.exit(exc.exit_code)
|
sys.exit(exc.exit_code)
|
||||||
|
if isinstance(exc, click.exceptions.Abort):
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
echo(f"Raised error: {exc}")
|
echo(f"Raised error: {exc}")
|
||||||
if debug:
|
if debug:
|
||||||
|
125
kasa/cli/time.py
125
kasa/cli/time.py
@ -5,15 +5,18 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import asyncclick as click
|
import asyncclick as click
|
||||||
|
import zoneinfo
|
||||||
|
|
||||||
from kasa import (
|
from kasa import (
|
||||||
Device,
|
Device,
|
||||||
Module,
|
Module,
|
||||||
)
|
)
|
||||||
from kasa.smart import SmartDevice
|
from kasa.iot import IotDevice
|
||||||
|
from kasa.iot.iottimezone import get_matching_timezones
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
echo,
|
echo,
|
||||||
|
error,
|
||||||
pass_dev,
|
pass_dev,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -31,25 +34,127 @@ async def time(ctx: click.Context):
|
|||||||
async def time_get(dev: Device):
|
async def time_get(dev: Device):
|
||||||
"""Get the device time."""
|
"""Get the device time."""
|
||||||
res = dev.time
|
res = dev.time
|
||||||
echo(f"Current time: {res}")
|
echo(f"Current time: {dev.time} ({dev.timezone})")
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@time.command(name="sync")
|
@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
|
@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."""
|
"""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:
|
if (time := dev.modules.get(Module.Time)) is None:
|
||||||
echo("Device does not have time module")
|
echo("Device does not have time module")
|
||||||
return
|
return
|
||||||
|
|
||||||
echo("Old time: %s" % time.time)
|
now = datetime.now()
|
||||||
|
|
||||||
local_tz = datetime.now().astimezone().tzinfo
|
tzinfo: zoneinfo.ZoneInfo | None = None
|
||||||
await time.set_time(datetime.now(tz=local_tz))
|
if timezone:
|
||||||
|
tzinfo = await _get_timezone(dev, timezone, skip_confirm)
|
||||||
|
if tzinfo.utcoffset(now) != now.astimezone().utcoffset():
|
||||||
|
error(
|
||||||
|
f"{timezone} has a different utc offset to local time,"
|
||||||
|
+ "syncing will produce unexpected results."
|
||||||
|
)
|
||||||
|
now = now.replace(tzinfo=tzinfo)
|
||||||
|
|
||||||
|
echo(f"Old time: {time.time} ({time.timezone})")
|
||||||
|
|
||||||
|
await time.set_time(now)
|
||||||
|
|
||||||
await dev.update()
|
await dev.update()
|
||||||
echo("New time: %s" % time.time)
|
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,
|
||||||
|
minute: 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
|
||||||
|
|
||||||
|
tzinfo: zoneinfo.ZoneInfo | None = None
|
||||||
|
if timezone:
|
||||||
|
tzinfo = await _get_timezone(dev, timezone, skip_confirm)
|
||||||
|
|
||||||
|
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(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.")
|
||||||
|
|
||||||
|
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.")
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
msg = (
|
||||||
|
f"Supported timezones matching {timezone} are {', '.join(matches)}\n"
|
||||||
|
+ msg
|
||||||
|
)
|
||||||
|
click.confirm(msg, abort=True)
|
||||||
|
return tzinfo
|
||||||
|
@ -51,7 +51,7 @@ Energy
|
|||||||
schedule
|
schedule
|
||||||
usage
|
usage
|
||||||
anti_theft
|
anti_theft
|
||||||
time
|
Time
|
||||||
cloud
|
cloud
|
||||||
Led
|
Led
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ from .led import Led
|
|||||||
from .light import Light, LightState
|
from .light import Light, LightState
|
||||||
from .lighteffect import LightEffect
|
from .lighteffect import LightEffect
|
||||||
from .lightpreset import LightPreset
|
from .lightpreset import LightPreset
|
||||||
|
from .time import Time
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Fan",
|
"Fan",
|
||||||
@ -15,4 +16,5 @@ __all__ = [
|
|||||||
"LightEffect",
|
"LightEffect",
|
||||||
"LightState",
|
"LightState",
|
||||||
"LightPreset",
|
"LightPreset",
|
||||||
|
"Time",
|
||||||
]
|
]
|
||||||
|
26
kasa/interfaces/time.py
Normal file
26
kasa/interfaces/time.py
Normal file
@ -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."""
|
@ -219,7 +219,7 @@ class IotBulb(IotDevice):
|
|||||||
self.add_module(
|
self.add_module(
|
||||||
Module.IotAntitheft, Antitheft(self, "smartlife.iot.common.anti_theft")
|
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.Energy, Emeter(self, self.emeter_type))
|
||||||
self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
|
self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
|
||||||
self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud"))
|
self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud"))
|
||||||
|
@ -20,6 +20,7 @@ import logging
|
|||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from datetime import datetime, timedelta, tzinfo
|
from datetime import datetime, timedelta, tzinfo
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
from warnings import warn
|
||||||
|
|
||||||
from ..device import Device, WifiNetwork
|
from ..device import Device, WifiNetwork
|
||||||
from ..deviceconfig import DeviceConfig
|
from ..deviceconfig import DeviceConfig
|
||||||
@ -460,27 +461,27 @@ class IotDevice(Device):
|
|||||||
@requires_update
|
@requires_update
|
||||||
def time(self) -> datetime:
|
def time(self) -> datetime:
|
||||||
"""Return current time from the device."""
|
"""Return current time from the device."""
|
||||||
return self.modules[Module.IotTime].time
|
return self.modules[Module.Time].time
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@requires_update
|
@requires_update
|
||||||
def timezone(self) -> tzinfo:
|
def timezone(self) -> tzinfo:
|
||||||
"""Return the current timezone."""
|
"""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."""
|
"""Return current time from the device, if available."""
|
||||||
_LOGGER.warning(
|
msg = "Use `time` property instead, this call will be removed in the future."
|
||||||
"Use `time` property instead, this call will be removed in the future."
|
warn(msg, DeprecationWarning, stacklevel=1)
|
||||||
)
|
return self.time
|
||||||
return await self.modules[Module.IotTime].get_time()
|
|
||||||
|
|
||||||
async def get_timezone(self) -> dict:
|
async def get_timezone(self) -> tzinfo:
|
||||||
"""Return timezone information."""
|
"""Return timezone information."""
|
||||||
_LOGGER.warning(
|
msg = (
|
||||||
"Use `timezone` property instead, this call will be removed in the future."
|
"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
|
@property # type: ignore
|
||||||
@requires_update
|
@requires_update
|
||||||
|
@ -60,7 +60,7 @@ class IotPlug(IotDevice):
|
|||||||
self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
|
self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
|
||||||
self.add_module(Module.IotUsage, Usage(self, "schedule"))
|
self.add_module(Module.IotUsage, Usage(self, "schedule"))
|
||||||
self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft"))
|
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.IotCloud, Cloud(self, "cnCloud"))
|
||||||
self.add_module(Module.Led, Led(self, "system"))
|
self.add_module(Module.Led, Led(self, "system"))
|
||||||
|
|
||||||
|
@ -105,7 +105,7 @@ class IotStrip(IotDevice):
|
|||||||
self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft"))
|
self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft"))
|
||||||
self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
|
self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
|
||||||
self.add_module(Module.IotUsage, Usage(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.IotCountdown, Countdown(self, "countdown"))
|
||||||
self.add_module(Module.Led, Led(self, "system"))
|
self.add_module(Module.Led, Led(self, "system"))
|
||||||
self.add_module(Module.IotCloud, Cloud(self, "cnCloud"))
|
self.add_module(Module.IotCloud, Cloud(self, "cnCloud"))
|
||||||
|
@ -3,7 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
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
|
from ..cachedzoneinfo import CachedZoneInfo
|
||||||
|
|
||||||
@ -22,26 +25,53 @@ async def get_timezone(index: int) -> tzinfo:
|
|||||||
return await CachedZoneInfo.get_cached_zone_info(name)
|
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."""
|
"""Return the iot firmware index for a valid IANA timezone key."""
|
||||||
rev = {val: key for key, val in TIMEZONE_INDEX.items()}
|
if isinstance(tzone, ZoneInfo):
|
||||||
if name in rev:
|
name = tzone.key
|
||||||
return rev[name]
|
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):
|
for i in range(110):
|
||||||
configured_zone = await get_timezone(i)
|
if _is_same_timezone(tzone, await get_timezone(i)):
|
||||||
if zone.utcoffset(winter) == configured_zone.utcoffset(
|
|
||||||
winter
|
|
||||||
) and zone.utcoffset(summer) == configured_zone.utcoffset(summer):
|
|
||||||
return i
|
return i
|
||||||
raise ValueError("Device does not support timezone %s", name)
|
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)
|
||||||
|
|
||||||
|
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 = {
|
TIMEZONE_INDEX = {
|
||||||
0: "Etc/GMT+12",
|
0: "Etc/GMT+12",
|
||||||
1: "Pacific/Samoa",
|
1: "Pacific/Samoa",
|
||||||
|
@ -5,11 +5,12 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timezone, tzinfo
|
from datetime import datetime, timezone, tzinfo
|
||||||
|
|
||||||
from ...exceptions import KasaException
|
from ...exceptions import KasaException
|
||||||
|
from ...interfaces import Time as TimeInterface
|
||||||
from ..iotmodule import IotModule, merge
|
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."""
|
"""Implements the timezone settings."""
|
||||||
|
|
||||||
_timezone: tzinfo = timezone.utc
|
_timezone: tzinfo = timezone.utc
|
||||||
@ -57,10 +58,36 @@ class Time(IotModule):
|
|||||||
res["hour"],
|
res["hour"],
|
||||||
res["min"],
|
res["min"],
|
||||||
res["sec"],
|
res["sec"],
|
||||||
|
tzinfo=self.timezone,
|
||||||
)
|
)
|
||||||
except KasaException:
|
except KasaException:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def set_time(self, dt: datetime) -> dict:
|
||||||
|
"""Set the device time."""
|
||||||
|
params = {
|
||||||
|
"year": dt.year,
|
||||||
|
"month": dt.month,
|
||||||
|
"mday": dt.day,
|
||||||
|
"hour": dt.hour,
|
||||||
|
"min": dt.minute,
|
||||||
|
"sec": dt.second,
|
||||||
|
}
|
||||||
|
if dt.tzinfo:
|
||||||
|
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"
|
||||||
|
else:
|
||||||
|
method = "set_time"
|
||||||
|
try:
|
||||||
|
return await self.call(method, params)
|
||||||
|
except Exception as ex:
|
||||||
|
raise KasaException(ex) from ex
|
||||||
|
|
||||||
async def get_timezone(self):
|
async def get_timezone(self):
|
||||||
"""Request timezone information from the device."""
|
"""Request timezone information from the device."""
|
||||||
return await self.call("get_timezone")
|
return await self.call("get_timezone")
|
||||||
|
@ -77,6 +77,7 @@ class Module(ABC):
|
|||||||
Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led")
|
Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led")
|
||||||
Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light")
|
Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light")
|
||||||
LightPreset: Final[ModuleName[interfaces.LightPreset]] = ModuleName("LightPreset")
|
LightPreset: Final[ModuleName[interfaces.LightPreset]] = ModuleName("LightPreset")
|
||||||
|
Time: Final[ModuleName[interfaces.Time]] = ModuleName("Time")
|
||||||
|
|
||||||
# IOT only Modules
|
# IOT only Modules
|
||||||
IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient")
|
IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient")
|
||||||
@ -86,7 +87,6 @@ class Module(ABC):
|
|||||||
IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule")
|
IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule")
|
||||||
IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage")
|
IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage")
|
||||||
IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud")
|
IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud")
|
||||||
IotTime: Final[ModuleName[iot.Time]] = ModuleName("time")
|
|
||||||
|
|
||||||
# SMART only Modules
|
# SMART only Modules
|
||||||
Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm")
|
Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm")
|
||||||
@ -123,7 +123,6 @@ class Module(ABC):
|
|||||||
TemperatureControl: Final[ModuleName[smart.TemperatureControl]] = ModuleName(
|
TemperatureControl: Final[ModuleName[smart.TemperatureControl]] = ModuleName(
|
||||||
"TemperatureControl"
|
"TemperatureControl"
|
||||||
)
|
)
|
||||||
Time: Final[ModuleName[smart.Time]] = ModuleName("Time")
|
|
||||||
WaterleakSensor: Final[ModuleName[smart.WaterleakSensor]] = ModuleName(
|
WaterleakSensor: Final[ModuleName[smart.WaterleakSensor]] = ModuleName(
|
||||||
"WaterleakSensor"
|
"WaterleakSensor"
|
||||||
)
|
)
|
||||||
|
@ -3,17 +3,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone, tzinfo
|
from datetime import datetime, timedelta, timezone, tzinfo
|
||||||
from time import mktime
|
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from zoneinfo import ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from ...cachedzoneinfo import CachedZoneInfo
|
from ...cachedzoneinfo import CachedZoneInfo
|
||||||
from ...feature import Feature
|
from ...feature import Feature
|
||||||
|
from ...interfaces import Time as TimeInterface
|
||||||
from ..smartmodule import SmartModule
|
from ..smartmodule import SmartModule
|
||||||
|
|
||||||
|
|
||||||
class Time(SmartModule):
|
class Time(SmartModule, TimeInterface):
|
||||||
"""Implementation of device_local_time."""
|
"""Implementation of device_local_time."""
|
||||||
|
|
||||||
REQUIRED_COMPONENT = "time"
|
REQUIRED_COMPONENT = "time"
|
||||||
@ -63,16 +63,23 @@ class Time(SmartModule):
|
|||||||
tz=self.timezone,
|
tz=self.timezone,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_time(self, dt: datetime):
|
async def set_time(self, dt: datetime) -> dict:
|
||||||
"""Set device time."""
|
"""Set device time."""
|
||||||
unixtime = mktime(dt.timetuple())
|
if not dt.tzinfo:
|
||||||
offset = cast(timedelta, dt.utcoffset())
|
timestamp = dt.replace(tzinfo=self.timezone).timestamp()
|
||||||
diff = offset / timedelta(minutes=1)
|
utc_offset = cast(timedelta, self.timezone.utcoffset(dt))
|
||||||
return await self.call(
|
else:
|
||||||
"set_device_time",
|
timestamp = dt.timestamp()
|
||||||
{
|
utc_offset = cast(timedelta, dt.utcoffset())
|
||||||
"timestamp": int(unixtime),
|
time_diff = utc_offset / timedelta(minutes=1)
|
||||||
"time_diff": int(diff),
|
|
||||||
"region": dt.tzname(),
|
params: dict[str, int | str] = {
|
||||||
},
|
"timestamp": int(timestamp),
|
||||||
)
|
"time_diff": int(time_diff),
|
||||||
|
}
|
||||||
|
if tz := dt.tzinfo:
|
||||||
|
region = tz.key if isinstance(tz, ZoneInfo) else dt.tzname()
|
||||||
|
# tzname can return null if a simple timezone object is provided.
|
||||||
|
if region:
|
||||||
|
params["region"] = region
|
||||||
|
return await self.call("set_device_time", params)
|
||||||
|
@ -118,7 +118,6 @@ TIME_MODULE = {
|
|||||||
"index": 12,
|
"index": 12,
|
||||||
"tz_str": "test2",
|
"tz_str": "test2",
|
||||||
},
|
},
|
||||||
"set_timezone": None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CLOUD_MODULE = {
|
CLOUD_MODULE = {
|
||||||
@ -353,6 +352,19 @@ class FakeIotTransport(BaseTransport):
|
|||||||
else:
|
else:
|
||||||
return light_state
|
return light_state
|
||||||
|
|
||||||
|
def set_time(self, new_state: dict, *args):
|
||||||
|
"""Implement set_time."""
|
||||||
|
mods = [
|
||||||
|
v
|
||||||
|
for k, v in self.proto.items()
|
||||||
|
if k in {"time", "smartlife.iot.common.timesetting"}
|
||||||
|
]
|
||||||
|
index = new_state.pop("index", None)
|
||||||
|
for mod in mods:
|
||||||
|
mod["get_time"] = new_state
|
||||||
|
if index is not None:
|
||||||
|
mod["get_timezone"]["index"] = index
|
||||||
|
|
||||||
baseproto = {
|
baseproto = {
|
||||||
"system": {
|
"system": {
|
||||||
"set_relay_state": set_relay_state,
|
"set_relay_state": set_relay_state,
|
||||||
@ -391,8 +403,12 @@ class FakeIotTransport(BaseTransport):
|
|||||||
"smartlife.iot.common.system": {
|
"smartlife.iot.common.system": {
|
||||||
"set_dev_alias": set_alias,
|
"set_dev_alias": set_alias,
|
||||||
},
|
},
|
||||||
"time": TIME_MODULE,
|
"time": {**TIME_MODULE, "set_time": set_time, "set_timezone": set_time},
|
||||||
"smartlife.iot.common.timesetting": TIME_MODULE,
|
"smartlife.iot.common.timesetting": {
|
||||||
|
**TIME_MODULE,
|
||||||
|
"set_time": set_time,
|
||||||
|
"set_timezone": set_time,
|
||||||
|
},
|
||||||
# HS220 brightness, different setter and getter
|
# HS220 brightness, different setter and getter
|
||||||
"smartlife.iot.dimmer": {
|
"smartlife.iot.dimmer": {
|
||||||
"set_brightness": set_hs220_brightness,
|
"set_brightness": set_hs220_brightness,
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import asyncclick as click
|
import asyncclick as click
|
||||||
import pytest
|
import pytest
|
||||||
from asyncclick.testing import CliRunner
|
from asyncclick.testing import CliRunner
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from kasa import (
|
from kasa import (
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
@ -308,12 +310,8 @@ async def test_time_get(dev, runner):
|
|||||||
assert "Current time: " in res.output
|
assert "Current time: " in res.output
|
||||||
|
|
||||||
|
|
||||||
@device_smart
|
|
||||||
async def test_time_sync(dev, mocker, runner):
|
async def test_time_sync(dev, mocker, runner):
|
||||||
"""Test time sync command.
|
"""Test time sync command."""
|
||||||
|
|
||||||
Currently implemented only for SMART.
|
|
||||||
"""
|
|
||||||
update = mocker.patch.object(dev, "update")
|
update = mocker.patch.object(dev, "update")
|
||||||
set_time_mock = mocker.spy(dev.modules[Module.Time], "set_time")
|
set_time_mock = mocker.spy(dev.modules[Module.Time], "set_time")
|
||||||
res = await runner.invoke(
|
res = await runner.invoke(
|
||||||
@ -329,6 +327,48 @@ async def test_time_sync(dev, mocker, runner):
|
|||||||
assert "New time: " in res.output
|
assert "New time: " in res.output
|
||||||
|
|
||||||
|
|
||||||
|
async def test_time_set(dev: Device, mocker, runner):
|
||||||
|
"""Test time set command."""
|
||||||
|
time_mod = dev.modules[Module.Time]
|
||||||
|
set_time_mock = mocker.spy(time_mod, "set_time")
|
||||||
|
dt = datetime(2024, 10, 15, 8, 15)
|
||||||
|
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)
|
||||||
|
|
||||||
|
assert res.exit_code == 0
|
||||||
|
assert "Old time: " in res.output
|
||||||
|
assert "New time: " in res.output
|
||||||
|
|
||||||
|
zone = ZoneInfo("Europe/Berlin")
|
||||||
|
dt = dt.replace(tzinfo=zone)
|
||||||
|
res = await runner.invoke(
|
||||||
|
time,
|
||||||
|
[
|
||||||
|
"set",
|
||||||
|
str(dt.year),
|
||||||
|
str(dt.month),
|
||||||
|
str(dt.day),
|
||||||
|
str(dt.hour),
|
||||||
|
str(dt.minute),
|
||||||
|
"--timezone",
|
||||||
|
zone.key,
|
||||||
|
],
|
||||||
|
input="y\n",
|
||||||
|
obj=dev,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert time_mod.time == dt
|
||||||
|
|
||||||
|
assert res.exit_code == 0
|
||||||
|
assert "Old time: " in res.output
|
||||||
|
assert "New time: " in res.output
|
||||||
|
|
||||||
|
|
||||||
async def test_emeter(dev: Device, mocker, runner):
|
async def test_emeter(dev: Device, mocker, runner):
|
||||||
res = await runner.invoke(emeter, obj=dev)
|
res = await runner.invoke(emeter, obj=dev)
|
||||||
if not dev.has_emeter:
|
if not dev.has_emeter:
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from kasa import Device, LightState, Module
|
from kasa import Device, LightState, Module
|
||||||
from kasa.tests.device_fixtures import (
|
from kasa.tests.device_fixtures import (
|
||||||
@ -319,3 +323,24 @@ async def test_light_preset_save(dev: Device, mocker: MockerFixture):
|
|||||||
assert new_preset_state.hue == new_preset.hue
|
assert new_preset_state.hue == new_preset.hue
|
||||||
assert new_preset_state.saturation == new_preset.saturation
|
assert new_preset_state.saturation == new_preset.saturation
|
||||||
assert new_preset_state.color_temp == new_preset.color_temp
|
assert new_preset_state.color_temp == new_preset.color_temp
|
||||||
|
|
||||||
|
|
||||||
|
async def test_set_time(dev: Device, freezer: FrozenDateTimeFactory):
|
||||||
|
"""Test setting the device time."""
|
||||||
|
freezer.move_to("2021-01-09 12:00:00+00:00")
|
||||||
|
time_mod = dev.modules[Module.Time]
|
||||||
|
tz_info = time_mod.timezone
|
||||||
|
now = datetime.now(tz=tz_info)
|
||||||
|
now = now.replace(microsecond=0)
|
||||||
|
assert time_mod.time != now
|
||||||
|
|
||||||
|
await time_mod.set_time(now)
|
||||||
|
await dev.update()
|
||||||
|
assert time_mod.time == now
|
||||||
|
|
||||||
|
zone = ZoneInfo("Europe/Berlin")
|
||||||
|
now = datetime.now(tz=zone)
|
||||||
|
now = now.replace(microsecond=0)
|
||||||
|
await time_mod.set_time(now)
|
||||||
|
await dev.update()
|
||||||
|
assert time_mod.time == now
|
||||||
|
@ -321,13 +321,14 @@ async def test_device_timezones():
|
|||||||
|
|
||||||
# Get an index from a timezone
|
# Get an index from a timezone
|
||||||
for index, zone in TIMEZONE_INDEX.items():
|
for index, zone in TIMEZONE_INDEX.items():
|
||||||
found_index = await get_timezone_index(zone)
|
zone_info = zoneinfo.ZoneInfo(zone)
|
||||||
|
found_index = await get_timezone_index(zone_info)
|
||||||
assert found_index == index
|
assert found_index == index
|
||||||
|
|
||||||
# Try a timezone not hardcoded finds another match
|
# Try a timezone not hardcoded finds another match
|
||||||
index = await get_timezone_index("Asia/Katmandu")
|
index = await get_timezone_index(zoneinfo.ZoneInfo("Asia/Katmandu"))
|
||||||
assert index == 77
|
assert index == 77
|
||||||
|
|
||||||
# Try a timezone not hardcoded no match
|
# Try a timezone not hardcoded no match
|
||||||
with pytest.raises(zoneinfo.ZoneInfoNotFoundError):
|
with pytest.raises(zoneinfo.ZoneInfoNotFoundError):
|
||||||
await get_timezone_index("Foo/bar")
|
await get_timezone_index(zoneinfo.ZoneInfo("Foo/bar"))
|
||||||
|
@ -184,7 +184,7 @@ async def test_time(dev):
|
|||||||
|
|
||||||
@device_iot
|
@device_iot
|
||||||
async def test_timezone(dev):
|
async def test_timezone(dev):
|
||||||
TZ_SCHEMA(await dev.get_timezone())
|
TZ_SCHEMA(await dev.modules[Module.Time].get_timezone())
|
||||||
|
|
||||||
|
|
||||||
@device_iot
|
@device_iot
|
||||||
|
Loading…
Reference in New Issue
Block a user