diff --git a/kasa/module.py b/kasa/module.py index 5d717891..db343810 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -163,6 +163,9 @@ class Module(ABC): PowerProtection: Final[ModuleName[smart.PowerProtection]] = ModuleName( "PowerProtection" ) + LightStripSegment: Final[ModuleName[smart.LightStripSegment]] = ModuleName( + "LightStripSegment" + ) HomeKit: Final[ModuleName[smart.HomeKit]] = ModuleName("HomeKit") Matter: Final[ModuleName[smart.Matter]] = ModuleName("Matter") diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 15404239..0cb3e751 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -29,6 +29,7 @@ from .light import Light from .lighteffect import LightEffect from .lightpreset import LightPreset from .lightstripeffect import LightStripEffect +from .lightstripsegment import LightStripSegment from .lighttransition import LightTransition from .matter import Matter from .mop import Mop @@ -83,6 +84,7 @@ __all__ = [ "SmartLightEffect", "PowerProtection", "OverheatProtection", + "LightStripSegment", "Speaker", "HomeKit", "Matter", diff --git a/kasa/smart/modules/lightstripsegment.py b/kasa/smart/modules/lightstripsegment.py new file mode 100644 index 00000000..ca1c9f22 --- /dev/null +++ b/kasa/smart/modules/lightstripsegment.py @@ -0,0 +1,52 @@ +"""Implementation of the light strip segment module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + +# The device reports no maximum, so it cannot be queried: a run is 5m in 10cm +# segments. Multi-spool products drive every run from this one value, making +# the limit per-run rather than per-model. +SEGMENTS_MIN = 0 +SEGMENTS_MAX = 50 + + +class LightStripSegment(SmartModule): + """Implementation of the configurable light strip length.""" + + REQUIRED_COMPONENT = "segment" + QUERY_GETTER_NAME = "get_device_segment" + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="strip_segments", + name="Strip segments", + container=self, + attribute_getter="segments", + attribute_setter="set_segments", + range_getter=lambda: (SEGMENTS_MIN, SEGMENTS_MAX), + type=Feature.Type.Number, + category=Feature.Category.Config, + ) + ) + + @property + def segments(self) -> int: + """Return the number of 10cm segments the strip is configured for.""" + return self.data["segment"] + + async def set_segments(self, segments: int) -> dict: + """Set the number of 10cm segments the strip is cut to.""" + if not isinstance(segments, int) or not ( + SEGMENTS_MIN <= segments <= SEGMENTS_MAX + ): + raise ValueError( + f"Invalid segment count: {segments} " + f"(valid range: {SEGMENTS_MIN}-{SEGMENTS_MAX})" + ) + + return await self.call("set_device_segment", {"segment": segments}) diff --git a/tests/smart/modules/test_lightstripsegment.py b/tests/smart/modules/test_lightstripsegment.py new file mode 100644 index 00000000..6617eee3 --- /dev/null +++ b/tests/smart/modules/test_lightstripsegment.py @@ -0,0 +1,57 @@ +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice +from kasa.smart.modules.lightstripsegment import SEGMENTS_MAX, SEGMENTS_MIN + +from ...device_fixtures import parametrize + +segment = parametrize( + "has segment", component_filter="segment", protocol_filter={"SMART"} +) + + +@segment +async def test_feature(dev: SmartDevice) -> None: + """Test that the strip length feature is registered and reads the device value.""" + segment_module = dev.modules[Module.LightStripSegment] + + feat = dev.features["strip_segments"] + assert feat.value == segment_module.segments + assert isinstance(feat.value, int) + assert (feat.minimum_value, feat.maximum_value) == (SEGMENTS_MIN, SEGMENTS_MAX) + + +@segment +async def test_set_segments(dev: SmartDevice, mocker: MockerFixture) -> None: + """Test that setting the length calls the device with the right payload.""" + segment_module = dev.modules[Module.LightStripSegment] + call_spy = mocker.spy(segment_module, "call") + + await segment_module.set_segments(47) + + call_spy.assert_called_once_with("set_device_segment", {"segment": 47}) + + +@segment +@pytest.mark.parametrize("value", [SEGMENTS_MIN - 1, SEGMENTS_MAX + 1, 1.5, "47"]) +async def test_set_segments_out_of_range(dev: SmartDevice, value: object) -> None: + """Test that invalid lengths are rejected before reaching the device.""" + segment_module = dev.modules[Module.LightStripSegment] + + with pytest.raises(ValueError, match="Invalid segment count"): + await segment_module.set_segments(value) # type: ignore[arg-type] + + +@segment +async def test_set_segments_via_feature( + dev: SmartDevice, mocker: MockerFixture +) -> None: + """Test that the feature setter reaches the module.""" + segment_module = dev.modules[Module.LightStripSegment] + call_spy = mocker.spy(segment_module, "call") + + await dev.features["strip_segments"].set_value(SEGMENTS_MAX) + + call_spy.assert_called_once_with("set_device_segment", {"segment": SEGMENTS_MAX})