Add support for tapo bulbs (#558)

* Add support for tapo light bulbs

* Use TapoDevice for on/off

* Add tapobulbs to discovery

* Add partial support for effects

Activating the effect does not work as I thought it would,
but this implements rest of the interface from SmartLightStrip.

* Add missing __init__ for tapo package

* Make mypy happy

* Add docstrings to make ruff happy

* Implement state_information and has_emeter

* Import tapoplug from kasa.tapo package

* Add tapo L530 fixture

* Enable tests for L530 fixture

* Make ruff happy

* Update fixture filename

* Raise exceptions on invalid parameters

* Return results in a wrapped dict

* Implement set_*

* Reorganize bulbs to iot&smart, fix tests for smarts

* Fix linting

* Fix BULBS_LIGHT_STRIP back to LIGHT_STRIPS
This commit is contained in:
Teemu R 2023-12-05 20:07:10 +01:00 committed by GitHub
parent 5febd300ca
commit f9b5003da2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 564 additions and 53 deletions

View File

@ -1,5 +1,12 @@
"""Script that checks if README.md is missing devices that have fixtures.""" """Script that checks if README.md is missing devices that have fixtures."""
from kasa.tests.conftest import ALL_DEVICES, BULBS, DIMMERS, LIGHT_STRIPS, PLUGS, STRIPS from kasa.tests.conftest import (
ALL_DEVICES,
BULBS,
DIMMERS,
LIGHT_STRIPS,
PLUGS,
STRIPS,
)
with open("README.md") as f: with open("README.md") as f:
readme = f.read() readme = f.read()

View File

@ -18,7 +18,7 @@ from .smartlightstrip import SmartLightStrip
from .smartplug import SmartPlug from .smartplug import SmartPlug
from .smartprotocol import SmartProtocol from .smartprotocol import SmartProtocol
from .smartstrip import SmartStrip from .smartstrip import SmartStrip
from .tapo.tapoplug import TapoPlug from .tapo import TapoBulb, TapoPlug
DEVICE_TYPE_TO_CLASS = { DEVICE_TYPE_TO_CLASS = {
DeviceType.Plug: SmartPlug, DeviceType.Plug: SmartPlug,
@ -27,6 +27,7 @@ DEVICE_TYPE_TO_CLASS = {
DeviceType.Dimmer: SmartDimmer, DeviceType.Dimmer: SmartDimmer,
DeviceType.LightStrip: SmartLightStrip, DeviceType.LightStrip: SmartLightStrip,
DeviceType.TapoPlug: TapoPlug, DeviceType.TapoPlug: TapoPlug,
DeviceType.TapoBulb: TapoBulb,
} }
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -139,6 +140,7 @@ def get_device_class_from_type_name(device_type: str) -> Optional[Type[SmartDevi
"""Return the device class from the type name.""" """Return the device class from the type name."""
supported_device_types: dict[str, Type[SmartDevice]] = { supported_device_types: dict[str, Type[SmartDevice]] = {
"SMART.TAPOPLUG": TapoPlug, "SMART.TAPOPLUG": TapoPlug,
"SMART.TAPOBULB": TapoBulb,
"SMART.KASAPLUG": TapoPlug, "SMART.KASAPLUG": TapoPlug,
"IOT.SMARTPLUGSWITCH": SmartPlug, "IOT.SMARTPLUGSWITCH": SmartPlug,
} }

View File

@ -15,6 +15,7 @@ class DeviceType(Enum):
Dimmer = "dimmer" Dimmer = "dimmer"
LightStrip = "lightstrip" LightStrip = "lightstrip"
TapoPlug = "tapoplug" TapoPlug = "tapoplug"
TapoBulb = "tapobulb"
Unknown = "unknown" Unknown = "unknown"
@staticmethod @staticmethod

View File

@ -1,5 +1,6 @@
"""Package for supporting tapo-branded and newer kasa devices.""" """Package for supporting tapo-branded and newer kasa devices."""
from .tapobulb import TapoBulb
from .tapodevice import TapoDevice from .tapodevice import TapoDevice
from .tapoplug import TapoPlug from .tapoplug import TapoPlug
__all__ = ["TapoDevice", "TapoPlug"] __all__ = ["TapoDevice", "TapoPlug", "TapoBulb"]

267
kasa/tapo/tapobulb.py Normal file
View File

@ -0,0 +1,267 @@
"""Module for tapo-branded smart bulbs (L5**)."""
from typing import Any, Dict, List, Optional
from ..exceptions import SmartDeviceException
from ..smartbulb import HSV, ColorTempRange, SmartBulb, SmartBulbPreset
from .tapodevice import TapoDevice
AVAILABLE_EFFECTS = {
"L1": "Party",
"L2": "Relax",
}
class TapoBulb(TapoDevice, SmartBulb):
"""Representation of a TP-Link Tapo Bulb.
Documentation TBD. See :class:`~kasa.smartbulb.SmartBulb` for now.
"""
@property
def has_emeter(self) -> bool:
"""Bulbs have only historical emeter.
{'usage':
'power_usage': {'today': 6, 'past7': 106, 'past30': 106},
'saved_power': {'today': 35, 'past7': 529, 'past30': 529},
}
"""
return False
@property
def is_color(self) -> bool:
"""Whether the bulb supports color changes."""
# TODO: this makes an assumption that only color bulbs report this
return "hue" in self._info
@property
def is_dimmable(self) -> bool:
"""Whether the bulb supports brightness changes."""
# TODO: this makes an assumption that only dimmables report this
return "brightness" in self._info
@property
def is_variable_color_temp(self) -> bool:
"""Whether the bulb supports color temperature changes."""
# TODO: this makes an assumption, that only ct bulbs report this
return bool(self._info.get("color_temp_range", False))
@property
def valid_temperature_range(self) -> ColorTempRange:
"""Return the device-specific white temperature range (in Kelvin).
:return: White temperature range in Kelvin (minimum, maximum)
"""
ct_range = self._info.get("color_temp_range", [0, 0])
return ColorTempRange(min=ct_range[0], max=ct_range[1])
@property
def has_effects(self) -> bool:
"""Return True if the device supports effects."""
return "dynamic_light_effect_enable" in self._info
@property
def effect(self) -> Dict:
"""Return effect state.
This follows the format used by SmartLightStrip.
Example:
{'brightness': 50,
'custom': 0,
'enable': 0,
'id': '',
'name': ''}
"""
# If no effect is active, dynamic_light_effect_id does not appear in info
current_effect = self._info.get("dynamic_light_effect_id", "")
data = {
"brightness": self.brightness,
"enable": current_effect != "",
"id": current_effect,
"name": AVAILABLE_EFFECTS.get(current_effect, ""),
}
return data
@property
def effect_list(self) -> Optional[List[str]]:
"""Return built-in effects list.
Example:
['Party', 'Relax', ...]
"""
return list(AVAILABLE_EFFECTS.keys()) if self.has_effects else None
@property
def hsv(self) -> HSV:
"""Return the current HSV state of the bulb.
:return: hue, saturation and value (degrees, %, %)
"""
if not self.is_color:
raise SmartDeviceException("Bulb does not support color.")
h, s, v = (
self._info.get("hue", 0),
self._info.get("saturation", 0),
self._info.get("brightness", 0),
)
return HSV(hue=h, saturation=s, value=v)
@property
def color_temp(self) -> int:
"""Whether the bulb supports color temperature changes."""
if not self.is_variable_color_temp:
raise SmartDeviceException("Bulb does not support colortemp.")
return self._info.get("color_temp", -1)
@property
def brightness(self) -> int:
"""Return the current brightness in percentage."""
if not self.is_dimmable: # pragma: no cover
raise SmartDeviceException("Bulb is not dimmable.")
return self._info.get("brightness", -1)
async def set_hsv(
self,
hue: int,
saturation: int,
value: Optional[int] = None,
*,
transition: Optional[int] = None,
) -> Dict:
"""Set new HSV.
Note, transition is not supported and will be ignored.
:param int hue: hue in degrees
:param int saturation: saturation in percentage [0,100]
:param int value: value in percentage [0, 100]
:param int transition: transition in milliseconds.
"""
if not self.is_color:
raise SmartDeviceException("Bulb does not support color.")
if not isinstance(hue, int) or not (0 <= hue <= 360):
raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)")
if not isinstance(saturation, int) or not (0 <= saturation <= 100):
raise ValueError(
f"Invalid saturation value: {saturation} (valid range: 0-100%)"
)
if value is not None:
self._raise_for_invalid_brightness(value)
return await self.protocol.query(
{
"set_device_info": {
"hue": hue,
"saturation": saturation,
"brightness": value,
}
}
)
async def set_color_temp(
self, temp: int, *, brightness=None, transition: Optional[int] = None
) -> Dict:
"""Set the color temperature of the device in kelvin.
Note, transition is not supported and will be ignored.
:param int temp: The new color temperature, in Kelvin
:param int transition: transition in milliseconds.
"""
# TODO: Note, trying to set brightness at the same time
# with color_temp causes error -1008
if not self.is_variable_color_temp:
raise SmartDeviceException("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
)
)
return await self.protocol.query({"set_device_info": {"color_temp": temp}})
async def set_brightness(
self, brightness: int, *, transition: Optional[int] = None
) -> Dict:
"""Set the brightness in percentage.
Note, transition is not supported and will be ignored.
:param int brightness: brightness in percent
:param int transition: transition in milliseconds.
"""
if not self.is_dimmable: # pragma: no cover
raise SmartDeviceException("Bulb is not dimmable.")
return await self.protocol.query(
{"set_device_info": {"brightness": brightness}}
)
# Default state information, should be made to settings
"""
"info": {
"default_states": {
"re_power_type": "always_on",
"type": "last_states",
"state": {
"brightness": 36,
"hue": 0,
"saturation": 0,
"color_temp": 2700,
},
},
"""
async def set_effect(
self,
effect: str,
*,
brightness: Optional[int] = None,
transition: Optional[int] = None,
) -> None:
"""Set an effect on the device."""
raise NotImplementedError()
# TODO: the code below does to activate the effect but gives no error
return await self.protocol.query(
{
"set_device_info": {
"dynamic_light_effect_enable": 1,
"dynamic_light_effect_id": effect,
}
}
)
@property # type: ignore
def state_information(self) -> Dict[str, Any]:
"""Return bulb-specific state information."""
info: Dict[str, Any] = {
# TODO: re-enable after we don't inherit from smartbulb
# **super().state_information
"Brightness": self.brightness,
"Is dimmable": self.is_dimmable,
}
if self.is_variable_color_temp:
info["Color temperature"] = self.color_temp
info["Valid temperature range"] = self.valid_temperature_range
if self.is_color:
info["HSV"] = self.hsv
info["Presets"] = self.presets
return info
@property
def presets(self) -> List[SmartBulbPreset]:
"""Return a list of available bulb setting presets."""
return []

View File

@ -6,7 +6,7 @@ from dataclasses import dataclass
from json import dumps as json_dumps from json import dumps as json_dumps
from os.path import basename from os.path import basename
from pathlib import Path, PurePath from pathlib import Path, PurePath
from typing import Dict, Optional from typing import Dict, Optional, Set
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342
@ -21,7 +21,7 @@ from kasa import (
SmartStrip, SmartStrip,
TPLinkSmartHomeProtocol, TPLinkSmartHomeProtocol,
) )
from kasa.tapo import TapoDevice, TapoPlug from kasa.tapo import TapoBulb, TapoDevice, TapoPlug
from .newfakes import FakeSmartProtocol, FakeTransportProtocol from .newfakes import FakeSmartProtocol, FakeTransportProtocol
@ -42,19 +42,44 @@ SUPPORTED_SMART_DEVICES = [
SUPPORTED_DEVICES = SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES SUPPORTED_DEVICES = SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES
# Tapo bulbs
BULBS_SMART_VARIABLE_TEMP = {"L530"}
BULBS_SMART_COLOR = {"L530"}
BULBS_SMART_LIGHT_STRIP: Set[str] = set()
BULBS_SMART_DIMMABLE: Set[str] = set()
BULBS_SMART = (
BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR)
.union(BULBS_SMART_DIMMABLE)
.union(BULBS_SMART_LIGHT_STRIP)
)
LIGHT_STRIPS = {"KL400", "KL430", "KL420"} # Kasa (IOT-prefixed) bulbs
VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL125", "KL130", "KL135", "KL430"} BULBS_IOT_LIGHT_STRIP = {"KL400", "KL430", "KL420"}
COLOR_BULBS = {"LB130", "KL125", "KL130", "KL135", *LIGHT_STRIPS} BULBS_IOT_VARIABLE_TEMP = {
"LB120",
"LB130",
"KL120",
"KL125",
"KL130",
"KL135",
"KL430",
}
BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP}
BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"}
BULBS_IOT = (
BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR)
.union(BULBS_IOT_DIMMABLE)
.union(BULBS_IOT_LIGHT_STRIP)
)
BULBS_VARIABLE_TEMP = {*BULBS_SMART_VARIABLE_TEMP, *BULBS_IOT_VARIABLE_TEMP}
BULBS_COLOR = {*BULBS_SMART_COLOR, *BULBS_IOT_COLOR}
LIGHT_STRIPS = {*BULBS_SMART_LIGHT_STRIP, *BULBS_IOT_LIGHT_STRIP}
BULBS = { BULBS = {
"KL50", *BULBS_IOT,
"KL60", *BULBS_SMART,
"LB100",
"LB110",
"KL110",
*VARIABLE_TEMP,
*COLOR_BULBS,
*LIGHT_STRIPS,
} }
@ -83,7 +108,7 @@ WITH_EMETER = {"HS110", "HS300", "KP115", "KP125", *BULBS}
ALL_DEVICES_IOT = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS) ALL_DEVICES_IOT = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS)
PLUGS_SMART = {"P110"} PLUGS_SMART = {"P110"}
ALL_DEVICES_SMART = PLUGS_SMART ALL_DEVICES_SMART = BULBS_SMART.union(PLUGS_SMART)
ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART)
@ -91,9 +116,12 @@ IP_MODEL_CACHE: Dict[str, str] = {}
def idgenerator(paramtuple): def idgenerator(paramtuple):
return basename(paramtuple[0]) + ( try:
"" if paramtuple[1] == "IOT" else "-" + paramtuple[1] return basename(paramtuple[0]) + (
) "" if paramtuple[1] == "IOT" else "-" + paramtuple[1]
)
except: # TODO: HACK as idgenerator is now used by default # noqa: E722
return None
def filter_model(desc, model_filter, protocol_filter=None): def filter_model(desc, model_filter, protocol_filter=None):
@ -108,11 +136,15 @@ def filter_model(desc, model_filter, protocol_filter=None):
filtered.append((file, protocol)) filtered.append((file, protocol))
filtered_basenames = [basename(f) + "-" + p for f, p in filtered] filtered_basenames = [basename(f) + "-" + p for f, p in filtered]
print(f"{desc}: {filtered_basenames}") print(f"# {desc}")
for file in filtered_basenames:
print(f"\t{file}")
return filtered return filtered
def parametrize(desc, devices, protocol_filter=None, ids=None): def parametrize(desc, devices, protocol_filter=None, ids=None):
if ids is None:
ids = idgenerator
return pytest.mark.parametrize( return pytest.mark.parametrize(
"dev", filter_model(desc, devices, protocol_filter), indirect=True, ids=ids "dev", filter_model(desc, devices, protocol_filter), indirect=True, ids=ids
) )
@ -121,29 +153,34 @@ def parametrize(desc, devices, protocol_filter=None, ids=None):
has_emeter = parametrize("has emeter", WITH_EMETER) has_emeter = parametrize("has emeter", WITH_EMETER)
no_emeter = parametrize("no emeter", ALL_DEVICES_IOT - WITH_EMETER) no_emeter = parametrize("no emeter", ALL_DEVICES_IOT - WITH_EMETER)
bulb = parametrize("bulbs", BULBS, ids=idgenerator) bulb = parametrize("bulbs", BULBS, protocol_filter={"SMART", "IOT"})
plug = parametrize("plugs", PLUGS, ids=idgenerator) plug = parametrize("plugs", PLUGS)
strip = parametrize("strips", STRIPS, ids=idgenerator) strip = parametrize("strips", STRIPS)
dimmer = parametrize("dimmers", DIMMERS, ids=idgenerator) dimmer = parametrize("dimmers", DIMMERS)
lightstrip = parametrize("lightstrips", LIGHT_STRIPS, ids=idgenerator) lightstrip = parametrize("lightstrips", LIGHT_STRIPS)
# bulb types # bulb types
dimmable = parametrize("dimmable", DIMMABLE) dimmable = parametrize("dimmable", DIMMABLE)
non_dimmable = parametrize("non-dimmable", BULBS - DIMMABLE) non_dimmable = parametrize("non-dimmable", BULBS - DIMMABLE)
variable_temp = parametrize("variable color temp", VARIABLE_TEMP) variable_temp = parametrize(
non_variable_temp = parametrize("non-variable color temp", BULBS - VARIABLE_TEMP) "variable color temp", BULBS_VARIABLE_TEMP, {"SMART", "IOT"}
color_bulb = parametrize("color bulbs", COLOR_BULBS) )
non_color_bulb = parametrize("non-color bulbs", BULBS - COLOR_BULBS) non_variable_temp = parametrize(
"non-variable color temp", BULBS - BULBS_VARIABLE_TEMP, {"SMART", "IOT"}
)
color_bulb = parametrize("color bulbs", BULBS_COLOR, {"SMART", "IOT"})
non_color_bulb = parametrize("non-color bulbs", BULBS - BULBS_COLOR, {"SMART", "IOT"})
plug_smart = parametrize( color_bulb_iot = parametrize("color bulbs iot", BULBS_COLOR, {"IOT"})
"plug devices smart", PLUGS_SMART, protocol_filter={"SMART"}, ids=idgenerator variable_temp_iot = parametrize("variable color temp iot", BULBS_VARIABLE_TEMP, {"IOT"})
) bulb_iot = parametrize("bulb devices iot", BULBS_IOT)
plug_smart = parametrize("plug devices smart", PLUGS_SMART, protocol_filter={"SMART"})
bulb_smart = parametrize("bulb devices smart", BULBS_SMART, protocol_filter={"SMART"})
device_smart = parametrize( device_smart = parametrize(
"devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"}, ids=idgenerator "devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"}
)
device_iot = parametrize(
"devices iot", ALL_DEVICES_IOT, protocol_filter={"IOT"}, ids=idgenerator
) )
device_iot = parametrize("devices iot", ALL_DEVICES_IOT, protocol_filter={"IOT"})
def get_fixture_data(): def get_fixture_data():
@ -197,6 +234,7 @@ def check_categories():
+ bulb.args[1] + bulb.args[1]
+ lightstrip.args[1] + lightstrip.args[1]
+ plug_smart.args[1] + plug_smart.args[1]
+ bulb_smart.args[1]
) )
diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures) diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures)
if diff: if diff:
@ -225,6 +263,9 @@ def device_for_file(model, protocol):
for d in PLUGS_SMART: for d in PLUGS_SMART:
if d in model: if d in model:
return TapoPlug return TapoPlug
for d in BULBS_SMART:
if d in model:
return TapoBulb
else: else:
for d in STRIPS: for d in STRIPS:
if d in model: if d in model:
@ -235,7 +276,7 @@ def device_for_file(model, protocol):
return SmartPlug return SmartPlug
# Light strips are recognized also as bulbs, so this has to go first # Light strips are recognized also as bulbs, so this has to go first
for d in LIGHT_STRIPS: for d in BULBS_IOT_LIGHT_STRIP:
if d in model: if d in model:
return SmartLightStrip return SmartLightStrip

