Line Sender
This commit is contained in:
183
README.md
183
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!
|
||||
```
|
||||
|
||||
- `;<epoch>` = Aufnahme-Zeitstempel. Sonst nichts Service-Internes in der `.gcode`.
|
||||
- `<id>.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`).
|
||||
- `;<epoch>` = Aufnahme-Zeitstempel.
|
||||
- `;!` (Ausrufezeichen am Ende des Kommentars) = **Cursor-Marker**: zeigt die
|
||||
zuletzt angefahrene Zeile. Primäre Cursor-Quelle beim Lesen. Direkt im Texteditor
|
||||
sichtbar.
|
||||
- `<id>.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 <name>` | `POST /api/programs` |
|
||||
| `FLoad <id>` | `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 <n>` | `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 <id>` | `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 <n>` | `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).
|
||||
|
||||
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 |
|
||||
@@ -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="<id>.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: <gcode-Datei> }
|
||||
→ speichert unter slugify(filename) in GCodeFiles/
|
||||
```
|
||||
|
||||
Im Frontend: `<input type="file" accept=".gcode,.ngc">` + `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/<dir>/<id>.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: <ActiveState-JSON>\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 → `<input>` 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
|
||||
```
|
||||
|
||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -53,13 +53,23 @@
|
||||
<div class="kv"><span class="k">Version</span><span class="v" id="ai-version">–</span></div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="btn-first" disabled title="Erste Zeile">|◀ First</button>
|
||||
<button id="btn-prev" disabled title="Schritt zurück">◀ Prev</button>
|
||||
<button id="btn-next" disabled title="Schritt vor">Next ▶</button>
|
||||
<button id="btn-last" disabled title="Letzte Zeile">Last ▶|</button>
|
||||
<button id="btn-clear" disabled title="Programm leeren">✕ Clear</button>
|
||||
<a id="btn-download" class="dl-link" style="display:none" download>⬇ .gcode</a>
|
||||
<div class="controls-row">
|
||||
<div class="ctrl-group">
|
||||
<button id="btn-first" disabled title="Erste Zeile">|◀ First</button>
|
||||
<button id="btn-prev" disabled title="Schritt zurück">◀ Prev</button>
|
||||
<button id="btn-next" disabled title="Schritt vor">Next ▶</button>
|
||||
<button id="btn-last" disabled title="Letzte Zeile">Last ▶|</button>
|
||||
</div>
|
||||
<div class="ctrl-group">
|
||||
<button id="btn-clear" disabled title="Programm leeren">✕ 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" title="Nur Cursor bewegen">↕ Navigieren</button>
|
||||
<button id="btn-mode-send" class="toggle" title="Zeile an Roboter senden" disabled>▶ Senden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-line" id="active-status"></div>
|
||||
@@ -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);
|
||||
</script>
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
60
src/driverClient.js
Normal file
60
src/driverClient.js
Normal file
@@ -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 };
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
136
test/driverClient.test.js
Normal file
136
test/driverClient.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user