# 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 |