Initiales Projekt-Skelett appRobotFileservice

Ausgelagertes Programm-/File-Handling (vormals GCode.receiveFC im appRobotDriver,
ToDo_4 / ToDo_6b). Express-Service mit .gcode + .json-Storage, aktivem Programm +
Cursor, Teaching (FPoint) und Playback. Speicherung in Grad, driver-nativ (Radian)
zum Driver. Konzept/API unter doc/draft_filehandeling*.md. Tests: jest (13 gruen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chk
2026-06-14 10:12:41 +02:00
commit b68bdfa9b4
20 changed files with 6085 additions and 0 deletions

220
src/active/activeState.js Normal file
View File

@@ -0,0 +1,220 @@
/**
* Aktives Programm + Cursor — Single Source of Truth (doc/draft_filehandeling.md §9).
*
* Der Cursor lebt zur Laufzeit als In-Memory-Index (schnelles Stepping ohne
* Datei-Neuschreiben). Beim Laden wird er aus dem '!'-Kommentar gelesen, beim
* Speichern/Entladen als '!' in die Cursor-Zeile zurückgeschrieben.
*
* Inhaltliche Änderungen (FPoint, Editieren) werden direkt persistiert (durables
* Teaching); reine Cursor-Bewegungen NICHT.
*/
const store = require('../store/fileStore');
const units = require('../gcode/units');
const { ApiError } = require('../errors');
class ActiveState {
constructor() {
this.programId = null;
this.name = null;
// gespeicherte Zeilen (Grad, mit ;<epoch>-Kommentar, OHNE '!' — Cursor separat)
this.lines = [];
this.cursor = 0;
this.playing = false;
this.version = 0;
}
_touch() {
this.version += 1;
}
_requireActive() {
if (!this.programId) throw new ApiError(409, 'NO_ACTIVE_PROGRAM', 'no active program');
}
/** API-Repräsentation (ActiveState). currentLine = driver-nativ (Radian). */
getState() {
const currentLine = this.lines.length ? units.toExecutable(this.lines[this.cursor]) : null;
return {
programId: this.programId,
cursor: this.cursor,
lineCount: this.lines.length,
currentLine,
playing: this.playing,
version: this.version,
};
}
/**
* Setzt ein Programm aktiv (FLoad). Existiert es nicht, wird es leer angelegt
* (nötig für Teaching). Ein vorher aktives Programm wird zuvor persistiert.
*/
async load(id, name) {
store.assertValidId(id);
await this._persistIfActive();
if (!(await store.exists(id))) {
await store.write(id, { name: name || id, lines: [] });
this.programId = id;
this.name = name || id;
this.lines = [];
this.cursor = 0;
this.playing = false;
this._touch();
return this.getState();
}
const prog = await store.read(id);
let cursor = 0;
const lines = prog.lines.map((line, i) => {
if (units.hasCursorMarker(line)) cursor = i;
return units.removeCursorMarker(line);
});
this.programId = id;
this.name = prog.name;
this.lines = lines;
this.cursor = Math.min(cursor, Math.max(0, lines.length - 1));
this.playing = false;
this._touch();
return this.getState();
}
/** Leert das aktive Programm (FClear). */
async clear() {
this._requireActive();
this.lines = [];
this.cursor = 0;
this.playing = false;
this._touch();
await this._persist();
return this.getState();
}
// ---- Stepping (reine Cursor-Bewegung, gibt die ausführbare Zeile zurück) ----
_gotoIndex(index) {
this._requireActive();
if (!this.lines.length) throw new ApiError(409, 'EMPTY_PROGRAM', 'active program is empty');
if (index < 0 || index >= this.lines.length) {
throw new ApiError(
409,
'CURSOR_OUT_OF_RANGE',
`index ${index} out of range 0..${this.lines.length - 1}`
);
}
this.cursor = index;
this._touch();
return { cursor: this.cursor, line: units.toExecutable(this.lines[this.cursor]) };
}
next() { return this._gotoIndex(this.cursor + 1); }
prev() { return this._gotoIndex(this.cursor - 1); }
first() { return this._gotoIndex(0); }
last() { return this._gotoIndex(this.lines.length - 1); }
goto(index) { return this._gotoIndex(Number(index)); }
// ---- Teaching / Editieren (persistiert) ----
/** Hängt die aktuelle Pose als G-Code-Zeile an (FPoint). pose: a/b/c/e in RADIAN. */
async appendPoint(pose, feedrate) {
this._requireActive();
if (!pose) throw new ApiError(400, 'FILE_ERROR', 'pose required');
const line = units.formatPointLine(pose, feedrate);
this.lines.push(line);
this.cursor = this.lines.length - 1;
this._touch();
await this._persist();
return { index: this.cursor, line };
}
/** Hängt eine rohe Zeile an oder fügt sie an atIndex ein. */
async appendLine(line, atIndex) {
this._requireActive();
if (!line) throw new ApiError(400, 'FILE_ERROR', 'line required');
const clean = units.removeCursorMarker(String(line));
if (atIndex == null) {
this.lines.push(clean);
this.cursor = this.lines.length - 1;
} else {
const i = Math.max(0, Math.min(Number(atIndex), this.lines.length));
this.lines.splice(i, 0, clean);
this.cursor = i;
}
this._touch();
await this._persist();
return { index: this.cursor, line: clean };
}
async replaceLine(index, line) {
this._requireActive();
const i = Number(index);
if (i < 0 || i >= this.lines.length) {
throw new ApiError(409, 'CURSOR_OUT_OF_RANGE', `index ${i} out of range`);
}
this.lines[i] = units.removeCursorMarker(String(line));
this._touch();
await this._persist();
return { index: i, line: this.lines[i] };
}
async deleteLine(index) {
this._requireActive();
const i = Number(index);
if (i < 0 || i >= this.lines.length) {
throw new ApiError(409, 'CURSOR_OUT_OF_RANGE', `index ${i} out of range`);
}
this.lines.splice(i, 1);
if (this.cursor >= this.lines.length) this.cursor = Math.max(0, this.lines.length - 1);
this._touch();
await this._persist();
return this.getState();
}
// ---- Playback (passiv: der Driver führt die Zeilen aus) ----
/** Liefert die ausführbaren Zeilen ab Cursor (bzw. ab 0). Setzt playing. */
play({ mode = 'run', fromStart = false } = {}) {
this._requireActive();
if (!this.lines.length) throw new ApiError(409, 'EMPTY_PROGRAM', 'active program is empty');
if (fromStart) this.cursor = 0;
this.playing = true;
this._touch();
if (mode === 'step') {
return { mode, cursor: this.cursor, lines: [units.toExecutable(this.lines[this.cursor])] };
}
return { mode, cursor: this.cursor, lines: this.lines.slice(this.cursor).map(units.toExecutable) };
}
stop() {
this.playing = false;
this._touch();
return this.getState();
}
// ---- Speichern ----
/** Speichert den aktiven Puffer unter einem (neuen) Namen (FSave). */
async saveAs(name) {
this._requireActive();
const id = store.slugify(name);
if (!id) throw new ApiError(400, 'INVALID_NAME', `invalid name: ${name}`);
const meta = await store.write(id, { name, lines: this._linesWithCursor() });
return { id: meta.id, lineCount: meta.lineCount };
}
/** In-Memory-Zeilen mit '!' an der Cursor-Position (für die Persistenz). */
_linesWithCursor() {
return this.lines.map((line, i) => (i === this.cursor ? units.addCursorMarker(line) : line));
}
async _persist() {
if (!this.programId) return;
await store.write(this.programId, { name: this.name, lines: this._linesWithCursor() });
}
async _persistIfActive() {
if (this.programId) await this._persist();
}
}
// Singleton — gemeinsamer Zustand für alle Anfragen (Single Source of Truth).
module.exports = { ActiveState, active: new ActiveState() };

15
src/auth.js Normal file
View File

@@ -0,0 +1,15 @@
const cfg = require('./config');
const { envelope } = require('./errors');
/**
* Bearer-Auth für schreibende Endpoints. Ohne gesetzten FILE_API_KEY offen (Dev).
* Anlehnung an ROBOT_API_KEY im Driver.
*/
function requireAuth(req, res, next) {
if (!cfg.apiKey) return next();
const header = req.get('authorization') || '';
if (header === `Bearer ${cfg.apiKey}`) return next();
return res.status(401).json(envelope('UNAUTHORIZED', 'invalid or missing bearer token'));
}
module.exports = requireAuth;

17
src/config.js Normal file
View File

@@ -0,0 +1,17 @@
// Zentrale Konfiguration aus Umgebungsvariablen (mit sinnvollen Defaults).
// Die appRobotFileservice ist passiv und braucht KEINEN Driver-Zugang.
const path = require('path');
module.exports = {
// Port des HTTP-Service.
port: Number(process.env.FILE_SERVICE_PORT) || 2100,
// Verzeichnis für die Programm-Dateien (.gcode) + Sidecars (.json).
storageDir: process.env.STORAGE_DIR || path.join(__dirname, '..', 'GCodeFiles'),
// Datei-Endung der Programme: 'gcode' (Default) oder 'ngc'.
fileExt: (process.env.FILE_EXT || 'gcode').replace(/^\./, ''),
// Einheit, in der Winkel gespeichert werden (standardnahe .gcode → Grad).
storeAngleUnit: process.env.STORE_ANGLE_UNIT || 'deg',
// Optionaler Bearer-Token für schreibende Endpoints.
// Fehlt er, sind Schreibzugriffe offen (Dev-Modus).
apiKey: process.env.FILE_API_KEY || null,
};

32
src/errors.js Normal file
View File

@@ -0,0 +1,32 @@
// Fehler-Modell — Envelope konsistent mit dem Driver (doc/ToDo_5_API.md):
// { type: 'error', code, message, input }
/** Fehler mit HTTP-Status + maschinenlesbarem Code. */
class ApiError extends Error {
constructor(status, code, message) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
}
}
/** Baut den maschinenlesbaren Fehler-Envelope. */
function envelope(code, message, input = null) {
return { type: 'error', code, message, input };
}
/** Express-Fehler-Middleware. */
function errorMiddleware(err, req, res, _next) {
if (err instanceof ApiError) {
return res.status(err.status).json(envelope(err.code, err.message));
}
// Ungültiger JSON-Body (vom express.json-Parser)
if (err && err.type === 'entity.parse.failed') {
return res.status(400).json(envelope('FILE_ERROR', 'invalid JSON body'));
}
console.error(err);
return res.status(500).json(envelope('FILE_ERROR', err.message || 'internal error'));
}
module.exports = { ApiError, envelope, errorMiddleware };

