From dc0745ad93f23c808fb835a8d11985953819d443 Mon Sep 17 00:00:00 2001 From: nopoz Date: Sun, 4 Oct 2026 06:56:51 -0700 Subject: [PATCH] Add strip segment module for configurable light strip length (#1744) A light strip can be cut shorter than it ships, and the device has to be told how much of it is actually there so effects and colours span the right distance. The Tapo app exposes this as "Light Strip Length". python-kasa has no way to read or change it, so the setting is missing from Home Assistant. This change exposes a new feature backed by `segment` component: - add a `Segment` module: `REQUIRED_COMPONENT = "segment"`, `QUERY_GETTER_NAME = "get_device_segment"` - expose one Number feature, `strip_segments`, range 0-50, `Category.Config` - add a `segments` property and `set_segments()`, which writes `set_device_segment` --- kasa/module.py | 3 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/lightstripsegment.py | 52 +++++++++++++++++ tests/smart/modules/test_lightstripsegment.py | 57 +++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 kasa/smart/modules/lightstripsegment.py create mode 100644 tests/smart/modules/test_lightstripsegment.py 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})