Skill: jftech/jftech-open-pro-ptz-control (version 1.0.0)

--- name: jf-open-pro-ptz-control description: 面向开发者杰峰设备 API 工具，可支持设备状态、方向控制、一键遮蔽、变倍和聚焦、预置位及巡航计划管理功能等。触发词：云台控制、设备状态、方向转动、预置位、巡航计划、一键遮蔽。 # 必需凭证声明 - 平台元数据 credentials: required: - name: JF_UUID type: string description: 杰峰开放平台用户唯一标识 source: https://open.jftech.com/ - name: JF_APPKEY type: string description: 杰峰开放平台应用 Key source: https://open.jftech.com/ - name: JF_APPSECRET type: string description: 杰峰开放平台应用密钥 source: https://open.jftech.com/ - name: JF_MOVECARD type: integer description: 签名算法偏移量 (0-9) source: https://open.jftech.com/ optional: - name: JF_SN type: string description: 设备序列号 - name: JF_USERNAME type: string description: 设备用户名 default: admin - name: JF_PASSWORD type: string description: 设备密码 - name: JF_ENDPOINT type: string description: API 端点 default: api.jftechws.com # 网络端点声明 endpoints: - url: https://api.jftechws.com description: 杰峰官方 API (国际) - url: https://api-cn.jftech.com description: 杰峰官方 API (中国大陆) # 安全声明 security: credentials_required: true env_vars_only: true # 仅支持环境变量 language: python # 仅支持 Python --- # JF Open Pro PTZ Control > **面向开发者杰峰设备云台控制工具 (Python)** > > 支持设备状态查询、方向控制、一键遮蔽、变倍和聚焦、预置位及巡航计划管理功能。 --- ## 🔒 安全说明 **仅支持环境变量存储凭据** | 方式 | 支持 | 说明 | |------|------|------| | **环境变量** | ✅ 支持 | 不会在进程列表中暴露，不会执行本地代码 | | **命令行参数** | ❌ 不支持 | 避免凭据泄露风险 | | **配置文件** | ❌ 不支持 | 避免代码执行风险 | --- ## 🚀 快速开始 ### 设置环境变量 ```bash export JF_UUID="your-uuid" # 开放平台用户唯一标识 export JF_APPKEY="your-appkey" # 开放平台应用 Key export JF_APPSECRET="your-appsecret" # 开放平台应用密钥 export JF_MOVECARD=5 # 签名算法偏移量 (0-9) export JF_SN="your-device-sn" # 设备序列号 export JF_USERNAME="admin" # 设备用户名（可选，默认：admin） export JF_PASSWORD="your-password" # 设备密码（可选） ``` ### 使用技能 ```bash # 查询设备状态 python scripts/jf_open_pro_ptz_control.py status # 云台方向控制（向上转动） python scripts/jf_open_pro_ptz_control.py ptz --direction up --action start # 停止转动 python scripts/jf_open_pro_ptz_control.py ptz --direction up --action stop # 一键遮蔽（开启） python scripts/jf_open_pro_ptz_control.py mask --enable true # 一键遮蔽（关闭） python scripts/jf_open_pro_ptz_control.py mask --enable false # 变倍控制（放大） python scripts/jf_open_pro_ptz_control.py zoom --zoom-command ZoomTile --action start python scripts/jf_open_pro_ptz_control.py zoom --zoom-command ZoomTile --action stop # 设置预置点 python scripts/jf_open_pro_ptz_control.py preset --preset-command set --id 1 --name "门口" # 转到预置点 python scripts/jf_open_pro_ptz_control.py preset --preset-command goto --id 1 # 获取预置点列表 python scripts/jf_open_pro_ptz_control.py preset --preset-command list # 添加巡航点 python scripts/jf_open_pro_ptz_control.py tour --tour-command add --tour-id 0 --preset-id 1 # 启动巡航 python scripts/jf_open_pro_ptz_control.py tour --tour-command start --tour-id 0 # 获取巡航列表 python scripts/jf_open_pro_ptz_control.py tour --tour-command list ``` --- ## 📋 环境变量 | 变量名 | 说明 | 必需 | 默认值 | |--------|------|------|--------| | `JF_UUID` | 开放平台用户唯一标识 | 是 | - | | `JF_APPKEY` | 开放平台应用 Key | 是 | - | | `JF_APPSECRET` | 开放平台应用密钥 | 是 | - | | `JF_MOVECARD` | 签名算法偏移量 (0-9) | 是 | - | | `JF_SN` | 设备序列号 | 是 | - | | `JF_USERNAME` | 设备用户名 | 否 | `admin` | | `JF_PASSWORD` | 设备密码 | 否 | - | | `JF_ENDPOINT` | API 端点 | 否 | `api.jftechws.com` | --- ## 🛠️ 功能 ### 1. 设备状态查询 查询设备在线状态、休眠状态、认证状态、设备 WAN IP 等。 ```bash python scripts/jf_open_pro_ptz_control.py status ``` **返回信息：** - 设备在线状态（online/notfound） - 低功耗设备休眠状态 - 认证状态 - 设备 WAN IP --- ### 2. 方向控制 (PTZ) 云台支持 8 个方向转动： | 方向 | 参数值 | |------|--------| | 上 | `up` | | 下 | `down` | | 左 | `left` | | 右 | `right` | | 左上 | `leftup` | | 左下 | `leftdown` | | 右上 | `rightup` | | 右下 | `rightdown` | **使用示例：** ```bash # 开始向上转动（速度 5） python scripts/jf_open_pro_ptz_control.py ptz --direction up --action start --step 5 # 停止转动 python scripts/jf_open_pro_ptz_control.py ptz --direction up --action stop ``` **参数说明：** - `--direction`: 方向（up/down/left/right/leftup/leftdown/rightup/rightdown） - `--action`: 动作（start/stop） - `--step`: 速度（1-8，1 最慢，8 最快，默认：5） ⚠️ **重要**: 必须先发送 start 再发送 stop，建议间隔 500ms。如果不发送 stop，设备会一直转动到最大角度。 --- ### 3. 一键遮蔽 (Mask) 开启后摄像头转至最下方然后转至最右侧，同时关闭视频预览和录像。 ```bash # 开启遮蔽 python scripts/jf_open_pro_ptz_control.py mask --enable true # 关闭遮蔽 python scripts/jf_open_pro_ptz_control.py mask --enable false ``` --- ### 4. 变倍和聚焦控制 (Zoom/Focus) 支持变倍（Zoom）和聚焦（Focus）操作： | 功能 | 参数值 | 说明 | |------|--------|------| | 变倍 - | `ZoomWide` | 缩小（广角） | | 变倍 + | `ZoomTile` | 放大（长焦） | | 聚焦 - | `FocusFar` | 聚焦远处 | | 聚焦 + | `FocusNear` | 聚焦近处 | | 光圈 - | `IrisSmall` | 缩小光圈 | | 光圈 + | `IrisLarge` | 放大光圈 | **使用示例：** ```bash # 开始变倍 +（放大） python scripts/jf_open_pro_ptz_control.py zoom --zoom-command ZoomTile --action start --step 8 # 停止 python scripts/jf_open_pro_ptz_control.py zoom --zoom-command ZoomTile --action stop ``` --- ### 5. 预置位管理 (Preset) 预置点编号范围：1-255（建议不使用 200 以后的编号） **特殊预置点：** - `100`: 移动追踪守望位（追踪停止后自动回归） - `128`: 自检回归预置点（设备重启或自检时回归） **操作类型：** | 操作 | 参数值 | 说明 | |------|--------|------| | 设置预置点 | `set` | 将当前位置保存为预置点 | | 删除预置点 | `clear` | 删除指定预置点 | | 转到预置点 | `goto` | 云台转动到预置点位置 | | 编辑预置点名 | `name` | 修改预置点名称 | | 获取列表 | `list` | 获取所有预置点 | **使用示例：** ```bash # 设置预置点 1，名称为"门口" python scripts/jf_open_pro_ptz_control.py preset --preset-command set --id 1 --name "门口" # 转到预置点 1 python scripts/jf_open_pro_ptz_control.py preset --preset-command goto --id 1 # 删除预置点 1 python scripts/jf_open_pro_ptz_control.py preset --preset-command clear --id 1 # 编辑预置点名称 python scripts/jf_open_pro_ptz_control.py preset --preset-command name --id 1 --name "新名称" # 获取预置点列表 python scripts/jf_open_pro_ptz_control.py preset --preset-command list ``` --- ### 6. 巡航计划管理 (Tour) 巡航功能让设备在多个预置点之间自动循环巡视。 **操作类型：** | 操作 | 参数值 | 说明 | |------|--------|------| | 添加巡航点 | `add` | 往巡航线路添加预置点 | | 删除巡航点 | `delete` | 从巡航线路删除预置点 | | 启动巡航 | `start` | 开始自动巡航 | | 停止巡航 | `stop` | 停止巡航 | | 清除巡航线路 | `clear` | 清空整个巡航线路 | | 获取列表 | `list` | 获取巡航配置 | **使用示例：** ```bash # 添加预置点 1 到巡航线路 0 python scripts/jf_open_pro_ptz_control.py tour --tour-command add --tour-id 0 --preset-id 1 --step 5 # 添加预置点 2 到巡航线路 0 python scripts/jf_open_pro_ptz_control.py tour --tour-command add --tour-id 0 --preset-id 2 # 启动巡航线路 0 python scripts/jf_open_pro_ptz_control.py tour --tour-command start --tour-id 0 # 停止巡航 python scripts/jf_open_pro_ptz_control.py tour --tour-command stop --tour-id 0 # 获取巡航配置 python scripts/jf_open_pro_ptz_control.py tour --tour-command list ``` --- ## 📖 使用场景示例 ### 场景 1: 基础云台控制 ```bash # 1. 检查设备状态 python scripts/jf_open_pro_ptz_control.py status # 2. 向上转动 python scripts/jf_open_pro_ptz_control.py ptz --direction up --action start sleep 1 python scripts/jf_open_pro_ptz_control.py ptz --direction up --action stop # 3. 向右转动 python scripts/jf_open_pro_ptz_control.py ptz --direction right --action start sleep 1 python scripts/jf_open_pro_ptz_control.py ptz --direction right --action stop ``` ### 场景 2: 设置并使用预置位 ```bash # 1. 转动到目标位置 python scripts/jf_open_pro_ptz_control.py ptz --direction up --action start sleep 1 python scripts/jf_open_pro_ptz_control.py ptz --direction up --action stop # 2. 保存为预置点 1 python scripts/jf_open_pro_ptz_control.py preset --preset-command set --id 1 --name "门口" # 3. 转动到其他位置... # 4. 回到预置点 1 python scripts/jf_open_pro_ptz_control.py preset --preset-command goto --id 1 ``` ### 场景 3: 设置自动巡航 ```bash # 1. 设置多个预置点 python scripts/jf_open_pro_ptz_control.py preset --preset-command set --id 1 --name "位置 1" python scripts/jf_open_pro_ptz_control.py preset --preset-command set --id 2 --name "位置 2" python scripts/jf_open_pro_ptz_control.py preset --preset-command set --id 3 --name "位置 3" # 2. 添加到巡航线路 python scripts/jf_open_pro_ptz_control.py tour --tour-command add --tour-id 0 --preset-id 1 python scripts/jf_open_pro_ptz_control.py tour --tour-command add --tour-id 0 --preset-id 2 python scripts/jf_open_pro_ptz_control.py tour --tour-command add --tour-id 0 --preset-id 3 # 3. 启动巡航 python scripts/jf_open_pro_ptz_control.py tour --tour-command start --tour-id 0 ``` --- ## ⚠️ 错误处理 | 错误码 | 说明 | 解决方案 | |--------|------|----------| | `2000` | 成功 | - | | `4118` | 连接超时 | 设备离线/休眠，稍后重试 | | `10001` | Token 无效 | 重新获取 Token | | `10002` | 设备未登录 | 脚本会自动处理登录 | | `526` | 低电量/不支持 | 设备电量不足或为固定摄像头 | ### 错误码 526 说明 **含义：** 设备支持云台，但电量过低无法执行 **解决方案：** 1. 给设备充电 2. 等待电量恢复至 20% 以上 3. 使用电源供电模式 --- ## ⚠️ 注意事项 1. **设备需在线** - 操作前确保设备在线 2. **设备需登录** - 脚本会自动处理设备登录 3. **PTZ 控制** - start/stop 指令需串行发送（间隔 500ms） 4. **预置点范围** - 建议使用 1-199 编号 5. **电量检查** - 低电量时云台功能可能被禁用 --- ## 📚 官方参考资料 - **杰峰开放平台**: https://open.jftech.com/ - **API 文档**: https://docs.jftech.com/ - **API 端点**: `api.jftechws.com` (国际) / `api-cn.jftech.com` (中国大陆) --- ## 📁 脚本工具 ```bash # 获取帮助 python scripts/jf_open_pro_ptz_control.py --help # 查询设备状态 python scripts/jf_open_pro_ptz_control.py status # PTZ 方向控制 python scripts/jf_open_pro_ptz_control.py ptz --direction <方向> --action <start|stop> # 一键遮蔽 python scripts/jf_open_pro_ptz_control.py mask --enable <true|false> # 变倍聚焦 python scripts/jf_open_pro_ptz_control.py zoom --zoom-command <命令> --action <start|stop> # 预置点管理 python scripts/jf_open_pro_ptz_control.py preset --preset-command <set|clear|goto|name|list> [选项] # 巡航管理 python scripts/jf_open_pro_ptz_control.py tour --tour-command <add|delete|start|stop|clear|list> [选项] ``` 脚本路径：`scripts/jf_open_pro_ptz_control.py` --- **技能版本：** v1.0.0 **语言：** Python **最后更新：** 2026-04-07

