264 lines
9.6 KiB
JavaScript
Executable File
264 lines
9.6 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 { WebcamClient } from './webcamClient.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const app = express();
|
|
app.use(express.json({ limit: '20mb' }));
|
|
|
|
const PORT = parseInt(process.env.PORT || process.env.HTTPS_PORT || '2093', 10);
|
|
const publicDir = path.join(__dirname, '..', 'public');
|
|
const snapshotsDir = path.join(publicDir, 'snapshots');
|
|
const WEBCAM_URL = process.env.WEBCAM_URL || '';
|
|
const BODYTRACKER_URL = process.env.BODYTRACKER_URL || '';
|
|
const HTTPS_KEY_PATH = process.env.HTTPS_KEY_PATH || path.join(__dirname, '..', 'https', 'localhost.key');
|
|
const HTTPS_CERT_PATH = process.env.HTTPS_CERT_PATH || path.join(__dirname, '..', 'https', 'localhost.pem');
|
|
const HTTPS_PASSPHRASE = process.env.HTTPS_PASSPHRASE || 'abcd';
|
|
|
|
app.use(express.static(publicDir));
|
|
|
|
app.get('/api/health', (req, res) => {
|
|
res.json({ ok: true, mode: 'backend-proxy', webcamUrl: WEBCAM_URL || null, bodyTrackerUrl: BODYTRACKER_URL || null });
|
|
});
|
|
|
|
// ── WebCam-Proxy ─────────────────────────────────────────────────────────────
|
|
|
|
/** Kameraliste mit Metadaten (inkl. calibrationUrl falls Kalibrierung vorhanden). */
|
|
app.get('/api/webcam/cameras', async (req, res) => {
|
|
if (!WEBCAM_URL) return res.status(501).json({ error: 'WEBCAM_URL ist nicht konfiguriert' });
|
|
try {
|
|
const wc = new WebcamClient(WEBCAM_URL);
|
|
const data = await wc.getCameras();
|
|
return res.json(data);
|
|
} catch (err) {
|
|
console.error('webcam/cameras error:', err);
|
|
return res.status(502).json({ error: 'WebCam-Fehler', details: String(err) });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* HD-JPEG einer Kamera (per Default hires).
|
|
* Streamt die JPEG-Antwort direkt durch — kein Buffering im Backend.
|
|
* Query-Parameter: ?hires=false für Live-Auflösung.
|
|
*/
|
|
app.get('/api/webcam/snapshot/:id', async (req, res) => {
|
|
if (!WEBCAM_URL) return res.status(501).json({ error: 'WEBCAM_URL ist nicht konfiguriert' });
|
|
const hires = req.query.hires !== 'false';
|
|
try {
|
|
const wc = new WebcamClient(WEBCAM_URL);
|
|
const upstream = await wc.getSnapshot(req.params.id, hires);
|
|
|
|
// Relevante Response-Header durchreichen
|
|
res.setHeader('Content-Type', upstream.headers.get('content-type') || 'image/jpeg');
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
for (const header of ['x-camera-id', 'x-frame-width', 'x-timestamp', 'content-length']) {
|
|
const val = upstream.headers.get(header);
|
|
if (val) res.setHeader(header, val);
|
|
}
|
|
|
|
const nodeStream = Readable.fromWeb(upstream.body);
|
|
nodeStream.on('error', (err) => {
|
|
console.error(`webcam/snapshot/${req.params.id} stream error:`, err);
|
|
if (!res.headersSent) res.status(502).json({ error: 'Stream-Fehler' });
|
|
});
|
|
nodeStream.pipe(res);
|
|
} catch (err) {
|
|
console.error(`webcam/snapshot/${req.params.id} error:`, err);
|
|
if (!res.headersSent) res.status(502).json({ error: 'WebCam-Fehler', details: String(err) });
|
|
}
|
|
});
|
|
|
|
async function findLatestSnapshotFile() {
|
|
const files = await fsPromises.readdir(snapshotsDir);
|
|
const entries = await Promise.all(
|
|
files
|
|
.filter((name) => name.endsWith('.csv'))
|
|
.map(async (name) => ({
|
|
name,
|
|
mtime: (await fsPromises.stat(path.join(snapshotsDir, name))).mtime.valueOf()
|
|
}))
|
|
);
|
|
if (entries.length === 0) return null;
|
|
entries.sort((a, b) => b.mtime - a.mtime);
|
|
return entries[0].name;
|
|
}
|
|
|
|
app.get('/api/latest-snapshot', async (req, res) => {
|
|
try {
|
|
if (WEBCAM_URL) {
|
|
const url = new URL('/api/latest-snapshot', WEBCAM_URL).toString();
|
|
const fetchRes = await fetch(url);
|
|
const contentType = fetchRes.headers.get('content-type') || '';
|
|
|
|
if (!fetchRes.ok) {
|
|
const text = await fetchRes.text();
|
|
return res.status(fetchRes.status).type('text/plain').send(text);
|
|
}
|
|
|
|
if (contentType.includes('application/json')) {
|
|
const body = await fetchRes.json();
|
|
return res.json(body);
|
|
}
|
|
|
|
const text = await fetchRes.text();
|
|
return res.json({ filename: 'latest.csv', mtime: new Date().toISOString(), content: text });
|
|
}
|
|
|
|
const latestFile = await findLatestSnapshotFile();
|
|
if (!latestFile) {
|
|
return res.status(404).json({ error: 'Keine Snapshot-CSV-Datei gefunden' });
|
|
}
|
|
|
|
const baseName = path.basename(latestFile, path.extname(latestFile));
|
|
const csvPath = path.join(snapshotsDir, latestFile);
|
|
const jsonPath = path.join(snapshotsDir, `${baseName}.json`);
|
|
const imagePath = path.join(snapshotsDir, `${baseName}_annotated.jpg`);
|
|
const imagePath2 = path.join(snapshotsDir, `${baseName}_annotated2.jpg`);
|
|
|
|
const content = await fsPromises.readFile(csvPath, 'utf8');
|
|
const result = { filename: latestFile, mtime: (await fsPromises.stat(csvPath)).mtime.toISOString(), content };
|
|
|
|
try {
|
|
result.jsonFile = { filename: `${baseName}.json`, content: await fsPromises.readFile(jsonPath, 'utf8') };
|
|
} catch {}
|
|
|
|
try {
|
|
const jpg = await fsPromises.readFile(imagePath);
|
|
result.imageFile = {
|
|
filename: path.basename(imagePath),
|
|
mimeType: 'image/jpeg',
|
|
contentBase64: jpg.toString('base64')
|
|
};
|
|
} catch {}
|
|
|
|
try {
|
|
const jpg2 = await fsPromises.readFile(imagePath2);
|
|
result.image2 = {
|
|
filename: path.basename(imagePath2),
|
|
mimeType: 'image/jpeg',
|
|
contentBase64: jpg2.toString('base64')
|
|
};
|
|
} catch {}
|
|
|
|
return res.json(result);
|
|
} catch (err) {
|
|
console.error('latest-snapshot error:', err);
|
|
return res.status(500).json({ error: 'Fehler beim Laden des Snapshots', details: String(err) });
|
|
}
|
|
});
|
|
|
|
app.post('/api/estimate', async (req, res) => {
|
|
if (!BODYTRACKER_URL) {
|
|
return res.status(501).json({ error: 'BODYTRACKER_URL ist nicht konfiguriert' });
|
|
}
|
|
|
|
try {
|
|
const { imageFile, image2, robotIntrinsics } = req.body;
|
|
const formData = new FormData();
|
|
|
|
if (imageFile?.contentBase64) {
|
|
const buffer = Buffer.from(imageFile.contentBase64, 'base64');
|
|
formData.append('images', new Blob([buffer], { type: imageFile.mimeType || 'image/jpeg' }), imageFile.filename || 'snapshot.jpg');
|
|
}
|
|
|
|
if (image2?.contentBase64) {
|
|
const buffer2 = Buffer.from(image2.contentBase64, 'base64');
|
|
formData.append('images', new Blob([buffer2], { type: image2.mimeType || 'image/jpeg' }), image2.filename || 'snapshot2.jpg');
|
|
}
|
|
|
|
if (robotIntrinsics) {
|
|
formData.append('intrinsics', new Blob([JSON.stringify(robotIntrinsics)], { type: 'application/json' }), 'intrinsics.json');
|
|
}
|
|
|
|
const estimateUrl = new URL('/v1/estimate', BODYTRACKER_URL).toString();
|
|
const fetchRes = await fetch(estimateUrl, { method: 'POST', body: formData });
|
|
|
|
if (!fetchRes.ok) {
|
|
const message = await fetchRes.text();
|
|
return res.status(fetchRes.status).json({ error: 'BodyTracker-Fehler', details: message });
|
|
}
|
|
|
|
const body = await fetchRes.json();
|
|
return res.json(body);
|
|
} catch (err) {
|
|
console.error('estimate error:', err);
|
|
return res.status(500).json({ error: 'Fehler beim Aufruf des BodyTracker', details: String(err) });
|
|
}
|
|
});
|
|
|
|
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>'}`);
|
|
});
|
|
});
|