Просмотр исходного кода

feat: add HIKVISION ISAPI PTZ control documentation

- Introduce comprehensive documentation for HIKVISION ISAPI PTZ control protocol
- Detail authentication mechanisms, API endpoints, and parameter specifications
- Include practical examples using curl for various PTZ commands
- Provide guidelines for handling common issues and network considerations
yb 2 недель назад
Родитель
Сommit
12ca49d94f
5 измененных файлов с 346 добавлено и 52 удалено
  1. 217 0
      docs/HIKVISION-ISAPI-PTZ.md
  2. 53 33
      src/api/ptz.ts
  3. 9 0
      src/components.d.ts
  4. 1 1
      src/components/PTZController.vue
  5. 66 18
      src/views/demo/rtsp-stream.vue

+ 217 - 0
docs/HIKVISION-ISAPI-PTZ.md

@@ -0,0 +1,217 @@
+# 海康威视 ISAPI PTZ 云台控制协议
+
+> 基于实际抓包分析整理,适用于海康威视网络摄像头
+
+## 1. 概述
+
+### 1.1 ISAPI 简介
+
+ISAPI(IP Surveillance API)是海康威视设备的标准 HTTP API 接口,用于设备配置、实时预览、PTZ 控制等功能。
+
+### 1.2 PTZ 功能
+
+PTZ(Pan-Tilt-Zoom)云台控制,支持:
+- **Pan**:水平转动(左右)
+- **Tilt**:垂直转动(上下)
+- **Zoom**:变焦(放大缩小)
+
+---
+
+## 2. 认证机制
+
+### 2.1 Session 认证(Web 界面使用)
+
+海康摄像头 Web 界面使用 **Session 认证**,而非 Basic Auth。
+
+认证流程:
+1. 访问摄像头 Web 界面:`http://{ip}/doc/page/preview.asp`
+2. 输入用户名密码登录
+3. 登录成功后获取:
+   - **Cookie**: `WebSession_{id}={token}`
+   - **SessionTag**: 用于请求验证的 hash 值
+
+### 2.2 认证 Header
+
+| Header | 格式 | 说明 |
+|--------|------|------|
+| Cookie | `WebSession_{id}={token}` | Session Token |
+| SessionTag | `{hash}` | 请求验证标识 |
+
+### 2.3 获取认证信息
+
+登录后,可以从浏览器开发者工具中获取:
+
+1. 打开摄像头 Web 界面并登录
+2. F12 打开开发者工具
+3. 在 Network 标签中找到任意请求
+4. 复制 `Cookie` 和 `SessionTag` Header
+
+---
+
+## 3. PTZ 控制 API
+
+### 3.1 连续移动(Continuous)
+
+持续向某个方向移动,直到发送停止命令。
+
+**端点**
+```
+PUT /ISAPI/PTZCtrl/channels/{channel}/continuous
+```
+
+**参数**
+- `channel`: 通道号,通常为 `1`
+
+**请求头**
+```http
+Content-Type: application/x-www-form-urlencoded; charset=UTF-8
+Cookie: WebSession_{id}={token}
+SessionTag: {hash}
+X-Requested-With: XMLHttpRequest
+If-Modified-Since: 0
+```
+
+**请求体(XML)**
+```xml
+<?xml version="1.0" encoding="UTF-8"?>
+<PTZData>
+  <pan>{pan_value}</pan>
+  <tilt>{tilt_value}</tilt>
+</PTZData>
+```
+
+---
+
+## 4. 参数说明
+
+### 4.1 PTZ 参数范围
+
+| 参数 | 范围 | 说明 |
+|------|------|------|
+| pan | -100 ~ 100 | 水平转动速度,负值向左,正值向右 |
+| tilt | -100 ~ 100 | 垂直转动速度,负值向下,正值向上 |
+| zoom | -100 ~ 100 | 变焦速度,负值缩小,正值放大 |
+
+### 4.2 方向控制对照表
+
+| 方向 | pan | tilt | 说明 |
+|------|-----|------|------|
+| 上 | 0 | 60 | 向上转动 |
+| 下 | 0 | -60 | 向下转动 |
+| 左 | -60 | 0 | 向左转动 |
+| 右 | 60 | 0 | 向右转动 |
+| 左上 | -60 | 60 | 斜向左上 |
+| 右上 | 60 | 60 | 斜向右上 |
+| 左下 | -60 | -60 | 斜向左下 |
+| 右下 | 60 | -60 | 斜向右下 |
+| **停止** | 0 | 0 | 停止移动 |
+
+> 注:数值 60 是推荐的移动速度,可根据需要调整(1-100)
+
+---
+
+## 5. curl 示例
+
+### 5.1 向右转动
+
+```bash
+curl 'http://192.168.0.64/ISAPI/PTZCtrl/channels/1/continuous' \
+  -X 'PUT' \
+  -H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \
+  -H 'Cookie: WebSession_8febb54dd5=1ca674cedc0b6ffddacf1eb4ecfa3016127c90104527f1ecf8ef88ca09bd2ef1' \
+  -H 'SessionTag: 7615ce65f40480305568ed02726ac67e89452590e58244976c836b22daeff522' \
+  -H 'X-Requested-With: XMLHttpRequest' \
+  -H 'If-Modified-Since: 0' \
+  --data-raw '<?xml version="1.0" encoding="UTF-8"?><PTZData><pan>60</pan><tilt>0</tilt></PTZData>'
+```
+
+### 5.2 向上转动
+
+```bash
+curl 'http://192.168.0.64/ISAPI/PTZCtrl/channels/1/continuous' \
+  -X 'PUT' \
+  -H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \
+  -H 'Cookie: WebSession_xxx=xxx' \
+  -H 'SessionTag: xxx' \
+  --data-raw '<?xml version="1.0" encoding="UTF-8"?><PTZData><pan>0</pan><tilt>60</tilt></PTZData>'
+```
+
+### 5.3 停止移动
+
+```bash
+curl 'http://192.168.0.64/ISAPI/PTZCtrl/channels/1/continuous' \
+  -X 'PUT' \
+  -H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \
+  -H 'Cookie: WebSession_xxx=xxx' \
+  -H 'SessionTag: xxx' \
+  --data-raw '<?xml version="1.0" encoding="UTF-8"?><PTZData><pan>0</pan><tilt>0</tilt></PTZData>'
+```
+
+---
+
+## 6. 完整请求头参考
+
+基于实际抓包的完整请求头:
+
+```http
+PUT /ISAPI/PTZCtrl/channels/1/continuous HTTP/1.1
+Host: 192.168.0.64
+Accept: */*
+Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
+Cache-Control: no-cache
+Connection: keep-alive
+Content-Type: application/x-www-form-urlencoded; charset=UTF-8
+Cookie: language=en; WebSession_8febb54dd5=1ca674cedc0b6ffddacf1eb4ecfa3016127c90104527f1ecf8ef88ca09bd2ef1
+If-Modified-Since: 0
+Origin: http://192.168.0.64
+Pragma: no-cache
+Referer: http://192.168.0.64/doc/page/preview.asp
+SessionTag: 7615ce65f40480305568ed02726ac67e89452590e58244976c836b22daeff522
+User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36
+X-Requested-With: XMLHttpRequest
+
+<?xml version="1.0" encoding="UTF-8"?><PTZData><pan>60</pan><tilt>0</tilt></PTZData>
+```
+
+---
+
+## 7. 注意事项
+
+### 7.1 认证问题
+
+- **Session 过期**:登录 Session 会过期,需要重新登录获取新的 Cookie 和 SessionTag
+- **401 错误**:如果返回 401 Unauthorized,说明认证信息无效或已过期
+- **Basic Auth 无效**:直接使用 Basic Auth 认证无法控制 PTZ
+
+### 7.2 CORS 问题
+
+浏览器前端直接请求摄像头会遇到跨域问题(CORS),解决方案:
+
+1. **开发环境**:使用 Vite/Webpack 代理
+2. **生产环境**:通过后端 API 转发请求
+
+### 7.3 网络环境
+
+- 摄像头通常在内网,外网无法直接访问
+- 生产环境需要通过后端服务代理
+
+### 7.4 速度控制
+
+- pan/tilt 值越大,移动速度越快
+- 推荐使用 50-60 作为默认速度
+- 停止命令必须发送 `pan=0, tilt=0`
+
+---
+
+## 8. 相关资源
+
+- 摄像头 Web 界面:`http://{ip}/doc/page/preview.asp`
+- 海康 ISAPI 官方文档:联系海康技术支持获取
+
+---
+
+## 更新日志
+
+| 日期 | 内容 |
+|------|------|
+| 2026-01-09 | 初始版本,基于实际抓包整理 |

