From d30d00a3fff140fa2481a7e094bb992d997e66ef Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 19 Jul 2020 22:32:17 +0200 Subject: [PATCH] Add support for lightstrips (KL430) (#74) * Preliminary support for light strips * Add color temperature range and cleanup, thanks to @darkoppressor * Use lightstrip instead of {led,light}strip consistently everywhere * The cli flag is now --lightstrip * add apidocs * Add fixture file for KL430 Signed-off-by: Kevin Wells * Add discovery support, expose effect and length of the strip * use set_light_state instead of transition_light_state * Add tests for lightstrip * add doctests * Add KL430 to supported devices in README Co-authored-by: Kevin Wells --- README.md | 7 +++ docs/source/index.rst | 1 + docs/source/smartlightstrip.rst | 6 +++ kasa/__init__.py | 2 + kasa/cli.py | 16 ++++-- kasa/discover.py | 9 ++++ kasa/smartbulb.py | 4 +- kasa/smartdevice.py | 6 +++ kasa/smartlightstrip.py | 75 ++++++++++++++++++++++++++ kasa/tests/conftest.py | 30 +++++++++-- kasa/tests/fixtures/KL430(US)_1.0.json | 70 ++++++++++++++++++++++++ kasa/tests/newfakes.py | 8 ++- kasa/tests/test_bulb.py | 6 ++- kasa/tests/test_discovery.py | 15 ++++-- kasa/tests/test_readme_examples.py | 9 ++++ 15 files changed, 249 insertions(+), 15 deletions(-) create mode 100644 docs/source/smartlightstrip.rst create mode 100644 kasa/smartlightstrip.py create mode 100644 kasa/tests/fixtures/KL430(US)_1.0.json diff --git a/README.md b/README.md index a4783fee..41e342c4 100644 --- a/README.md +++ b/README.md @@ -112,15 +112,18 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * HS110 ### Power Strips + * HS300 * KP303 ### Wall switches + * HS200 * HS210 * HS220 ### Bulbs + * LB100 * LB110 * LB120 @@ -131,6 +134,10 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * KL120 * KL130 +### Light strips + +* KL430 + **Contributions (be it adding missing features, fixing bugs or improving documentation) are more than welcome, feel free to submit pull requests!** ### Resources diff --git a/docs/source/index.rst b/docs/source/index.rst index 7d59f5f4..59897b39 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -15,3 +15,4 @@ python-kasa documentation smartplug smartdimmer smartstrip + smartlightstrip diff --git a/docs/source/smartlightstrip.rst b/docs/source/smartlightstrip.rst new file mode 100644 index 00000000..b02342ed --- /dev/null +++ b/docs/source/smartlightstrip.rst @@ -0,0 +1,6 @@ +Light strips +============ + +.. autoclass:: kasa.SmartLightStrip + :members: + :undoc-members: diff --git a/kasa/__init__.py b/kasa/__init__.py index e77aa7dd..911a7dc3 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -18,6 +18,7 @@ from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb from kasa.smartdevice import DeviceType, EmeterStatus, SmartDevice from kasa.smartdimmer import SmartDimmer +from kasa.smartlightstrip import SmartLightStrip from kasa.smartplug import SmartPlug from kasa.smartstrip import SmartStrip @@ -35,4 +36,5 @@ __all__ = [ "SmartPlug", "SmartStrip", "SmartDimmer", + "SmartLightStrip", ] diff --git a/kasa/cli.py b/kasa/cli.py index 4c643b13..ed126410 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -7,7 +7,14 @@ from typing import cast import asyncclick as click -from kasa import Discover, SmartBulb, SmartDevice, SmartPlug, SmartStrip +from kasa import ( + Discover, + SmartBulb, + SmartDevice, + SmartLightStrip, + SmartPlug, + SmartStrip, +) click.anyio_backend = "asyncio" @@ -37,10 +44,11 @@ pass_dev = click.make_pass_decorator(SmartDevice) @click.option("-d", "--debug", default=False, is_flag=True) @click.option("--bulb", default=False, is_flag=True) @click.option("--plug", default=False, is_flag=True) +@click.option("--lightstrip", default=False, is_flag=True) @click.option("--strip", default=False, is_flag=True) @click.version_option() @click.pass_context -async def cli(ctx, host, alias, target, debug, bulb, plug, strip): +async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip): """A tool for controlling TP-Link smart home devices.""" # noqa if debug: logging.basicConfig(level=logging.DEBUG) @@ -64,7 +72,7 @@ async def cli(ctx, host, alias, target, debug, bulb, plug, strip): await ctx.invoke(discover) return else: - if not bulb and not plug and not strip: + if not bulb and not plug and not strip and not lightstrip: click.echo("No --strip nor --bulb nor --plug given, discovering..") dev = await Discover.discover_single(host) elif bulb: @@ -73,6 +81,8 @@ async def cli(ctx, host, alias, target, debug, bulb, plug, strip): dev = SmartPlug(host) elif strip: dev = SmartStrip(host) + elif lightstrip: + dev = SmartLightStrip(host) else: click.echo("Unable to detect type, use --strip or --bulb or --plug!") return diff --git a/kasa/discover.py b/kasa/discover.py index ba5d702b..9fe0f451 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -9,6 +9,7 @@ from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb from kasa.smartdevice import SmartDevice, SmartDeviceException from kasa.smartdimmer import SmartDimmer +from kasa.smartlightstrip import SmartLightStrip from kasa.smartplug import SmartPlug from kasa.smartstrip import SmartStrip @@ -227,11 +228,19 @@ class Discover: and "get_dimmer_parameters" in info["smartlife.iot.dimmer"] ): return SmartDimmer + elif "smartplug" in type_.lower() and "children" in sysinfo: return SmartStrip + elif "smartplug" in type_.lower(): + if "children" in sysinfo: + return SmartStrip + return SmartPlug elif "smartbulb" in type_.lower(): + if "length" in sysinfo: # strips have length + return SmartLightStrip + return SmartBulb raise SmartDeviceException("Unknown device type: %s", type_) diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index 5241635a..be81c134 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -17,6 +17,7 @@ TPLINK_KELVIN = { "KL130": (2500, 9000), r"KL120\(EU\)": (2700, 6500), r"KL120\(US\)": (2700, 5000), + r"KL430\(US\)": (2500, 9000), } @@ -89,6 +90,7 @@ class SmartBulb(SmartDevice): """ LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice" + SET_LIGHT_METHOD = "transition_light_state" def __init__(self, host: str) -> None: super().__init__(host=host) @@ -190,7 +192,7 @@ class SmartBulb(SmartDevice): state["ignore_default"] = 1 light_state = await self._query_helper( - self.LIGHT_SERVICE, "transition_light_state", state + self.LIGHT_SERVICE, self.SET_LIGHT_METHOD, state ) return light_state diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 644ff958..c4f71fa6 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -32,6 +32,7 @@ class DeviceType(Enum): Bulb = 2 Strip = 3 Dimmer = 4 + LightStrip = 5 Unknown = -1 @@ -702,6 +703,11 @@ class SmartDevice: """Return True if the device is a bulb.""" return self._device_type == DeviceType.Bulb + @property + def is_light_strip(self) -> bool: + """Return True if the device is a led strip.""" + return self._device_type == DeviceType.LightStrip + @property def is_plug(self) -> bool: """Return True if the device is a plug.""" diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py new file mode 100644 index 00000000..c579fec2 --- /dev/null +++ b/kasa/smartlightstrip.py @@ -0,0 +1,75 @@ +"""Module for light strips (KL430).""" +from typing import Any, Dict + +from .smartbulb import SmartBulb +from .smartdevice import DeviceType, requires_update + + +class SmartLightStrip(SmartBulb): + """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 = SmartLightStrip("127.0.0.1") + >>> asyncio.run(strip.update()) + >>> print(strip.alias) + KL430 pantry lightstrip + + Getting the length of the strip: + + >>> strip.length + 16 + + Currently active effect: + + >>> strip.effect + {'brightness': 50, 'custom': 0, 'enable': 0, 'id': '', 'name': ''} + + .. 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) -> None: + super().__init__(host) + self._device_type = DeviceType.LightStrip + + @property # type: ignore + @requires_update + def length(self) -> int: + """Return length of the strip.""" + return self.sys_info["length"] + + @property # type: ignore + @requires_update + def effect(self) -> Dict: + """Return effect state. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + return self.sys_info["lighting_effect_state"] + + @property # type: ignore + @requires_update + def state_information(self) -> Dict[str, Any]: + """Return strip specific state information.""" + info = super().state_information + + info["Length"] = self.length + + return info diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index f530b5db..69f1f3b7 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -8,7 +8,14 @@ from unittest.mock import MagicMock import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 -from kasa import Discover, SmartBulb, SmartDimmer, SmartPlug, SmartStrip +from kasa import ( + Discover, + SmartBulb, + SmartDimmer, + SmartLightStrip, + SmartPlug, + SmartStrip, +) from .newfakes import FakeTransportProtocol @@ -17,9 +24,11 @@ SUPPORTED_DEVICES = glob.glob( ) -BULBS = {"KL60", "LB100", "LB120", "LB130", "KL120", "KL130"} -VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL130"} -COLOR_BULBS = {"LB130", "KL130"} +LIGHT_STRIPS = {"KL430"} +BULBS = {"KL60", "LB100", "LB120", "LB130", "KL120", "KL130", *LIGHT_STRIPS} +VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL130", "KL430", *LIGHT_STRIPS} +COLOR_BULBS = {"LB130", "KL130", *LIGHT_STRIPS} + PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210"} STRIPS = {"HS107", "HS300", "KP303", "KP400"} @@ -65,9 +74,12 @@ bulb = parametrize("bulbs", BULBS, ids=name_for_filename) plug = parametrize("plugs", PLUGS, ids=name_for_filename) strip = parametrize("strips", STRIPS, ids=name_for_filename) dimmer = parametrize("dimmers", DIMMERS, ids=name_for_filename) +lightstrip = parametrize("lightstrips", LIGHT_STRIPS, ids=name_for_filename) # This ensures that every single file inside fixtures/ is being placed in some category -categorized_fixtures = set(dimmer.args[1] + strip.args[1] + plug.args[1] + bulb.args[1]) +categorized_fixtures = set( + dimmer.args[1] + strip.args[1] + plug.args[1] + bulb.args[1] + lightstrip.args[1] +) diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures) if diff: for file in diff: @@ -105,12 +117,20 @@ def device_for_file(model): for d in STRIPS: if d in model: return SmartStrip + for d in PLUGS: if d in model: return SmartPlug + + # Light strips are recognized also as bulbs, so this has to go first + for d in LIGHT_STRIPS: + if d in model: + return SmartLightStrip + for d in BULBS: if d in model: return SmartBulb + for d in DIMMERS: if d in model: return SmartDimmer diff --git a/kasa/tests/fixtures/KL430(US)_1.0.json b/kasa/tests/fixtures/KL430(US)_1.0.json new file mode 100644 index 00000000..f12e7d50 --- /dev/null +++ b/kasa/tests/fixtures/KL430(US)_1.0.json @@ -0,0 +1,70 @@ +{ + "emeter": { + "err_code": -1, + "err_msg": "module not support" + }, + "smartlife.iot.common.emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 8729, + "total_wh": 21, + "voltage_mv": 0 + } + }, + "smartlife.iot.dimmer": { + "err_code": -1, + "err_msg": "module not support" + }, + "smartlife.iot.smartbulb.lightingservice": { + "err_code": -1, + "err_msg": "module not support" + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "KL430 pantry lightstrip", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Kasa Smart Light Strip, Multicolor", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "length": 16, + "light_state": { + "brightness": 50, + "color_temp": 3630, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "lighting_effect_state": { + "brightness": 50, + "custom": 0, + "enable": 0, + "id": "", + "name": "" + }, + "longitude_i": 0, + "mic_mac": "CC32E5230F55", + "mic_type": "IOT.SMARTBULB", + "model": "KL430(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -56, + "status": "new", + "sw_ver": "1.0.10 Build 200522 Rel.104340" + } + } +} diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index b0d2f29f..55c3e00c 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -355,7 +355,8 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): light_state = self.proto["system"]["get_sysinfo"]["light_state"] # Our tests have light state off, so we simply return the dft_on_state when device is on. _LOGGER.debug("reporting light state: %s", light_state) - if light_state["on_off"]: + # TODO: hack to go around KL430 fixture differences + if light_state["on_off"] and "dft_on_state" in light_state: return light_state["dft_on_state"] else: return light_state @@ -385,6 +386,11 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): "get_light_state": light_state, "transition_light_state": transition_light_state, }, + # lightstrip follows the same payloads but uses different module & method + "smartlife.iot.lightStrip": { + "set_light_state": transition_light_state, + "get_light_state": light_state, + }, "time": { "get_time": { "year": 2017, diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index b8d3ab3c..7d6e45e0 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -24,8 +24,10 @@ async def test_bulb_sysinfo(dev): assert dev.model is not None - assert dev.device_type == DeviceType.Bulb - assert dev.is_bulb + # TODO: remove special handling for lightstrip + if not dev.is_light_strip: + assert dev.device_type == DeviceType.Bulb + assert dev.is_bulb @bulb diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 10de7b99..529ad8d6 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -3,7 +3,7 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import DeviceType, Discover, SmartDevice, SmartDeviceException -from .conftest import bulb, dimmer, plug, pytestmark, strip +from .conftest import bulb, dimmer, lightstrip, plug, pytestmark, strip @plug @@ -16,8 +16,10 @@ async def test_type_detection_plug(dev: SmartDevice): @bulb async def test_type_detection_bulb(dev: SmartDevice): d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") - assert d.is_bulb - assert d.device_type == DeviceType.Bulb + # TODO: light_strip is a special case for now to force bulb tests on it + if not d.is_light_strip: + assert d.is_bulb + assert d.device_type == DeviceType.Bulb @strip @@ -34,6 +36,13 @@ async def test_type_detection_dimmer(dev: SmartDevice): assert d.device_type == DeviceType.Dimmer +@lightstrip +async def test_type_detection_lightstrip(dev: SmartDevice): + d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") + assert d.is_light_strip + assert d.device_type == DeviceType.LightStrip + + async def test_type_unknown(): invalid_info = {"system": {"get_sysinfo": {"type": "nosuchtype"}}} with pytest.raises(SmartDeviceException): diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py index 204a923e..c4d9f693 100644 --- a/kasa/tests/test_readme_examples.py +++ b/kasa/tests/test_readme_examples.py @@ -51,6 +51,15 @@ def test_dimmer_examples(mocker): assert not res["failed"] +def test_lightstrip_examples(mocker): + """Test lightstrip examples.""" + p = get_device_for_file("KL430(US)_1.0.json") + mocker.patch("kasa.smartlightstrip.SmartLightStrip", return_value=p) + mocker.patch("kasa.smartlightstrip.SmartLightStrip.update") + res = xdoctest.doctest_module("kasa.smartlightstrip", "all") + assert not res["failed"] + + @pytest.mark.skipif( sys.version_info < (3, 8), reason="3.7 handles asyncio.run differently" )