1505 lines
56 KiB
JavaScript
Executable File
1505 lines
56 KiB
JavaScript
Executable File
import express from 'express';
|
||
import https from 'https';
|
||
import { Readable } from 'node:stream';
|
||
import path from 'path';
|
||
import fs from 'fs';
|
||
import fsPromises from 'fs/promises';
|
||
import { fileURLToPath } from 'url';
|
||
import process from 'process';
|
||
import { spawn } from 'child_process';
|
||
import { WebcamClient } from './webcamClient.js';
|
||
import { assignByZRange, removeMarkerAssignment, alignSetToMeasured, assignMarkerId, adoptXAxis, assignFixedMarkersToLink, setJointOriginYZ, setArmMarkerSpin } from './editRobot.js';
|
||
import multer from 'multer';
|
||
import { runHoming, runHomingOffline } from './homingOrchestrator.js';
|
||
import { fetchRobot, robotCachePath } from './robotConfig.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 homingOfflineDataDir = path.join(__dirname, '..', 'data', 'homing-offline');
|
||
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<number>).
|
||
*/
|
||
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 } = {}) {
|
||
try {
|
||
await fetchRobot();
|
||
} catch (err) {
|
||
send({ type: 'log', text: `⚠ robot.json-Cache: Driver nicht erreichbar – nutze lokale Datei (${err.message})` });
|
||
}
|
||
|
||
// 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', robotCachePath,
|
||
'-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', robotCachePath, '-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', robotCachePath,
|
||
], 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: '' });
|
||
}
|
||
|
||
/**
|
||
* Board-Pipeline für Offline-Homing: Bilder und NPZs liegen bereits im runDir.
|
||
* Kein Webcam-Zugriff, keine NPZ-Suche — Scripts 1, 2, 3b werden identisch aufgerufen.
|
||
*
|
||
* Dateinamen-Konvention im runDir:
|
||
* {cameraId}.jpg – Kamerabild
|
||
* {cameraId}_calibration.npz – Kalibrierung
|
||
* robot_run.json – robot.json für diesen Lauf
|
||
*
|
||
* @param {string} runDir
|
||
* @param {Function} send
|
||
* @param {{ refSet?: string }} [opts]
|
||
*/
|
||
async function runBoardPipelineOffline(runDir, send, { refSet } = {}) {
|
||
const robotRunPath = path.join(runDir, 'robot_run.json');
|
||
|
||
const allFiles = await fsPromises.readdir(runDir);
|
||
const cameraIds = allFiles
|
||
.filter(f => /^[a-zA-Z0-9]+\.jpg$/.test(f))
|
||
.map(f => path.basename(f, '.jpg'))
|
||
.sort();
|
||
|
||
send({ type: 'log', text: `▶ Kameras: ${cameraIds.join(', ')}` });
|
||
send({ type: 'log', text: '' });
|
||
|
||
for (const camId of cameraIds) {
|
||
send({ type: 'log', text: `─── ${camId} ${'─'.repeat(40 - camId.length)}` });
|
||
|
||
const imgPath = path.join(runDir, `${camId}.jpg`);
|
||
const npzPath = path.join(runDir, `${camId}_calibration.npz`);
|
||
|
||
try { await fsPromises.access(npzPath); } catch {
|
||
send({ type: 'log', text: `⚠ Keine NPZ für ${camId} – übersprungen` });
|
||
continue;
|
||
}
|
||
|
||
send({ type: 'log', text: '\n▷ 1_detect_aruco_observations' });
|
||
const exit1 = await runScript([
|
||
SCRIPT_1,
|
||
'-i', imgPath,
|
||
'-npz', npzPath,
|
||
'-robot', robotRunPath,
|
||
'-cameraId', camId,
|
||
'-outDir', runDir,
|
||
'--saveDebugImage',
|
||
], send);
|
||
if (exit1 !== 0) {
|
||
send({ type: 'log', text: `❌ Script 1 Exit ${exit1}` });
|
||
continue;
|
||
}
|
||
|
||
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', robotRunPath, '-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: '' });
|
||
}
|
||
|
||
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', robotRunPath], 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: '' });
|
||
}
|
||
|
||
// ── Multer-Setup für Offline-Homing ──────────────────────────────────────────
|
||
|
||
async function prepareOfflineRunDir(req, res, next) {
|
||
try {
|
||
const ts = makeTimestamp();
|
||
const runDir = path.join(homingOfflineDataDir, ts);
|
||
await fsPromises.mkdir(runDir, { recursive: true });
|
||
req.offlineRunDir = runDir;
|
||
req.offlineTs = ts;
|
||
next();
|
||
} catch (err) {
|
||
res.status(500).json({ error: String(err) });
|
||
}
|
||
}
|
||
|
||
const offlineMulter = multer({
|
||
storage: multer.diskStorage({
|
||
destination: (req, file, cb) => cb(null, req.offlineRunDir),
|
||
filename: (req, file, cb) => {
|
||
const safe = path.basename(file.originalname).replace(/[^a-zA-Z0-9_.-]/g, '_');
|
||
cb(null, safe);
|
||
},
|
||
}),
|
||
}).fields([
|
||
{ name: 'images', maxCount: 10 },
|
||
{ name: 'calibrations', maxCount: 10 },
|
||
{ name: 'robot', maxCount: 1 },
|
||
]);
|
||
|
||
function runOfflineUpload(req, res, next) {
|
||
offlineMulter(req, res, (err) => {
|
||
if (err) return res.status(400).json({ error: `Upload-Fehler: ${err.message}` });
|
||
next();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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(robotCachePath, '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: ${robotCachePath}` });
|
||
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=<timestamp>&from=homing
|
||
* Gibt Daten eines Board-Runs zurück: robot.json + Detection-Ergebnisse + Kamera-Posen.
|
||
* Ohne ?run → neuester Run. Mit ?run=<timestamp> → 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(robotCachePath, '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(robotCachePath), 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: robotCachePath,
|
||
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=<timestamp>
|
||
* 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) });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* POST /api/homing/run-offline
|
||
* Vollständiger Homing-Ablauf ohne Live-Kameras.
|
||
* Bilder, NPZs und robot.json werden per multipart/form-data hochgeladen.
|
||
* Antwortet synchron mit { ok, runDir, state, files, log }.
|
||
*
|
||
* Felder:
|
||
* images – ein oder mehrere JPEG-Dateien, Name muss {cameraId}.jpg sein
|
||
* calibrations – je eine NPZ pro Kamera, Name muss {cameraId}_calibration.npz sein
|
||
* robot – robot.json für diesen Lauf (einmalig, wird nicht dauerhaft gespeichert)
|
||
* refSet – (Text, optional) Referenz-Set für Script 2, z. B. "A0"
|
||
*/
|
||
app.post('/api/homing/run-offline',
|
||
prepareOfflineRunDir,
|
||
runOfflineUpload,
|
||
async (req, res) => {
|
||
const runDir = req.offlineRunDir;
|
||
const ts = req.offlineTs;
|
||
const log = [];
|
||
|
||
// robot.json validieren und als robot_run.json speichern
|
||
const robotFile = req.files?.robot?.[0];
|
||
if (!robotFile) {
|
||
return res.status(400).json({ error: '"robot" fehlt – robot.json muss hochgeladen werden', log });
|
||
}
|
||
let robotRunPath;
|
||
try {
|
||
const content = await fsPromises.readFile(robotFile.path, 'utf8');
|
||
JSON.parse(content); // Syntaxprüfung
|
||
robotRunPath = path.join(runDir, 'robot_run.json');
|
||
await fsPromises.rename(robotFile.path, robotRunPath);
|
||
} catch (err) {
|
||
return res.status(400).json({ error: `robot.json ungültig: ${err.message}`, log });
|
||
}
|
||
|
||
// Mindestens ein Bild erforderlich
|
||
if (!req.files?.images?.length) {
|
||
return res.status(400).json({ error: 'Mindestens ein Bild ("images") fehlt', log });
|
||
}
|
||
|
||
const refSet = req.body?.refSet || undefined;
|
||
|
||
// Logs und done-Event akkumulieren
|
||
let finalState = null;
|
||
let exitCode = -1;
|
||
const send = (obj) => {
|
||
if (obj.type === 'log') log.push(obj.text);
|
||
if (obj.type === 'done') { finalState = obj.state ?? null; exitCode = obj.exitCode; }
|
||
};
|
||
|
||
try {
|
||
await runHomingOffline({
|
||
robotJsonPath: robotRunPath,
|
||
runDir,
|
||
send,
|
||
runScript,
|
||
runBoardPipelineOffline: (dir, s) => runBoardPipelineOffline(dir, s, { refSet }),
|
||
SCRIPT_4B,
|
||
SCRIPT_5POSE,
|
||
});
|
||
} catch (err) {
|
||
console.error('homing/run-offline error:', err);
|
||
return res.status(500).json({ error: String(err), log });
|
||
}
|
||
|
||
// Zu wenige Kameras → aruco_marker_poses.json fehlt
|
||
if (exitCode !== 0) {
|
||
const arucoExists = await fsPromises.access(path.join(runDir, 'aruco_marker_poses.json'))
|
||
.then(() => true).catch(() => false);
|
||
const status = arucoExists ? 500 : 422;
|
||
return res.status(status).json({ error: 'Homing fehlgeschlagen', log });
|
||
}
|
||
|
||
// Alle JSON-Ausgabedateien einlesen (robot_run.json ausgenommen)
|
||
const allFiles = await fsPromises.readdir(runDir).catch(() => []);
|
||
const files = {};
|
||
for (const f of allFiles.sort()) {
|
||
if (f.endsWith('.json') && f !== 'robot_run.json') {
|
||
try { files[f] = JSON.parse(await fsPromises.readFile(path.join(runDir, f), 'utf8')); } catch {}
|
||
}
|
||
}
|
||
|
||
return res.json({ ok: true, runDir: ts, state: finalState, files, log });
|
||
}
|
||
);
|
||
|
||
// ── 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(robotCachePath, { 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(robotCachePath, { 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 = await fetchRobot();
|
||
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 = await fetchRobot();
|
||
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(robotCachePath, { 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(robotCachePath, { 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(robotCachePath, { 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(robotCachePath, { 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(robotCachePath, { 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(robotCachePath, { 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());
|
||
}
|
||
|
||
try {
|
||
await fetchRobot();
|
||
console.log('✅ robot.json geladen und gecacht.');
|
||
} catch (err) {
|
||
console.warn(`⚠ robot.json: Driver nicht erreichbar – nutze lokale Datei: ${err.message}`);
|
||
}
|
||
|
||
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>'}`);
|
||
});
|
||
});
|