Line Sender
This commit is contained in:
421
doc/commandsFromGCodeFile.md
Normal file
421
doc/commandsFromGCodeFile.md
Normal file
@@ -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 `<div class="ctrl-group">`.
|
||||
|
||||
### Schalter Navigieren / Senden
|
||||
|
||||
Zwei Buttons als Radiogruppe (einer immer aktiv):
|
||||
|
||||
```html
|
||||
<div class="toggle-group">
|
||||
<button id="btn-mode-nav" class="toggle active">↕ Navigieren</button>
|
||||
<button id="btn-mode-send" class="toggle" >▶ Senden</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
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
|
||||
<div class="controls-row">
|
||||
<div class="ctrl-group">
|
||||
<button id="btn-first" disabled>|◀ First</button>
|
||||
<button id="btn-prev" disabled>◀ Prev</button>
|
||||
<button id="btn-next" disabled>Next ▶</button>
|
||||
<button id="btn-last" disabled>Last ▶|</button>
|
||||
</div>
|
||||
<div class="ctrl-group">
|
||||
<button id="btn-clear" disabled>✕ Clear</button>
|
||||
<a id="btn-download" class="dl-link" style="display:none" download>⬇ .gcode</a>
|
||||
</div>
|
||||
<div class="ctrl-group">
|
||||
<div class="toggle-group">
|
||||
<button id="btn-mode-nav" class="toggle active">↕ Navigieren</button>
|
||||
<button id="btn-mode-send" class="toggle">▶ Senden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 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 |
|
||||
Reference in New Issue
Block a user