+ 53 - 33
src/api/ptz.ts

@@ -1,6 +1,6 @@
 /**
 /**
  * PTZ 云台控制 API
  * PTZ 云台控制 API
- * 支持海康威视 ISAPI 协议
+ * 通过独立的 PTZ 后端服务调用海康威视 ISAPI 协议
  */
  */
 
 
 export interface PTZConfig {
 export interface PTZConfig {
@@ -15,6 +15,9 @@ export interface PTZDirection {
   tilt: number // -100 ~ 100, 负下正上
   tilt: number // -100 ~ 100, 负下正上
 }
 }
 
 
+// PTZ 后端服务地址
+const PTZ_API_BASE = 'http://localhost:3002'
+
 // 方向预设值
 // 方向预设值
 export const PTZ_DIRECTIONS = {
 export const PTZ_DIRECTIONS = {
   UP: { pan: 0, tilt: 50 },
   UP: { pan: 0, tilt: 50 },
@@ -28,52 +31,69 @@ export const PTZ_DIRECTIONS = {
   STOP: { pan: 0, tilt: 0 }
   STOP: { pan: 0, tilt: 0 }
 } as const
 } as const
 
 
-/**
- * 生成 Basic Auth Header
- */
-function createBasicAuth(username: string, password: string): string {
-  const credentials = btoa(`${username}:${password}`)
-  return `Basic ${credentials}`
-}
-
-/**
- * 生成 PTZ 控制 XML
- */
-function createPTZXml(direction: PTZDirection): string {
-  return `<?xml version="1.0" encoding="UTF-8"?>
-<PTZData>
-  <pan>${direction.pan}</pan>
-  <tilt>${direction.tilt}</tilt>
-</PTZData>`
-}
-
 /**
 /**
  * 发送 PTZ 控制命令
  * 发送 PTZ 控制命令
- * 通过 Vite 代理转发请求,避免 CORS 问题
+ * 通过独立的 PTZ 后端服务,支持 Digest Auth
  */
  */
 export async function sendPTZCommand(
 export async function sendPTZCommand(
   config: PTZConfig,
   config: PTZConfig,
   direction: PTZDirection
   direction: PTZDirection
 ): Promise<{ success: boolean; error?: string }> {
 ): Promise<{ success: boolean; error?: string }> {
-  const channel = config.channel || 1
-  // 开发环境: 使用 /camera-proxy 代理 (vite.config.ts 配置固定 IP)
-  // 生产环境: 需要通过后端 API 代理
-  const url = `/camera-proxy/ISAPI/PTZCtrl/channels/${channel}/continuous`
-
   try {
   try {
-    const response = await fetch(url, {
-      method: 'PUT',
+    const response = await fetch(`${PTZ_API_BASE}/ptz/control`, {
+      method: 'POST',
       headers: {
       headers: {
-        'Content-Type': 'application/xml',
-        Authorization: createBasicAuth(config.username, config.password)
+        'Content-Type': 'application/json'
       },
       },
-      body: createPTZXml(direction)
+      body: JSON.stringify({
+        host: config.host,
+        username: config.username,
+        password: config.password,
+        channel: config.channel || 1,
+        pan: direction.pan,
+        tilt: direction.tilt,
+        zoom: 0
+      })
     })
     })
 
 
-    if (response.ok) {
+    const data = await response.json()
+
+    if (data.code === 200) {
       return { success: true }
       return { success: true }
     } else {
     } else {
-      return { success: false, error: `HTTP ${response.status}` }
+      return { success: false, error: data.msg || 'Unknown error' }
+    }
+  } catch (error) {
+    return { success: false, error: String(error) }
+  }
+}
+
+/**
+ * 获取 PTZ 状态
+ */
+export async function getPTZStatus(
+  config: PTZConfig
+): Promise<{ success: boolean; data?: string; error?: string }> {
+  try {
+    const response = await fetch(`${PTZ_API_BASE}/ptz/status`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify({
+        host: config.host,
+        username: config.username,
+        password: config.password,
+        channel: config.channel || 1
+      })
+    })
+
+    const data = await response.json()
+
+    if (data.code === 200) {
+      return { success: true, data: data.data?.raw }
+    } else {
+      return { success: false, error: data.msg || 'Unknown error' }
     }
     }
   } catch (error) {
   } catch (error) {
     return { success: false, error: String(error) }
     return { success: false, error: String(error) }

+ 9 - 0
src/components.d.ts

@@ -63,6 +63,15 @@ declare module 'vue' {
     ElTooltip: typeof import('element-plus/es')['ElTooltip']
     ElTooltip: typeof import('element-plus/es')['ElTooltip']
     ElUpload: typeof import('element-plus/es')['ElUpload']
     ElUpload: typeof import('element-plus/es')['ElUpload']
     HelloWorld: typeof import('./components/HelloWorld.vue')['default']
     HelloWorld: typeof import('./components/HelloWorld.vue')['default']
+    IEpArrowDown: typeof import('~icons/ep/arrow-down')['default']
+    IEpArrowLeft: typeof import('~icons/ep/arrow-left')['default']
+    IEpArrowRight: typeof import('~icons/ep/arrow-right')['default']
+    IEpArrowUp: typeof import('~icons/ep/arrow-up')['default']
+    IEpBottomLeft: typeof import('~icons/ep/bottom-left')['default']
+    IEpBottomRight: typeof import('~icons/ep/bottom-right')['default']
+    IEpLoading: typeof import('~icons/ep/loading')['default']
+    IEpTopLeft: typeof import('~icons/ep/top-left')['default']
+    IEpTopRight: typeof import('~icons/ep/top-right')['default']
     LangDropdown: typeof import('./components/LangDropdown.vue')['default']
     LangDropdown: typeof import('./components/LangDropdown.vue')['default']
     PTZController: typeof import('./components/PTZController.vue')['default']
     PTZController: typeof import('./components/PTZController.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterLink: typeof import('vue-router')['RouterLink']

+ 1 - 1
src/components/PTZController.vue

@@ -11,7 +11,7 @@ const props = withDefaults(
     config: () => ({
     config: () => ({
       host: '192.168.0.64',
       host: '192.168.0.64',
       username: 'admin',
       username: 'admin',
-      password: 'admin123',
+      password: 'Wxc767718929',
       channel: 1
       channel: 1
     })
     })
   }
   }

+ 66 - 18
src/views/demo/rtsp-stream.vue

@@ -27,24 +27,43 @@
     </div>
     </div>
 
 
     <!-- 播放器区域 -->
     <!-- 播放器区域 -->
-    <div class="player-section">
-      <div v-if="!currentSrc" class="player-placeholder">
-        <el-icon :size="60" color="#ddd"><VideoPlay /></el-icon>
-        <p>请输入 RTSP 转换服务地址并点击播放</p>
+    <div class="player-wrapper">
+      <div class="player-section">
+        <div v-if="!currentSrc" class="player-placeholder">
+          <el-icon :size="60" color="#ddd"><VideoPlay /></el-icon>
+          <p>请输入 RTSP 转换服务地址并点击播放</p>
+        </div>
+        <VideoPlayer
+          v-else
+          ref="playerRef"
+          player-type="hls"
+          :src="currentSrc"
+          :autoplay="playConfig.autoplay"
+          :muted="playConfig.muted"
+          :controls="true"
+          @play="onPlay"
+          @pause="onPause"
+          @error="onError"
+          @loadedmetadata="onLoaded"
+        />
+      </div>
+
+      <!-- PTZ 云台控制 -->
+      <div class="ptz-section">
+        <h4>云台控制</h4>
+        <PTZController :config="ptzConfig" />
+        <el-form label-width="80px" size="small" style="margin-top: 15px">
+          <el-form-item label="摄像头IP">
+            <el-input v-model="ptzConfig.host" placeholder="192.168.0.64" />
+          </el-form-item>
+          <el-form-item label="用户名">
+            <el-input v-model="ptzConfig.username" />
+          </el-form-item>
+          <el-form-item label="密码">
+            <el-input v-model="ptzConfig.password" type="password" show-password />
+          </el-form-item>
+        </el-form>
       </div>
       </div>
-      <VideoPlayer
-        v-else
-        ref="playerRef"
-        player-type="hls"
-        :src="currentSrc"
-        :autoplay="playConfig.autoplay"
-        :muted="playConfig.muted"
-        :controls="true"
-        @play="onPlay"
-        @pause="onPause"
-        @error="onError"
-        @loadedmetadata="onLoaded"
-      />
     </div>
     </div>
 
 
     <!-- 播放控制 -->
     <!-- 播放控制 -->
@@ -97,6 +116,8 @@ import { ref, reactive } from 'vue'
 import { ElMessage } from 'element-plus'
 import { ElMessage } from 'element-plus'
 import { VideoPlay } from '@element-plus/icons-vue'
 import { VideoPlay } from '@element-plus/icons-vue'
 import VideoPlayer from '@/components/VideoPlayer.vue'
 import VideoPlayer from '@/components/VideoPlayer.vue'
+import PTZController from '@/components/PTZController.vue'
+import type { PTZConfig } from '@/api/ptz'
 
 
 const playerRef = ref<InstanceType<typeof VideoPlayer>>()
 const playerRef = ref<InstanceType<typeof VideoPlayer>>()
 
 
@@ -106,6 +127,14 @@ const rtspConfig = reactive({
   proxyUrl: ''
   proxyUrl: ''
 })
 })
 
 
+// PTZ 配置
+const ptzConfig = reactive<PTZConfig>({
+  host: '192.168.0.64',
+  username: 'admin',
+  password: 'Wxc767718929',
+  channel: 1
+})
+
 // 播放配置
 // 播放配置
 const playConfig = reactive({
 const playConfig = reactive({
   autoplay: false,
   autoplay: false,
@@ -213,9 +242,15 @@ function onError(error: any) {
   border-radius: var(--radius-base);
   border-radius: var(--radius-base);
 }
 }
 
 
+.player-wrapper {
+  display: flex;
+  gap: 20px;
+  margin-bottom: 20px;
+}
+
 .player-section {
 .player-section {
+  flex: 1;
   height: 500px;
   height: 500px;
-  margin-bottom: 20px;
   border-radius: var(--radius-base);
   border-radius: var(--radius-base);
   overflow: hidden;
   overflow: hidden;
   background-color: #000;
   background-color: #000;
@@ -235,6 +270,19 @@ function onError(error: any) {
   }
   }
 }
 }
 
 
+.ptz-section {
+  width: 220px;
+  padding: 15px;
+  background-color: var(--bg-container);
+  border-radius: var(--radius-base);
+
+  h4 {
+    margin: 0 0 15px;
+    font-size: 14px;
+    color: var(--text-primary);
+  }
+}
+
 .control-section {
 .control-section {
   padding: 15px 20px;
   padding: 15px 20px;
   background-color: var(--bg-container);
   background-color: var(--bg-container);