path: scripts/jf_open_pro_ptz_control.py
content: #!/usr/bin/env python3
"""
JF 杰峰云台 PTZ 控制工具 - Python 版本

仅支持环境变量配置凭据，避免命令行泄露风险。

用法:
    export JF_UUID="your-uuid"
    export JF_APPKEY="your-appkey"
    export JF_APPSECRET="your-appsecret"
    export JF_MOVECARD=5
    export JF_SN="your-device-sn"
    export JF_USERNAME="admin"
    export JF_PASSWORD="your-password"
    
    python jf_open_pro_ptz_control.py status
    python jf_open_pro_ptz_control.py ptz --direction up --action start
    python jf_open_pro_ptz_control.py mask --enable true
    python jf_open_pro_ptz_control.py zoom --zoom-command ZoomTile --action start
    python jf_open_pro_ptz_control.py preset --preset-command set --id 1 --name "门口"
    python jf_open_pro_ptz_control.py tour --tour-command add --tour-id 0 --preset-id 1
"""

import argparse
import hashlib
import json
import os
import time
import urllib.request
import urllib.error


# ==================== 工具函数 ====================

def str2byte(s):
    """字符串转字节数组（UTF-8 编码）"""
    return list(s.encode('utf-8'))


def change(encrypt_str, move_card):
    """移位算法"""
    encrypt_byte = str2byte(encrypt_str)
    length = len(encrypt_byte)
    for idx in range(length):
        tmp = encrypt_byte[idx] if (idx % move_card) > ((length - idx) % move_card) else encrypt_byte[length - (idx + 1)]
        encrypt_byte[idx], encrypt_byte[length - (idx + 1)] = encrypt_byte[length - (idx + 1)], tmp
    return encrypt_byte


