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" /> @@ -58,24 +74,32 @@ class="zoom-btn" :icon="ZoomIn" aria-label="放大" - @click="sendControl('zoom-in')" + @mousedown.prevent="startZoomTele" + @mouseup="stopControl" + @mouseleave="stopControl" + @touchstart.prevent="startZoomTele" + @touchend="stopControl" />
- {{ item }} + {{ item.label }}
@@ -93,6 +117,22 @@ import { ZoomIn, ZoomOut, } from '@element-plus/icons-vue' +import { + autoFocusOn, + callPreset1, + defogOn, + defrostOn, + laserAuto, + laserOff, + laserOn, + moveDown, + moveLeft, + moveRight, + moveUp, + stopPt, + zoomTele, + zoomWide, +} from '../../api/control' const WS_STREAM_URL = import.meta.env.VITE_VIDEO_WS_URL || 'ws://localhost:8080/video' const MIME_CODEC = @@ -106,12 +146,12 @@ const queue = [] const connectionState = ref('连接中') const streamUrl = computed(() => WS_STREAM_URL) const functionButtons = [ - '功能1', - '功能2', - '功能3', - '功能4', - '功能5', - '功能6' + {label: '激光开', request: laserOn}, + {label: '激光自动', request: laserAuto}, + {label: '激光关', request: laserOff}, + {label: '自动聚焦', request: autoFocusOn}, + {label: '透雾开', request: defogOn}, + {label: '除霜开', request: defrostOn}, ] function appendNextChunk() { @@ -181,19 +221,42 @@ function connectStream() { }) } -function sendControl(action) { - const ws = wsRef.value - const payload = JSON.stringify({ - type: 'control', - action, - sentAt: new Date().toISOString(), - }) - - if (ws?.readyState === WebSocket.OPEN) { - ws.send(payload) +async function runControl(requestFn) { + try { + await requestFn() + } catch (error) { + console.error('Send control failed:', error) } } +function startMoveUp() { + runControl(moveUp) +} + +function startMoveDown() { + runControl(moveDown) +} + +function startMoveLeft() { + runControl(moveLeft) +} + +function startMoveRight() { + runControl(moveRight) +} + +function startZoomTele() { + runControl(zoomTele) +} + +function startZoomWide() { + runControl(zoomWide) +} + +function stopControl() { + runControl(stopPt) +} + onMounted(setupMediaSource) onBeforeUnmount(() => { diff --git a/vite.config.js b/vite.config.js index 6db2aa6..a2bd09d 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,5 +6,13 @@ export default defineConfig({ server: { host: '0.0.0.0', port: 5173, + proxy: { + '/dev-api': { + target: 'http://localhost:8001', + changeOrigin: true, + ws: true, + rewrite: (path) => path.replace(new RegExp('^' + '/dev-api'), '') + }, + } }, })