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'; import { assignByZRange, removeMarkerAssignment, alignSetToMeasured, assignMarkerId, adoptXAxis, assignFixedMarkersToLink, setJointOriginYZ, setArmMarkerSpin } from './editRobot.js'; import { runHoming } from './homingOrchestrator.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 ROBOT_URL = process.env.ROBOT_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'; // .html/.js immer revalidieren lassen (kein stilles Stale-Caching durch Browser/Proxy // nach Code-Änderungen, z.B. boardViewer.html) – Bilder/STL etc. bleiben normal cachebar. app.use((req, res, next) => { if (/\.(html|js)$/.test(req.path)) res.setHeader('Cache-Control', 'no-cache'); next(); }); 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; } } /** * Sucht die neueste Kalibrierungs-Session, die eine NPZ für die angegebene Kamera enthält. * Gibt { session, npzPath } zurück oder null wenn keine gefunden. */ async function findLatestNpzForCamera(camId) { 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(); for (const dir of dirs) { const npzPath = path.join(calibDataDir, dir, `${camId}_calibration.npz`); try { await fsPromises.access(npzPath); return { session: dir, npzPath }; } catch {} } return 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) { let response; // Bei 503 (Kamera kurz busy nach Hires-Grab) einmal nach 2 s neu versuchen for (let attempt = 1; attempt <= 2; attempt++) { response = await new WebcamClient(WEBCAM_URL).getSnapshot(camId, true); if (response.status !== 503) break; if (attempt < 2) await new Promise(r => setTimeout(r, 2000)); } if (!response.ok) throw new Error(`getSnapshot(${camId}): HTTP ${response.status}`); 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: '' }); const exitCode = await runScript([ calibScriptPath, '--camera', camera, '--input-dir', sessionDir, '--output-dir', sessionDir, ], send); send({ type: 'done', exitCode }); 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 */ } } } }); // ── Board-Erkennung ─────────────────────────────────────────────────────────── const boardDataDir = path.join(__dirname, '..', 'data', 'board'); const homingDataDir = path.join(__dirname, '..', 'data', 'homing'); const ROBOT_JSON = process.env.ROBOT_JSON || path.join(__dirname, '..', 'scripts', 'robot_1781069752019.json'); const SCRIPT_1 = path.join(__dirname, '..', 'scripts', '1_detect_aruco_observations.py'); const SCRIPT_2 = path.join(__dirname, '..', 'scripts', '2_estimate_camera_from_observations.py'); const SCRIPT_3B = path.join(__dirname, '..', 'scripts', '3b_corner_marker_poses.py'); const SCRIPT_4B = path.join(__dirname, '..', 'scripts', '4b_revolute_angle.py'); const SCRIPT_5POSE = path.join(__dirname, '..', 'scripts', '5_pose_estimation.py'); /** * Führt ein Python-Script aus und leitet stdout/stderr zeilenweise an `send` weiter. * Gibt den Exit-Code zurück (Promise). */ function runScript(args, send) { return new Promise((resolve) => { const cmd = [PYTHON_BIN, '-u', ...args].join(' '); console.log(`[runScript] ${cmd}`); const proc = spawn(PYTHON_BIN, ['-u', ...args]); let outBuf = ''; proc.stdout.on('data', chunk => { outBuf += chunk.toString(); const lines = outBuf.split('\n'); outBuf = lines.pop(); for (const line of lines) send({ type: 'log', text: line }); }); let errBuf = ''; proc.stderr.on('data', chunk => { errBuf += chunk.toString(); const lines = errBuf.split('\n'); errBuf = 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}` }); resolve(-1); }); proc.on('close', code => { if (outBuf) send({ type: 'log', text: outBuf }); if (errBuf) send({ type: 'log', text: `[stderr] ${errBuf}` }); resolve(code ?? -1); }); }); } /** * Board-Pipeline: Snapshot + Script 1 + Script 2 (pro Kamera) + Script 3b. * Schreibt Ergebnisse nach runDir (muss bereits existieren). * Wird von /api/board/run UND /api/homing/run genutzt. * * @param {string} runDir – Zielverzeichnis (bereits erstellt) * @param {Function} send – SSE-Send-Funktion (obj => void) * @param {{ refSet?: string }} [opts] */ async function runBoardPipeline(runDir, send, { refSet } = {}) { // Kameras ermitteln if (!WEBCAM_URL) throw new Error('WEBCAM_URL nicht konfiguriert'); const camData = await new WebcamClient(WEBCAM_URL).getCameras(); const cameraIds = (camData.cameras ?? []).map(c => c.id); send({ type: 'log', text: `▶ Kameras: ${cameraIds.join(', ')}` }); send({ type: 'log', text: '' }); // Pro Kamera: Foto → Script 1 → Script 2 for (const camId of cameraIds) { send({ type: 'log', text: `─── ${camId} ${'─'.repeat(40 - camId.length)}` }); // Snapshot send({ type: 'log', text: 'Foto aufnehmen …' }); let snapResp; for (let attempt = 1; attempt <= 2; attempt++) { snapResp = await new WebcamClient(WEBCAM_URL).getSnapshot(camId, true); if (snapResp.status !== 503) break; if (attempt < 2) await new Promise(r => setTimeout(r, 2000)); } if (!snapResp.ok) { send({ type: 'log', text: `⚠ HTTP ${snapResp.status} – Kamera übersprungen` }); continue; } const imgPath = path.join(runDir, `${camId}.jpg`); await fsPromises.writeFile(imgPath, Buffer.from(await snapResp.arrayBuffer())); send({ type: 'log', text: `✅ Foto: ${camId}.jpg` }); // NPZ suchen – neueste Session, die eine NPZ für diese Kamera enthält const npzInfo = await findLatestNpzForCamera(camId); if (!npzInfo) { send({ type: 'log', text: `⚠ Keine NPZ für ${camId} gefunden – übersprungen` }); continue; } const npzPath = npzInfo.npzPath; send({ type: 'log', text: `▶ NPZ: data/calibration/${npzInfo.session}/${camId}_calibration.npz` }); // Script 1 – ArUco-Erkennung send({ type: 'log', text: '\n▷ 1_detect_aruco_observations' }); const exit1 = await runScript([ SCRIPT_1, '-i', imgPath, '-npz', npzPath, '-robot', ROBOT_JSON, '-cameraId', camId, '-outDir', runDir, '--saveDebugImage', ], send); if (exit1 !== 0) { send({ type: 'log', text: `❌ Script 1 Exit ${exit1}` }); continue; } // Script 2 – Kamera-Pose schätzen const detJson = path.join(runDir, `${camId}_aruco_detection.json`); try { await fsPromises.access(detJson); } catch { send({ type: 'log', text: '⚠ Detection-JSON fehlt – Script 2 übersprungen' }); continue; } send({ type: 'log', text: '\n▷ 2_estimate_camera_from_observations' }); const script2Args = [SCRIPT_2, '-i', detJson, '-robot', ROBOT_JSON, '-outDir', runDir]; if (refSet) script2Args.push('--refSet', refSet); const exit2 = await runScript(script2Args, send); if (exit2 !== 0) send({ type: 'log', text: `❌ Script 2 Exit ${exit2}` }); send({ type: 'log', text: '' }); } // Script 3b: Marker-Triangulierung (benötigt ≥2 Kamera-Posen) send({ type: 'log', text: '' }); send({ type: 'log', text: '─── 3b: Marker-Triangulierung ────────────────────────────' }); const runFiles3b = await fsPromises.readdir(runDir); const numPoses = runFiles3b.filter(f => f.endsWith('_camera_pose.json')).length; if (numPoses >= 2) { send({ type: 'log', text: `▷ 3b_corner_marker_poses (${numPoses} Kamera-Posen)` }); const exit3b = await runScript([ SCRIPT_3B, '--evalDir', runDir, '--robot', ROBOT_JSON, ], send); if (exit3b !== 0) send({ type: 'log', text: `❌ Script 3b Exit ${exit3b}` }); } else { send({ type: 'log', text: `⚠ Nur ${numPoses} Kamera-Pose(n) – Script 3b braucht ≥2 Kameras` }); } send({ type: 'log', text: '' }); } /** * POST /api/board/run * 1. Erstellt data/board/{timestamp}/ * 2. Holt Snapshot jeder Kamera * 3. Für jede Kamera: Script 1 (ArUco-Erkennung) → Script 2 (Kamera-Pose) * SSE-Stream während der Ausführung. */ app.post('/api/board/run', async (req, res) => { try { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); const send = (obj) => { if (!res.writableEnded) res.write(`data: ${JSON.stringify(obj)}\n\n`); }; const { refSet } = req.body ?? {}; // 1. Temp-Verzeichnis const ts = makeTimestamp(); const runDir = path.join(boardDataDir, ts); await fsPromises.mkdir(runDir, { recursive: true }); send({ type: 'log', text: `▶ Board-Run: ${ts}` }); send({ type: 'log', text: `▶ Ordner: ${runDir}` }); // Robot-JSON laden und Marker-Anzahl loggen let robotData = null; try { robotData = JSON.parse(await fsPromises.readFile(ROBOT_JSON, 'utf8')); } catch {} const boardMarkers = robotData?.links?.Board?.markers ?? []; const boardMarkerCount = boardMarkers.length; const refMarkerCount = refSet ? boardMarkers.filter(m => m.set === refSet).length : boardMarkerCount; send({ type: 'log', text: `▶ Robot-JSON: ${ROBOT_JSON}` }); send({ type: 'log', text: `▶ Board-Marker: ${boardMarkerCount} (links.Board.markers)` }); send({ type: 'log', text: `▶ Referenz-Set: ${refSet ? `"${refSet}" (${refMarkerCount} Marker)` : 'alle'}` }); send({ type: 'log', text: '' }); // 2–3b: Board-Pipeline (Foto + Scripts 1, 2, 3b) await runBoardPipeline(runDir, send, { refSet }); send({ type: 'log', text: `✅ Board-Run abgeschlossen: ${ts}` }); send({ type: 'done', exitCode: 0, runDir: ts }); if (!res.writableEnded) res.end(); } catch (err) { console.error('board/run error:', err); if (!res.headersSent) { res.status(500).json({ error: String(err) }); } else { try { res.write(`data: ${JSON.stringify({ type: 'log', text: `❌ ${err.message}` })}\n\n`); res.write(`data: ${JSON.stringify({ type: 'done', exitCode: -1 })}\n\n`); res.end(); } catch {} } } }); /** Alle Board-Run-Verzeichnisse, neueste zuerst */ async function listBoardRuns() { try { await fsPromises.access(boardDataDir); const entries = await fsPromises.readdir(boardDataDir, { withFileTypes: true }); return entries.filter(e => e.isDirectory()).map(e => e.name).sort().reverse(); } catch { return []; } } /** Neuestes Board-Run-Verzeichnis (Timestamp-Name) oder null */ async function findLatestBoardRun() { const dirs = await listBoardRuns(); return dirs[0] ?? null; } /** * GET /api/board/runs?limit=N * Gibt eine Liste der vorhandenen Board-Run-Verzeichnisse zurück (neueste zuerst). */ app.get('/api/board/runs', async (req, res) => { try { const limit = Math.max(1, Math.min(50, parseInt(req.query.limit ?? '10', 10))); const runs = await listBoardRuns(); return res.json({ runs: runs.slice(0, limit) }); } catch (err) { return res.status(500).json({ error: String(err) }); } }); /** * GET /api/board/latest?run=&from=homing * Gibt Daten eines Board-Runs zurück: robot.json + Detection-Ergebnisse + Kamera-Posen. * Ohne ?run → neuester Run. Mit ?run= → genau dieser Run. * ?from=homing → liest aus data/homing/ statt data/board/ (für boardViewer im Homing-Mode). * Wird vom Board-Viewer (boardViewer.html) abgefragt. */ app.get('/api/board/latest', async (req, res) => { try { const fromHoming = req.query.from === 'homing'; const dataDir = fromHoming ? homingDataDir : boardDataDir; let runName = req.query.run; if (!runName) { if (fromHoming) { try { const entries = await fsPromises.readdir(dataDir, { withFileTypes: true }); runName = entries.filter(e => e.isDirectory()).map(e => e.name).sort().reverse()[0] ?? null; } catch { runName = null; } } else { runName = await findLatestBoardRun(); } } if (!runName) return res.json({ runDir: null, robot: null, detections: [], cameraPoses: [] }); const runDir = path.join(dataDir, runName); let robot = null; try { robot = JSON.parse(await fsPromises.readFile(ROBOT_JSON, 'utf8')); } catch {} let files = []; try { files = await fsPromises.readdir(runDir); } catch {} const detections = []; const cameraPoses = []; for (const f of files.sort()) { if (f.endsWith('_aruco_detection.json')) { try { const data = JSON.parse(await fsPromises.readFile(path.join(runDir, f), 'utf8')); detections.push({ file: f, cameraId: data.camera?.camera_id ?? f.replace('_aruco_detection.json', ''), detectedMarkerIds: (data.detections ?? []).map(d => d.marker_id), numDetected: data.aruco?.num_detected_markers ?? 0, numRejected: data.aruco?.num_rejected_candidates ?? 0, }); } catch {} } else if (f.endsWith('_camera_pose.json')) { try { const data = JSON.parse(await fsPromises.readFile(path.join(runDir, f), 'utf8')); const cp = data.camera_pose; cameraPoses.push({ file: f, cameraId: data.camera?.camera_id ?? f.replace('_camera_pose.json', ''), position_mm: cp?.camera_in_world?.position_mm ?? null, rotation_matrix: cp?.world_to_camera?.rotation_matrix ?? null, usedMarkerIds: data.estimation?.used_marker_ids ?? [], rms_px: data.estimation?.residual_rms_px ?? null, }); } catch {} } } // aruco_marker_poses.json (Ausgabe von 3b_corner_marker_poses.py) let measuredMarkers = null; try { const raw = await fsPromises.readFile(path.join(runDir, 'aruco_marker_poses.json'), 'utf8'); measuredMarkers = JSON.parse(raw); } catch {} return res.json({ runDir: runName, robotFile: path.basename(ROBOT_JSON), robot, detections, cameraPoses, measuredMarkers }); } catch (err) { return res.status(500).json({ error: String(err) }); } }); // ── Homing ─────────────────────────────────────────────────────────────────── /** * POST /api/homing/run * Vollständiger Homing-Ablauf: Board-Pipeline + 4b-Kette (SSE-Stream). */ app.post('/api/homing/run', async (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); const send = (obj) => { if (!res.writableEnded) res.write(`data: ${JSON.stringify(obj)}\n\n`); }; try { await fsPromises.mkdir(homingDataDir, { recursive: true }); await runHoming({ robotJsonPath: ROBOT_JSON, homingDir: homingDataDir, send, runScript, runBoardPipeline, SCRIPT_4B, SCRIPT_5POSE, }); } catch (err) { console.error('homing/run error:', err); try { send({ type: 'error', text: String(err) }); send({ type: 'done', exitCode: -1 }); } catch {} } if (!res.writableEnded) res.end(); }); /** * POST /api/homing/send-state * Sendet { state: { x, y, z, a, b, c, e } } an ROBOT_URL/api/state. */ app.post('/api/homing/send-state', async (req, res) => { try { const { state } = req.body ?? {}; if (!state) return res.status(400).json({ error: '"state" fehlt' }); if (!ROBOT_URL) return res.status(501).json({ error: 'ROBOT_URL ist nicht konfiguriert' }); const url = new URL('/api/state', ROBOT_URL).toString(); const upstream = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(state), }); if (!upstream.ok) { const text = await upstream.text(); return res.status(upstream.status).json({ error: `Robot-Fehler: ${text}` }); } const result = await upstream.json().catch(() => ({})); return res.json({ ok: true, result }); } catch (err) { console.error('homing/send-state error:', err); return res.status(500).json({ error: String(err) }); } }); /** * GET /api/homing/run-data?run= * Gibt Bilder (base64) und JSON-Dateien eines Homing-Runs zurück. */ app.get('/api/homing/run-data', async (req, res) => { try { const runName = req.query.run; if (!runName) return res.status(400).json({ error: '"run" parameter fehlt' }); const runDir = path.join(homingDataDir, runName); let files = []; try { files = await fsPromises.readdir(runDir); } catch {} const images = []; for (const f of files.sort()) { if (/\.(jpg|jpeg|png)$/i.test(f)) { try { const buf = await fsPromises.readFile(path.join(runDir, f)); images.push({ filename: f, contentBase64: buf.toString('base64'), mimeType: 'image/jpeg' }); } catch {} } } // Letzten accumulated_state zurückgeben let finalState = null; const stateFiles = files.filter(f => f.startsWith('state_') && f.endsWith('.json')).sort(); if (stateFiles.length > 0) { try { const raw = await fsPromises.readFile(path.join(runDir, stateFiles[stateFiles.length - 1]), 'utf8'); finalState = JSON.parse(raw).accumulated_state ?? null; } catch {} } // aruco_marker_poses.csv für Snapshot-CSV-Tabelle let csvContent = null; try { csvContent = await fsPromises.readFile(path.join(runDir, 'aruco_marker_poses.csv'), 'utf8'); } catch {} return res.json({ runDir: runName, images, finalState, csvContent }); } catch (err) { return res.status(500).json({ error: String(err) }); } }); // ── Robot-JSON bearbeiten ───────────────────────────────────────────────────── /** * POST /api/robot/assign-by-z * Weist allen Markern in [zMin, zMax] mm das angegebene Set und/oder Link zu. * Body: { zMin, zMax, set?, link? } */ app.post('/api/robot/assign-by-z', async (req, res) => { try { const { zMin, zMax, set, link } = req.body ?? {}; if (zMin == null || zMax == null) { return res.status(400).json({ error: 'zMin und zMax sind erforderlich' }); } if (!set && !link) { return res.status(400).json({ error: 'Mindestens set oder link muss angegeben werden' }); } // Triangulierte Marker aus dem letzten Board-Run als Zusatzquelle für // Marker, die noch nicht in robot.json stehen (z.B. neu entdeckte Marker) let extraMarkers = []; try { const latestRun = await findLatestBoardRun(); if (latestRun) { const posesPath = path.join(boardDataDir, latestRun, 'aruco_marker_poses.json'); const poses = JSON.parse(await fsPromises.readFile(posesPath, 'utf8')); extraMarkers = poses.markers ?? []; } } catch { /* kein 3b-Output vorhanden – nur bestehende robot.json-Marker bearbeiten */ } const result = await assignByZRange(ROBOT_JSON, { zMin, zMax, set, link, extraMarkers }); const added = result.changes.filter(c => c.action === 'added').length; const updated = result.changes.filter(c => c.action === 'updated').length; console.log(`robot/assign-by-z z=[${zMin}..${zMax}] set="${set}" link="${link}" → ${updated} aktualisiert, ${added} neu (von ${extraMarkers.length} 3b-Markern)`); return res.json(result); } catch (err) { console.error('robot/assign-by-z error:', err); return res.status(500).json({ error: String(err) }); } }); /** * POST /api/robot/remove-marker * Entfernt Set oder Link-Zuordnung eines Markers. * Body: { markerId, removeFrom } removeFrom: 'set' | 'link' */ app.post('/api/robot/remove-marker', async (req, res) => { try { const { markerId, removeFrom } = req.body ?? {}; if (markerId == null) { return res.status(400).json({ error: 'markerId ist erforderlich' }); } if (!['set', 'link'].includes(removeFrom)) { return res.status(400).json({ error: 'removeFrom muss "set" oder "link" sein' }); } const result = await removeMarkerAssignment(ROBOT_JSON, { markerId, removeFrom }); console.log(`robot/remove-marker id=${markerId} from=${removeFrom} → changed=${result.changed}`); return res.json(result); } catch (err) { console.error('robot/remove-marker error:', err); return res.status(500).json({ error: String(err) }); } }); /** * GET /api/robot * Gibt robot.json zurück (ohne Board-Run-Daten). */ app.get('/api/robot', async (req, res) => { try { const robot = JSON.parse(await fsPromises.readFile(ROBOT_JSON, 'utf8')); return res.json(robot); } catch (err) { return res.status(500).json({ error: String(err) }); } }); /** * GET /api/robot/board-sets * Gibt die einzigartigen "set"-Werte aller Marker in links.Board zurück. * Wird vom Frontend genutzt, um Dropdowns zu befüllen. */ app.get('/api/robot/board-sets', async (req, res) => { try { const robot = JSON.parse(await fsPromises.readFile(ROBOT_JSON, 'utf8')); const markers = robot?.links?.Board?.markers ?? []; const sets = [...new Set(markers.map(m => m.set).filter(Boolean))].sort(); return res.json({ sets }); } catch (err) { console.error('robot/board-sets error:', err); return res.status(500).json({ error: String(err) }); } }); /** * POST /api/robot/align-sets * Richtet alle Marker des angegebenen Sets rigid (2D-Rotation um Z + 3D-Translation) * an den aktuellen 3b-Messpositionen aus. * Body: { setToMove } */ app.post('/api/robot/align-sets', async (req, res) => { try { const { setToMove, setFixed } = req.body ?? {}; if (!setToMove) return res.status(400).json({ error: '"setToMove" ist erforderlich.' }); let extraMarkers = []; try { const latestRun = await findLatestBoardRun(); if (latestRun) { const posesPath = path.join(boardDataDir, latestRun, 'aruco_marker_poses.json'); const poses = JSON.parse(await fsPromises.readFile(posesPath, 'utf8')); extraMarkers = poses.markers ?? []; } } catch { /* kein 3b-Output vorhanden */ } const result = await alignSetToMeasured(ROBOT_JSON, { setToMove, extraMarkers }); if (result.error) return res.status(400).json(result); console.log( `robot/align-sets fixed="${setFixed ?? '–'}" move="${setToMove}" → ${result.numChanged} Marker` + ` (${result.numMatchingPts} Messpunkte) Δx=${result.transform.tx} Δy=${result.transform.ty}` + ` Δz=${result.transform.tz} mm θ=${result.transform.thetaDeg}°`, ); return res.json(result); } catch (err) { console.error('robot/align-sets error:', err); return res.status(500).json({ error: String(err) }); } }); /** * POST /api/robot/assign-id * Fügt einen einzelnen Marker per ID zu Set und Link hinzu oder aktualisiert ihn. * Body: { markerId, set?, link? } */ app.post('/api/robot/assign-id', async (req, res) => { try { const { markerId, set, link } = req.body ?? {}; if (markerId == null) return res.status(400).json({ error: '"markerId" ist erforderlich.' }); let extraMarkers = []; try { const latestRun = await findLatestBoardRun(); if (latestRun) { const posesPath = path.join(boardDataDir, latestRun, 'aruco_marker_poses.json'); const poses = JSON.parse(await fsPromises.readFile(posesPath, 'utf8')); extraMarkers = poses.markers ?? []; } } catch { /* kein 3b-Output vorhanden */ } const result = await assignMarkerId(ROBOT_JSON, { markerId, set, link, extraMarkers }); if (!result.changed && result.error) return res.status(400).json(result); console.log( `robot/assign-id id=${markerId} set="${set ?? ''}" link="${link ?? ''}"` + ` → ${result.change?.action ?? 'unverändert'}`, ); return res.json(result); } catch (err) { console.error('robot/assign-id error:', err); return res.status(500).json({ error: String(err) }); } }); /** * POST /api/robot/adopt-x-axis * Dreht alle Marker-Positionen in robot.json so, dass die gemessene Richtung * zur neuen X-Achse [1,0,0] wird. Rotation um den A0-Schwerpunkt. * Body: { direction: [vx, vy, vz] } */ app.post('/api/robot/adopt-x-axis', async (req, res) => { try { const { direction } = req.body ?? {}; if (!Array.isArray(direction) || direction.length < 3) { return res.status(400).json({ error: '"direction" muss ein Array [vx,vy,vz] sein.' }); } const result = await adoptXAxis(ROBOT_JSON, { direction }); console.log( `robot/adopt-x-axis dir=[${direction.map(v => Number(v).toFixed(4)).join(', ')}]` + ` → ${result.numChanged} Marker, Ursprung=[${result.origin.join(', ')}]` + ` XY=${result.angleXYdeg}° XZ=${result.angleXZdeg}°`, ); return res.json(result); } catch (err) { console.error('robot/adopt-x-axis error:', err); return res.status(500).json({ error: String(err) }); } }); /** * POST /api/robot/assign-fixed-markers * Ordnet Marker, die sich bei einer Gelenk-Rotation kaum bewegen, dem * angegebenen Link zu (typisch: 'Base'). * Body: { markerIds: number[], targetLink: string, measuredPositions: [{id, position_mm}] } */ app.post('/api/robot/assign-fixed-markers', async (req, res) => { try { const { markerIds, targetLink, measuredPositions = [] } = req.body ?? {}; if (!Array.isArray(markerIds) || markerIds.length === 0) { return res.status(400).json({ error: '"markerIds" muss ein nicht-leeres Array sein.' }); } if (!targetLink) { return res.status(400).json({ error: '"targetLink" muss angegeben werden.' }); } const result = await assignFixedMarkersToLink(ROBOT_JSON, { markerIds, targetLink, measuredPositions }); console.log( `robot/assign-fixed-markers [${markerIds.join(',')}] → ${targetLink}` + ` added=${result.numAdded} alreadyPresent=${result.numAlreadyPresent}`, ); return res.json(result); } catch (err) { console.error('robot/assign-fixed-markers error:', err); return res.status(500).json({ error: String(err) }); } }); /** * POST /api/robot/set-joint-origin * Setzt Y und Z des jointToParent.origin eines Links aus der berechneten * Drehachsen-Position. * Body: { linkName: string, y: number, z: number } */ app.post('/api/robot/set-joint-origin', async (req, res) => { try { const { linkName, y, z } = req.body ?? {}; if (!linkName) return res.status(400).json({ error: '"linkName" muss angegeben werden.' }); if (!Number.isFinite(Number(y)) || !Number.isFinite(Number(z))) { return res.status(400).json({ error: '"y" und "z" müssen Zahlen sein.' }); } const result = await setJointOriginYZ(ROBOT_JSON, { linkName, y: Number(y), z: Number(z) }); if (!result.changed) { return res.status(400).json({ error: result.error }); } console.log( `robot/set-joint-origin ${linkName}: ` + `[${result.oldOrigin.join(', ')}] → [${result.newOrigin.join(', ')}]`, ); return res.json(result); } catch (err) { console.error('robot/set-joint-origin error:', err); return res.status(500).json({ error: String(err) }); } }); /** * POST /api/robot/set-arm-marker-spin * Setzt den `spin`-Wert eines Arm-Markers in robot.json. * Body: { linkName: string, markerId: number, spin: number } */ app.post('/api/robot/set-arm-marker-spin', async (req, res) => { try { const { linkName, markerId, spin } = req.body ?? {}; if (!linkName) return res.status(400).json({ error: '"linkName" muss angegeben werden.' }); if (markerId == null) return res.status(400).json({ error: '"markerId" muss angegeben werden.' }); if (!Number.isFinite(Number(spin))) return res.status(400).json({ error: '"spin" muss eine Zahl sein.' }); const result = await setArmMarkerSpin(ROBOT_JSON, { linkName, markerId, spin: Number(spin) }); if (!result.changed) return res.status(400).json({ error: result.error }); console.log(`robot/set-arm-marker-spin ${linkName}#${markerId}: ${result.oldSpin}° → ${result.newSpin}°`); return res.json(result); } catch (err) { console.error('robot/set-arm-marker-spin error:', err); return res.status(500).json({ error: String(err) }); } }); /** * POST /api/calibration/upload-npz * Liest {camera}_calibration.npz aus der aktuellen Session und * schickt sie per PUT an den Webcam-Service. * Body: { camera: "cam0" } */ app.post('/api/calibration/upload-npz', async (req, res) => { try { const { camera } = req.body ?? {}; if (!camera) return res.status(400).json({ error: '"camera" parameter fehlt' }); if (!WEBCAM_URL) return res.status(501).json({ error: 'WEBCAM_URL ist nicht konfiguriert' }); const session = await findLatestCalibSession(); if (!session) return res.status(400).json({ error: 'Keine Kalibrierungs-Session vorhanden' }); const npzPath = path.join(calibDataDir, session, `${camera}_calibration.npz`); try { await fsPromises.access(npzPath); } catch { return res.status(404).json({ error: `Datei nicht gefunden: ${camera}_calibration.npz — bitte zuerst "Kalibrierung berechnen".` }); } const npzData = await fsPromises.readFile(npzPath); const putUrl = new URL(`/api/cameras/${camera}/calibration`, WEBCAM_URL).toString(); const putRes = await fetch(putUrl, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream' }, body: npzData, }); if (!putRes.ok) { const text = await putRes.text(); return res.status(putRes.status).json({ error: `Webcam-Service: ${putRes.status} – ${text}` }); } const result = await putRes.json(); return res.json({ ok: true, camera, session, size: npzData.length, webcam: result }); } catch (err) { console.error('calibration/upload-npz error:', err); return res.status(500).json({ error: String(err) }); } }); // ── X-Achse / Rotations-Detektion ──────────────────────────────────────────── const xaxisDataDir = path.join(__dirname, '..', 'data', 'xaxis'); const ROTATION_DETECTION_FILE = path.join(xaxisDataDir, 'rotation_detection.json'); /** POST /api/xaxis/save-rotation-detection * Speichert eine Achsmessung an rotation_detection.json (append-Modus). */ app.post('/api/xaxis/save-rotation-detection', express.json(), async (req, res) => { try { const { axis, runs, numMarkers, markers } = req.body ?? {}; if (!axis || !axis.dir || !axis.referencePoint) { return res.status(400).json({ error: 'Ungültige Nutzlast: axis.dir und axis.referencePoint erwartet' }); } // Verzeichnis anlegen falls nötig await fsPromises.mkdir(xaxisDataDir, { recursive: true }); // Bestehende Einträge lesen oder leer beginnen let entries = []; try { const raw = await fsPromises.readFile(ROTATION_DETECTION_FILE, 'utf-8'); entries = JSON.parse(raw); if (!Array.isArray(entries)) entries = []; } catch { // Datei existiert noch nicht – kein Fehler } const newEntry = { timestamp: new Date().toISOString(), runs: runs ?? {}, axis, numMarkers: numMarkers ?? null, markers: markers ?? [], }; entries.push(newEntry); await fsPromises.writeFile(ROTATION_DETECTION_FILE, JSON.stringify(entries, null, 2), 'utf-8'); return res.json({ file: 'data/xaxis/rotation_detection.json', total: entries.length, }); } catch (err) { console.error('save-rotation-detection error:', err); return res.status(500).json({ error: String(err) }); } }); 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 || ''}`); }); });