From f32f7f3925718e435eeb1471013f2be13875aa58 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 23 Oct 2022 00:15:47 +0200 Subject: [PATCH] Add support for bulb presets (#379) * Add support for bulb presets * Update docs --- docs/source/smartbulb.rst | 5 +++ kasa/__init__.py | 3 +- kasa/cli.py | 61 ++++++++++++++++++++++++++- kasa/smartbulb.py | 61 ++++++++++++++++++++++++--- kasa/tests/newfakes.py | 8 ++++ kasa/tests/test_bulb.py | 89 +++++++++++++++++++++++++++++---------- 6 files changed, 197 insertions(+), 30 deletions(-) diff --git a/docs/source/smartbulb.rst b/docs/source/smartbulb.rst index 8e02f89f..ec963653 100644 --- a/docs/source/smartbulb.rst +++ b/docs/source/smartbulb.rst @@ -11,6 +11,7 @@ Supported features * Setting brightness, color temperature, and color (in HSV) * Querying emeter information * Transitions +* Presets Currently unsupported ********************* @@ -61,3 +62,7 @@ API documentation :members: :inherited-members: :undoc-members: + +.. autoclass:: kasa.SmartBulbPreset + :members: + :undoc-members: diff --git a/kasa/__init__.py b/kasa/__init__.py index fc798fb3..e17cb2e6 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -17,7 +17,7 @@ from kasa.discover import Discover from kasa.emeterstatus import EmeterStatus from kasa.exceptions import SmartDeviceException from kasa.protocol import TPLinkSmartHomeProtocol -from kasa.smartbulb import SmartBulb +from kasa.smartbulb import SmartBulb, SmartBulbPreset from kasa.smartdevice import DeviceType, SmartDevice from kasa.smartdimmer import SmartDimmer from kasa.smartlightstrip import SmartLightStrip @@ -31,6 +31,7 @@ __all__ = [ "Discover", "TPLinkSmartHomeProtocol", "SmartBulb", + "SmartBulbPreset", "DeviceType", "EmeterStatus", "SmartDevice", diff --git a/kasa/cli.py b/kasa/cli.py index 48a2f342..ea1c0be3 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -216,8 +216,13 @@ async def state(dev: SmartDevice): click.echo(f"\tLocation: {dev.location}") click.echo(click.style("\n\t== Device specific information ==", bold=True)) - for k, v in dev.state_information.items(): - click.echo(f"\t{k}: {v}") + for info_name, info_data in dev.state_information.items(): + 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: 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}") +@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__": cli() diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index f060d256..e14fc0ee 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -1,7 +1,9 @@ """Module for bulbs (LB*, KL*, KB*).""" import logging 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 .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update @@ -22,6 +24,16 @@ class HSV(NamedTuple): value: int +class SmartBulbPreset(BaseModel): + """Bulb configuration preset.""" + + index: int + brightness: int + hue: int + saturation: int + color_temp: int + + TPLINK_KELVIN = { "LB130": ColorTempRange(2500, 9000), "LB120": ColorTempRange(2700, 6500), @@ -50,7 +62,7 @@ class SmartBulb(SmartDevice): All changes to the device are done using awaitable methods, 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 `, and should be handled by the user of the library. Examples: @@ -68,7 +80,8 @@ class SmartBulb(SmartDevice): >>> print(bulb.is_on) 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 True >>> bulb.is_color @@ -102,11 +115,26 @@ class SmartBulb(SmartDevice): HSV(hue=180, saturation=100, value=80) 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: >>> 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" @@ -167,7 +195,7 @@ class SmartBulb(SmartDevice): @requires_update def light_state(self) -> Dict[str, str]: """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: raise SmartDeviceException( "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 if self.is_color: info["HSV"] = self.hsv + info["Presets"] = self.presets return info @@ -407,3 +436,25 @@ class SmartBulb(SmartDevice): return await self._query_helper( "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() + ) diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 904d45c7..18f52e17 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -391,6 +391,12 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): _LOGGER.debug("New light state: %s", 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): 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. @@ -425,6 +431,7 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): "smartlife.iot.smartbulb.lightingservice": { "get_light_state": light_state, "transition_light_state": transition_light_state, + "set_preferred_state": set_preferred_state, }, "smartlife.iot.lighting_effect": { "set_lighting_effect": set_lighting_effect, @@ -433,6 +440,7 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): "smartlife.iot.lightStrip": { "set_light_state": transition_light_state, "get_light_state": light_state, + "set_preferred_state": set_preferred_state, }, "smartlife.iot.common.system": { "set_dev_alias": set_alias, diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 03215442..9012f5b7 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -1,6 +1,6 @@ import pytest -from kasa import DeviceType, SmartDeviceException +from kasa import DeviceType, SmartBulb, SmartBulbPreset, SmartDeviceException from .conftest import ( bulb, @@ -18,7 +18,7 @@ from .newfakes import BULB_SCHEMA, LIGHT_STATE_SCHEMA @bulb -async def test_bulb_sysinfo(dev): +async def test_bulb_sysinfo(dev: SmartBulb): assert dev.sys_info is not None BULB_SCHEMA(dev.sys_info) @@ -31,7 +31,7 @@ async def test_bulb_sysinfo(dev): @bulb -async def test_state_attributes(dev): +async def test_state_attributes(dev: SmartBulb): assert "Brightness" in dev.state_information assert dev.state_information["Brightness"] == dev.brightness @@ -40,7 +40,7 @@ async def test_state_attributes(dev): @bulb -async def test_light_state_without_update(dev, monkeypatch): +async def test_light_state_without_update(dev: SmartBulb, monkeypatch): with pytest.raises(SmartDeviceException): monkeypatch.setitem( dev._last_update["system"]["get_sysinfo"], "light_state", None @@ -49,13 +49,13 @@ async def test_light_state_without_update(dev, monkeypatch): @bulb -async def test_get_light_state(dev): +async def test_get_light_state(dev: SmartBulb): LIGHT_STATE_SCHEMA(await dev.get_light_state()) @color_bulb @turn_on -async def test_hsv(dev, turn_on): +async def test_hsv(dev: SmartBulb, turn_on): await handle_turn_on(dev, turn_on) assert dev.is_color @@ -74,7 +74,7 @@ async def test_hsv(dev, turn_on): @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") await dev.set_hsv(10, 10, 100, transition=1000) @@ -86,7 +86,7 @@ async def test_set_hsv_transition(dev, mocker): @color_bulb @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) assert dev.is_color @@ -104,13 +104,13 @@ async def test_invalid_hsv(dev, turn_on): @color_bulb -async def test_color_state_information(dev): +async def test_color_state_information(dev: SmartBulb): assert "HSV" in dev.state_information assert dev.state_information["HSV"] == dev.hsv @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 with pytest.raises(SmartDeviceException): @@ -120,7 +120,7 @@ async def test_hsv_on_non_color(dev): @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 dev.state_information["Color temperature"] == dev.color_temp @@ -132,7 +132,7 @@ async def test_variable_temp_state_information(dev): @variable_temp @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 dev.set_color_temp(2700) await dev.update() @@ -140,7 +140,7 @@ async def test_try_set_colortemp(dev, turn_on): @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") await dev.set_color_temp(2700, transition=100) @@ -148,7 +148,7 @@ async def test_set_color_temp_transition(dev, mocker): @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") assert dev.valid_temperature_range == (2700, 5000) @@ -156,7 +156,7 @@ async def test_unknown_temp_range(dev, monkeypatch, caplog): @variable_temp -async def test_out_of_range_temperature(dev): +async def test_out_of_range_temperature(dev: SmartBulb): with pytest.raises(ValueError): await dev.set_color_temp(1000) with pytest.raises(ValueError): @@ -164,7 +164,7 @@ async def test_out_of_range_temperature(dev): @non_variable_temp -async def test_non_variable_temp(dev): +async def test_non_variable_temp(dev: SmartBulb): with pytest.raises(SmartDeviceException): await dev.set_color_temp(2700) @@ -177,7 +177,7 @@ async def test_non_variable_temp(dev): @dimmable @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) assert dev.is_dimmable @@ -194,7 +194,7 @@ async def test_dimmable_brightness(dev, turn_on): @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") await dev.turn_on(transition=1000) @@ -206,7 +206,7 @@ async def test_turn_on_transition(dev, mocker): @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") await dev.set_brightness(10, transition=1000) @@ -214,7 +214,7 @@ async def test_dimmable_brightness_transition(dev, mocker): @dimmable -async def test_invalid_brightness(dev): +async def test_invalid_brightness(dev: SmartBulb): assert dev.is_dimmable with pytest.raises(ValueError): @@ -225,7 +225,7 @@ async def test_invalid_brightness(dev): @non_dimmable -async def test_non_dimmable(dev): +async def test_non_dimmable(dev: SmartBulb): assert not dev.is_dimmable with pytest.raises(SmartDeviceException): @@ -235,7 +235,9 @@ async def test_non_dimmable(dev): @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") # When turning back without settings, ignore default to restore the state 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() args, kwargs = query_helper.call_args_list[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) + )