mirror of
				https://github.com/python-kasa/python-kasa.git
				synced 2025-11-04 06:32:07 +00:00 
			
		
		
		
	Add ADC Value to PIR Enabled Switches
- Add: ADC value reporting to PIR enabled switches, so that it may be used in polling automations. - Add: ADC trigger state value reporting, for simpler automations. - Add: Ability for features to use custom value parsers.
This commit is contained in:
		@@ -133,7 +133,7 @@ async def feature(
 | 
			
		||||
        echo(f"{feat.name} ({name}): {feat.value}{unit}")
 | 
			
		||||
        return feat.value
 | 
			
		||||
 | 
			
		||||
    value = ast.literal_eval(value)
 | 
			
		||||
    value = feat.parse_value(value, ast.literal_eval)
 | 
			
		||||
    echo(f"Changing {name} from {feat.value} to {value}")
 | 
			
		||||
    response = await dev.features[name].set_value(value)
 | 
			
		||||
    await dev.update()
 | 
			
		||||
 
 | 
			
		||||
@@ -163,6 +163,9 @@ class Feature:
 | 
			
		||||
    #: If set, this property will be used to get *choices*.
 | 
			
		||||
    choices_getter: str | Callable[[], list[str]] | None = None
 | 
			
		||||
 | 
			
		||||
    #: Value converter, for when working with complex types.
 | 
			
		||||
    value_parser: str | None = None
 | 
			
		||||
 | 
			
		||||
    def __post_init__(self) -> None:
 | 
			
		||||
        """Handle late-binding of members."""
 | 
			
		||||
        # Populate minimum & maximum values, if range_getter is given
 | 
			
		||||
@@ -270,6 +273,26 @@ class Feature:
 | 
			
		||||
 | 
			
		||||
        return await attribute_setter(value)
 | 
			
		||||
 | 
			
		||||
    def parse_value(
 | 
			
		||||
        self, value: str, fallback: Callable[[str], Any | None] = lambda x: None
 | 
			
		||||
    ) -> Any | None:
 | 
			
		||||
        """Attempt to parse a given string into a value accepted by this feature."""
 | 
			
		||||
        parser = self._get_property_value(self.value_parser)
 | 
			
		||||
        parser = parser if parser else fallback
 | 
			
		||||
        allowed = f"{self.choices}" if self.choices else "Unknown"
 | 
			
		||||
        try:
 | 
			
		||||
            parsed = parser(value)
 | 
			
		||||
            if parsed is None:
 | 
			
		||||
                raise ValueError(
 | 
			
		||||
                    f"Unexpected value for {self.name}: {value}"
 | 
			
		||||
                    f" - allowed: {allowed}"
 | 
			
		||||
                )
 | 
			
		||||
            return parsed
 | 
			
		||||
        except SyntaxError as se:
 | 
			
		||||
            raise ValueError(
 | 
			
		||||
                f"{se.msg} for {self.name}: {value}" f" - allowed: {allowed}",
 | 
			
		||||
            ) from se
 | 
			
		||||
 | 
			
		||||
    def __repr__(self) -> str:
 | 
			
		||||
        try:
 | 
			
		||||
            value = self.value
 | 
			
		||||
@@ -278,7 +301,18 @@ class Feature:
 | 
			
		||||
            return f"Unable to read value ({self.id}): {ex}"
 | 
			
		||||
 | 
			
		||||
        if self.type == Feature.Type.Choice:
 | 
			
		||||
            if not isinstance(choices, list) or value not in choices:
 | 
			
		||||
            if not isinstance(choices, list):
 | 
			
		||||
                _LOGGER.critical(
 | 
			
		||||
                    "Choices are not properly defined for %s (%s). Type: <%s> Value: %s",  # noqa: E501
 | 
			
		||||
                    self.name,
 | 
			
		||||
                    self.id,
 | 
			
		||||
                    type(choices),
 | 
			
		||||
                    choices,
 | 
			
		||||
                )
 | 
			
		||||
                return f"{self.name} ({self.id}): improperly defined choice set."
 | 
			
		||||
            if (value not in choices) and (
 | 
			
		||||
                isinstance(value, Enum) and value.name not in choices
 | 
			
		||||
            ):
 | 
			
		||||
                _LOGGER.warning(
 | 
			
		||||
                    "Invalid value for for choice %s (%s): %s not in %s",
 | 
			
		||||
                    self.name,
 | 
			
		||||
@@ -290,7 +324,13 @@ class Feature:
 | 
			
		||||
                    f"{self.name} ({self.id}): invalid value '{value}' not in {choices}"
 | 
			
		||||
                )
 | 
			
		||||
            value = " ".join(
 | 
			
		||||
                [f"*{choice}*" if choice == value else choice for choice in choices]
 | 
			
		||||
                [
 | 
			
		||||
                    f"*{choice}*"
 | 
			
		||||
                    if choice == value
 | 
			
		||||
                    or (isinstance(value, Enum) and choice == value.name)
 | 
			
		||||
                    else f"{choice}"
 | 
			
		||||
                    for choice in choices
 | 
			
		||||
                ]
 | 
			
		||||
            )
 | 
			
		||||
        if self.precision_hint is not None and isinstance(value, float):
 | 
			
		||||
            value = round(value, self.precision_hint)
 | 
			
		||||
 
 | 
			
		||||
@@ -4,10 +4,11 @@ from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import logging
 | 
			
		||||
from enum import Enum
 | 
			
		||||
from typing import Literal, overload
 | 
			
		||||
 | 
			
		||||
from ...exceptions import KasaException
 | 
			
		||||
from ...feature import Feature
 | 
			
		||||
from ..iotmodule import IotModule
 | 
			
		||||
from ..iotmodule import IotModule, merge
 | 
			
		||||
 | 
			
		||||
_LOGGER = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -20,6 +21,9 @@ class Range(Enum):
 | 
			
		||||
    Near = 2
 | 
			
		||||
    Custom = 3
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Motion(IotModule):
 | 
			
		||||
    """Implements the motion detection (PIR) module."""
 | 
			
		||||
@@ -30,6 +34,11 @@ class Motion(IotModule):
 | 
			
		||||
        if "get_config" not in self.data:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Require that ADC value is also present.
 | 
			
		||||
        if "get_adc_value" not in self.data:
 | 
			
		||||
            _LOGGER.warning("%r initialized, but no get_adc_value in response")
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        if "enable" not in self.config:
 | 
			
		||||
            _LOGGER.warning("%r initialized, but no enable in response")
 | 
			
		||||
            return
 | 
			
		||||
@@ -48,20 +57,78 @@ class Motion(IotModule):
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self._add_feature(
 | 
			
		||||
            Feature(
 | 
			
		||||
                device=self._device,
 | 
			
		||||
                container=self,
 | 
			
		||||
                id="pir_range",
 | 
			
		||||
                name="Motion Sensor Range",
 | 
			
		||||
                icon="mdi:motion-sensor",
 | 
			
		||||
                attribute_getter="range",
 | 
			
		||||
                attribute_setter="_set_range_cli",
 | 
			
		||||
                type=Feature.Type.Choice,
 | 
			
		||||
                choices_getter="ranges",
 | 
			
		||||
                value_parser="parse_range_value",
 | 
			
		||||
                category=Feature.Category.Config,
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self._add_feature(
 | 
			
		||||
            Feature(
 | 
			
		||||
                device=self._device,
 | 
			
		||||
                container=self,
 | 
			
		||||
                id="pir_threshold",
 | 
			
		||||
                name="Motion Sensor Threshold",
 | 
			
		||||
                icon="mdi:motion-sensor",
 | 
			
		||||
                attribute_getter="threshold",
 | 
			
		||||
                attribute_setter="set_threshold",
 | 
			
		||||
                type=Feature.Type.Number,
 | 
			
		||||
                category=Feature.Category.Config,
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self._add_feature(
 | 
			
		||||
            Feature(
 | 
			
		||||
                device=self._device,
 | 
			
		||||
                container=self,
 | 
			
		||||
                id="pir_adc_value",
 | 
			
		||||
                name="PIR ADC Value",
 | 
			
		||||
                icon="mdi:motion-sensor",
 | 
			
		||||
                attribute_getter="adc_value",
 | 
			
		||||
                attribute_setter=None,
 | 
			
		||||
                type=Feature.Type.Sensor,
 | 
			
		||||
                category=Feature.Category.Primary,
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self._add_feature(
 | 
			
		||||
            Feature(
 | 
			
		||||
                device=self._device,
 | 
			
		||||
                container=self,
 | 
			
		||||
                id="pir_triggered",
 | 
			
		||||
                name="PIR Triggered",
 | 
			
		||||
                icon="mdi:motion-sensor",
 | 
			
		||||
                attribute_getter="is_triggered",
 | 
			
		||||
                attribute_setter=None,
 | 
			
		||||
                type=Feature.Type.Sensor,
 | 
			
		||||
                category=Feature.Category.Primary,
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def query(self) -> dict:
 | 
			
		||||
        """Request PIR configuration."""
 | 
			
		||||
        return self.query_for_command("get_config")
 | 
			
		||||
        req = merge(
 | 
			
		||||
            self.query_for_command("get_config"),
 | 
			
		||||
            self.query_for_command("get_adc_value"),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        return req
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def config(self) -> dict:
 | 
			
		||||
        """Return current configuration."""
 | 
			
		||||
        return self.data["get_config"]
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def range(self) -> Range:
 | 
			
		||||
        """Return motion detection range."""
 | 
			
		||||
        return Range(self.config["trigger_index"])
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def enabled(self) -> bool:
 | 
			
		||||
        """Return True if module is enabled."""
 | 
			
		||||
@@ -71,23 +138,99 @@ class Motion(IotModule):
 | 
			
		||||
        """Enable/disable PIR."""
 | 
			
		||||
        return await self.call("set_enable", {"enable": int(state)})
 | 
			
		||||
 | 
			
		||||
    async def set_range(
 | 
			
		||||
        self, *, range: Range | None = None, custom_range: int | None = None
 | 
			
		||||
    ) -> dict:
 | 
			
		||||
        """Set the range for the sensor.
 | 
			
		||||
    def _parse_range_value(self, value: str) -> int | Range | None:
 | 
			
		||||
        """Attempt to parse a range value from the given string."""
 | 
			
		||||
        _LOGGER.debug("Parse Range Value: %s", value)
 | 
			
		||||
        parsed: int | Range | None = None
 | 
			
		||||
        try:
 | 
			
		||||
            parsed = int(value)
 | 
			
		||||
            _LOGGER.debug("Parse Range Value: %s is an integer.", value)
 | 
			
		||||
            return parsed
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            _LOGGER.debug("Parse Range Value: %s is not an integer.", value)
 | 
			
		||||
        value = value.strip().upper()
 | 
			
		||||
        if value in Range._member_names_:
 | 
			
		||||
            _LOGGER.debug("Parse Range Value: %s is an enumeration.", value)
 | 
			
		||||
            parsed = Range[value]
 | 
			
		||||
            return parsed
 | 
			
		||||
        _LOGGER.debug("Parse Range Value: %s is not a Range Value.", value)
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
        :param range: for using standard ranges
 | 
			
		||||
        :param custom_range: range in decimeters, overrides the range parameter
 | 
			
		||||
    @property
 | 
			
		||||
    def ranges(self) -> list[Range]:
 | 
			
		||||
        """Return set of supported range classes."""
 | 
			
		||||
        range_min = 0
 | 
			
		||||
        range_max = len(self.config["array"])
 | 
			
		||||
        valid_ranges = list()
 | 
			
		||||
        for r in Range:
 | 
			
		||||
            if (r.value >= range_min) and (r.value < range_max):
 | 
			
		||||
                valid_ranges.append(r)
 | 
			
		||||
        return valid_ranges
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def range(self) -> Range:
 | 
			
		||||
        """Return motion detection Range."""
 | 
			
		||||
        return Range(self.config["trigger_index"])
 | 
			
		||||
 | 
			
		||||
    @overload
 | 
			
		||||
    async def set_range(self, *, range: Range) -> dict: ...
 | 
			
		||||
 | 
			
		||||
    @overload
 | 
			
		||||
    async def set_range(self, *, range: Literal[Range.Custom], value: int) -> dict: ...
 | 
			
		||||
 | 
			
		||||
    @overload
 | 
			
		||||
    async def set_range(self, *, value: int) -> dict: ...
 | 
			
		||||
 | 
			
		||||
    async def set_range(
 | 
			
		||||
        self, *, range: Range | None = None, value: int | None = None
 | 
			
		||||
    ) -> dict:
 | 
			
		||||
        """Set the Range for the sensor.
 | 
			
		||||
 | 
			
		||||
        :param Range: for using standard Ranges
 | 
			
		||||
        :param custom_Range: Range in decimeters, overrides the Range parameter
 | 
			
		||||
        """
 | 
			
		||||
        if custom_range is not None:
 | 
			
		||||
            payload = {"index": Range.Custom.value, "value": custom_range}
 | 
			
		||||
        if value is not None:
 | 
			
		||||
            if range is not None and range is not Range.Custom:
 | 
			
		||||
                raise KasaException(
 | 
			
		||||
                    "Refusing to set non-custom range %s to value %d." % (range, value)
 | 
			
		||||
                )
 | 
			
		||||
            elif value is None:
 | 
			
		||||
                raise KasaException("Custom range threshold may not be set to None.")
 | 
			
		||||
            payload = {"index": Range.Custom.value, "value": value}
 | 
			
		||||
        elif range is not None:
 | 
			
		||||
            payload = {"index": range.value}
 | 
			
		||||
        else:
 | 
			
		||||
            raise KasaException("Either range or custom_range need to be defined")
 | 
			
		||||
            raise KasaException("Either range or value needs to be defined")
 | 
			
		||||
 | 
			
		||||
        return await self.call("set_trigger_sens", payload)
 | 
			
		||||
 | 
			
		||||
    async def _set_range_cli(self, input: Range | int) -> dict:
 | 
			
		||||
        if isinstance(input, Range):
 | 
			
		||||
            return await self.set_range(range=input)
 | 
			
		||||
        elif isinstance(input, int):
 | 
			
		||||
            return await self.set_range(value=input)
 | 
			
		||||
        else:
 | 
			
		||||
            raise KasaException(
 | 
			
		||||
                "Invalid type: %s given to cli motion set." % (type(input))
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def get_range_threshold(self, range_type: Range) -> int:
 | 
			
		||||
        """Get the distance threshold at which the PIR sensor is will trigger."""
 | 
			
		||||
        if range_type.value < 0 or range_type.value >= len(self.config["array"]):
 | 
			
		||||
            raise KasaException(
 | 
			
		||||
                "Range type is outside the bounds of the configured device ranges."
 | 
			
		||||
            )
 | 
			
		||||
        return int(self.config["array"][range_type.value])
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def threshold(self) -> int:
 | 
			
		||||
        """Return motion detection Range."""
 | 
			
		||||
        return self.get_range_threshold(self.range)
 | 
			
		||||
 | 
			
		||||
    async def set_threshold(self, value: int) -> dict:
 | 
			
		||||
        """Set the distance threshold at which the PIR sensor is will trigger."""
 | 
			
		||||
        return await self.set_range(value=value)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def inactivity_timeout(self) -> int:
 | 
			
		||||
        """Return inactivity timeout in milliseconds."""
 | 
			
		||||
@@ -100,3 +243,13 @@ class Motion(IotModule):
 | 
			
		||||
        to avoid reverting this back to 60 seconds after a period of time.
 | 
			
		||||
        """
 | 
			
		||||
        return await self.call("set_cold_time", {"cold_time": timeout})
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def adc_value(self) -> int:
 | 
			
		||||
        """Return motion adc value."""
 | 
			
		||||
        return int(self.data["get_adc_value"]["value"])
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def is_triggered(self) -> bool:
 | 
			
		||||
        """Return if the motion sensor has been triggered."""
 | 
			
		||||
        return (self.enabled) and (self.adc_value < self.threshold)
 | 
			
		||||
 
 | 
			
		||||
@@ -192,6 +192,7 @@ AMBIENT_MODULE = {
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
MOTION_MODULE = {
 | 
			
		||||
    "get_adc_value": {"value": 50, "err_code": 0},
 | 
			
		||||
    "get_config": {
 | 
			
		||||
        "enable": 0,
 | 
			
		||||
        "version": "1.0",
 | 
			
		||||
@@ -201,7 +202,7 @@ MOTION_MODULE = {
 | 
			
		||||
        "max_adc": 4095,
 | 
			
		||||
        "array": [80, 50, 20, 0],
 | 
			
		||||
        "err_code": 0,
 | 
			
		||||
    }
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
LIGHT_DETAILS = {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,8 @@
 | 
			
		||||
import pytest
 | 
			
		||||
from pytest_mock import MockerFixture
 | 
			
		||||
 | 
			
		||||
from kasa import Module
 | 
			
		||||
from kasa.exceptions import KasaException
 | 
			
		||||
from kasa.iot import IotDimmer
 | 
			
		||||
from kasa.iot.modules.motion import Motion, Range
 | 
			
		||||
 | 
			
		||||
@@ -36,7 +38,14 @@ async def test_motion_range(dev: IotDimmer, mocker: MockerFixture):
 | 
			
		||||
    motion: Motion = dev.modules[Module.IotMotion]
 | 
			
		||||
    query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper")
 | 
			
		||||
 | 
			
		||||
    await motion.set_range(custom_range=123)
 | 
			
		||||
    await motion.set_range(value=123)
 | 
			
		||||
    query_helper.assert_called_with(
 | 
			
		||||
        "smartlife.iot.PIR",
 | 
			
		||||
        "set_trigger_sens",
 | 
			
		||||
        {"index": Range.Custom.value, "value": 123},
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    await motion.set_range(range=Range.Custom, value=123)
 | 
			
		||||
    query_helper.assert_called_with(
 | 
			
		||||
        "smartlife.iot.PIR",
 | 
			
		||||
        "set_trigger_sens",
 | 
			
		||||
@@ -48,6 +57,14 @@ async def test_motion_range(dev: IotDimmer, mocker: MockerFixture):
 | 
			
		||||
        "smartlife.iot.PIR", "set_trigger_sens", {"index": Range.Far.value}
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    with pytest.raises(KasaException, match="Refusing to set non-custom range"):
 | 
			
		||||
        await motion.set_range(range=Range.Near, value=100)  # type: ignore[call-overload]
 | 
			
		||||
 | 
			
		||||
    with pytest.raises(
 | 
			
		||||
        KasaException, match="Either range or value needs to be defined"
 | 
			
		||||
    ):
 | 
			
		||||
        await motion.set_range()  # type: ignore[call-overload]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dimmer_iot
 | 
			
		||||
def test_motion_feature(dev: IotDimmer):
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user