def merge_byte(encrypt_byte, change_byte):
    """合并字节数组"""
    length = len(encrypt_byte)
    temp = [0] * (length * 2)
    for idx in range(length):
        temp[idx] = encrypt_byte[idx]
        temp[length * 2 - 1 - idx] = change_byte[idx]
    return temp


def get_signature(uuid, app_key, app_secret, time_millis, move_card=5):
    """获取签名"""
    encrypt_str = uuid + app_key + app_secret + time_millis
    encrypt_byte = str2byte(encrypt_str)
    change_byte = change(encrypt_str, move_card)
    merged_byte = merge_byte(encrypt_byte, change_byte)
    return hashlib.md5(bytes(merged_byte)).hexdigest()


def get_time_millis():
    """获取 20 位时间戳"""
    return str(int(time.time() * 1000)).zfill(20)


def generate_request_id():
    """生成 32 位请求 ID"""
    import random
    return ''.join(random.choice('0123456789abcdef') for _ in range(32))


# ==================== HTTP 请求 ====================

def https_post(url, data, headers):
    """发送 HTTPS POST 请求"""
    req = urllib.request.Request(
        url,
        data=json.dumps(data).encode('utf-8'),
        headers=headers,
        method='POST'
    )
    req.add_header('Content-Type', 'application/json')
    
    try:
        with urllib.request.urlopen(req, timeout=30) as response:
            return json.loads(response.read().decode('utf-8'))
    except urllib.error.HTTPError as e:
        raise Exception(f'HTTP 错误：{e.code} - {e.reason}')
    except urllib.error.URLError as e:
        raise Exception(f'请求失败：{e.reason}')


