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) => { 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 res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); const send = (obj) => 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: '' }); const proc = spawn(PYTHON_BIN, [ calibScriptPath, '--camera', camera, '--input-dir', sessionDir, '--output-dir', sessionDir, ]); // stdout zeilenweise weiterleiten let stdoutBuf = ''; proc.stdout.on('data', (chunk) => { stdoutBuf += chunk.toString(); const lines = stdoutBuf.split('\n'); stdoutBuf = lines.pop(); // letztes (unvollständiges) Fragment behalten for (const line of lines) send({ type: 'log', text: line }); }); // stderr als Warnung weiterleiten 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) => { send({ type: 'log', text: `Fehler beim Starten: ${err.message}` }); send({ type: 'done', exitCode: -1 }); res.end(); }); proc.on('close', (code) => { if (stdoutBuf) send({ type: 'log', text: stdoutBuf }); // Rest ausgeben if (stderrBuf) send({ type: 'log', text: `[stderr] ${stderrBuf}` }); send({ type: 'done', exitCode: code }); res.end(); }); }); 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 || ''}`); console.log(`BODYTRACKER_URL=${BODYTRACKER_URL || ''}`); }); } 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 || ''}`); console.log(`BODYTRACKER_URL=${BODYTRACKER_URL || ''}`); }); });