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 <darkoppressor@gmail.com>

* 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 <darkoppressor@gmail.com>
This commit is contained in:
Teemu R 2020-07-19 22:32:17 +02:00 committed by GitHub
parent 0edbb4301f
commit d30d00a3ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 249 additions and 15 deletions

View File

@ -112,15 +112,18 @@ or the `parse_pcap.py` script contained inside the `devtools` directory.
* HS110 * HS110
### Power Strips ### Power Strips
* HS300 * HS300
* KP303 * KP303
### Wall switches ### Wall switches
* HS200 * HS200
* HS210 * HS210
* HS220 * HS220
### Bulbs ### Bulbs
* LB100 * LB100
* LB110 * LB110
* LB120 * LB120
@ -131,6 +134,10 @@ or the `parse_pcap.py` script contained inside the `devtools` directory.
* KL120 * KL120
* KL130 * 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!** **Contributions (be it adding missing features, fixing bugs or improving documentation) are more than welcome, feel free to submit pull requests!**
### Resources ### Resources

View File

@ -15,3 +15,4 @@ python-kasa documentation
smartplug smartplug
smartdimmer smartdimmer
smartstrip smartstrip
smartlightstrip

View File

@ -0,0 +1,6 @@
Light strips
============
.. autoclass:: kasa.SmartLightStrip
:members:
:undoc-members:

View File

@ -18,6 +18,7 @@ from kasa.protocol import TPLinkSmartHomeProtocol
from kasa.smartbulb import SmartBulb from kasa.smartbulb import SmartBulb
from kasa.smartdevice import DeviceType, EmeterStatus, SmartDevice from kasa.smartdevice import DeviceType, EmeterStatus, SmartDevice
from kasa.smartdimmer import SmartDimmer from kasa.smartdimmer import SmartDimmer
from kasa.smartlightstrip import SmartLightStrip
from kasa.smartplug import SmartPlug from kasa.smartplug import SmartPlug
from kasa.smartstrip import SmartStrip from kasa.smartstrip import SmartStrip
@ -35,4 +36,5 @@ __all__ = [
"SmartPlug", "SmartPlug",
"SmartStrip", "SmartStrip",
"SmartDimmer", "SmartDimmer",
"SmartLightStrip",
] ]

View File

@ -7,7 +7,14 @@ from typing import cast
import asyncclick as click 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" 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("-d", "--debug", default=False, is_flag=True)
@click.option("--bulb", default=False, is_flag=True) @click.option("--bulb", default=False, is_flag=True)
@click.option("--plug", 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.option("--strip", default=False, is_flag=True)
@click.version_option() @click.version_option()
@click.pass_context @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 """A tool for controlling TP-Link smart home devices.""" # noqa
if debug: if debug:
logging.basicConfig(level=logging.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) await ctx.invoke(discover)
return return
else: 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..") click.echo("No --strip nor --bulb nor --plug given, discovering..")
dev = await Discover.discover_single(host) dev = await Discover.discover_single(host)
elif bulb: elif bulb:
@ -73,6 +81,8 @@ async def cli(ctx, host, alias, target, debug, bulb, plug, strip):
dev = SmartPlug(host) dev = SmartPlug(host)
elif strip: elif strip:
dev = SmartStrip(host) dev = SmartStrip(host)
elif lightstrip:
dev = SmartLightStrip(host)
else: else:
click.echo("Unable to detect type, use --strip or --bulb or --plug!") click.echo("Unable to detect type, use --strip or --bulb or --plug!")
return return

View File

@ -9,6 +9,7 @@ from kasa.protocol import TPLinkSmartHomeProtocol
from kasa.smartbulb import SmartBulb from kasa.smartbulb import SmartBulb
from kasa.smartdevice import SmartDevice, SmartDeviceException from kasa.smartdevice import SmartDevice, SmartDeviceException
from kasa.smartdimmer import SmartDimmer from kasa.smartdimmer import SmartDimmer
from kasa.smartlightstrip import SmartLightStrip
from kasa.smartplug import SmartPlug from kasa.smartplug import SmartPlug
from kasa.smartstrip import SmartStrip from kasa.smartstrip import SmartStrip
@ -227,11 +228,19 @@ class Discover:
and "get_dimmer_parameters" in info["smartlife.iot.dimmer"] and "get_dimmer_parameters" in info["smartlife.iot.dimmer"]
): ):
return SmartDimmer return SmartDimmer
elif "smartplug" in type_.lower() and "children" in sysinfo: elif "smartplug" in type_.lower() and "children" in sysinfo:
return SmartStrip return SmartStrip
elif "smartplug" in type_.lower(): elif "smartplug" in type_.lower():
if "children" in sysinfo:
return SmartStrip
return SmartPlug return SmartPlug
elif "smartbulb" in type_.lower(): elif "smartbulb" in type_.lower():
if "length" in sysinfo: # strips have length
return SmartLightStrip
return SmartBulb return SmartBulb
raise SmartDeviceException("Unknown device type: %s", type_) raise SmartDeviceException("Unknown device type: %s", type_)

View File