# ==================== 认证相关 ====================

def get_device_token(config):
    """获取设备 Token"""
    time_millis = get_time_millis()
    signature = get_signature(config['uuid'], config['appKey'], config['appSecret'], time_millis, config['moveCard'])
    
    url = f"https://{config['endpoint']}/gwp/v3/rtc/device/token"
    headers = {
        'uuid': config['uuid'],
        'appKey': config['appKey'],
        'timeMillis': time_millis,
        'signature': signature,
        'X-Request-Id': generate_request_id()
    }
    
    data = {'sns': [config['deviceSn']], 'accessToken': ''}
    response = https_post(url, data, headers)
    
    if response.get('code') != 2000:
        raise Exception(f"获取 Token 失败：{response.get('msg')} (code: {response.get('code')})")
    
    if not response.get('data') or len(response['data']) == 0:
        raise Exception('获取 Token 失败：返回数据为空')
    
    return response['data'][0]['token']


def device_login(config, device_token, keepalive_time=300):
    """设备登录"""
    time_millis = get_time_millis()
    signature = get_signature(config['uuid'], config['appKey'], config['appSecret'], time_millis, config['moveCard'])
    
    url = f"https://{config['endpoint']}/gwp/v3/rtc/device/login/{device_token}"
    headers = {
        'uuid': config['uuid'],
        'appKey': config['appKey'],
        'timeMillis': time_millis,
        'signature': signature,
        'X-Request-Id': generate_request_id()
    }
    
    data = {
        'UserName': config['userName'],
        'PassWord': config['passWord'] or '',
        'KeepaliveTime': keepalive_time
    }
    
    response = https_post(url, data, headers)
    
    if response.get('code') != 2000:
        raise Exception(f"设备登录失败：{response.get('msg')} (code: {response.get('code')})")
    
    if response.get('data', {}).get('Ret') != 100:
        raise Exception(f"设备登录失败：设备返回码 {response['data']['Ret']}")
    
    return response['data']


