修改视频RTSP连接

main
suixy 5 days ago
parent 0a118e73bf
commit e9b491dd08

@ -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

@ -6,11 +6,13 @@
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"serve:video": "node serve/rtsp-ws-server.js", "serve:video": "node serve/rtsp-ws-server.js",
"serve:control": "node serve/control.js",
"build": "vite build", "build": "vite build",
"preview": "vite preview --host 0.0.0.0" "preview": "vite preview --host 0.0.0.0"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.18.0",
"element-plus": "^2.11.8", "element-plus": "^2.11.8",
"vue": "^3.5.24", "vue": "^3.5.24",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"

@ -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()
}

@ -17,6 +17,7 @@ const VIDEO_TRANSCODE = process.env.VIDEO_TRANSCODE === '1'
function loadDotEnv() { function loadDotEnv() {
const envPath = resolve(process.cwd(), '.env') const envPath = resolve(process.cwd(), '.env')
if (!existsSync(envPath)) { if (!existsSync(envPath)) {
return return
} }

@ -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 },
})
}

@ -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

@ -22,34 +22,50 @@
type="primary" type="primary"
:icon="ArrowUp" :icon="ArrowUp"
aria-label="向上" aria-label="向上"
@click="sendControl('up')" @mousedown.prevent="startMoveUp"
@mouseup="stopControl"
@mouseleave="stopControl"
@touchstart.prevent="startMoveUp"
@touchend="stopControl"
/> />
<el-button <el-button
class="control-btn left" class="control-btn left"
type="primary" type="primary"
:icon="ArrowLeft" :icon="ArrowLeft"
aria-label="向左" aria-label="向左"
@click="sendControl('left')" @mousedown.prevent="startMoveLeft"
@mouseup="stopControl"
@mouseleave="stopControl"
@touchstart.prevent="startMoveLeft"
@touchend="stopControl"
/> />
<el-button <el-button
class="control-btn center" class="control-btn center"
:icon="Aim" :icon="Aim"
aria-label="居中" aria-label="居中"
@click="sendControl('center')" @click="runControl(callPreset1)"
/> />
<el-button <el-button
class="control-btn right" class="control-btn right"
type="primary" type="primary"
:icon="ArrowRight" :icon="ArrowRight"
aria-label="向右" aria-label="向右"
@click="sendControl('right')" @mousedown.prevent="startMoveRight"
@mouseup="stopControl"
@mouseleave="stopControl"
@touchstart.prevent="startMoveRight"
@touchend="stopControl"
/> />
<el-button <el-button
class="control-btn down" class="control-btn down"
type="primary" type="primary"
:icon="ArrowDown" :icon="ArrowDown"
aria-label="向下" aria-label="向下"
@click="sendControl('down')" @mousedown.prevent="startMoveDown"
@mouseup="stopControl"
@mouseleave="stopControl"
@touchstart.prevent="startMoveDown"
@touchend="stopControl"
/> />
</div> </div>
@ -58,24 +74,32 @@
class="zoom-btn" class="zoom-btn"
:icon="ZoomIn" :icon="ZoomIn"
aria-label="放大" aria-label="放大"
@click="sendControl('zoom-in')" @mousedown.prevent="startZoomTele"
@mouseup="stopControl"
@mouseleave="stopControl"
@touchstart.prevent="startZoomTele"
@touchend="stopControl"
/> />
<el-button <el-button
class="zoom-btn" class="zoom-btn"
:icon="ZoomOut" :icon="ZoomOut"
aria-label="缩小" aria-label="缩小"
@click="sendControl('zoom-out')" @mousedown.prevent="startZoomWide"
@mouseup="stopControl"
@mouseleave="stopControl"
@touchstart.prevent="startZoomWide"
@touchend="stopControl"
/> />
</div> </div>
<div class="function-grid"> <div class="function-grid">
<el-button <el-button
v-for="item in functionButtons" v-for="item in functionButtons"
:key="item" :key="item.label"
class="function-btn" class="function-btn"
@click="sendControl(item)" @click="runControl(item.request)"
> >
{{ item }} {{ item.label }}
</el-button> </el-button>
</div> </div>
</aside> </aside>
@ -93,6 +117,22 @@ import {
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
} from '@element-plus/icons-vue' } 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 WS_STREAM_URL = import.meta.env.VITE_VIDEO_WS_URL || 'ws://localhost:8080/video'
const MIME_CODEC = const MIME_CODEC =
@ -106,12 +146,12 @@ const queue = []
const connectionState = ref('连接中') const connectionState = ref('连接中')
const streamUrl = computed(() => WS_STREAM_URL) const streamUrl = computed(() => WS_STREAM_URL)
const functionButtons = [ const functionButtons = [
'功能1', {label: '激光开', request: laserOn},
'功能2', {label: '激光自动', request: laserAuto},
'功能3', {label: '激光关', request: laserOff},
'功能4', {label: '自动聚焦', request: autoFocusOn},
'功能5', {label: '透雾开', request: defogOn},
'功能6' {label: '除霜开', request: defrostOn},
] ]
function appendNextChunk() { function appendNextChunk() {
@ -181,19 +221,42 @@ function connectStream() {
}) })
} }
function sendControl(action) { async function runControl(requestFn) {
const ws = wsRef.value try {
const payload = JSON.stringify({ await requestFn()
type: 'control', } catch (error) {
action, console.error('Send control failed:', error)
sentAt: new Date().toISOString(),
})
if (ws?.readyState === WebSocket.OPEN) {
ws.send(payload)
} }
} }
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) onMounted(setupMediaSource)
onBeforeUnmount(() => { onBeforeUnmount(() => {

@ -6,5 +6,13 @@ export default defineConfig({
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 5173, port: 5173,
proxy: {
'/dev-api': {
target: 'http://localhost:8001',
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(new RegExp('^' + '/dev-api'), '')
},
}
}, },
}) })

Loading…
Cancel
Save