diff --git a/README.md b/README.md index f9c586c..a1cb5d1 100644 --- a/README.md +++ b/README.md @@ -2,55 +2,51 @@ Programm-/File-Handling-Service für den AppRobot. Speichert **G-Code-Programme** (`.gcode` + `.json`-Sidecar), hält das **aktive Programm + Cursor** und unterstützt -**Teaching** (Pose aufnehmen) und **Playback** (Programm abspielen). - -Dieses Projekt wurde aus dem `appRobotDriver` ausgelagert (vormals `GCode.receiveFC`, -ToDo_4 / ToDo_6b). Konzept und Schnittstelle: -[`doc/draft_filehandeling.md`](doc/draft_filehandeling.md) · -[`doc/draft_filehandeling_API.md`](doc/draft_filehandeling_API.md). +**Teaching** (Pose aufnehmen), **Playback** (Programm abspielen) sowie einen +**Browser-basierten Datei-Browser** auf Port 2100. ## Rolle in der Architektur ``` -Steuerungen → appRobotDriver → appRobotFileservice -(Joystick, …) (Gateway) (dieses Projekt, passiv) +Steuerungen → appRobotDriver → appRobotFileservice ← Browser (Port 2100) +(Joystick, …) (Gateway) (dieses Projekt) + │ + [Senden-Modus, lokales Netz] + └──► appRobotDriver WS ``` -- Steuerungen kennen **nur den Driver**. Datei-Befehle (**FCodes** wie `FList`, +- **Steuerungen** kennen nur den Driver. Datei-Befehle (**FCodes** wie `FList`, `FPoint`, `FPlus`) schickt die Steuerung an den Driver, der sie als REST-Aufrufe hierher weiterreicht. -- Dieser Service ist **passiv und driver-agnostisch**: er ruft den Driver nie an, - kennt weder dessen URL noch dessen Pose. Beim `FPoint` schickt der **Driver die - Pose mit**. -- **Playback:** dieser Service liefert die nächste Zeile **driver-nativ (Radian)** - zurück; **ausgeführt** wird sie vom Driver. +- **Browser** spricht direkt mit dem Fileservice (Port 2100). Im **Senden-Modus** + sendet der Fileservice die G-Code-Zeile server-seitig an den Driver — der Browser + verbindet sich nie direkt mit dem Driver. +- **Playback**: dieser Service liefert die nächste Zeile **driver-nativ (Radian)** + zurück; ausgeführt wird sie vom Driver. ## Einheiten (wichtig) -- **Gespeichert** wird in **Grad** (standardnahe `.gcode`, lesbar): `a/b/c/e` in Grad, +- **Gespeichert** wird in **Grad** (Standard-G-Code, lesbar): `a/b/c/e` in Grad, `x/y/z` in mm. - **Am Wire / zum Driver** ist alles **driver-nativ**: `a/b/c/e` in **Radian**. -- Die Umrechnung passiert **ausschließlich hier** (`src/gcode/units.js`) — der Driver - rechnet nie um. +- Die Umrechnung passiert **ausschließlich hier** (`src/gcode/units.js`). ## Dateiformat `.gcode` ist die **einzige verbindliche Positions-Abfolge** — reiner Standard-G-Code, -nur der Aufnahme-Zeitstempel steht im **Kommentarfeld** (`;…`, standardkonform): +Zeitstempel und Cursor-Marker stehen im Kommentarfeld: ``` G90 G1 x0 y300 z0 a90.00 b-90.00 c0.00 e0.00 f1000 ;1759566014 -G90 G1 x310 y444 z0.5 a90.00 b-90.00 c0.00 e6.88 f1000 ;1759566112 +G90 G1 x310 y444 z0.5 a90.00 b-90.00 c0.00 e6.88 f1000 ;1759566112! ``` -- `;` = Aufnahme-Zeitstempel. Sonst nichts Service-Internes in der `.gcode`. -- `.json` ist ein Sidecar mit **Zusatz-Infos**: Name, Zeiten, `lineCount`, - `angleUnit` und der **`cursor`** (Index der zuletzt angefahrenen Zeile). -- Der Cursor lebt zur Laufzeit als In-Memory-Index (schnelles Stepping ohne - Neuschreiben) und wird beim Speichern/Entladen ins `.json` geschrieben — die - `.gcode` bleibt sauber. -- Migration: alte `.gcode`-Dateien mit `!`-Cursor-Marker werden beim ersten Lesen - automatisch übernommen (Marker raus, Cursor ins `.json`). +- `;` = Aufnahme-Zeitstempel. +- `;!` (Ausrufezeichen am Ende des Kommentars) = **Cursor-Marker**: zeigt die + zuletzt angefahrene Zeile. Primäre Cursor-Quelle beim Lesen. Direkt im Texteditor + sichtbar. +- `.json` ist ein Sidecar mit Metadaten (Name, Zeiten, `lineCount`, `angleUnit`). + `cursor` im Sidecar dient nur als Fallback für Altdateien ohne `;!`-Marker. ## Start @@ -60,74 +56,101 @@ npm start # http://localhost:2100 npm test # jest ``` -HTTP (kein TLS) — der Service ist intern (Driver → Service). TLS kann später analog -zum Driver-`InfoServer` ergänzt werden. - -## API (Kurzüberblick) - -Vollständig in [`doc/draft_filehandeling_API.md`](doc/draft_filehandeling_API.md). - -| FCode (am Driver) | Endpoint | -|---|---| -| `FList` | `GET /api/programs` | -| `FShow [id]` | `GET /api/programs/:id` | -| `FSave ` | `POST /api/programs` | -| `FLoad ` | `PUT /api/active` | -| `FClear` | `POST /api/active/clear` | -| `FPoint` | `POST /api/active/points` | -| `FPlus` / `FMinus` | `POST /api/active/next` / `/prev` | -| `FFirst` / `FLast` | `POST /api/active/first` / `/last` | -| `FGoto ` | `POST /api/active/goto` | -| `FPlay` / `FStop` | `POST /api/active/play` / `/stop` | - -Beispiel: - -```bash -# Teaching: leeres Programm aktiv, Pose aufnehmen, speichern -curl -X PUT localhost:2100/api/active -H 'content-type: application/json' -d '{"id":"demo_c"}' -curl -X POST localhost:2100/api/active/points -H 'content-type: application/json' \ - -d '{"pose":{"x":0,"y":300,"z":0,"a":1.5708,"b":-1.5708,"c":0,"e":0},"feedrate":1000}' -curl -X POST localhost:2100/api/programs -H 'content-type: application/json' -d '{"name":"Demo C","fromActive":true}' - -# Playback: laden, erste Zeile (Radian) holen → der Driver führt sie aus -curl -X PUT localhost:2100/api/active -H 'content-type: application/json' -d '{"id":"demo_c"}' -curl -X POST localhost:2100/api/active/first -``` - -> Hinweis: `PUT /api/active` legt ein nicht existierendes Programm **leer an** (für -> Teaching). `EMPTY_PROGRAM`/`CURSOR_OUT_OF_RANGE` betreffen nur das Stepping/Playback. +HTTP (kein TLS) — der Service ist intern. Konfiguration via Env-Variablen (s. u.). ## Konfiguration (Env) | Variable | Default | Zweck | |---|---|---| | `FILE_SERVICE_PORT` | `2100` | Port | -| `STORAGE_DIR` | `./GCodeFiles` | Verzeichnis für `.gcode` + `.json` | +| `STORAGE_DIR` | `./GCodeFiles` | Wurzel-Verzeichnis für `.gcode` + `.json` | | `FILE_EXT` | `gcode` | `gcode` oder `ngc` | | `STORE_ANGLE_UNIT` | `deg` | Speichereinheit der Winkel | | `FILE_API_KEY` | – | Bearer-Token für Schreibzugriffe (fehlt → offen, Dev) | +| `DRIVER_WS_URL` | – | WS-URL des appRobotDriver (z. B. `wss://appRobotDriver:2095`); leer → Senden-Modus deaktiviert | +| `DRIVER_TIMEOUT_MS` | `10000` | Maximale Wartezeit auf Driver-Antwort (ms) | + +## API (Kurzüberblick) + +### Programme + +| Endpoint | Methode | Zweck | +|---|---|---| +| `GET /api/programs?dir=` | GET | Programme auflisten | +| `POST /api/programs` | POST | Programm anlegen | +| `DELETE /api/programs/:id?dir=` | DELETE | Programm löschen | +| `GET /api/programs/:id/download` | GET | `.gcode`-Datei herunterladen | + +### Ordner + +| Endpoint | Methode | Zweck | +|---|---|---| +| `GET /api/folders?dir=` | GET | Unterordner auflisten | +| `POST /api/folders` | POST | Unterordner anlegen | +| `DELETE /api/folders/:name?dir=` | DELETE | Unterordner (rekursiv) löschen | + +### Aktives Programm + +| FCode (am Driver) | Endpoint | Hinweis | +|---|---|---| +| `FList` | `GET /api/programs` | | +| `FLoad ` | `PUT /api/active` | `{ id, dir }` | +| `FClear` | `POST /api/active/clear` | | +| `FPoint` | `POST /api/active/points` | | +| `FPlus` / `FMinus` | `POST /api/active/next` / `/prev` | `?execute=true` → auch an Driver senden | +| `FFirst` / `FLast` | `POST /api/active/first` / `/last` | `?execute=true` → auch an Driver senden | +| `FGoto ` | `POST /api/active/goto` | | +| `FPlay` / `FStop` | `POST /api/active/play` / `/stop` | | +| — | `DELETE /api/active/lines/:index` | Zeile löschen | + +### Senden-Modus (`?execute=true`) + +`POST /api/active/next?execute=true` bewegt den Cursor UND sendet die G-Code-Zeile +an den Driver (server-seitig). Der Endpoint wartet auf die Driver-Antwort: +- **Erfolg** (M114-Broadcast) → HTTP 200 `{ cursor, line, driverPos }` +- **Driver-Fehler** → HTTP 502 `{ error, message }` +- **Timeout** → HTTP 504 + +### Konfiguration + +| Endpoint | Zweck | +|---|---| +| `GET /api/health` | Liveness-Check | +| `GET /api/config` | `{ driverConfigured: bool }` — ob `DRIVER_WS_URL` gesetzt ist | ## Projektstruktur ``` -index.js Einstiegspunkt (startet den Server) +index.js Einstiegspunkt (startet den Server) src/ - config.js Env-Konfiguration - server.js Express-App (createApp) - errors.js Fehler-Envelope + Middleware - auth.js Bearer-Auth (für Schreibzugriffe) - gcode/units.js Grad↔Radian, Zeilenformat, Cursor-/Kommentar-Helfer - store/fileStore.js .gcode + .json Persistenz (id-basiert, kein Pfad-Zugriff) - active/activeState.js aktives Programm + Cursor (Single Source of Truth) - routes/programs.js /api/programs* - routes/active.js /api/active* -test/ jest (units, fileStore, activeState) -doc/ Konzept + API (Drafts) -GCodeFiles/ Programm-Storage (zur Laufzeit) + config.js Env-Konfiguration (inkl. driverWsUrl, driverTimeoutMs) + server.js Express-App (createApp, /api/config, /api/health) + errors.js Fehler-Envelope + Middleware + auth.js Bearer-Auth (für Schreibzugriffe) + driverClient.js WS-Client zum Driver (Senden-Modus) + gcode/units.js Grad↔Radian, Zeilenformat, Cursor-Marker (;!) + store/fileStore.js .gcode + .json Persistenz (mit dir-Support, max. 5 Ebenen) + active/activeState.js Aktives Programm + Cursor (Singleton, ;!-Persistenz) + routes/programs.js /api/programs* inkl. /download + routes/active.js /api/active* inkl. ?execute=true + routes/folders.js /api/folders* +public/ + index.html Browser-UI (Datei-Browser + Stepping + Senden-Modus) + index.css Design-System (dark theme, CSS-Variablen) +test/ + units.test.js Einheiten + Cursor-Marker-Funktionen + fileStore.test.js Persistenz-Tests (;!-Marker, dir-Support) + activeState.test.js ActiveState-Tests (Teaching, Stepping, Cursor-Persistenz) + driverClient.test.js WS-Client (Erfolg, Fehler, Timeout) +doc/ + fileBrowser.md Browser-UI Ist-Zustand + commandsFromGCodeFile.md Senden-Modus Konzept + Implementierungsplan + draft_filehandeling.md Original-Konzept (historisch) + draft_filehandeling_API.md Original-API-Entwurf (historisch) +GCodeFiles/ Programm-Storage zur Laufzeit ``` ## Status -Erste lauffähige Umsetzung. Offen u. a.: WebSocket-Event-Kanal (Live-Cursor), -Playlists („nächste File"), benannte Labels im Sidecar, TLS. Siehe „Offene Fragen" -in [`doc/draft_filehandeling.md`](doc/draft_filehandeling.md). +Produktiv einsetzbar. Offen: Upload via Browser, Inline-Editor, Live-Updates via SSE +(statt 5s-Polling). Siehe [`doc/fileBrowser.md`](doc/fileBrowser.md). diff --git a/doc/commandsFromGCodeFile.md b/doc/commandsFromGCodeFile.md new file mode 100644 index 0000000..c5556d7 --- /dev/null +++ b/doc/commandsFromGCodeFile.md @@ -0,0 +1,421 @@ +# Commands from GCode File — Konzept & Implementierungsplan + +## Ausgangslage + +Der Browser-Datei-Browser (`appRobotFileservice:2100`) kann Zeilen im aktiven +Programm navigieren. Der **Driver** (`appRobotDriver`) führt G-Code aus und hat +den Roboter angeschlossen. + +Aktuell sind die zwei Pfade strikt getrennt: + +| Pfad | Wer | Effekt | +|------|-----|--------| +| Browser → `POST /api/active/next` (Fileservice) | nur Browser | Cursor bewegt sich, Datei gespeichert — **kein Roboter** | +| WS-Client → Driver → FCode → Fileservice | externer Controller | Cursor bewegt sich **und** Zeile wird ausgeführt | + +Das Ziel: der Browser bekommt einen Modus-Schalter. Im Modus **Senden** +wandert der Cursor nicht nur, sondern der Roboter fährt auch dorthin. + +--- + +## Sicherheit und Netzwerk-Topologie + +``` +Internet / User + │ + ▼ +appRobotFileservice (Port 2100, erreichbar von aussen) + │ Browser spricht nur hier hin (HTTP/REST) + │ + │ lokales Netz (server-seitig) + ▼ +appRobotDriver (Port 2095, nur im LAN erreichbar) + │ + ▼ +GRBL/FluidNC-Controller → Roboter-Hardware +``` + +Der Browser verbindet sich **niemals** direkt mit dem Driver. Der Fileservice +ist die einzige Aussenschnittstelle. Die Verbindung zum Driver läuft +server-seitig im lokalen Netz. + +--- + +## UI-Konzept + +### Drei Button-Gruppen in einer Zeile (Links · Mitte · Rechts) + +``` +[ |◀ First ◀ Prev Next ▶ Last ▶| ] [ ✕ Clear ⬇ .gcode ] [ ↕ Navigieren | ▶ Senden ] + Gruppe Links Gruppe Mitte Gruppe Rechts +``` + +CSS-Ansatz: `.controls-row { display: flex; justify-content: space-between; }`, +jede Gruppe ist ein `
`. + +### Schalter Navigieren / Senden + +Zwei Buttons als Radiogruppe (einer immer aktiv): + +```html +
+ + +
+``` + +CSS-State `.toggle.active` hebt den aktiven Modus hervor (z. B. Hintergrund +`var(--active)`, Rand `var(--accent)`). Der Senden-Button bleibt ausgegraut, +solange keine `DRIVER_WS_URL` konfiguriert ist. + +--- + +## Architektur im Modus "Senden" + +### Warum kein FCode an den Driver? + +Wenn der Fileservice dem Driver ein FCode schickte (z. B. `FPlus`), würde +der Driver seinerseits über `FCodeClient` wieder `POST /api/active/next` auf +den Fileservice aufrufen — der Cursor bewegt sich zweimal. + +### Richtiger Weg: G-Code-Zeile direkt an den Driver-WS + +Der Driver-WebSocket (`InputWS.js`) akzeptiert rohe G-Code-Befehle +(`G90 G1 X… Y… A… F…`) direkt, ohne Fileservice-Rückruf. Die Fileservice-Logik: + +1. Cursor-Schritt wie bisher (`_gotoIndex()`), liefert die saubere G-Code-Zeile +2. Diese Zeile wird über eine **server-seitige WS-Verbindung** an den Driver gesendet +3. Driver führt Inverse Kinematik aus, schickt an GRBL-Controller, broadcast M114 + +### Ablauf im Modus "Senden" + +``` +Browser klickt Next ▶ + │ + ├─[Navigieren]─► POST /api/active/next (Fileservice) + │ Cursor +1, Datei schreibt ;!, Browser rendert Cursor-Position + │ + └─[Senden]──────► POST /api/active/next?execute=true (Fileservice) + Fileservice: Cursor +1, holt line = 'G90 G1 X250 Y0 A45 F1000' + Fileservice: sendet line über lokale WS-Verbindung an Driver + Driver: Inverse Kinematik → GRBL-Controller → Roboter fährt + Driver: broadcast M114 (Position) an alle Driver-WS-Clients + Fileservice antwortet mit { cursor, line } — Browser rendert Cursor +``` + +--- + +## Nötige Code-Änderungen + +### 1. `public/index.html` — JS + +**Neuer Zustand (kein WS im Browser):** +```js +let sendMode = false; // false = Navigieren, true = Senden +``` + +**`step()`-Funktion — Buttons sperren während Ausführung, Fehler anzeigen:** +```js +const STEP_BUTTONS = ['btn-first', 'btn-prev', 'btn-next', 'btn-last']; + +function setStepButtonsEnabled(enabled) { + STEP_BUTTONS.forEach(id => { + document.getElementById(id).disabled = !enabled; + }); +} + +async function step(endpoint) { + setStatus(elActStatus, sendMode ? '⏳ Sende an Roboter…' : '…'); + if (sendMode) setStepButtonsEnabled(false); // während Roboterfahrt sperren + try { + const url = sendMode + ? `/api/active/${endpoint}?execute=true` + : `/api/active/${endpoint}`; + const r = await apiFetch('POST', url); + setStatus(elActStatus, sendMode + ? `✓ Ausgeführt → Cursor ${r.cursor}` + : `Cursor → ${r.cursor}`); + await refresh(); + } catch (err) { + // err.message enthält den Driver-Fehlertext (z. B. 'inverse kinematics failed') + setStatus(elActStatus, `Fehler: ${err.message}`, true); + } finally { + if (sendMode) setStepButtonsEnabled(true); // immer wieder freigeben + } +} +``` + +**Toggle-Buttons:** +```js +document.getElementById('btn-mode-nav').addEventListener('click', () => { + sendMode = false; + document.getElementById('btn-mode-nav').classList.add('active'); + document.getElementById('btn-mode-send').classList.remove('active'); +}); +document.getElementById('btn-mode-send').addEventListener('click', () => { + sendMode = true; + document.getElementById('btn-mode-send').classList.add('active'); + document.getElementById('btn-mode-nav').classList.remove('active'); +}); +``` + +**Senden-Button beim Start deaktivieren, wenn kein Driver konfiguriert:** +```js +// Beim Start prüfen ob Driver-URL gesetzt ist +const cfg = await apiFetch('GET', '/api/config'); +if (!cfg.driverWsUrl) { + document.getElementById('btn-mode-send').disabled = true; + document.getElementById('btn-mode-send').title = 'Driver WS nicht konfiguriert'; +} +``` + +**Layout — `controls` aufteilen:** +```html +
+
+ + + + +
+
+ + +
+
+
+ + +
+
+
+``` + +### 2. `public/index.css` + +```css +/* Drei-Gruppen-Zeile */ +.controls-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 12px; + flex-wrap: wrap; +} +.ctrl-group { + display: flex; + gap: 6px; + align-items: center; +} + +/* Toggle-Schalter */ +.toggle-group { + display: flex; + border: 1px solid #334155; + border-radius: 6px; + overflow: hidden; +} +.toggle-group .toggle { + border: none; + border-radius: 0; + border-right: 1px solid #334155; + padding: 6px 12px; +} +.toggle-group .toggle:last-child { border-right: none; } +.toggle-group .toggle.active { + background: var(--active); + color: var(--accent); + border-color: var(--accent); +} +``` + +### 3. Driver-Protokoll — Bestätigung und Fehler + +`InputWS.js` sendet nach G-Code-Verarbeitung: + +| Ergebnis | Empfänger | Format | +|----------|-----------|--------| +| **Erfolg** | Broadcast an **alle** WS-Clients | `{"position":{…}, "motorCounts":{…}}` (M114) | +| **Fehler** | Nur an den **anfragenden** Client | `{ "type": "error", "code": "GCODE_ERROR", "message": "…", "input": "…" }` | + +Da `driverClient.js` der anfragende Client ist, empfängt er **beides** — Erfolg +(M114-Broadcast) und Fehler (targeted). Damit kann der Fileservice-Endpoint auf +die Driver-Antwort warten bevor er dem Browser antwortet. + +**Folge für das UI:** Der Browser muss keine separate Bestätigungslogik +implementieren. Die Pfeil-Buttons sperren einfach für die Dauer des +HTTP-POST — Entsperrung und Fehleranzeige kommen mit der HTTP-Antwort. + +``` +Browser POST /api/active/next?execute=true + │ + │ Fileservice: Cursor +1, holt line, sendet an Driver WS + │ ┌─ wartet auf nächste WS-Nachricht (max. DRIVER_TIMEOUT_MS) ─┐ + │ │ M114 empfangen → HTTP 200 { cursor, line, driverPos } │ + │ │ error empfangen → HTTP 502 { error.code, error.message } │ + │ │ Timeout → HTTP 504 'Driver timeout' │ + │ └──────────────────────────────────────────────────────────────┘ + │ +Browser: buttons gesperrt während fetch() läuft + buttons aktiv nach Antwort + bei 5xx: Fehlermeldung anzeigen +``` + +### 4. `src/driverClient.js` — neues Modul im Fileservice + +Hält eine persistente WS-Verbindung zum Driver. `send()` gibt ein Promise zurück, +das mit der nächsten Driver-Antwort (M114 oder Fehler) resolved/rejected wird: + +```js +// src/driverClient.js — liest URL + Timeout aus cfg (src/config.js) +const WebSocket = require('ws'); +const log = require('./log'); +const cfg = require('./config'); + +let ws = null; + +function getOrCreateWs() { + if (ws && ws.readyState !== WebSocket.CLOSED) return ws; + ws = new WebSocket(cfg.driverWsUrl, { rejectUnauthorized: false }); + ws.on('error', (e) => log.warn('driverClient WS Fehler:', e.message)); + ws.on('close', () => log.info('driverClient WS geschlossen')); + ws.on('open', () => log.info('driverClient WS verbunden mit ' + cfg.driverWsUrl)); + return ws; +} + +function send(line) { + if (!cfg.driverWsUrl) { + return Promise.reject(Object.assign( + new Error('DRIVER_WS_URL nicht konfiguriert'), { driverCode: 'NOT_CONFIGURED' } + )); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(Object.assign( + new Error(`Keine Antwort vom Driver innerhalb von ${cfg.driverTimeoutMs} ms`), + { driverCode: 'TIMEOUT' } + )); + }, cfg.driverTimeoutMs); + + const conn = getOrCreateWs(); + + const handler = (data) => { + clearTimeout(timer); + const text = data.toString(); + try { + const parsed = JSON.parse(text); + if (parsed.type === 'error') { + reject(Object.assign(new Error(parsed.message), { driverCode: parsed.code || 'DRIVER_ERROR' })); + } else { + resolve(parsed); // M114: { position: {…}, motorCounts: {…} } + } + } catch { + resolve({ raw: text }); // unbekanntes Format → als Erfolg werten + } + }; + + if (conn.readyState === WebSocket.OPEN) { + conn.once('message', handler); + conn.send(line); + } else { + conn.once('open', () => { conn.once('message', handler); conn.send(line); }); + } + }); +} + +module.exports = { send, configured: () => !!cfg.driverWsUrl }; +``` + +### 5. `src/active/activeState.js` — `execute`-Parameter + +Ein interner `_step()`-Helper enthält die gemeinsame Logik inkl. `_ensureActive()`. +Die öffentlichen Methoden delegieren nur noch: + +```js +const driverClient = require('../driverClient'); + +async _step(index, execute) { + await this._ensureActive(); + const result = await this._gotoIndex(index); + if (execute) { + const driverPos = await driverClient.send(result.line); // wartet auf ACK + return { ...result, driverPos }; + } + return result; +} + +async next(execute = false) { return this._step(this.cursor + 1, execute); } +async prev(execute = false) { return this._step(this.cursor - 1, execute); } +async first(execute = false) { return this._step(0, execute); } +async last(execute = false) { return this._step(this.lines.length - 1, execute); } +async goto(index, execute = false) { await this._ensureActive(); return this._step(Number(index), execute); } +``` + +### 6. `src/routes/active.js` — `execute`-Flag + Fehlerweiterleitung + +Alle Step-Routen sind auth-geschützt (`requireAuth`). Driver-Fehler (`err.driverCode`) → HTTP 502: + +```js +router.post('/next', requireAuth, asyncH(async (req, res) => { + const execute = req.query.execute === 'true'; + try { res.json(await active.next(execute)); } + catch (err) { if (err.driverCode) return res.status(502).json({ error: err.driverCode, message: err.message }); throw err; } +})); +// analog für /prev, /first, /last, /goto +``` +// analog für /prev, /first, /last +``` + +### 6. `src/config.js` — neue Option + +```js +driverWsUrl: process.env.DRIVER_WS_URL || '', +``` + +Beim Start in `server.js`: +```js +const driverClient = require('./driverClient'); +if (cfg.driverWsUrl) driverClient.configure(cfg.driverWsUrl); +``` + +### 7. `GET /api/config` — Browser-Endpoint + +```js +app.get('/api/config', (req, res) => res.json({ + driverWsUrl: cfg.driverWsUrl ? 'configured' : '', // URL nicht exponieren +})); +``` + +Die echte WS-URL wird dem Browser nicht mitgeteilt — nur ob ein Driver +konfiguriert ist (boolean-ähnlich). Das verhindert, dass Clients die Driver- +Adresse kennen. + +### 8. Env-Variable `DRIVER_WS_URL` im Fileservice + +``` +DRIVER_WS_URL=wss://appRobotDriver:2095 +``` + +Im Docker-Stack entspricht `appRobotDriver` dem Dienstnamen im Portainer-Stack +(analog zu `FILESERVICE_URL=http://appRobot_Fileservice:2100` im Driver). + +--- + +## Keine Änderungen am Driver nötig + +`InputWS.js` verarbeitet rohe G-Code-Befehle (`G90 G1 X… A… F…`) bereits +korrekt — der neue Sende-Modus nutzt exakt diesen bestehenden Pfad. + +--- + +## Offene Fragen / Risiken + +| Thema | Hinweis | +|-------|---------| +| **WSS / Zertifikat** | Driver läuft über HTTPS/WSS mit selbstsigniertem Zertifikat — `ws`-Client braucht `rejectUnauthorized: false` (nur im LAN akzeptabel) | +| **Timeout** | `DRIVER_TIMEOUT_MS` (Default 10 s) muss länger sein als die längste Roboterfahrt; langsame Moves oder blockierte Controller könnten Timeouts auslösen | +| **WS-Gleichzeitigkeit** | `driverClient.js` verwendet `ws.once('message', …)` — funktioniert nur wenn jeweils **ein** Step auf einmal läuft. Buttons werden während des laufenden Steps gesperrt, daher sicher | +| **Reconnect** | `driverClient.js` öffnet WS neu bei `CLOSED`; kurze Unterbrechungen im LAN werden so automatisch überbrückt | +| **Fehlermeldung im Browser** | Driver-Fehlertext (`err.message`) wird 1:1 angezeigt — kann englisch oder intern sein; ggf. spätere Übersetzungstabelle ergänzen | +| **FPlay / FStop** | Analog zu Step könnten auch Playback-Befehle über diesen Kanal laufen — ist aber ein separates Feature | diff --git a/doc/fileBrowser.md b/doc/fileBrowser.md index 5a77eff..9c56b69 100644 --- a/doc/fileBrowser.md +++ b/doc/fileBrowser.md @@ -1,153 +1,127 @@ -# ROADMAP — appRobotFileservice Web-UI (Datei-Browser) +# appRobotFileservice — Web-UI (Datei-Browser) -## Ziel - -Einfache Web-Oberfläche direkt auf Port 2100, die GCode-Programme verwaltet. -Kein Framework, kein Build-Schritt — reines HTML/CSS/JS, serviert als static files. +> Stand: 2026-06-15 +> Beschreibt den implementierten Ist-Zustand der Browser-Oberfläche auf Port 2100. --- -## Phase 1 — Datei-Browser (aktuell implementiert) +## Implementierter Funktionsumfang **Dateien:** `public/index.html`, `public/index.css` -### Funktionen +### Programm-Liste (linke Spalte) + +| Feature | Status | Endpunkt | +|---|---|---| +| Programme auflisten | ✅ | `GET /api/programs?dir=` | +| Unterordner auflisten | ✅ | `GET /api/folders?dir=` | +| Breadcrumb-Navigation | ✅ | — | +| In Unterordner navigieren (Klick) | ✅ | — | +| Programm laden (Einzelklick) | ✅ | `PUT /api/active` | +| Neue Datei anlegen (📄+) | ✅ | `POST /api/programs` | +| Neuen Ordner anlegen (📁+) | ✅ | `POST /api/folders` | +| Auswahl löschen (🗑) — Datei oder Ordner | ✅ | `DELETE /api/programs/:id` / `DELETE /api/folders/:name` | +| Ordner-Löschen rekursiv | ✅ | `DELETE /api/folders/:name?dir=` | +| Auto-Refresh (5 s Polling) | ✅ | — | + +### Aktives Programm (rechte Spalte) | Feature | Status | Endpunkt | |---|---|---| -| Programm-Liste laden | ✅ | `GET /api/programs` | | Aktives Programm anzeigen (Zeilenliste in Grad) | ✅ | `GET /api/active` | -| Programm als aktiv setzen (FLoad) | ✅ | `PUT /api/active` | -| Programm löschen | ✅ | `DELETE /api/programs/:id` | +| Cursor in Tabelle hervorheben + auto-scrollen | ✅ | — | | Stepping: First / Prev / Next / Last | ✅ | `POST /api/active/first|prev|next|last` | | Programm leeren (FClear) | ✅ | `POST /api/active/clear` | -| Cursor in Tabelle hervorheben + auto-scrollen | ✅ | — | -| Auto-Refresh (5 s Polling) | ✅ | — | -| Collapse-Karten (wie appRobotHoming) | ✅ | — | +| Download `.gcode` | ✅ | `GET /api/programs/:id/download` | +| Zeilen löschen (🗑 pro Zeile, pending-State) | ✅ | `DELETE /api/active/lines/:index` | +| Abbrechen / Speichern Edit-Bar | ✅ | — | -### Was Phase 1 noch fehlt +### Navigieren / Senden Toggle (rechts in Controls-Zeile) -- **Download `.gcode`** — Endpunkt `GET /api/programs/:id/download` muss im Server - hinzugefügt werden (liefert die rohe Datei mit `Content-Disposition: attachment`). -- **Upload `.gcode`** — Datei vom Desktop in den Service laden (Phase 2). -- **Umbenennen** — `PUT /api/programs/:id` mit neuem `name`. +| Feature | Status | Details | +|---|---|---| +| Modus-Schalter `↕ Navigieren` / `▶ Senden` | ✅ | Drei-Gruppen-Layout | +| Navigieren-Modus: nur Cursor bewegen | ✅ | `POST /api/active/next` | +| Senden-Modus: G-Code-Zeile an Driver senden | ✅ | `POST /api/active/next?execute=true` | +| Pfeil-Buttons während Fahrt gesperrt | ✅ | Bis HTTP-Antwort kommt | +| Driver-Fehlermeldung im UI anzeigen | ✅ | HTTP 502 → Statuszeile | +| Senden-Button deaktiviert wenn kein Driver | ✅ | `GET /api/config` beim Start | --- -## Phase 2 — Datei-Verwaltung - -### Download-Endpunkt (kleines Backend-Feature für Phase 1.5) +## Architektur ``` -GET /api/programs/:id/download -→ Content-Type: text/plain -→ Content-Disposition: attachment; filename=".gcode" -→ Body: rohe .gcode-Datei +Browser (index.html) + │ + ├──[Navigieren]─► POST /api/active/next (Fileservice) + │ Cursor +1, ;! in .gcode gespeichert + │ + └──[Senden]──────► POST /api/active/next?execute=true (Fileservice) + Fileservice: Cursor +1, sendet G-Code-Zeile + │ server-seitig (lokales Netz) + └──► appRobotDriver WS + Inverse Kinematik → GRBL → Roboter + M114-Broadcast / Fehler zurück an Fileservice + Fileservice → HTTP 200 (OK) oder 502 (Driver-Fehler) ``` -Implementierung: 1 Route in `src/routes/programs.js` + `store.gcodePath(id)`. - -### Upload - -``` -POST /api/programs/upload -Body: multipart/form-data { file: } -→ speichert unter slugify(filename) in GCodeFiles/ -``` - -Im Frontend: `` + `FormData` fetch. - -### Umbenennen / Metadaten bearbeiten - -`PUT /api/programs/:id` ist bereits vorhanden — nur das Frontend-Formular fehlt. -Inline-Eingabefeld in der Programm-Zeile, Enter → PUT. +Der Browser verbindet sich **niemals direkt** mit dem Driver — der Fileservice +ist die einzige Aussenschnittstelle. --- -## Phase 3 — Verzeichnis-Navigation +## Cursor-Persistenz -Aktuell: alles flach im `GCodeFiles/`-Ordner. -Später: Unterordner für Projekte (z. B. `GCodeFiles/greifen/`, `GCodeFiles/ablegen/`). - -### Backend - -Neue Konfiguration: `storageDir` bleibt Wurzel; zusätzlicher optionaler `subDir`-Parameter. +Der Cursor wird als `;!`-Marker direkt **in der `.gcode`-Datei** gespeichert +(primäre Quelle, sichtbar in jedem Texteditor). Das `.json`-Sidecar enthält `cursor` +nur noch als Fallback für Altdateien ohne Marker. ``` -GET /api/programs?dir=greifen → listet GCodeFiles/greifen/ -PUT /api/active { id, dir } → lädt GCodeFiles//.gcode -POST /api/dirs { name } → legt Unterordner an -DELETE /api/dirs/:name → löscht (leer) Unterordner +G90 G1 x0 y300 z0 a90.00 b-90.00 c0.00 e0.00 f1000 ;1 +G90 G1 x10 y300 z0 a0.00 b-90.00 c0.00 e0.00 f1000 ;2! ← Cursor-Zeile ``` -Wichtig: Pfad-Traversal verhindern — `assertValidId` auf jede Pfad-Komponente anwenden. - -### Frontend - -Breadcrumb-Leiste oberhalb der Programmliste: -``` -GCodeFiles/ > greifen/ > [zurück] -``` - -Doppelklick auf Ordner-Zeile → navigiert hinein. -Klick auf Breadcrumb-Segment → navigiert zurück. +`store.read()` liefert immer saubere Zeilen (kein `!`) und `cursor` als Index. --- -## Phase 4 — Live-Updates (WebSocket oder SSE) +## Verzeichnis-Navigation -Aktuell: Polling alle 5 s. -Besser: Server-Sent Events (SSE) oder WebSocket, damit die UI sofort -reagiert wenn der Driver per FPoint einen neuen Punkt schreibt. - -### Option A — Server-Sent Events (einfach, read-only) - -``` -GET /api/events -→ text/event-stream -→ event: active-changed\ndata: \n\n -``` - -Der Fileservice emittiert nach jedem `_persist()` ein SSE-Event. -Frontend: `new EventSource('/api/events')` statt `setInterval`. - -### Option B — WebSocket (bidirektional, für spätere Steuerung) - -Erlaubt auch WS-basierte FCode-Befehle direkt von der Web-UI. -Aufwändiger, aber konsistent mit dem Driver-Pattern. - -**Empfehlung für Phase 4:** SSE — eine Zeile Mehraufwand gegenüber Polling, -kein zusätzliches Protokoll. +- Echte Unterverzeichnisse in `GCodeFiles/` (max. 5 Ebenen tief) +- Segmente validiert mit `/^[a-z0-9_-]+$/` +- `dir`-Parameter in allen relevanten Endpoints (`?dir=training/run1`) +- Breadcrumb: klickbare Segmente, Root = `GCodeFiles` --- -## Phase 5 — Inline-Editor +## Noch nicht implementiert / offene Punkte -Einzelne Zeilen direkt im Browser bearbeiten. - -``` -PUT /api/active/lines/:index { line: "G90 G1 x0 y300 z0 a90.00 …" } -DELETE /api/active/lines/:index -POST /api/active/lines { line, atIndex } -``` - -Alle drei Endpunkte sind bereits im Backend implementiert. -Frontend: Klick auf Tabellenzeile → `` erscheint inline; Escape abbricht, Enter speichert. +| Feature | Priorität | Hinweis | +|---|---|---| +| Upload `.gcode` vom Desktop | P2 | `POST /api/programs/upload` (multipart) | +| Programm umbenennen | P2 | `PUT /api/programs/:id` (Backend vorhanden, UI fehlt) | +| Zeile inline bearbeiten | P3 | `PUT /api/active/lines/:index` (Backend vorhanden) | +| Zeile einfügen | P3 | `POST /api/active/lines { atIndex }` (Backend vorhanden) | +| Live-Updates via SSE | P4 | statt 5s-Polling; `GET /api/events` | +| `FPlay` / `FStop` im Browser | P4 | Playback-Modus | --- -## Datei-Übersicht (nach Phase 1) +## Datei-Übersicht ``` -appRobotFileservice/ - public/ - index.html HTML-Struktur + JavaScript (inline) - index.css Design-System (identisch mit appRobotHoming/styles.css) - doc/ - fileBrowser_ROADMAP.md diese Datei - src/ - server.js +express.static('public/') - routes/ - programs.js TODO Phase 2: /download-Endpunkt - active.js komplett +public/ + index.html HTML + JavaScript (inline) + index.css Design-System (dark, CSS-Variablen) +src/ + server.js GET /api/config → driverConfigured + driverClient.js WS-Client zum Driver (für Senden-Modus) + routes/ + programs.js /api/programs* inkl. /download + active.js /api/active* mit ?execute=true für Step-Routes + folders.js /api/folders* +doc/ + fileBrowser.md diese Datei (Ist-Zustand) + commandsFromGCodeFile.md Senden-Modus Konzept + Implementierungsplan ``` diff --git a/package-lock.json b/package-lock.json index de15475..805a8db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "license": "ISC", "dependencies": { - "express": "^4.18.2" + "express": "^4.18.2", + "ws": "^8.21.0" }, "devDependencies": { "jest": "^29.7.0" @@ -4428,6 +4429,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 3917f02..251378f 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "author": "Ch Kendel", "license": "ISC", "dependencies": { - "express": "^4.18.2" + "express": "^4.18.2", + "ws": "^8.21.0" }, "devDependencies": { "jest": "^29.7.0" diff --git a/public/index.css b/public/index.css index 53a89f0..6c8b37d 100644 --- a/public/index.css +++ b/public/index.css @@ -177,6 +177,40 @@ body { gap: 8px; margin-bottom: 12px; } + +/* Drei-Gruppen-Zeile (Aktives Programm) */ +.controls-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 12px; + flex-wrap: wrap; +} +.ctrl-group { + display: flex; + gap: 6px; + align-items: center; +} + +/* Toggle Navigieren / Senden */ +.toggle-group { + display: flex; + border: 1px solid #334155; + border-radius: 6px; + overflow: hidden; +} +.toggle-group .toggle { + border: none; + border-radius: 0; + border-right: 1px solid #334155; + padding: 6px 12px; +} +.toggle-group .toggle:last-child { border-right: none; } +.toggle-group .toggle.active { + background: var(--active); + color: var(--accent); +} button { background: #1e293b; color: var(--text); diff --git a/public/index.html b/public/index.html index d07de22..8ce247a 100644 --- a/public/index.html +++ b/public/index.html @@ -53,13 +53,23 @@
Version
-
- - - - - - +
+
+ + + + +
+
+ + +
+
+
+ + +
+
@@ -102,6 +112,7 @@ let cachedState = null; // letzter bekannter Serverzustand (für Abbrechen) let currentDir = ''; // aktuelles Verzeichnis (relativer Pfad, z. B. 'training/run1') let currentPath = []; // Segmente des currentDir als Array (für Breadcrumb) + let sendMode = false; // false = Navigieren, true = Senden an Driver // ─── DOM-Referenzen ─────────────────────────────────────────────────────────── const elProgCount = document.getElementById('prog-count'); @@ -373,14 +384,27 @@ } // ─── Stepping ──────────────────────────────────────────────────────────────── + const STEP_BTN_IDS = ['btn-first', 'btn-prev', 'btn-next', 'btn-last']; + async function step(endpoint) { - setStatus(elActStatus, '…'); + if (sendMode) { + STEP_BTN_IDS.forEach(id => { document.getElementById(id).disabled = true; }); + setStatus(elActStatus, '⏳ Sende an Roboter…'); + } else { + setStatus(elActStatus, '…'); + } try { - const r = await apiFetch('POST', `/api/active/${endpoint}`); - setStatus(elActStatus, `Cursor → ${r.cursor}`); - await refresh(); + const url = sendMode + ? `/api/active/${endpoint}?execute=true` + : `/api/active/${endpoint}`; + const r = await apiFetch('POST', url); + setStatus(elActStatus, sendMode + ? `✓ Ausgeführt → Cursor ${r.cursor}` + : `Cursor → ${r.cursor}`); } catch (err) { setStatus(elActStatus, `Fehler: ${err.message}`, true); + } finally { + await refresh(); // renderLines() setzt Buttons korrekt nach Cursor-Position } } @@ -409,11 +433,14 @@ setStatus(elActStatus, `Lösche ${indices.length} Zeile(n)…`); document.getElementById('btn-save-delete').disabled = true; try { + let lastState; for (const i of indices) { - await apiFetch('DELETE', `/api/active/lines/${i}`); + lastState = await apiFetch('DELETE', `/api/active/lines/${i}`); } pendingDeletes.clear(); setStatus(elActStatus, `${indices.length} Zeile(n) gelöscht`); + // Sofortiges Neuzeichnen aus der DELETE-Antwort (enthält getState() mit aktualisierten Zeilen) + if (lastState) renderLines(lastState); await refresh(); } catch (err) { setStatus(elActStatus, `Fehler: ${err.message}`, true); @@ -495,6 +522,22 @@ elBtnLast .addEventListener('click', () => step('last')); elBtnClear.addEventListener('click', clearProgram); + // ─── Navigieren / Senden Toggle ────────────────────────────────────────────── + const elBtnModeNav = document.getElementById('btn-mode-nav'); + const elBtnModeSend = document.getElementById('btn-mode-send'); + elBtnModeNav.addEventListener('click', () => { + sendMode = false; + elBtnModeNav.classList.add('active'); + elBtnModeSend.classList.remove('active'); + setStatus(elActStatus, ''); + }); + elBtnModeSend.addEventListener('click', () => { + sendMode = true; + elBtnModeSend.classList.add('active'); + elBtnModeNav.classList.remove('active'); + setStatus(elActStatus, ''); + }); + // Collapse-Verhalten (wie im appRobotHoming) document.querySelectorAll('.section h2').forEach(h2 => { h2.addEventListener('click', () => h2.closest('.section').classList.toggle('collapsed')); @@ -506,6 +549,16 @@ } // ─── Start ─────────────────────────────────────────────────────────────────── + // Prüfen ob Driver konfiguriert ist, dann Senden-Button freigeben + apiFetch('GET', '/api/config').then(cfg => { + if (cfg.driverConfigured) { + elBtnModeSend.disabled = false; + elBtnModeSend.title = 'Zeile an Roboter senden'; + } else { + elBtnModeSend.title = 'Driver WS nicht konfiguriert (DRIVER_WS_URL fehlt)'; + } + }).catch(() => { /* config nicht verfügbar → Senden bleibt deaktiviert */ }); + refresh(); setInterval(refresh, REFRESH_MS); diff --git a/src/active/activeState.js b/src/active/activeState.js index fff442f..69ba506 100644 --- a/src/active/activeState.js +++ b/src/active/activeState.js @@ -6,11 +6,12 @@ * in der .gcode-Datei persistiert — so bleibt die Position nach einem Neustart * erhalten und ist direkt im File sichtbar. */ -const store = require('../store/fileStore'); -const units = require('../gcode/units'); -const cfg = require('../config'); -const log = require('../log'); +const store = require('../store/fileStore'); +const units = require('../gcode/units'); +const cfg = require('../config'); +const log = require('../log'); const { ApiError } = require('../errors'); +const driverClient = require('../driverClient'); class ActiveState { constructor() { @@ -132,11 +133,22 @@ class ActiveState { } // 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)); } + // execute=true → G-Code-Zeile zusätzlich an den Driver senden (Senden-Modus). + async _step(index, execute) { + await this._ensureActive(); + const result = await this._gotoIndex(index); + if (execute) { + const driverPos = await driverClient.send(result.line); // wirft bei Fehler/Timeout + return { ...result, driverPos }; + } + return result; + } + + async next(execute = false) { return this._step(this.cursor + 1, execute); } + async prev(execute = false) { return this._step(this.cursor - 1, execute); } + async first(execute = false) { return this._step(0, execute); } + async last(execute = false) { return this._step(this.lines.length - 1, execute); } + async goto(index, execute = false) { await this._ensureActive(); return this._step(Number(index), execute); } // ---- Teaching / Editieren (persistiert) ---- diff --git a/src/config.js b/src/config.js index f28a4d6..392c98d 100644 --- a/src/config.js +++ b/src/config.js @@ -17,4 +17,10 @@ module.exports = { // Default-Programm, das bei FPoint automatisch geladen wird wenn keines aktiv ist. // Entspricht dem alten Verhalten (GCodeFiles/log.gcode immer implizit aktiv). defaultProgramId: process.env.DEFAULT_PROGRAM_ID || 'log', + // WebSocket-URL des appRobotDriver (nur lokales Netz, server-seitig). + // Leer → Senden-Modus deaktiviert. + driverWsUrl: process.env.DRIVER_WS_URL || '', + // Maximale Wartezeit auf Driver-Antwort (ms). Muss länger sein als die + // längste Roboterfahrt. + driverTimeoutMs: Number(process.env.DRIVER_TIMEOUT_MS) || 10000, }; diff --git a/src/driverClient.js b/src/driverClient.js new file mode 100644 index 0000000..7960709 --- /dev/null +++ b/src/driverClient.js @@ -0,0 +1,60 @@ +// Persistente WS-Verbindung zum appRobotDriver (server-seitig, lokales Netz). +// send(line) gibt ein Promise zurück: resolved mit M114-Objekt bei Erfolg, +// rejected mit { driverCode } bei Driver-Fehler oder Timeout. +const WebSocket = require('ws'); +const log = require('./log'); +const cfg = require('./config'); + +let ws = null; + +function getOrCreateWs() { + if (ws && ws.readyState !== WebSocket.CLOSED) return ws; + ws = new WebSocket(cfg.driverWsUrl, { rejectUnauthorized: false }); + ws.on('error', (e) => log.warn('driverClient WS Fehler:', e.message)); + ws.on('close', () => log.info('driverClient WS geschlossen')); + ws.on('open', () => log.info('driverClient WS verbunden mit ' + cfg.driverWsUrl)); + return ws; +} + +function send(line) { + if (!cfg.driverWsUrl) { + return Promise.reject(Object.assign(new Error('DRIVER_WS_URL nicht konfiguriert'), { driverCode: 'NOT_CONFIGURED' })); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(Object.assign( + new Error(`Keine Antwort vom Driver innerhalb von ${cfg.driverTimeoutMs} ms`), + { driverCode: 'TIMEOUT' } + )); + }, cfg.driverTimeoutMs); + + const conn = getOrCreateWs(); + + const handler = (data) => { + clearTimeout(timer); + const text = data.toString(); + try { + const parsed = JSON.parse(text); + if (parsed.type === 'error') { + reject(Object.assign(new Error(parsed.message), { driverCode: parsed.code || 'DRIVER_ERROR' })); + } else { + resolve(parsed); // M114: { position: {…}, motorCounts: {…} } + } + } catch { + resolve({ raw: text }); // unbekanntes Format → als Erfolg werten + } + }; + + if (conn.readyState === WebSocket.OPEN) { + conn.once('message', handler); + conn.send(line); + } else { + conn.once('open', () => { + conn.once('message', handler); + conn.send(line); + }); + } + }); +} + +module.exports = { send, configured: () => !!cfg.driverWsUrl }; diff --git a/src/routes/active.js b/src/routes/active.js index 40b378d..9c2ce26 100644 --- a/src/routes/active.js +++ b/src/routes/active.js @@ -27,11 +27,32 @@ router.put( router.post('/clear', requireAuth, asyncH(async (req, res) => res.json(await active.clear()))); // 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)))); +// ?execute=true → G-Code-Zeile an Driver senden; Driver-Fehler → 502 +router.post('/next', requireAuth, asyncH(async (req, res) => { + const execute = req.query.execute === 'true'; + try { res.json(await active.next(execute)); } + catch (err) { if (err.driverCode) return res.status(502).json({ error: err.driverCode, message: err.message }); throw err; } +})); +router.post('/prev', requireAuth, asyncH(async (req, res) => { + const execute = req.query.execute === 'true'; + try { res.json(await active.prev(execute)); } + catch (err) { if (err.driverCode) return res.status(502).json({ error: err.driverCode, message: err.message }); throw err; } +})); +router.post('/first', requireAuth, asyncH(async (req, res) => { + const execute = req.query.execute === 'true'; + try { res.json(await active.first(execute)); } + catch (err) { if (err.driverCode) return res.status(502).json({ error: err.driverCode, message: err.message }); throw err; } +})); +router.post('/last', requireAuth, asyncH(async (req, res) => { + const execute = req.query.execute === 'true'; + try { res.json(await active.last(execute)); } + catch (err) { if (err.driverCode) return res.status(502).json({ error: err.driverCode, message: err.message }); throw err; } +})); +router.post('/goto', requireAuth, asyncH(async (req, res) => { + const execute = req.query.execute === 'true'; + try { res.json(await active.goto((req.body || {}).index, execute)); } + catch (err) { if (err.driverCode) return res.status(502).json({ error: err.driverCode, message: err.message }); throw err; } +})); // Teaching / Editieren router.post( diff --git a/src/server.js b/src/server.js index afdbf54..a7bbde2 100644 --- a/src/server.js +++ b/src/server.js @@ -6,6 +6,7 @@ const activeRouter = require('./routes/active'); const foldersRouter = require('./routes/folders'); const { errorMiddleware, envelope } = require('./errors'); const log = require('./log'); +const cfg = require('./config'); function createApp() { const app = express(); @@ -24,6 +25,8 @@ function createApp() { }); app.get('/api/health', (req, res) => res.json({ ok: true, service: 'appRobotFileservice' })); + // Browser-Konfiguration: ob Senden-Modus verfügbar ist (URL selbst wird nicht exponiert) + app.get('/api/config', (req, res) => res.json({ driverConfigured: !!cfg.driverWsUrl })); app.use('/api/programs', programsRouter); app.use('/api/folders', foldersRouter); app.use('/api/active', activeRouter); diff --git a/test/activeState.test.js b/test/activeState.test.js index 48d848f..34a55f2 100644 --- a/test/activeState.test.js +++ b/test/activeState.test.js @@ -1,9 +1,12 @@ +jest.mock('../src/driverClient'); + const os = require('os'); const path = require('path'); const fsp = require('fs/promises'); const cfg = require('../src/config'); const store = require('../src/store/fileStore'); const units = require('../src/gcode/units'); +const driverClient = require('../src/driverClient'); const { ActiveState } = require('../src/active/activeState'); let tmp; @@ -126,3 +129,67 @@ test('FPoint ohne aktives Programm → auto-lädt Default-Programm (log)', async expect(r.index).toBe(0); expect(a.programId).toBeTruthy(); // Default-Programm wurde geladen }); + +// ─── Senden-Modus (execute=true) ───────────────────────────────────────────── + +describe('Senden-Modus (execute=true)', () => { + const M114 = { position: { x: 10, y: 300, z: 0, a: 0 }, motorCounts: {} }; + + beforeEach(() => { + driverClient.send.mockReset(); + driverClient.send.mockResolvedValue(M114); + }); + + async function makeActive() { + await store.write('step_1', { + name: 'Step', + lines: [ + 'G90 G1 x0 y300 z0 a90.00 b-90.00 c0.00 e0.00 f1000 ;1', + 'G90 G1 x10 y300 z0 a0.00 b-90.00 c0.00 e0.00 f1000 ;2', + ], + }); + const a = new ActiveState(); + await a.load('step_1'); + return a; + } + + test('next(true) ruft driverClient.send mit der G-Code-Zeile auf', async () => { + const a = await makeActive(); + await a.first(); // cursor → 0 + await a.next(true); // cursor → 1, sendet Zeile 1 + + expect(driverClient.send).toHaveBeenCalledTimes(1); + const sentLine = driverClient.send.mock.calls[0][0]; + // Zeile muss Radian-Werte enthalten (a ≈ 0) und kein Semikolon (kein Kommentar) + expect(sentLine).toContain('G90 G1'); + expect(sentLine).not.toContain(';'); + }); + + test('next(true) gibt { cursor, line, driverPos } zurück', async () => { + const a = await makeActive(); + await a.first(); + const result = await a.next(true); + + expect(result.cursor).toBe(1); + expect(result.line).toContain('G90 G1'); + expect(result.driverPos).toEqual(M114); + }); + + test('next(true) wirft Fehler mit driverCode wenn driverClient.send ablehnt', async () => { + const driverErr = Object.assign(new Error('inverse kinematics failed'), { driverCode: 'GCODE_ERROR' }); + driverClient.send.mockRejectedValueOnce(driverErr); + + const a = await makeActive(); + await a.first(); + + await expect(a.next(true)).rejects.toMatchObject({ driverCode: 'GCODE_ERROR' }); + }); + + test('next(false) ruft driverClient.send NICHT auf', async () => { + const a = await makeActive(); + await a.first(); + await a.next(false); // Navigieren-Modus + + expect(driverClient.send).not.toHaveBeenCalled(); + }); +}); diff --git a/test/driverClient.test.js b/test/driverClient.test.js new file mode 100644 index 0000000..4c75162 --- /dev/null +++ b/test/driverClient.test.js @@ -0,0 +1,136 @@ +// Tests für src/driverClient.js +// Mockt das 'ws'-Modul vollständig — keine echte Netzwerkverbindung. +jest.mock('ws'); + +function makeMockWs(readyState = 1 /*OPEN*/) { + return { + readyState, + send: jest.fn(), + once: jest.fn(), + on: jest.fn(), + }; +} + +describe('driverClient', () => { + // Nach jedem resetModules() zeigt 'WebSocket' auf die frische Auto-Mock-Instanz. + // Deshalb wird MockWebSocket in beforeEach neu geladen, nicht oben auf dem Modul. + let MockWebSocket; + let cfg; + let dc; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + + MockWebSocket = require('ws'); + MockWebSocket.OPEN = 1; + MockWebSocket.CLOSED = 3; + + cfg = require('../src/config'); + cfg.driverWsUrl = 'wss://test-driver:9999'; + cfg.driverTimeoutMs = 500; + + dc = require('../src/driverClient'); + }); + + // ─── Fehler: nicht konfiguriert ──────────────────────────────────────────── + + test('send() rejects mit NOT_CONFIGURED wenn DRIVER_WS_URL leer ist', async () => { + cfg.driverWsUrl = ''; + await expect(dc.send('G90 G1 x0')).rejects.toMatchObject({ + driverCode: 'NOT_CONFIGURED', + }); + }); + + // ─── Erfolg: M114-Antwort ────────────────────────────────────────────────── + + test('send() resolved mit M114-Objekt bei Erfolg', async () => { + const mockWs = makeMockWs(); + MockWebSocket.mockImplementation(() => mockWs); + + let capturedHandler; + mockWs.once.mockImplementation((event, handler) => { + if (event === 'message') capturedHandler = handler; + }); + + const promise = dc.send('G90 G1 x10'); + + const m114 = { position: { x: 10, y: 300, z: 0, a: 0 }, motorCounts: { x: 1 } }; + capturedHandler(Buffer.from(JSON.stringify(m114))); + + const result = await promise; + expect(result).toMatchObject({ position: { x: 10 } }); + expect(mockWs.send).toHaveBeenCalledWith('G90 G1 x10'); + }); + + // ─── Fehler: Driver meldet GCODE_ERROR ──────────────────────────────────── + + test('send() rejects mit driverCode=GCODE_ERROR wenn Driver Fehler schickt', async () => { + const mockWs = makeMockWs(); + MockWebSocket.mockImplementation(() => mockWs); + + let capturedHandler; + mockWs.once.mockImplementation((event, handler) => { + if (event === 'message') capturedHandler = handler; + }); + + const promise = dc.send('UNGUELTIG'); + + const errMsg = { type: 'error', code: 'GCODE_ERROR', message: 'inverse kinematics failed', input: 'UNGUELTIG' }; + capturedHandler(Buffer.from(JSON.stringify(errMsg))); + + await expect(promise).rejects.toMatchObject({ + message: 'inverse kinematics failed', + driverCode: 'GCODE_ERROR', + }); + }); + + // ─── Fehler: Timeout ─────────────────────────────────────────────────────── + + test('send() rejects mit TIMEOUT wenn Driver nicht antwortet', async () => { + jest.useFakeTimers(); + + const mockWs = makeMockWs(); + MockWebSocket.mockImplementation(() => mockWs); + mockWs.once.mockImplementation(() => {}); // handler nie aufrufen → Timeout tritt ein + + cfg.driverTimeoutMs = 5000; + const promise = dc.send('G90 G1 x0'); + + // Fake-Timer vorschiessen damit der Timeout feuert + jest.runAllTimers(); + + await expect(promise).rejects.toMatchObject({ driverCode: 'TIMEOUT' }); + + jest.useRealTimers(); + }); + + // ─── Unbekanntes Antwort-Format → trotzdem Erfolg ──────────────────────── + + test('send() resolved auch bei unbekanntem (nicht-JSON) Antwort-Format', async () => { + const mockWs = makeMockWs(); + MockWebSocket.mockImplementation(() => mockWs); + + let capturedHandler; + mockWs.once.mockImplementation((event, handler) => { + if (event === 'message') capturedHandler = handler; + }); + + const promise = dc.send('G28'); + capturedHandler(Buffer.from('ok')); + + const result = await promise; + expect(result).toMatchObject({ raw: 'ok' }); + }); + + // ─── configured() ────────────────────────────────────────────────────────── + + test('configured() ist true wenn DRIVER_WS_URL gesetzt', () => { + expect(dc.configured()).toBe(true); + }); + + test('configured() ist false wenn DRIVER_WS_URL leer', () => { + cfg.driverWsUrl = ''; + expect(dc.configured()).toBe(false); + }); +});