From f9b5003da2eeb9d80ee3594764f278ec83a60e15 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 5 Dec 2023 20:07:10 +0100 Subject: [PATCH] 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 --- devtools/check_readme_vs_fixtures.py | 9 +- kasa/device_factory.py | 4 +- kasa/device_type.py | 1 + kasa/tapo/__init__.py | 3 +- kasa/tapo/tapobulb.py | 267 ++++++++++++++++++ kasa/tests/conftest.py | 111 +++++--- .../fixtures/smart/L530E(EU)_3.0_1.0.6.json | 186 ++++++++++++ kasa/tests/newfakes.py | 7 +- kasa/tests/test_bulb.py | 25 +- kasa/tests/test_discovery.py | 4 +- 10 files changed, 564 insertions(+), 53 deletions(-) create mode 100644 kasa/tapo/tapobulb.py create mode 100644 kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json diff --git a/devtools/check_readme_vs_fixtures.py b/devtools/check_readme_vs_fixtures.py index 2c1e7d95..1f55eea8 100644 --- a/devtools/check_readme_vs_fixtures.py +++ b/devtools/check_readme_vs_fixtures.py @@ -1,5 +1,12 @@ """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: readme = f.read() diff --git a/kasa/device_factory.py b/kasa/device_factory.py index be293ee2..15896e06 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -18,7 +18,7 @@ from .smartlightstrip import SmartLightStrip from .smartplug import SmartPlug from .smartprotocol import SmartProtocol from .smartstrip import SmartStrip -from .tapo.tapoplug import TapoPlug +from .tapo import TapoBulb, TapoPlug DEVICE_TYPE_TO_CLASS = { DeviceType.Plug: SmartPlug, @@ -27,6 +27,7 @@ DEVICE_TYPE_TO_CLASS = { DeviceType.Dimmer: SmartDimmer, DeviceType.LightStrip: SmartLightStrip, DeviceType.TapoPlug: TapoPlug, + DeviceType.TapoBulb: TapoBulb, } _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.""" supported_device_types: dict[str, Type[SmartDevice]] = { "SMART.TAPOPLUG": TapoPlug, + "SMART.TAPOBULB": TapoBulb, "SMART.KASAPLUG": TapoPlug, "IOT.SMARTPLUGSWITCH": SmartPlug, } diff --git a/kasa/device_type.py b/kasa/device_type.py index c8657306..8373d730 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -15,6 +15,7 @@ class DeviceType(Enum): Dimmer = "dimmer" LightStrip = "lightstrip" TapoPlug = "tapoplug" + TapoBulb = "tapobulb" Unknown = "unknown" @staticmethod diff --git a/kasa/tapo/__init__.py b/kasa/tapo/__init__.py index 0ec72f3d..eeb3670c 100644 --- a/kasa/tapo/__init__.py +++ b/kasa/tapo/__init__.py @@ -1,5 +1,6 @@ """Package for supporting tapo-branded and newer kasa devices.""" +from .tapobulb import TapoBulb from .tapodevice import TapoDevice from .tapoplug import TapoPlug -__all__ = ["TapoDevice", "TapoPlug"] +__all__ = ["TapoDevice", "TapoPlug", "TapoBulb"] diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py new file mode 100644 index 00000000..e01c69b0 --- /dev/null +++ b/kasa/tapo/tapobulb.py @@ -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 [] diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 50d2f0de..0d5180cd 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from json import dumps as json_dumps from os.path import basename from pathlib import Path, PurePath -from typing import Dict, Optional +from typing import Dict, Optional, Set from unittest.mock import MagicMock import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 @@ -21,7 +21,7 @@ from kasa import ( SmartStrip, TPLinkSmartHomeProtocol, ) -from kasa.tapo import TapoDevice, TapoPlug +from kasa.tapo import TapoBulb, TapoDevice, TapoPlug from .newfakes import FakeSmartProtocol, FakeTransportProtocol @@ -42,19 +42,44 @@ 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"} -VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL125", "KL130", "KL135", "KL430"} -COLOR_BULBS = {"LB130", "KL125", "KL130", "KL135", *LIGHT_STRIPS} +# Kasa (IOT-prefixed) bulbs +BULBS_IOT_LIGHT_STRIP = {"KL400", "KL430", "KL420"} +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 = { - "KL50", - "KL60", - "LB100", - "LB110", - "KL110", - *VARIABLE_TEMP, - *COLOR_BULBS, - *LIGHT_STRIPS, + *BULBS_IOT, + *BULBS_SMART, } @@ -83,7 +108,7 @@ WITH_EMETER = {"HS110", "HS300", "KP115", "KP125", *BULBS} ALL_DEVICES_IOT = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS) 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) @@ -91,9 +116,12 @@ IP_MODEL_CACHE: Dict[str, str] = {} def idgenerator(paramtuple): - return basename(paramtuple[0]) + ( - "" if paramtuple[1] == "IOT" else "-" + paramtuple[1] - ) + try: + 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): @@ -108,11 +136,15 @@ def filter_model(desc, model_filter, protocol_filter=None): filtered.append((file, protocol)) 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 def parametrize(desc, devices, protocol_filter=None, ids=None): + if ids is None: + ids = idgenerator return pytest.mark.parametrize( "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) no_emeter = parametrize("no emeter", ALL_DEVICES_IOT - WITH_EMETER) -bulb = parametrize("bulbs", BULBS, ids=idgenerator) -plug = parametrize("plugs", PLUGS, ids=idgenerator) -strip = parametrize("strips", STRIPS, ids=idgenerator) -dimmer = parametrize("dimmers", DIMMERS, ids=idgenerator) -lightstrip = parametrize("lightstrips", LIGHT_STRIPS, ids=idgenerator) +bulb = parametrize("bulbs", BULBS, protocol_filter={"SMART", "IOT"}) +plug = parametrize("plugs", PLUGS) +strip = parametrize("strips", STRIPS) +dimmer = parametrize("dimmers", DIMMERS) +lightstrip = parametrize("lightstrips", LIGHT_STRIPS) # bulb types dimmable = parametrize("dimmable", DIMMABLE) non_dimmable = parametrize("non-dimmable", BULBS - DIMMABLE) -variable_temp = parametrize("variable color temp", VARIABLE_TEMP) -non_variable_temp = parametrize("non-variable color temp", BULBS - VARIABLE_TEMP) -color_bulb = parametrize("color bulbs", COLOR_BULBS) -non_color_bulb = parametrize("non-color bulbs", BULBS - COLOR_BULBS) +variable_temp = parametrize( + "variable color temp", BULBS_VARIABLE_TEMP, {"SMART", "IOT"} +) +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( - "plug devices smart", PLUGS_SMART, protocol_filter={"SMART"}, ids=idgenerator -) +color_bulb_iot = parametrize("color bulbs iot", BULBS_COLOR, {"IOT"}) +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( - "devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"}, ids=idgenerator -) -device_iot = parametrize( - "devices iot", ALL_DEVICES_IOT, protocol_filter={"IOT"}, ids=idgenerator + "devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"} ) +device_iot = parametrize("devices iot", ALL_DEVICES_IOT, protocol_filter={"IOT"}) def get_fixture_data(): @@ -197,6 +234,7 @@ def check_categories(): + bulb.args[1] + lightstrip.args[1] + plug_smart.args[1] + + bulb_smart.args[1] ) diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures) if diff: @@ -225,6 +263,9 @@ def device_for_file(model, protocol): for d in PLUGS_SMART: if d in model: return TapoPlug + for d in BULBS_SMART: + if d in model: + return TapoBulb else: for d in STRIPS: if d in model: @@ -235,7 +276,7 @@ def device_for_file(model, protocol): return SmartPlug # 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: return SmartLightStrip diff --git a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json new file mode 100644 index 00000000..06e7cded --- /dev/null +++ b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json @@ -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 + } + } +} diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index c5bf238f..76faae33 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -313,10 +313,13 @@ class FakeSmartTransport(BaseTransport): async def send(self, request: str): request_dict = json_loads(request) method = request_dict["method"] + params = request_dict["params"] if method == "component_nego" or method[:4] == "get_": - return self.info[method] + return {"result": self.info[method]} 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: pass diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index f73a948b..5bacf3cc 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -4,7 +4,9 @@ from kasa import DeviceType, SmartBulb, SmartBulbPreset, SmartDeviceException from .conftest import ( bulb, + bulb_iot, color_bulb, + color_bulb_iot, dimmable, handle_turn_on, non_color_bulb, @@ -12,6 +14,7 @@ from .conftest import ( non_variable_temp, turn_on, variable_temp, + variable_temp_iot, ) 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 -@bulb +@bulb_iot async def test_light_state_without_update(dev: SmartBulb, monkeypatch): with pytest.raises(SmartDeviceException): monkeypatch.setitem( @@ -47,7 +50,7 @@ async def test_light_state_without_update(dev: SmartBulb, monkeypatch): print(dev.light_state) -@bulb +@bulb_iot async def test_get_light_state(dev: SmartBulb): LIGHT_STATE_SCHEMA(await dev.get_light_state()) @@ -72,7 +75,7 @@ async def test_hsv(dev: SmartBulb, turn_on): assert brightness == 1 -@color_bulb +@color_bulb_iot async def test_set_hsv_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") 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 -@variable_temp +@variable_temp_iot async def test_set_color_temp_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") 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) -@variable_temp +@variable_temp_iot async def test_unknown_temp_range(dev: SmartBulb, monkeypatch, caplog): 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") -@bulb +@bulb_iot async def test_turn_on_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") 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) -@bulb +@bulb_iot async def test_dimmable_brightness_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") await dev.set_brightness(10, transition=1000) @@ -233,7 +236,7 @@ async def test_non_dimmable(dev: SmartBulb): await dev.set_brightness(100) -@bulb +@bulb_iot async def test_ignore_default_not_set_without_color_mode_change_turn_on( 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} -@bulb +@bulb_iot async def test_list_presets(dev: SmartBulb): presets = dev.presets 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"] -@bulb +@bulb_iot async def test_modify_preset(dev: SmartBulb, mocker): """Verify that modifying preset calls the and exceptions are raised properly.""" if not dev.presets: @@ -291,7 +294,7 @@ async def test_modify_preset(dev: SmartBulb, mocker): ) -@bulb +@bulb_iot @pytest.mark.parametrize( ("preset", "payload"), [ diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index ea97d94a..72555c7e 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -15,7 +15,7 @@ from kasa import ( from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps 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 = { "result": { @@ -46,7 +46,7 @@ async def test_type_detection_plug(dev: SmartDevice): assert d.device_type == DeviceType.Plug -@bulb +@bulb_iot async def test_type_detection_bulb(dev: SmartDevice): d = Discover._get_device_class(dev._last_update)("localhost") # TODO: light_strip is a special case for now to force bulb tests on it