# ==================== 设备状态查询 ====================

def get_device_status(config):
    """获取设备状态"""
    print('>>> 获取设备 Token...')
    device_token = get_device_token(config)
    
    time_millis = get_time_millis()
    signature = get_signature(config['uuid'], config['appKey'], config['appSecret'], time_millis, config['moveCard'])
    
    url = f"https://{config['endpoint']}/gwp/v3/rtc/device/status"
    headers = {
        'uuid': config['uuid'],
        'appKey': config['appKey'],
        'timeMillis': time_millis,
        'signature': signature,
        'X-Request-Id': generate_request_id()
    }
    
    data = {'deviceTokenList': [device_token], 'region': 'Local'}
    response = https_post(url, data, headers)
    
    if response.get('code') != 2000:
        raise Exception(f"查询状态失败：{response.get('msg')} (code: {response.get('code')})")
    
    return response['data'][0]


# ==================== PTZ 方向控制 ====================

DIRECTION_MAP = {
    'up': 'DirectionUp',
    'down': 'DirectionDown',
    'left': 'DirectionLeft',
    'right': 'DirectionRight',
    'leftup': 'DirectionLeftUp',
    'leftdown': 'DirectionLeftDown',
    'rightup': 'DirectionRightUp',
    'rightdown': 'DirectionRightDown'
}


def ptz_control(config, device_token, direction, action, step=5, channel=0):
    """云台方向控制"""
    time_millis = get_time_millis()
    signature = get_signature(config['uuid'], config['appKey'], config['appSecret'], time_millis, config['moveCard'])
    
    url = f"https://{config['endpoint']}/gwp/v3/rtc/device/opdev/{device_token}"
    headers = {
        'uuid': config['uuid'],
        'appKey': config['appKey'],
        'timeMillis': time_millis,
        'signature': signature,
        'X-Request-Id': generate_request_id()
    }
    
    command = DIRECTION_MAP.get(direction.lower())
    if not command:
        raise Exception(f'无效的方向：{direction}')
    
    preset = 0 if action.lower() == 'start' else -1
    
    data = {
        'Name': 'OPPTZControl',
        'OPPTZControl': {
            'Command': command,
            'Parameter': {
                'Preset': preset,
                'Channel': channel,
                'Step': step
            }
        }
    }
    
    response = https_post(url, data, headers)
    
    if response.get('code') != 2000:
        raise Exception(f"PTZ 控制失败：{response.get('msg')} (code: {response.get('code')})")
    
    return response['data']


# ==================== 一键遮蔽 ====================

def set_mask(config, device_token, enable):
    """一键遮蔽"""
    time_millis = get_time_millis()
    signature = get_signature(config['uuid'], config['appKey'], config['appSecret'], time_millis, config['moveCard'])
    
    url = f"https://{config['endpoint']}/gwp/v3/rtc/device/setconfig/{device_token}"
    headers = {
        'uuid': config['uuid'],
        'appKey': config['appKey'],
        'timeMillis': time_millis,
        'signature': signature,
        'X-Request-Id': generate_request_id()
    }
    
    data = {
        'Name': 'General.OneKeyMaskVideo',
        'General.OneKeyMaskVideo': [{'Enable': enable}]
    }
    
    response = https_post(url, data, headers)
    
    if response.get('code') != 2000:
        raise Exception(f"一键遮蔽设置失败：{response.get('msg')} (code: {response.get('code')})")
    
    return response['data']


# ==================== 变倍聚焦控制 ====================

def zoom_focus_control(config, device_token, command, action, step=8, channel=0):
    """变倍聚焦控制"""
    time_millis = get_time_millis()
    signature = get_signature(config['uuid'], config['appKey'], config['appSecret'], time_millis, config['moveCard'])
    
    url = f"https://{config['endpoint']}/gwp/v3/rtc/device/opdev/{device_token}"
    headers = {
        'uuid': config['uuid'],
        'appKey': config['appKey'],
        'timeMillis': time_millis,
        'signature': signature,
        'X-Request-Id': generate_request_id()
    }
    
    preset = 0 if action.lower() == 'start' else -1
    
    data = {
        'Name': 'OPPTZControl',
        'OPPTZControl': {
            'Command': command,
            'Parameter': {
                'Channel': channel,
                'Step': step,
                'Preset': preset
            }
        }
    }
    
    response = https_post(url, data, headers)
    
    if response.get('code') != 2000:
        raise Exception(f"变倍聚焦控制失败：{response.get('msg')} (code: {response.get('code')})")
    
    return response['data']


# ==================== 预置点管理 ====================

