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`
This commit is contained in:
nopoz
2026-10-04 06:56:51 -07:00
committed by GitHub
parent 9030179aad
commit dc0745ad93
4 changed files with 114 additions and 0 deletions

View File

@@ -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")

View File

@@ -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",

View File

@@ -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})

View File

@@ -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})