From e9b491dd086c1a65fa9bbcb492953b2e50714c2e Mon Sep 17 00:00:00 2001
From: suixy <2277317060@qq.com>
Date: Tue, 16 Jun 2026 10:47:45 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=A7=86=E9=A2=91RTSP?=
=?UTF-8?q?=E8=BF=9E=E6=8E=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 21 +++
package.json | 2 +
serve/control.js | 388 +++++++++++++++++++++++++++++++++++++++
serve/rtsp-ws-server.js | 1 +
src/api/control.js | 109 +++++++++++
src/utils/request.js | 22 +++
src/view/video/index.vue | 115 +++++++++---
vite.config.js | 8 +
8 files changed, 640 insertions(+), 26 deletions(-)
create mode 100644 .env.example
create mode 100644 src/api/control.js
create mode 100644 src/utils/request.js
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..48a3ed2
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,21 @@
+CAMERA_HOST=192.168.1.68
+CAMERA_PORT=554
+CAMERA_USER=admin
+CAMERA_PASSWORD=change-me
+CAMERA_RTSP_PATH=/Stream/Live/101
+
+WS_HOST=0.0.0.0
+WS_PORT=8080
+WS_PATH=/video
+RTSP_TRANSPORT=tcp
+VIDEO_TRANSCODE=0
+FFMPEG_PATH=ffmpeg
+
+CONTROL_HOST=0.0.0.0
+CONTROL_PORT=8090
+CONTROL_TRANSPORT=tcp
+CONTROL_TCP_HOST=192.168.1.123
+CONTROL_TCP_PORT=502
+CONTROL_ADDRESS=1
+CONTROL_SPEED=32
+VITE_CONTROL_API_URL=http://localhost:8090/control
diff --git a/package.json b/package.json
index 2cba85f..66a6fe8 100644
--- a/package.json
+++ b/package.json
@@ -6,11 +6,13 @@
"scripts": {
"dev": "vite --host 0.0.0.0",
"serve:video": "node serve/rtsp-ws-server.js",
+ "serve:control": "node serve/control.js",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
+ "axios": "^1.18.0",
"element-plus": "^2.11.8",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
diff --git a/serve/control.js b/serve/control.js
index e69de29..dc11d37 100644
--- a/serve/control.js
+++ b/serve/control.js
@@ -0,0 +1,388 @@
+import dgram from 'node:dgram'
+import {existsSync, readFileSync} from 'node:fs'
+import http from 'node:http'
+import net from 'node:net'
+import {resolve} from 'node:path'
+import {fileURLToPath} from 'node:url'
+
+loadDotEnv()
+
+const DEFAULT_ADDRESS = 0x01
+const DEFAULT_SPEED = 0x20
+
+const CONTROL_HOST = process.env.CONTROL_HOST || '0.0.0.0'
+const CONTROL_PORT = Number(process.env.CONTROL_PORT || 8090)
+const CONTROL_TRANSPORT = process.env.CONTROL_TRANSPORT || 'log'
+const CONTROL_TCP_HOST = process.env.CONTROL_TCP_HOST || ''
+const CONTROL_TCP_PORT = Number(process.env.CONTROL_TCP_PORT || 0)
+const CONTROL_UDP_HOST = process.env.CONTROL_UDP_HOST || ''
+const CONTROL_UDP_PORT = Number(process.env.CONTROL_UDP_PORT || 0)
+const CONTROL_ADDRESS = parseByte(process.env.CONTROL_ADDRESS, DEFAULT_ADDRESS)
+const CONTROL_SPEED = parseByte(process.env.CONTROL_SPEED, DEFAULT_SPEED)
+
+function loadDotEnv() {
+ const envPath = resolve(process.cwd(), '.env')
+
+ if (!existsSync(envPath)) {
+ return
+ }
+
+ const lines = readFileSync(envPath, 'utf8').split(/\r?\n/)
+ for (const line of lines) {
+ const trimmed = line.trim()
+ if (!trimmed || trimmed.startsWith('#')) {
+ continue
+ }
+
+ const separatorIndex = trimmed.indexOf('=')
+ if (separatorIndex === -1) {
+ continue
+ }
+
+ const key = trimmed.slice(0, separatorIndex).trim()
+ let value = trimmed.slice(separatorIndex + 1).trim()
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ value = value.slice(1, -1)
+ }
+
+ if (!process.env[key]) {
+ process.env[key] = value
+ }
+ }
+}
+
+function parseByte(value, fallback) {
+ if (!value) {
+ return fallback
+ }
+
+ const normalized = String(value).trim().toLowerCase()
+ const parsed = normalized.startsWith('0x')
+ ? Number.parseInt(normalized.slice(2), 16)
+ : Number.parseInt(normalized, 10)
+
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 0xff) {
+ return fallback
+ }
+
+ return parsed
+}
+
+function clampPelcoSpeed(value) {
+ return Math.max(0x00, Math.min(0x3f, value))
+}
+
+function createPelcoDCommand(command1, command2, data1 = 0x00, data2 = 0x00) {
+ const address = CONTROL_ADDRESS
+ const checksum = (address + command1 + command2 + data1 + data2) & 0xff
+ return Buffer.from([
+ 0xff,
+ address,
+ command1,
+ command2,
+ data1,
+ data2,
+ checksum,
+ ])
+}
+
+function createPresetCommand(operation, presetNo) {
+ const preset = parseByte(presetNo, 0x00)
+
+ if (preset < 0x01 || preset > 0xff) {
+ throw new Error(`Invalid preset number: ${presetNo}`)
+ }
+
+ const command2ByOperation = {
+ set: 0x03,
+ clear: 0x05,
+ call: 0x07,
+ }
+ const command2 = command2ByOperation[operation]
+
+ if (command2 === undefined) {
+ throw new Error(`Invalid preset operation: ${operation}`)
+ }
+
+ return createPelcoDCommand(0x00, command2, 0x00, preset)
+}
+
+export const CONTROL_COMMANDS = Object.freeze({
+ PTZ_UP: 'PTZ_UP',
+ PTZ_DOWN: 'PTZ_DOWN',
+ PTZ_LEFT: 'PTZ_LEFT',
+ PTZ_RIGHT: 'PTZ_RIGHT',
+ PTZ_STOP: 'PTZ_STOP',
+ ZOOM_TELE: 'ZOOM_TELE',
+ ZOOM_WIDE: 'ZOOM_WIDE',
+ FOCUS_FAR: 'FOCUS_FAR',
+ FOCUS_NEAR: 'FOCUS_NEAR',
+ IRIS_OPEN: 'IRIS_OPEN',
+ IRIS_CLOSE: 'IRIS_CLOSE',
+ AUX_2_ON: 'AUX_2_ON',
+ AUX_2_OFF: 'AUX_2_OFF',
+ PRESET_1_CALL: 'PRESET_1_CALL',
+ LASER_ON: 'LASER_ON',
+ LASER_AUTO: 'LASER_AUTO',
+ LASER_OFF: 'LASER_OFF',
+ AUTO_FOCUS_ON: 'AUTO_FOCUS_ON',
+ AUTO_FOCUS_OFF: 'AUTO_FOCUS_OFF',
+ DEFOG_ON: 'DEFOG_ON',
+ DEFOG_OFF: 'DEFOG_OFF',
+ DEFROST_ON: 'DEFROST_ON',
+ DEFROST_OFF: 'DEFROST_OFF',
+ REBOOT: 'REBOOT',
+})
+
+export function buildControlCommand(action) {
+ const speed = clampPelcoSpeed(CONTROL_SPEED)
+
+ switch (action) {
+ case CONTROL_COMMANDS.PTZ_UP:
+ return createPelcoDCommand(0x00, 0x08, 0x00, speed)
+ case CONTROL_COMMANDS.PTZ_DOWN:
+ return createPelcoDCommand(0x00, 0x10, 0x00, speed)
+ case CONTROL_COMMANDS.PTZ_LEFT:
+ return createPelcoDCommand(0x00, 0x04, speed, 0x00)
+ case CONTROL_COMMANDS.PTZ_RIGHT:
+ return createPelcoDCommand(0x00, 0x02, speed, 0x00)
+ case CONTROL_COMMANDS.PTZ_STOP:
+ return createPelcoDCommand(0x00, 0x00, 0x00, 0x00)
+ case CONTROL_COMMANDS.ZOOM_TELE:
+ return createPelcoDCommand(0x00, 0x20, 0x00, 0x00)
+ case CONTROL_COMMANDS.ZOOM_WIDE:
+ return createPelcoDCommand(0x00, 0x40, 0x00, 0x00)
+ case CONTROL_COMMANDS.FOCUS_FAR:
+ return createPelcoDCommand(0x00, 0x80, 0x00, 0x00)
+ case CONTROL_COMMANDS.FOCUS_NEAR:
+ return createPelcoDCommand(0x01, 0x00, 0x00, 0x00)
+ case CONTROL_COMMANDS.IRIS_OPEN:
+ return createPelcoDCommand(0x02, 0x00, 0x00, 0x00)
+ case CONTROL_COMMANDS.IRIS_CLOSE:
+ return createPelcoDCommand(0x04, 0x00, 0x00, 0x00)
+ case CONTROL_COMMANDS.AUX_2_ON:
+ return createPelcoDCommand(0x00, 0x09, 0x00, 0x02)
+ case CONTROL_COMMANDS.AUX_2_OFF:
+ return createPelcoDCommand(0x00, 0x0b, 0x00, 0x02)
+ case CONTROL_COMMANDS.PRESET_1_CALL:
+ return createPresetCommand('call', 1)
+ case CONTROL_COMMANDS.LASER_ON:
+ return createPresetCommand('call', 224)
+ case CONTROL_COMMANDS.LASER_AUTO:
+ return createPresetCommand('call', 225)
+ case CONTROL_COMMANDS.LASER_OFF:
+ return createPresetCommand('call', 226)
+ case CONTROL_COMMANDS.AUTO_FOCUS_ON:
+ return createPresetCommand('call', 135)
+ case CONTROL_COMMANDS.AUTO_FOCUS_OFF:
+ return createPresetCommand('call', 136)
+ case CONTROL_COMMANDS.DEFOG_ON:
+ return createPresetCommand('call', 252)
+ case CONTROL_COMMANDS.DEFOG_OFF:
+ return createPresetCommand('call', 253)
+ case CONTROL_COMMANDS.DEFROST_ON:
+ return createPresetCommand('call', 250)
+ case CONTROL_COMMANDS.DEFROST_OFF:
+ return createPresetCommand('call', 251)
+ case CONTROL_COMMANDS.REBOOT:
+ return createPresetCommand('call', 220)
+ default:
+ throw new Error(`Unsupported control action: ${action}`)
+ }
+}
+
+function writeTcp(command) {
+ return new Promise((resolve, reject) => {
+ if (!CONTROL_TCP_HOST || !CONTROL_TCP_PORT) {
+ reject(new Error('Missing CONTROL_TCP_HOST or CONTROL_TCP_PORT'))
+ return
+ }
+
+ const socket = net.createConnection({
+ host: CONTROL_TCP_HOST,
+ port: CONTROL_TCP_PORT,
+ })
+
+ socket.setTimeout(3000)
+ socket.on('connect', () => {
+ socket.end(command)
+ })
+ socket.on('close', resolve)
+ socket.on('timeout', () => {
+ socket.destroy(new Error('Control TCP connection timed out'))
+ })
+ socket.on('error', reject)
+ })
+}
+
+function writeUdp(command) {
+ return new Promise((resolve, reject) => {
+ if (!CONTROL_UDP_HOST || !CONTROL_UDP_PORT) {
+ reject(new Error('Missing CONTROL_UDP_HOST or CONTROL_UDP_PORT'))
+ return
+ }
+
+ const socket = dgram.createSocket('udp4')
+ socket.send(command, CONTROL_UDP_PORT, CONTROL_UDP_HOST, (error) => {
+ socket.close()
+
+ if (error) {
+ reject(error)
+ return
+ }
+
+ resolve()
+ })
+ })
+}
+
+export async function sendControlAction(action) {
+ const command = buildControlCommand(action)
+ const hex = command.toString('hex').match(/../g).join(' ').toUpperCase()
+
+ if (CONTROL_TRANSPORT === 'tcp') {
+ await writeTcp(command)
+ } else if (CONTROL_TRANSPORT === 'udp') {
+ await writeUdp(command)
+ } else {
+ console.log(`[control] dry-run ${action}: ${hex}`)
+ }
+
+ return {
+ action,
+ hex,
+ transport: CONTROL_TRANSPORT,
+ }
+}
+
+export function getControlStatus() {
+ return {
+ host: CONTROL_HOST,
+ port: CONTROL_PORT,
+ transport: CONTROL_TRANSPORT,
+ address: CONTROL_ADDRESS,
+ speed: clampPelcoSpeed(CONTROL_SPEED),
+ tcpConfigured: Boolean(CONTROL_TCP_HOST && CONTROL_TCP_PORT),
+ udpConfigured: Boolean(CONTROL_UDP_HOST && CONTROL_UDP_PORT),
+ }
+}
+
+function writeJson(response, statusCode, payload) {
+ response.writeHead(statusCode, {
+ 'access-control-allow-origin': '*',
+ 'access-control-allow-methods': 'GET,POST,OPTIONS',
+ 'access-control-allow-headers': 'content-type',
+ 'content-type': 'application/json; charset=utf-8',
+ })
+ response.end(JSON.stringify(payload))
+}
+
+function readRequestJson(request) {
+ return new Promise((resolve, reject) => {
+ let body = ''
+
+ request.setEncoding('utf8')
+ request.on('data', (chunk) => {
+ body += chunk
+
+ if (body.length > 1024 * 1024) {
+ request.destroy(new Error('Request body is too large'))
+ }
+ })
+ request.on('end', () => {
+ if (!body.trim()) {
+ resolve({})
+ return
+ }
+
+ try {
+ resolve(JSON.parse(body))
+ } catch {
+ reject(new Error('Invalid JSON body'))
+ }
+ })
+ request.on('error', reject)
+ })
+}
+
+async function handleControlRequest(request, response) {
+ try {
+ const payload = await readRequestJson(request)
+ const action = payload.action
+
+ if (!action || typeof action !== 'string') {
+ writeJson(response, 400, {
+ ok: false,
+ message: 'Missing action',
+ })
+ return
+ }
+
+ const result = await sendControlAction(action)
+ writeJson(response, 200, {
+ ok: true,
+ ...result,
+ })
+ } catch (error) {
+ writeJson(response, 400, {
+ ok: false,
+ message: error.message,
+ })
+ }
+}
+
+export function startControlServer() {
+ const server = http.createServer((request, response) => {
+ if (request.method === 'OPTIONS') {
+ writeJson(response, 204, {})
+ return
+ }
+
+ if (request.method === 'GET' && request.url === '/health') {
+ writeJson(response, 200, {
+ ok: true,
+ control: getControlStatus(),
+ })
+ return
+ }
+
+ if (request.method === 'GET' && request.url === '/commands') {
+ writeJson(response, 200, {
+ ok: true,
+ commands: Object.values(CONTROL_COMMANDS),
+ })
+ return
+ }
+
+ if (request.method === 'POST' && request.url === '/control') {
+ handleControlRequest(request, response)
+ return
+ }
+
+ writeJson(response, 404, {
+ ok: false,
+ message: 'Not Found',
+ })
+ })
+
+ server.on('error', (error) => {
+ console.error(`[control] failed to listen on ${CONTROL_HOST}:${CONTROL_PORT}: ${error.message}`)
+ process.exitCode = 1
+ })
+
+ server.listen(CONTROL_PORT, CONTROL_HOST, () => {
+ console.log(`[control] http://${CONTROL_HOST}:${CONTROL_PORT}`)
+ console.log(`[control] transport=${CONTROL_TRANSPORT}`)
+ })
+
+ return server
+}
+
+const currentFile = fileURLToPath(import.meta.url)
+
+if (process.argv[1] === currentFile) {
+ startControlServer()
+}
diff --git a/serve/rtsp-ws-server.js b/serve/rtsp-ws-server.js
index 59f36be..57f7058 100644
--- a/serve/rtsp-ws-server.js
+++ b/serve/rtsp-ws-server.js
@@ -17,6 +17,7 @@ const VIDEO_TRANSCODE = process.env.VIDEO_TRANSCODE === '1'
function loadDotEnv() {
const envPath = resolve(process.cwd(), '.env')
+
if (!existsSync(envPath)) {
return
}
diff --git a/src/api/control.js b/src/api/control.js
new file mode 100644
index 0000000..5d68d5a
--- /dev/null
+++ b/src/api/control.js
@@ -0,0 +1,109 @@
+import request from '../utils/request'
+
+const DEFAULT_SPEED = 50
+
+export function stopPt() {
+ return request({
+ url: '/api/Pt/Stop',
+ method: 'post',
+ })
+}
+
+export function moveUp(speed = DEFAULT_SPEED) {
+ return request({
+ url: '/api/Pt/MoveUp',
+ method: 'post',
+ params: { speed },
+ })
+}
+
+export function moveDown(speed = DEFAULT_SPEED) {
+ return request({
+ url: '/api/Pt/MoveDown',
+ method: 'post',
+ params: { speed },
+ })
+}
+
+export function moveLeft(speed = DEFAULT_SPEED) {
+ return request({
+ url: '/api/Pt/MoveLeft',
+ method: 'post',
+ params: { speed },
+ })
+}
+
+export function moveRight(speed = DEFAULT_SPEED) {
+ return request({
+ url: '/api/Pt/MoveRight',
+ method: 'post',
+ params: { speed },
+ })
+}
+
+export function zoomTele() {
+ return request({
+ url: '/api/Pt/ZoomTele',
+ method: 'post',
+ })
+}
+
+export function zoomWide() {
+ return request({
+ url: '/api/Pt/ZoomWide',
+ method: 'post',
+ })
+}
+
+export function callPreset1() {
+ return request({
+ url: '/api/Pt/CallPreset',
+ method: 'post',
+ params: { presetNo: 1 },
+ })
+}
+
+export function laserOn() {
+ return request({
+ url: '/api/Pt/LaserOn',
+ method: 'post',
+ })
+}
+
+export function laserAuto() {
+ return request({
+ url: '/api/Pt/LaserAuto',
+ method: 'post',
+ })
+}
+
+export function laserOff() {
+ return request({
+ url: '/api/Pt/LaserOff',
+ method: 'post',
+ })
+}
+
+export function autoFocusOn() {
+ return request({
+ url: '/api/Pt/CallPreset',
+ method: 'post',
+ params: { presetNo: 135 },
+ })
+}
+
+export function defogOn() {
+ return request({
+ url: '/api/Pt/CallPreset',
+ method: 'post',
+ params: { presetNo: 252 },
+ })
+}
+
+export function defrostOn() {
+ return request({
+ url: '/api/Pt/CallPreset',
+ method: 'post',
+ params: { presetNo: 250 },
+ })
+}
diff --git a/src/utils/request.js b/src/utils/request.js
new file mode 100644
index 0000000..bb581b1
--- /dev/null
+++ b/src/utils/request.js
@@ -0,0 +1,22 @@
+import axios from 'axios'
+
+const service = axios.create({
+ baseURL: import.meta.env.VITE_APP_BASE_API || '/dev-api',
+ timeout: 10000
+})
+
+service.interceptors.request.use(
+ (config) => config,
+ (error) => Promise.reject(error)
+)
+
+service.interceptors.response.use(
+ (response) => response.data,
+ (error) => Promise.reject(error)
+)
+
+export function request(config) {
+ return service(config)
+}
+
+export default request
diff --git a/src/view/video/index.vue b/src/view/video/index.vue
index 4986a75..dc75c17 100644
--- a/src/view/video/index.vue
+++ b/src/view/video/index.vue
@@ -22,34 +22,50 @@
type="primary"
:icon="ArrowUp"
aria-label="向上"
- @click="sendControl('up')"
+ @mousedown.prevent="startMoveUp"
+ @mouseup="stopControl"
+ @mouseleave="stopControl"
+ @touchstart.prevent="startMoveUp"
+ @touchend="stopControl"
/>