Add Dimmer Configuration Support (#1484)

This commit is contained in:
Ryan Nitcher 2025-02-02 06:48:34 -07:00 committed by GitHub
parent 8259d28b12
commit bff5409d22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 479 additions and 1 deletions

View File

@ -11,7 +11,7 @@ from ..module import Module
from ..protocols import BaseProtocol
from .iotdevice import KasaException, requires_update
from .iotplug import IotPlug
from .modules import AmbientLight, Light, Motion
from .modules import AmbientLight, Dimmer, Light, Motion
class ButtonAction(Enum):
@ -87,6 +87,7 @@ class IotDimmer(IotPlug):
# TODO: need to be figured out what's the best approach to detect support
self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR"))
self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS"))
self.add_module(Module.IotDimmer, Dimmer(self, "smartlife.iot.dimmer"))
self.add_module(Module.Light, Light(self, "light"))
@property # type: ignore

View File

@ -4,6 +4,7 @@ from .ambientlight import AmbientLight
from .antitheft import Antitheft
from .cloud import Cloud
from .countdown import Countdown
from .dimmer import Dimmer
from .emeter import Emeter
from .led import Led
from .light import Light
@ -20,6 +21,7 @@ __all__ = [
"Antitheft",
"Cloud",
"Countdown",
"Dimmer",
"Emeter",
"Led",
"Light",

270
kasa/iot/modules/dimmer.py Normal file
View File

@ -0,0 +1,270 @@
"""Implementation of the dimmer config module found in dimmers."""
from __future__ import annotations
import logging
from datetime import timedelta
from typing import Any, Final, cast
from ...exceptions import KasaException
from ...feature import Feature
from ..iotmodule import IotModule, merge
_LOGGER = logging.getLogger(__name__)
def _td_to_ms(td: timedelta) -> int:
"""
Convert timedelta to integer milliseconds.
Uses default float to integer rounding.
"""
return int(td / timedelta(milliseconds=1))
class Dimmer(IotModule):
"""Implements the dimmer config module."""
THRESHOLD_ABS_MIN: Final[int] = 0
# Strange value, but verified against hardware (KS220).
THRESHOLD_ABS_MAX: Final[int] = 51
FADE_TIME_ABS_MIN: Final[timedelta] = timedelta(seconds=0)
# Arbitrary, but set low intending GENTLE FADE for longer fades.
FADE_TIME_ABS_MAX: Final[timedelta] = timedelta(seconds=10)
GENTLE_TIME_ABS_MIN: Final[timedelta] = timedelta(seconds=0)
# Arbitrary, but reasonable default.
GENTLE_TIME_ABS_MAX: Final[timedelta] = timedelta(seconds=120)
# Verified against KS220.
RAMP_RATE_ABS_MIN: Final[int] = 10
# Verified against KS220.
RAMP_RATE_ABS_MAX: Final[int] = 50
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
device=self._device,
container=self,
id="dimmer_threshold_min",
name="Minimum dimming level",
icon="mdi:lightbulb-on-20",
attribute_getter="threshold_min",
attribute_setter="set_threshold_min",
range_getter=lambda: (self.THRESHOLD_ABS_MIN, self.THRESHOLD_ABS_MAX),
type=Feature.Type.Number,
category=Feature.Category.Config,
)
)
self._add_feature(
Feature(
device=self._device,
container=self,
id="dimmer_fade_off_time",
name="Dimmer fade off time",
icon="mdi:clock-in",
attribute_getter="fade_off_time",
attribute_setter="set_fade_off_time",
range_getter=lambda: (
_td_to_ms(self.FADE_TIME_ABS_MIN),
_td_to_ms(self.FADE_TIME_ABS_MAX),
),
type=Feature.Type.Number,
category=Feature.Category.Config,
)
)
self._add_feature(
Feature(
device=self._device,
container=self,
id="dimmer_fade_on_time",
name="Dimmer fade on time",
icon="mdi:clock-out",
attribute_getter="fade_on_time",
attribute_setter="set_fade_on_time",
range_getter=lambda: (
_td_to_ms(self.FADE_TIME_ABS_MIN),
_td_to_ms(self.FADE_TIME_ABS_MAX),
),
type=Feature.Type.Number,
category=Feature.Category.Config,
)
)
self._add_feature(
Feature(
device=self._device,
container=self,
id="dimmer_gentle_off_time",
name="Dimmer gentle off time",
icon="mdi:clock-in",
attribute_getter="gentle_off_time",
attribute_setter="set_gentle_off_time",
range_getter=lambda: (
_td_to_ms(self.GENTLE_TIME_ABS_MIN),
_td_to_ms(self.GENTLE_TIME_ABS_MAX),
),
type=Feature.Type.Number,
category=Feature.Category.Config,
)
)
self._add_feature(
Feature(
device=self._device,
container=self,
id="dimmer_gentle_on_time",
name="Dimmer gentle on time",
icon="mdi:clock-out",
attribute_getter="gentle_on_time",
attribute_setter="set_gentle_on_time",
range_getter=lambda: (
_td_to_ms(self.GENTLE_TIME_ABS_MIN),
_td_to_ms(self.GENTLE_TIME_ABS_MAX),
),
type=Feature.Type.Number,
category=Feature.Category.Config,
)
)
self._add_feature(
Feature(
device=self._device,
container=self,
id="dimmer_ramp_rate",
name="Dimmer ramp rate",
icon="mdi:clock-fast",
attribute_getter="ramp_rate",
attribute_setter="set_ramp_rate",
range_getter=lambda: (self.RAMP_RATE_ABS_MIN, self.RAMP_RATE_ABS_MAX),
type=Feature.Type.Number,
category=Feature.Category.Config,
)
)
def query(self) -> dict:
"""Request Dimming configuration."""
req = merge(
self.query_for_command("get_dimmer_parameters"),
self.query_for_command("get_default_behavior"),
)
return req
@property
def config(self) -> dict[str, Any]:
"""Return current configuration."""
return self.data["get_dimmer_parameters"]
@property
def threshold_min(self) -> int:
"""Return the minimum dimming level for this dimmer."""
return self.config["minThreshold"]
async def set_threshold_min(self, min: int) -> dict:
"""Set the minimum dimming level for this dimmer.
The value will depend on the luminaries connected to the dimmer.
:param min: The minimum dimming level, in the range 0-51.
"""
if min < self.THRESHOLD_ABS_MIN or min > self.THRESHOLD_ABS_MAX:
raise KasaException(
"Minimum dimming threshold is outside the supported range: "
f"{self.THRESHOLD_ABS_MIN}-{self.THRESHOLD_ABS_MAX}"
)
return await self.call("calibrate_brightness", {"minThreshold": min})
@property
def fade_off_time(self) -> timedelta:
"""Return the fade off animation duration."""
return timedelta(milliseconds=cast(int, self.config["fadeOffTime"]))
async def set_fade_off_time(self, time: int | timedelta) -> dict:
"""Set the duration of the fade off animation.
:param time: The animation duration, in ms.
"""
if isinstance(time, int):
time = timedelta(milliseconds=time)
if time < self.FADE_TIME_ABS_MIN or time > self.FADE_TIME_ABS_MAX:
raise KasaException(
"Fade time is outside the bounds of the supported range:"
f"{self.FADE_TIME_ABS_MIN}-{self.FADE_TIME_ABS_MAX}"
)
return await self.call("set_fade_off_time", {"fadeTime": _td_to_ms(time)})
@property
def fade_on_time(self) -> timedelta:
"""Return the fade on animation duration."""
return timedelta(milliseconds=cast(int, self.config["fadeOnTime"]))
async def set_fade_on_time(self, time: int | timedelta) -> dict:
"""Set the duration of the fade on animation.
:param time: The animation duration, in ms.
"""
if isinstance(time, int):
time = timedelta(milliseconds=time)
if time < self.FADE_TIME_ABS_MIN or time > self.FADE_TIME_ABS_MAX:
raise KasaException(
"Fade time is outside the bounds of the supported range:"
f"{self.FADE_TIME_ABS_MIN}-{self.FADE_TIME_ABS_MAX}"
)
return await self.call("set_fade_on_time", {"fadeTime": _td_to_ms(time)})
@property
def gentle_off_time(self) -> timedelta:
"""Return the gentle fade off animation duration."""
return timedelta(milliseconds=cast(int, self.config["gentleOffTime"]))
async def set_gentle_off_time(self, time: int | timedelta) -> dict:
"""Set the duration of the gentle fade off animation.
:param time: The animation duration, in ms.
"""
if isinstance(time, int):
time = timedelta(milliseconds=time)
if time < self.GENTLE_TIME_ABS_MIN or time > self.GENTLE_TIME_ABS_MAX:
raise KasaException(
"Gentle off time is outside the bounds of the supported range: "
f"{self.GENTLE_TIME_ABS_MIN}-{self.GENTLE_TIME_ABS_MAX}."
)
return await self.call("set_gentle_off_time", {"duration": _td_to_ms(time)})
@property
def gentle_on_time(self) -> timedelta:
"""Return the gentle fade on animation duration."""
return timedelta(milliseconds=cast(int, self.config["gentleOnTime"]))
async def set_gentle_on_time(self, time: int | timedelta) -> dict:
"""Set the duration of the gentle fade on animation.
:param time: The animation duration, in ms.
"""
if isinstance(time, int):
time = timedelta(milliseconds=time)
if time < self.GENTLE_TIME_ABS_MIN or time > self.GENTLE_TIME_ABS_MAX:
raise KasaException(
"Gentle off time is outside the bounds of the supported range: "
f"{self.GENTLE_TIME_ABS_MIN}-{self.GENTLE_TIME_ABS_MAX}."
)
return await self.call("set_gentle_on_time", {"duration": _td_to_ms(time)})
@property
def ramp_rate(self) -> int:
"""Return the rate that the dimmer buttons increment the dimmer level."""
return self.config["rampRate"]
async def set_ramp_rate(self, rate: int) -> dict:
"""Set how quickly to ramp the dimming level when using the dimmer buttons.
:param rate: The rate to increment the dimming level with each press.
"""
if rate < self.RAMP_RATE_ABS_MIN or rate > self.RAMP_RATE_ABS_MAX:
raise KasaException(
"Gentle off time is outside the bounds of the supported range:"
f"{self.RAMP_RATE_ABS_MIN}-{self.RAMP_RATE_ABS_MAX}"
)
return await self.call("set_button_ramp_rate", {"rampRate": rate})