110
src/gcode/units.js Normal file
View File

@@ -0,0 +1,110 @@
/**
* G-Code-Einheiten & Zeilenformat der appRobotFileservice.
*
* Vertrag (siehe doc/draft_filehandeling.md §7):
* - GESPEICHERT wird in GRAD (standardnahe .gcode): a/b/c/e in Grad, x/y/z in mm.
* - Der DRIVER erwartet am Eingang RADIAN für a/b/c/e.
* - Diese Umrechnung passiert AUSSCHLIESSLICH hier (an der Storage-Grenze);
* der Driver rechnet nie um.
*
* Zeitstempel und Cursor liegen im G-Code-Kommentarfeld (standardkonform):
* - jede Zeile endet mit ';<epoch>'
* - die Cursor-Zeile zusätzlich mit '!': ';<epoch>!'
*/
// Achsen, die Winkel/Greifer sind und Grad↔Radian umgerechnet werden.
const ANGLE_AXES = ['a', 'b', 'c', 'e'];
const degToRad = (deg) => (deg * Math.PI) / 180;
const radToDeg = (rad) => (rad * 180) / Math.PI;
const isNumeric = (s) => s !== '' && s != null && !Number.isNaN(Number(s));
// Zahl ohne überflüssige Nachkommastellen (z. B. 90.000000 → 90, 1.500 → 1.5).
const trimNum = (v, digits = 6) => String(Number(Number(v).toFixed(digits)));
/**
* Zerlegt eine gespeicherte Zeile in Code-Teil, Kommentar und Cursor-Flag.
* @returns {{code:string, comment:string, cursor:boolean}}
*/
function splitComment(line) {
const s = String(line);
const idx = s.indexOf(';');
if (idx === -1) return { code: s.trim(), comment: '', cursor: false };
const code = s.slice(0, idx).trim();
let comment = s.slice(idx + 1).trim();
const cursor = comment.endsWith('!');
if (cursor) comment = comment.slice(0, -1).trim();
return { code, comment, cursor };
}
/** Baut eine gespeicherte Zeile aus Code-Teil, Kommentar und Cursor-Flag. */
function buildLine(code, comment = '', cursor = false) {
let out = String(code).trim();
if (comment || cursor) out += ` ;${comment}${cursor ? '!' : ''}`;
return out;
}
const hasCursorMarker = (line) => splitComment(line).cursor;
const addCursorMarker = (line) => {
const { code, comment } = splitComment(line);
return buildLine(code, comment, true);
};
const removeCursorMarker = (line) => {
const { code, comment } = splitComment(line);
return buildLine(code, comment, false);
};
/**
* Wandelt eine gespeicherte Zeile (Grad, mit Kommentar) in eine driver-native,
* ausführbare Zeile um (Radian, ohne Kommentar).
*/
function toExecutable(storedLine) {
const { code } = splitComment(storedLine);
return code
.split(/\s+/)
.filter(Boolean)
.map((tok) => {
const axis = tok[0].toLowerCase();
const rest = tok.slice(1);
if (ANGLE_AXES.includes(axis) && isNumeric(rest)) {
return axis + trimNum(degToRad(Number(rest)));
}
return tok;
})
.join(' ');
}
/**
* Baut eine zu speichernde G-Code-Zeile (Grad + Zeitstempel-Kommentar) aus einer
* Driver-Pose (Radian).
* @param {{x:number,y:number,z:number,a:number,b:number,c:number,e:number}} pose a/b/c/e in RADIAN
* @param {number} feedrate
* @param {number} epoch Zeitstempel (ms seit Epoch)
*/
function formatPointLine(pose, feedrate = 1000, epoch = Date.now()) {
const deg = (r) => radToDeg(Number(r) || 0).toFixed(2);
const mm = (v) => trimNum(Number(v) || 0, 3);
const code = [
'G90', 'G1',
`x${mm(pose.x)}`, `y${mm(pose.y)}`, `z${mm(pose.z)}`,
`a${deg(pose.a)}`, `b${deg(pose.b)}`, `c${deg(pose.c)}`, `e${deg(pose.e)}`,
`f${trimNum(Number(feedrate) || 1000, 3)}`,
].join(' ');
return buildLine(code, String(Math.floor(epoch)), false);
}
module.exports = {
ANGLE_AXES,
degToRad,
radToDeg,
splitComment,
buildLine,
hasCursorMarker,
addCursorMarker,
removeCursorMarker,
toExecutable,
formatPointLine,
};

