Files
appRobotHoming/server/server.js
2026-06-10 11:52:03 +02:00

496 lines
18 KiB
JavaScript
Executable File
Raw 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.
import express from 'express';
import https from 'https';
import { Readable } from 'node:stream';
import path from 'path';
import fs from 'fs';
import fsPromises from 'fs/promises';
import { fileURLToPath } from 'url';
import process from 'process';
import { spawn } from 'child_process';
import { WebcamClient } from './webcamClient.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
app.use(express.json({ limit: '20mb' }));
const PORT = parseInt(process.env.PORT || process.env.HTTPS_PORT || '2093', 10);
const publicDir = path.join(__dirname, '..', 'public');
const snapshotsDir = path.join(publicDir, 'snapshots');
const WEBCAM_URL = process.env.WEBCAM_URL || '';
const BODYTRACKER_URL = process.env.BODYTRACKER_URL || '';
const HTTPS_KEY_PATH = process.env.HTTPS_KEY_PATH || path.join(__dirname, '..', 'https', 'localhost.key');
const HTTPS_CERT_PATH = process.env.HTTPS_CERT_PATH || path.join(__dirname, '..', 'https', 'localhost.pem');
const HTTPS_PASSPHRASE = process.env.HTTPS_PASSPHRASE || 'abcd';
app.use(express.static(publicDir));
app.get('/api/health', (req, res) => {
res.json({ ok: true, mode: 'backend-proxy', webcamUrl: WEBCAM_URL || null, bodyTrackerUrl: BODYTRACKER_URL || null });
});
// ── WebCam-Proxy ─────────────────────────────────────────────────────────────
/** Kameraliste mit Metadaten (inkl. calibrationUrl falls Kalibrierung vorhanden). */
app.get('/api/webcam/cameras', async (req, res) => {
if (!WEBCAM_URL) return res.status(501).json({ error: 'WEBCAM_URL ist nicht konfiguriert' });
try {
const wc = new WebcamClient(WEBCAM_URL);
const data = await wc.getCameras();
return res.json(data);
} catch (err) {
console.error('webcam/cameras error:', err);
return res.status(502).json({ error: 'WebCam-Fehler', details: String(err) });
}
});
/**
* HD-JPEG einer Kamera (per Default hires).
* Streamt die JPEG-Antwort direkt durch — kein Buffering im Backend.
* Query-Parameter: ?hires=false für Live-Auflösung.
*/
app.get('/api/webcam/snapshot/:id', async (req, res) => {
if (!WEBCAM_URL) return res.status(501).json({ error: 'WEBCAM_URL ist nicht konfiguriert' });
const hires = req.query.hires !== 'false';
try {
const wc = new WebcamClient(WEBCAM_URL);
const upstream = await wc.getSnapshot(req.params.id, hires);
// Relevante Response-Header durchreichen
res.setHeader('Content-Type', upstream.headers.get('content-type') || 'image/jpeg');
res.setHeader('Cache-Control', 'no-store');
for (const header of ['x-camera-id', 'x-frame-width', 'x-timestamp', 'content-length']) {
const val = upstream.headers.get(header);
if (val) res.setHeader(header, val);
}
const nodeStream = Readable.fromWeb(upstream.body);
nodeStream.on('error', (err) => {
console.error(`webcam/snapshot/${req.params.id} stream error:`, err);
if (!res.headersSent) res.status(502).json({ error: 'Stream-Fehler' });
});
nodeStream.pipe(res);
} catch (err) {
console.error(`webcam/snapshot/${req.params.id} error:`, err);
if (!res.headersSent) res.status(502).json({ error: 'WebCam-Fehler', details: String(err) });
}
});
async function findLatestSnapshotFile() {
const files = await fsPromises.readdir(snapshotsDir);
const entries = await Promise.all(
files
.filter((name) => name.endsWith('.csv'))
.map(async (name) => ({
name,
mtime: (await fsPromises.stat(path.join(snapshotsDir, name))).mtime.valueOf()
}))
);
if (entries.length === 0) return null;
entries.sort((a, b) => b.mtime - a.mtime);
return entries[0].name;
}
app.get('/api/latest-snapshot', async (req, res) => {
try {
if (WEBCAM_URL) {
const url = new URL('/api/latest-snapshot', WEBCAM_URL).toString();
const fetchRes = await fetch(url);
const contentType = fetchRes.headers.get('content-type') || '';
if (!fetchRes.ok) {
const text = await fetchRes.text();
return res.status(fetchRes.status).type('text/plain').send(text);
}
if (contentType.includes('application/json')) {
const body = await fetchRes.json();
return res.json(body);
}
const text = await fetchRes.text();
return res.json({ filename: 'latest.csv', mtime: new Date().toISOString(), content: text });
}
const latestFile = await findLatestSnapshotFile();
if (!latestFile) {
return res.status(404).json({ error: 'Keine Snapshot-CSV-Datei gefunden' });
}
const baseName = path.basename(latestFile, path.extname(latestFile));
const csvPath = path.join(snapshotsDir, latestFile);
const jsonPath = path.join(snapshotsDir, `${baseName}.json`);
const imagePath = path.join(snapshotsDir, `${baseName}_annotated.jpg`);
const imagePath2 = path.join(snapshotsDir, `${baseName}_annotated2.jpg`);
const content = await fsPromises.readFile(csvPath, 'utf8');
const result = { filename: latestFile, mtime: (await fsPromises.stat(csvPath)).mtime.toISOString(), content };
try {
result.jsonFile = { filename: `${baseName}.json`, content: await fsPromises.readFile(jsonPath, 'utf8') };
} catch {}
try {
const jpg = await fsPromises.readFile(imagePath);
result.imageFile = {
filename: path.basename(imagePath),
mimeType: 'image/jpeg',
contentBase64: jpg.toString('base64')
};
} catch {}
try {
const jpg2 = await fsPromises.readFile(imagePath2);
result.image2 = {
filename: path.basename(imagePath2),
mimeType: 'image/jpeg',
contentBase64: jpg2.toString('base64')
};
} catch {}
return res.json(result);
} catch (err) {
console.error('latest-snapshot error:', err);
return res.status(500).json({ error: 'Fehler beim Laden des Snapshots', details: String(err) });
}
});
app.post('/api/estimate', async (req, res) => {
if (!BODYTRACKER_URL) {
return res.status(501).json({ error: 'BODYTRACKER_URL ist nicht konfiguriert' });
}
try {
const { imageFile, image2, robotIntrinsics } = req.body;
const formData = new FormData();
if (imageFile?.contentBase64) {
const buffer = Buffer.from(imageFile.contentBase64, 'base64');
formData.append('images', new Blob([buffer], { type: imageFile.mimeType || 'image/jpeg' }), imageFile.filename || 'snapshot.jpg');
}
if (image2?.contentBase64) {
const buffer2 = Buffer.from(image2.contentBase64, 'base64');
formData.append('images', new Blob([buffer2], { type: image2.mimeType || 'image/jpeg' }), image2.filename || 'snapshot2.jpg');
}
if (robotIntrinsics) {
formData.append('intrinsics', new Blob([JSON.stringify(robotIntrinsics)], { type: 'application/json' }), 'intrinsics.json');
}
const estimateUrl = new URL('/v1/estimate', BODYTRACKER_URL).toString();
const fetchRes = await fetch(estimateUrl, { method: 'POST', body: formData });
if (!fetchRes.ok) {
const message = await fetchRes.text();
return res.status(fetchRes.status).json({ error: 'BodyTracker-Fehler', details: message });
}
const body = await fetchRes.json();
return res.json(body);
} catch (err) {
console.error('estimate error:', err);
return res.status(500).json({ error: 'Fehler beim Aufruf des BodyTracker', details: String(err) });
}
});
// ── Kalibrierung ─────────────────────────────────────────────────────────────
const calibDataDir = path.join(__dirname, '..', 'data', 'calibration');
/** Timestamp-String im Format YYYYMMDD_HHmmss */
function makeTimestamp() {
const now = new Date();
const p = (n, l = 2) => String(n).padStart(l, '0');
return `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}_${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
}
/** Neueste Kalibrierungs-Session (Verzeichnisname) oder null */
async function findLatestCalibSession() {
try {
await fsPromises.access(calibDataDir);
const entries = await fsPromises.readdir(calibDataDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort().reverse();
return dirs[0] ?? null;
} catch {
return null;
}
}
/** Liest meta.json einer Session */
async function readCalibMeta(sessionName) {
try {
const raw = await fsPromises.readFile(path.join(calibDataDir, sessionName, 'meta.json'), 'utf8');
return JSON.parse(raw);
} catch {
return null;
}
}
/** Schreibt meta.json einer Session */
async function writeCalibMeta(sessionName, meta) {
await fsPromises.writeFile(
path.join(calibDataDir, sessionName, 'meta.json'),
JSON.stringify(meta, null, 2),
'utf8'
);
}
/**
* Holt Snapshots aller verfügbaren Kameras und speichert sie im Session-Verzeichnis.
* Dateiname: {cameraId}_{setNr}.jpg (setNr = 001, 002, …)
*/
async function capturePhotos(sessionName) {
if (!WEBCAM_URL) throw new Error('WEBCAM_URL ist nicht konfiguriert keine Kameras erreichbar');
const wc = new WebcamClient(WEBCAM_URL);
const data = await wc.getCameras();
const cameraIds = (data.cameras ?? []).map(c => c.id);
if (cameraIds.length === 0) throw new Error('Keine Kameras vom WebCam-Service gemeldet');
// Nächste Set-Nummer bestimmen (höchste vorhandene + 1)
const sessionDir = path.join(calibDataDir, sessionName);
const existing = await fsPromises.readdir(sessionDir);
const maxSet = existing.reduce((max, f) => {
const m = f.match(/_(\d+)\.jpg$/);
return m ? Math.max(max, parseInt(m[1], 10)) : max;
}, 0);
const setNr = String(maxSet + 1).padStart(3, '0');
const savedFiles = [];
for (const camId of cameraIds) {
const response = await new WebcamClient(WEBCAM_URL).getSnapshot(camId, true);
const buffer = Buffer.from(await response.arrayBuffer());
const filename = `${camId}_${setNr}.jpg`;
await fsPromises.writeFile(path.join(sessionDir, filename), buffer);
savedFiles.push(filename);
}
return { cameraIds, savedFiles, setNr: parseInt(setNr, 10) };
}
/** GET /api/calibration/current — aktuelle Session-Info */
app.get('/api/calibration/current', async (req, res) => {
try {
const session = await findLatestCalibSession();
if (!session) return res.json({ session: null, meta: null });
const meta = await readCalibMeta(session);
return res.json({ session, meta });
} catch (err) {
return res.status(500).json({ error: String(err) });
}
});
/** POST /api/calibration/new — neue Session anlegen + erste Fotos */
app.post('/api/calibration/new', async (req, res) => {
try {
const ts = makeTimestamp();
const sessionDir = path.join(calibDataDir, ts);
await fsPromises.mkdir(sessionDir, { recursive: true });
const meta = { timestamp: ts, createdAt: new Date().toISOString(), cameras: [], imageSets: 0, imageCount: 0 };
await writeCalibMeta(ts, meta);
try {
const capture = await capturePhotos(ts);
meta.cameras = capture.cameraIds;
meta.imageSets = capture.setNr;
meta.imageCount = capture.setNr * capture.cameraIds.length;
await writeCalibMeta(ts, meta);
return res.json({ session: ts, meta, savedFiles: capture.savedFiles });
} catch (captureErr) {
// Session angelegt, aber Fotos nicht verfügbar → trotzdem OK zurück
return res.json({ session: ts, meta, warning: String(captureErr) });
}
} catch (err) {
return res.status(500).json({ error: String(err) });
}
});
/** POST /api/calibration/foto — weitere Fotos für aktuelle Session */
app.post('/api/calibration/foto', async (req, res) => {
try {
const session = await findLatestCalibSession();
if (!session) {
return res.status(400).json({ error: 'Keine Session vorhanden. Bitte zuerst "Neue Kalibrierung anlegen".' });
}
const capture = await capturePhotos(session);
const meta = await readCalibMeta(session) ?? {
timestamp: session, createdAt: new Date().toISOString(),
cameras: [], imageSets: 0, imageCount: 0
};
meta.cameras = capture.cameraIds;
meta.imageSets = capture.setNr;
meta.imageCount = capture.setNr * capture.cameraIds.length;
await writeCalibMeta(session, meta);
return res.json({ session, meta, savedFiles: capture.savedFiles });
} catch (err) {
return res.status(500).json({ error: String(err) });
}
});
/**
* POST /api/calibration/compute
* Führt scripts/callibriate.py für eine Kamera aus.
* Body: { camera: "cam0" }
* Antwortet als Server-Sent Events (SSE): jede Zeile stdout/stderr als
* data: {"type":"log","text":"..."}
* Abschluss:
* data: {"type":"done","exitCode":0}
*/
const PYTHON_BIN = process.env.PYTHON_BIN || 'python3';
const calibScriptPath = path.join(__dirname, '..', 'scripts', 'callibriate.py');
app.post('/api/calibration/compute', async (req, res) => {
try {
const { camera } = req.body ?? {};
if (!camera) return res.status(400).json({ error: '"camera" parameter fehlt' });
const session = await findLatestCalibSession();
if (!session) return res.status(400).json({ error: 'Keine Kalibrierungs-Session vorhanden' });
const sessionDir = path.join(calibDataDir, session);
// SSE-Header erst NACH den Validierungen senden
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
// Schreibt nur wenn die Verbindung noch offen ist
const send = (obj) => {
if (!res.writableEnded) res.write(`data: ${JSON.stringify(obj)}\n\n`);
};
send({ type: 'log', text: `▶ Session: ${session}` });
send({ type: 'log', text: `▶ Kamera: ${camera}` });
send({ type: 'log', text: `▶ Script: ${calibScriptPath}` });
send({ type: 'log', text: '' });
// -u = unbuffered (Python gibt jede Zeile sofort aus)
const proc = spawn(PYTHON_BIN, [
'-u',
calibScriptPath,
'--camera', camera,
'--input-dir', sessionDir,
'--output-dir', sessionDir,
]);
let stdoutBuf = '';
proc.stdout.on('data', (chunk) => {
stdoutBuf += chunk.toString();
const lines = stdoutBuf.split('\n');
stdoutBuf = lines.pop();
for (const line of lines) send({ type: 'log', text: line });
});
let stderrBuf = '';
proc.stderr.on('data', (chunk) => {
stderrBuf += chunk.toString();
const lines = stderrBuf.split('\n');
stderrBuf = lines.pop();
for (const line of lines) send({ type: 'log', text: `[stderr] ${line}` });
});
proc.on('error', (err) => {
console.error('calibration/compute spawn error:', err);
send({ type: 'log', text: `Fehler beim Starten: ${err.message}` });
send({ type: 'done', exitCode: -1 });
if (!res.writableEnded) res.end();
});
proc.on('close', (code) => {
if (stdoutBuf) send({ type: 'log', text: stdoutBuf });
if (stderrBuf) send({ type: 'log', text: `[stderr] ${stderrBuf}` });
send({ type: 'done', exitCode: code ?? -1 });
if (!res.writableEnded) res.end();
});
} catch (err) {
// Fehler VOR flushHeaders → normaler JSON-Fehler
// Fehler NACH flushHeaders → SSE-Fehlerevent + close
console.error('calibration/compute error:', err);
if (!res.headersSent) {
res.status(500).json({ error: String(err) });
} else {
try {
res.write(`data: ${JSON.stringify({ type: 'log', text: `Server-Fehler: ${err.message}` })}\n\n`);
res.write(`data: ${JSON.stringify({ type: 'done', exitCode: -1 })}\n\n`);
res.end();
} catch { /* Verbindung bereits geschlossen */ }
}
}
});
async function checkServiceReachability(name, url) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
if (!res.ok) {
console.warn(`${name} ist nicht vollständig erreichbar (${res.status}) unter ${url}`);
return false;
}
console.log(`${name} erreichbar unter ${url}`);
return true;
} catch (err) {
console.warn(`${name} konnte nicht erreicht werden unter ${url}:`, err.message || err);
return false;
}
}
async function createHttpsServer() {
try {
await fsPromises.access(HTTPS_KEY_PATH);
await fsPromises.access(HTTPS_CERT_PATH);
const key = fs.readFileSync(HTTPS_KEY_PATH);
const cert = fs.readFileSync(HTTPS_CERT_PATH);
const httpsOptions = { key, cert, passphrase: HTTPS_PASSPHRASE };
console.log(`HTTPS-Zertifikate geladen: ${HTTPS_KEY_PATH}, ${HTTPS_CERT_PATH}`);
return https.createServer(httpsOptions, app);
} catch (err) {
console.warn('HTTPS-Zertifikate konnten nicht geladen werden:', err.message || err);
console.warn('Fallback auf HTTP. External proxy muss HTTPS terminieren.');
return null;
}
}
async function startServer() {
if (WEBCAM_URL) {
await checkServiceReachability('WEBCAM_URL', new URL('/health', WEBCAM_URL).toString());
}
if (BODYTRACKER_URL) {
await checkServiceReachability('BODYTRACKER_URL', new URL('/v1/health', BODYTRACKER_URL).toString());
}
const server = await createHttpsServer();
const isHttps = Boolean(server);
const listenServer = server || app;
listenServer.listen(PORT, () => {
console.log(`appRobotHoming backend listening on port ${PORT} (${isHttps ? 'HTTPS' : 'HTTP'})`);
console.log(`WEBCAM_URL=${WEBCAM_URL || '<lokal>'}`);
console.log(`BODYTRACKER_URL=${BODYTRACKER_URL || '<nicht konfiguriert>'}`);
});
}
startServer().catch((err) => {
console.error('Fehler beim Starten des Servers:', err);
console.log('Starte trotzdem den Server weiter...');
app.listen(PORT, () => {
console.log(`appRobotHoming backend listening on port ${PORT} (HTTP)`);
console.log(`WEBCAM_URL=${WEBCAM_URL || '<lokal>'}`);
console.log(`BODYTRACKER_URL=${BODYTRACKER_URL || '<nicht konfiguriert>'}`);
});
});