def preset_control(config, device_token, command, preset_id, preset_name='', channel=0):
    """预置点控制"""
    time_millis = get_time_millis()
    signature = get_signature(config['uuid'], config['appKey'], config['appSecret'], time_millis, config['moveCard'])
    
    url = f"https://{config['endpoint']}/gwp/v3/rtc/device/opdev/{device_token}"
    headers = {
        'uuid': config['uuid'],
        'appKey': config['appKey'],
        'timeMillis': time_millis,
        'signature': signature,
        'X-Request-Id': generate_request_id()
    }
    
    command_map = {
        'set': 'SetPreset',
        'clear': 'ClearPreset',
        'goto': 'GotoPreset',
        'name': 'SetPresetName'
    }
    
    op_command = command_map.get(command.lower())
    if not op_command:
        raise Exception(f'无效的预置点命令：{command}')
    
    data = {
        'Name': 'OPPTZControl',
        'OPPTZControl': {
            'Command': op_command,
            'Parameter': {
                'Preset': preset_id,
                'Channel': channel
            }
        }
    }
    
    if command in ['set', 'name']:
        data['OPPTZControl']['Parameter']['PresetName'] = preset_name or f'预置点{preset_id}'
    
    response = https_post(url, data, headers)
    
    if response.get('code') != 2000:
        raise Exception(f"预置点操作失败：{response.get('msg')} (code: {response.get('code')})")
    
    return response['data']


def get_preset_list(config, device_token):
    """获取预置点列表"""
    time_millis = get_time_millis()
    signature = get_signature(config['uuid'], config['appKey'], config['appSecret'], time_millis, config['moveCard'])
    
    url = f"https://{config['endpoint']}/gwp/v3/rtc/device/getconfig/{device_token}"
    headers = {
        'uuid': config['uuid'],
        'appKey': config['appKey'],
        'timeMillis': time_millis,
        'signature': signature,
        'X-Request-Id': generate_request_id()
    }
    
    data = {'Name': 'Uart.PTZPreset'}
    response = https_post(url, data, headers)
    
    if response.get('code') != 2000:
        raise Exception(f"获取预置点列表失败：{response.get('msg')} (code: {response.get('code')})")
    
    # API 返回的是二维数组，需要扁平化
    presets = response['data'].get('Uart.PTZPreset', [])
    return [item for sublist in presets for item in sublist] if presets else []


# ==================== 巡航管理 ====================

def tour_control(config, device_token, command, tour_id=0, preset_id=0, step=5, channel=0):
    """巡航控制"""
    time_millis = get_time_millis()
    signature = get_signature(config['uuid'], config['appKey'], config['appSecret'], time_millis, config['moveCard'])
    
    url = f"https://{config['endpoint']}/gwp/v3/rtc/device/opdev/{device_token}"
    headers = {
        'uuid': config['uuid'],
        'appKey': config['appKey'],
        'timeMillis': time_millis,
        'signature': signature,
        'X-Request-Id': generate_request_id()
    }
    
    command_map = {
        'add': 'AddTour',
        'delete': 'DeleteTour',
        'start': 'StartTour',
        'stop': 'StopTour',
        'clear': 'ClearTour'
    }
    
    op_command = command_map.get(command.lower())
    if not op_command:
        raise Exception(f'无效的巡航命令：{command}')
    
    data = {
        'Name': 'OPPTZControl',
        'OPPTZControl': {
            'Command': op_command,
            'Parameter': {
                'Tour': tour_id,
                'Channel': channel
            }
        }
    }
    
    if command in ['add', 'delete']:
        data['OPPTZControl']['Parameter']['Preset'] = preset_id
        data['OPPTZControl']['Parameter']['Step'] = step
    
    response = https_post(url, data, headers)
    
    if response.get('code') != 2000:
        raise Exception(f"巡航操作失败：{response.get('msg')} (code: {response.get('code')})")
    
    return response['data']


def get_tour_list(config, device_token):
    """获取巡航列表"""
    time_millis = get_time_millis()
    signature = get_signature(config['uuid'], config['appKey'], config['appSecret'], time_millis, config['moveCard'])
    
    url = f"https://{config['endpoint']}/gwp/v3/rtc/device/getconfig/{device_token}"
    headers = {
        'uuid': config['uuid'],
        'appKey': config['appKey'],
        'timeMillis': time_millis,
        'signature': signature,
        'X-Request-Id': generate_request_id()
    }
    
    data = {'Name': 'Uart.PTZTour'}
    response = https_post(url, data, headers)
    
    if response.get('code') != 2000:
        raise Exception(f"获取巡航列表失败：{response.get('msg')} (code: {response.get('code')})")
    
    return response['data'].get('Uart.PTZTour', [])


# ==================== 命令行参数解析 ====================