72
src/routes/active.js Normal file
View File

@@ -0,0 +1,72 @@
// Aktives Programm + Cursor (FLoad/FClear/FPlus/FMinus/FFirst/FLast/FGoto/
// FPoint/FPlay/FStop). Die zurückgegebenen Playback-Zeilen sind driver-nativ
// (Radian) — ausgeführt werden sie vom Driver.
const express = require('express');
const { active } = require('../active/activeState');
const requireAuth = require('../auth');
const { ApiError } = require('../errors');
const router = express.Router();
const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
// GET /api/active
router.get('/', (req, res) => res.json(active.getState()));
// PUT /api/active (FLoad) — existiert nicht → leer anlegen (für Teaching)
router.put(
'/',
requireAuth,
asyncH(async (req, res) => {
const { id, name } = req.body || {};
if (!id) throw new ApiError(400, 'INVALID_NAME', 'id required');
res.json(await active.load(id, name));
})
);
// POST /api/active/clear (FClear)
router.post('/clear', requireAuth, asyncH(async (req, res) => res.json(await active.clear())));
// Stepping (synchron; ApiError wird von Express an die Fehler-Middleware gereicht)
router.post('/next', requireAuth, (req, res) => res.json(active.next()));
router.post('/prev', requireAuth, (req, res) => res.json(active.prev()));
router.post('/first', requireAuth, (req, res) => res.json(active.first()));
router.post('/last', requireAuth, (req, res) => res.json(active.last()));
router.post('/goto', requireAuth, (req, res) => res.json(active.goto((req.body || {}).index)));
// Teaching / Editieren
router.post(
'/points',
requireAuth,
asyncH(async (req, res) => {
const { pose, feedrate } = req.body || {};
res.status(201).json(await active.appendPoint(pose, feedrate));
})
);
router.post(
'/lines',
requireAuth,
asyncH(async (req, res) => {
const { line, atIndex } = req.body || {};
res.status(201).json(await active.appendLine(line, atIndex));
})
);
router.put(
'/lines/:index',
requireAuth,
asyncH(async (req, res) => {
res.json(await active.replaceLine(req.params.index, (req.body || {}).line));
})
);
router.delete(
'/lines/:index',
requireAuth,
asyncH(async (req, res) => {
res.json(await active.deleteLine(req.params.index));
})
);
// Playback
router.post('/play', requireAuth, (req, res) => res.json(active.play(req.body || {})));
router.post('/stop', requireAuth, (req, res) => res.json(active.stop()));
module.exports = router;

