Files
appRobotVideoControls/server.js

283 lines
8.4 KiB
JavaScript
Executable File

// server.js
'use strict';
const fs = require('fs');
const path = require('path');
const https = require('https');
const express = require('express');
const helmet = require('helmet');
const compression = require('compression');
const WebSocket = require('ws');
const { FFmpegStreamer } = require('./programs/videoServer');
const { pickDevices } = require('./programs/input');
const driverWS = require("./programs/driver");
const screenShot = require("./programs/screenShot")
const {logHttpRequest, logTcpConnection, logHttpUpgrade, logWssConnected,
logWssClosed, connected, connectionLost} = require('./programs/log');
const PORT = Number(process.env.PORT || 8443);
const HOST = process.env.HOST || '0.0.0.0';
// --- TLS ---
const TLS_DIR = path.resolve(__dirname, 'https');
const serverOptions = {
key: fs.readFileSync(path.join(TLS_DIR, 'server.key')),
cert: fs.readFileSync(path.join(TLS_DIR, 'server.crt')),
...(fs.existsSync(path.join(TLS_DIR, 'dhparam.pem'))
? { dhparam: fs.readFileSync(path.join(TLS_DIR, 'dhparam.pem')) }
: {}),
requestCert: false,
rejectUnauthorized: false, // dev-friendly for self-signed
};
// --- Express + CSP ---
const app = express();
app.disable('x-powered-by');
app.use(compression());
app.use(
helmet({
contentSecurityPolicy: {
useDefaults: true,
directives: {
"default-src": ["'self'"],
"script-src": ["'self'"], // no inline JS
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:"],
"connect-src": ["'self'"], // same-origin WSS
"object-src": ["'none'"],
"base-uri": ["'self'"],
"frame-ancestors": ["'self'"]
}
},
})
);
app.use(express.static(path.join(__dirname, 'public'), { etag: true, maxAge: '1h' }));
app.get('/health', (_req, res) => res.status(200).send('ok'));
// --- HTTPS server ---
const server = https.createServer(serverOptions, app);
// Track sockets so shutdown is clean
const activeHttpSockets = new Set();
server.on('connection', (socket) => {
activeHttpSockets.add(socket);
socket.on('close', () => activeHttpSockets.delete(socket));
});
// --- WebSocket server ---
const wss = new WebSocket.Server({ noServer: true, perMessageDeflate: false });
// Heartbeat
function installHeartbeat(ws) {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
}
const pingInterval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
try { ws.ping(); } catch {}
});
}, 30000);
// Upgrade routing
server.on('upgrade', (request, socket, head) => {
try { socket.setNoDelay(true); } catch {}
const { url } = request;
if (!url || !url.startsWith('/ws/')) return socket.destroy();
if (url === '/ws/robot') {
console.log('Robot requested');
wssInput.handleUpgrade(request, socket, head, (ws) => {
ws.upgradePath = url;
wssInput.emit('connection', ws, request);
});
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
ws.upgradePath = url;
wss.emit('connection', ws, request);
});
});
// --- Streams ---
const [DEV0, DEV1] = pickDevices(process.env);
console.log(`[DEV] Using devices: ${DEV0} (video0), ${DEV1} (video1)`);
// Cam0: MJPEG pass-through if available (lowest latency)
const cam0 = new FFmpegStreamer(DEV0, {
name: 'video0',
fps: 30,
quality: 8, // 5 wäre besser
input: {
format: 'mjpeg',
fps: 30,
//size: '640x480',
//size: '1280x720',
size: '1280x960',
useWallclock: true,
threadQueueSize: 64,
channel: 0,
},
tryFormats: ['mjpeg', 'yuyv422', 'rgb24'],
});
// Cam1: your working timing on /dev/video2; let driver pick format first
const cam1 = new FFmpegStreamer(DEV1, {
name: 'video1',
fps: 30,
quality: 8, // 5 wäre besser
input: {
format: 'mjpeg', // driver decides
fps: 30,
//size: '640x480',
//size: '1280x720',
size: '1280x960',
useWallclock: true,
threadQueueSize: 64,
channel: 0,
},
tryFormats: ['mjpeg', 'yuyv422', 'rgb24'],
});
cam0.start();
cam1.start();
const wssInput = new WebSocket.Server({ noServer: true });
// WS connections
wss.on('connection', (ws, req) => {
logWssConnected(req);
installHeartbeat(ws);
const pathName = ws.upgradePath || req.url || '';
const controlEnabled = pathName === '/ws/video0';
if (pathName === '/ws/video0') cam0.attach(ws);
else if (pathName === '/ws/video1') cam1.attach(ws);
else return ws.close(1008, 'Unknown WS path');
// If the streamer isn't running when a client connects, try to start it.
// This makes the system more resilient to transient ffmpeg failures.
try {
if (pathName === '/ws/video0' && !cam0.running) {
console.log('[WS] client connected to /ws/video0 — starting cam0');
cam0.start();
}
if (pathName === '/ws/video1' && !cam1.running) {
console.log('[WS] client connected to /ws/video1 — starting cam1');
cam1.start();
}
} catch (err) { console.warn('[WS] failed to start streamer:', err?.message || err); }
ws.on('message', (data, isBinary) => {
if (isBinary || !controlEnabled) return;
let msg;
try { msg = JSON.parse(data.toString()); }
catch { return ws.send(JSON.stringify({ type: 'error', error: 'Invalid JSON' })); }
handleControlMessage(ws, msg);
});
ws.send(JSON.stringify({
type: 'hello',
path: pathName,
control: controlEnabled,
camera: pathName.endsWith('video0') ? 'video0' : 'video1',
}));
ws.on('close', (code, reason) => { logWssClosed(req, code, reason); });
});
wss.on("upgrade", (req, socket, head) => {
if (req.url === "/ws/robot") {
console.log("Robot requested");
wssInput.handleUpgrade(req, socket, head, (ws) => { wssInput.emit("connection", ws, req); });
}
});
const targetServer = process.env.TARGET_SERVER || 'wss://localhost:2095';
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
driverWS.setupCommandForwarding(wssInput, targetServer);
// Control channel (video0 only)
function handleControlMessage(ws, msg) {
if (msg?.type !== 'control') {
ws.send(JSON.stringify({ type: 'error', error: 'Unsupported message type' }));
return;
}
try {
switch (msg.action) {
case 'snapshot': {
const outDir = path.join(__dirname, 'public', 'snapshots');
screenShot.snapshot(outDir, cam0, cam1, ws);
break;
}
case 'setParams': {
const p = msg.params || {};
cam0.restart({
...(Number(p.width) ? { width: Number(p.width) } : { width: undefined }),
...(Number(p.height) ? { height: Number(p.height) } : { height: undefined }),
...(Number(p.fps) ? { fps: Number(p.fps) } : {}),
...(Number(p.quality) ? { quality: Number(p.quality) } : {}),
...(p.input ? { input: { ...cam0.opts.input, ...p.input } } : {}),
});
ws.send(JSON.stringify({ type: 'ack', action: 'setParams', params: { ...cam0.opts } }));
break;
}
case 'start': cam0.start(); ws.send(JSON.stringify({ type: 'ack', action: 'start' })); break;
case 'stop': cam0.stop(); ws.send(JSON.stringify({ type: 'ack', action: 'stop' })); break;
default:
ws.send(JSON.stringify({ type: 'error', error: `Unknown action: ${msg.action}` }));
}
} catch (err) {
ws.send(JSON.stringify({ type: 'error', error: err.message }));
}
}
// Start
server.listen(PORT, HOST, () => {
console.log(`HTTPS server listening on https://${HOST}:${PORT}`);
console.log(`Open https://localhost:${PORT} (accept the self-signed certificate in dev)`);
});
// Graceful shutdown
let shuttingDown = false;
async function shutdown(code = 0) {
if (shuttingDown) return;
shuttingDown = true;
console.log('\nShutting down…');
try { clearInterval(pingInterval); } catch {}
try { cam0.stop(); } catch {}
try { cam1.stop(); } catch {}
try { wss.clients.forEach(ws => { try { ws.terminate(); } catch {} }); } catch {}
try { await new Promise(res => wss.close(() => res())); } catch {}
try { for (const s of activeHttpSockets) { try { s.destroy(); } catch {} } } catch {}
try { await new Promise(res => server.close(() => res())); } catch {}
setTimeout(() => { console.warn('Force exiting…'); process.exit(code); }, 1500).unref();
process.exit(code);
}
process.once('SIGINT', () => shutdown(0));
process.once('SIGTERM', () => shutdown(0));
process.once('uncaughtException', (e) => { console.error(e); shutdown(1); });
process.once('unhandledRejection', (r) => { console.error(r); shutdown(1); });