def parse_args():
    parser = argparse.ArgumentParser(
        description='JLink 杰峰云台 PTZ 控制工具 - Python 版本',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog='''
环境变量:
  JF_UUID       开放平台用户唯一标识 (必需)
  JF_APPKEY     开放平台应用 Key (必需)
  JF_APPSECRET  开放平台应用密钥 (必需)
  JF_MOVECARD   签名算法偏移量，通常设为 5 (必需)
  JF_SN         设备序列号 (必需)
  JF_USERNAME   设备用户名，默认 admin (可选)
  JF_PASSWORD   设备密码 (可选)
  JF_ENDPOINT   API 端点，默认 api.jftechws.com (可选)

示例:
  # 设置环境变量
  export JF_UUID="your-uuid"
  export JF_APPKEY="your-appkey"
  export JF_APPSECRET="your-appsecret"
  export JF_MOVECARD=5
  export JF_SN="your-device-sn"
  
  # 查询设备状态
  python jf_open_pro_ptz_control.py status
  
  # 云台方向控制（向上转动）
  python jf_open_pro_ptz_control.py ptz --direction up --action start
  python jf_open_pro_ptz_control.py ptz --direction up --action stop
  
  # 一键遮蔽
  python jf_open_pro_ptz_control.py mask --enable true
  
  # 变倍控制（放大）
  python jf_open_pro_ptz_control.py zoom --zoom-command ZoomTile --action start
  python jf_open_pro_ptz_control.py zoom --zoom-command ZoomTile --action stop
  
  # 设置预置点
  python jf_open_pro_ptz_control.py preset --preset-command set --id 1 --name "门口"
  
  # 转到预置点
  python jf_open_pro_ptz_control.py preset --preset-command goto --id 1
  
  # 添加巡航点
  python jf_open_pro_ptz_control.py tour --tour-command add --tour-id 0 --preset-id 1
  
  # 启动巡航
  python jf_open_pro_ptz_control.py tour --tour-command start --tour-id 0
        ''')
    
    parser.add_argument('command', choices=['status', 'ptz', 'mask', 'zoom', 'preset', 'tour'], help='命令')
    
    # PTZ 参数
    parser.add_argument('--direction', choices=['up', 'down', 'left', 'right', 'leftup', 'leftdown', 'rightup', 'rightdown'], help='方向')
    parser.add_argument('--action', choices=['start', 'stop'], help='动作')
    parser.add_argument('--step', type=int, default=5, help='速度 1-8（默认：5）')
    
    # Mask 参数
    parser.add_argument('--enable', type=str, help='开启/关闭遮蔽 (true/false)')
    
    # Zoom 参数
    parser.add_argument('--zoom-command', dest='zoom_command', help='变倍/聚焦命令')
    
    # Preset 参数
    parser.add_argument('--preset-command', dest='preset_command', help='预置点操作 (set/clear/goto/name/list)')
    parser.add_argument('--id', type=int, help='预置点 ID')
    parser.add_argument('--name', help='预置点名称')
    
    # Tour 参数
    parser.add_argument('--tour-command', dest='tour_command', help='巡航操作 (add/delete/start/stop/clear/list)')
    parser.add_argument('--tour-id', dest='tour_id', type=int, default=0, help='巡航线路 ID（默认：0）')
    parser.add_argument('--preset-id', dest='preset_id', type=int, help='预置点 ID')
    
    return parser.parse_args()


def get_config_from_env():
    """从环境变量读取配置"""
    required_vars = ['JF_UUID', 'JF_APPKEY', 'JF_APPSECRET', 'JF_MOVECARD', 'JF_SN']
    missing_vars = [var for var in required_vars if not os.environ.get(var)]
    
    if missing_vars:
        raise Exception(f"缺少必需的环境变量：{', '.join(missing_vars)}\n"
                       f"请设置：export JF_UUID='...' JF_APPKEY='...' JF_APPSECRET='...' JF_MOVECARD=5 JF_SN='...'")
    
    return {
        'uuid': os.environ.get('JF_UUID'),
        'appKey': os.environ.get('JF_APPKEY'),
        'appSecret': os.environ.get('JF_APPSECRET'),
        'moveCard': int(os.environ.get('JF_MOVECARD', 5)),
        'endpoint': os.environ.get('JF_ENDPOINT', 'api.jftechws.com'),
        'deviceSn': os.environ.get('JF_SN'),
        'userName': os.environ.get('JF_USERNAME', 'admin'),
        'passWord': os.environ.get('JF_PASSWORD', '')
    }


