mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-02-26 20:59:57 +00:00
Add preset support for Tapo cameras (#1615)
This commit is contained in:
@@ -304,6 +304,7 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
|
|||||||
- Hardware: 2.0 (EU) / Firmware: 1.4.3
|
- Hardware: 2.0 (EU) / Firmware: 1.4.3
|
||||||
- **C210**
|
- **C210**
|
||||||
- Hardware: 2.0 / Firmware: 1.3.11
|
- Hardware: 2.0 / Firmware: 1.3.11
|
||||||
|
- Hardware: 1.0 (EU) / Firmware: 1.4.7
|
||||||
- Hardware: 2.0 (EU) / Firmware: 1.4.2
|
- Hardware: 2.0 (EU) / Firmware: 1.4.2
|
||||||
- Hardware: 2.0 (EU) / Firmware: 1.4.3
|
- Hardware: 2.0 (EU) / Firmware: 1.4.3
|
||||||
- **C220**
|
- **C220**
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ class Module(ABC):
|
|||||||
# SMARTCAM only modules
|
# SMARTCAM only modules
|
||||||
Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera")
|
Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera")
|
||||||
LensMask: Final[ModuleName[smartcam.LensMask]] = ModuleName("LensMask")
|
LensMask: Final[ModuleName[smartcam.LensMask]] = ModuleName("LensMask")
|
||||||
|
PanTilt: Final[ModuleName[smartcam.PanTilt]] = ModuleName("PanTilt")
|
||||||
|
|
||||||
# Vacuum modules
|
# Vacuum modules
|
||||||
Clean: Final[ModuleName[smart.Clean]] = ModuleName("Clean")
|
Clean: Final[ModuleName[smart.Clean]] = ModuleName("Clean")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Implementation of time module."""
|
"""Implementation of pan/tilt module."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -10,9 +10,13 @@ DEFAULT_TILT_STEP = 10
|
|||||||
|
|
||||||
|
|
||||||
class PanTilt(SmartCamModule):
|
class PanTilt(SmartCamModule):
|
||||||
"""Implementation of device_local_time."""
|
"""Implementation of pan/tilt module for PTZ cameras."""
|
||||||
|
|
||||||
REQUIRED_COMPONENT = "ptz"
|
REQUIRED_COMPONENT = "ptz"
|
||||||
|
QUERY_GETTER_NAME = "getPresetConfig"
|
||||||
|
QUERY_MODULE_NAME = "preset"
|
||||||
|
QUERY_SECTION_NAMES = ["preset"]
|
||||||
|
|
||||||
_pan_step = DEFAULT_PAN_STEP
|
_pan_step = DEFAULT_PAN_STEP
|
||||||
_tilt_step = DEFAULT_TILT_STEP
|
_tilt_step = DEFAULT_TILT_STEP
|
||||||
|
|
||||||
@@ -88,10 +92,52 @@ class PanTilt(SmartCamModule):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def query(self) -> dict:
|
if self._presets:
|
||||||
"""Query to execute during the update cycle."""
|
self._add_feature(
|
||||||
|
Feature(
|
||||||
|
self._device,
|
||||||
|
"ptz_preset",
|
||||||
|
"PTZ Preset",
|
||||||
|
container=self,
|
||||||
|
attribute_getter="preset",
|
||||||
|
attribute_setter="set_preset",
|
||||||
|
choices_getter=lambda: list(self._presets.keys()),
|
||||||
|
type=Feature.Type.Choice,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _presets(self) -> dict[str, str]:
|
||||||
|
"""Return presets from device data."""
|
||||||
|
if "preset" not in self.data:
|
||||||
|
return {}
|
||||||
|
preset_info = self.data["preset"]
|
||||||
|
return {
|
||||||
|
name: preset_id
|
||||||
|
for preset_id, name in zip(
|
||||||
|
preset_info.get("id", []), preset_info.get("name", []), strict=False
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def preset(self) -> str | None:
|
||||||
|
"""Return first preset name as current value."""
|
||||||
|
return next(iter(self._presets.keys()), None)
|
||||||
|
|
||||||
|
async def set_preset(self, preset: str) -> dict:
|
||||||
|
"""Set preset by name or ID."""
|
||||||
|
preset_id = self._presets.get(preset)
|
||||||
|
if preset_id:
|
||||||
|
return await self.goto_preset(preset_id)
|
||||||
|
if preset in self._presets.values():
|
||||||
|
return await self.goto_preset(preset)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def presets(self) -> dict[str, str]:
|
||||||
|
"""Return available presets as dict of name -> id."""
|
||||||
|
return self._presets
|
||||||
|
|
||||||
async def pan(self, pan: int) -> dict:
|
async def pan(self, pan: int) -> dict:
|
||||||
"""Pan horizontally."""
|
"""Pan horizontally."""
|
||||||
return await self.move(pan=pan, tilt=0)
|
return await self.move(pan=pan, tilt=0)
|
||||||
@@ -105,3 +151,25 @@ class PanTilt(SmartCamModule):
|
|||||||
return await self._device._raw_query(
|
return await self._device._raw_query(
|
||||||
{"do": {"motor": {"move": {"x_coord": str(pan), "y_coord": str(tilt)}}}}
|
{"do": {"motor": {"move": {"x_coord": str(pan), "y_coord": str(tilt)}}}}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def get_presets(self) -> dict:
|
||||||
|
"""Get presets."""
|
||||||
|
return await self._device._raw_query(
|
||||||
|
{"getPresetConfig": {"preset": {"name": ["preset"]}}}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def goto_preset(self, preset_id: str) -> dict:
|
||||||
|
"""Go to preset."""
|
||||||
|
return await self._device._raw_query(
|
||||||
|
{"motorMoveToPreset": {"preset": {"goto_preset": {"id": preset_id}}}}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def save_preset(self, name: str) -> dict:
|
||||||
|
"""Save preset."""
|
||||||
|
return await self._device._raw_query(
|
||||||
|
{
|
||||||
|
"addMotorPostion": { # Note: API has typo in method name
|
||||||
|
"preset": {"set_preset": {"name": name, "save_ptz": "1"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -314,6 +314,8 @@ class FakeSmartCamTransport(BaseTransport):
|
|||||||
elif method in [
|
elif method in [
|
||||||
"addScanChildDeviceList",
|
"addScanChildDeviceList",
|
||||||
"startScanChildDevice",
|
"startScanChildDevice",
|
||||||
|
"motorMoveToPreset",
|
||||||
|
"addMotorPostion", # Note: API has typo in method name
|
||||||
]:
|
]:
|
||||||
return {"result": {}, "error_code": 0}
|
return {"result": {}, "error_code": 0}
|
||||||
|
|
||||||
|
|||||||
1060
tests/fixtures/smartcam/C210(EU)_1.0_1.4.7.json
vendored
Normal file
1060
tests/fixtures/smartcam/C210(EU)_1.0_1.4.7.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
212
tests/smartcam/modules/test_pantilt.py
Normal file
212
tests/smartcam/modules/test_pantilt.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""Tests for PanTilt module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
from kasa import Device, Module
|
||||||
|
|
||||||
|
from ...device_fixtures import parametrize
|
||||||
|
|
||||||
|
pantilt = parametrize(
|
||||||
|
"has pantilt", component_filter="ptz", protocol_filter={"SMARTCAM"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pantilt
|
||||||
|
async def test_pantilt_presets(dev: Device, mocker: MockerFixture):
|
||||||
|
"""Test PanTilt module preset functionality."""
|
||||||
|
pantilt_mod = dev.modules.get(Module.PanTilt)
|
||||||
|
assert pantilt_mod is not None
|
||||||
|
|
||||||
|
presets = pantilt_mod.presets
|
||||||
|
if not presets:
|
||||||
|
pytest.skip("Device has no presets configured")
|
||||||
|
|
||||||
|
assert "ptz_preset" in dev.features
|
||||||
|
preset_feature = dev.features["ptz_preset"]
|
||||||
|
assert preset_feature is not None
|
||||||
|
|
||||||
|
first_preset_name = next(iter(presets.keys()))
|
||||||
|
assert preset_feature.value == first_preset_name
|
||||||
|
|
||||||
|
mock_protocol_query = mocker.patch.object(dev.protocol, "query")
|
||||||
|
mock_protocol_query.return_value = {}
|
||||||
|
|
||||||
|
await preset_feature.set_value(first_preset_name)
|
||||||
|
|
||||||
|
mock_protocol_query.assert_called_once()
|
||||||
|
call_args = mock_protocol_query.call_args
|
||||||
|
assert "motorMoveToPreset" in str(call_args)
|
||||||
|
|
||||||
|
|
||||||
|
@pantilt
|
||||||
|
async def test_pantilt_save_preset(dev: Device, mocker: MockerFixture):
|
||||||
|
"""Test PanTilt save_preset functionality."""
|
||||||
|
pantilt_mod = dev.modules.get(Module.PanTilt)
|
||||||
|
assert pantilt_mod is not None
|
||||||
|
|
||||||
|
mock_protocol_query = mocker.patch.object(dev.protocol, "query")
|
||||||
|
mock_protocol_query.return_value = {}
|
||||||
|
|
||||||
|
await pantilt_mod.save_preset("NewPreset")
|
||||||
|
|
||||||
|
mock_protocol_query.assert_called_with(
|
||||||
|
request={
|
||||||
|
"addMotorPostion": {
|
||||||
|
"preset": {"set_preset": {"name": "NewPreset", "save_ptz": "1"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pantilt
|
||||||
|
async def test_pantilt_invalid_preset(dev: Device, mocker: MockerFixture):
|
||||||
|
"""Test set_preset with invalid preset name raises ValueError."""
|
||||||
|
pantilt_mod = dev.modules.get(Module.PanTilt)
|
||||||
|
assert pantilt_mod is not None
|
||||||
|
|
||||||
|
if not pantilt_mod.presets:
|
||||||
|
pytest.skip("Device has no presets configured")
|
||||||
|
|
||||||
|
preset_feature = dev.features.get("ptz_preset")
|
||||||
|
if not preset_feature:
|
||||||
|
pytest.skip("Device has no preset feature")
|
||||||
|
|
||||||
|
mocker.patch.object(dev.protocol, "query", return_value={})
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Unexpected value"):
|
||||||
|
await preset_feature.set_value("NonExistentPreset12345")
|
||||||
|
|
||||||
|
|
||||||
|
@pantilt
|
||||||
|
async def test_pantilt_move(dev: Device, mocker: MockerFixture):
|
||||||
|
"""Test PanTilt move commands."""
|
||||||
|
pantilt_mod = dev.modules.get(Module.PanTilt)
|
||||||
|
assert pantilt_mod is not None
|
||||||
|
|
||||||
|
mock_protocol_query = mocker.patch.object(dev.protocol, "query")
|
||||||
|
mock_protocol_query.return_value = {}
|
||||||
|
|
||||||
|
await pantilt_mod.pan(30)
|
||||||
|
call_args = mock_protocol_query.call_args
|
||||||
|
assert "motor" in str(call_args)
|
||||||
|
assert "move" in str(call_args)
|
||||||
|
|
||||||
|
mock_protocol_query.reset_mock()
|
||||||
|
|
||||||
|
await pantilt_mod.tilt(10)
|
||||||
|
call_args = mock_protocol_query.call_args
|
||||||
|
assert "motor" in str(call_args)
|
||||||
|
assert "move" in str(call_args)
|
||||||
|
|
||||||
|
|
||||||
|
@pantilt
|
||||||
|
async def test_pantilt_goto_preset(dev: Device, mocker: MockerFixture):
|
||||||
|
"""Test PanTilt goto_preset command."""
|
||||||
|
pantilt_mod = dev.modules.get(Module.PanTilt)
|
||||||
|
assert pantilt_mod is not None
|
||||||
|
|
||||||
|
mock_protocol_query = mocker.patch.object(dev.protocol, "query")
|
||||||
|
mock_protocol_query.return_value = {}
|
||||||
|
|
||||||
|
await pantilt_mod.goto_preset("1")
|
||||||
|
|
||||||
|
mock_protocol_query.assert_called_with(
|
||||||
|
request={"motorMoveToPreset": {"preset": {"goto_preset": {"id": "1"}}}}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pantilt
|
||||||
|
async def test_pantilt_get_presets(dev: Device, mocker: MockerFixture):
|
||||||
|
"""Test PanTilt get_presets command."""
|
||||||
|
pantilt_mod = dev.modules.get(Module.PanTilt)
|
||||||
|
assert pantilt_mod is not None
|
||||||
|
|
||||||
|
mock_protocol_query = mocker.patch.object(dev.protocol, "query")
|
||||||
|
mock_protocol_query.return_value = {}
|
||||||
|
|
||||||
|
await pantilt_mod.get_presets()
|
||||||
|
|
||||||
|
mock_protocol_query.assert_called_with(
|
||||||
|
request={"getPresetConfig": {"preset": {"name": ["preset"]}}}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pantilt
|
||||||
|
async def test_pantilt_set_preset_by_id(dev: Device, mocker: MockerFixture):
|
||||||
|
"""Test set_preset with preset ID instead of name."""
|
||||||
|
pantilt_mod = dev.modules.get(Module.PanTilt)
|
||||||
|
assert pantilt_mod is not None
|
||||||
|
|
||||||
|
if not pantilt_mod.presets:
|
||||||
|
pytest.skip("Device has no presets configured")
|
||||||
|
|
||||||
|
mock_protocol_query = mocker.patch.object(dev.protocol, "query")
|
||||||
|
mock_protocol_query.return_value = {}
|
||||||
|
|
||||||
|
# Get the first preset ID
|
||||||
|
first_preset_id = next(iter(pantilt_mod.presets.values()))
|
||||||
|
|
||||||
|
# Call set_preset with ID instead of name
|
||||||
|
await pantilt_mod.set_preset(first_preset_id)
|
||||||
|
|
||||||
|
mock_protocol_query.assert_called_with(
|
||||||
|
request={
|
||||||
|
"motorMoveToPreset": {"preset": {"goto_preset": {"id": first_preset_id}}}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pantilt
|
||||||
|
async def test_pantilt_set_preset_not_found(dev: Device, mocker: MockerFixture):
|
||||||
|
"""Test set_preset with non-existent preset returns empty dict."""
|
||||||
|
pantilt_mod = dev.modules.get(Module.PanTilt)
|
||||||
|
assert pantilt_mod is not None
|
||||||
|
|
||||||
|
mock_protocol_query = mocker.patch.object(dev.protocol, "query")
|
||||||
|
mock_protocol_query.return_value = {}
|
||||||
|
|
||||||
|
# Call set_preset with a non-existent preset
|
||||||
|
result = await pantilt_mod.set_preset("NonExistentPreset99999")
|
||||||
|
|
||||||
|
# Should return empty dict and not call API
|
||||||
|
assert result == {}
|
||||||
|
mock_protocol_query.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pantilt
|
||||||
|
async def test_pantilt_step_features(dev: Device, mocker: MockerFixture):
|
||||||
|
"""Test pan/tilt step features."""
|
||||||
|
pantilt_mod = dev.modules.get(Module.PanTilt)
|
||||||
|
assert pantilt_mod is not None
|
||||||
|
|
||||||
|
# Test pan_step feature
|
||||||
|
pan_step_feature = dev.features.get("pan_step")
|
||||||
|
assert pan_step_feature is not None
|
||||||
|
assert pan_step_feature.value == 30 # DEFAULT_PAN_STEP
|
||||||
|
|
||||||
|
await pan_step_feature.set_value(45)
|
||||||
|
assert pantilt_mod._pan_step == 45
|
||||||
|
|
||||||
|
# Test tilt_step feature
|
||||||
|
tilt_step_feature = dev.features.get("tilt_step")
|
||||||
|
assert tilt_step_feature is not None
|
||||||
|
assert tilt_step_feature.value == 10 # DEFAULT_TILT_STEP
|
||||||
|
|
||||||
|
await tilt_step_feature.set_value(20)
|
||||||
|
assert pantilt_mod._tilt_step == 20
|
||||||
|
|
||||||
|
|
||||||
|
@pantilt
|
||||||
|
async def test_pantilt_no_presets_in_data(dev: Device, mocker: MockerFixture):
|
||||||
|
"""Test _presets returns empty dict when no preset data."""
|
||||||
|
pantilt_mod = dev.modules.get(Module.PanTilt)
|
||||||
|
assert pantilt_mod is not None
|
||||||
|
|
||||||
|
# Mock data property to return empty dict (no preset key)
|
||||||
|
mocker.patch.object(type(pantilt_mod), "data", property(lambda self: {}))
|
||||||
|
|
||||||
|
assert pantilt_mod._presets == {}
|
||||||
|
assert pantilt_mod.presets == {}
|
||||||
Reference in New Issue
Block a user