Files
appRobotHoming/server/server.js
2026-06-10 14:58:14 +02:00

783 lines
29 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) {
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 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');
/**
* Führt ein Python-Script aus und leitet stdout/stderr zeilenweise an `send` weiter.
* Gibt den Exit-Code zurück (Promise<number>).
*/
function runScript(args, send) {
return new Promise((resolve) => {
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);
});
});
}
/**
* 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`);
};
// 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}` });
send({ type: 'log', text: `▶ Robot-JSON: ${ROBOT_JSON}` });
send({ type: 'log', text: '' });
// 2. 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(', ')}` });
// 3. Aktuelle Kalibrierungs-Session für NPZ-Dateien
const calibSession = await findLatestCalibSession();
if (!calibSession) throw new Error('Keine Kalibrierungs-Session. Bitte zuerst Camera NPZ kalibrieren.');
send({ type: 'log', text: `▶ NPZ-Session: ${calibSession}` });
send({ type: 'log', text: '' });
// 4. 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 prüfen
const npzPath = path.join(calibDataDir, calibSession, `${camId}_calibration.npz`);
try { await fsPromises.access(npzPath); }
catch {
send({ type: 'log', text: `⚠ Keine NPZ (${camId}_calibration.npz) übersprungen` });
continue;
}
// 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 exit2 = await runScript([
SCRIPT_2,
'-i', detJson,
'-robot', ROBOT_JSON,
'-outDir', runDir,
], 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) vorhanden Script 3b braucht ≥2 Kameras für Triangulierung, wird übersprungen.` });
}
send({ type: 'log', text: '' });
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 {}
}
}
});
/** Neuestes Board-Run-Verzeichnis (Timestamp-Name) oder null */
async function findLatestBoardRun() {
try {
await fsPromises.access(boardDataDir);
const entries = await fsPromises.readdir(boardDataDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort().reverse();
return dirs[0] ?? null;
} catch {
return null;
}
}
/**
* GET /api/board/latest
* Gibt Daten des letzten Board-Runs zurück: robot.json + Detection-Ergebnisse + Kamera-Posen.
* Wird vom Board-Viewer (boardViewer.html) abgefragt.
*/
app.get('/api/board/latest', async (req, res) => {
try {
const runName = await findLatestBoardRun();
if (!runName) return res.json({ runDir: null, robot: null, detections: [], cameraPoses: [] });
const runDir = path.join(boardDataDir, 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, robot, detections, cameraPoses, measuredMarkers });
} catch (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) });
}
});
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>'}`);
});
});