This commit is contained in:
chk
2026-06-14 13:40:38 +02:00
parent e6abe047dc
commit c68ce406a6
8 changed files with 108 additions and 31 deletions

View File

@@ -11,6 +11,7 @@
const store = require('../store/fileStore');
const units = require('../gcode/units');
const cfg = require('../config');
const log = require('../log');
const { ApiError } = require('../errors');
class ActiveState {
@@ -32,6 +33,20 @@ class ActiveState {
if (!this.programId) throw new ApiError(409, 'NO_ACTIVE_PROGRAM', 'no active program');
}
/**
* Stellt sicher, dass ein Programm aktiv ist. Ist keines geladen, wird das
* Default-Programm (cfg.defaultProgramId, i. d. R. 'log') geladen — exakt das
* alte Verhalten, bei dem log.gcode immer implizit aktiv war. Gilt für Lesen
* (Stepping/Play) UND Schreiben (FPoint), damit nach einem Neustart sofort
* gearbeitet werden kann, ohne vorher FLoad zu senden.
*/
async _ensureActive() {
if (!this.programId) {
log.info(`kein aktives Programm → lade Default '${cfg.defaultProgramId}'`);
await this.load(cfg.defaultProgramId);
}
}
/** API-Repräsentation (ActiveState). currentLine = driver-nativ (Radian). */
getState() {
const currentLine = this.lines.length ? units.toExecutable(this.lines[this.cursor]) : null;
@@ -61,6 +76,7 @@ class ActiveState {
this.cursor = 0;
this.playing = false;
this._touch();
log.info(`load '${id}' → neu (leer angelegt)`);
return this.getState();
}
@@ -71,12 +87,13 @@ class ActiveState {
this.cursor = Math.min(prog.cursor ?? 0, Math.max(0, prog.lines.length - 1));
this.playing = false;
this._touch();
log.info(`load '${id}' → ${prog.lines.length} Zeilen von Disk, cursor ${this.cursor}`);
return this.getState();
}
/** Leert das aktive Programm (FClear). */
async clear() {
this._requireActive();
await this._ensureActive();
this.lines = [];
this.cursor = 0;
this.playing = false;
@@ -88,7 +105,6 @@ class ActiveState {
// ---- 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(
@@ -102,30 +118,32 @@ class ActiveState {
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)); }
// Stepping lädt bei Bedarf das Default-Programm (Lesen mit implizitem log).
async next() { await this._ensureActive(); return this._gotoIndex(this.cursor + 1); }
async prev() { await this._ensureActive(); return this._gotoIndex(this.cursor - 1); }
async first() { await this._ensureActive(); return this._gotoIndex(0); }
async last() { await this._ensureActive(); return this._gotoIndex(this.lines.length - 1); }
async goto(index) { await this._ensureActive(); 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.
* Kein aktives Programm → Default-Programm auto-laden (backward-compat. mit log.gcode). */
async appendPoint(pose, feedrate) {
if (!this.programId) await this.load(cfg.defaultProgramId);
await this._ensureActive();
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();
log.info(`FPoint → '${this.programId}' index ${this.cursor}: ${line}`);
return { index: this.cursor, line };
}
/** Hängt eine rohe Zeile an oder fügt sie an atIndex ein. */
async appendLine(line, atIndex) {
this._requireActive();
await this._ensureActive();
if (!line) throw new ApiError(400, 'FILE_ERROR', 'line required');
const clean = units.removeCursorMarker(String(line));
if (atIndex == null) {
@@ -169,8 +187,8 @@ class ActiveState {
// ---- 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();
async play({ mode = 'run', fromStart = false } = {}) {
await this._ensureActive();
if (!this.lines.length) throw new ApiError(409, 'EMPTY_PROGRAM', 'active program is empty');
if (fromStart) this.cursor = 0;
this.playing = true;

View File

@@ -1,5 +1,6 @@
// Fehler-Modell — Envelope konsistent mit dem Driver (doc/ToDo_5_API.md):
// { type: 'error', code, message, input }
const log = require('./log');
/** Fehler mit HTTP-Status + maschinenlesbarem Code. */
class ApiError extends Error {
@@ -19,13 +20,15 @@ function envelope(code, message, input = null) {
/** Express-Fehler-Middleware. */
function errorMiddleware(err, req, res, _next) {
if (err instanceof ApiError) {
log.warn(`${err.code} (${err.status}): ${err.message}`);
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') {
log.warn('FILE_ERROR: invalid JSON body');
return res.status(400).json(envelope('FILE_ERROR', 'invalid JSON body'));
}
console.error(err);
log.error(err.stack || err.message || err);
return res.status(500).json(envelope('FILE_ERROR', err.message || 'internal error'));
}

18
src/log.js Normal file
View File

@@ -0,0 +1,18 @@
// Schlanker, konsistenter Logger. Alle Ausgaben mit Zeitstempel + [fsvc]-Präfix,
// damit man im Container-Log Fileservice-Zeilen sofort erkennt.
// Im Test (NODE_ENV=test) still, damit die Jest-Ausgabe sauber bleibt.
const silent = process.env.NODE_ENV === 'test';
const ts = () => new Date().toISOString();
function info(...args) {
if (!silent) console.log(`${ts()} [fsvc]`, ...args);
}
function warn(...args) {
if (!silent) console.warn(`${ts()} [fsvc] ⚠`, ...args);
}
function error(...args) {
if (!silent) console.error(`${ts()} [fsvc] ✖`, ...args);
}
module.exports = { info, warn, error };

View File

@@ -26,12 +26,12 @@ router.put(
// 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)));
// Stepping (lädt bei Bedarf das Default-Programm; ApiError → Fehler-Middleware)
router.post('/next', requireAuth, asyncH(async (req, res) => res.json(await active.next())));
router.post('/prev', requireAuth, asyncH(async (req, res) => res.json(await active.prev())));
router.post('/first', requireAuth, asyncH(async (req, res) => res.json(await active.first())));
router.post('/last', requireAuth, asyncH(async (req, res) => res.json(await active.last())));
router.post('/goto', requireAuth, asyncH(async (req, res) => res.json(await active.goto((req.body || {}).index))));
// Teaching / Editieren
router.post(
@@ -66,7 +66,7 @@ router.delete(
);
// Playback
router.post('/play', requireAuth, (req, res) => res.json(active.play(req.body || {})));
router.post('/play', requireAuth, asyncH(async (req, res) => res.json(await active.play(req.body || {}))));
router.post('/stop', requireAuth, (req, res) => res.json(active.stop()));
module.exports = router;

View File

@@ -3,11 +3,24 @@ const express = require('express');
const programsRouter = require('./routes/programs');
const activeRouter = require('./routes/active');
const { errorMiddleware, envelope } = require('./errors');
const log = require('./log');
function createApp() {
const app = express();
app.use(express.json({ limit: '5mb' }));
// Request-Logging: jede Anfrage mit Methode, Pfad, Status und Dauer. So sieht man
// im Container-Log sofort, ob der Driver durchkommt und wie der Service antwortet.
app.use((req, res, next) => {
const t0 = Date.now();
res.on('finish', () => {
const ms = Date.now() - t0;
const mark = res.statusCode >= 400 ? '✖' : '→';
log.info(`${mark} ${req.method} ${req.originalUrl} ${res.statusCode} (${ms}ms)`);
});
next();
});
app.get('/api/health', (req, res) => res.json({ ok: true, service: 'appRobotFileservice' }));
app.use('/api/programs', programsRouter);
app.use('/api/active', activeRouter);

View File

@@ -10,6 +10,7 @@ const fsp = require('fs/promises');
const path = require('path');
const cfg = require('../config');
const units = require('../gcode/units');
const log = require('../log');
const { ApiError } = require('../errors');
const ID_RE = /^[a-z0-9_]+$/;
@@ -107,6 +108,7 @@ async function write(id, { name, lines, cursor = 0 }) {
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');
log.info(`write ${gcodePath(id)} (${lines.length} Zeilen, cursor ${cursor})`);
return meta;
}