75
src/routes/programs.js Normal file
View File

@@ -0,0 +1,75 @@
// Programm-Verwaltung (FList/FShow/FSave/…). Storage-agnostisch über id/Name.
const express = require('express');
const store = require('../store/fileStore');
const { active } = require('../active/activeState');
const requireAuth = require('../auth');
const { ApiError } = require('../errors');
const router = express.Router();
const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
// GET /api/programs (FList)
router.get(
'/',
asyncH(async (req, res) => {
res.json({ programs: await store.list() });
})
);
// GET /api/programs/:id (FShow) — Inhalt in Grad, wie gespeichert.
router.get(
'/:id',
asyncH(async (req, res) => {
const prog = await store.read(req.params.id);
res.json({
id: prog.id,
name: prog.name,
displayUnit: prog.meta.angleUnit || 'deg',
lines: prog.lines,
});
})
);
// POST /api/programs (FSave) — aus aktivem Puffer ODER expliziter Inhalt.
router.post(
'/',
requireAuth,
asyncH(async (req, res) => {
const { name, fromActive, lines } = req.body || {};
if (!name) throw new ApiError(400, 'INVALID_NAME', 'name required');
if (fromActive) {
return res.status(201).json(await active.saveAs(name));
}
const id = store.slugify(name);
if (!id) throw new ApiError(400, 'INVALID_NAME', `invalid name: ${name}`);
const meta = await store.write(id, { name, lines: Array.isArray(lines) ? lines : [] });
res.status(201).json({ id: meta.id, lineCount: meta.lineCount });
})
);
// PUT /api/programs/:id — Inhalt ersetzen / umbenennen.
router.put(
'/:id',
requireAuth,
asyncH(async (req, res) => {
const { name, lines } = req.body || {};
const existing = await store.read(req.params.id); // 404, falls nicht vorhanden
const meta = await store.write(req.params.id, {
name: name || existing.name,
lines: Array.isArray(lines) ? lines : existing.lines,
});
res.json({ id: meta.id, lineCount: meta.lineCount });
})
);
// DELETE /api/programs/:id
router.delete(
'/:id',
requireAuth,
asyncH(async (req, res) => {
await store.remove(req.params.id);
res.status(204).end();
})
);
module.exports = router;