View File

@ -0,0 +1,186 @@
{
"component_nego": {
"component_list": [
{
"id": "device",
"ver_code": 2
},
{
"id": "firmware",
"ver_code": 2
},
{
"id": "quick_setup",
"ver_code": 3
},
{
"id": "inherit",
"ver_code": 1
},
{
"id": "time",
"ver_code": 1
},
{
"id": "wireless",
"ver_code": 1
},
{
"id": "schedule",
"ver_code": 2
},
{
"id": "countdown",
"ver_code": 2
},
{
"id": "antitheft",
"ver_code": 1
},
{
"id": "account",
"ver_code": 1
},
{
"id": "synchronize",
"ver_code": 1
},
{
"id": "sunrise_sunset",
"ver_code": 1
},
{
"id": "cloud_connect",
"ver_code": 1
},
{
"id": "default_states",
"ver_code": 1
},
{
"id": "preset",
"ver_code": 1
},
{
"id": "brightness",
"ver_code": 1
},
{
"id": "color",
"ver_code": 1
},
{
"id": "color_temperature",
"ver_code": 1
},
{
"id": "auto_light",
"ver_code": 1
},
{
"id": "on_off_gradually",
"ver_code": 1
},
{
"id": "device_local_time",
"ver_code": 1
},
{
"id": "light_effect",
"ver_code": 1
},
{
"id": "iot_cloud",
"ver_code": 1
},
{
"id": "bulb_quick_control",
"ver_code": 1
}
]
},
"discovery_result": {
"device_id": "00000000000000000000000000000000",
"device_model": "L530E(EU)",
"device_type": "SMART.TAPOBULB",
"factory_default": false,
"ip": "127.0.0.123",
"is_support_iot_cloud": true,
"mac": "00-00-00-00-00-00",
"mgt_encrypt_schm": {
"encrypt_type": "AES",
"http_port": 80,
"is_support_https": false,
"lv": 2
},
"obd_src": "tplink",
"owner": "00000000000000000000000000000000"
},
"get_device_info": {
"avatar": "bulb",
"brightness": 100,
"color_temp": 2500,
"color_temp_range": [
2500,
6500
],
"default_states": {
"re_power_type": "always_on",
"state": {
"brightness": 100,
"color_temp": 2500,
"hue": 0,
"saturation": 100
},
"type": "last_states"
},
"device_id": "0000000000000000000000000000000000000000",
"device_on": true,
"dynamic_light_effect_enable": false,
"fw_id": "00000000000000000000000000000000",
"fw_ver": "1.0.6 Build 230509 Rel.195312",
"has_set_location_info": true,
"hue": 0,
"hw_id": "00000000000000000000000000000000",
"hw_ver": "3.0",
"ip": "127.0.0.123",
"lang": "de_DE",
"latitude": 0,
"longitude": 0,
"mac": "00-00-00-00-00-00",
"model": "L530",
"nickname": "c21hcnRlIFdMQU4tR2zDvGhiaXJuZQ==",
"oem_id": "00000000000000000000000000000000",
"overheated": false,
"region": "Europe/Berlin",
"rssi": -38,
"saturation": 100,
"signal_level": 3,
"specs": "",
"ssid": "IyNNQVNLRUROQU1FIyM=",
"time_diff": 60,
"type": "SMART.TAPOBULB"
},
"get_device_time": {
"region": "Europe/Berlin",
"time_diff": 60,
"timestamp": 1701618972
},
"get_device_usage": {
"power_usage": {
"past30": 107,
"past7": 107,
"today": 7
},
"saved_power": {
"past30": 535,
"past7": 535,
"today": 41
},
"time_usage": {
"past30": 642,
"past7": 642,
"today": 48
}
}
}

