Files

244 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 智慧停车设备模拟服务
* - 通过 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); // 错开连接,避免同时冲击
});