From ddacd188d14a39a807d5025fb6c0574258a86246 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 14 Nov 2024 16:55:34 +0000 Subject: [PATCH 1/5] Add pan tilt module --- kasa/feature.py | 2 + kasa/smartcamera/modules/__init__.py | 2 + kasa/smartcamera/modules/pantilt.py | 59 ++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 kasa/smartcamera/modules/pantilt.py diff --git a/kasa/feature.py b/kasa/feature.py index e61cba07c..aed1b227b 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -231,6 +231,8 @@ def value(self) -> int | float | bool | str | Enum | None: """Return the current value.""" if self.type == Feature.Type.Action: return "" + if self.type is Feature.Type.Number and not self.attribute_getter: + return 0 if self.attribute_getter is None: raise ValueError("Not an action and no attribute_getter set") diff --git a/kasa/smartcamera/modules/__init__.py b/kasa/smartcamera/modules/__init__.py index cf2b43777..61a453b11 100644 --- a/kasa/smartcamera/modules/__init__.py +++ b/kasa/smartcamera/modules/__init__.py @@ -4,6 +4,7 @@ from .childdevice import ChildDevice from .device import DeviceModule from .led import Led +from .pantilt import PanTilt from .time import Time __all__ = [ @@ -11,5 +12,6 @@ "ChildDevice", "DeviceModule", "Led", + "PanTilt", "Time", ] diff --git a/kasa/smartcamera/modules/pantilt.py b/kasa/smartcamera/modules/pantilt.py new file mode 100644 index 000000000..53f659235 --- /dev/null +++ b/kasa/smartcamera/modules/pantilt.py @@ -0,0 +1,59 @@ +"""Implementation of time module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartcameramodule import SmartCameraModule + + +class PanTilt(SmartCameraModule): + """Implementation of device_local_time.""" + + REQUIRED_COMPONENT = "ptz" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + "pan", + "Pan", + container=self, + attribute_setter="do_pan", + type=Feature.Type.Number, + range_getter=lambda: (-360, 360), + ) + ) + self._add_feature( + Feature( + self._device, + "tilt", + "Tilt", + container=self, + attribute_setter="do_tilt", + type=Feature.Type.Number, + range_getter=lambda: (-180, 180), + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + async def do_pan(self, pan: int) -> dict: + """Pan horizontally.""" + return await self._device._raw_query( + {"do": {"motor": {"move": {"x_coord": str(pan), "y_coord": str(0)}}}} + ) + + async def do_tilt(self, tilt: int) -> dict: + """Tilt vertically horizontally.""" + return await self._device._raw_query( + {"do": {"motor": {"move": {"x_coord": str(0), "y_coord": str(tilt)}}}} + ) + + async def do_move(self, pan: int, tilt: int) -> dict: + """Tilt vertically horizontally.""" + return await self._device._raw_query( + {"do": {"motor": {"move": {"x_coord": str(pan), "y_coord": str(tilt)}}}} + ) From 3c473162fcee46c8aaa614433a96df90afc2e559 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 15 Nov 2024 08:56:05 +0000 Subject: [PATCH 2/5] Address review comments --- kasa/smartcamera/modules/pantilt.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/kasa/smartcamera/modules/pantilt.py b/kasa/smartcamera/modules/pantilt.py index 53f659235..c3307fffb 100644 --- a/kasa/smartcamera/modules/pantilt.py +++ b/kasa/smartcamera/modules/pantilt.py @@ -19,7 +19,7 @@ def _initialize_features(self) -> None: "pan", "Pan", container=self, - attribute_setter="do_pan", + attribute_setter="pan", type=Feature.Type.Number, range_getter=lambda: (-360, 360), ) @@ -30,7 +30,7 @@ def _initialize_features(self) -> None: "tilt", "Tilt", container=self, - attribute_setter="do_tilt", + attribute_setter="tilt", type=Feature.Type.Number, range_getter=lambda: (-180, 180), ) @@ -40,20 +40,20 @@ def query(self) -> dict: """Query to execute during the update cycle.""" return {} - async def do_pan(self, pan: int) -> dict: + async def pan(self, pan: int) -> dict: """Pan horizontally.""" return await self._device._raw_query( {"do": {"motor": {"move": {"x_coord": str(pan), "y_coord": str(0)}}}} ) - async def do_tilt(self, tilt: int) -> dict: - """Tilt vertically horizontally.""" + async def tilt(self, tilt: int) -> dict: + """Tilt vertically.""" return await self._device._raw_query( {"do": {"motor": {"move": {"x_coord": str(0), "y_coord": str(tilt)}}}} ) - async def do_move(self, pan: int, tilt: int) -> dict: - """Tilt vertically horizontally.""" + async def move(self, pan: int, tilt: int) -> dict: + """Pan and tilte camera.""" return await self._device._raw_query( {"do": {"motor": {"move": {"x_coord": str(pan), "y_coord": str(tilt)}}}} ) From e67df1d1391c885beaa6fd47958120aeefb5fa57 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 15 Nov 2024 17:54:25 +0000 Subject: [PATCH 3/5] Make features use default step values --- kasa/feature.py | 17 +++--- kasa/smartcamera/modules/pantilt.py | 80 +++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 23 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index aed1b227b..bf48103af 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -71,7 +71,7 @@ from dataclasses import dataclass from enum import Enum, auto from functools import cached_property -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, Coroutine if TYPE_CHECKING: from .device import Device @@ -139,7 +139,7 @@ class Category(Enum): #: Name of the property that allows accessing the value attribute_getter: str | Callable | None = None #: Name of the method that allows changing the value - attribute_setter: str | None = None + attribute_setter: str | Callable[..., Coroutine[Any, Any, Any]] | None = None #: Container storing the data, this overrides 'device' for getters container: Any = None #: Icon suggestion @@ -231,8 +231,6 @@ def value(self) -> int | float | bool | str | Enum | None: """Return the current value.""" if self.type == Feature.Type.Action: return "" - if self.type is Feature.Type.Number and not self.attribute_getter: - return 0 if self.attribute_getter is None: raise ValueError("Not an action and no attribute_getter set") @@ -260,11 +258,16 @@ async def set_value(self, value: int | float | bool | str | Enum | None) -> Any: f" - allowed: {self.choices}" ) - container = self.container if self.container is not None else self.device + if callable(self.attribute_setter): + attribute_setter = self.attribute_setter + else: + container = self.container if self.container is not None else self.device + attribute_setter = getattr(container, self.attribute_setter) + if self.type == Feature.Type.Action: - return await getattr(container, self.attribute_setter)() + return await attribute_setter() - return await getattr(container, self.attribute_setter)(value) + return await attribute_setter(value) def __repr__(self) -> str: try: diff --git a/kasa/smartcamera/modules/pantilt.py b/kasa/smartcamera/modules/pantilt.py index c3307fffb..d1882927a 100644 --- a/kasa/smartcamera/modules/pantilt.py +++ b/kasa/smartcamera/modules/pantilt.py @@ -5,34 +5,86 @@ from ...feature import Feature from ..smartcameramodule import SmartCameraModule +DEFAULT_PAN_STEP = 30 +DEFAULT_TILT_STEP = 10 + class PanTilt(SmartCameraModule): """Implementation of device_local_time.""" REQUIRED_COMPONENT = "ptz" + _pan_step = DEFAULT_PAN_STEP + _tilt_step = DEFAULT_TILT_STEP def _initialize_features(self) -> None: """Initialize features after the initial update.""" + + async def set_pan_step(value: int) -> None: + self._pan_step = value + + async def set_tilt_step(value: int) -> None: + self._tilt_step = value + + self._add_feature( + Feature( + self._device, + "pan_right", + "Pan right", + container=self, + attribute_setter=lambda: self.pan(self._pan_step * -1), + type=Feature.Type.Action, + ) + ) + self._add_feature( + Feature( + self._device, + "pan_left", + "Pan left", + container=self, + attribute_setter=lambda: self.pan(self._pan_step), + type=Feature.Type.Action, + ) + ) self._add_feature( Feature( self._device, - "pan", - "Pan", + "pan_step", + "Pan step", container=self, - attribute_setter="pan", + attribute_getter="_pan_step", + attribute_setter=set_pan_step, type=Feature.Type.Number, - range_getter=lambda: (-360, 360), ) ) self._add_feature( Feature( self._device, - "tilt", - "Tilt", + "tilt_up", + "Tilt up", container=self, - attribute_setter="tilt", + attribute_setter=lambda: self.tilt(self._tilt_step), + type=Feature.Type.Action, + ) + ) + self._add_feature( + Feature( + self._device, + "tilt_down", + "Tilt down", + container=self, + attribute_setter=lambda: self.tilt(self._tilt_step * -1), + type=Feature.Type.Action, + ) + ) + self._add_feature( + Feature( + self._device, + "tilt_step", + "Tilt step", + container=self, + attribute_getter="_tilt_step", + attribute_setter=set_tilt_step, type=Feature.Type.Number, - range_getter=lambda: (-180, 180), ) ) @@ -42,18 +94,14 @@ def query(self) -> dict: async def pan(self, pan: int) -> dict: """Pan horizontally.""" - return await self._device._raw_query( - {"do": {"motor": {"move": {"x_coord": str(pan), "y_coord": str(0)}}}} - ) + return await self.move(pan=pan, tilt=0) async def tilt(self, tilt: int) -> dict: """Tilt vertically.""" - return await self._device._raw_query( - {"do": {"motor": {"move": {"x_coord": str(0), "y_coord": str(tilt)}}}} - ) + return await self.move(pan=0, tilt=tilt) - async def move(self, pan: int, tilt: int) -> dict: - """Pan and tilte camera.""" + async def move(self, *, pan: int, tilt: int) -> dict: + """Pan and tilt camera.""" return await self._device._raw_query( {"do": {"motor": {"move": {"x_coord": str(pan), "y_coord": str(tilt)}}}} ) From 0ee1d0c3b9a0ef246f85d013a5280f6e805e47b8 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 18 Nov 2024 13:13:30 +0000 Subject: [PATCH 4/5] Update feature docstrings --- kasa/feature.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index bf48103af..bbf473758 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -136,9 +136,9 @@ class Category(Enum): name: str #: Type of the feature type: Feature.Type - #: Name of the property that allows accessing the value + #: Callable or name of the property that allows accessing the value attribute_getter: str | Callable | None = None - #: Name of the method that allows changing the value + #: Callable coroutine or name of the method that allows changing the value attribute_setter: str | Callable[..., Coroutine[Any, Any, Any]] | None = None #: Container storing the data, this overrides 'device' for getters container: Any = None From 4137a3b8025de9c86ef5b12023fe457f669fe795 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 18 Nov 2024 15:33:44 +0000 Subject: [PATCH 5/5] Fix tests --- tests/test_feature.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_feature.py b/tests/test_feature.py index 938f9547a..db4ef1400 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -165,12 +165,14 @@ async def test_precision_hint(dummy_feature, precision_hint): ) async def test_feature_setters(dev: Device, mocker: MockerFixture): """Test that all feature setters query something.""" + # setters that do not call set on the device itself. + internal_setters = {"pan_step", "tilt_step"} async def _test_feature(feat, query_mock): if feat.attribute_setter is None: return - expecting_call = True + expecting_call = feat.id not in internal_setters if feat.type == Feature.Type.Number: await feat.set_value(feat.minimum_value)