def main():
    args = parse_args()
    
    try:
        config = get_config_from_env()
    except Exception as e:
        print(f'❌ 配置错误：{e}')
        return 1
    
    try:
        if args.command == 'status':
            print('========================================')
            print('JLink 设备状态查询')
            print('========================================')
            print(f"设备 SN: {config['deviceSn']}")
            print()
            
            status = get_device_status(config)
            
            print('=== 设备状态 ===')
            print(f"设备序列号：{status.get('uuid')}")
            print(f"状态：{status.get('status')}")
            print(f"认证状态：{status.get('authStatus')}")
            if status.get('wakeUpStatus') is not None:
                print(f"唤醒状态：{status.get('wakeUpStatus')}")
            if status.get('wakeUpEnable') is not None:
                print(f"支持唤醒：{status.get('wakeUpEnable')}")
            if status.get('wanIp'):
                print(f"WAN IP: {status.get('wanIp')}")
        
        elif args.command == 'ptz':
            if not args.direction or not args.action:
                print('❌ PTZ 命令需要 --direction 和 --action 参数')
                return 1
            
            print('========================================')
            print('JLink 云台方向控制')
            print('========================================')
            print(f"设备 SN: {config['deviceSn']}")
            print(f"方向：{args.direction}")
            print(f"动作：{args.action}")
            print(f"速度：{args.step}")
            print()
            
            device_token = get_device_token(config)
            result = ptz_control(config, device_token, args.direction, args.action, args.step)
            
            print('✅ PTZ 控制成功')
            print(f"返回码：{result.get('Ret')}")
        
        elif args.command == 'mask':
            if args.enable is None:
                print('❌ Mask 命令需要 --enable 参数')
                return 1
            
            enable = args.enable.lower() == 'true'
            print('========================================')
            print('JLink 一键遮蔽')
            print('========================================')
            print(f"设备 SN: {config['deviceSn']}")
            print(f"开启遮蔽：{enable}")
            print()
            
            device_token = get_device_token(config)
            result = set_mask(config, device_token, enable)
            
            print(f"✅ 一键遮蔽{'开启' if enable else '关闭'}成功")
            print(f"返回码：{result.get('Ret')}")
        
        elif args.command == 'zoom':
            if not args.zoom_command or not args.action:
                print('❌ Zoom 命令需要 --zoom-command 和 --action 参数')
                return 1
            
            print('========================================')
            print('JLink 变倍聚焦控制')
            print('========================================')
            print(f"设备 SN: {config['deviceSn']}")
            print(f"命令：{args.zoom_command}")
            print(f"动作：{args.action}")
            print(f"速度：{args.step}")
            print()
            
            device_token = get_device_token(config)
            result = zoom_focus_control(config, device_token, args.zoom_command, args.action, args.step)
            
            print('✅ 变倍聚焦控制成功')
            print(f"返回码：{result.get('Ret')}")
        
        elif args.command == 'preset':
            if not args.preset_command:
                print('❌ Preset 命令需要 --preset-command 参数')
                return 1
            
            print('========================================')
            print('JLink 预置点管理')
            print('========================================')
            print(f"设备 SN: {config['deviceSn']}")
            print(f"操作：{args.preset_command}")
            if args.id is not None:
                print(f"预置点 ID: {args.id}")
            if args.name:
                print(f"名称：{args.name}")
            print()
            
            device_token = get_device_token(config)
            
            if args.preset_command == 'list':
                presets = get_preset_list(config, device_token)
                print('=== 预置点列表 ===')
                if not presets:
                    print('暂无预置点')
                else:
                    for p in presets:
                        print(f"  ID {p.get('Id')}: {p.get('PresetName')}")
            else:
                if args.id is None:
                    print('❌ 需要指定 --id 参数')
                    return 1
                
                result = preset_control(config, device_token, args.preset_command, args.id, args.name or '')
                print(f'✅ 预置点{args.preset_command}成功')
                print(f"返回码：{result.get('Ret')}")
        
        elif args.command == 'tour':
            if not args.tour_command:
                print('❌ Tour 命令需要 --tour-command 参数')
                return 1
            
            print('========================================')
            print('JLink 巡航管理')
            print('========================================')
            print(f"设备 SN: {config['deviceSn']}")
            print(f"操作：{args.tour_command}")
            print(f"巡航线路 ID: {args.tour_id}")
            if args.preset_id is not None:
                print(f"预置点 ID: {args.preset_id}")
            print()
            
            device_token = get_device_token(config)
            
            if args.tour_command == 'list':
                tours = get_tour_list(config, device_token)
                print('=== 巡航线路列表 ===')
                print(json.dumps(tours, indent=2, ensure_ascii=False))
            else:
                result = tour_control(config, device_token, args.tour_command, args.tour_id, args.preset_id or 0, args.step)
                print(f'✅ 巡航{args.tour_command}成功')
                print(f"返回码：{result.get('Ret')}")
        
        print('========================================')
        return 0
    
    except Exception as e:
        print(f'❌ 错误：{e}')
        return 1


if __name__ == '__main__':
    exit(main())


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)