View File

@ -111,6 +111,7 @@ class Module(ABC):
IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient")
IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft")
IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown")
IotDimmer: Final[ModuleName[iot.Dimmer]] = ModuleName("dimmer")
IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion")
IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule")
IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage")

View File

@ -0,0 +1,204 @@
from datetime import timedelta
from typing import Final
import pytest
from pytest_mock import MockerFixture
from kasa import KasaException, Module
from kasa.iot import IotDimmer
from kasa.iot.modules.dimmer import Dimmer
from ...device_fixtures import dimmer_iot
_TD_ONE_MS: Final[timedelta] = timedelta(milliseconds=1)
@dimmer_iot
def test_dimmer_getters(dev: IotDimmer):
assert Module.IotDimmer in dev.modules
dimmer: Dimmer = dev.modules[Module.IotDimmer]
assert dimmer.threshold_min == dimmer.config["minThreshold"]
assert int(dimmer.fade_off_time / _TD_ONE_MS) == dimmer.config["fadeOffTime"]
assert int(dimmer.fade_on_time / _TD_ONE_MS) == dimmer.config["fadeOnTime"]
assert int(dimmer.gentle_off_time / _TD_ONE_MS) == dimmer.config["gentleOffTime"]
assert int(dimmer.gentle_on_time / _TD_ONE_MS) == dimmer.config["gentleOnTime"]
assert dimmer.ramp_rate == dimmer.config["rampRate"]
@dimmer_iot
async def test_dimmer_setters(dev: IotDimmer, mocker: MockerFixture):
dimmer: Dimmer = dev.modules[Module.IotDimmer]
query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper")
test_threshold = 10
await dimmer.set_threshold_min(test_threshold)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "calibrate_brightness", {"minThreshold": test_threshold}
)
test_time = 100
await dimmer.set_fade_off_time(test_time)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "set_fade_off_time", {"fadeTime": test_time}
)
await dimmer.set_fade_on_time(test_time)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "set_fade_on_time", {"fadeTime": test_time}
)
test_time = 1000
await dimmer.set_gentle_off_time(test_time)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "set_gentle_off_time", {"duration": test_time}
)
await dimmer.set_gentle_on_time(test_time)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "set_gentle_on_time", {"duration": test_time}
)
test_rate = 30
await dimmer.set_ramp_rate(test_rate)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "set_button_ramp_rate", {"rampRate": test_rate}
)
@dimmer_iot
async def test_dimmer_setter_min(dev: IotDimmer, mocker: MockerFixture):
dimmer: Dimmer = dev.modules[Module.IotDimmer]
query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper")
test_threshold = dimmer.THRESHOLD_ABS_MIN
await dimmer.set_threshold_min(test_threshold)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "calibrate_brightness", {"minThreshold": test_threshold}
)
test_time = int(dimmer.FADE_TIME_ABS_MIN / _TD_ONE_MS)
await dimmer.set_fade_off_time(test_time)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "set_fade_off_time", {"fadeTime": test_time}
)
await dimmer.set_fade_on_time(test_time)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "set_fade_on_time", {"fadeTime": test_time}
)
test_time = int(dimmer.GENTLE_TIME_ABS_MIN / _TD_ONE_MS)
await dimmer.set_gentle_off_time(test_time)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "set_gentle_off_time", {"duration": test_time}
)
await dimmer.set_gentle_on_time(test_time)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "set_gentle_on_time", {"duration": test_time}
)
test_rate = dimmer.RAMP_RATE_ABS_MIN
await dimmer.set_ramp_rate(test_rate)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "set_button_ramp_rate", {"rampRate": test_rate}
)
@dimmer_iot
async def test_dimmer_setter_max(dev: IotDimmer, mocker: MockerFixture):
dimmer: Dimmer = dev.modules[Module.IotDimmer]
query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper")
test_threshold = dimmer.THRESHOLD_ABS_MAX
await dimmer.set_threshold_min(test_threshold)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "calibrate_brightness", {"minThreshold": test_threshold}
)
test_time = int(dimmer.FADE_TIME_ABS_MAX / _TD_ONE_MS)
await dimmer.set_fade_off_time(test_time)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "set_fade_off_time", {"fadeTime": test_time}
)
await dimmer.set_fade_on_time(test_time)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "set_fade_on_time", {"fadeTime": test_time}
)
test_time = int(dimmer.GENTLE_TIME_ABS_MAX / _TD_ONE_MS)
await dimmer.set_gentle_off_time(test_time)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "set_gentle_off_time", {"duration": test_time}
)
await dimmer.set_gentle_on_time(test_time)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "set_gentle_on_time", {"duration": test_time}
)
test_rate = dimmer.RAMP_RATE_ABS_MAX
await dimmer.set_ramp_rate(test_rate)
query_helper.assert_called_with(
"smartlife.iot.dimmer", "set_button_ramp_rate", {"rampRate": test_rate}
)
@dimmer_iot
async def test_dimmer_setters_min_oob(dev: IotDimmer, mocker: MockerFixture):
dimmer: Dimmer = dev.modules[Module.IotDimmer]
query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper")
test_threshold = dimmer.THRESHOLD_ABS_MIN - 1
with pytest.raises(KasaException):
await dimmer.set_threshold_min(test_threshold)
query_helper.assert_not_called()
test_time = dimmer.FADE_TIME_ABS_MIN - _TD_ONE_MS
with pytest.raises(KasaException):
await dimmer.set_fade_off_time(test_time)
query_helper.assert_not_called()
with pytest.raises(KasaException):
await dimmer.set_fade_on_time(test_time)
query_helper.assert_not_called()
test_time = dimmer.GENTLE_TIME_ABS_MIN - _TD_ONE_MS
with pytest.raises(KasaException):
await dimmer.set_gentle_off_time(test_time)
query_helper.assert_not_called()
with pytest.raises(KasaException):
await dimmer.set_gentle_on_time(test_time)
query_helper.assert_not_called()
test_rate = dimmer.RAMP_RATE_ABS_MIN - 1
with pytest.raises(KasaException):
await dimmer.set_ramp_rate(test_rate)
query_helper.assert_not_called()
@dimmer_iot
async def test_dimmer_setters_max_oob(dev: IotDimmer, mocker: MockerFixture):
dimmer: Dimmer = dev.modules[Module.IotDimmer]
query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper")
test_threshold = dimmer.THRESHOLD_ABS_MAX + 1
with pytest.raises(KasaException):
await dimmer.set_threshold_min(test_threshold)
query_helper.assert_not_called()
test_time = dimmer.FADE_TIME_ABS_MAX + _TD_ONE_MS
with pytest.raises(KasaException):
await dimmer.set_fade_off_time(test_time)
query_helper.assert_not_called()
with pytest.raises(KasaException):
await dimmer.set_fade_on_time(test_time)
query_helper.assert_not_called()
test_time = dimmer.GENTLE_TIME_ABS_MAX + _TD_ONE_MS
with pytest.raises(KasaException):
await dimmer.set_gentle_off_time(test_time)
query_helper.assert_not_called()
with pytest.raises(KasaException):
await dimmer.set_gentle_on_time(test_time)
query_helper.assert_not_called()
test_rate = dimmer.RAMP_RATE_ABS_MAX + 1
with pytest.raises(KasaException):
await dimmer.set_ramp_rate(test_rate)
query_helper.assert_not_called()