feat: 完善智慧停车综合管理平台源码
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* 智慧停车设备模拟服务
|
||||
* - 通过 WebSocket 伪装算法端(ai-end)注册,使摄像头在平台上线
|
||||
* - 调用 ffmpeg 将本地模拟视频循环推送到 nginx-rtmp,生成 HLS 供监控页播放
|
||||
* - 按摄像头报警规则随机上报报警事件(抓拍图转 base64)
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { io } = require('socket.io-client');
|
||||
|
||||
// 发送前对明文密码做 HMAC-SHA256(base64),与库中哈希比对
|
||||
const HMAC_KEY = 'smart-parking-platform';
|
||||
const hashPassword = (plain) =>
|
||||
crypto.createHmac('sha256', HMAC_KEY).update(plain).digest('base64');
|
||||
|
||||
const HTTP_SERVER_URL = (process.env.HTTP_SERVER_URL || 'http://app').replace(/\/+$/, '');
|
||||
const RTMP_SERVER_URL = process.env.RTMP_SERVER_URL || 'rtmp://app:1515/live';
|
||||
const USERNAME = process.env.ADMIN_USERNAME || 'admin';
|
||||
const PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
const ASSETS_DIR = process.env.ASSETS_DIR || '/assets';
|
||||
const FFMPEG_BIN = process.env.FFMPEG_BIN || 'ffmpeg';
|
||||
|
||||
// 要模拟的摄像头 ID 列表;逗号分隔,默认 1..12(与 seeder 的 12 路对应)
|
||||
const CAMERA_IDS = (process.env.CAMERA_IDS || '1,2,3,4,5,6,7,8,9,10,11,12')
|
||||
.split(',')
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => Number.isInteger(n) && n > 0);
|
||||
|
||||
// 摄像头 ID 与素材文件一一对应(cam-<两位ID>.mp4)
|
||||
const videoFileFor = (cameraID) =>
|
||||
`cam-${String(cameraID).padStart(2, '0')}.mp4`;
|
||||
|
||||
// 报警抓拍图(与 seeder 中 algorithmType 对应:body / vehicle)
|
||||
const SHOTS = {
|
||||
body: ['alarm-crowd.jpg', 'alarm-bicycle.jpg'],
|
||||
vehicle: ['alarm-parking.jpg', 'alarm-peak.jpg'],
|
||||
};
|
||||
|
||||
const log = (...args) => console.log(`[${new Date().toISOString()}]`, ...args);
|
||||
|
||||
const devices = new Map();
|
||||
|
||||
function startFfmpeg(cameraID, videoFile) {
|
||||
const videoPath = path.join(ASSETS_DIR, videoFile);
|
||||
const target = `${RTMP_SERVER_URL}/${cameraID}`;
|
||||
const args = [
|
||||
'-re',
|
||||
'-stream_loop', '-1',
|
||||
'-i', videoPath,
|
||||
'-an',
|
||||
'-c:v', 'copy',
|
||||
'-f', 'flv',
|
||||
target,
|
||||
];
|
||||
return spawn(FFMPEG_BIN, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
}
|
||||
|
||||
/**
|
||||
* 每台设备的推流管理器:
|
||||
* - 仅在 WS 在线时保持推流;
|
||||
* - ffmpeg 异常退出后自动重启,退避 3s→6s→…封顶 30s;
|
||||
* - app 重建导致 RTMP 断连时也能自愈。
|
||||
*/
|
||||
class StreamManager {
|
||||
constructor(device, videoFile) {
|
||||
this.device = device;
|
||||
this.videoFile = videoFile;
|
||||
this.proc = null;
|
||||
this.restartTimer = null;
|
||||
this.backoff = 3000;
|
||||
this.stopped = false;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.stopped = false;
|
||||
if (this.proc || this.restartTimer) return;
|
||||
if (!fs.existsSync(path.join(ASSETS_DIR, this.videoFile))) {
|
||||
log(`camera ${this.device.cameraID} 素材缺失:${this.videoFile}`);
|
||||
return;
|
||||
}
|
||||
const proc = startFfmpeg(this.device.cameraID, this.videoFile);
|
||||
this.proc = proc;
|
||||
|
||||
proc.stderr.on('data', (d) => {
|
||||
const line = d.toString();
|
||||
if (/conversion failed|broken pipe|connection refused|could not|error/i.test(line)) {
|
||||
log(`camera ${this.device.cameraID} ffmpeg: ${line.trim().slice(-160)}`);
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
this.proc = null;
|
||||
log(`camera ${this.device.cameraID} ffmpeg 退出 code=${code},准备重启`);
|
||||
if (!this.stopped) this.scheduleRestart();
|
||||
});
|
||||
}
|
||||
|
||||
scheduleRestart() {
|
||||
if (this.restartTimer || this.stopped) return;
|
||||
// app 尚未上线时,等待 WS 重新连接后再推
|
||||
if (!this.device.connected) {
|
||||
this.restartTimer = setTimeout(() => {
|
||||
this.restartTimer = null;
|
||||
this.scheduleRestart();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
const delay = this.backoff;
|
||||
this.backoff = Math.min(this.backoff * 2, 30000);
|
||||
this.restartTimer = setTimeout(() => {
|
||||
this.restartTimer = null;
|
||||
this.start();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// 成功稳定推流一段时间后可由外部复位退避;这里在 start 成功连接后保守复位
|
||||
resetBackoff() {
|
||||
this.backoff = 3000;
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
if (this.restartTimer) {
|
||||
clearTimeout(this.restartTimer);
|
||||
this.restartTimer = null;
|
||||
}
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill('SIGTERM');
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readShotBase64(file) {
|
||||
const buf = fs.readFileSync(path.join(ASSETS_DIR, file));
|
||||
return `data:image/jpeg;base64,${buf.toString('base64')}`;
|
||||
}
|
||||
|
||||
function scheduleAlarm(device) {
|
||||
// 每台设备 180~480 秒尝试一次报警,70% 概率真正触发
|
||||
const delay = 180000 + Math.random() * 300000;
|
||||
device.alarmTimer = setTimeout(() => {
|
||||
const rules = device.rules || [];
|
||||
if (device.connected && rules.length > 0 && Math.random() < 0.7) {
|
||||
const rule = rules[Math.floor(Math.random() * rules.length)];
|
||||
const pool = SHOTS[rule.algorithmType] || SHOTS.vehicle;
|
||||
const shot = pool[Math.floor(Math.random() * pool.length)];
|
||||
try {
|
||||
const picBase64 = readShotBase64(shot);
|
||||
device.socket.emit('alarm', { picBase64, alarmRuleID: rule.id });
|
||||
log(`camera ${device.cameraID} 上报报警 rule=${rule.id}(${rule.name}) shot=${shot}`);
|
||||
} catch (e) {
|
||||
log(`camera ${device.cameraID} 报警失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
scheduleAlarm(device);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function startDevice(cameraID) {
|
||||
const videoFile = videoFileFor(cameraID);
|
||||
const socket = io(HTTP_SERVER_URL, {
|
||||
path: '/ws/ai/',
|
||||
extraHeaders: {
|
||||
data: JSON.stringify({
|
||||
username: USERNAME,
|
||||
password: hashPassword(PASSWORD),
|
||||
cameraID: String(cameraID),
|
||||
}),
|
||||
},
|
||||
transports: ['websocket'],
|
||||
reconnection: true,
|
||||
reconnectionDelay: 3000,
|
||||
});
|
||||
|
||||
const device = {
|
||||
cameraID,
|
||||
socket,
|
||||
stream: null,
|
||||
rules: [],
|
||||
connected: false,
|
||||
alarmTimer: null,
|
||||
};
|
||||
device.stream = new StreamManager(device, videoFile);
|
||||
|
||||
socket.on('connect', () => {
|
||||
device.connected = true;
|
||||
log(`camera ${cameraID} 已注册上线 (${videoFile})`);
|
||||
device.stream.resetBackoff();
|
||||
device.stream.start();
|
||||
scheduleAlarm(device);
|
||||
});
|
||||
|
||||
socket.on('cameraConfigChange', (config) => {
|
||||
device.rules = Array.isArray(config.alarmRules) ? config.alarmRules : [];
|
||||
log(`camera ${cameraID} 收到规则 ${device.rules.map((r) => r.id).join(',') || '无'}`);
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
device.connected = false;
|
||||
log(`camera ${cameraID} 断开: ${reason},暂停推流等待重连`);
|
||||
device.stream.stop();
|
||||
});
|
||||
|
||||
socket.io.on('reconnect', () => {
|
||||
// connect 事件中已处理 start,这里仅记录
|
||||
log(`camera ${cameraID} 触发重连`);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (err) => {
|
||||
log(`camera ${cameraID} 连接错误: ${err.message}`);
|
||||
});
|
||||
|
||||
devices.set(cameraID, device);
|
||||
}
|
||||
|
||||
function shutdown() {
|
||||
log('正在停止模拟服务…');
|
||||
for (const device of devices.values()) {
|
||||
clearTimeout(device.alarmTimer);
|
||||
try {
|
||||
device.stream.stop();
|
||||
} catch {}
|
||||
try {
|
||||
device.socket.close();
|
||||
} catch {}
|
||||
}
|
||||
setTimeout(() => process.exit(0), 1000);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
log(`将模拟 ${CAMERA_IDS.length} 路摄像头:${CAMERA_IDS.join(',')}`);
|
||||
log(`平台=${HTTP_SERVER_URL} RTMP=${RTMP_SERVER_URL}`);
|
||||
CAMERA_IDS.forEach((id, idx) => {
|
||||
setTimeout(() => startDevice(id), idx * 1500); // 错开连接,避免同时冲击
|
||||
});
|
||||
Reference in New Issue
Block a user