Files
appRobotHoming/server/server.js
2026-06-10 18:30:16 +02:00

982 lines
37 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';
import { assignByZRange, removeMarkerAssignment, alignSetToMeasured, assignMarkerId } from './editRobot.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;
}
}
/**
* 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 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`);
};
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. 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: '' });
// 3. 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 (in keiner Kalibrierungs-Session) ü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) 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 {}
}
}
});
/** 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=<timestamp>
* Gibt Daten eines Board-Runs zurück: robot.json + Detection-Ergebnisse + Kamera-Posen.
* Ohne ?run → neuester Run. Mit ?run=<timestamp> → genau dieser Run.
* Wird vom Board-Viewer (boardViewer.html) abgefragt.
*/
app.get('/api/board/latest', async (req, res) => {
try {
const runName = req.query.run || 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, robotFile: path.basename(ROBOT_JSON), robot, detections, cameraPoses, measuredMarkers });
} 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/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/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>'}`);
});
});