Add support for bulb presets (#379)

* Add support for bulb presets

* Update docs
This commit is contained in:
Teemu R 2022-10-23 00:15:47 +02:00 committed by GitHub
parent b386485ab0
commit f32f7f3925
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 197 additions and 30 deletions

View File

@ -11,6 +11,7 @@ Supported features
* Setting brightness, color temperature, and color (in HSV) * Setting brightness, color temperature, and color (in HSV)
* Querying emeter information * Querying emeter information
* Transitions * Transitions
* Presets
Currently unsupported Currently unsupported
********************* *********************
@ -61,3 +62,7 @@ API documentation
:members: :members:
:inherited-members: :inherited-members:
:undoc-members: :undoc-members:
.. autoclass:: kasa.SmartBulbPreset
:members:
:undoc-members:

View File

@ -17,7 +17,7 @@ from kasa.discover import Discover
from kasa.emeterstatus import EmeterStatus from kasa.emeterstatus import EmeterStatus
from kasa.exceptions import SmartDeviceException from kasa.exceptions import SmartDeviceException
from kasa.protocol import TPLinkSmartHomeProtocol from kasa.protocol import TPLinkSmartHomeProtocol
from kasa.smartbulb import SmartBulb from kasa.smartbulb import SmartBulb, SmartBulbPreset
from kasa.smartdevice import DeviceType, SmartDevice from kasa.smartdevice import DeviceType, SmartDevice
from kasa.smartdimmer import SmartDimmer from kasa.smartdimmer import SmartDimmer
from kasa.smartlightstrip import SmartLightStrip from kasa.smartlightstrip import SmartLightStrip
@ -31,6 +31,7 @@ __all__ = [
"Discover", "Discover",
"TPLinkSmartHomeProtocol", "TPLinkSmartHomeProtocol",
"SmartBulb", "SmartBulb",
"SmartBulbPreset",
"DeviceType", "DeviceType",
"EmeterStatus", "EmeterStatus",
"SmartDevice", "SmartDevice",

View File

@ -216,8 +216,13 @@ async def state(dev: SmartDevice):
click.echo(f"\tLocation: {dev.location}") click.echo(f"\tLocation: {dev.location}")
click.echo(click.style("\n\t== Device specific information ==", bold=True)) click.echo(click.style("\n\t== Device specific information ==", bold=True))
for k, v in dev.state_information.items(): for info_name, info_data in dev.state_information.items():
click.echo(f"\t{k}: {v}") if isinstance(info_data, list):
click.echo(f"\t{info_name}:")
for item in info_data:
click.echo(f"\t\t{item}")
else:
click.echo(f"\t{info_name}: {info_data}")
if dev.has_emeter: if dev.has_emeter:
click.echo(click.style("\n\t== Current State ==", bold=True)) click.echo(click.style("\n\t== Current State ==", bold=True))
@ -538,5 +543,57 @@ def _schedule_list(dev, type):
click.echo(f"No rules of type {type}") click.echo(f"No rules of type {type}")
@cli.group(invoke_without_command=True)
@click.pass_context
async def presets(ctx):
"""List and modify bulb setting presets."""
if ctx.invoked_subcommand is None:
return await ctx.invoke(presets_list)
@presets.command(name="list")
@pass_dev
def presets_list(dev: SmartBulb):
"""List presets."""
if not dev.is_bulb:
click.echo("Presets only supported on bulbs")
return
for preset in dev.presets:
print(preset)
@presets.command(name="modify")
@click.argument("index", type=int)
@click.option("--brightness", type=int)
@click.option("--hue", type=int)
@click.option("--saturation", type=int)
@click.option("--temperature", type=int)
@pass_dev
async def presets_modify(
dev: SmartBulb, index, brightness, hue, saturation, temperature
):
"""Modify a preset."""
for preset in dev.presets:
if preset.index == index:
break
else:
click.echo(f"No preset found for index {index}")
return
if brightness is not None:
preset.brightness = brightness
if hue is not None:
preset.hue = hue
if saturation is not None:
preset.saturation = saturation
if temperature is not None:
preset.color_temp = temperature
click.echo(f"Going to save preset: {preset}")
await dev.save_preset(preset)
if __name__ == "__main__": if __name__ == "__main__":
cli() cli()

View File

@ -1,7 +1,9 @@
"""Module for bulbs (LB*, KL*, KB*).""" """Module for bulbs (LB*, KL*, KB*)."""
import logging import logging
import re import re
from typing import Any, Dict, NamedTuple, cast from typing import Any, Dict, List, NamedTuple, cast
from pydantic import BaseModel
from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage
from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update
@ -22,6 +24,16 @@ class HSV(NamedTuple):
value: int value: int
class SmartBulbPreset(BaseModel):
"""Bulb configuration preset."""
index: int
brightness: int
hue: int
saturation: int
color_temp: int
TPLINK_KELVIN = { TPLINK_KELVIN = {
"LB130": ColorTempRange(2500, 9000), "LB130": ColorTempRange(2500, 9000),
"LB120": ColorTempRange(2700, 6500), "LB120": ColorTempRange(2700, 6500),
@ -50,7 +62,7 @@ class SmartBulb(SmartDevice):
All changes to the device are done using awaitable methods, All changes to the device are done using awaitable methods,
which will not change the cached values, but you must await :func:`update()` separately. which will not change the cached values, but you must await :func:`update()` separately.
Errors reported by the device are raised as :class:`SmartDeviceException`\s, Errors reported by the device are raised as :class:`SmartDeviceExceptions <kasa.exceptions.SmartDeviceException>`,
and should be handled by the user of the library. and should be handled by the user of the library.
Examples: Examples:
@ -68,7 +80,8 @@ class SmartBulb(SmartDevice):
>>> print(bulb.is_on) >>> print(bulb.is_on)
True True
You can use the ``is_``-prefixed properties to check for supported features You can use the ``is_``-prefixed properties to check for supported features:
>>> bulb.is_dimmable >>> bulb.is_dimmable
True True
>>> bulb.is_color >>> bulb.is_color
@ -102,11 +115,26 @@ class SmartBulb(SmartDevice):
HSV(hue=180, saturation=100, value=80) HSV(hue=180, saturation=100, value=80)
If you don't want to use the default transitions, you can pass `transition` in milliseconds. If you don't want to use the default transitions, you can pass `transition` in milliseconds.
This applies to all transitions (turn_on, turn_off, set_hsv, set_color_temp, set_brightness). This applies to all transitions (:func:`turn_on`, :func:`turn_off`, :func:`set_hsv`, :func:`set_color_temp`, :func:`set_brightness`).
The following changes the brightness over a period of 10 seconds: The following changes the brightness over a period of 10 seconds:
>>> asyncio.run(bulb.set_brightness(100, transition=10_000)) >>> asyncio.run(bulb.set_brightness(100, transition=10_000))
Bulb configuration presets can be accessed using the :func:`presets` property:
>>> bulb.presets
[SmartBulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700), SmartBulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0), SmartBulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0), SmartBulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0)]
To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset` instance to :func:`save_preset` method:
>>> preset = bulb.presets[0]
>>> preset.brightness
50
>>> preset.brightness = 100
>>> asyncio.run(bulb.save_preset(preset))
>>> bulb.presets[0].brightness
100
""" """
LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice" LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice"
@ -167,7 +195,7 @@ class SmartBulb(SmartDevice):
@requires_update @requires_update
def light_state(self) -> Dict[str, str]: def light_state(self) -> Dict[str, str]:
"""Query the light state.""" """Query the light state."""
light_state = self._last_update["system"]["get_sysinfo"]["light_state"] light_state = self.sys_info["light_state"]
if light_state is None: if light_state is None:
raise SmartDeviceException( raise SmartDeviceException(
"The device has no light_state or you have not called update()" "The device has no light_state or you have not called update()"
@ -369,6 +397,7 @@ class SmartBulb(SmartDevice):
info["Valid temperature range"] = self.valid_temperature_range info["Valid temperature range"] = self.valid_temperature_range
if self.is_color: if self.is_color:
info["HSV"] = self.hsv info["HSV"] = self.hsv
info["Presets"] = self.presets
return info return info
@ -407,3 +436,25 @@ class SmartBulb(SmartDevice):
return await self._query_helper( return await self._query_helper(
"smartlife.iot.common.system", "set_dev_alias", {"alias": alias} "smartlife.iot.common.system", "set_dev_alias", {"alias": alias}
) )
@property # type: ignore
@requires_update
def presets(self) -> List[SmartBulbPreset]:
"""Return a list of available bulb setting presets."""
return [SmartBulbPreset(**vals) for vals in self.sys_info["preferred_state"]]
async def save_preset(self, preset: SmartBulbPreset):
"""Save a setting preset.
You can either construct a preset object manually, or pass an existing one obtained
obtained using :func:`presets`.
"""
if len(self.presets) == 0:
raise SmartDeviceException("Device does not supported saving presets")
if preset.index >= len(self.presets):
raise SmartDeviceException("Invalid preset index")
return await self._query_helper(
self.LIGHT_SERVICE, "set_preferred_state", preset.dict()
)

View File

@ -391,6 +391,12 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
_LOGGER.debug("New light state: %s", new_state) _LOGGER.debug("New light state: %s", new_state)
self.proto["system"]["get_sysinfo"]["light_state"] = new_state self.proto["system"]["get_sysinfo"]["light_state"] = new_state
def set_preferred_state(self, new_state, *args):
"""Implementation of set_preferred_state."""
self.proto["system"]["get_sysinfo"]["preferred_state"][
new_state["index"]
] = new_state
def light_state(self, x, *args): def light_state(self, x, *args):
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.
@ -425,6 +431,7 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
"smartlife.iot.smartbulb.lightingservice": { "smartlife.iot.smartbulb.lightingservice": {
"get_light_state": light_state, "get_light_state": light_state,
"transition_light_state": transition_light_state, "transition_light_state": transition_light_state,
"set_preferred_state": set_preferred_state,
}, },
"smartlife.iot.lighting_effect": { "smartlife.iot.lighting_effect": {
"set_lighting_effect": set_lighting_effect, "set_lighting_effect": set_lighting_effect,
@ -433,6 +440,7 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
"smartlife.iot.lightStrip": { "smartlife.iot.lightStrip": {
"set_light_state": transition_light_state, "set_light_state": transition_light_state,
"get_light_state": light_state, "get_light_state": light_state,
"set_preferred_state": set_preferred_state,
}, },
"smartlife.iot.common.system": { "smartlife.iot.common.system": {
"set_dev_alias": set_alias, "set_dev_alias": set_alias,

View File

@ -1,6 +1,6 @@
import pytest import pytest
from kasa import DeviceType, SmartDeviceException from kasa import DeviceType, SmartBulb, SmartBulbPreset, SmartDeviceException
from .conftest import ( from .conftest import (
bulb, bulb,
@ -18,7 +18,7 @@ from .newfakes import BULB_SCHEMA, LIGHT_STATE_SCHEMA
@bulb @bulb
async def test_bulb_sysinfo(dev): async def test_bulb_sysinfo(dev: SmartBulb):
assert dev.sys_info is not None assert dev.sys_info is not None
BULB_SCHEMA(dev.sys_info) BULB_SCHEMA(dev.sys_info)
@ -31,7 +31,7 @@ async def test_bulb_sysinfo(dev):
@bulb @bulb
async def test_state_attributes(dev): async def test_state_attributes(dev: SmartBulb):
assert "Brightness" in dev.state_information assert "Brightness" in dev.state_information
assert dev.state_information["Brightness"] == dev.brightness assert dev.state_information["Brightness"] == dev.brightness
@ -40,7 +40,7 @@ async def test_state_attributes(dev):
@bulb @bulb
async def test_light_state_without_update(dev, monkeypatch): async def test_light_state_without_update(dev: SmartBulb, monkeypatch):
with pytest.raises(SmartDeviceException): with pytest.raises(SmartDeviceException):
monkeypatch.setitem( monkeypatch.setitem(
dev._last_update["system"]["get_sysinfo"], "light_state", None dev._last_update["system"]["get_sysinfo"], "light_state", None
@ -49,13 +49,13 @@ async def test_light_state_without_update(dev, monkeypatch):
@bulb @bulb
async def test_get_light_state(dev): async def test_get_light_state(dev: SmartBulb):
LIGHT_STATE_SCHEMA(await dev.get_light_state()) LIGHT_STATE_SCHEMA(await dev.get_light_state())
@color_bulb @color_bulb
@turn_on @turn_on
async def test_hsv(dev, turn_on): async def test_hsv(dev: SmartBulb, turn_on):
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
assert dev.is_color assert dev.is_color
@ -74,7 +74,7 @@ async def test_hsv(dev, turn_on):
@color_bulb @color_bulb
async def test_set_hsv_transition(dev, 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)
@ -86,7 +86,7 @@ async def test_set_hsv_transition(dev, mocker):
@color_bulb @color_bulb
@turn_on @turn_on
async def test_invalid_hsv(dev, turn_on): async def test_invalid_hsv(dev: SmartBulb, turn_on):
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
assert dev.is_color assert dev.is_color
@ -104,13 +104,13 @@ async def test_invalid_hsv(dev, turn_on):
@color_bulb @color_bulb
async def test_color_state_information(dev): async def test_color_state_information(dev: SmartBulb):
assert "HSV" in dev.state_information assert "HSV" in dev.state_information
assert dev.state_information["HSV"] == dev.hsv assert dev.state_information["HSV"] == dev.hsv
@non_color_bulb @non_color_bulb
async def test_hsv_on_non_color(dev): async def test_hsv_on_non_color(dev: SmartBulb):
assert not dev.is_color assert not dev.is_color
with pytest.raises(SmartDeviceException): with pytest.raises(SmartDeviceException):
@ -120,7 +120,7 @@ async def test_hsv_on_non_color(dev):
@variable_temp @variable_temp
async def test_variable_temp_state_information(dev): async def test_variable_temp_state_information(dev: SmartBulb):
assert "Color temperature" in dev.state_information assert "Color temperature" in dev.state_information
assert dev.state_information["Color temperature"] == dev.color_temp assert dev.state_information["Color temperature"] == dev.color_temp
@ -132,7 +132,7 @@ async def test_variable_temp_state_information(dev):
@variable_temp @variable_temp
@turn_on @turn_on
async def test_try_set_colortemp(dev, turn_on): async def test_try_set_colortemp(dev: SmartBulb, turn_on):
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
await dev.set_color_temp(2700) await dev.set_color_temp(2700)
await dev.update() await dev.update()
@ -140,7 +140,7 @@ async def test_try_set_colortemp(dev, turn_on):
@variable_temp @variable_temp
async def test_set_color_temp_transition(dev, 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)
@ -148,7 +148,7 @@ async def test_set_color_temp_transition(dev, mocker):
@variable_temp @variable_temp
async def test_unknown_temp_range(dev, 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")
assert dev.valid_temperature_range == (2700, 5000) assert dev.valid_temperature_range == (2700, 5000)
@ -156,7 +156,7 @@ async def test_unknown_temp_range(dev, monkeypatch, caplog):
@variable_temp @variable_temp
async def test_out_of_range_temperature(dev): async def test_out_of_range_temperature(dev: SmartBulb):
with pytest.raises(ValueError): with pytest.raises(ValueError):
await dev.set_color_temp(1000) await dev.set_color_temp(1000)
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -164,7 +164,7 @@ async def test_out_of_range_temperature(dev):
@non_variable_temp @non_variable_temp
async def test_non_variable_temp(dev): async def test_non_variable_temp(dev: SmartBulb):
with pytest.raises(SmartDeviceException): with pytest.raises(SmartDeviceException):
await dev.set_color_temp(2700) await dev.set_color_temp(2700)
@ -177,7 +177,7 @@ async def test_non_variable_temp(dev):
@dimmable @dimmable
@turn_on @turn_on
async def test_dimmable_brightness(dev, turn_on): async def test_dimmable_brightness(dev: SmartBulb, turn_on):
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
assert dev.is_dimmable assert dev.is_dimmable
@ -194,7 +194,7 @@ async def test_dimmable_brightness(dev, turn_on):
@bulb @bulb
async def test_turn_on_transition(dev, 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)
@ -206,7 +206,7 @@ async def test_turn_on_transition(dev, mocker):
@bulb @bulb
async def test_dimmable_brightness_transition(dev, 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)
@ -214,7 +214,7 @@ async def test_dimmable_brightness_transition(dev, mocker):
@dimmable @dimmable
async def test_invalid_brightness(dev): async def test_invalid_brightness(dev: SmartBulb):
assert dev.is_dimmable assert dev.is_dimmable
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -225,7 +225,7 @@ async def test_invalid_brightness(dev):
@non_dimmable @non_dimmable
async def test_non_dimmable(dev): async def test_non_dimmable(dev: SmartBulb):
assert not dev.is_dimmable assert not dev.is_dimmable
with pytest.raises(SmartDeviceException): with pytest.raises(SmartDeviceException):
@ -235,7 +235,9 @@ async def test_non_dimmable(dev):
@bulb @bulb
async def test_ignore_default_not_set_without_color_mode_change_turn_on(dev, mocker): async def test_ignore_default_not_set_without_color_mode_change_turn_on(
dev: SmartBulb, mocker
):
query_helper = mocker.patch("kasa.SmartBulb._query_helper") query_helper = mocker.patch("kasa.SmartBulb._query_helper")
# When turning back without settings, ignore default to restore the state # When turning back without settings, ignore default to restore the state
await dev.turn_on() await dev.turn_on()
@ -245,3 +247,46 @@ async def test_ignore_default_not_set_without_color_mode_change_turn_on(dev, moc
await dev.turn_off() await dev.turn_off()
args, kwargs = query_helper.call_args_list[1] args, kwargs = query_helper.call_args_list[1]
assert args[2] == {"on_off": 0, "ignore_default": 1} assert args[2] == {"on_off": 0, "ignore_default": 1}
@bulb
async def test_list_presets(dev: SmartBulb):
presets = dev.presets
assert len(presets) == len(dev.sys_info["preferred_state"])
for preset, raw in zip(presets, dev.sys_info["preferred_state"]):
assert preset.index == raw["index"]
assert preset.hue == raw["hue"]
assert preset.brightness == raw["brightness"]
assert preset.saturation == raw["saturation"]
assert preset.color_temp == raw["color_temp"]
@bulb
async def test_modify_preset(dev: SmartBulb, mocker):
"""Verify that modifying preset calls the and exceptions are raised properly."""
if not dev.presets:
pytest.skip("Some strips do not support presets")
data = {
"index": 0,
"brightness": 10,
"hue": 0,
"saturation": 0,
"color_temp": 0,
}
preset = SmartBulbPreset(**data)
assert preset.index == 0
assert preset.brightness == 10
assert preset.hue == 0
assert preset.saturation == 0
assert preset.color_temp == 0
await dev.save_preset(preset)
assert dev.presets[0].brightness == 10
with pytest.raises(SmartDeviceException):
await dev.save_preset(
SmartBulbPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0)
)