mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-09-05 13:43:53 +00:00
Merge remote-tracking branch 'upstream/master' into nw/fix_non_indexed_strip
This commit is contained in:
20
kasa/iot/__init__.py
Normal file
20
kasa/iot/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Package for supporting legacy kasa devices."""
|
||||
|
||||
from .iotbulb import IotBulb
|
||||
from .iotcamera import IotCamera
|
||||
from .iotdevice import IotDevice
|
||||
from .iotdimmer import IotDimmer
|
||||
from .iotlightstrip import IotLightStrip
|
||||
from .iotplug import IotPlug, IotWallSwitch
|
||||
from .iotstrip import IotStrip
|
||||
|
||||
__all__ = [
|
||||
"IotDevice",
|
||||
"IotPlug",
|
||||
"IotBulb",
|
||||
"IotStrip",
|
||||
"IotDimmer",
|
||||
"IotLightStrip",
|
||||
"IotWallSwitch",
|
||||
"IotCamera",
|
||||
]
|
||||
298
kasa/iot/effects.py
Normal file
298
kasa/iot/effects.py
Normal 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}
|
||||
534
kasa/iot/iotbulb.py
Normal file
534
kasa/iot/iotbulb.py
Normal file
@@ -0,0 +1,534 @@
|
||||
"""Module for bulbs (LB*, KL*, KB*)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Annotated, cast
|
||||
|
||||
from mashumaro import DataClassDictMixin
|
||||
from mashumaro.config import BaseConfig
|
||||
from mashumaro.types import Alias
|
||||
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..interfaces.light import HSV, ColorTempRange
|
||||
from ..module import Module
|
||||
from ..protocols import BaseProtocol
|
||||
from .iotdevice import IotDevice, KasaException, requires_update
|
||||
from .modules import (
|
||||
Antitheft,
|
||||
Cloud,
|
||||
Countdown,
|
||||
Emeter,
|
||||
Light,
|
||||
LightPreset,
|
||||
Schedule,
|
||||
Time,
|
||||
Usage,
|
||||
)
|
||||
|
||||
|
||||
class BehaviorMode(str, Enum):
|
||||
"""Enum to present type of turn on behavior."""
|
||||
|
||||
#: Return to the last state known state.
|
||||
Last = "last_status"
|
||||
#: Use chosen preset.
|
||||
Preset = "customize_preset"
|
||||
#: Circadian
|
||||
Circadian = "circadian"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnOnBehavior(DataClassDictMixin):
|
||||
"""Model to present a single turn on behavior.
|
||||
|
||||
:param int preset: the index number of wanted preset.
|
||||
:param BehaviorMode mode: last status or preset mode.
|
||||
If you are changing existing settings, you should not set this manually.
|
||||
|
||||
To change the behavior, it is only necessary to change the :attr:`preset` field
|
||||
to contain either the preset index, or ``None`` for the last known state.
|
||||
"""
|
||||
|
||||
class Config(BaseConfig):
|
||||
"""Serialization config."""
|
||||
|
||||
omit_none = True
|
||||
serialize_by_alias = True
|
||||
|
||||
#: Wanted behavior
|
||||
mode: BehaviorMode
|
||||
#: Index of preset to use, or ``None`` for the last known state.
|
||||
preset: Annotated[int | None, Alias("index")] = None
|
||||
brightness: int | None = None
|
||||
color_temp: int | None = None
|
||||
hue: int | None = None
|
||||
saturation: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnOnBehaviors(DataClassDictMixin):
|
||||
"""Model to contain turn on behaviors."""
|
||||
|
||||
#: The behavior when the bulb is turned on programmatically.
|
||||
soft: Annotated[TurnOnBehavior, Alias("soft_on")]
|
||||
#: The behavior when the bulb has been off from mains power.
|
||||
hard: Annotated[TurnOnBehavior, Alias("hard_on")]
|
||||
|
||||
|
||||
TPLINK_KELVIN = {
|
||||
"LB130": ColorTempRange(2500, 9000),
|
||||
"LB120": ColorTempRange(2700, 6500),
|
||||
"LB230": ColorTempRange(2500, 9000),
|
||||
"KB130": ColorTempRange(2500, 9000),
|
||||
"KL130": ColorTempRange(2500, 9000),
|
||||
"KL125": ColorTempRange(2500, 6500),
|
||||
"KL135": ColorTempRange(2500, 9000),
|
||||
r"KL120\(EU\)": ColorTempRange(2700, 6500),
|
||||
r"KL120\(US\)": ColorTempRange(2700, 5000),
|
||||
r"KL430": ColorTempRange(2500, 9000),
|
||||
}
|
||||
|
||||
|
||||
NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IotBulb(IotDevice):
|
||||
r"""Representation of a TP-Link Smart Bulb.
|
||||
|
||||
To initialize, you have to await :func:`update()` at least once.
|
||||
This will allow accessing the properties using the exposed properties.
|
||||
|
||||
All changes to the device are done using awaitable methods,
|
||||
which will not change the cached values,
|
||||
so you must await :func:`update()` to fetch updates values from the device.
|
||||
|
||||
Errors reported by the device are raised as
|
||||
:class:`KasaException <kasa.exceptions.KasaException>`,
|
||||
and should be handled by the user of the library.
|
||||
|
||||
Examples:
|
||||
>>> import asyncio
|
||||
>>> bulb = IotBulb("127.0.0.1")
|
||||
>>> asyncio.run(bulb.update())
|
||||
>>> print(bulb.alias)
|
||||
Bulb2
|
||||
|
||||
Bulbs, like any other supported devices, can be turned on and off:
|
||||
|
||||
>>> asyncio.run(bulb.turn_off())
|
||||
>>> asyncio.run(bulb.turn_on())
|
||||
>>> asyncio.run(bulb.update())
|
||||
>>> print(bulb.is_on)
|
||||
True
|
||||
|
||||
You can use the ``is_``-prefixed properties to check for supported features:
|
||||
|
||||
>>> bulb.is_dimmable
|
||||
True
|
||||
>>> bulb.is_color
|
||||
True
|
||||
>>> bulb.is_variable_color_temp
|
||||
True
|
||||
|
||||
All known bulbs support changing the brightness:
|
||||
|
||||
>>> bulb.brightness
|
||||
30
|
||||
>>> asyncio.run(bulb.set_brightness(50))
|
||||
>>> asyncio.run(bulb.update())
|
||||
>>> bulb.brightness
|
||||
50
|
||||
|
||||
Bulbs supporting color temperature can be queried for the supported range:
|
||||
|
||||
>>> bulb.valid_temperature_range
|
||||
ColorTempRange(min=2500, max=9000)
|
||||
>>> asyncio.run(bulb.set_color_temp(3000))
|
||||
>>> asyncio.run(bulb.update())
|
||||
>>> bulb.color_temp
|
||||
3000
|
||||
|
||||
Color bulbs can be adjusted by passing hue, saturation and value:
|
||||
|
||||
>>> asyncio.run(bulb.set_hsv(180, 100, 80))
|
||||
>>> asyncio.run(bulb.update())
|
||||
>>> bulb.hsv
|
||||
HSV(hue=180, saturation=100, value=80)
|
||||
|
||||
If you don't want to use the default transitions,
|
||||
you can pass `transition` in milliseconds.
|
||||
All methods changing the state of the device support this parameter:
|
||||
|
||||
* :func:`turn_on`
|
||||
* :func:`turn_off`
|
||||
* :func:`set_hsv`
|
||||
* :func:`set_color_temp`
|
||||
* :func:`set_brightness`
|
||||
|
||||
Light strips (e.g., KL420L5) do not support this feature,
|
||||
but silently ignore the parameter.
|
||||
The following changes the brightness over a period of 10 seconds:
|
||||
|
||||
>>> asyncio.run(bulb.set_brightness(100, transition=10_000))
|
||||
|
||||
Bulb configuration presets can be accessed using the :func:`presets` property:
|
||||
|
||||
>>> [ preset.to_dict() for preset in bulb.presets }
|
||||
[{'brightness': 50, 'hue': 0, 'saturation': 0, 'color_temp': 2700, 'index': 0}, {'brightness': 100, 'hue': 0, 'saturation': 75, 'color_temp': 0, 'index': 1}, {'brightness': 100, 'hue': 120, 'saturation': 75, 'color_temp': 0, 'index': 2}, {'brightness': 100, 'hue': 240, 'saturation': 75, 'color_temp': 0, 'index': 3}]
|
||||
|
||||
To modify an existing preset, pass :class:`~kasa.interfaces.light.LightPreset`
|
||||
instance to :func:`save_preset` method:
|
||||
|
||||
>>> preset = bulb.presets[0]
|
||||
>>> preset.brightness
|
||||
50
|
||||
>>> preset.brightness = 100
|
||||
>>> asyncio.run(bulb.save_preset(preset))
|
||||
>>> asyncio.run(bulb.update())
|
||||
>>> bulb.presets[0].brightness
|
||||
100
|
||||
|
||||
""" # noqa: E501
|
||||
|
||||
LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice"
|
||||
SET_LIGHT_METHOD = "transition_light_state"
|
||||
emeter_type = "smartlife.iot.common.emeter"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
*,
|
||||
config: DeviceConfig | None = None,
|
||||
protocol: BaseProtocol | None = None,
|
||||
) -> None:
|
||||
super().__init__(host=host, config=config, protocol=protocol)
|
||||
self._device_type = DeviceType.Bulb
|
||||
|
||||
async def _initialize_modules(self) -> None:
|
||||
"""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.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"))
|
||||
self.add_module(Module.Light, Light(self, self.LIGHT_SERVICE))
|
||||
self.add_module(Module.LightPreset, LightPreset(self, self.LIGHT_SERVICE))
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
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:
|
||||
"""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:
|
||||
"""Whether the bulb supports color temperature changes."""
|
||||
sys_info = self.sys_info
|
||||
return bool(sys_info["is_variable_color_temp"])
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def _valid_temperature_range(self) -> ColorTempRange:
|
||||
"""Return the device-specific white temperature range (in Kelvin).
|
||||
|
||||
:return: White temperature range in Kelvin (minimum, maximum)
|
||||
"""
|
||||
if not self._is_variable_color_temp:
|
||||
raise KasaException("Color temperature not supported")
|
||||
|
||||
for model, temp_range in TPLINK_KELVIN.items():
|
||||
sys_info = self.sys_info
|
||||
if re.match(model, sys_info["model"]):
|
||||
return temp_range
|
||||
|
||||
_LOGGER.warning("Unknown color temperature range, fallback to 2700-5000")
|
||||
return ColorTempRange(2700, 5000)
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def light_state(self) -> dict[str, str]:
|
||||
"""Query the light state."""
|
||||
light_state = self.sys_info["light_state"]
|
||||
if light_state is None:
|
||||
raise KasaException(
|
||||
"The device has no light_state or you have not called update()"
|
||||
)
|
||||
|
||||
# if the bulb is off, its state is stored under a different key
|
||||
# as is_on property depends on on_off itself, we check it here manually
|
||||
is_on = light_state["on_off"]
|
||||
if not is_on:
|
||||
off_state = {**light_state["dft_on_state"], "on_off": is_on}
|
||||
return cast(dict, off_state)
|
||||
|
||||
return light_state
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def _has_effects(self) -> bool:
|
||||
"""Return True if the device supports effects."""
|
||||
return "lighting_effect_state" in self.sys_info
|
||||
|
||||
async def get_light_details(self) -> dict[str, int]:
|
||||
"""Return light details.
|
||||
|
||||
Example::
|
||||
|
||||
{'lamp_beam_angle': 290, 'min_voltage': 220, 'max_voltage': 240,
|
||||
'wattage': 5, 'incandescent_equivalent': 40, 'max_lumens': 450,
|
||||
'color_rendering_index': 80}
|
||||
"""
|
||||
return await self._query_helper(self.LIGHT_SERVICE, "get_light_details")
|
||||
|
||||
async def get_turn_on_behavior(self) -> TurnOnBehaviors:
|
||||
"""Return the behavior for turning the bulb on."""
|
||||
return TurnOnBehaviors.from_dict(
|
||||
await self._query_helper(self.LIGHT_SERVICE, "get_default_behavior")
|
||||
)
|
||||
|
||||
async def set_turn_on_behavior(self, behavior: TurnOnBehaviors) -> dict:
|
||||
"""Set the behavior for turning the bulb on.
|
||||
|
||||
If you do not want to manually construct the behavior object,
|
||||
you should use :func:`get_turn_on_behavior` to get the current settings.
|
||||
"""
|
||||
return await self._query_helper(
|
||||
self.LIGHT_SERVICE, "set_default_behavior", behavior.to_dict()
|
||||
)
|
||||
|
||||
async def get_light_state(self) -> dict[str, dict]:
|
||||
"""Query the light state."""
|
||||
# TODO: add warning and refer to use light.state?
|
||||
return await self._query_helper(self.LIGHT_SERVICE, "get_light_state")
|
||||
|
||||
async def _set_light_state(
|
||||
self, state: dict, *, transition: int | None = None
|
||||
) -> dict:
|
||||
"""Set the light state."""
|
||||
state = {**state}
|
||||
if transition is not None:
|
||||
state["transition_period"] = transition
|
||||
|
||||
if "brightness" in state:
|
||||
self._raise_for_invalid_brightness(state["brightness"])
|
||||
|
||||
# if no on/off is defined, turn on the light
|
||||
if "on_off" not in state:
|
||||
state["on_off"] = 1
|
||||
|
||||
# If we are turning on without any color mode flags,
|
||||
# we do not want to set ignore_default to ensure
|
||||
# we restore the previous state.
|
||||
if state["on_off"] and NON_COLOR_MODE_FLAGS.issuperset(state):
|
||||
state["ignore_default"] = 0
|
||||
else:
|
||||
# This is necessary to allow turning on into a specific state
|
||||
state["ignore_default"] = 1
|
||||
|
||||
light_state = await self._query_helper(
|
||||
self.LIGHT_SERVICE, self.SET_LIGHT_METHOD, state
|
||||
)
|
||||
return light_state
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def _hsv(self) -> HSV:
|
||||
"""Return the current HSV state of the bulb.
|
||||
|
||||
:return: hue, saturation and value (degrees, %, %)
|
||||
"""
|
||||
if not self._is_color:
|
||||
raise KasaException("Bulb does not support color.")
|
||||
|
||||
light_state = cast(dict, self.light_state)
|
||||
|
||||
hue = light_state["hue"]
|
||||
saturation = light_state["saturation"]
|
||||
value = self._brightness
|
||||
|
||||
# Simple HSV(hue, saturation, value) is less efficent than below
|
||||
# due to the cpython implementation.
|
||||
return tuple.__new__(HSV, (hue, saturation, value))
|
||||
|
||||
@requires_update
|
||||
async def _set_hsv(
|
||||
self,
|
||||
hue: int,
|
||||
saturation: int,
|
||||
value: int | None = None,
|
||||
*,
|
||||
transition: int | None = None,
|
||||
) -> dict:
|
||||
"""Set new HSV.
|
||||
|
||||
: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 not self._is_color:
|
||||
raise KasaException("Bulb does not support color.")
|
||||
|
||||
if not isinstance(hue, int):
|
||||
raise TypeError("Hue must be an integer.")
|
||||
if not (0 <= hue <= 360):
|
||||
raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)")
|
||||
|
||||
if not isinstance(saturation, int):
|
||||
raise TypeError("Saturation must be an integer.")
|
||||
if not (0 <= saturation <= 100):
|
||||
raise ValueError(
|
||||
f"Invalid saturation value: {saturation} (valid range: 0-100%)"
|
||||
)
|
||||
|
||||
light_state = {
|
||||
"hue": hue,
|
||||
"saturation": saturation,
|
||||
"color_temp": 0,
|
||||
}
|
||||
|
||||
if value is not None:
|
||||
self._raise_for_invalid_brightness(value)
|
||||
light_state["brightness"] = value
|
||||
|
||||
return await self._set_light_state(light_state, transition=transition)
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def _color_temp(self) -> int:
|
||||
"""Return color temperature of the device in kelvin."""
|
||||
if not self._is_variable_color_temp:
|
||||
raise KasaException("Bulb does not support colortemp.")
|
||||
|
||||
light_state = self.light_state
|
||||
return int(light_state["color_temp"])
|
||||
|
||||
@requires_update
|
||||
async def _set_color_temp(
|
||||
self, temp: int, *, brightness: int | None = None, transition: int | None = None
|
||||
) -> dict:
|
||||
"""Set the color temperature of the device in kelvin.
|
||||
|
||||
:param int temp: The new color temperature, in Kelvin
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
if not self._is_variable_color_temp:
|
||||
raise KasaException("Bulb does not support colortemp.")
|
||||
|
||||
valid_temperature_range = self._valid_temperature_range
|
||||
if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]:
|
||||
raise ValueError(
|
||||
"Temperature should be between {} and {}, was {}".format(
|
||||
*valid_temperature_range, temp
|
||||
)
|
||||
)
|
||||
|
||||
light_state = {"color_temp": temp}
|
||||
if brightness is not None:
|
||||
light_state["brightness"] = brightness
|
||||
|
||||
return await self._set_light_state(light_state, transition=transition)
|
||||
|
||||
def _raise_for_invalid_brightness(self, value: int) -> None:
|
||||
if not isinstance(value, int):
|
||||
raise TypeError("Brightness must be an integer")
|
||||
if 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
|
||||
raise KasaException("Bulb is not dimmable.")
|
||||
|
||||
# If the device supports effects and one is active, we get the brightness
|
||||
# from the effect. This is not required when setting the brightness as
|
||||
# the device handles it via set_light_state
|
||||
if (
|
||||
light_effect := self.modules.get(Module.IotLightEffect)
|
||||
) is not None and light_effect.effect != light_effect.LIGHT_EFFECTS_OFF:
|
||||
return light_effect.brightness
|
||||
light_state = self.light_state
|
||||
return int(light_state["brightness"])
|
||||
|
||||
@requires_update
|
||||
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.
|
||||
"""
|
||||
if not self._is_dimmable: # pragma: no cover
|
||||
raise KasaException("Bulb is not dimmable.")
|
||||
|
||||
self._raise_for_invalid_brightness(brightness)
|
||||
|
||||
light_state = {"brightness": brightness}
|
||||
return await self._set_light_state(light_state, transition=transition)
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def is_on(self) -> bool:
|
||||
"""Return whether the device is on."""
|
||||
light_state = self.light_state
|
||||
return bool(light_state["on_off"])
|
||||
|
||||
async def turn_off(self, *, transition: int | None = None, **kwargs) -> dict:
|
||||
"""Turn the bulb off.
|
||||
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
return await self._set_light_state({"on_off": 0}, transition=transition)
|
||||
|
||||
async def turn_on(self, *, transition: int | None = None, **kwargs) -> dict:
|
||||
"""Turn the bulb on.
|
||||
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
return await self._set_light_state({"on_off": 1}, transition=transition)
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def has_emeter(self) -> bool:
|
||||
"""Return that the bulb has an emeter."""
|
||||
return True
|
||||
|
||||
async def set_alias(self, alias: str) -> dict:
|
||||
"""Set the device name (alias).
|
||||
|
||||
Overridden to use a different module name.
|
||||
"""
|
||||
return await self._query_helper(
|
||||
"smartlife.iot.common.system", "set_dev_alias", {"alias": alias}
|
||||
)
|
||||
|
||||
@property
|
||||
def max_device_response_size(self) -> int:
|
||||
"""Returns the maximum response size the device can safely construct."""
|
||||
return 4096
|
||||
42
kasa/iot/iotcamera.py
Normal file
42
kasa/iot/iotcamera.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Module for cameras."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, tzinfo
|
||||
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..protocols import BaseProtocol
|
||||
from .iotdevice import IotDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IotCamera(IotDevice):
|
||||
"""Representation of a TP-Link Camera."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
*,
|
||||
config: DeviceConfig | None = None,
|
||||
protocol: BaseProtocol | None = None,
|
||||
) -> None:
|
||||
super().__init__(host=host, config=config, protocol=protocol)
|
||||
self._device_type = DeviceType.Camera
|
||||
|
||||
@property
|
||||
def time(self) -> datetime:
|
||||
"""Get the camera's time."""
|
||||
return datetime.fromtimestamp(self.sys_info["system_time"])
|
||||
|
||||
@property
|
||||
def timezone(self) -> tzinfo:
|
||||
"""Get the camera's timezone."""
|
||||
return None # type: ignore
|
||||
|
||||
@property # type: ignore
|
||||
def is_on(self) -> bool:
|
||||
"""Return whether device is on."""
|
||||
return True
|
||||
777
kasa/iot/iotdevice.py
Executable file
777
kasa/iot/iotdevice.py
Executable file
@@ -0,0 +1,777 @@
|
||||
"""Python library supporting TP-Link Smart Home devices.
|
||||
|
||||
The communication protocol was reverse engineered by Lubomir Stroetmann and
|
||||
Tobias Esser in 'Reverse Engineering the TP-Link HS110':
|
||||
https://www.softscheck.com/en/reverse-engineering-tp-link-hs110/
|
||||
|
||||
This library reuses codes and concepts of the TP-Link WiFi SmartPlug Client
|
||||
at https://github.com/softScheck/tplink-smartplug, developed by Lubomir
|
||||
Stroetmann which is licensed under the Apache License, Version 2.0.
|
||||
|
||||
You may obtain a copy of the license at
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from datetime import datetime, timedelta, tzinfo
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from warnings import warn
|
||||
|
||||
from ..device import Device, DeviceInfo, WifiNetwork
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..exceptions import KasaException
|
||||
from ..feature import Feature
|
||||
from ..module import Module
|
||||
from ..modulemapping import ModuleMapping, ModuleName
|
||||
from ..protocols import BaseProtocol
|
||||
from .iotmodule import IotModule, merge
|
||||
from .modules import Emeter
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def requires_update(f: Callable) -> Any:
|
||||
"""Indicate that `update` should be called before accessing this method.""" # noqa: D202
|
||||
if inspect.iscoroutinefunction(f):
|
||||
|
||||
@functools.wraps(f)
|
||||
async def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||
self = args[0]
|
||||
if not self._last_update and (
|
||||
self._sys_info is None or f.__name__ not in self._sys_info
|
||||
):
|
||||
raise KasaException("You need to await update() to access the data")
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
else:
|
||||
|
||||
@functools.wraps(f)
|
||||
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||
self = args[0]
|
||||
if not self._last_update and (
|
||||
self._sys_info is None or f.__name__ not in self._sys_info
|
||||
):
|
||||
raise KasaException("You need to await update() to access the data")
|
||||
return f(*args, **kwargs)
|
||||
|
||||
f.requires_update = True # type: ignore[attr-defined]
|
||||
return wrapped
|
||||
|
||||
|
||||
@functools.lru_cache
|
||||
def _parse_features(features: str) -> set[str]:
|
||||
"""Parse features string."""
|
||||
return set(features.split(":"))
|
||||
|
||||
|
||||
def _extract_sys_info(info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the system info structure."""
|
||||
sysinfo_default = info.get("system", {}).get("get_sysinfo", {})
|
||||
sysinfo_nest = sysinfo_default.get("system", {})
|
||||
|
||||
if len(sysinfo_nest) > len(sysinfo_default) and isinstance(sysinfo_nest, dict):
|
||||
return sysinfo_nest
|
||||
return sysinfo_default
|
||||
|
||||
|
||||
class IotDevice(Device):
|
||||
"""Base class for all supported device types.
|
||||
|
||||
You don't usually want to initialize this class manually,
|
||||
but either use :class:`Discover` class, or use one of the subclasses:
|
||||
|
||||
* :class:`IotPlug`
|
||||
* :class:`IotBulb`
|
||||
* :class:`IotStrip`
|
||||
* :class:`IotDimmer`
|
||||
* :class:`IotLightStrip`
|
||||
|
||||
To initialize, you have to await :func:`update()` at least once.
|
||||
This will allow accessing the properties using the exposed properties.
|
||||
|
||||
All changes to the device are done using awaitable methods,
|
||||
which will not change the cached values, but you must await update() separately.
|
||||
|
||||
Errors reported by the device are raised as
|
||||
:class:`KasaException <kasa.exceptions.KasaException>`,
|
||||
and should be handled by the user of the library.
|
||||
|
||||
Examples:
|
||||
>>> import asyncio
|
||||
>>> dev = IotDevice("127.0.0.1")
|
||||
>>> asyncio.run(dev.update())
|
||||
|
||||
All devices provide several informational properties:
|
||||
|
||||
>>> dev.alias
|
||||
Bedroom Lamp Plug
|
||||
>>> dev.model
|
||||
HS110
|
||||
>>> dev.rssi
|
||||
-71
|
||||
>>> dev.mac
|
||||
50:C7:BF:00:00:00
|
||||
|
||||
Some information can also be changed programmatically:
|
||||
|
||||
>>> asyncio.run(dev.set_alias("new alias"))
|
||||
>>> asyncio.run(dev.set_mac("01:23:45:67:89:ab"))
|
||||
>>> asyncio.run(dev.update())
|
||||
>>> dev.alias
|
||||
new alias
|
||||
>>> dev.mac
|
||||
01:23:45:67:89:ab
|
||||
|
||||
When initialized using discovery or using a subclass,
|
||||
you can check the type of the device:
|
||||
|
||||
>>> dev.is_bulb
|
||||
False
|
||||
>>> dev.is_strip
|
||||
False
|
||||
>>> dev.is_plug
|
||||
True
|
||||
|
||||
You can also get the hardware and software as a dict,
|
||||
or access the full device response:
|
||||
|
||||
>>> dev.hw_info
|
||||
{'sw_ver': '1.2.5 Build 171213 Rel.101523',
|
||||
'hw_ver': '1.0',
|
||||
'mac': '01:23:45:67:89:ab',
|
||||
'type': 'IOT.SMARTPLUGSWITCH',
|
||||
'hwId': '00000000000000000000000000000000',
|
||||
'fwId': '00000000000000000000000000000000',
|
||||
'oemId': '00000000000000000000000000000000',
|
||||
'dev_name': 'Wi-Fi Smart Plug With Energy Monitoring'}
|
||||
>>> dev.sys_info
|
||||
|
||||
All devices can be turned on and off:
|
||||
|
||||
>>> asyncio.run(dev.turn_off())
|
||||
>>> asyncio.run(dev.turn_on())
|
||||
>>> asyncio.run(dev.update())
|
||||
>>> dev.is_on
|
||||
True
|
||||
|
||||
Some devices provide energy consumption meter,
|
||||
and regular update will already fetch some information:
|
||||
|
||||
>>> dev.has_emeter
|
||||
True
|
||||
>>> dev.emeter_realtime
|
||||
<EmeterStatus power=0.928511 voltage=231.067823 current=0.014937 total=55.139>
|
||||
>>> dev.emeter_today
|
||||
>>> dev.emeter_this_month
|
||||
|
||||
You can also query the historical data (note that these needs to be awaited),
|
||||
keyed with month/day:
|
||||
|
||||
>>> asyncio.run(dev.get_emeter_monthly(year=2016))
|
||||
{11: 1.089, 12: 1.582}
|
||||
>>> asyncio.run(dev.get_emeter_daily(year=2016, month=11))
|
||||
{24: 0.026, 25: 0.109}
|
||||
|
||||
"""
|
||||
|
||||
emeter_type = "emeter"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
*,
|
||||
config: DeviceConfig | None = None,
|
||||
protocol: BaseProtocol | None = None,
|
||||
) -> None:
|
||||
"""Create a new IotDevice instance."""
|
||||
super().__init__(host=host, config=config, protocol=protocol)
|
||||
|
||||
self._sys_info: Any = None # TODO: this is here to avoid changing tests
|
||||
self._supported_modules: dict[str | ModuleName[Module], IotModule] | None = None
|
||||
self._legacy_features: set[str] = set()
|
||||
self._children: Mapping[str, IotDevice] = {}
|
||||
self._modules: dict[str | ModuleName[Module], IotModule] = {}
|
||||
self._on_since: datetime | None = None
|
||||
|
||||
@property
|
||||
def children(self) -> Sequence[IotDevice]:
|
||||
"""Return list of children."""
|
||||
return list(self._children.values())
|
||||
|
||||
@property
|
||||
@requires_update
|
||||
def modules(self) -> ModuleMapping[IotModule]:
|
||||
"""Return the device modules."""
|
||||
if TYPE_CHECKING:
|
||||
return cast(ModuleMapping[IotModule], self._supported_modules)
|
||||
return self._supported_modules
|
||||
|
||||
def add_module(self, name: str | ModuleName[Module], module: IotModule) -> None:
|
||||
"""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
|
||||
|
||||
def _create_request(
|
||||
self,
|
||||
target: str,
|
||||
cmd: str,
|
||||
arg: dict | None = None,
|
||||
child_ids: list | None = None,
|
||||
) -> dict:
|
||||
if arg is None:
|
||||
arg = {}
|
||||
request: dict[str, Any] = {target: {cmd: arg}}
|
||||
if child_ids is not None:
|
||||
request = {"context": {"child_ids": child_ids}, target: {cmd: arg}}
|
||||
|
||||
return request
|
||||
|
||||
def _verify_emeter(self) -> None:
|
||||
"""Raise an exception if there is no emeter."""
|
||||
if not self.has_emeter:
|
||||
raise KasaException("Device has no emeter")
|
||||
if self.emeter_type not in self._last_update:
|
||||
raise KasaException("update() required prior accessing emeter")
|
||||
|
||||
async def _query_helper(
|
||||
self,
|
||||
target: str,
|
||||
cmd: str,
|
||||
arg: dict | None = None,
|
||||
child_ids: list | None = None,
|
||||
) -> dict:
|
||||
"""Query device, return results or raise an exception.
|
||||
|
||||
:param target: Target system {system, time, emeter, ..}
|
||||
:param cmd: Command to execute
|
||||
:param arg: payload dict to be send to the device
|
||||
:param child_ids: ids of child devices
|
||||
:return: Unwrapped result for the call.
|
||||
"""
|
||||
request = self._create_request(target, cmd, arg, child_ids)
|
||||
|
||||
try:
|
||||
response = await self._raw_query(request=request)
|
||||
except Exception as ex:
|
||||
raise KasaException(f"Communication error on {target}:{cmd}") from ex
|
||||
|
||||
if target not in response:
|
||||
raise KasaException(f"No required {target} in response: {response}")
|
||||
|
||||
result = response[target]
|
||||
if "err_code" in result and result["err_code"] != 0:
|
||||
raise KasaException(f"Error on {target}.{cmd}: {result}")
|
||||
|
||||
if cmd not in result:
|
||||
raise KasaException(f"No command in response: {response}")
|
||||
result = result[cmd]
|
||||
if "err_code" in result and result["err_code"] != 0:
|
||||
raise KasaException(f"Error on {target} {cmd}: {result}")
|
||||
|
||||
if "err_code" in result:
|
||||
del result["err_code"]
|
||||
|
||||
return result
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def features(self) -> dict[str, Feature]:
|
||||
"""Return a set of features that the device supports."""
|
||||
return self._features
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def has_emeter(self) -> bool:
|
||||
"""Return True if device has an energy meter."""
|
||||
return "ENE" in self._legacy_features
|
||||
|
||||
async def get_sys_info(self) -> dict[str, Any]:
|
||||
"""Retrieve system information."""
|
||||
return await self._query_helper("system", "get_sysinfo")
|
||||
|
||||
async def update(self, update_children: bool = True) -> None:
|
||||
"""Query the device to update the data.
|
||||
|
||||
Needed for properties that are decorated with `requires_update`.
|
||||
"""
|
||||
req = {}
|
||||
req.update(self._create_request("system", "get_sysinfo"))
|
||||
|
||||
# If this is the initial update, check only for the sysinfo
|
||||
# This is necessary as some devices crash on unexpected modules
|
||||
# See #105, #120, #161
|
||||
if not self._last_update:
|
||||
_LOGGER.debug("Performing the initial update to obtain sysinfo")
|
||||
response = await self.protocol.query(req)
|
||||
self._last_update = response
|
||||
self._set_sys_info(_extract_sys_info(response))
|
||||
|
||||
if not self._modules:
|
||||
await self._initialize_modules()
|
||||
|
||||
await self._modular_update(req)
|
||||
|
||||
self._set_sys_info(_extract_sys_info(self._last_update))
|
||||
for module in self._modules.values():
|
||||
await module._post_update_hook()
|
||||
|
||||
if not self._features:
|
||||
await self._initialize_features()
|
||||
|
||||
async def _initialize_modules(self) -> None:
|
||||
"""Initialize modules not added in init."""
|
||||
if self.has_emeter:
|
||||
_LOGGER.debug(
|
||||
"The device has emeter, querying its information along sysinfo"
|
||||
)
|
||||
self.add_module(Module.Energy, Emeter(self, self.emeter_type))
|
||||
|
||||
async def _initialize_features(self) -> None:
|
||||
"""Initialize common features."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self,
|
||||
id="state",
|
||||
name="State",
|
||||
attribute_getter="is_on",
|
||||
attribute_setter="set_state",
|
||||
type=Feature.Type.Switch,
|
||||
category=Feature.Category.Primary,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self,
|
||||
id="rssi",
|
||||
name="RSSI",
|
||||
attribute_getter="rssi",
|
||||
icon="mdi:signal",
|
||||
unit_getter=lambda: "dBm",
|
||||
category=Feature.Category.Debug,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
# iot strips calculate on_since from the children
|
||||
if "on_time" in self._sys_info or self.device_type == Device.Type.Strip:
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self,
|
||||
id="on_since",
|
||||
name="On since",
|
||||
attribute_getter="on_since",
|
||||
icon="mdi:clock",
|
||||
category=Feature.Category.Info,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self,
|
||||
id="reboot",
|
||||
name="Reboot",
|
||||
attribute_setter="reboot",
|
||||
icon="mdi:restart",
|
||||
category=Feature.Category.Debug,
|
||||
type=Feature.Type.Action,
|
||||
)
|
||||
)
|
||||
|
||||
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."""
|
||||
request_list = []
|
||||
est_response_size = 1024 if "system" in req else 0
|
||||
for module in self._modules.values():
|
||||
if not module.is_supported:
|
||||
_LOGGER.debug("Module %s not supported, skipping", module)
|
||||
continue
|
||||
|
||||
est_response_size += module.estimated_query_response_size
|
||||
if est_response_size > self.max_device_response_size:
|
||||
request_list.append(req)
|
||||
req = {}
|
||||
est_response_size = module.estimated_query_response_size
|
||||
|
||||
q = module.query()
|
||||
_LOGGER.debug("Adding query for %s: %s", module, q)
|
||||
req = merge(req, q)
|
||||
request_list.append(req)
|
||||
|
||||
responses = [
|
||||
await self.protocol.query(request) for request in request_list if request
|
||||
]
|
||||
|
||||
# Preserve the last update and merge
|
||||
# responses on top of it so we remember
|
||||
# which modules are not supported, otherwise
|
||||
# every other update will query for them
|
||||
update: dict = self._last_update.copy() if self._last_update else {}
|
||||
for response in responses:
|
||||
for k, v in response.items():
|
||||
# The same module could have results in different responses
|
||||
# i.e. smartlife.iot.common.schedule for Usage and
|
||||
# Schedule, so need to call update(**v) here. If a module is
|
||||
# not supported the response
|
||||
# {'err_code': -1, 'err_msg': 'module not support'}
|
||||
# become top level key/values of the response so check for dict
|
||||
if isinstance(v, dict):
|
||||
update.setdefault(k, {}).update(**v)
|
||||
self._last_update = update
|
||||
|
||||
# IOT modules are added as default but could be unsupported post first update
|
||||
if self._supported_modules is None:
|
||||
supported = {}
|
||||
for module_name, module in self._modules.items():
|
||||
if module.is_supported:
|
||||
supported[module_name] = module
|
||||
|
||||
self._supported_modules = supported
|
||||
|
||||
def update_from_discover_info(self, info: dict[str, Any]) -> None:
|
||||
"""Update state from info from the discover call."""
|
||||
self._discovery_info = info
|
||||
if "system" in info and (sys_info := info["system"].get("get_sysinfo")):
|
||||
self._last_update = info
|
||||
self._set_sys_info(sys_info)
|
||||
else:
|
||||
# This allows setting of some info properties directly
|
||||
# from partial discovery info that will then be found
|
||||
# by the requires_update decorator
|
||||
discovery_model = info["device_model"]
|
||||
no_region_model, _, _ = discovery_model.partition("(")
|
||||
self._set_sys_info({**info, "model": no_region_model})
|
||||
|
||||
def _set_sys_info(self, sys_info: dict[str, Any]) -> None:
|
||||
"""Set sys_info."""
|
||||
self._sys_info = sys_info
|
||||
if features := sys_info.get("feature"):
|
||||
self._legacy_features = _parse_features(features)
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def sys_info(self) -> dict[str, Any]:
|
||||
"""
|
||||
Return system information.
|
||||
|
||||
Do not call this function from within the SmartDevice
|
||||
class itself as @requires_update will be affected for other properties.
|
||||
"""
|
||||
return self._sys_info # type: ignore
|
||||
|
||||
@property
|
||||
@requires_update
|
||||
def model(self) -> str:
|
||||
"""Returns the device model."""
|
||||
if self._last_update:
|
||||
return self.device_info.short_name
|
||||
return self._sys_info["model"]
|
||||
|
||||
@property # type: ignore
|
||||
def alias(self) -> str | None:
|
||||
"""Return device name (alias)."""
|
||||
sys_info = self._sys_info
|
||||
return sys_info.get("alias") if sys_info else None
|
||||
|
||||
async def set_alias(self, alias: str) -> dict:
|
||||
"""Set the device name (alias)."""
|
||||
return await self._query_helper("system", "set_dev_alias", {"alias": alias})
|
||||
|
||||
@property
|
||||
@requires_update
|
||||
def time(self) -> datetime:
|
||||
"""Return current time from the device."""
|
||||
return self.modules[Module.Time].time
|
||||
|
||||
@property
|
||||
@requires_update
|
||||
def timezone(self) -> tzinfo:
|
||||
"""Return the current timezone."""
|
||||
return self.modules[Module.Time].timezone
|
||||
|
||||
async def get_time(self) -> datetime:
|
||||
"""Return current time from the device, if available."""
|
||||
msg = "Use `time` property instead, this call will be removed in the future."
|
||||
warn(msg, DeprecationWarning, stacklevel=2)
|
||||
return self.time
|
||||
|
||||
async def get_timezone(self) -> tzinfo:
|
||||
"""Return timezone information."""
|
||||
msg = (
|
||||
"Use `timezone` property instead, this call will be removed in the future."
|
||||
)
|
||||
warn(msg, DeprecationWarning, stacklevel=2)
|
||||
return self.timezone
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def hw_info(self) -> dict:
|
||||
"""Return hardware information.
|
||||
|
||||
This returns just a selection of sysinfo keys that are related to hardware.
|
||||
"""
|
||||
keys = [
|
||||
"sw_ver",
|
||||
"hw_ver",
|
||||
"mac",
|
||||
"mic_mac",
|
||||
"type",
|
||||
"mic_type",
|
||||
"hwId",
|
||||
"fwId",
|
||||
"oemId",
|
||||
"dev_name",
|
||||
]
|
||||
sys_info = self._sys_info
|
||||
return {key: sys_info[key] for key in keys if key in sys_info}
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def location(self) -> dict:
|
||||
"""Return geographical location."""
|
||||
sys_info = self._sys_info
|
||||
loc = {"latitude": None, "longitude": None}
|
||||
|
||||
if "latitude" in sys_info and "longitude" in sys_info:
|
||||
loc["latitude"] = sys_info["latitude"]
|
||||
loc["longitude"] = sys_info["longitude"]
|
||||
elif "latitude_i" in sys_info and "longitude_i" in sys_info:
|
||||
loc["latitude"] = sys_info["latitude_i"] / 10000
|
||||
loc["longitude"] = sys_info["longitude_i"] / 10000
|
||||
else:
|
||||
_LOGGER.debug("Unsupported device location.")
|
||||
|
||||
return loc
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def rssi(self) -> int | None:
|
||||
"""Return WiFi signal strength (rssi)."""
|
||||
rssi = self._sys_info.get("rssi")
|
||||
return None if rssi is None else int(rssi)
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def mac(self) -> str:
|
||||
"""Return mac address.
|
||||
|
||||
:return: mac address in hexadecimal with colons, e.g. 01:23:45:67:89:ab
|
||||
"""
|
||||
sys_info = self._sys_info
|
||||
mac = sys_info.get("mac", sys_info.get("mic_mac"))
|
||||
if not mac:
|
||||
raise KasaException(
|
||||
"Unknown mac, please submit a bug report with sys_info output."
|
||||
)
|
||||
mac = mac.replace("-", ":")
|
||||
# Format a mac that has no colons (usually from mic_mac field)
|
||||
if ":" not in mac:
|
||||
mac = ":".join(format(s, "02x") for s in bytes.fromhex(mac))
|
||||
|
||||
return mac
|
||||
|
||||
async def set_mac(self, mac: str) -> dict:
|
||||
"""Set the mac address.
|
||||
|
||||
:param str mac: mac in hexadecimal with colons, e.g. 01:23:45:67:89:ab
|
||||
"""
|
||||
return await self._query_helper("system", "set_mac_addr", {"mac": mac})
|
||||
|
||||
async def reboot(self, delay: int = 1) -> None:
|
||||
"""Reboot the device.
|
||||
|
||||
Note that giving a delay of zero causes this to block,
|
||||
as the device reboots immediately without responding to the call.
|
||||
"""
|
||||
await self._query_helper("system", "reboot", {"delay": delay})
|
||||
|
||||
async def factory_reset(self) -> None:
|
||||
"""Reset device back to factory settings.
|
||||
|
||||
Note, this does not downgrade the firmware.
|
||||
"""
|
||||
await self._query_helper("system", "reset")
|
||||
|
||||
async def turn_off(self, **kwargs) -> dict:
|
||||
"""Turn off the device."""
|
||||
raise NotImplementedError("Device subclass needs to implement this.")
|
||||
|
||||
async def turn_on(self, **kwargs) -> dict:
|
||||
"""Turn device on."""
|
||||
raise NotImplementedError("Device subclass needs to implement this.")
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if the device is on."""
|
||||
raise NotImplementedError("Device subclass needs to implement this.")
|
||||
|
||||
async def set_state(self, on: bool) -> dict:
|
||||
"""Set the device state."""
|
||||
if on:
|
||||
return await self.turn_on()
|
||||
else:
|
||||
return await self.turn_off()
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def on_since(self) -> datetime | None:
|
||||
"""Return the time that the device was turned on or None if turned off.
|
||||
|
||||
This returns a cached value if the device reported value difference is under
|
||||
five seconds to avoid device-caused jitter.
|
||||
"""
|
||||
if self.is_off or "on_time" not in self._sys_info:
|
||||
self._on_since = None
|
||||
return None
|
||||
|
||||
on_time = self._sys_info["on_time"]
|
||||
|
||||
on_since = self.time - timedelta(seconds=on_time)
|
||||
if not self._on_since or timedelta(
|
||||
seconds=0
|
||||
) < on_since - self._on_since > timedelta(seconds=5):
|
||||
self._on_since = on_since
|
||||
return self._on_since
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def device_id(self) -> str:
|
||||
"""Return unique ID for the device.
|
||||
|
||||
If not overridden, this is the MAC address of the device.
|
||||
Individual sockets on strips will override this.
|
||||
"""
|
||||
return self.mac
|
||||
|
||||
async def wifi_scan(self) -> list[WifiNetwork]: # noqa: D202
|
||||
"""Scan for available wifi networks."""
|
||||
|
||||
async def _scan(target: str) -> dict:
|
||||
return await self._query_helper(target, "get_scaninfo", {"refresh": 1})
|
||||
|
||||
try:
|
||||
info = await _scan("netif")
|
||||
except KasaException as ex:
|
||||
_LOGGER.debug(
|
||||
"Unable to scan using 'netif', retrying with 'softaponboarding': %s", ex
|
||||
)
|
||||
info = await _scan("smartlife.iot.common.softaponboarding")
|
||||
|
||||
if "ap_list" not in info:
|
||||
raise KasaException(f"Invalid response for wifi scan: {info}")
|
||||
|
||||
return [WifiNetwork(**x) for x in info["ap_list"]]
|
||||
|
||||
async def wifi_join(self, ssid: str, password: str, keytype: str = "3") -> dict: # noqa: D202
|
||||
"""Join the given wifi network.
|
||||
|
||||
If joining the network fails, the device will return to AP mode after a while.
|
||||
"""
|
||||
|
||||
async def _join(target: str, payload: dict) -> dict:
|
||||
return await self._query_helper(target, "set_stainfo", payload)
|
||||
|
||||
payload = {"ssid": ssid, "password": password, "key_type": int(keytype)}
|
||||
try:
|
||||
return await _join("netif", payload)
|
||||
except KasaException as ex:
|
||||
_LOGGER.debug(
|
||||
"Unable to join using 'netif', retrying with 'softaponboarding': %s", ex
|
||||
)
|
||||
return await _join("smartlife.iot.common.softaponboarding", payload)
|
||||
|
||||
@property
|
||||
def max_device_response_size(self) -> int:
|
||||
"""Returns the maximum response size the device can safely construct."""
|
||||
return 16 * 1024
|
||||
|
||||
@property
|
||||
def internal_state(self) -> Any:
|
||||
"""Return the internal state of the instance.
|
||||
|
||||
The returned object contains the raw results from the last update call.
|
||||
This should only be used for debugging purposes.
|
||||
"""
|
||||
return self._last_update or self._discovery_info
|
||||
|
||||
@staticmethod
|
||||
def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType:
|
||||
"""Find SmartDevice subclass for device described by passed data."""
|
||||
if "system" in info.get("system", {}).get("get_sysinfo", {}):
|
||||
return DeviceType.Camera
|
||||
|
||||
if "system" not in info or "get_sysinfo" not in info["system"]:
|
||||
raise KasaException("No 'system' or 'get_sysinfo' in response")
|
||||
|
||||
sysinfo: dict[str, Any] = _extract_sys_info(info)
|
||||
type_: str | None = sysinfo.get("type", sysinfo.get("mic_type"))
|
||||
if type_ is None:
|
||||
raise KasaException("Unable to find the device type field!")
|
||||
|
||||
if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]:
|
||||
return DeviceType.Dimmer
|
||||
|
||||
if "smartplug" in type_.lower():
|
||||
if "children" in sysinfo:
|
||||
return DeviceType.Strip
|
||||
if (dev_name := sysinfo.get("dev_name")) and "light" in dev_name.lower():
|
||||
return DeviceType.WallSwitch
|
||||
return DeviceType.Plug
|
||||
|
||||
if "smartbulb" in type_.lower():
|
||||
if "length" in sysinfo: # strips have length
|
||||
return DeviceType.LightStrip
|
||||
|
||||
return DeviceType.Bulb
|
||||
|
||||
_LOGGER.warning("Unknown device type %s, falling back to plug", type_)
|
||||
return DeviceType.Plug
|
||||
|
||||
@staticmethod
|
||||
def _get_device_info(
|
||||
info: dict[str, Any], discovery_info: dict[str, Any] | None
|
||||
) -> DeviceInfo:
|
||||
"""Get model information for a device."""
|
||||
sys_info = _extract_sys_info(info)
|
||||
|
||||
# Get model and region info
|
||||
region = None
|
||||
device_model = sys_info["model"]
|
||||
long_name, _, region = device_model.partition("(")
|
||||
if region: # All iot devices have region but just in case
|
||||
region = region.replace(")", "")
|
||||
|
||||
# Get other info
|
||||
device_family = sys_info.get("type", sys_info.get("mic_type"))
|
||||
device_type = IotDevice._get_device_type_from_sys_info(info)
|
||||
fw_version_full = sys_info["sw_ver"]
|
||||
firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1)
|
||||
auth = bool(discovery_info and ("mgt_encrypt_schm" in discovery_info))
|
||||
|
||||
return DeviceInfo(
|
||||
short_name=long_name,
|
||||
long_name=long_name,
|
||||
brand="kasa",
|
||||
device_family=device_family,
|
||||
device_type=device_type,
|
||||
hardware_version=sys_info["hw_ver"],
|
||||
firmware_version=firmware_version,
|
||||
firmware_build=firmware_build,
|
||||
requires_auth=auth,
|
||||
region=region,
|
||||
)
|
||||
241
kasa/iot/iotdimmer.py
Normal file
241
kasa/iot/iotdimmer.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Module for dimmers (currently only HS220)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..module import Module
|
||||
from ..protocols import BaseProtocol
|
||||
from .iotdevice import KasaException, requires_update
|
||||
from .iotplug import IotPlug
|
||||
from .modules import AmbientLight, Light, Motion
|
||||
|
||||
|
||||
class ButtonAction(Enum):
|
||||
"""Button action."""
|
||||
|
||||
NoAction = "none"
|
||||
Instant = "instant_on_off"
|
||||
Gentle = "gentle_on_off"
|
||||
Preset = "customize_preset"
|
||||
|
||||
|
||||
class ActionType(Enum):
|
||||
"""Button action."""
|
||||
|
||||
DoubleClick = "double_click_action"
|
||||
LongPress = "long_press_action"
|
||||
|
||||
|
||||
class FadeType(Enum):
|
||||
"""Fade on/off setting."""
|
||||
|
||||
FadeOn = "fade_on"
|
||||
FadeOff = "fade_off"
|
||||
|
||||
|
||||
class IotDimmer(IotPlug):
|
||||
r"""Representation of a TP-Link Smart Dimmer.
|
||||
|
||||
Dimmers work similarly to plugs, but provide also support for
|
||||
adjusting the brightness. This class extends :class:`SmartPlug` interface.
|
||||
|
||||
To initialize, you have to await :func:`update()` at least once.
|
||||
This will allow accessing the properties using the exposed properties.
|
||||
|
||||
All changes to the device are done using awaitable methods,
|
||||
which will not change the cached values,
|
||||
but you must await :func:`update()` separately.
|
||||
|
||||
Errors reported by the device are raised as :class:`KasaException`\s,
|
||||
and should be handled by the user of the library.
|
||||
|
||||
Examples:
|
||||
>>> import asyncio
|
||||
>>> dimmer = IotDimmer("192.168.1.105")
|
||||
>>> asyncio.run(dimmer.turn_on())
|
||||
>>> dimmer.brightness
|
||||
25
|
||||
|
||||
>>> asyncio.run(dimmer.set_brightness(50))
|
||||
>>> asyncio.run(dimmer.update())
|
||||
>>> dimmer.brightness
|
||||
50
|
||||
|
||||
Refer to :class:`SmartPlug` for the full API.
|
||||
"""
|
||||
|
||||
DIMMER_SERVICE = "smartlife.iot.dimmer"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
*,
|
||||
config: DeviceConfig | None = None,
|
||||
protocol: BaseProtocol | None = None,
|
||||
) -> None:
|
||||
super().__init__(host=host, config=config, protocol=protocol)
|
||||
self._device_type = DeviceType.Dimmer
|
||||
|
||||
async def _initialize_modules(self) -> None:
|
||||
"""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(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
|
||||
def _brightness(self) -> int:
|
||||
"""Return current brightness on dimmers.
|
||||
|
||||
Will return a range between 0 - 100.
|
||||
"""
|
||||
if not self._is_dimmable:
|
||||
raise KasaException("Device is not dimmable.")
|
||||
|
||||
sys_info = self.sys_info
|
||||
return int(sys_info["brightness"])
|
||||
|
||||
@requires_update
|
||||
async def _set_brightness(
|
||||
self, brightness: int, *, transition: int | None = None
|
||||
) -> dict:
|
||||
"""Set the new dimmer brightness level in percentage.
|
||||
|
||||
:param int transition: transition duration in milliseconds.
|
||||
Using a transition will cause the dimmer to turn on.
|
||||
"""
|
||||
if not self._is_dimmable:
|
||||
raise KasaException("Device is not dimmable.")
|
||||
|
||||
if not isinstance(brightness, int):
|
||||
raise ValueError(
|
||||
"Brightness must be integer, " "not of %s.", type(brightness)
|
||||
)
|
||||
|
||||
if not 0 <= brightness <= 100:
|
||||
raise ValueError(
|
||||
f"Invalid brightness value: {brightness} (valid range: 0-100%)"
|
||||
)
|
||||
|
||||
# Dimmers do not support a brightness of 0, but bulbs do.
|
||||
# Coerce 0 to 1 to maintain the same interface between dimmers and bulbs.
|
||||
if brightness == 0:
|
||||
brightness = 1
|
||||
|
||||
if transition is not None:
|
||||
return await self.set_dimmer_transition(brightness, transition)
|
||||
|
||||
return await self._query_helper(
|
||||
self.DIMMER_SERVICE, "set_brightness", {"brightness": brightness}
|
||||
)
|
||||
|
||||
async def turn_off(self, *, transition: int | None = None, **kwargs) -> dict:
|
||||
"""Turn the bulb off.
|
||||
|
||||
:param int transition: transition duration in milliseconds.
|
||||
"""
|
||||
if transition is not None:
|
||||
return await self.set_dimmer_transition(brightness=0, transition=transition)
|
||||
|
||||
return await super().turn_off()
|
||||
|
||||
@requires_update
|
||||
async def turn_on(self, *, transition: int | None = None, **kwargs) -> dict:
|
||||
"""Turn the bulb on.
|
||||
|
||||
:param int transition: transition duration in milliseconds.
|
||||
"""
|
||||
if transition is not None:
|
||||
return await self.set_dimmer_transition(
|
||||
brightness=self._brightness, transition=transition
|
||||
)
|
||||
|
||||
return await super().turn_on()
|
||||
|
||||
async def set_dimmer_transition(self, brightness: int, transition: int) -> dict:
|
||||
"""Turn the bulb on to brightness percentage over transition milliseconds.
|
||||
|
||||
A brightness value of 0 will turn off the dimmer.
|
||||
"""
|
||||
if not isinstance(brightness, int):
|
||||
raise TypeError(f"Brightness must be an integer, not {type(brightness)}.")
|
||||
|
||||
if not 0 <= brightness <= 100:
|
||||
raise ValueError(
|
||||
f"Invalid brightness value: {brightness} (valid range: 0-100%)"
|
||||
)
|
||||
|
||||
# If zero set to 1 millisecond
|
||||
if transition == 0:
|
||||
transition = 1
|
||||
if not isinstance(transition, int):
|
||||
raise TypeError(f"Transition must be integer, not of {type(transition)}.")
|
||||
if transition <= 0:
|
||||
raise ValueError(f"Transition value {transition} is not valid.")
|
||||
|
||||
return await self._query_helper(
|
||||
self.DIMMER_SERVICE,
|
||||
"set_dimmer_transition",
|
||||
{"brightness": brightness, "duration": transition},
|
||||
)
|
||||
|
||||
@requires_update
|
||||
async def get_behaviors(self) -> dict:
|
||||
"""Return button behavior settings."""
|
||||
behaviors = await self._query_helper(
|
||||
self.DIMMER_SERVICE, "get_default_behavior", {}
|
||||
)
|
||||
return behaviors
|
||||
|
||||
@requires_update
|
||||
async def set_button_action(
|
||||
self, action_type: ActionType, action: ButtonAction, index: int | None = None
|
||||
) -> dict:
|
||||
"""Set action to perform on button click/hold.
|
||||
|
||||
:param action_type ActionType: whether to control double click or hold action.
|
||||
:param action ButtonAction: what should the button do
|
||||
(nothing, instant, gentle, change preset)
|
||||
:param index int: in case of preset change, the preset to select
|
||||
"""
|
||||
action_type_setter = f"set_{action_type}"
|
||||
|
||||
payload: dict[str, Any] = {"mode": str(action)}
|
||||
if index is not None:
|
||||
payload["index"] = index
|
||||
|
||||
return await self._query_helper(
|
||||
self.DIMMER_SERVICE, action_type_setter, payload
|
||||
)
|
||||
|
||||
@requires_update
|
||||
async def set_fade_time(self, fade_type: FadeType, time: int) -> dict:
|
||||
"""Set time for fade in / fade out."""
|
||||
fade_type_setter = f"set_{fade_type}_time"
|
||||
payload = {"fadeTime": time}
|
||||
|
||||
return await self._query_helper(self.DIMMER_SERVICE, fade_type_setter, payload)
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def _is_dimmable(self) -> bool:
|
||||
"""Whether the switch supports brightness changes."""
|
||||
sys_info = self.sys_info
|
||||
return "brightness" in sys_info
|
||||
|
||||
@property
|
||||
def _is_variable_color_temp(self) -> bool:
|
||||
"""Whether the device supports variable color temp."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def _is_color(self) -> bool:
|
||||
"""Whether the device supports color."""
|
||||
return False
|
||||
72
kasa/iot/iotlightstrip.py
Normal file
72
kasa/iot/iotlightstrip.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Module for light strips (KL430)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..module import Module
|
||||
from ..protocols import BaseProtocol
|
||||
from .iotbulb import IotBulb
|
||||
from .iotdevice import requires_update
|
||||
from .modules.lighteffect import LightEffect
|
||||
|
||||
|
||||
class IotLightStrip(IotBulb):
|
||||
"""Representation of a TP-Link Smart light strip.
|
||||
|
||||
Light strips work similarly to bulbs, but use a different service for controlling,
|
||||
and expose some extra information (such as length and active effect).
|
||||
This class extends :class:`SmartBulb` interface.
|
||||
|
||||
Examples:
|
||||
>>> import asyncio
|
||||
>>> strip = IotLightStrip("127.0.0.1")
|
||||
>>> asyncio.run(strip.update())
|
||||
>>> print(strip.alias)
|
||||
Bedroom Lightstrip
|
||||
|
||||
Getting the length of the strip:
|
||||
|
||||
>>> strip.length
|
||||
16
|
||||
|
||||
Currently active effect:
|
||||
|
||||
>>> strip.effect
|
||||
{'brightness': 100, 'custom': 0, 'enable': 0,
|
||||
'id': 'bCTItKETDFfrKANolgldxfgOakaarARs', 'name': 'Flicker'}
|
||||
|
||||
.. note::
|
||||
The device supports some features that are not currently implemented,
|
||||
feel free to find out how to control them and create a PR!
|
||||
|
||||
|
||||
See :class:`SmartBulb` for more examples.
|
||||
"""
|
||||
|
||||
LIGHT_SERVICE = "smartlife.iot.lightStrip"
|
||||
SET_LIGHT_METHOD = "set_light_state"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
*,
|
||||
config: DeviceConfig | None = None,
|
||||
protocol: BaseProtocol | None = None,
|
||||
) -> None:
|
||||
super().__init__(host=host, config=config, protocol=protocol)
|
||||
self._device_type = DeviceType.LightStrip
|
||||
|
||||
async def _initialize_modules(self) -> None:
|
||||
"""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:
|
||||
"""Return length of the strip."""
|
||||
return self.sys_info["length"]
|
||||
76
kasa/iot/iotmodule.py
Normal file
76
kasa/iot/iotmodule.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Base class for IOT module implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..exceptions import KasaException
|
||||
from ..module import Module
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .iotdevice import IotDevice
|
||||
|
||||
|
||||
def _merge_dict(dest: dict, source: dict) -> dict:
|
||||
"""Update dict recursively."""
|
||||
for k, v in source.items():
|
||||
if k in dest and type(v) is dict: # noqa: E721 - only accepts `dict` type
|
||||
_merge_dict(dest[k], v)
|
||||
else:
|
||||
dest[k] = v
|
||||
return dest
|
||||
|
||||
|
||||
merge = _merge_dict
|
||||
|
||||
|
||||
class IotModule(Module):
|
||||
"""Base class implemention for all IOT modules."""
|
||||
|
||||
_device: IotDevice
|
||||
|
||||
async def call(self, method: str, params: dict | None = None) -> dict:
|
||||
"""Call the given method with the given parameters."""
|
||||
return await self._device._query_helper(self._module, method, params)
|
||||
|
||||
def query_for_command(self, query: str, params: dict | None = None) -> dict:
|
||||
"""Create a request object for the given parameters."""
|
||||
return self._device._create_request(self._module, query, params)
|
||||
|
||||
@property
|
||||
def estimated_query_response_size(self) -> int:
|
||||
"""Estimated maximum size of query response.
|
||||
|
||||
The inheriting modules implement this to estimate how large a query response
|
||||
will be so that queries can be split should an estimated response be too large
|
||||
"""
|
||||
return 256 # Estimate for modules that don't specify
|
||||
|
||||
@property
|
||||
def data(self) -> dict[str, Any]:
|
||||
"""Return the module specific raw data from the 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 dev._last_update[self._module]
|
||||
|
||||
@property
|
||||
def is_supported(self) -> bool:
|
||||
"""Return whether the module is supported by the device."""
|
||||
if self._module not in self._device._last_update:
|
||||
_LOGGER.debug("Initial update, so consider supported: %s", self._module)
|
||||
return True
|
||||
|
||||
return "err_code" not in self.data
|
||||
104
kasa/iot/iotplug.py
Normal file
104
kasa/iot/iotplug.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Module for smart plugs (HS100, HS110, ..)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..module import Module
|
||||
from ..protocols import BaseProtocol
|
||||
from .iotdevice import IotDevice, requires_update
|
||||
from .modules import AmbientLight, Antitheft, Cloud, Led, Motion, Schedule, Time, Usage
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IotPlug(IotDevice):
|
||||
r"""Representation of a TP-Link Smart Plug.
|
||||
|
||||
To initialize, you have to await :func:`update()` at least once.
|
||||
This will allow accessing the properties using the exposed properties.
|
||||
|
||||
All changes to the device are done using awaitable methods,
|
||||
which will not change the cached values,
|
||||
but you must await :func:`update()` separately.
|
||||
|
||||
Errors reported by the device are raised as :class:`KasaException`\s,
|
||||
and should be handled by the user of the library.
|
||||
|
||||
Examples:
|
||||
>>> import asyncio
|
||||
>>> plug = IotPlug("127.0.0.1")
|
||||
>>> asyncio.run(plug.update())
|
||||
>>> plug.alias
|
||||
Bedroom Lamp Plug
|
||||
|
||||
Setting the LED state:
|
||||
|
||||
>>> asyncio.run(plug.set_led(True))
|
||||
>>> asyncio.run(plug.update())
|
||||
>>> plug.led
|
||||
True
|
||||
|
||||
For more examples, see the :class:`Device` class.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
*,
|
||||
config: DeviceConfig | None = None,
|
||||
protocol: BaseProtocol | None = None,
|
||||
) -> None:
|
||||
super().__init__(host=host, config=config, protocol=protocol)
|
||||
self._device_type = DeviceType.Plug
|
||||
|
||||
async def _initialize_modules(self) -> None:
|
||||
"""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.Time, Time(self, "time"))
|
||||
self.add_module(Module.IotCloud, Cloud(self, "cnCloud"))
|
||||
self.add_module(Module.Led, Led(self, "system"))
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def is_on(self) -> bool:
|
||||
"""Return whether device is on."""
|
||||
sys_info = self.sys_info
|
||||
return bool(sys_info["relay_state"])
|
||||
|
||||
async def turn_on(self, **kwargs: Any) -> dict:
|
||||
"""Turn the switch on."""
|
||||
return await self._query_helper("system", "set_relay_state", {"state": 1})
|
||||
|
||||
async def turn_off(self, **kwargs: Any) -> dict:
|
||||
"""Turn the switch off."""
|
||||
return await self._query_helper("system", "set_relay_state", {"state": 0})
|
||||
|
||||
|
||||
class IotWallSwitch(IotPlug):
|
||||
"""Representation of a TP-Link Smart Wall Switch."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
*,
|
||||
config: DeviceConfig | None = None,
|
||||
protocol: BaseProtocol | None = None,
|
||||
) -> None:
|
||||
super().__init__(host=host, config=config, protocol=protocol)
|
||||
self._device_type = DeviceType.WallSwitch
|
||||
|
||||
async def _initialize_modules(self) -> None:
|
||||
"""Initialize modules."""
|
||||
await super()._initialize_modules()
|
||||
if (dev_name := self.sys_info["dev_name"]) and "PIR" in dev_name:
|
||||
self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR"))
|
||||
self.add_module(
|
||||
Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS")
|
||||
)
|
||||
489
kasa/iot/iotstrip.py
Executable file
489
kasa/iot/iotstrip.py
Executable file
@@ -0,0 +1,489 @@
|
||||
"""Module for multi-socket devices (HS300, HS107, KP303, ..)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..emeterstatus import EmeterStatus
|
||||
from ..exceptions import KasaException
|
||||
from ..feature import Feature
|
||||
from ..interfaces import Energy
|
||||
from ..module import Module
|
||||
from ..protocols import BaseProtocol
|
||||
from .iotdevice import (
|
||||
IotDevice,
|
||||
requires_update,
|
||||
)
|
||||
from .iotmodule import IotModule
|
||||
from .iotplug import IotPlug
|
||||
from .modules import Antitheft, Cloud, Countdown, Emeter, Led, Schedule, Time, Usage
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def merge_sums(dicts: list[dict]) -> dict:
|
||||
"""Merge the sum of dicts."""
|
||||
total_dict: defaultdict[int, float] = defaultdict(lambda: 0.0)
|
||||
for sum_dict in dicts:
|
||||
for day, value in sum_dict.items():
|
||||
total_dict[day] += value
|
||||
return total_dict
|
||||
|
||||
|
||||
class IotStrip(IotDevice):
|
||||
r"""Representation of a TP-Link Smart Power Strip.
|
||||
|
||||
A strip consists of the parent device and its children.
|
||||
All methods of the parent act on all children, while the child devices
|
||||
share the common API with the :class:`SmartPlug` class.
|
||||
|
||||
To initialize, you have to await :func:`update()` at least once.
|
||||
This will allow accessing the properties using the exposed properties.
|
||||
|
||||
All changes to the device are done using awaitable methods,
|
||||
which will not change the cached values,
|
||||
but you must await :func:`update()` separately.
|
||||
|
||||
Errors reported by the device are raised as :class:`KasaException`\s,
|
||||
and should be handled by the user of the library.
|
||||
|
||||
Examples:
|
||||
>>> import asyncio
|
||||
>>> strip = IotStrip("127.0.0.1")
|
||||
>>> asyncio.run(strip.update())
|
||||
>>> strip.alias
|
||||
Bedroom Power Strip
|
||||
|
||||
All methods act on the whole strip:
|
||||
|
||||
>>> for plug in strip.children:
|
||||
>>> print(f"{plug.alias}: {plug.is_on}")
|
||||
Plug 1: True
|
||||
Plug 2: False
|
||||
Plug 3: False
|
||||
>>> strip.is_on
|
||||
True
|
||||
>>> asyncio.run(strip.turn_off())
|
||||
>>> asyncio.run(strip.update())
|
||||
|
||||
Accessing individual plugs can be done using the `children` property:
|
||||
|
||||
>>> len(strip.children)
|
||||
3
|
||||
>>> for plug in strip.children:
|
||||
>>> print(f"{plug.alias}: {plug.is_on}")
|
||||
Plug 1: False
|
||||
Plug 2: False
|
||||
Plug 3: False
|
||||
>>> asyncio.run(strip.children[1].turn_on())
|
||||
>>> asyncio.run(strip.update())
|
||||
>>> strip.is_on
|
||||
True
|
||||
|
||||
For more examples, see the :class:`Device` class.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
*,
|
||||
config: DeviceConfig | None = None,
|
||||
protocol: BaseProtocol | None = None,
|
||||
) -> None:
|
||||
super().__init__(host=host, config=config, protocol=protocol)
|
||||
self.emeter_type = "emeter"
|
||||
self._device_type = DeviceType.Strip
|
||||
|
||||
async def _initialize_modules(self) -> None:
|
||||
"""Initialize modules."""
|
||||
# Strip has different modules to plug so do not call super
|
||||
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.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"))
|
||||
if self.has_emeter:
|
||||
_LOGGER.debug(
|
||||
"The device has emeter, querying its information along sysinfo"
|
||||
)
|
||||
self.add_module(Module.Energy, StripEmeter(self, self.emeter_type))
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def is_on(self) -> bool:
|
||||
"""Return if any of the outlets are on."""
|
||||
return any(plug.is_on for plug in self.children)
|
||||
|
||||
async def update(self, update_children: bool = True) -> None:
|
||||
"""Update some of the attributes.
|
||||
|
||||
Needed for methods that are decorated with `requires_update`.
|
||||
"""
|
||||
# Super initializes modules and features
|
||||
await super().update(update_children)
|
||||
|
||||
initialize_children = not self.children
|
||||
# Initialize the child devices during the first update.
|
||||
if initialize_children:
|
||||
children = self.sys_info["children"]
|
||||
_LOGGER.debug("Initializing %s child sockets", len(children))
|
||||
self._children = {
|
||||
f"{self.mac}_{child['id']}": IotStripPlug(
|
||||
self.host, parent=self, child_id=child["id"]
|
||||
)
|
||||
for child in children
|
||||
}
|
||||
for child in self._children.values():
|
||||
await child._initialize_modules()
|
||||
|
||||
if update_children:
|
||||
for plug in self.children:
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(plug, IotStripPlug)
|
||||
await plug._update()
|
||||
|
||||
if not self.features:
|
||||
await self._initialize_features()
|
||||
|
||||
async def _initialize_features(self) -> None:
|
||||
"""Initialize common features."""
|
||||
# Do not initialize features until children are created
|
||||
if not self.children:
|
||||
return
|
||||
await super()._initialize_features()
|
||||
|
||||
async def turn_on(self, **kwargs) -> dict:
|
||||
"""Turn the strip on."""
|
||||
for plug in self.children:
|
||||
await plug.turn_on()
|
||||
return {}
|
||||
|
||||
async def turn_off(self, **kwargs) -> dict:
|
||||
"""Turn the strip off."""
|
||||
for plug in self.children:
|
||||
await plug.turn_off()
|
||||
return {}
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def on_since(self) -> datetime | None:
|
||||
"""Return the maximum on-time of all outlets."""
|
||||
if self.is_off:
|
||||
return None
|
||||
|
||||
return min(plug.on_since for plug in self.children if plug.on_since is not None)
|
||||
|
||||
|
||||
class StripEmeter(IotModule, Energy):
|
||||
"""Energy module implementation to aggregate child modules."""
|
||||
|
||||
_supported = (
|
||||
Energy.ModuleFeature.CONSUMPTION_TOTAL
|
||||
| Energy.ModuleFeature.PERIODIC_STATS
|
||||
| Energy.ModuleFeature.VOLTAGE_CURRENT
|
||||
)
|
||||
|
||||
def supports(self, module_feature: Energy.ModuleFeature) -> bool:
|
||||
"""Return True if module supports the feature."""
|
||||
return module_feature in self._supported
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Return the base query."""
|
||||
return {}
|
||||
|
||||
@property
|
||||
def current_consumption(self) -> float | None:
|
||||
"""Get the current power consumption in watts."""
|
||||
return sum(
|
||||
v if (v := plug.modules[Module.Energy].current_consumption) else 0.0
|
||||
for plug in self._device.children
|
||||
)
|
||||
|
||||
async def get_status(self) -> EmeterStatus:
|
||||
"""Retrieve current energy readings."""
|
||||
emeter_rt = await self._async_get_emeter_sum("get_status", {})
|
||||
# Voltage is averaged since each read will result
|
||||
# in a slightly different voltage since they are not atomic
|
||||
emeter_rt["voltage_mv"] = int(
|
||||
emeter_rt["voltage_mv"] / len(self._device.children)
|
||||
)
|
||||
return EmeterStatus(emeter_rt)
|
||||
|
||||
async def get_daily_stats(
|
||||
self, year: int | None = None, month: int | None = None, kwh: bool = True
|
||||
) -> dict:
|
||||
"""Retrieve daily statistics for a given month.
|
||||
|
||||
:param year: year for which to retrieve statistics (default: this year)
|
||||
:param month: month for which to retrieve statistics (default: this
|
||||
month)
|
||||
:param kwh: return usage in kWh (default: True)
|
||||
:return: mapping of day of month to value
|
||||
"""
|
||||
return await self._async_get_emeter_sum(
|
||||
"get_daily_stats", {"year": year, "month": month, "kwh": kwh}
|
||||
)
|
||||
|
||||
async def get_monthly_stats(
|
||||
self, year: int | None = None, kwh: bool = True
|
||||
) -> dict:
|
||||
"""Retrieve monthly statistics for a given year.
|
||||
|
||||
:param year: year for which to retrieve statistics (default: this year)
|
||||
:param kwh: return usage in kWh (default: True)
|
||||
"""
|
||||
return await self._async_get_emeter_sum(
|
||||
"get_monthly_stats", {"year": year, "kwh": kwh}
|
||||
)
|
||||
|
||||
async def _async_get_emeter_sum(self, func: str, kwargs: dict[str, Any]) -> dict:
|
||||
"""Retrieve emeter stats for a time period from children."""
|
||||
return merge_sums(
|
||||
[
|
||||
await getattr(plug.modules[Module.Energy], func)(**kwargs)
|
||||
for plug in self._device.children
|
||||
]
|
||||
)
|
||||
|
||||
async def erase_stats(self) -> dict:
|
||||
"""Erase energy meter statistics for all plugs."""
|
||||
for plug in self._device.children:
|
||||
await plug.modules[Module.Energy].erase_stats()
|
||||
|
||||
return {}
|
||||
|
||||
@property # type: ignore
|
||||
def consumption_this_month(self) -> float | None:
|
||||
"""Return this month's energy consumption in kWh."""
|
||||
return sum(
|
||||
v if (v := plug.modules[Module.Energy].consumption_this_month) else 0.0
|
||||
for plug in self._device.children
|
||||
)
|
||||
|
||||
@property # type: ignore
|
||||
def consumption_today(self) -> float | None:
|
||||
"""Return this month's energy consumption in kWh."""
|
||||
return sum(
|
||||
v if (v := plug.modules[Module.Energy].consumption_today) else 0.0
|
||||
for plug in self._device.children
|
||||
)
|
||||
|
||||
@property # type: ignore
|
||||
def consumption_total(self) -> float | None:
|
||||
"""Return total energy consumption since reboot in kWh."""
|
||||
return sum(
|
||||
v if (v := plug.modules[Module.Energy].consumption_total) else 0.0
|
||||
for plug in self._device.children
|
||||
)
|
||||
|
||||
@property # type: ignore
|
||||
def status(self) -> EmeterStatus:
|
||||
"""Return current energy readings."""
|
||||
emeter = merge_sums(
|
||||
[plug.modules[Module.Energy].status for plug in self._device.children]
|
||||
)
|
||||
# Voltage is averaged since each read will result
|
||||
# in a slightly different voltage since they are not atomic
|
||||
emeter["voltage_mv"] = int(emeter["voltage_mv"] / len(self._device.children))
|
||||
return EmeterStatus(emeter)
|
||||
|
||||
@property
|
||||
def current(self) -> float | None:
|
||||
"""Return the current in A."""
|
||||
return self.status.current
|
||||
|
||||
@property
|
||||
def voltage(self) -> float | None:
|
||||
"""Get the current voltage in V."""
|
||||
return self.status.voltage
|
||||
|
||||
|
||||
class IotStripPlug(IotPlug):
|
||||
"""Representation of a single socket in a power strip.
|
||||
|
||||
This allows you to use the sockets as they were SmartPlug objects.
|
||||
Instead of calling an update on any of these, you should call an update
|
||||
on the parent device before accessing the properties.
|
||||
|
||||
The plug inherits (most of) the system information from the parent.
|
||||
"""
|
||||
|
||||
_parent: IotStrip
|
||||
|
||||
def __init__(self, host: str, parent: IotStrip, child_id: str) -> None:
|
||||
super().__init__(host)
|
||||
|
||||
self._parent = parent
|
||||
self.child_id = child_id
|
||||
self._last_update = parent._last_update
|
||||
self._set_sys_info(parent.sys_info)
|
||||
self._device_type = DeviceType.StripSocket
|
||||
self.protocol = parent.protocol # Must use the same connection as the parent
|
||||
self._on_since: datetime | None = None
|
||||
|
||||
async def _initialize_modules(self) -> None:
|
||||
"""Initialize modules not added in init."""
|
||||
if self.has_emeter:
|
||||
self.add_module(Module.Energy, Emeter(self, self.emeter_type))
|
||||
self.add_module(Module.IotUsage, Usage(self, "schedule"))
|
||||
self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft"))
|
||||
self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
|
||||
self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
|
||||
|
||||
async def _initialize_features(self) -> None:
|
||||
"""Initialize common features."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self,
|
||||
id="state",
|
||||
name="State",
|
||||
attribute_getter="is_on",
|
||||
attribute_setter="set_state",
|
||||
type=Feature.Type.Switch,
|
||||
category=Feature.Category.Primary,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self,
|
||||
id="on_since",
|
||||
name="On since",
|
||||
attribute_getter="on_since",
|
||||
icon="mdi:clock",
|
||||
category=Feature.Category.Info,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
|
||||
for module in self.modules.values():
|
||||
module._initialize_features()
|
||||
for module_feat in module._module_features.values():
|
||||
self._add_feature(module_feat)
|
||||
|
||||
async def update(self, update_children: bool = True) -> None:
|
||||
"""Query the device to update the data.
|
||||
|
||||
Needed for properties that are decorated with `requires_update`.
|
||||
"""
|
||||
await self._update(update_children)
|
||||
|
||||
async def _update(self, update_children: bool = True) -> None:
|
||||
"""Query the device to update the data.
|
||||
|
||||
Internal implementation to allow patching of public update in the cli
|
||||
or test framework.
|
||||
"""
|
||||
await self._modular_update({})
|
||||
for module in self._modules.values():
|
||||
await module._post_update_hook()
|
||||
|
||||
if not self._features:
|
||||
await self._initialize_features()
|
||||
|
||||
def _create_request(
|
||||
self,
|
||||
target: str,
|
||||
cmd: str,
|
||||
arg: dict | None = None,
|
||||
child_ids: list | None = None,
|
||||
) -> dict:
|
||||
request: dict[str, Any] = {
|
||||
"context": {"child_ids": [self.child_id]},
|
||||
target: {cmd: arg},
|
||||
}
|
||||
return request
|
||||
|
||||
async def _query_helper(
|
||||
self,
|
||||
target: str,
|
||||
cmd: str,
|
||||
arg: dict | None = None,
|
||||
child_ids: list | None = None,
|
||||
) -> dict:
|
||||
"""Override query helper to include the child_ids."""
|
||||
return await self._parent._query_helper(
|
||||
target, cmd, arg, child_ids=[self.child_id]
|
||||
)
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def is_on(self) -> bool:
|
||||
"""Return whether device is on."""
|
||||
info = self._get_child_info()
|
||||
return bool(info["state"])
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def led(self) -> bool:
|
||||
"""Return the state of the led.
|
||||
|
||||
This is always false for subdevices.
|
||||
"""
|
||||
return False
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def device_id(self) -> str:
|
||||
"""Return unique ID for the socket.
|
||||
|
||||
This is a combination of MAC and child's ID.
|
||||
"""
|
||||
return f"{self.mac}_{self.child_id}"
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def alias(self) -> str:
|
||||
"""Return device name (alias)."""
|
||||
info = self._get_child_info()
|
||||
return info["alias"]
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def next_action(self) -> dict:
|
||||
"""Return next scheduled(?) action."""
|
||||
info = self._get_child_info()
|
||||
return info["next_action"]
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def on_since(self) -> datetime | None:
|
||||
"""Return on-time, if available."""
|
||||
if self.is_off:
|
||||
self._on_since = None
|
||||
return None
|
||||
|
||||
info = self._get_child_info()
|
||||
on_time = info["on_time"]
|
||||
|
||||
time = self._parent.time
|
||||
|
||||
on_since = time - timedelta(seconds=on_time)
|
||||
if not self._on_since or timedelta(
|
||||
seconds=0
|
||||
) < on_since - self._on_since > timedelta(seconds=5):
|
||||
self._on_since = on_since
|
||||
return self._on_since
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def model(self) -> str:
|
||||
"""Return device model for a child socket."""
|
||||
sys_info = self._parent.sys_info
|
||||
return f"Socket for {sys_info['model']}"
|
||||
|
||||
def _get_child_info(self) -> dict:
|
||||
"""Return the subdevice information for this device."""
|
||||
for plug in self._parent.sys_info["children"]:
|
||||
if plug["id"] == self.child_id:
|
||||
return plug
|
||||
|
||||
raise KasaException(
|
||||
f"Unable to find children {self.child_id}"
|
||||
) # pragma: no cover
|
||||
185
kasa/iot/iottimezone.py
Normal file
185
kasa/iot/iottimezone.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Module for io device timezone lookups."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, tzinfo
|
||||
from typing import cast
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from ..cachedzoneinfo import CachedZoneInfo
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_timezone(index: int) -> tzinfo:
|
||||
"""Get the timezone from the index."""
|
||||
if index > 109:
|
||||
_LOGGER.error(
|
||||
"Unexpected index %s not configured as a timezone, defaulting to UTC", index
|
||||
)
|
||||
return await CachedZoneInfo.get_cached_zone_info("Etc/UTC")
|
||||
|
||||
name = TIMEZONE_INDEX[index]
|
||||
return await CachedZoneInfo.get_cached_zone_info(name)
|
||||
|
||||
|
||||
async def get_timezone_index(tzone: tzinfo) -> int:
|
||||
"""Return the iot firmware index for a valid IANA timezone key."""
|
||||
if isinstance(tzone, ZoneInfo):
|
||||
name = tzone.key
|
||||
rev = {val: key for key, val in TIMEZONE_INDEX.items()}
|
||||
if name in rev:
|
||||
return rev[name]
|
||||
|
||||
for i in range(110):
|
||||
if _is_same_timezone(tzone, await get_timezone(i)):
|
||||
return i
|
||||
raise ValueError("Device does not support timezone %s", name)
|
||||
|
||||
|
||||
async def get_matching_timezones(tzone: tzinfo) -> list[str]:
|
||||
"""Return the iot firmware index for a valid IANA timezone key."""
|
||||
matches = []
|
||||
if isinstance(tzone, ZoneInfo):
|
||||
name = tzone.key
|
||||
vals = {val for val in TIMEZONE_INDEX.values()}
|
||||
if name in vals:
|
||||
matches.append(name)
|
||||
|
||||
for i in range(110):
|
||||
fw_tz = await get_timezone(i)
|
||||
if _is_same_timezone(tzone, fw_tz):
|
||||
match_key = cast(ZoneInfo, fw_tz).key
|
||||
if match_key not in matches:
|
||||
matches.append(match_key)
|
||||
return matches
|
||||
|
||||
|
||||
def _is_same_timezone(tzone1: tzinfo, tzone2: tzinfo) -> bool:
|
||||
"""Return true if the timezones have the same utcffset and dst offset.
|
||||
|
||||
Iot devices only support a limited static list of IANA timezones; this is used to
|
||||
check if a static timezone matches the same utc offset and dst settings.
|
||||
"""
|
||||
now = datetime.now()
|
||||
start_day = datetime(now.year, 1, 1, 12)
|
||||
for i in range(365):
|
||||
the_day = start_day + timedelta(days=i)
|
||||
if tzone1.utcoffset(the_day) != tzone2.utcoffset(the_day):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
TIMEZONE_INDEX = {
|
||||
0: "Etc/GMT+12",
|
||||
1: "Pacific/Samoa",
|
||||
2: "US/Hawaii",
|
||||
3: "US/Alaska",
|
||||
4: "Mexico/BajaNorte",
|
||||
5: "Etc/GMT+8",
|
||||
6: "PST8PDT",
|
||||
7: "US/Arizona",
|
||||
8: "America/Mazatlan",
|
||||
9: "MST",
|
||||
10: "MST7MDT",
|
||||
11: "Mexico/General",
|
||||
12: "Etc/GMT+6",
|
||||
13: "CST6CDT",
|
||||
14: "America/Monterrey",
|
||||
15: "Canada/Saskatchewan",
|
||||
16: "America/Bogota",
|
||||
17: "Etc/GMT+5",
|
||||
18: "EST",
|
||||
19: "America/Indiana/Indianapolis",
|
||||
20: "America/Caracas",
|
||||
21: "America/Asuncion",
|
||||
22: "Etc/GMT+4",
|
||||
23: "Canada/Atlantic",
|
||||
24: "America/Cuiaba",
|
||||
25: "Brazil/West",
|
||||
26: "America/Santiago",
|
||||
27: "Canada/Newfoundland",
|
||||
28: "America/Sao_Paulo",
|
||||
29: "America/Argentina/Buenos_Aires",
|
||||
30: "America/Cayenne",
|
||||
31: "America/Miquelon",
|
||||
32: "America/Montevideo",
|
||||
33: "Chile/Continental",
|
||||
34: "Etc/GMT+2",
|
||||
35: "Atlantic/Azores",
|
||||
36: "Atlantic/Cape_Verde",
|
||||
37: "Africa/Casablanca",
|
||||
38: "UCT",
|
||||
39: "GB",
|
||||
40: "Africa/Monrovia",
|
||||
41: "Europe/Amsterdam",
|
||||
42: "Europe/Belgrade",
|
||||
43: "Europe/Brussels",
|
||||
44: "Europe/Sarajevo",
|
||||
45: "Africa/Lagos",
|
||||
46: "Africa/Windhoek",
|
||||
47: "Asia/Amman",
|
||||
48: "Europe/Athens",
|
||||
49: "Asia/Beirut",
|
||||
50: "Africa/Cairo",
|
||||
51: "Asia/Damascus",
|
||||
52: "EET",
|
||||
53: "Africa/Harare",
|
||||
54: "Europe/Helsinki",
|
||||
55: "Asia/Istanbul",
|
||||
56: "Asia/Jerusalem",
|
||||
57: "Europe/Kaliningrad",
|
||||
58: "Africa/Tripoli",
|
||||
59: "Asia/Baghdad",
|
||||
60: "Asia/Kuwait",
|
||||
61: "Europe/Minsk",
|
||||
62: "Europe/Moscow",
|
||||
63: "Africa/Nairobi",
|
||||
64: "Asia/Tehran",
|
||||
65: "Asia/Muscat",
|
||||
66: "Asia/Baku",
|
||||
67: "Europe/Samara",
|
||||
68: "Indian/Mauritius",
|
||||
69: "Asia/Tbilisi",
|
||||
70: "Asia/Yerevan",
|
||||
71: "Asia/Kabul",
|
||||
72: "Asia/Ashgabat",
|
||||
73: "Asia/Yekaterinburg",
|
||||
74: "Asia/Karachi",
|
||||
75: "Asia/Kolkata",
|
||||
76: "Asia/Colombo",
|
||||
77: "Asia/Kathmandu",
|
||||
78: "Asia/Almaty",
|
||||
79: "Asia/Dhaka",
|
||||
80: "Asia/Novosibirsk",
|
||||
81: "Asia/Rangoon",
|
||||
82: "Asia/Bangkok",
|
||||
83: "Asia/Krasnoyarsk",
|
||||
84: "Asia/Chongqing",
|
||||
85: "Asia/Irkutsk",
|
||||
86: "Asia/Singapore",
|
||||
87: "Australia/Perth",
|
||||
88: "Asia/Taipei",
|
||||
89: "Asia/Ulaanbaatar",
|
||||
90: "Asia/Tokyo",
|
||||
91: "Asia/Seoul",
|
||||
92: "Asia/Yakutsk",
|
||||
93: "Australia/Adelaide",
|
||||
94: "Australia/Darwin",
|
||||
95: "Australia/Brisbane",
|
||||
96: "Australia/Canberra",
|
||||
97: "Pacific/Guam",
|
||||
98: "Australia/Hobart",
|
||||
99: "Antarctica/DumontDUrville",
|
||||
100: "Asia/Magadan",
|
||||
101: "Asia/Srednekolymsk",
|
||||
102: "Etc/GMT-11",
|
||||
103: "Asia/Anadyr",
|
||||
104: "Pacific/Auckland",
|
||||
105: "Etc/GMT-12",
|
||||
106: "Pacific/Fiji",
|
||||
107: "Etc/GMT-13",
|
||||
108: "Pacific/Apia",
|
||||
109: "Etc/GMT-14",
|
||||
}
|
||||
35
kasa/iot/modules/__init__.py
Normal file
35
kasa/iot/modules/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Module for individual feature modules."""
|
||||
|
||||
from .ambientlight import AmbientLight
|
||||
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 .lightpreset import IotLightPreset, LightPreset
|
||||
from .motion import Motion
|
||||
from .rulemodule import Rule, RuleModule
|
||||
from .schedule import Schedule
|
||||
from .time import Time
|
||||
from .usage import Usage
|
||||
|
||||
__all__ = [
|
||||
"AmbientLight",
|
||||
"Antitheft",
|
||||
"Cloud",
|
||||
"Countdown",
|
||||
"Emeter",
|
||||
"Led",
|
||||
"Light",
|
||||
"LightEffect",
|
||||
"LightPreset",
|
||||
"IotLightPreset",
|
||||
"Motion",
|
||||
"Rule",
|
||||
"RuleModule",
|
||||
"Schedule",
|
||||
"Time",
|
||||
"Usage",
|
||||
]
|
||||
93
kasa/iot/modules/ambientlight.py
Normal file
93
kasa/iot/modules/ambientlight.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Implementation of the ambient light (LAS) module found in some dimmers."""
|
||||
|
||||
import logging
|
||||
|
||||
from ...feature import Feature
|
||||
from ..iotmodule import IotModule, merge
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AmbientLight(IotModule):
|
||||
"""Implements ambient light controls for the motion sensor."""
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features after the initial update."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="ambient_light_enabled",
|
||||
name="Ambient light enabled",
|
||||
icon="mdi:brightness-percent",
|
||||
attribute_getter="enabled",
|
||||
attribute_setter="set_enabled",
|
||||
type=Feature.Type.Switch,
|
||||
category=Feature.Category.Config,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="ambient_light",
|
||||
name="Ambient Light",
|
||||
icon="mdi:brightness-percent",
|
||||
attribute_getter="ambientlight_brightness",
|
||||
type=Feature.Type.Sensor,
|
||||
category=Feature.Category.Primary,
|
||||
unit_getter=lambda: "%",
|
||||
)
|
||||
)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Request configuration."""
|
||||
req = merge(
|
||||
self.query_for_command("get_config"),
|
||||
self.query_for_command("get_current_brt"),
|
||||
)
|
||||
|
||||
return req
|
||||
|
||||
@property
|
||||
def config(self) -> dict:
|
||||
"""Return current ambient light config."""
|
||||
config = self.data["get_config"]
|
||||
devs = config["devs"]
|
||||
if len(devs) != 1:
|
||||
_LOGGER.error("Unexpected number of devs in config: %s", config)
|
||||
|
||||
return devs[0]
|
||||
|
||||
@property
|
||||
def presets(self) -> dict:
|
||||
"""Return device-defined presets for brightness setting."""
|
||||
return self.config["level_array"]
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Return True if the module is enabled."""
|
||||
return bool(self.config["enable"])
|
||||
|
||||
@property
|
||||
def ambientlight_brightness(self) -> int:
|
||||
"""Return True if the module is enabled."""
|
||||
return int(self.data["get_current_brt"]["value"])
|
||||
|
||||
async def set_enabled(self, state: bool) -> dict:
|
||||
"""Enable/disable LAS."""
|
||||
return await self.call("set_enable", {"enable": int(state)})
|
||||
|
||||
async def current_brightness(self) -> dict:
|
||||
"""Return current brightness.
|
||||
|
||||
Return value units.
|
||||
"""
|
||||
return await self.call("get_current_brt")
|
||||
|
||||
async def set_brightness_limit(self, value: int) -> dict:
|
||||
"""Set the limit when the motion sensor is inactive.
|
||||
|
||||
See `presets` for preset values. Custom values are also likely allowed.
|
||||
"""
|
||||
return await self.call("set_brt_level", {"index": 0, "value": value})
|
||||
10
kasa/iot/modules/antitheft.py
Normal file
10
kasa/iot/modules/antitheft.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Implementation of the antitheft module."""
|
||||
|
||||
from .rulemodule import RuleModule
|
||||
|
||||
|
||||
class Antitheft(RuleModule):
|
||||
"""Implementation of the antitheft module.
|
||||
|
||||
This shares the functionality among other rule-based modules.
|
||||
"""
|
||||
77
kasa/iot/modules/cloud.py
Normal file
77
kasa/iot/modules/cloud.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Cloud module implementation."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from mashumaro import DataClassDictMixin
|
||||
from mashumaro.types import Alias
|
||||
|
||||
from ...feature import Feature
|
||||
from ..iotmodule import IotModule
|
||||
|
||||
|
||||
@dataclass
|
||||
class CloudInfo(DataClassDictMixin):
|
||||
"""Container for cloud settings."""
|
||||
|
||||
provisioned: Annotated[int, Alias("binded")]
|
||||
cloud_connected: Annotated[int, Alias("cld_connection")]
|
||||
firmware_download_page: Annotated[str, Alias("fwDlPage")]
|
||||
firmware_notify_type: Annotated[int, Alias("fwNotifyType")]
|
||||
illegal_type: Annotated[int, Alias("illegalType")]
|
||||
server: str
|
||||
stop_connect: Annotated[int, Alias("stopConnect")]
|
||||
tcsp_info: Annotated[str, Alias("tcspInfo")]
|
||||
tcsp_status: Annotated[int, Alias("tcspStatus")]
|
||||
username: str
|
||||
|
||||
|
||||
class Cloud(IotModule):
|
||||
"""Module implementing support for cloud services."""
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features after the initial update."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="cloud_connection",
|
||||
name="Cloud connection",
|
||||
icon="mdi:cloud",
|
||||
attribute_getter="is_connected",
|
||||
type=Feature.Type.BinarySensor,
|
||||
category=Feature.Category.Info,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return true if device is connected to the cloud."""
|
||||
return bool(self.info.cloud_connected)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Request cloud connectivity info."""
|
||||
return self.query_for_command("get_info")
|
||||
|
||||
@property
|
||||
def info(self) -> CloudInfo:
|
||||
"""Return information about the cloud connectivity."""
|
||||
return CloudInfo.from_dict(self.data["get_info"])
|
||||
|
||||
def get_available_firmwares(self) -> dict:
|
||||
"""Return list of available firmwares."""
|
||||
return self.query_for_command("get_intl_fw_list")
|
||||
|
||||
def set_server(self, url: str) -> dict:
|
||||
"""Set the update server URL."""
|
||||
return self.query_for_command("set_server_url", {"server": url})
|
||||
|
||||
def connect(self, username: str, password: str) -> dict:
|
||||
"""Login to the cloud using given information."""
|
||||
return self.query_for_command(
|
||||
"bind", {"username": username, "password": password}
|
||||
)
|
||||
|
||||
def disconnect(self) -> dict:
|
||||
"""Disconnect from the cloud."""
|
||||
return self.query_for_command("unbind")
|
||||
7
kasa/iot/modules/countdown.py
Normal file
7
kasa/iot/modules/countdown.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Implementation for the countdown timer."""
|
||||
|
||||
from .rulemodule import RuleModule
|
||||
|
||||
|
||||
class Countdown(RuleModule):
|
||||
"""Implementation of countdown module."""
|
||||
155
kasa/iot/modules/emeter.py
Normal file
155
kasa/iot/modules/emeter.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Implementation of the emeter module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from ...emeterstatus import EmeterStatus
|
||||
from ...interfaces.energy import Energy as EnergyInterface
|
||||
from .usage import Usage
|
||||
|
||||
|
||||
class Emeter(Usage, EnergyInterface):
|
||||
"""Emeter module."""
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
self._supported = EnergyInterface.ModuleFeature.PERIODIC_STATS
|
||||
if (
|
||||
"voltage_mv" in self.data["get_realtime"]
|
||||
or "voltage" in self.data["get_realtime"]
|
||||
):
|
||||
self._supported = (
|
||||
self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT
|
||||
)
|
||||
if (
|
||||
"total_wh" in self.data["get_realtime"]
|
||||
or "total" in self.data["get_realtime"]
|
||||
):
|
||||
self._supported = (
|
||||
self._supported | EnergyInterface.ModuleFeature.CONSUMPTION_TOTAL
|
||||
)
|
||||
|
||||
@property # type: ignore
|
||||
def status(self) -> EmeterStatus:
|
||||
"""Return current energy readings."""
|
||||
return EmeterStatus(self.data["get_realtime"])
|
||||
|
||||
@property
|
||||
def consumption_today(self) -> float | None:
|
||||
"""Return today's energy consumption in kWh."""
|
||||
raw_data = self.daily_data
|
||||
today = datetime.now().day
|
||||
data = self._convert_stat_data(raw_data, entry_key="day", key=today)
|
||||
return data.get(today, 0.0)
|
||||
|
||||
@property
|
||||
def consumption_this_month(self) -> float | None:
|
||||
"""Return this month's energy consumption in kWh."""
|
||||
raw_data = self.monthly_data
|
||||
current_month = datetime.now().month
|
||||
data = self._convert_stat_data(raw_data, entry_key="month", key=current_month)
|
||||
return data.get(current_month, 0.0)
|
||||
|
||||
@property
|
||||
def current_consumption(self) -> float | None:
|
||||
"""Get the current power consumption in Watt."""
|
||||
return self.status.power
|
||||
|
||||
@property
|
||||
def consumption_total(self) -> float | None:
|
||||
"""Return total consumption since last reboot in kWh."""
|
||||
return self.status.total
|
||||
|
||||
@property
|
||||
def current(self) -> float | None:
|
||||
"""Return the current in A."""
|
||||
return self.status.current
|
||||
|
||||
@property
|
||||
def voltage(self) -> float | None:
|
||||
"""Get the current voltage in V."""
|
||||
return self.status.voltage
|
||||
|
||||
async def erase_stats(self) -> dict:
|
||||
"""Erase all stats.
|
||||
|
||||
Uses different query than usage meter.
|
||||
"""
|
||||
return await self.call("erase_emeter_stat")
|
||||
|
||||
async def get_status(self) -> EmeterStatus:
|
||||
"""Return real-time statistics."""
|
||||
return EmeterStatus(await self.call("get_realtime"))
|
||||
|
||||
async def get_daily_stats(
|
||||
self, *, year: int | None = None, month: int | None = None, kwh: bool = True
|
||||
) -> dict:
|
||||
"""Return daily stats for the given year & month.
|
||||
|
||||
The return value is a dictionary of {day: energy, ...}.
|
||||
"""
|
||||
data = await self.get_raw_daystat(year=year, month=month)
|
||||
data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh)
|
||||
return data
|
||||
|
||||
async def get_monthly_stats(
|
||||
self, *, year: int | None = None, kwh: bool = True
|
||||
) -> dict:
|
||||
"""Return monthly stats for the given year.
|
||||
|
||||
The return value is a dictionary of {month: energy, ...}.
|
||||
"""
|
||||
data = await self.get_raw_monthstat(year=year)
|
||||
data = self._convert_stat_data(data["month_list"], entry_key="month", kwh=kwh)
|
||||
return data
|
||||
|
||||
def _convert_stat_data(
|
||||
self,
|
||||
data: list[dict[str, int | float]],
|
||||
entry_key: str,
|
||||
kwh: bool = True,
|
||||
key: int | None = None,
|
||||
) -> dict[int | float, int | float]:
|
||||
"""Return emeter information keyed with the day/month.
|
||||
|
||||
The incoming data is a list of dictionaries::
|
||||
|
||||
[{'year': int,
|
||||
'month': int,
|
||||
'day': int, <-- for get_daystat not get_monthstat
|
||||
'energy_wh': int, <-- for emeter in some versions (wh)
|
||||
'energy': float <-- for emeter in other versions (kwh)
|
||||
}, ...]
|
||||
|
||||
:return: a dictionary keyed by day or month with energy as the value.
|
||||
"""
|
||||
if not data:
|
||||
return {}
|
||||
|
||||
scale: float = 1
|
||||
|
||||
if "energy_wh" in data[0]:
|
||||
value_key = "energy_wh"
|
||||
if kwh:
|
||||
scale = 1 / 1000
|
||||
else:
|
||||
value_key = "energy"
|
||||
if not kwh:
|
||||
scale = 1000
|
||||
|
||||
if key is None:
|
||||
# Return all the data
|
||||
return {entry[entry_key]: entry[value_key] * scale for entry in data}
|
||||
|
||||
# In this case we want a specific key in the data
|
||||
# i.e. the current day or month.
|
||||
#
|
||||
# Since we usually want the data at the end of the list so we can
|
||||
# optimize the search by starting at the end and avoid scaling
|
||||
# the data we don't need.
|
||||
#
|
||||
for entry in reversed(data):
|
||||
if entry[entry_key] == key:
|
||||
return {entry[entry_key]: entry[value_key] * scale}
|
||||
|
||||
return {}
|
||||
37
kasa/iot/modules/led.py
Normal file
37
kasa/iot/modules/led.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""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) -> str:
|
||||
"""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) -> dict:
|
||||
"""Set the state of the led (night mode)."""
|
||||
return await self.call("set_led_off", {"off": int(not state)})
|
||||
|
||||
@property
|
||||
def is_supported(self) -> bool:
|
||||
"""Return whether the module is supported by the device."""
|
||||
return "led_off" in self.data
|
||||
268
kasa/iot/modules/light.py
Normal file
268
kasa/iot/modules/light.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""Implementation of brightness module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import TYPE_CHECKING, Annotated, cast
|
||||
|
||||
from ...device_type import DeviceType
|
||||
from ...exceptions import KasaException
|
||||
from ...feature import Feature
|
||||
from ...interfaces.light import HSV, ColorTempRange, LightState
|
||||
from ...interfaces.light import Light as LightInterface
|
||||
from ...module import FeatureAttribute
|
||||
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
|
||||
_light_state: LightState
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features."""
|
||||
super()._initialize_features()
|
||||
device = self._device
|
||||
|
||||
if device._is_dimmable:
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device,
|
||||
id="brightness",
|
||||
name="Brightness",
|
||||
container=self,
|
||||
attribute_getter="brightness",
|
||||
attribute_setter="set_brightness",
|
||||
range_getter=lambda: (BRIGHTNESS_MIN, BRIGHTNESS_MAX),
|
||||
type=Feature.Type.Number,
|
||||
category=Feature.Category.Primary,
|
||||
)
|
||||
)
|
||||
if 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 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) -> Annotated[int, FeatureAttribute()]:
|
||||
"""Return the current brightness in percentage."""
|
||||
return self._device._brightness
|
||||
|
||||
async def set_brightness(
|
||||
self, brightness: int, *, transition: int | None = None
|
||||
) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set the brightness in percentage. A value of 0 will turn off the light.
|
||||
|
||||
:param int brightness: brightness in percent
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
return await self.set_state(
|
||||
LightState(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) -> Annotated[HSV, FeatureAttribute()]:
|
||||
"""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,
|
||||
) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""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) -> Annotated[int, FeatureAttribute()]:
|
||||
"""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: int | None = None, transition: int | None = None
|
||||
) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""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
|
||||
)
|
||||
|
||||
async def set_state(self, state: LightState) -> dict:
|
||||
"""Set the light state."""
|
||||
# iot protocol Dimmers and smart protocol devices do not support
|
||||
# brightness of 0 so 0 will turn off all devices for consistency
|
||||
if (bulb := self._get_bulb_device()) is None: # Dimmer
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(self._device, IotDimmer)
|
||||
if state.brightness == 0 or state.light_on is False:
|
||||
return await self._device.turn_off(transition=state.transition)
|
||||
elif state.brightness:
|
||||
# set_dimmer_transition will turn on the device
|
||||
return await self._device.set_dimmer_transition(
|
||||
state.brightness, state.transition or 0
|
||||
)
|
||||
return await self._device.turn_on(transition=state.transition)
|
||||
else:
|
||||
transition = state.transition
|
||||
state_dict = asdict(state)
|
||||
state_dict = {k: v for k, v in state_dict.items() if v is not None}
|
||||
if "transition" in state_dict:
|
||||
del state_dict["transition"]
|
||||
state_dict["on_off"] = 1 if state.light_on is None else int(state.light_on)
|
||||
if state_dict.get("brightness") == 0:
|
||||
state_dict["on_off"] = 0
|
||||
del state_dict["brightness"]
|
||||
# If light on state not set default to on.
|
||||
elif state.light_on is None:
|
||||
state_dict["on_off"] = 1
|
||||
else:
|
||||
state_dict["on_off"] = int(state.light_on)
|
||||
# Remove the light_on from the dict
|
||||
state_dict.pop("light_on", None)
|
||||
return await bulb._set_light_state(state_dict, transition=transition)
|
||||
|
||||
@property
|
||||
def state(self) -> LightState:
|
||||
"""Return the current light state."""
|
||||
return self._light_state
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
device = self._device
|
||||
if device.is_on is False:
|
||||
state = LightState(light_on=False)
|
||||
else:
|
||||
state = LightState(light_on=True)
|
||||
if device._is_dimmable:
|
||||
state.brightness = self.brightness
|
||||
if device._is_color:
|
||||
hsv = self.hsv
|
||||
state.hue = hsv.hue
|
||||
state.saturation = hsv.saturation
|
||||
if device._is_variable_color_temp:
|
||||
state.color_temp = self.color_temp
|
||||
self._light_state = state
|
||||
|
||||
async def _deprecated_set_light_state(
|
||||
self, state: dict, *, transition: int | None = None
|
||||
) -> dict:
|
||||
"""Set the light state."""
|
||||
if (bulb := self._get_bulb_device()) is None:
|
||||
raise KasaException("Device does not support set_light_state")
|
||||
else:
|
||||
return await bulb._set_light_state(state, transition=transition)
|
||||
135
kasa/iot/modules/lighteffect.py
Normal file
135
kasa/iot/modules/lighteffect.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""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': ''}
|
||||
"""
|
||||
eff = self.data["lighting_effect_state"]
|
||||
name = eff["name"]
|
||||
if eff["enable"]:
|
||||
return name
|
||||
|
||||
return self.LIGHT_EFFECTS_OFF
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Return light effect brightness."""
|
||||
return self.data["lighting_effect_state"]["brightness"]
|
||||
|
||||
@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,
|
||||
) -> dict:
|
||||
"""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:
|
||||
if self.effect in EFFECT_MAPPING_V1:
|
||||
# TODO: We could query get_lighting_effect here to
|
||||
# get the custom effect although not sure how to find
|
||||
# custom effects
|
||||
effect_dict = EFFECT_MAPPING_V1[self.effect]
|
||||
else:
|
||||
effect_dict = EFFECT_MAPPING_V1["Aurora"]
|
||||
effect_dict = {**effect_dict}
|
||||
effect_dict["enable"] = 0
|
||||
return await self.set_custom_effect(effect_dict)
|
||||
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]
|
||||
effect_dict = {**effect_dict}
|
||||
if brightness is not None:
|
||||
effect_dict["brightness"] = brightness
|
||||
if transition is not None:
|
||||
effect_dict["transition"] = transition
|
||||
|
||||
return await self.set_custom_effect(effect_dict)
|
||||
|
||||
async def set_custom_effect(
|
||||
self,
|
||||
effect_dict: dict,
|
||||
) -> dict:
|
||||
"""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) -> dict:
|
||||
"""Return the base query."""
|
||||
return {}
|
||||
|
||||
@property # type: ignore
|
||||
def _deprecated_effect(self) -> dict:
|
||||
"""Return effect state.
|
||||
|
||||
Example:
|
||||
{'brightness': 50,
|
||||
'custom': 0,
|
||||
'enable': 0,
|
||||
'id': '',
|
||||
'name': ''}
|
||||
"""
|
||||
# LightEffectModule returns the current effect name
|
||||
# so return the dict here for backwards compatibility
|
||||
return self.data["lighting_effect_state"]
|
||||
|
||||
@property # type: ignore
|
||||
def _deprecated_effect_list(self) -> list[str] | None:
|
||||
"""Return built-in effects list.
|
||||
|
||||
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
|
||||
169
kasa/iot/modules/lightpreset.py
Normal file
169
kasa/iot/modules/lightpreset.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Light preset module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from mashumaro.config import BaseConfig
|
||||
|
||||
from ...exceptions import KasaException
|
||||
from ...interfaces import LightPreset as LightPresetInterface
|
||||
from ...interfaces import LightState
|
||||
from ...json import DataClassJSONMixin
|
||||
from ...module import Module
|
||||
from ..iotmodule import IotModule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
# type ignore can be removed after migration mashumaro:
|
||||
# error: Signature of "__replace__" incompatible with supertype "LightState"
|
||||
|
||||
|
||||
@dataclass(kw_only=True, repr=False)
|
||||
class IotLightPreset(DataClassJSONMixin, LightState): # type: ignore[override]
|
||||
"""Light configuration preset."""
|
||||
|
||||
class Config(BaseConfig):
|
||||
"""Config class."""
|
||||
|
||||
omit_none = True
|
||||
|
||||
index: int
|
||||
brightness: int
|
||||
|
||||
# These are not available for effect mode presets on light strips
|
||||
hue: int | None = None
|
||||
saturation: int | None = None
|
||||
color_temp: int | None = None
|
||||
|
||||
# Variables for effect mode presets
|
||||
custom: int | None = None
|
||||
id: str | None = None
|
||||
mode: int | None = None
|
||||
|
||||
|
||||
class LightPreset(IotModule, LightPresetInterface):
|
||||
"""Class for setting light presets."""
|
||||
|
||||
_presets: dict[str, IotLightPreset]
|
||||
_preset_list: list[str]
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
"""Update the internal presets."""
|
||||
self._presets = {
|
||||
f"Light preset {index+1}": IotLightPreset.from_dict(vals)
|
||||
for index, vals in enumerate(self.data["preferred_state"])
|
||||
# Devices may list some light effects along with normal presets but these
|
||||
# are handled by the LightEffect module so exclude preferred states with id
|
||||
if "id" not in vals
|
||||
}
|
||||
self._preset_list = [self.PRESET_NOT_SET]
|
||||
self._preset_list.extend(self._presets.keys())
|
||||
|
||||
@property
|
||||
def preset_list(self) -> list[str]:
|
||||
"""Return built-in effects list.
|
||||
|
||||
Example:
|
||||
['Off', 'Preset 1', 'Preset 2', ...]
|
||||
"""
|
||||
return self._preset_list
|
||||
|
||||
@property
|
||||
def preset_states_list(self) -> Sequence[IotLightPreset]:
|
||||
"""Return built-in effects list.
|
||||
|
||||
Example:
|
||||
['Off', 'Preset 1', 'Preset 2', ...]
|
||||
"""
|
||||
return list(self._presets.values())
|
||||
|
||||
@property
|
||||
def preset(self) -> str:
|
||||
"""Return current preset name."""
|
||||
light = self._device.modules[Module.Light]
|
||||
is_color = light.has_feature("hsv")
|
||||
is_variable_color_temp = light.has_feature("color_temp")
|
||||
|
||||
brightness = light.brightness
|
||||
color_temp = light.color_temp if is_variable_color_temp else None
|
||||
|
||||
h, s = (light.hsv.hue, light.hsv.saturation) if is_color else (None, None)
|
||||
for preset_name, preset in self._presets.items():
|
||||
if (
|
||||
preset.brightness == brightness
|
||||
and (preset.color_temp == color_temp or not is_variable_color_temp)
|
||||
and (preset.hue == h or not is_color)
|
||||
and (preset.saturation == s or not is_color)
|
||||
):
|
||||
return preset_name
|
||||
return self.PRESET_NOT_SET
|
||||
|
||||
async def set_preset(
|
||||
self,
|
||||
preset_name: str,
|
||||
) -> dict:
|
||||
"""Set a light preset for the device."""
|
||||
light = self._device.modules[Module.Light]
|
||||
if preset_name == self.PRESET_NOT_SET:
|
||||
if light.has_feature("hsv"):
|
||||
preset = LightState(hue=0, saturation=0, brightness=100)
|
||||
else:
|
||||
preset = LightState(brightness=100)
|
||||
elif (preset := self._presets.get(preset_name)) is None: # type: ignore[assignment]
|
||||
raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}")
|
||||
|
||||
return await light.set_state(preset)
|
||||
|
||||
@property
|
||||
def has_save_preset(self) -> bool:
|
||||
"""Return True if the device supports updating presets."""
|
||||
return True
|
||||
|
||||
async def save_preset(
|
||||
self,
|
||||
preset_name: str,
|
||||
preset_state: LightState,
|
||||
) -> dict:
|
||||
"""Update the preset with preset_name with the new preset_info."""
|
||||
if len(self._presets) == 0:
|
||||
raise KasaException("Device does not supported saving presets")
|
||||
if preset_name not in self._presets:
|
||||
raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}")
|
||||
|
||||
index = list(self._presets.keys()).index(preset_name)
|
||||
state = asdict(preset_state)
|
||||
state = {k: v for k, v in state.items() if v is not None}
|
||||
state["index"] = index
|
||||
|
||||
return await self.call("set_preferred_state", state)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Return the base query."""
|
||||
return {}
|
||||
|
||||
@property # type: ignore
|
||||
def _deprecated_presets(self) -> list[IotLightPreset]:
|
||||
"""Return a list of available bulb setting presets."""
|
||||
return [
|
||||
IotLightPreset(**vals)
|
||||
for vals in self._device.sys_info["preferred_state"]
|
||||
if "id" not in vals
|
||||
]
|
||||
|
||||
async def _deprecated_save_preset(self, preset: IotLightPreset) -> dict:
|
||||
"""Save a setting preset.
|
||||
|
||||
You can either construct a preset object manually, or pass an existing one
|
||||
obtained using :func:`presets`.
|
||||
"""
|
||||
if len(self._presets) == 0:
|
||||
raise KasaException("Device does not supported saving presets")
|
||||
|
||||
if preset.index >= len(self._presets):
|
||||
raise KasaException("Invalid preset index")
|
||||
|
||||
return await self.call("set_preferred_state", preset.to_dict())
|
||||
102
kasa/iot/modules/motion.py
Normal file
102
kasa/iot/modules/motion.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Implementation of the motion detection (PIR) module found in some dimmers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
|
||||
from ...exceptions import KasaException
|
||||
from ...feature import Feature
|
||||
from ..iotmodule import IotModule
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Range(Enum):
|
||||
"""Range for motion detection."""
|
||||
|
||||
Far = 0
|
||||
Mid = 1
|
||||
Near = 2
|
||||
Custom = 3
|
||||
|
||||
|
||||
class Motion(IotModule):
|
||||
"""Implements the motion detection (PIR) module."""
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features after the initial update."""
|
||||
# Only add features if the device supports the module
|
||||
if "get_config" not in self.data:
|
||||
return
|
||||
|
||||
if "enable" not in self.config:
|
||||
_LOGGER.warning("%r initialized, but no enable in response")
|
||||
return
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="pir_enabled",
|
||||
name="PIR enabled",
|
||||
icon="mdi:motion-sensor",
|
||||
attribute_getter="enabled",
|
||||
attribute_setter="set_enabled",
|
||||
type=Feature.Type.Switch,
|
||||
category=Feature.Category.Config,
|
||||
)
|
||||
)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Request PIR configuration."""
|
||||
return self.query_for_command("get_config")
|
||||
|
||||
@property
|
||||
def config(self) -> dict:
|
||||
"""Return current configuration."""
|
||||
return self.data["get_config"]
|
||||
|
||||
@property
|
||||
def range(self) -> Range:
|
||||
"""Return motion detection range."""
|
||||
return Range(self.config["trigger_index"])
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Return True if module is enabled."""
|
||||
return bool(self.config["enable"])
|
||||
|
||||
async def set_enabled(self, state: bool) -> dict:
|
||||
"""Enable/disable PIR."""
|
||||
return await self.call("set_enable", {"enable": int(state)})
|
||||
|
||||
async def set_range(
|
||||
self, *, range: Range | None = None, custom_range: int | None = None
|
||||
) -> dict:
|
||||
"""Set the range for the sensor.
|
||||
|
||||
:param range: for using standard ranges
|
||||
:param custom_range: range in decimeters, overrides the range parameter
|
||||
"""
|
||||
if custom_range is not None:
|
||||
payload = {"index": Range.Custom.value, "value": custom_range}
|
||||
elif range is not None:
|
||||
payload = {"index": range.value}
|
||||
else:
|
||||
raise KasaException("Either range or custom_range need to be defined")
|
||||
|
||||
return await self.call("set_trigger_sens", payload)
|
||||
|
||||
@property
|
||||
def inactivity_timeout(self) -> int:
|
||||
"""Return inactivity timeout in milliseconds."""
|
||||
return self.config["cold_time"]
|
||||
|
||||
async def set_inactivity_timeout(self, timeout: int) -> dict:
|
||||
"""Set inactivity timeout in milliseconds.
|
||||
|
||||
Note, that you need to delete the default "Smart Control" rule in the app
|
||||
to avoid reverting this back to 60 seconds after a period of time.
|
||||
"""
|
||||
return await self.call("set_cold_time", {"cold_time": timeout})
|
||||
87
kasa/iot/modules/rulemodule.py
Normal file
87
kasa/iot/modules/rulemodule.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Base implementation for all rule-based modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from mashumaro import DataClassDictMixin
|
||||
|
||||
from ..iotmodule import IotModule, merge
|
||||
|
||||
|
||||
class Action(Enum):
|
||||
"""Action to perform."""
|
||||
|
||||
Disabled = -1
|
||||
TurnOff = 0
|
||||
TurnOn = 1
|
||||
Unknown = 2
|
||||
|
||||
|
||||
class TimeOption(Enum):
|
||||
"""Time when the action is executed."""
|
||||
|
||||
Disabled = -1
|
||||
Enabled = 0
|
||||
AtSunrise = 1
|
||||
AtSunset = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class Rule(DataClassDictMixin):
|
||||
"""Representation of a rule."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
enable: int
|
||||
wday: list[int]
|
||||
repeat: int
|
||||
|
||||
# start action
|
||||
sact: Action | None = None
|
||||
stime_opt: TimeOption | None = None
|
||||
smin: int | None = None
|
||||
|
||||
eact: Action | None = None
|
||||
etime_opt: TimeOption | None = None
|
||||
emin: int | None = None
|
||||
|
||||
# Only on bulbs
|
||||
s_light: dict | None = None
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RuleModule(IotModule):
|
||||
"""Base class for rule-based modules, such as countdown and antitheft."""
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Prepare the query for rules."""
|
||||
q = self.query_for_command("get_rules")
|
||||
return merge(q, self.query_for_command("get_next_action"))
|
||||
|
||||
@property
|
||||
def rules(self) -> list[Rule]:
|
||||
"""Return the list of rules for the service."""
|
||||
try:
|
||||
return [
|
||||
Rule.from_dict(rule) for rule in self.data["get_rules"]["rule_list"]
|
||||
]
|
||||
except Exception as ex:
|
||||
_LOGGER.error("Unable to read rule list: %s (data: %s)", ex, self.data)
|
||||
return []
|
||||
|
||||
async def set_enabled(self, state: bool) -> dict:
|
||||
"""Enable or disable the service."""
|
||||
return await self.call("set_overall_enable", {"enable": state})
|
||||
|
||||
async def delete_rule(self, rule: Rule) -> dict:
|
||||
"""Delete the given rule."""
|
||||
return await self.call("delete_rule", {"id": rule.id})
|
||||
|
||||
async def delete_all_rules(self) -> dict:
|
||||
"""Delete all rules."""
|
||||
return await self.call("delete_all_rules")
|
||||
7
kasa/iot/modules/schedule.py
Normal file
7
kasa/iot/modules/schedule.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Schedule module implementation."""
|
||||
|
||||
from .rulemodule import RuleModule
|
||||
|
||||
|
||||
class Schedule(RuleModule):
|
||||
"""Implements the scheduling interface."""
|
||||
93
kasa/iot/modules/time.py
Normal file
93
kasa/iot/modules/time.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Provides the current time and timezone information."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, tzinfo
|
||||
|
||||
from ...exceptions import KasaException
|
||||
from ...interfaces import Time as TimeInterface
|
||||
from ..iotmodule import IotModule, merge
|
||||
from ..iottimezone import get_timezone, get_timezone_index
|
||||
|
||||
|
||||
class Time(IotModule, TimeInterface):
|
||||
"""Implements the timezone settings."""
|
||||
|
||||
_timezone: tzinfo = UTC
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Request time and timezone."""
|
||||
q = self.query_for_command("get_time")
|
||||
|
||||
merge(q, self.query_for_command("get_timezone"))
|
||||
return q
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
"""Perform actions after a device update."""
|
||||
if res := self.data.get("get_timezone"):
|
||||
self._timezone = await get_timezone(res.get("index"))
|
||||
|
||||
@property
|
||||
def time(self) -> datetime:
|
||||
"""Return current device time."""
|
||||
res = self.data["get_time"]
|
||||
time = datetime(
|
||||
res["year"],
|
||||
res["month"],
|
||||
res["mday"],
|
||||
res["hour"],
|
||||
res["min"],
|
||||
res["sec"],
|
||||
tzinfo=self.timezone,
|
||||
)
|
||||
return time
|
||||
|
||||
@property
|
||||
def timezone(self) -> tzinfo:
|
||||
"""Return current timezone."""
|
||||
return self._timezone
|
||||
|
||||
async def get_time(self) -> datetime | None:
|
||||
"""Return current device time."""
|
||||
try:
|
||||
res = await self.call("get_time")
|
||||
return datetime(
|
||||
res["year"],
|
||||
res["month"],
|
||||
res["mday"],
|
||||
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."""
|
||||
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) -> dict:
|
||||
"""Request timezone information from the device."""
|
||||
return await self.call("get_timezone")
|
||||
122
kasa/iot/modules/usage.py
Normal file
122
kasa/iot/modules/usage.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Implementation of the usage interface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from ..iotmodule import IotModule, merge
|
||||
|
||||
|
||||
class Usage(IotModule):
|
||||
"""Baseclass for emeter/usage interfaces."""
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Return the base query."""
|
||||
now = datetime.now()
|
||||
year = now.year
|
||||
month = now.month
|
||||
|
||||
req = self.query_for_command("get_realtime")
|
||||
req = merge(
|
||||
req, self.query_for_command("get_daystat", {"year": year, "month": month})
|
||||
)
|
||||
req = merge(req, self.query_for_command("get_monthstat", {"year": year}))
|
||||
|
||||
return req
|
||||
|
||||
@property
|
||||
def estimated_query_response_size(self) -> int:
|
||||
"""Estimated maximum query response size."""
|
||||
return 2048
|
||||
|
||||
@property
|
||||
def daily_data(self) -> list[dict]:
|
||||
"""Return statistics on daily basis."""
|
||||
return self.data["get_daystat"]["day_list"]
|
||||
|
||||
@property
|
||||
def monthly_data(self) -> list[dict]:
|
||||
"""Return statistics on monthly basis."""
|
||||
return self.data["get_monthstat"]["month_list"]
|
||||
|
||||
@property
|
||||
def usage_today(self) -> int | None:
|
||||
"""Return today's usage in minutes."""
|
||||
today = datetime.now().day
|
||||
# Traverse the list in reverse order to find the latest entry.
|
||||
for entry in reversed(self.daily_data):
|
||||
if entry["day"] == today:
|
||||
return entry["time"]
|
||||
return None
|
||||
|
||||
@property
|
||||
def usage_this_month(self) -> int | None:
|
||||
"""Return usage in this month in minutes."""
|
||||
this_month = datetime.now().month
|
||||
# Traverse the list in reverse order to find the latest entry.
|
||||
for entry in reversed(self.monthly_data):
|
||||
if entry["month"] == this_month:
|
||||
return entry["time"]
|
||||
return None
|
||||
|
||||
async def get_raw_daystat(
|
||||
self, *, year: int | None = None, month: int | None = None
|
||||
) -> dict:
|
||||
"""Return raw daily stats for the given year & month."""
|
||||
if year is None:
|
||||
year = datetime.now().year
|
||||
if month is None:
|
||||
month = datetime.now().month
|
||||
|
||||
return await self.call("get_daystat", {"year": year, "month": month})
|
||||
|
||||
async def get_raw_monthstat(self, *, year: int | None = None) -> dict:
|
||||
"""Return raw monthly stats for the given year."""
|
||||
if year is None:
|
||||
year = datetime.now().year
|
||||
|
||||
return await self.call("get_monthstat", {"year": year})
|
||||
|
||||
async def get_daystat(
|
||||
self, *, year: int | None = None, month: int | None = None
|
||||
) -> dict:
|
||||
"""Return daily stats for the given year & month.
|
||||
|
||||
The return value is a dictionary of {day: time, ...}.
|
||||
"""
|
||||
data = await self.get_raw_daystat(year=year, month=month)
|
||||
data = self._convert_stat_data(data["day_list"], entry_key="day")
|
||||
return data
|
||||
|
||||
async def get_monthstat(self, *, year: int | None = None) -> dict:
|
||||
"""Return monthly stats for the given year.
|
||||
|
||||
The return value is a dictionary of {month: time, ...}.
|
||||
"""
|
||||
data = await self.get_raw_monthstat(year=year)
|
||||
data = self._convert_stat_data(data["month_list"], entry_key="month")
|
||||
return data
|
||||
|
||||
async def erase_stats(self) -> dict:
|
||||
"""Erase all stats."""
|
||||
return await self.call("erase_runtime_stat")
|
||||
|
||||
def _convert_stat_data(self, data: list[dict], entry_key: str) -> dict:
|
||||
"""Return usage information keyed with the day/month.
|
||||
|
||||
The incoming data is a list of dictionaries::
|
||||
|
||||
[{'year': int,
|
||||
'month': int,
|
||||
'day': int, <-- for get_daystat not get_monthstat
|
||||
'time': int, <-- for usage (mins)
|
||||
}, ...]
|
||||
|
||||
:return: return a dictionary keyed by day or month with time as the value.
|
||||
"""
|
||||
if not data:
|
||||
return {}
|
||||
|
||||
res = {entry[entry_key]: entry["time"] for entry in data}
|
||||
|
||||
return res
|
||||
Reference in New Issue
Block a user