@ -17,6 +17,7 @@ TPLINK_KELVIN = {
"KL130": (2500, 9000), "KL130": (2500, 9000),
r"KL120\(EU\)": (2700, 6500), r"KL120\(EU\)": (2700, 6500),
r"KL120\(US\)": (2700, 5000), r"KL120\(US\)": (2700, 5000),
r"KL430\(US\)": (2500, 9000),
} }
@ -89,6 +90,7 @@ class SmartBulb(SmartDevice):
""" """
LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice" LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice"
SET_LIGHT_METHOD = "transition_light_state"
def __init__(self, host: str) -> None: def __init__(self, host: str) -> None:
super().__init__(host=host) super().__init__(host=host)
@ -190,7 +192,7 @@ class SmartBulb(SmartDevice):
state["ignore_default"] = 1 state["ignore_default"] = 1
light_state = await self._query_helper( light_state = await self._query_helper(
self.LIGHT_SERVICE, "transition_light_state", state self.LIGHT_SERVICE, self.SET_LIGHT_METHOD, state
) )
return light_state return light_state

View File

@ -32,6 +32,7 @@ class DeviceType(Enum):
Bulb = 2 Bulb = 2
Strip = 3 Strip = 3
Dimmer = 4 Dimmer = 4
LightStrip = 5
Unknown = -1 Unknown = -1
@ -702,6 +703,11 @@ class SmartDevice:
"""Return True if the device is a bulb.""" """Return True if the device is a bulb."""
return self._device_type == DeviceType.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 @property
def is_plug(self) -> bool: def is_plug(self) -> bool:
"""Return True if the device is a plug.""" """Return True if the device is a plug."""

75
kasa/smartlightstrip.py Normal file
View File

@ -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

View File

@ -8,7 +8,14 @@ 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
from kasa import Discover, SmartBulb, SmartDimmer, SmartPlug, SmartStrip from kasa import (
Discover,
SmartBulb,
SmartDimmer,
SmartLightStrip,
SmartPlug,
SmartStrip,
)
from .newfakes import FakeTransportProtocol from .newfakes import FakeTransportProtocol
@ -17,9 +24,11 @@ SUPPORTED_DEVICES = glob.glob(
) )
BULBS = {"KL60", "LB100", "LB120", "LB130", "KL120", "KL130"} LIGHT_STRIPS = {"KL430"}
VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL130"} BULBS = {"KL60", "LB100", "LB120", "LB130", "KL120", "KL130", *LIGHT_STRIPS}
COLOR_BULBS = {"LB130", "KL130"} VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL130", "KL430", *LIGHT_STRIPS}
COLOR_BULBS = {"LB130", "KL130", *LIGHT_STRIPS}
PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210"} PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210"}
STRIPS = {"HS107", "HS300", "KP303", "KP400"} 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) plug = parametrize("plugs", PLUGS, ids=name_for_filename)
strip = parametrize("strips", STRIPS, ids=name_for_filename) strip = parametrize("strips", STRIPS, ids=name_for_filename)
dimmer = parametrize("dimmers", DIMMERS, 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 # 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) diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures)
if diff: if diff:
for file in diff: for file in diff:
@ -105,12 +117,20 @@ def device_for_file(model):
for d in STRIPS: for d in STRIPS:
if d in model: if d in model:
return SmartStrip return SmartStrip
for d in PLUGS: for d in PLUGS:
if d in model: if d in model:
return SmartPlug 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: for d in BULBS:
if d in model: if d in model:
return SmartBulb return SmartBulb
for d in DIMMERS: for d in DIMMERS:
if d in model: if d in model:
return SmartDimmer return SmartDimmer

70
kasa/tests/fixtures/KL430(US)_1.0.json vendored Normal file
View File

@ -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"
}
}
}

View File

@ -355,7 +355,8 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
light_state = self.proto["system"]["get_sysinfo"]["light_state"] 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. # 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) _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"] return light_state["dft_on_state"]
else: else:
return light_state return light_state
@ -385,6 +386,11 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
"get_light_state": light_state, "get_light_state": light_state,
"transition_light_state": transition_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": { "time": {
"get_time": { "get_time": {
"year": 2017, "year": 2017,

View File

@ -24,6 +24,8 @@ async def test_bulb_sysinfo(dev):
assert dev.model is not None assert dev.model is not None
# TODO: remove special handling for lightstrip
if not dev.is_light_strip:
assert dev.device_type == DeviceType.Bulb assert dev.device_type == DeviceType.Bulb
assert dev.is_bulb assert dev.is_bulb

View File

@ -3,7 +3,7 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342
from kasa import DeviceType, Discover, SmartDevice, SmartDeviceException 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 @plug
@ -16,6 +16,8 @@ async def test_type_detection_plug(dev: SmartDevice):
@bulb @bulb
async def test_type_detection_bulb(dev: SmartDevice): async def test_type_detection_bulb(dev: SmartDevice):
d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") d = Discover._get_device_class(dev.protocol.discovery_data)("localhost")
# 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.is_bulb
assert d.device_type == DeviceType.Bulb assert d.device_type == DeviceType.Bulb
@ -34,6 +36,13 @@ async def test_type_detection_dimmer(dev: SmartDevice):
assert d.device_type == DeviceType.Dimmer 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(): async def test_type_unknown():
invalid_info = {"system": {"get_sysinfo": {"type": "nosuchtype"}}} invalid_info = {"system": {"get_sysinfo": {"type": "nosuchtype"}}}
with pytest.raises(SmartDeviceException): with pytest.raises(SmartDeviceException):

View File

@ -51,6 +51,15 @@ def test_dimmer_examples(mocker):
assert not res["failed"] 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( @pytest.mark.skipif(
sys.version_info < (3, 8), reason="3.7 handles asyncio.run differently" sys.version_info < (3, 8), reason="3.7 handles asyncio.run differently"
) )