View File

@ -313,10 +313,13 @@ class FakeSmartTransport(BaseTransport):
async def send(self, request: str): async def send(self, request: str):
request_dict = json_loads(request) request_dict = json_loads(request)
method = request_dict["method"] method = request_dict["method"]
params = request_dict["params"]
if method == "component_nego" or method[:4] == "get_": if method == "component_nego" or method[:4] == "get_":
return self.info[method] return {"result": self.info[method]}
elif method[:4] == "set_": elif method[:4] == "set_":
_LOGGER.debug("Call %s not implemented, doing nothing", method) target_method = f"get_{method[4:]}"
self.info[target_method].update(params)
return {"result": ""}
async def close(self) -> None: async def close(self) -> None:
pass pass

View File

@ -4,7 +4,9 @@ from kasa import DeviceType, SmartBulb, SmartBulbPreset, SmartDeviceException
from .conftest import ( from .conftest import (
bulb, bulb,
bulb_iot,
color_bulb, color_bulb,
color_bulb_iot,
dimmable, dimmable,
handle_turn_on, handle_turn_on,
non_color_bulb, non_color_bulb,
@ -12,6 +14,7 @@ from .conftest import (
non_variable_temp, non_variable_temp,
turn_on, turn_on,
variable_temp, variable_temp,
variable_temp_iot,
) )
from .newfakes import BULB_SCHEMA, LIGHT_STATE_SCHEMA from .newfakes import BULB_SCHEMA, LIGHT_STATE_SCHEMA
@ -38,7 +41,7 @@ async def test_state_attributes(dev: SmartBulb):
assert dev.state_information["Is dimmable"] == dev.is_dimmable assert dev.state_information["Is dimmable"] == dev.is_dimmable
@bulb @bulb_iot
async def test_light_state_without_update(dev: SmartBulb, monkeypatch): async def test_light_state_without_update(dev: SmartBulb, monkeypatch):
with pytest.raises(SmartDeviceException): with pytest.raises(SmartDeviceException):
monkeypatch.setitem( monkeypatch.setitem(
@ -47,7 +50,7 @@ async def test_light_state_without_update(dev: SmartBulb, monkeypatch):
print(dev.light_state) print(dev.light_state)
@bulb @bulb_iot
async def test_get_light_state(dev: SmartBulb): async def test_get_light_state(dev: SmartBulb):
LIGHT_STATE_SCHEMA(await dev.get_light_state()) LIGHT_STATE_SCHEMA(await dev.get_light_state())
@ -72,7 +75,7 @@ async def test_hsv(dev: SmartBulb, turn_on):
assert brightness == 1 assert brightness == 1
@color_bulb @color_bulb_iot
async def test_set_hsv_transition(dev: SmartBulb, mocker): async def test_set_hsv_transition(dev: SmartBulb, mocker):
set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") set_light_state = mocker.patch("kasa.SmartBulb.set_light_state")
await dev.set_hsv(10, 10, 100, transition=1000) await dev.set_hsv(10, 10, 100, transition=1000)
@ -138,7 +141,7 @@ async def test_try_set_colortemp(dev: SmartBulb, turn_on):
assert dev.color_temp == 2700 assert dev.color_temp == 2700
@variable_temp @variable_temp_iot
async def test_set_color_temp_transition(dev: SmartBulb, mocker): async def test_set_color_temp_transition(dev: SmartBulb, mocker):
set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") set_light_state = mocker.patch("kasa.SmartBulb.set_light_state")
await dev.set_color_temp(2700, transition=100) await dev.set_color_temp(2700, transition=100)
@ -146,7 +149,7 @@ async def test_set_color_temp_transition(dev: SmartBulb, mocker):
set_light_state.assert_called_with({"color_temp": 2700}, transition=100) set_light_state.assert_called_with({"color_temp": 2700}, transition=100)
@variable_temp @variable_temp_iot
async def test_unknown_temp_range(dev: SmartBulb, monkeypatch, caplog): async def test_unknown_temp_range(dev: SmartBulb, monkeypatch, caplog):
monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") monkeypatch.setitem(dev._sys_info, "model", "unknown bulb")
@ -192,7 +195,7 @@ async def test_dimmable_brightness(dev: SmartBulb, turn_on):
await dev.set_brightness("foo") await dev.set_brightness("foo")
@bulb @bulb_iot
async def test_turn_on_transition(dev: SmartBulb, mocker): async def test_turn_on_transition(dev: SmartBulb, mocker):
set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") set_light_state = mocker.patch("kasa.SmartBulb.set_light_state")
await dev.turn_on(transition=1000) await dev.turn_on(transition=1000)
@ -204,7 +207,7 @@ async def test_turn_on_transition(dev: SmartBulb, mocker):
set_light_state.assert_called_with({"on_off": 0}, transition=100) set_light_state.assert_called_with({"on_off": 0}, transition=100)
@bulb @bulb_iot
async def test_dimmable_brightness_transition(dev: SmartBulb, mocker): async def test_dimmable_brightness_transition(dev: SmartBulb, mocker):
set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") set_light_state = mocker.patch("kasa.SmartBulb.set_light_state")
await dev.set_brightness(10, transition=1000) await dev.set_brightness(10, transition=1000)
@ -233,7 +236,7 @@ async def test_non_dimmable(dev: SmartBulb):
await dev.set_brightness(100) await dev.set_brightness(100)
@bulb @bulb_iot
async def test_ignore_default_not_set_without_color_mode_change_turn_on( async def test_ignore_default_not_set_without_color_mode_change_turn_on(
dev: SmartBulb, mocker dev: SmartBulb, mocker
): ):
@ -248,7 +251,7 @@ async def test_ignore_default_not_set_without_color_mode_change_turn_on(
assert args[2] == {"on_off": 0, "ignore_default": 1} assert args[2] == {"on_off": 0, "ignore_default": 1}
@bulb @bulb_iot
async def test_list_presets(dev: SmartBulb): async def test_list_presets(dev: SmartBulb):
presets = dev.presets presets = dev.presets
assert len(presets) == len(dev.sys_info["preferred_state"]) assert len(presets) == len(dev.sys_info["preferred_state"])
@ -261,7 +264,7 @@ async def test_list_presets(dev: SmartBulb):
assert preset.color_temp == raw["color_temp"] assert preset.color_temp == raw["color_temp"]
@bulb @bulb_iot
async def test_modify_preset(dev: SmartBulb, mocker): async def test_modify_preset(dev: SmartBulb, mocker):
"""Verify that modifying preset calls the and exceptions are raised properly.""" """Verify that modifying preset calls the and exceptions are raised properly."""
if not dev.presets: if not dev.presets:
@ -291,7 +294,7 @@ async def test_modify_preset(dev: SmartBulb, mocker):
) )
@bulb @bulb_iot
@pytest.mark.parametrize( @pytest.mark.parametrize(
("preset", "payload"), ("preset", "payload"),
[ [

View File

@ -15,7 +15,7 @@ from kasa import (
from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps
from kasa.exceptions import AuthenticationException, UnsupportedDeviceException from kasa.exceptions import AuthenticationException, UnsupportedDeviceException
from .conftest import bulb, dimmer, lightstrip, plug, strip from .conftest import bulb, bulb_iot, dimmer, lightstrip, plug, strip
UNSUPPORTED = { UNSUPPORTED = {
"result": { "result": {
@ -46,7 +46,7 @@ async def test_type_detection_plug(dev: SmartDevice):
assert d.device_type == DeviceType.Plug assert d.device_type == DeviceType.Plug
@bulb @bulb_iot
async def test_type_detection_bulb(dev: SmartDevice): async def test_type_detection_bulb(dev: SmartDevice):
d = Discover._get_device_class(dev._last_update)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
# TODO: light_strip is a special case for now to force bulb tests on it # TODO: light_strip is a special case for now to force bulb tests on it