Merge remote-tracking branch 'upstream/master' into feat/device_update

This commit is contained in:
sdb9696
2024-05-14 18:35:23 +01:00
83 changed files with 3312 additions and 933 deletions

298
kasa/iot/effects.py Normal file
View File

@@ -0,0 +1,298 @@
"""Module for light strip effects (LB*, KL*, KB*)."""
from __future__ import annotations
from typing import cast
EFFECT_AURORA = {
"custom": 0,
"id": "xqUxDhbAhNLqulcuRMyPBmVGyTOyEMEu",
"brightness": 100,
"name": "Aurora",
"segments": [0],
"expansion_strategy": 1,
"enable": 1,
"type": "sequence",
"duration": 0,
"transition": 1500,
"direction": 4,
"spread": 7,
"repeat_times": 0,
"sequence": [[120, 100, 100], [240, 100, 100], [260, 100, 100], [280, 100, 100]],
}
EFFECT_BUBBLING_CAULDRON = {
"custom": 0,
"id": "tIwTRQBqJpeNKbrtBMFCgkdPTbAQGfRP",
"brightness": 100,
"name": "Bubbling Cauldron",
"segments": [0],
"expansion_strategy": 1,
"enable": 1,
"type": "random",
"hue_range": [100, 270],
"saturation_range": [80, 100],
"brightness_range": [50, 100],
"duration": 0,
"transition": 200,
"init_states": [[270, 100, 100]],
"fadeoff": 1000,
"random_seed": 24,
"backgrounds": [[270, 40, 50]],
}
EFFECT_CANDY_CANE = {
"custom": 0,
"id": "HCOttllMkNffeHjEOLEgrFJjbzQHoxEJ",
"brightness": 100,
"name": "Candy Cane",
"segments": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
"expansion_strategy": 1,
"enable": 1,
"type": "sequence",
"duration": 700,
"transition": 500,
"direction": 1,
"spread": 1,
"repeat_times": 0,
"sequence": [
[0, 0, 100],
[0, 0, 100],
[360, 81, 100],
[0, 0, 100],
[0, 0, 100],
[360, 81, 100],
[360, 81, 100],
[0, 0, 100],
[0, 0, 100],
[360, 81, 100],
[360, 81, 100],
[360, 81, 100],
[360, 81, 100],
[0, 0, 100],
[0, 0, 100],
[360, 81, 100],
],
}
EFFECT_CHRISTMAS = {
"custom": 0,
"id": "bwTatyinOUajKrDwzMmqxxJdnInQUgvM",
"brightness": 100,
"name": "Christmas",
"segments": [0],
"expansion_strategy": 1,
"enable": 1,
"type": "random",
"hue_range": [136, 146],
"saturation_range": [90, 100],
"brightness_range": [50, 100],
"duration": 5000,
"transition": 0,
"init_states": [[136, 0, 100]],
"fadeoff": 2000,
"random_seed": 100,
"backgrounds": [[136, 98, 75], [136, 0, 0], [350, 0, 100], [350, 97, 94]],
}
EFFECT_FLICKER = {
"custom": 0,
"id": "bCTItKETDFfrKANolgldxfgOakaarARs",
"brightness": 100,
"name": "Flicker",
"segments": [1],
"expansion_strategy": 1,
"enable": 1,
"type": "random",
"hue_range": [30, 40],
"saturation_range": [100, 100],
"brightness_range": [50, 100],
"duration": 0,
"transition": 0,
"transition_range": [375, 500],
"init_states": [[30, 81, 80]],
}
EFFECT_HANUKKAH = {
"custom": 0,
"id": "CdLeIgiKcQrLKMINRPTMbylATulQewLD",
"brightness": 100,
"name": "Hanukkah",
"segments": [1],
"expansion_strategy": 1,
"enable": 1,
"type": "random",
"hue_range": [200, 210],
"saturation_range": [0, 100],
"brightness_range": [50, 100],
"duration": 1500,
"transition": 0,
"transition_range": [400, 500],
"init_states": [[35, 81, 80]],
}
EFFECT_HAUNTED_MANSION = {
"custom": 0,
"id": "oJnFHsVQzFUTeIOBAhMRfVeujmSauhjJ",
"brightness": 80,
"name": "Haunted Mansion",
"segments": [80],
"expansion_strategy": 2,
"enable": 1,
"type": "random",
"hue_range": [45, 45],
"saturation_range": [10, 10],
"brightness_range": [0, 80],
"duration": 0,
"transition": 0,
"transition_range": [50, 1500],
"init_states": [[45, 10, 100]],
"fadeoff": 200,
"random_seed": 1,
"backgrounds": [[45, 10, 100]],
}
EFFECT_ICICLE = {
"custom": 0,
"id": "joqVjlaTsgzmuQQBAlHRkkPAqkBUiqeb",
"brightness": 70,
"name": "Icicle",
"segments": [0],
"expansion_strategy": 1,
"enable": 1,
"type": "sequence",
"duration": 0,
"transition": 400,
"direction": 4,
"spread": 3,
"repeat_times": 0,
"sequence": [
[190, 100, 70],
[190, 100, 70],
[190, 30, 50],
[190, 100, 70],
[190, 100, 70],
],
}
EFFECT_LIGHTNING = {
"custom": 0,
"id": "ojqpUUxdGHoIugGPknrUcRoyJiItsjuE",
"brightness": 100,
"name": "Lightning",
"segments": [7, 20, 23, 32, 34, 35, 49, 65, 66, 74, 80],
"expansion_strategy": 1,
"enable": 1,
"type": "random",
"hue_range": [240, 240],
"saturation_range": [10, 11],
"brightness_range": [90, 100],
"duration": 0,
"transition": 50,
"init_states": [[240, 30, 100]],
"fadeoff": 150,
"random_seed": 600,
"backgrounds": [[200, 100, 100], [200, 50, 10], [210, 10, 50], [240, 10, 0]],
}
EFFECT_OCEAN = {
"custom": 0,
"id": "oJjUMosgEMrdumfPANKbkFmBcAdEQsPy",
"brightness": 30,
"name": "Ocean",
"segments": [0],
"expansion_strategy": 1,
"enable": 1,
"type": "sequence",
"duration": 0,
"transition": 2000,
"direction": 3,
"spread": 16,
"repeat_times": 0,
"sequence": [[198, 84, 30], [198, 70, 30], [198, 10, 30]],
}
EFFECT_RAINBOW = {
"custom": 0,
"id": "izRhLCQNcDzIKdpMPqSTtBMuAIoreAuT",
"brightness": 100,
"name": "Rainbow",
"segments": [0],
"expansion_strategy": 1,
"enable": 1,
"type": "sequence",
"duration": 0,
"transition": 1500,
"direction": 1,
"spread": 12,
"repeat_times": 0,
"sequence": [[0, 100, 100], [100, 100, 100], [200, 100, 100], [300, 100, 100]],
}
EFFECT_RAINDROP = {
"custom": 0,
"id": "QbDFwiSFmLzQenUOPnJrsGqyIVrJrRsl",
"brightness": 30,
"name": "Raindrop",
"segments": [0],
"expansion_strategy": 1,
"enable": 1,
"type": "random",
"hue_range": [200, 200],
"saturation_range": [10, 20],
"brightness_range": [10, 30],
"duration": 0,
"transition": 1000,
"init_states": [[200, 40, 100]],
"fadeoff": 1000,
"random_seed": 24,
"backgrounds": [[200, 40, 0]],
}
EFFECT_SPRING = {
"custom": 0,
"id": "URdUpEdQbnOOechDBPMkKrwhSupLyvAg",
"brightness": 100,
"name": "Spring",
"segments": [0],
"expansion_strategy": 1,
"enable": 1,
"type": "random",
"hue_range": [0, 90],
"saturation_range": [30, 100],
"brightness_range": [90, 100],
"duration": 600,
"transition": 0,
"transition_range": [2000, 6000],
"init_states": [[80, 30, 100]],
"fadeoff": 1000,
"random_seed": 20,
"backgrounds": [[130, 100, 40]],
}
EFFECT_VALENTINES = {
"custom": 0,
"id": "QglBhMShPHUAuxLqzNEefFrGiJwahOmz",
"brightness": 100,
"name": "Valentines",
"segments": [0],
"expansion_strategy": 1,
"enable": 1,
"type": "random",
"hue_range": [340, 340],
"saturation_range": [30, 40],
"brightness_range": [90, 100],
"duration": 600,
"transition": 2000,
"init_states": [[340, 30, 100]],
"fadeoff": 3000,
"random_seed": 100,
"backgrounds": [[340, 20, 50], [20, 50, 50], [0, 100, 50]],
}
EFFECTS_LIST_V1 = [
EFFECT_AURORA,
EFFECT_BUBBLING_CAULDRON,
EFFECT_CANDY_CANE,
EFFECT_CHRISTMAS,
EFFECT_FLICKER,
EFFECT_HANUKKAH,
EFFECT_HAUNTED_MANSION,
EFFECT_ICICLE,
EFFECT_LIGHTNING,
EFFECT_OCEAN,
EFFECT_RAINBOW,
EFFECT_RAINDROP,
EFFECT_SPRING,
EFFECT_VALENTINES,
]
EFFECT_NAMES_V1: list[str] = [cast(str, effect["name"]) for effect in EFFECTS_LIST_V1]
EFFECT_MAPPING_V1 = {effect["name"]: effect for effect in EFFECTS_LIST_V1}

