diff --git a/SUPPORTED.md b/SUPPORTED.md index b6911d88..66697138 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -304,6 +304,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 2.0 (EU) / Firmware: 1.4.3 - **C210** - 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.3 - **C220** diff --git a/kasa/module.py b/kasa/module.py index 57ee321f..097bac61 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -170,6 +170,7 @@ class Module(ABC): # SMARTCAM only modules Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera") LensMask: Final[ModuleName[smartcam.LensMask]] = ModuleName("LensMask") + PanTilt: Final[ModuleName[smartcam.PanTilt]] = ModuleName("PanTilt") # Vacuum modules Clean: Final[ModuleName[smart.Clean]] = ModuleName("Clean") diff --git a/kasa/smartcam/modules/pantilt.py b/kasa/smartcam/modules/pantilt.py index fb647f6f..52b2db0d 100644 --- a/kasa/smartcam/modules/pantilt.py +++ b/kasa/smartcam/modules/pantilt.py @@ -1,4 +1,4 @@ -"""Implementation of time module.""" +"""Implementation of pan/tilt module.""" from __future__ import annotations @@ -10,9 +10,13 @@ DEFAULT_TILT_STEP = 10 class PanTilt(SmartCamModule): - """Implementation of device_local_time.""" + """Implementation of pan/tilt module for PTZ cameras.""" REQUIRED_COMPONENT = "ptz" + QUERY_GETTER_NAME = "getPresetConfig" + QUERY_MODULE_NAME = "preset" + QUERY_SECTION_NAMES = ["preset"] + _pan_step = DEFAULT_PAN_STEP _tilt_step = DEFAULT_TILT_STEP @@ -88,10 +92,52 @@ class PanTilt(SmartCamModule): ) ) - def query(self) -> dict: - """Query to execute during the update cycle.""" + if self._presets: + 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 {} + @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: """Pan horizontally.""" return await self.move(pan=pan, tilt=0) @@ -105,3 +151,25 @@ class PanTilt(SmartCamModule): return await self._device._raw_query( {"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"}} + } + } + ) diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index d531e910..5cd291b3 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -314,6 +314,8 @@ class FakeSmartCamTransport(BaseTransport): elif method in [ "addScanChildDeviceList", "startScanChildDevice", + "motorMoveToPreset", + "addMotorPostion", # Note: API has typo in method name ]: return {"result": {}, "error_code": 0} diff --git a/tests/fixtures/smartcam/C210(EU)_1.0_1.4.7.json b/tests/fixtures/smartcam/C210(EU)_1.0_1.4.7.json new file mode 100644 index 00000000..b9e6640b --- /dev/null +++ b/tests/fixtures/smartcam/C210(EU)_1.0_1.4.7.json @@ -0,0 +1,1060 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1763150321", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "normal" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.4.7 Build 250625 Rel.58841n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "9C-A2-F4-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "15", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Tone" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 2 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "panoramicView", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "staticIp", + "version": 2 + }, + { + "name": "timeFormat", + "version": 1 + }, + { + "name": "relayPreConnection", + "version": 1 + }, + { + "name": "hubManage", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-11-28 00:22:51", + "seconds_from_1970": 1764285771 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -49, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "50", + "enabled": "off", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "high", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "room", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C210 1.0 IPC", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "9C-A2-F4-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.4.7 Build 250625 Rel.58841n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1763150321", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "on", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "off" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "on", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "off" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "off", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [ + "1", + "2", + "3" + ], + "name": [ + "Default", + "Door", + "Mid" + ], + "position_pan": [ + "-0.278697", + "-0.277663", + "-0.319545" + ], + "position_tilt": [ + "1.000000", + "-0.040201", + "0.366834" + ], + "position_zoom": [], + "read_only": [ + "0", + "0", + "0" + ] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:1\"]", + "monday": "[\"0000-2400:1\"]", + "saturday": "[\"0000-2400:1\"]", + "sunday": "[\"0000-2400:1\"]", + "thursday": "[\"0000-2400:1\"]", + "tuesday": "[\"0000-2400:1\"]", + "wednesday": "[\"0000-2400:1\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "normal", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "1", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "100", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "1763278827", + "rw_attr": "rw", + "status": "normal", + "total_space": "29.3GB", + "total_space_accurate": "31443156992B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "28.3GB", + "video_total_space_accurate": "30333206528B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+01:00", + "timing_mode": "ntp", + "zone_id": "Europe/Amsterdam" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1382", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561", + "65566" + ], + "minor_stream_support": "0", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2304*1296", + "1920*1080", + "1280*720" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1382", + "bitrate_type": "vbr", + "default_bitrate": "1382", + "encode_type": "H264", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "1920*1080", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8", + "16" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "1.1" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "1.3" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "1.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "1" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } + } +} diff --git a/tests/smartcam/modules/test_pantilt.py b/tests/smartcam/modules/test_pantilt.py new file mode 100644 index 00000000..fb58dc66 --- /dev/null +++ b/tests/smartcam/modules/test_pantilt.py @@ -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 == {}