496 lines
18 KiB
JavaScript
Executable File
496 lines
18 KiB
JavaScript
Executable File
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>'}`);
|
||
});
|
||
});
|