View File

@@ -9,13 +9,22 @@ from typing import Optional, cast
from pydantic.v1 import BaseModel, Field, root_validator
from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..feature import Feature
from ..interfaces.light import HSV, ColorTempRange, LightPreset
from ..module import Module
from ..protocol import BaseProtocol
from .iotdevice import IotDevice, KasaException, requires_update
from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage
from .modules import (
Antitheft,
Cloud,
Countdown,
Emeter,
Light,
Schedule,
Time,
Usage,
)
class BehaviorMode(str, Enum):
@@ -87,7 +96,7 @@ NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"}
_LOGGER = logging.getLogger(__name__)
class IotBulb(IotDevice, Bulb):
class IotBulb(IotDevice):
r"""Representation of a TP-Link Smart Bulb.
To initialize, you have to await :func:`update()` at least once.
@@ -169,9 +178,9 @@ class IotBulb(IotDevice, Bulb):
Bulb configuration presets can be accessed using the :func:`presets` property:
>>> bulb.presets
[BulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), BulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), BulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), BulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)]
[LightPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), LightPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)]
To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset`
To modify an existing preset, pass :class:`~kasa.smartbulb.LightPreset`
instance to :func:`save_preset` method:
>>> preset = bulb.presets[0]
@@ -198,64 +207,40 @@ class IotBulb(IotDevice, Bulb):
) -> None:
super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.Bulb
self.add_module("schedule", Schedule(self, "smartlife.iot.common.schedule"))
self.add_module("usage", Usage(self, "smartlife.iot.common.schedule"))
self.add_module("antitheft", Antitheft(self, "smartlife.iot.common.anti_theft"))
self.add_module("time", Time(self, "smartlife.iot.common.timesetting"))
self.add_module("emeter", Emeter(self, self.emeter_type))
self.add_module("countdown", Countdown(self, "countdown"))
self.add_module("cloud", Cloud(self, "smartlife.iot.common.cloud"))
async def _initialize_features(self):
await super()._initialize_features()
if bool(self.sys_info["is_dimmable"]): # pragma: no branch
self._add_feature(
Feature(
device=self,
id="brightness",
name="Brightness",
attribute_getter="brightness",
attribute_setter="set_brightness",
minimum_value=1,
maximum_value=100,
type=Feature.Type.Number,
category=Feature.Category.Primary,
)
)
if self.is_variable_color_temp:
self._add_feature(
Feature(
device=self,
id="color_temperature",
name="Color temperature",
container=self,
attribute_getter="color_temp",
attribute_setter="set_color_temp",
range_getter="valid_temperature_range",
category=Feature.Category.Primary,
type=Feature.Type.Number,
)
)
async def _initialize_modules(self):
"""Initialize modules not added in init."""
await super()._initialize_modules()
self.add_module(
Module.IotSchedule, Schedule(self, "smartlife.iot.common.schedule")
)
self.add_module(Module.IotUsage, Usage(self, "smartlife.iot.common.schedule"))
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.IotEmeter, Emeter(self, self.emeter_type))
self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud"))
self.add_module(Module.Light, Light(self, "light"))
@property # type: ignore
@requires_update
def is_color(self) -> bool:
def _is_color(self) -> bool:
"""Whether the bulb supports color changes."""
sys_info = self.sys_info
return bool(sys_info["is_color"])
@property # type: ignore
@requires_update
def is_dimmable(self) -> bool:
def _is_dimmable(self) -> bool:
"""Whether the bulb supports brightness changes."""
sys_info = self.sys_info
return bool(sys_info["is_dimmable"])
@property # type: ignore
@requires_update
def is_variable_color_temp(self) -> bool:
def _is_variable_color_temp(self) -> bool:
"""Whether the bulb supports color temperature changes."""
sys_info = self.sys_info
return bool(sys_info["is_variable_color_temp"])
@@ -267,7 +252,7 @@ class IotBulb(IotDevice, Bulb):
:return: White temperature range in Kelvin (minimum, maximum)
"""
if not self.is_variable_color_temp:
if not self._is_variable_color_temp:
raise KasaException("Color temperature not supported")
for model, temp_range in TPLINK_KELVIN.items():
@@ -367,7 +352,7 @@ class IotBulb(IotDevice, Bulb):
:return: hue, saturation and value (degrees, %, %)
"""
if not self.is_color:
if not self._is_color:
raise KasaException("Bulb does not support color.")
light_state = cast(dict, self.light_state)
@@ -394,7 +379,7 @@ class IotBulb(IotDevice, Bulb):
:param int value: value in percentage [0, 100]
:param int transition: transition in milliseconds.
"""
if not self.is_color:
if not self._is_color:
raise KasaException("Bulb does not support color.")
if not isinstance(hue, int) or not (0 <= hue <= 360):
@@ -421,7 +406,7 @@ class IotBulb(IotDevice, Bulb):
@requires_update
def color_temp(self) -> int:
"""Return color temperature of the device in kelvin."""
if not self.is_variable_color_temp:
if not self._is_variable_color_temp:
raise KasaException("Bulb does not support colortemp.")
light_state = self.light_state
@@ -436,7 +421,7 @@ class IotBulb(IotDevice, Bulb):
:param int temp: The new color temperature, in Kelvin
:param int transition: transition in milliseconds.
"""
if not self.is_variable_color_temp:
if not self._is_variable_color_temp:
raise KasaException("Bulb does not support colortemp.")
valid_temperature_range = self.valid_temperature_range
@@ -453,11 +438,15 @@ class IotBulb(IotDevice, Bulb):
return await self.set_light_state(light_state, transition=transition)
def _raise_for_invalid_brightness(self, value):
if not isinstance(value, int) or not (0 <= value <= 100):
raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)")
@property # type: ignore
@requires_update
def brightness(self) -> int:
"""Return the current brightness in percentage."""
if not self.is_dimmable: # pragma: no cover
if not self._is_dimmable: # pragma: no cover
raise KasaException("Bulb is not dimmable.")
light_state = self.light_state
@@ -472,7 +461,7 @@ class IotBulb(IotDevice, Bulb):
:param int brightness: brightness in percent
:param int transition: transition in milliseconds.
"""
if not self.is_dimmable: # pragma: no cover
if not self._is_dimmable: # pragma: no cover
raise KasaException("Bulb is not dimmable.")
self._raise_for_invalid_brightness(brightness)
@@ -518,11 +507,11 @@ class IotBulb(IotDevice, Bulb):
@property # type: ignore
@requires_update
def presets(self) -> list[BulbPreset]:
def presets(self) -> list[LightPreset]:
"""Return a list of available bulb setting presets."""
return [BulbPreset(**vals) for vals in self.sys_info["preferred_state"]]
return [LightPreset(**vals) for vals in self.sys_info["preferred_state"]]
async def save_preset(self, preset: BulbPreset):
async def save_preset(self, preset: LightPreset):
"""Save a setting preset.
You can either construct a preset object manually, or pass an existing one

View File

@@ -19,18 +19,18 @@ import functools
import inspect
import logging
from datetime import datetime, timedelta
from typing import Any, Mapping, Sequence, cast, overload
from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast
from ..device import Device, WifiNetwork
from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus
from ..exceptions import KasaException
from ..feature import Feature
from ..firmware import Firmware
from ..module import ModuleT
from ..module import Module
from ..modulemapping import ModuleMapping, ModuleName
from ..protocol import BaseProtocol
from .iotmodule import IotModule
from .modules import Emeter, Time
from .modules import Emeter
_LOGGER = logging.getLogger(__name__)
@@ -191,7 +191,7 @@ class IotDevice(Device):
self._supported_modules: dict[str, IotModule] | None = None
self._legacy_features: set[str] = set()
self._children: Mapping[str, IotDevice] = {}
self._modules: dict[str, IotModule] = {}
self._modules: dict[str | ModuleName[Module], IotModule] = {}
@property
def children(self) -> Sequence[IotDevice]:
@@ -199,38 +199,20 @@ class IotDevice(Device):
return list(self._children.values())
@property
def modules(self) -> dict[str, IotModule]:
def modules(self) -> ModuleMapping[IotModule]:
"""Return the device modules."""
if TYPE_CHECKING:
return cast(ModuleMapping[IotModule], self._modules)
return self._modules
@overload
def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ...
@overload
def get_module(self, module_type: str) -> IotModule | None: ...
def get_module(
self, module_type: type[ModuleT] | str
) -> ModuleT | IotModule | None:
"""Return the module from the device modules or None if not present."""
if isinstance(module_type, str):
module_name = module_type.lower()
elif issubclass(module_type, IotModule):
module_name = module_type.__name__.lower()
else:
return None
if module_name in self.modules:
return self.modules[module_name]
return None
def add_module(self, name: str, module: IotModule):
def add_module(self, name: str | ModuleName[Module], module: IotModule):
"""Register a module."""
if name in self.modules:
_LOGGER.debug("Module %s already registered, ignoring..." % name)
return
_LOGGER.debug("Adding module %s", module)
self.modules[name] = module
self._modules[name] = module
def _create_request(
self, target: str, cmd: str, arg: dict | None = None, child_ids=None
@@ -292,11 +274,11 @@ class IotDevice(Device):
@property # type: ignore
@requires_update
def supported_modules(self) -> list[str]:
def supported_modules(self) -> list[str | ModuleName[Module]]:
"""Return a set of modules supported by the device."""
# TODO: this should rather be called `features`, but we don't want to break
# the API now. Maybe just deprecate it and point the users to use this?
return list(self.modules.keys())
return list(self._modules.keys())
@property # type: ignore
@requires_update
@@ -325,12 +307,19 @@ class IotDevice(Device):
self._last_update = response
self._set_sys_info(response["system"]["get_sysinfo"])
if not self._modules:
await self._initialize_modules()
await self._modular_update(req)
if not self._features:
await self._initialize_features()
await self._modular_update(req)
self._set_sys_info(self._last_update["system"]["get_sysinfo"])
async def _initialize_modules(self):
"""Initialize modules not added in init."""
async def _initialize_features(self):
self._add_feature(
Feature(
@@ -353,29 +342,32 @@ class IotDevice(Device):
)
)
for module in self._modules.values():
module._initialize_features()
for module_feat in module._module_features.values():
self._add_feature(module_feat)
async def _modular_update(self, req: dict) -> None:
"""Execute an update query."""
if self.has_emeter:
_LOGGER.debug(
"The device has emeter, querying its information along sysinfo"
)
self.add_module("emeter", Emeter(self, self.emeter_type))
self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type))
# TODO: perhaps modules should not have unsupported modules,
# making separate handling for this unnecessary
if self._supported_modules is None:
supported = {}
for module in self.modules.values():
for module in self._modules.values():
if module.is_supported:
supported[module._module] = module
for module_feat in module._module_features.values():
self._add_feature(module_feat)
self._supported_modules = supported
request_list = []
est_response_size = 1024 if "system" in req else 0
for module in self.modules.values():
for module in self._modules.values():
if not module.is_supported:
_LOGGER.debug("Module %s not supported, skipping" % module)
continue
@@ -454,27 +446,27 @@ class IotDevice(Device):
@requires_update
def time(self) -> datetime:
"""Return current time from the device."""
return cast(Time, self.modules["time"]).time
return self.modules[Module.IotTime].time
@property
@requires_update
def timezone(self) -> dict:
"""Return the current timezone."""
return cast(Time, self.modules["time"]).timezone
return self.modules[Module.IotTime].timezone
async def get_time(self) -> datetime | None:
"""Return current time from the device, if available."""
_LOGGER.warning(
"Use `time` property instead, this call will be removed in the future."
)
return await cast(Time, self.modules["time"]).get_time()
return await self.modules[Module.IotTime].get_time()
async def get_timezone(self) -> dict:
"""Return timezone information."""
_LOGGER.warning(
"Use `timezone` property instead, this call will be removed in the future."
)
return await cast(Time, self.modules["time"]).get_timezone()
return await self.modules[Module.IotTime].get_timezone()
@property # type: ignore
@requires_update
@@ -555,26 +547,26 @@ class IotDevice(Device):
def emeter_realtime(self) -> EmeterStatus:
"""Return current energy readings."""
self._verify_emeter()
return EmeterStatus(cast(Emeter, self.modules["emeter"]).realtime)
return EmeterStatus(self.modules[Module.IotEmeter].realtime)
async def get_emeter_realtime(self) -> EmeterStatus:
"""Retrieve current energy readings."""
self._verify_emeter()
return EmeterStatus(await cast(Emeter, self.modules["emeter"]).get_realtime())
return EmeterStatus(await self.modules[Module.IotEmeter].get_realtime())
@property
@requires_update
def emeter_today(self) -> float | None:
"""Return today's energy consumption in kWh."""
self._verify_emeter()
return cast(Emeter, self.modules["emeter"]).emeter_today
return self.modules[Module.IotEmeter].emeter_today
@property
@requires_update
def emeter_this_month(self) -> float | None:
"""Return this month's energy consumption in kWh."""
self._verify_emeter()
return cast(Emeter, self.modules["emeter"]).emeter_this_month
return self.modules[Module.IotEmeter].emeter_this_month
async def get_emeter_daily(
self, year: int | None = None, month: int | None = None, kwh: bool = True
@@ -588,7 +580,7 @@ class IotDevice(Device):
:return: mapping of day of month to value
"""
self._verify_emeter()
return await cast(Emeter, self.modules["emeter"]).get_daystat(
return await self.modules[Module.IotEmeter].get_daystat(
year=year, month=month, kwh=kwh
)
@@ -603,15 +595,13 @@ class IotDevice(Device):
:return: dict: mapping of month to value
"""
self._verify_emeter()
return await cast(Emeter, self.modules["emeter"]).get_monthstat(
year=year, kwh=kwh
)
return await self.modules[Module.IotEmeter].get_monthstat(year=year, kwh=kwh)
@requires_update
async def erase_emeter_stats(self) -> dict:
"""Erase energy meter statistics."""
self._verify_emeter()
return await cast(Emeter, self.modules["emeter"]).erase_stats()
return await self.modules[Module.IotEmeter].erase_stats()
@requires_update
async def current_consumption(self) -> float:
@@ -716,9 +706,3 @@ class IotDevice(Device):
This should only be used for debugging purposes.
"""
return self._last_update or self._discovery_info
@property
@requires_update
def firmware(self) -> Firmware:
"""Returns object implementing the firmware handling."""
return cast(Firmware, self.modules["cloud"])

View File

@@ -7,11 +7,11 @@ from typing import Any
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..feature import Feature
from ..module import Module
from ..protocol import BaseProtocol
from .iotdevice import KasaException, requires_update
from .iotplug import IotPlug
from .modules import AmbientLight, Motion
from .modules import AmbientLight, Light, Motion
class ButtonAction(Enum):
@@ -79,29 +79,15 @@ class IotDimmer(IotPlug):
) -> None:
super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.Dimmer
async def _initialize_modules(self):
"""Initialize modules."""
await super()._initialize_modules()
# TODO: need to be verified if it's okay to call these on HS220 w/o these
# TODO: need to be figured out what's the best approach to detect support
self.add_module("motion", Motion(self, "smartlife.iot.PIR"))
self.add_module("ambient", AmbientLight(self, "smartlife.iot.LAS"))
async def _initialize_features(self):
await super()._initialize_features()
if "brightness" in self.sys_info: # pragma: no branch
self._add_feature(
Feature(
device=self,
id="brightness",
name="Brightness",
attribute_getter="brightness",
attribute_setter="set_brightness",
minimum_value=1,
maximum_value=100,
unit="%",
type=Feature.Type.Number,
category=Feature.Category.Primary,
)
)
self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR"))
self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS"))
self.add_module(Module.Light, Light(self, "light"))
@property # type: ignore
@requires_update
@@ -110,7 +96,7 @@ class IotDimmer(IotPlug):
Will return a range between 0 - 100.
"""
if not self.is_dimmable:
if not self._is_dimmable:
raise KasaException("Device is not dimmable.")
sys_info = self.sys_info
@@ -123,7 +109,7 @@ class IotDimmer(IotPlug):
:param int transition: transition duration in milliseconds.
Using a transition will cause the dimmer to turn on.
"""
if not self.is_dimmable:
if not self._is_dimmable:
raise KasaException("Device is not dimmable.")
if not isinstance(brightness, int):
@@ -232,7 +218,7 @@ class IotDimmer(IotPlug):
@property # type: ignore
@requires_update
def is_dimmable(self) -> bool:
def _is_dimmable(self) -> bool:
"""Whether the switch supports brightness changes."""
sys_info = self.sys_info
return "brightness" in sys_info

View File

@@ -4,10 +4,12 @@ from __future__ import annotations
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1
from ..module import Module
from ..protocol import BaseProtocol
from .effects import EFFECT_NAMES_V1
from .iotbulb import IotBulb
from .iotdevice import KasaException, requires_update
from .modules.lighteffect import LightEffect
class IotLightStrip(IotBulb):
@@ -55,6 +57,14 @@ class IotLightStrip(IotBulb):
super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.LightStrip
async def _initialize_modules(self):
"""Initialize modules not added in init."""
await super()._initialize_modules()
self.add_module(
Module.LightEffect,
LightEffect(self, "smartlife.iot.lighting_effect"),
)
@property # type: ignore
@requires_update
def length(self) -> int:
@@ -73,6 +83,8 @@ class IotLightStrip(IotBulb):
'id': '',
'name': ''}
"""
# LightEffectModule returns the current effect name
# so return the dict here for backwards compatibility
return self.sys_info["lighting_effect_state"]
@property # type: ignore
@@ -83,6 +95,8 @@ class IotLightStrip(IotBulb):
Example:
['Aurora', 'Bubbling Cauldron', ...]
"""
# LightEffectModule returns effect names along with a LIGHT_EFFECTS_OFF value
# so return the original effect names here for backwards compatibility
return EFFECT_NAMES_V1 if self.has_effects else None
@requires_update
@@ -105,15 +119,9 @@ class IotLightStrip(IotBulb):
:param int brightness: The wanted brightness
:param int transition: The wanted transition time
"""
if effect not in EFFECT_MAPPING_V1:
raise KasaException(f"The effect {effect} is not a built in effect.")
effect_dict = EFFECT_MAPPING_V1[effect]
if brightness is not None:
effect_dict["brightness"] = brightness
if transition is not None:
effect_dict["transition"] = transition
await self.set_custom_effect(effect_dict)
await self.modules[Module.LightEffect].set_effect(
effect, brightness=brightness, transition=transition
)
@requires_update
async def set_custom_effect(
@@ -126,8 +134,4 @@ class IotLightStrip(IotBulb):
"""
if not self.has_effects:
raise KasaException("Bulb does not support effects.")
await self._query_helper(
"smartlife.iot.lighting_effect",
"set_lighting_effect",
effect_dict,
)
await self.modules[Module.LightEffect].set_custom_effect(effect_dict)

View File

@@ -43,13 +43,19 @@ class IotModule(Module):
@property
def data(self):
"""Return the module specific raw data from the last update."""
if self._module not in self._device._last_update:
dev = self._device
q = self.query()
if not q:
return dev.sys_info
if self._module not in dev._last_update:
raise KasaException(
f"You need to call update() prior accessing module data"
f" for '{self._module}'"
)
return self._device._last_update[self._module]
return dev._last_update[self._module]
@property
def is_supported(self) -> bool:

View File

@@ -6,10 +6,10 @@ import logging
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..feature import Feature
from ..module import Module
from ..protocol import BaseProtocol
from .iotdevice import IotDevice, requires_update
from .modules import Antitheft, Cloud, Schedule, Time, Usage
from .modules import Antitheft, Cloud, Led, Schedule, Time, Usage
_LOGGER = logging.getLogger(__name__)
@@ -53,26 +53,16 @@ class IotPlug(IotDevice):
) -> None:
super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.Plug
self.add_module("schedule", Schedule(self, "schedule"))
self.add_module("usage", Usage(self, "schedule"))
self.add_module("antitheft", Antitheft(self, "anti_theft"))
self.add_module("time", Time(self, "time"))
self.add_module("cloud", Cloud(self, "cnCloud"))
async def _initialize_features(self):
await super()._initialize_features()
self._add_feature(
Feature(
device=self,
id="led",
name="LED",
icon="mdi:led-{state}",
attribute_getter="led",
attribute_setter="set_led",
type=Feature.Type.Switch,
)
)
async def _initialize_modules(self):
"""Initialize modules."""
await super()._initialize_modules()
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.IotCloud, Cloud(self, "cnCloud"))
self.add_module(Module.Led, Led(self, "system"))
@property # type: ignore
@requires_update
@@ -93,14 +83,11 @@ class IotPlug(IotDevice):
@requires_update
def led(self) -> bool:
"""Return the state of the led."""
sys_info = self.sys_info
return bool(1 - sys_info["led_off"])
return self.modules[Module.Led].led
async def set_led(self, state: bool):
"""Set the state of the led (night mode)."""
return await self._query_helper(
"system", "set_led_off", {"off": int(not state)}
)
return await self.modules[Module.Led].set_led(state)
class IotWallSwitch(IotPlug):

View File

@@ -10,6 +10,7 @@ from typing import Any
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..exceptions import KasaException
from ..module import Module
from ..protocol import BaseProtocol
from .iotdevice import (
EmeterStatus,
@@ -95,11 +96,11 @@ class IotStrip(IotDevice):
super().__init__(host=host, config=config, protocol=protocol)
self.emeter_type = "emeter"
self._device_type = DeviceType.Strip
self.add_module("antitheft", Antitheft(self, "anti_theft"))
self.add_module("schedule", Schedule(self, "schedule"))
self.add_module("usage", Usage(self, "schedule"))
self.add_module("time", Time(self, "time"))
self.add_module("countdown", Countdown(self, "countdown"))
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.IotCountdown, Countdown(self, "countdown"))
@property # type: ignore
@requires_update
@@ -253,8 +254,11 @@ class IotStripPlug(IotPlug):
self._last_update = parent._last_update
self._set_sys_info(parent.sys_info)
self._device_type = DeviceType.StripSocket
self._modules = {}
self.protocol = parent.protocol # Must use the same connection as the parent
async def _initialize_modules(self):
"""Initialize modules not added in init."""
await super()._initialize_modules()
self.add_module("time", Time(self, "time"))
async def update(self, update_children: bool = True):

View File

@@ -5,6 +5,9 @@ from .antitheft import Antitheft
from .cloud import Cloud
from .countdown import Countdown
from .emeter import Emeter
from .led import Led
from .light import Light
from .lighteffect import LightEffect
from .motion import Motion
from .rulemodule import Rule, RuleModule
from .schedule import Schedule
@@ -17,6 +20,9 @@ __all__ = [
"Cloud",
"Countdown",
"Emeter",
"Led",
"Light",
"LightEffect",
"Motion",
"Rule",
"RuleModule",

View File

@@ -4,19 +4,22 @@ from __future__ import annotations
import logging
from datetime import date
from typing import Optional
from typing import Callable, Coroutine, Optional
from pydantic.v1 import BaseModel, Field, validator
from ...feature import Feature
from ...firmware import (
from ...interfaces.firmware import (
Firmware,
UpdateResult,
)
from ...firmware import (
FirmwareUpdate as FirmwareUpdateInterface,
from ...interfaces.firmware import (
FirmwareDownloadState as FirmwareDownloadStateInterface,
)
from ..iotmodule import IotModule, merge
from ...interfaces.firmware import (
FirmwareUpdateInfo as FirmwareUpdateInfoInterface,
)
from ..iotmodule import IotModule
_LOGGER = logging.getLogger(__name__)
@@ -89,8 +92,11 @@ class Cloud(IotModule, Firmware):
# TODO: this is problematic, as it will fail the whole query on some
# devices if they are not connected to the internet
if self._module in self._device._last_update and self.is_connected:
req = merge(req, self.get_available_firmwares())
# The following causes a recursion error as self.is_connected
# accesses self.data which calls query. Also get_available_firmwares is async
# if self._module in self._device._last_update and self.is_connected:
# req = merge(req, self.get_available_firmwares())
return req
@@ -130,7 +136,12 @@ class Cloud(IotModule, Firmware):
"""Disconnect from the cloud."""
return await self.call("unbind")
async def update_firmware(self, *, progress_cb=None) -> UpdateResult:
async def update_firmware(
self,
*,
progress_cb: Callable[[FirmwareDownloadStateInterface], Coroutine]
| None = None,
) -> UpdateResult:
"""Perform firmware update."""
raise NotImplementedError
i = 0
@@ -144,11 +155,16 @@ class Cloud(IotModule, Firmware):
return UpdateResult("")
async def check_for_updates(self) -> FirmwareUpdateInterface:
async def check_for_updates(self) -> FirmwareUpdateInfoInterface:
"""Return firmware update information."""
# TODO: naming of the common firmware API methods
raise NotImplementedError
async def get_update_state(self) -> FirmwareUpdateInfoInterface:
"""Return firmware update information."""
fw = await self.get_firmware_update()
return FirmwareUpdateInterface(
return FirmwareUpdateInfoInterface(
update_available=fw.update_available,
current_version=self._device.hw_info.get("sw_ver"),
available_version=fw.version,

32
kasa/iot/modules/led.py Normal file
View File

@@ -0,0 +1,32 @@
"""Module for led controls."""
from __future__ import annotations
from ...interfaces.led import Led as LedInterface
from ..iotmodule import IotModule
class Led(IotModule, LedInterface):
"""Implementation of led controls."""
def query(self) -> dict:
"""Query to execute during the update cycle."""
return {}
@property
def mode(self):
"""LED mode setting.
"always", "never"
"""
return "always" if self.led else "never"
@property
def led(self) -> bool:
"""Return the state of the led."""
sys_info = self.data
return bool(1 - sys_info["led_off"])
async def set_led(self, state: bool):
"""Set the state of the led (night mode)."""
return await self.call("set_led_off", {"off": int(not state)})

200
kasa/iot/modules/light.py Normal file
View File

@@ -0,0 +1,200 @@
"""Implementation of brightness module."""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from ...device_type import DeviceType
from ...exceptions import KasaException
from ...feature import Feature
from ...interfaces.light import HSV, ColorTempRange
from ...interfaces.light import Light as LightInterface
from ..iotmodule import IotModule
if TYPE_CHECKING:
from ..iotbulb import IotBulb
from ..iotdimmer import IotDimmer
BRIGHTNESS_MIN = 0
BRIGHTNESS_MAX = 100
class Light(IotModule, LightInterface):
"""Implementation of brightness module."""
_device: IotBulb | IotDimmer
def _initialize_features(self):
"""Initialize features."""
super()._initialize_features()
device = self._device
if self._device.is_dimmable:
self._add_feature(
Feature(
device,
id="brightness",
name="Brightness",
container=self,
attribute_getter="brightness",
attribute_setter="set_brightness",
minimum_value=BRIGHTNESS_MIN,
maximum_value=BRIGHTNESS_MAX,
type=Feature.Type.Number,
category=Feature.Category.Primary,
)
)
if self._device.is_variable_color_temp:
self._add_feature(
Feature(
device=device,
id="color_temperature",
name="Color temperature",
container=self,
attribute_getter="color_temp",
attribute_setter="set_color_temp",
range_getter="valid_temperature_range",
category=Feature.Category.Primary,
type=Feature.Type.Number,
)
)
if self._device.is_color:
self._add_feature(
Feature(
device=device,
id="hsv",
name="HSV",
container=self,
attribute_getter="hsv",
attribute_setter="set_hsv",
# TODO proper type for setting hsv
type=Feature.Type.Unknown,
)
)
def query(self) -> dict:
"""Query to execute during the update cycle."""
# Brightness is contained in the main device info response.
return {}
def _get_bulb_device(self) -> IotBulb | None:
"""For type checker this gets an IotBulb.
IotDimmer is not a subclass of IotBulb and using isinstance
here at runtime would create a circular import.
"""
if self._device.device_type in {DeviceType.Bulb, DeviceType.LightStrip}:
return cast("IotBulb", self._device)
return None
@property # type: ignore
def is_dimmable(self) -> int:
"""Whether the bulb supports brightness changes."""
return self._device._is_dimmable
@property # type: ignore
def brightness(self) -> int:
"""Return the current brightness in percentage."""
return self._device.brightness
async def set_brightness(
self, brightness: int, *, transition: int | None = None
) -> dict:
"""Set the brightness in percentage.
:param int brightness: brightness in percent
:param int transition: transition in milliseconds.
"""
return await self._device.set_brightness(brightness, transition=transition)
@property
def is_color(self) -> bool:
"""Whether the light supports color changes."""
if (bulb := self._get_bulb_device()) is None:
return False
return bulb._is_color
@property
def is_variable_color_temp(self) -> bool:
"""Whether the bulb supports color temperature changes."""
if (bulb := self._get_bulb_device()) is None:
return False
return bulb._is_variable_color_temp
@property
def has_effects(self) -> bool:
"""Return True if the device supports effects."""
if (bulb := self._get_bulb_device()) is None:
return False
return bulb.has_effects
@property
def hsv(self) -> HSV:
"""Return the current HSV state of the bulb.
:return: hue, saturation and value (degrees, %, %)
"""
if (bulb := self._get_bulb_device()) is None or not bulb._is_color:
raise KasaException("Light does not support color.")
return bulb.hsv
async def set_hsv(
self,
hue: int,
saturation: int,
value: int | None = None,
*,
transition: int | None = None,
) -> dict:
"""Set new HSV.
Note, transition is not supported and will be ignored.
:param int hue: hue in degrees
:param int saturation: saturation in percentage [0,100]
:param int value: value in percentage [0, 100]
:param int transition: transition in milliseconds.
"""
if (bulb := self._get_bulb_device()) is None or not bulb._is_color:
raise KasaException("Light does not support color.")
return await bulb.set_hsv(hue, saturation, value, transition=transition)
@property
def valid_temperature_range(self) -> ColorTempRange:
"""Return the device-specific white temperature range (in Kelvin).
:return: White temperature range in Kelvin (minimum, maximum)
"""
if (
bulb := self._get_bulb_device()
) is None or not bulb._is_variable_color_temp:
raise KasaException("Light does not support colortemp.")
return bulb.valid_temperature_range
@property
def color_temp(self) -> int:
"""Whether the bulb supports color temperature changes."""
if (
bulb := self._get_bulb_device()
) is None or not bulb._is_variable_color_temp:
raise KasaException("Light does not support colortemp.")
return bulb.color_temp
async def set_color_temp(
self, temp: int, *, brightness=None, transition: int | None = None
) -> dict:
"""Set the color temperature of the device in kelvin.
Note, transition is not supported and will be ignored.
:param int temp: The new color temperature, in Kelvin
:param int transition: transition in milliseconds.
"""
if (
bulb := self._get_bulb_device()
) is None or not bulb._is_variable_color_temp:
raise KasaException("Light does not support colortemp.")
return await bulb.set_color_temp(
temp, brightness=brightness, transition=transition
)

View File

@@ -0,0 +1,97 @@
"""Module for light effects."""
from __future__ import annotations
from ...interfaces.lighteffect import LightEffect as LightEffectInterface
from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1
from ..iotmodule import IotModule
class LightEffect(IotModule, LightEffectInterface):
"""Implementation of dynamic light effects."""
@property
def effect(self) -> str:
"""Return effect state.
Example:
{'brightness': 50,
'custom': 0,
'enable': 0,
'id': '',
'name': ''}
"""
if (
(state := self.data.get("lighting_effect_state"))
and state.get("enable")
and (name := state.get("name"))
and name in EFFECT_NAMES_V1
):
return name
return self.LIGHT_EFFECTS_OFF
@property
def effect_list(self) -> list[str]:
"""Return built-in effects list.
Example:
['Aurora', 'Bubbling Cauldron', ...]
"""
effect_list = [self.LIGHT_EFFECTS_OFF]
effect_list.extend(EFFECT_NAMES_V1)
return effect_list
async def set_effect(
self,
effect: str,
*,
brightness: int | None = None,
transition: int | None = None,
) -> None:
"""Set an effect on the device.
If brightness or transition is defined,
its value will be used instead of the effect-specific default.
See :meth:`effect_list` for available effects,
or use :meth:`set_custom_effect` for custom effects.
:param str effect: The effect to set
:param int brightness: The wanted brightness
:param int transition: The wanted transition time
"""
if effect == self.LIGHT_EFFECTS_OFF:
effect_dict = dict(self.data["lighting_effect_state"])
effect_dict["enable"] = 0
elif effect not in EFFECT_MAPPING_V1:
raise ValueError(f"The effect {effect} is not a built in effect.")
else:
effect_dict = EFFECT_MAPPING_V1[effect]
if brightness is not None:
effect_dict["brightness"] = brightness
if transition is not None:
effect_dict["transition"] = transition
await self.set_custom_effect(effect_dict)
async def set_custom_effect(
self,
effect_dict: dict,
) -> None:
"""Set a custom effect on the device.
:param str effect_dict: The custom effect dict to set
"""
return await self.call(
"set_lighting_effect",
effect_dict,
)
@property
def has_custom_effects(self) -> bool:
"""Return True if the device supports setting custom effects."""
return True
def query(self):
"""Return the base query."""
return {}