21
src/server.js Normal file
View File

@@ -0,0 +1,21 @@
// Express-App der appRobotFileservice. createApp() ist test-freundlich (kein listen).
const express = require('express');
const programsRouter = require('./routes/programs');
const activeRouter = require('./routes/active');
const { errorMiddleware, envelope } = require('./errors');
function createApp() {
const app = express();
app.use(express.json({ limit: '5mb' }));
app.get('/api/health', (req, res) => res.json({ ok: true, service: 'appRobotFileservice' }));
app.use('/api/programs', programsRouter);
app.use('/api/active', activeRouter);
// Unbekannter Pfad → 404-Envelope
app.use((req, res) => res.status(404).json(envelope('NOT_FOUND', 'unknown endpoint', req.path)));
app.use(errorMiddleware);
return app;
}
module.exports = { createApp };

139
src/store/fileStore.js Normal file
View File

@@ -0,0 +1,139 @@
/**
* Datei-basierte Persistenz der Programme:
* <id>.<ext> — G-Code (Grad), standardnah, Zeitstempel/Cursor im Kommentar
* <id>.json — Sidecar mit Metadaten (Name, Zeiten, lineCount, angleUnit)
*
* Nach außen werden Programme NUR über die id angesprochen — niemals über Pfade.
* Storage-Details bleiben hier gekapselt (Konzept §8/§10).
*/
const fsp = require('fs/promises');
const path = require('path');
const cfg = require('../config');
const { ApiError } = require('../errors');
const ID_RE = /^[a-z0-9_]+$/;
/** Wandelt einen Anzeigenamen in eine sichere id (keine Pfade, kein '../'). */
function slugify(name) {
return String(name || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 100);
}
/** Stellt sicher, dass eine id keine Pfad-Trenner o. Ä. enthält. */
function assertValidId(id) {
if (!ID_RE.test(String(id || ''))) {
throw new ApiError(400, 'INVALID_NAME', `invalid program id: ${id}`);
}
return id;
}
const gcodePath = (id) => path.join(cfg.storageDir, `${id}.${cfg.fileExt}`);
const jsonPath = (id) => path.join(cfg.storageDir, `${id}.json`);
async function ensureDir() {
await fsp.mkdir(cfg.storageDir, { recursive: true });
}
async function exists(id) {
try {
await fsp.access(gcodePath(id));
return true;
} catch {
return false;
}
}
/** Zerlegt Datei-Text in nicht-leere Zeilen (CR/LF-tolerant). */
function splitLines(text) {
return String(text)
.split(/\r?\n/)
.map((l) => l.replace(/\s+$/, ''))
.filter((l) => l.trim().length > 0);
}
/** Liest ein Programm: { id, name, lines (Grad, mit Kommentaren), meta }. */
async function read(id) {
assertValidId(id);
if (!(await exists(id))) {
throw new ApiError(404, 'PROGRAM_NOT_FOUND', `no program '${id}'`);
}
const text = await fsp.readFile(gcodePath(id), 'utf8');
const lines = splitLines(text);
let meta = {};
try {
meta = JSON.parse(await fsp.readFile(jsonPath(id), 'utf8'));
} catch {
/* Sidecar ist optional */
}
return { id, name: meta.name || id, lines, meta };
}
/** Schreibt .gcode + .json. lines = gespeicherte Zeilen (Grad, inkl. Kommentar/Cursor). */
async function write(id, { name, lines }) {
assertValidId(id);
await ensureDir();
const now = new Date().toISOString();
let createdAt = now;
try {
const prev = JSON.parse(await fsp.readFile(jsonPath(id), 'utf8'));
createdAt = prev.createdAt || now;
} catch {
/* neues Programm */
}
const meta = {
id,
name: name || id,
lineCount: lines.length,
angleUnit: cfg.storeAngleUnit,
createdAt,
updatedAt: now,
};
const body = lines.join('\n') + (lines.length ? '\n' : '');
await fsp.writeFile(gcodePath(id), body, 'utf8');
await fsp.writeFile(jsonPath(id), JSON.stringify(meta, null, 2) + '\n', 'utf8');
return meta;
}
async function remove(id) {
assertValidId(id);
if (!(await exists(id))) {
throw new ApiError(404, 'PROGRAM_NOT_FOUND', `no program '${id}'`);
}
await fsp.rm(gcodePath(id), { force: true });
await fsp.rm(jsonPath(id), { force: true });
}
/** Liste aller Programme (id, name, lineCount). */
async function list() {
await ensureDir();
const entries = await fsp.readdir(cfg.storageDir);
const ext = `.${cfg.fileExt}`;
const ids = entries.filter((f) => f.endsWith(ext)).map((f) => f.slice(0, -ext.length));
const out = [];
for (const id of ids) {
try {
const { name, lines, meta } = await read(id);
out.push({ id, name, lineCount: meta.lineCount ?? lines.length });
} catch {
/* defekte Einträge überspringen */
}
}
return out;
}
module.exports = {
slugify,
assertValidId,
exists,
read,
write,
remove,
list,
ensureDir,
gcodePath,
jsonPath,
};