Initiales Projekt-Skelett appRobotFileservice
Ausgelagertes Programm-/File-Handling (vormals GCode.receiveFC im appRobotDriver, ToDo_4 / ToDo_6b). Express-Service mit .gcode + .json-Storage, aktivem Programm + Cursor, Teaching (FPoint) und Playback. Speicherung in Grad, driver-nativ (Radian) zum Driver. Konzept/API unter doc/draft_filehandeling*.md. Tests: jest (13 gruen). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
# Laufzeit-Daten: Programme werden zur Laufzeit erzeugt
|
||||
GCodeFiles/*.gcode
|
||||
GCodeFiles/*.ngc
|
||||
GCodeFiles/*.json
|
||||
|
||||
# lokaler API-Key
|
||||
.apikey
|
||||
0
GCodeFiles/.gitkeep
Normal file
0
GCodeFiles/.gitkeep
Normal file
129
README.md
Normal file
129
README.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# appRobotFileservice
|
||||
|
||||
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).
|
||||
|
||||
## Rolle in der Architektur
|
||||
|
||||
```
|
||||
Steuerungen → appRobotDriver → appRobotFileservice
|
||||
(Joystick, …) (Gateway) (dieses Projekt, passiv)
|
||||
```
|
||||
|
||||
- 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.
|
||||
|
||||
## Einheiten (wichtig)
|
||||
|
||||
- **Gespeichert** wird in **Grad** (standardnahe `.gcode`, 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.
|
||||
|
||||
## Dateiformat
|
||||
|
||||
`.gcode` sieht aus wie Standard-G-Code; Zeitstempel und Cursor stehen im
|
||||
**Kommentarfeld** (`;…`, standardkonform):
|
||||
|
||||
```
|
||||
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! <- Cursor (!)
|
||||
```
|
||||
|
||||
- `;<epoch>` = Aufnahme-Zeitstempel · abschließendes `!` = Cursor-Zeile.
|
||||
- Der Cursor lebt zur Laufzeit als In-Memory-Index (schnelles Stepping ohne
|
||||
Neuschreiben) und wird beim Speichern/Entladen als `!` zurückgeschrieben.
|
||||
- `<id>.json` ist ein Sidecar mit Metadaten (Name, Zeiten, `lineCount`, `angleUnit`).
|
||||
|
||||
## Start
|
||||
|
||||
```
|
||||
npm install
|
||||
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.
|
||||
|
||||
## Konfiguration (Env)
|
||||
|
||||
| Variable | Default | Zweck |
|
||||
|---|---|---|
|
||||
| `FILE_SERVICE_PORT` | `2100` | Port |
|
||||
| `STORAGE_DIR` | `./GCodeFiles` | 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) |
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
|
||||
## 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).
|
||||
292
doc/draft_filehandeling.md
Normal file
292
doc/draft_filehandeling.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Draft — File-Handling als externes Projekt `appRobotFileservice` (Driver als Gateway)
|
||||
|
||||
> **Status:** Entwurf / Diskussionsgrundlage.
|
||||
> **Projekte:** Der **Driver** lebt in `appRobotDriver` (dieses Repo). Das gesamte
|
||||
> G-Code-**Programm**-Handling wird in das eigenständige Projekt
|
||||
> **`appRobotFileservice`** ausgelagert. Schnittstelle:
|
||||
> [`draft_filehandeling_API.md`](draft_filehandeling_API.md).
|
||||
> **Verhältnis zu ToDos:** ersetzt den Driver-internen `GCodeFileManager`-Ansatz aus
|
||||
> `doc/ToDo_4_GCode.md` und `doc/ToDo_6b_FileHandling.md`.
|
||||
> **Übergang darf hart sein** — keine Rückwärtskompatibilität nötig.
|
||||
|
||||
---
|
||||
|
||||
## 1. Motivation
|
||||
|
||||
Heute lebt das Datei-Handling in [`robot/GCode.js`](../robot/GCode.js)
|
||||
(`receiveFC`, `ContainsFilesCommand`, `removeStringFromFile`, `toPiMultiple`, der
|
||||
`;!`-Cursor) und wird in [`server/InputWS.js`](../server/InputWS.js) gleichberechtigt
|
||||
neben den Bewegungs-Befehlen geroutet. Das vermischt zwei Verantwortungen:
|
||||
|
||||
| | **Bewegung / Hardware** | **Programm-Verwaltung** |
|
||||
|---|---|---|
|
||||
| Aufgabe | eine G-Code-Zeile → Achsen bewegen | Programme speichern, anzeigen, durchblättern |
|
||||
| Zustand | Live-Pose des Roboters | Datei-Inhalte, Cursor, Listen |
|
||||
| Echtzeit | ja (Telnet/FluidNC) | nein (Storage-/UI-getrieben) |
|
||||
| Gehört zu | **`appRobotDriver`** | **`appRobotFileservice`** |
|
||||
|
||||
---
|
||||
|
||||
## 2. Leitprinzip — der Driver ist das einzige Front Door
|
||||
|
||||
**Vorgabe:** Alle Steuerungen (Joystick, Tastatur, Bilderkennung,
|
||||
sensor-gesteuerte Programme …) kennen **nur den Driver**. Sie sprechen die
|
||||
appRobotFileservice **niemals direkt** an — nur indirekt, *durch den Driver hindurch*.
|
||||
|
||||
```
|
||||
Steuerungen → Driver → appRobotFileservice
|
||||
(nur EINE Verbindung pro Steuerung: zum Driver)
|
||||
```
|
||||
|
||||
Daraus folgt eine **einseitige Abhängigkeit**:
|
||||
|
||||
```
|
||||
Steuerung ──kennt──► Driver ──kennt──► appRobotFileservice
|
||||
(Gateway) (passiver Storage-Dienst)
|
||||
|
||||
• Der Driver hängt von der appRobotFileservice ab (ruft sie).
|
||||
• Die appRobotFileservice hängt von NICHTS ab — sie ruft den Driver nie an,
|
||||
kennt weder dessen URL noch dessen Pose.
|
||||
• Steuerungen brauchen KEINEN neuen Weg: sie reden weiter nur mit dem Driver.
|
||||
```
|
||||
|
||||
> **Abgrenzung:** Gemeint sind **Steuerungen** (Echtzeit-Eingaben). Die
|
||||
> **Visualisierungs-/Verwaltungs-UI** der appRobotFileservice ist Teil *jenes*
|
||||
> Projekts und darf den Fileservice direkt ansprechen — sie ist keine Steuerung.
|
||||
|
||||
---
|
||||
|
||||
## 3. Befehls-Routing im Driver (der „Pass-through")
|
||||
|
||||
Der Driver klassifiziert jede eingehende Nachricht und routet sie:
|
||||
|
||||
```
|
||||
eingehende Nachricht am Driver (WS :2095 oder POST /api/gcode)
|
||||
│
|
||||
├─ Bewegung (G…, M1, M92, G92) → lokal ausführen → Pose broadcast
|
||||
├─ Status (Ping, M114) → gezielt antworten
|
||||
├─ FCode (FShow, FList, FPoint …) → an appRobotFileservice weiterreichen
|
||||
└─ sonst → Fehler-Envelope
|
||||
```
|
||||
|
||||
### FCodes — eine Befehlsfamilie wie die G-/M-Codes
|
||||
|
||||
G-Code kennt `G1`, `G2`, `Gx` und `M1`, `M92`, … . Analog bilden die **FCodes** eine
|
||||
eigene Familie für Datei-/Programm-Befehle — **ohne Sonderzeichen**, einfach `F` +
|
||||
Wort:
|
||||
|
||||
| FCode (Steuerung → Driver) | Bedeutung | Driver leitet weiter an |
|
||||
|---|---|---|
|
||||
| `FList` | Programme auflisten | `GET /programs` |
|
||||
| `FShow [id]` | Inhalt anzeigen | `GET /programs/{id}` |
|
||||
| `FLoad <id>` | Programm aktiv setzen | `PUT /active` |
|
||||
| `FSave <name>` | aktiven Puffer speichern | `POST /programs` |
|
||||
| `FClear` | aktives Programm leeren | `POST /active/clear` |
|
||||
| `FPoint` | **aktuelle Pose** aufnehmen | `POST /active/points` (Driver hängt Pose an) |
|
||||
| `FPlus` / `FMinus` | nächste / vorige Zeile | `POST /active/next` / `/prev` |
|
||||
| `FFirst` / `FLast` | an Anfang / Ende | `POST /active/first` / `/last` |
|
||||
| `FGoto <n>` | zu Zeile springen | `POST /active/goto` |
|
||||
| `FPlay` / `FStop` | durchlaufen / anhalten | `POST /active/play` / `/stop` |
|
||||
|
||||
**Warum kein Sonderzeichen-Prefix nötig ist:** Eine Bewegungszeile beginnt mit `G`
|
||||
oder `M`; ein FCode mit `F`+Buchstabe. Das Feedrate-Wort `F1000` ist `F`+Ziffer und
|
||||
steht **nur innerhalb** einer `G`-Zeile, nie am Anfang. Der Router muss also nur
|
||||
**am Nachrichtenanfang** prüfen: `F` + Buchstabe → FCode. Damit ist die Familie
|
||||
kollisionsfrei — gegen die Lesbarkeit spricht nichts.
|
||||
|
||||
`FFirst`/`FLast` werden dabei endlich umgesetzt (heute erkannt, aber nicht
|
||||
implementiert — vgl. ToDo_6b / Bug 2). Konkrete API:
|
||||
[`draft_filehandeling_API.md`](draft_filehandeling_API.md).
|
||||
|
||||
---
|
||||
|
||||
## 4. Zwei Datei-Welten — nur eine wandert aus
|
||||
|
||||
| Welt | Beispiele | Verbleib |
|
||||
|---|---|---|
|
||||
| **Betriebs-Logs** | `logs/gcode_commands.log`, `logs/pings.log` | **bleibt im Driver** |
|
||||
| **G-Code-Programme** | `GCodeFiles/*.gcode` | **wird ausgelagert** (`appRobotFileservice`) |
|
||||
|
||||
Die Logs betreffen den Hardware-/Verbindungsbetrieb und bleiben. Ausgelagert wird
|
||||
ausschließlich `GCodeFiles/` samt Cursor und FCodes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Was bleibt im Driver, was wird ausgelagert
|
||||
|
||||
| Heute (in [`robot/GCode.js`](../robot/GCode.js)) | Ziel | Anmerkung |
|
||||
|---|---|---|
|
||||
| `receiveGCode` / `containsCommand` / `receiveMCode` | **bleibt** | reine Bewegung |
|
||||
| `getM114` / `GET /api/position` | **bleibt** | Pose-Quelle für `FPoint` |
|
||||
| `logCommand` / `logPing` | **bleibt** | Betriebs-Logging |
|
||||
| Routing der FCodes | **bleibt als dünner Proxy** | neuer Gateway-Zweig in `InputWS` |
|
||||
| `receiveFC` (Programm-Logik) | **appRobotFileservice** | Verwaltung |
|
||||
| `static fileName`, `;!`-Cursor | **appRobotFileservice** (Cursor: In-Memory-Index, persistiert als `!`-Kommentar) | löst ToDo_6b Paket 2 |
|
||||
| `removeStringFromFile` | **entfällt** | nur für den `;!`-Hack nötig |
|
||||
| `toPiMultiple` (Grad→Radian) | **entfällt im Driver** → Umrechnung lebt im Fileservice | siehe §7 |
|
||||
| Zeilen-String-Bau in `FPoint` | **appRobotFileservice** | Zeilenformat ist Programm-Logik |
|
||||
|
||||
Im Driver bleibt also: Bewegung, Pose, Logs — **plus ein dünner Proxy-Zweig**, der
|
||||
FCodes weiterreicht. Kein `GCodeFiles/`-IO, kein Cursor, **keine** Einheiten-Umrechnung.
|
||||
|
||||
---
|
||||
|
||||
## 6. Die zwei Kernabläufe
|
||||
|
||||
### 6a. Playback (Datei → Roboter)
|
||||
|
||||
```
|
||||
Steuerung → Driver: FPlus
|
||||
Driver → Fileservice: POST /active/next (Cursor++)
|
||||
Fileservice → Driver: { line: "G90 G1 x310 y444 … a1.5708 …" } (driver-nativ, Radian)
|
||||
Driver: receiveGCode(line) → Achsen bewegen
|
||||
Driver: Pose-Broadcast an alle WS-Clients
|
||||
```
|
||||
|
||||
Die appRobotFileservice liefert eine **fertig ausführbare, driver-native Zeile**; der
|
||||
Driver führt sie über seinen normalen `receiveGCode`-Pfad aus — *keine*
|
||||
Sonderbehandlung, *keine* Umrechnung.
|
||||
|
||||
### 6b. Teaching / Training (Roboter → Datei) — der robotik-spezifische Fall
|
||||
|
||||
Der Arm wird **per Joystick** bewegt; G-Code ist hier **Ausgabe**. Entscheidend:
|
||||
Beim `FPoint` hat der **Driver die Live-Pose bereits lokal**.
|
||||
|
||||
```
|
||||
Steuerung (Joystick) → Driver: G1 …/$J= (Arm bewegen, lokal)
|
||||
Steuerung → Driver: FPoint
|
||||
Driver: hängt die AKTUELLE Pose an (robot.x … robot.e, feedrate)
|
||||
Driver → Fileservice: POST /active/points { pose:{ x,y,z, a,b,c, e }, feedrate }
|
||||
Fileservice: Pose → Grad → als G-Code-Zeile persistieren, Cursor ans Ende
|
||||
Fileservice → Driver: { index, line }
|
||||
Driver → Steuerung: Bestätigung
|
||||
```
|
||||
|
||||
Der Driver ist die Quelle der Wahrheit für die Pose und reicht sie beim Forwarden
|
||||
mit. Die appRobotFileservice muss den Driver dafür **nicht** anrufen.
|
||||
|
||||
---
|
||||
|
||||
## 7. Einheiten: Driver bleibt Radian, der Fileservice rechnet um
|
||||
|
||||
Die Datei soll **wie Standard-G-Code aussehen** (Grad, `a-90.00`). Der Driver
|
||||
arbeitet intern und am G-Code-Eingang in **Radian** (Beleg: `receiveGCode` setzt
|
||||
`robot.phi = A` ohne Umrechnung). Beides ist vereinbar, ohne dass der Driver etwas
|
||||
umrechnen muss:
|
||||
|
||||
| Achse | `.gcode`-Datei (Storage) | Wire Driver ↔ Fileservice | Driver intern |
|
||||
|---|---|---|---|
|
||||
| `x y z` | mm | mm | mm |
|
||||
| `a b c` (φ/θ/ψ) | **Grad** (`a-90.00`) | **Radian** | Radian |
|
||||
| `e` (Greifer) | **Grad** | **Radian** | Radian |
|
||||
| Umrechnung | — | **in der appRobotFileservice** | **keine** |
|
||||
|
||||
- **Driver:** rechnet nie um — `toPiMultiple` **entfällt** ersatzlos (harter Übergang).
|
||||
- **appRobotFileservice:** konvertiert an ihrer **Storage-Grenze**: beim Lesen für
|
||||
Playback Grad→Radian, beim `FPoint`-Schreiben Radian→Grad. Damit liegt die
|
||||
Umrechnung an genau **einer** Stelle und ist testbar (löst ToDo_6b Paket 3).
|
||||
|
||||
So bleibt die Datei standardnah und lesbar, der Hot-Path im Driver aber sauber.
|
||||
|
||||
---
|
||||
|
||||
## 8. Storage-Modell der appRobotFileservice: GCode-Datei + JSON-Sidecar
|
||||
|
||||
Ziel: am Ende stehen **Dateien, die wie G-Code aussehen** (möglichst nah an einem
|
||||
Standard). Pro Programm:
|
||||
|
||||
```
|
||||
GCodeFiles/
|
||||
besteck_spuelmaschine.gcode ← das Programm, sieht aus wie Standard-G-Code (Grad)
|
||||
besteck_spuelmaschine.json ← Sidecar: Metadaten + Verwaltung
|
||||
```
|
||||
|
||||
- **`.gcode`** (alternativ `.ngc`): standardnahe Bewegungszeilen, Winkel in **Grad**.
|
||||
Zeitstempel **und** Cursor stehen im **G-Code-Kommentarfeld** (`;…`) — so bleibt die
|
||||
Zeile standardkonform (Kommentare sind Teil des G-Code-Standards):
|
||||
- jede Zeile endet mit `;<epoch>` (Aufnahme-Zeitstempel),
|
||||
- die **Cursor-Zeile** trägt zusätzlich ein `!`: `;<epoch>!`.
|
||||
|
||||
```
|
||||
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 z30.5 a90.00 b-90.00 c0.00 e6.88 f1000 ;1759566118
|
||||
```
|
||||
|
||||
Damit ist die `.gcode` **ohne Sidecar vollständig** (Bewegung + Zeitstempel + Cursor).
|
||||
- **`.json`-Sidecar** (Komfort/Verwaltung): Anzeigename, `createdAt`/`updatedAt`,
|
||||
`lineCount`, `angleUnit` (`"deg"`), optional benannte Labels (`"pick"`, `"place"`
|
||||
→ Zeilenindex). Quelle der Wahrheit für Bewegung/Zeitstempel/Cursor bleibt die `.gcode`.
|
||||
|
||||
Nach außen (API) werden Programme über **id/Name** angesprochen, **nie über
|
||||
Dateipfade** — `GCodeFiles/` und das Sidecar-Schema bleiben **intern** in der
|
||||
appRobotFileservice. Damit entfällt die `../`-Pfad-Problematik (ToDo_6b Paket 4) und
|
||||
ein späterer Wechsel des Storage bleibt unsichtbar.
|
||||
|
||||
---
|
||||
|
||||
## 9. Gemeinsamer Zustand: aktives Programm + Cursor (im Fileservice)
|
||||
|
||||
Die appRobotFileservice hält genau einen **„aktives Programm + Cursor"**-Zustand als
|
||||
*Single Source of Truth*. Weil alle Steuerungen durch denselben Driver auf denselben
|
||||
Fileservice gehen, teilen sie automatisch denselben Cursor — `FPlus` vom Joystick und
|
||||
gleich darauf `FPlus` von der Bilderkennung sehen denselben Stand.
|
||||
|
||||
- `aktivesProgramm` — id/Name (ersetzt `static fileName`).
|
||||
- `cursor` — während einer Session **Zeilenindex im Speicher** (schnelles Stepping
|
||||
ohne Neu-Schreiben). Beim Laden aus dem `!`-Kommentar gelesen, beim Speichern/
|
||||
Entladen als `!` in die Cursor-Zeile zurückgeschrieben — so ist der Cursor
|
||||
persistiert, **ohne** bei jedem `FPlus` die ganze Datei neu zu schreiben (löst
|
||||
ToDo_6b Paket 2).
|
||||
|
||||
---
|
||||
|
||||
## 10. `/api/gcode` & WS — der Steuerungs-Kanal
|
||||
|
||||
`POST /api/gcode` am Driver (optional, REST-Alternative zur WS) und die WS `:2095`
|
||||
sind der **Bewegungs-Eingang für alle Steuerungen**:
|
||||
|
||||
- **Zugriff: alle Steuerungen** (Joystick, Tastatur, Bilderkennung, Sensorik).
|
||||
- **Nicht** die appRobotFileservice — sie pusht nie Bewegung an den Driver; der
|
||||
Driver führt Playback-Zeilen selbst aus (§6a). Der Fileservice braucht **keinen**
|
||||
Driver-Zugang.
|
||||
|
||||
---
|
||||
|
||||
## 11. Durchgereichte Payload-Größen
|
||||
|
||||
Der Driver reicht bei `FShow`/`FList` ggf. größere Mengen durch (Datei-Inhalt,
|
||||
Listen). Das ist akzeptabel: die **appRobotFileservice** hält diese Antworten später
|
||||
klein (z. B. Paginierung, Kurz-/Übersichtsform), sodass der Durchreich-Weg über den
|
||||
Driver unkritisch bleibt.
|
||||
|
||||
---
|
||||
|
||||
## 12. Erforderliche kleine Driver-Ergänzungen
|
||||
|
||||
1. **`InputWS`-Router:** neuer Zweig „FCode am Anfang (`F`+Buchstabe) → an Fileservice
|
||||
forwarden, Antwort zurückreichen". Playback-Zeile lokal ausführen; Verwaltungs-
|
||||
Antworten gezielt an den Anfrager, Pose-ändernde Aktionen broadcasten (analog ToDo_5).
|
||||
2. **`FPoint`-Pose:** Der Driver muss die **Live-Pose inkl. Greifer `e`** (und φ/θ/ψ)
|
||||
mitliefern. Heute setzt `getM114` `e` hart auf `0.0` — sonst geht die
|
||||
Greiferstellung beim Aufnehmen verloren.
|
||||
3. **`POST /api/gcode`** (optional): REST-Bewegungs-Eingang für Steuerungen ohne WS.
|
||||
|
||||
---
|
||||
|
||||
## 13. Offene Fragen
|
||||
|
||||
- **FCode-Namen:** bestehende Familie (`FPlus`/`FMinus` …) beibehalten oder einzelne
|
||||
umbenennen (`FNext`/`FPrev`)? — Empfehlung: bestehende behalten, neue ergänzen.
|
||||
- **Cursor-Persistenz:** als `!`-Kommentar in der `.gcode` (gewählt) — Häufigkeit des
|
||||
Zurückschreibens (sofort vs. debounced beim Entladen) noch offen.
|
||||
- **Sidecar-Umfang:** Metadaten + Labels (Cursor & Zeitstempel liegen in der `.gcode`).
|
||||
|
||||
---
|
||||
|
||||
## 14. Verweise
|
||||
|
||||
- [`draft_filehandeling_API.md`](draft_filehandeling_API.md) — appRobotFileservice-Schnittstelle
|
||||
- [`ToDo_4_GCode.md`](ToDo_4_GCode.md) · [`ToDo_6b_FileHandling.md`](ToDo_6b_FileHandling.md) — abgelöst/gelöst
|
||||
- [`ToDo_5_API.md`](ToDo_5_API.md) / [`API.md`](API.md) — Routing & Fehler-Envelope
|
||||
- [`robot/GCode.js`](../robot/GCode.js) · [`server/InputWS.js`](../server/InputWS.js) · [`server/InfoServer.js`](../server/InfoServer.js)
|
||||
253
doc/draft_filehandeling_API.md
Normal file
253
doc/draft_filehandeling_API.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# Draft — `appRobotFileservice` API
|
||||
|
||||
> **Status:** Entwurf. Schnittstelle des ausgelagerten Programm-Handlings
|
||||
> (`appRobotFileservice`). Konzept & Rollenteilung:
|
||||
> [`draft_filehandeling.md`](draft_filehandeling.md).
|
||||
>
|
||||
> **Einziger Consumer ist der Driver** (`appRobotDriver`). Steuerungen sprechen die
|
||||
> appRobotFileservice nie direkt an, sondern schicken **FCodes** an den Driver, der
|
||||
> sie hierher weiterreicht. Die appRobotFileservice ist **passiv und
|
||||
> driver-agnostisch**: sie ruft den Driver nie an, kennt weder dessen URL noch dessen
|
||||
> Pose. (Eine eigene Visualisierungs-UI darf direkt zugreifen — sie ist keine Steuerung.)
|
||||
|
||||
---
|
||||
|
||||
## 1. Überblick
|
||||
|
||||
- **Transport:** HTTP/REST + JSON. Optional ein WebSocket-Event-Kanal (Abschnitt 8).
|
||||
- **Basis-URL (Vorschlag):** `https://<host>:2100/api`
|
||||
- **Identität:** Programme über **`id`/Name** — **nie über Dateipfade**. Storage
|
||||
(`.gcode` + `.json`-Sidecar) ist intern gekapselt.
|
||||
- **Einheiten am Wire:** **driver-nativ** (φ/θ/ψ und `e` in **Radian**, `x/y/z` in
|
||||
mm) — exakt die G-Code-Strings, die der Driver ausführt. **Gespeichert** wird in
|
||||
**Grad** (standardnahe `.gcode`); die appRobotFileservice rechnet an ihrer
|
||||
Storage-Grenze um (Konzept §7).
|
||||
- **Auth:** `Bearer <FILE_API_KEY>` für schreibende Operationen (analog `ROBOT_API_KEY`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Datenmodell
|
||||
|
||||
### Program (Metadaten, aus dem `.json`-Sidecar)
|
||||
```json
|
||||
{ "id": "besteck_spuelmaschine", "name": "Besteck Spülmaschine",
|
||||
"lineCount": 12, "angleUnit": "deg",
|
||||
"createdAt": "2025-10-04T10:25:00Z", "updatedAt": "2025-10-04T10:41:00Z" }
|
||||
```
|
||||
|
||||
### ActiveState (aktives Programm + Cursor — Single Source of Truth)
|
||||
```json
|
||||
{ "programId": "besteck_spuelmaschine", "cursor": 4, "lineCount": 12,
|
||||
"currentLine": "G90 G1 x310 y444 z0.5 a1.5708 b-1.5708 c0 e0.12 f1000",
|
||||
"playing": false, "version": 7 }
|
||||
```
|
||||
> `currentLine` ist **driver-nativ (Radian)** und kommentarfrei — direkt ausführbar.
|
||||
> Gespeichert wird in **Grad** mit Zeitstempel-Kommentar (`draft_filehandeling.md` §8).
|
||||
|
||||
### Pose (vom Driver beim `FPoint` mitgeschickt)
|
||||
Native Radian-Werte inkl. Greifer `e`:
|
||||
```json
|
||||
{ "pose": { "x": 0, "y": 300, "z": 0, "a": 1.5708, "b": -1.5708, "c": 0, "e": 0.12 },
|
||||
"feedrate": 1000 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. FCode ↔ Endpoint-Mapping
|
||||
|
||||
Der Driver übersetzt die FCodes der Steuerungen in diese Endpoints:
|
||||
|
||||
| FCode | Endpoint | Antwort an Steuerung (über Driver) |
|
||||
|---|---|---|
|
||||
| `FList` | `GET /programs` | Liste (gezielt) |
|
||||
| `FShow [id]` | `GET /programs/{id}` | Inhalt in **Grad** (gezielt) |
|
||||
| `FLoad <id>` | `PUT /active` | ActiveState (gezielt) |
|
||||
| `FSave <name>` | `POST /programs` | id (gezielt) |
|
||||
| `FClear` | `POST /active/clear` | ActiveState (gezielt) |
|
||||
| `FPoint` | `POST /active/points` | Bestätigung (gezielt) |
|
||||
| `FPlus` | `POST /active/next` | Bewegung → **Pose-Broadcast** |
|
||||
| `FMinus` | `POST /active/prev` | Bewegung → **Pose-Broadcast** |
|
||||
| `FFirst` | `POST /active/first` | Bewegung → **Pose-Broadcast** |
|
||||
| `FLast` | `POST /active/last` | Bewegung → **Pose-Broadcast** |
|
||||
| `FGoto <n>` | `POST /active/goto` | Bewegung → **Pose-Broadcast** |
|
||||
| `FPlay` / `FStop` | `POST /active/play` / `/stop` | Status |
|
||||
|
||||
---
|
||||
|
||||
## 4. Endpoints — Programm-Verwaltung
|
||||
|
||||
### `GET /programs` ← `FList`
|
||||
```json
|
||||
{ "programs": [ { "id": "log", "name": "log", "lineCount": 36 }, … ] }
|
||||
```
|
||||
|
||||
### `GET /programs/{id}` ← `FShow`
|
||||
Inhalt + Metadaten für die Anzeige — in **Grad**, wie gespeichert (lesbar):
|
||||
```json
|
||||
{ "id": "besteck_spuelmaschine", "displayUnit": "deg",
|
||||
"lines": [ "G90 G1 x0 y614 z0 a-90.00 b90.00 c0.00 e0 f1000 ;1759566014",
|
||||
"G90 G1 x0 y300 z0 a90.00 b-90.00 c0.00 e0 f1000 ;1759566052!" ] }
|
||||
```
|
||||
> Kommentar `;<epoch>` = Aufnahme-Zeitstempel; ein abschließendes `!` markiert die Cursor-Zeile.
|
||||
|
||||
### `POST /programs` ← `FSave`
|
||||
```jsonc
|
||||
{ "name": "Demo C", "fromActive": true } // aus aktivem Puffer
|
||||
// oder expliziter Inhalt (in Grad, wie eine .gcode):
|
||||
{ "name": "Demo C", "lines": ["G90 G1 x0 y300 … a90.00 …"], "angleUnit": "deg" }
|
||||
```
|
||||
→ `201 { "id": "demo_c", "lineCount": 12 }` (legt `demo_c.gcode` + `demo_c.json` an)
|
||||
|
||||
### `PUT /programs/{id}` · `DELETE /programs/{id}`
|
||||
Inhalt ersetzen / umbenennen · löschen (jeweils `.gcode` **und** `.json`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Endpoints — Aktives Programm & Cursor
|
||||
|
||||
### `GET /active`
|
||||
Aktuellen `ActiveState` lesen.
|
||||
|
||||
### `PUT /active` ← `FLoad`
|
||||
```json
|
||||
{ "id": "besteck_spuelmaschine" }
|
||||
```
|
||||
→ `ActiveState`. Validierung: existiert, ≥1 gültige Zeile (sonst `EMPTY_PROGRAM`).
|
||||
|
||||
### `POST /active/clear` ← `FClear`
|
||||
Aktives Programm leeren, Cursor → 0.
|
||||
|
||||
### Stepping — `next` · `prev` · `first` · `last` · `goto`
|
||||
Bewegt den Cursor und gibt die **driver-native, ausführbare Zeile (Radian)** zurück.
|
||||
Der **Driver führt sie selbst aus** — der Fileservice pusht nichts.
|
||||
|
||||
`POST /active/next` · `/prev` · `/first` · `/last` · `/goto` `{ "index": 7 }`
|
||||
```json
|
||||
{ "cursor": 5, "line": "G90 G1 x310 y444 z30.5 a1.5708 b-1.5708 c0 e0.12 f1000" }
|
||||
```
|
||||
Grenzen: `next` am Ende / `prev` am Anfang → `CURSOR_OUT_OF_RANGE` (optional `wrap`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Endpoints — Teaching / Aufnahme
|
||||
|
||||
### `POST /active/points` ← `FPoint`
|
||||
Der **Driver schickt die aktuelle Pose mit** (native Radian-Werte). Die
|
||||
appRobotFileservice rechnet **nach Grad** um, formatiert die Zeile (Feedrate,
|
||||
Zeitstempel als Kommentar `;<epoch>`) und hängt sie an.
|
||||
```json
|
||||
{ "pose": { "x": 0, "y": 300, "z": 0, "a": 1.5708, "b": -1.5708, "c": 0, "e": 0.12 },
|
||||
"feedrate": 1000 }
|
||||
```
|
||||
→ `201 { "index": 12, "line": "G90 G1 x0 y300 z0 a90.00 b-90.00 c0.00 e6.88 f1000 ;1759566014" }`
|
||||
|
||||
### `POST /active/lines`
|
||||
Rohe Zeile(n) anhängen/einfügen (z. B. Pause `G4`):
|
||||
```json
|
||||
{ "line": "G4 P0.5", "atIndex": 8 }
|
||||
```
|
||||
|
||||
### `PUT /active/lines/{index}` · `DELETE /active/lines/{index}`
|
||||
Einzelne Zeile ersetzen / löschen (Editieren der Aufnahme).
|
||||
|
||||
---
|
||||
|
||||
## 7. Endpoints — Playback (kontinuierlich)
|
||||
|
||||
### `POST /active/play` ← `FPlay`
|
||||
```jsonc
|
||||
{ "mode": "run", "fromStart": false } // "run" = bis Ende/Stop; "step" = eine Zeile
|
||||
```
|
||||
Die appRobotFileservice liefert die Zeilen getaktet zurück bzw. meldet Fortschritt
|
||||
über den Event-Kanal (§8); **ausgeführt werden sie vom Driver**. `POST /active/stop`
|
||||
hält an.
|
||||
|
||||
> **„Nächste File"** (Playlist über mehrere Programme) baut darauf auf:
|
||||
> `POST /playlist/next` lädt das nächste Programm (`PUT /active`) und startet `play`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Optionaler Event-Kanal (WebSocket)
|
||||
|
||||
Für eine Live-UI der appRobotFileservice (Fortschritt) ohne Polling:
|
||||
```json
|
||||
{ "event": "cursorMoved", "cursor": 5, "line": "G90 G1 … a1.5708 …" }
|
||||
{ "event": "activeChanged", "programId": "demo_c", "lineCount": 12 }
|
||||
{ "event": "playStopped", "cursor": 9, "reason": "end" }
|
||||
```
|
||||
(Die *Roboter*-Pose-Updates laufen weiterhin über den Driver-WS-Broadcast — der
|
||||
Fileservice kennt die Pose nur, soweit der Driver sie beim `FPoint` mitgibt.)
|
||||
|
||||
---
|
||||
|
||||
## 9. Fehler-Envelope
|
||||
|
||||
Konsistent mit dem Driver (`doc/ToDo_5_API.md`): `{ type, code, message, input }`.
|
||||
Der Driver reicht Fileservice-Fehler unverändert an die Steuerung zurück.
|
||||
|
||||
| `code` | Bedeutung |
|
||||
|---|---|
|
||||
| `PROGRAM_NOT_FOUND` | `{id}` existiert nicht |
|
||||
| `INVALID_NAME` | unzulässiger Name (kein Pfad) |
|
||||
| `EMPTY_PROGRAM` | `FLoad` auf Programm ohne gültige Zeile |
|
||||
| `CURSOR_OUT_OF_RANGE` | `next`/`prev`/`goto` über die Grenzen |
|
||||
| `NO_ACTIVE_PROGRAM` | Aktion erfordert geladenes Programm |
|
||||
| `FILE_ERROR` | Storage-Fehler (`.gcode`/`.json`) |
|
||||
|
||||
```json
|
||||
{ "type": "error", "code": "PROGRAM_NOT_FOUND", "message": "no program 'demo_x'", "input": "demo_x" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Durchgereichte Payloads
|
||||
|
||||
`FShow`/`FList` können größere Antworten erzeugen, die der Driver nur durchreicht.
|
||||
Die appRobotFileservice hält sie **akzeptabel klein** (Paginierung, Übersichtsform),
|
||||
sodass der Weg über den Driver unkritisch bleibt.
|
||||
|
||||
---
|
||||
|
||||
## 11. Konfiguration
|
||||
|
||||
Die appRobotFileservice braucht **keinen** Driver-Zugang (kein `DRIVER_BASE_URL`).
|
||||
|
||||
| Variable | Zweck | Beispiel |
|
||||
|---|---|---|
|
||||
| `FILE_SERVICE_PORT` | Port | `2100` |
|
||||
| `STORAGE_DIR` | Verzeichnis für `.gcode` + `.json` | `./GCodeFiles` |
|
||||
| `FILE_EXT` | `gcode` oder `ngc` | `gcode` |
|
||||
| `STORE_ANGLE_UNIT` | Speichereinheit der Winkel | `deg` |
|
||||
| `FILE_API_KEY` | Bearer-Token (Schreiben) | — |
|
||||
|
||||
---
|
||||
|
||||
## 12. Beispiel-Flows (durch den Driver)
|
||||
|
||||
### Teaching-Session (Joystick → Aufnahme)
|
||||
```
|
||||
Steuerung → Driver: FLoad demo_c → Driver: PUT /active {id:"demo_c"}
|
||||
Steuerung → Driver: G1 …/$J= (Arm bewegen, lokal — Fileservice unbeteiligt)
|
||||
Steuerung → Driver: FPoint → Driver hängt Live-Pose an,
|
||||
POST /active/points { pose, feedrate }
|
||||
… weitere Punkte …
|
||||
Steuerung → Driver: FSave "Demo C" → Driver: POST /programs {name,fromActive:true}
|
||||
→ demo_c.gcode + demo_c.json
|
||||
```
|
||||
|
||||
### Playback-Session (Datei → Roboter, schrittweise)
|
||||
```
|
||||
Steuerung → Driver: FList → GET /programs (Auswahl)
|
||||
Steuerung → Driver: FLoad demo_c → PUT /active
|
||||
Steuerung → Driver: FFirst → POST /active/first → {line (Radian)}
|
||||
Driver: receiveGCode(line) → Bewegung
|
||||
Driver: Pose-Broadcast an alle UIs
|
||||
Steuerung → Driver: FPlus … / FPlay
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Verweise
|
||||
- [`draft_filehandeling.md`](draft_filehandeling.md) — Konzept, Gateway-Rolle, Einheiten, Storage
|
||||
- [`API.md`](API.md) — bestehende Driver-Endpunkte (`/api/position`, WS `:2095`)
|
||||
- [`ToDo_6b_FileHandling.md`](ToDo_6b_FileHandling.md) — gelöste Detailprobleme
|
||||
10
index.js
Normal file
10
index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// Einstiegspunkt: startet den HTTP-Service.
|
||||
const { createApp } = require('./src/server');
|
||||
const cfg = require('./src/config');
|
||||
|
||||
const app = createApp();
|
||||
app.listen(cfg.port, () => {
|
||||
console.log(
|
||||
`appRobotFileservice läuft auf http://localhost:${cfg.port} (Storage: ${cfg.storageDir})`
|
||||
);
|
||||
});
|
||||
4491
package-lock.json
generated
Normal file
4491
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "approbotfileservice",
|
||||
"version": "0.1.0",
|
||||
"description": "Programm-/File-Handling-Service für den AppRobot: speichert G-Code-Programme (.gcode + .json), hält das aktive Programm + Cursor und unterstützt Teaching/Playback. Wird vom appRobotDriver als Gateway angesprochen (FCodes).",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"author": "Ch Kendel",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0"
|
||||
}
|
||||
}
|
||||
220
src/active/activeState.js
Normal file
220
src/active/activeState.js
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Aktives Programm + Cursor — Single Source of Truth (doc/draft_filehandeling.md §9).
|
||||
*
|
||||
* Der Cursor lebt zur Laufzeit als In-Memory-Index (schnelles Stepping ohne
|
||||
* Datei-Neuschreiben). Beim Laden wird er aus dem '!'-Kommentar gelesen, beim
|
||||
* Speichern/Entladen als '!' in die Cursor-Zeile zurückgeschrieben.
|
||||
*
|
||||
* Inhaltliche Änderungen (FPoint, Editieren) werden direkt persistiert (durables
|
||||
* Teaching); reine Cursor-Bewegungen NICHT.
|
||||
*/
|
||||
const store = require('../store/fileStore');
|
||||
const units = require('../gcode/units');
|
||||
const { ApiError } = require('../errors');
|
||||
|
||||
class ActiveState {
|
||||
constructor() {
|
||||
this.programId = null;
|
||||
this.name = null;
|
||||
// gespeicherte Zeilen (Grad, mit ;<epoch>-Kommentar, OHNE '!' — Cursor separat)
|
||||
this.lines = [];
|
||||
this.cursor = 0;
|
||||
this.playing = false;
|
||||
this.version = 0;
|
||||
}
|
||||
|
||||
_touch() {
|
||||
this.version += 1;
|
||||
}
|
||||
|
||||
_requireActive() {
|
||||
if (!this.programId) throw new ApiError(409, 'NO_ACTIVE_PROGRAM', 'no active program');
|
||||
}
|
||||
|
||||
/** API-Repräsentation (ActiveState). currentLine = driver-nativ (Radian). */
|
||||
getState() {
|
||||
const currentLine = this.lines.length ? units.toExecutable(this.lines[this.cursor]) : null;
|
||||
return {
|
||||
programId: this.programId,
|
||||
cursor: this.cursor,
|
||||
lineCount: this.lines.length,
|
||||
currentLine,
|
||||
playing: this.playing,
|
||||
version: this.version,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt ein Programm aktiv (FLoad). Existiert es nicht, wird es leer angelegt
|
||||
* (nötig für Teaching). Ein vorher aktives Programm wird zuvor persistiert.
|
||||
*/
|
||||
async load(id, name) {
|
||||
store.assertValidId(id);
|
||||
await this._persistIfActive();
|
||||
|
||||
if (!(await store.exists(id))) {
|
||||
await store.write(id, { name: name || id, lines: [] });
|
||||
this.programId = id;
|
||||
this.name = name || id;
|
||||
this.lines = [];
|
||||
this.cursor = 0;
|
||||
this.playing = false;
|
||||
this._touch();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
const prog = await store.read(id);
|
||||
let cursor = 0;
|
||||
const lines = prog.lines.map((line, i) => {
|
||||
if (units.hasCursorMarker(line)) cursor = i;
|
||||
return units.removeCursorMarker(line);
|
||||
});
|
||||
this.programId = id;
|
||||
this.name = prog.name;
|
||||
this.lines = lines;
|
||||
this.cursor = Math.min(cursor, Math.max(0, lines.length - 1));
|
||||
this.playing = false;
|
||||
this._touch();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
/** Leert das aktive Programm (FClear). */
|
||||
async clear() {
|
||||
this._requireActive();
|
||||
this.lines = [];
|
||||
this.cursor = 0;
|
||||
this.playing = false;
|
||||
this._touch();
|
||||
await this._persist();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
// ---- Stepping (reine Cursor-Bewegung, gibt die ausführbare Zeile zurück) ----
|
||||
|
||||
_gotoIndex(index) {
|
||||
this._requireActive();
|
||||
if (!this.lines.length) throw new ApiError(409, 'EMPTY_PROGRAM', 'active program is empty');
|
||||
if (index < 0 || index >= this.lines.length) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
'CURSOR_OUT_OF_RANGE',
|
||||
`index ${index} out of range 0..${this.lines.length - 1}`
|
||||
);
|
||||
}
|
||||
this.cursor = index;
|
||||
this._touch();
|
||||
return { cursor: this.cursor, line: units.toExecutable(this.lines[this.cursor]) };
|
||||
}
|
||||
|
||||
next() { return this._gotoIndex(this.cursor + 1); }
|
||||
prev() { return this._gotoIndex(this.cursor - 1); }
|
||||
first() { return this._gotoIndex(0); }
|
||||
last() { return this._gotoIndex(this.lines.length - 1); }
|
||||
goto(index) { return this._gotoIndex(Number(index)); }
|
||||
|
||||
// ---- Teaching / Editieren (persistiert) ----
|
||||
|
||||
/** Hängt die aktuelle Pose als G-Code-Zeile an (FPoint). pose: a/b/c/e in RADIAN. */
|
||||
async appendPoint(pose, feedrate) {
|
||||
this._requireActive();
|
||||
if (!pose) throw new ApiError(400, 'FILE_ERROR', 'pose required');
|
||||
const line = units.formatPointLine(pose, feedrate);
|
||||
this.lines.push(line);
|
||||
this.cursor = this.lines.length - 1;
|
||||
this._touch();
|
||||
await this._persist();
|
||||
return { index: this.cursor, line };
|
||||
}
|
||||
|
||||
/** Hängt eine rohe Zeile an oder fügt sie an atIndex ein. */
|
||||
async appendLine(line, atIndex) {
|
||||
this._requireActive();
|
||||
if (!line) throw new ApiError(400, 'FILE_ERROR', 'line required');
|
||||
const clean = units.removeCursorMarker(String(line));
|
||||
if (atIndex == null) {
|
||||
this.lines.push(clean);
|
||||
this.cursor = this.lines.length - 1;
|
||||
} else {
|
||||
const i = Math.max(0, Math.min(Number(atIndex), this.lines.length));
|
||||
this.lines.splice(i, 0, clean);
|
||||
this.cursor = i;
|
||||
}
|
||||
this._touch();
|
||||
await this._persist();
|
||||
return { index: this.cursor, line: clean };
|
||||
}
|
||||
|
||||
async replaceLine(index, line) {
|
||||
this._requireActive();
|
||||
const i = Number(index);
|
||||
if (i < 0 || i >= this.lines.length) {
|
||||
throw new ApiError(409, 'CURSOR_OUT_OF_RANGE', `index ${i} out of range`);
|
||||
}
|
||||
this.lines[i] = units.removeCursorMarker(String(line));
|
||||
this._touch();
|
||||
await this._persist();
|
||||
return { index: i, line: this.lines[i] };
|
||||
}
|
||||
|
||||
async deleteLine(index) {
|
||||
this._requireActive();
|
||||
const i = Number(index);
|
||||
if (i < 0 || i >= this.lines.length) {
|
||||
throw new ApiError(409, 'CURSOR_OUT_OF_RANGE', `index ${i} out of range`);
|
||||
}
|
||||
this.lines.splice(i, 1);
|
||||
if (this.cursor >= this.lines.length) this.cursor = Math.max(0, this.lines.length - 1);
|
||||
this._touch();
|
||||
await this._persist();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
// ---- Playback (passiv: der Driver führt die Zeilen aus) ----
|
||||
|
||||
/** Liefert die ausführbaren Zeilen ab Cursor (bzw. ab 0). Setzt playing. */
|
||||
play({ mode = 'run', fromStart = false } = {}) {
|
||||
this._requireActive();
|
||||
if (!this.lines.length) throw new ApiError(409, 'EMPTY_PROGRAM', 'active program is empty');
|
||||
if (fromStart) this.cursor = 0;
|
||||
this.playing = true;
|
||||
this._touch();
|
||||
if (mode === 'step') {
|
||||
return { mode, cursor: this.cursor, lines: [units.toExecutable(this.lines[this.cursor])] };
|
||||
}
|
||||
return { mode, cursor: this.cursor, lines: this.lines.slice(this.cursor).map(units.toExecutable) };
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.playing = false;
|
||||
this._touch();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
// ---- Speichern ----
|
||||
|
||||
/** Speichert den aktiven Puffer unter einem (neuen) Namen (FSave). */
|
||||
async saveAs(name) {
|
||||
this._requireActive();
|
||||
const id = store.slugify(name);
|
||||
if (!id) throw new ApiError(400, 'INVALID_NAME', `invalid name: ${name}`);
|
||||
const meta = await store.write(id, { name, lines: this._linesWithCursor() });
|
||||
return { id: meta.id, lineCount: meta.lineCount };
|
||||
}
|
||||
|
||||
/** In-Memory-Zeilen mit '!' an der Cursor-Position (für die Persistenz). */
|
||||
_linesWithCursor() {
|
||||
return this.lines.map((line, i) => (i === this.cursor ? units.addCursorMarker(line) : line));
|
||||
}
|
||||
|
||||
async _persist() {
|
||||
if (!this.programId) return;
|
||||
await store.write(this.programId, { name: this.name, lines: this._linesWithCursor() });
|
||||
}
|
||||
|
||||
async _persistIfActive() {
|
||||
if (this.programId) await this._persist();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton — gemeinsamer Zustand für alle Anfragen (Single Source of Truth).
|
||||
module.exports = { ActiveState, active: new ActiveState() };
|
||||
15
src/auth.js
Normal file
15
src/auth.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const cfg = require('./config');
|
||||
const { envelope } = require('./errors');
|
||||
|
||||
/**
|
||||
* Bearer-Auth für schreibende Endpoints. Ohne gesetzten FILE_API_KEY offen (Dev).
|
||||
* Anlehnung an ROBOT_API_KEY im Driver.
|
||||
*/
|
||||
function requireAuth(req, res, next) {
|
||||
if (!cfg.apiKey) return next();
|
||||
const header = req.get('authorization') || '';
|
||||
if (header === `Bearer ${cfg.apiKey}`) return next();
|
||||
return res.status(401).json(envelope('UNAUTHORIZED', 'invalid or missing bearer token'));
|
||||
}
|
||||
|
||||
module.exports = requireAuth;
|
||||
17
src/config.js
Normal file
17
src/config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// Zentrale Konfiguration aus Umgebungsvariablen (mit sinnvollen Defaults).
|
||||
// Die appRobotFileservice ist passiv und braucht KEINEN Driver-Zugang.
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
// Port des HTTP-Service.
|
||||
port: Number(process.env.FILE_SERVICE_PORT) || 2100,
|
||||
// Verzeichnis für die Programm-Dateien (.gcode) + Sidecars (.json).
|
||||
storageDir: process.env.STORAGE_DIR || path.join(__dirname, '..', 'GCodeFiles'),
|
||||
// Datei-Endung der Programme: 'gcode' (Default) oder 'ngc'.
|
||||
fileExt: (process.env.FILE_EXT || 'gcode').replace(/^\./, ''),
|
||||
// Einheit, in der Winkel gespeichert werden (standardnahe .gcode → Grad).
|
||||
storeAngleUnit: process.env.STORE_ANGLE_UNIT || 'deg',
|
||||
// Optionaler Bearer-Token für schreibende Endpoints.
|
||||
// Fehlt er, sind Schreibzugriffe offen (Dev-Modus).
|
||||
apiKey: process.env.FILE_API_KEY || null,
|
||||
};
|
||||
32
src/errors.js
Normal file
32
src/errors.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// Fehler-Modell — Envelope konsistent mit dem Driver (doc/ToDo_5_API.md):
|
||||
// { type: 'error', code, message, input }
|
||||
|
||||
/** Fehler mit HTTP-Status + maschinenlesbarem Code. */
|
||||
class ApiError extends Error {
|
||||
constructor(status, code, message) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Baut den maschinenlesbaren Fehler-Envelope. */
|
||||
function envelope(code, message, input = null) {
|
||||
return { type: 'error', code, message, input };
|
||||
}
|
||||
|
||||
/** Express-Fehler-Middleware. */
|
||||
function errorMiddleware(err, req, res, _next) {
|
||||
if (err instanceof ApiError) {
|
||||
return res.status(err.status).json(envelope(err.code, err.message));
|
||||
}
|
||||
// Ungültiger JSON-Body (vom express.json-Parser)
|
||||
if (err && err.type === 'entity.parse.failed') {
|
||||
return res.status(400).json(envelope('FILE_ERROR', 'invalid JSON body'));
|
||||
}
|
||||
console.error(err);
|
||||
return res.status(500).json(envelope('FILE_ERROR', err.message || 'internal error'));
|
||||
}
|
||||
|
||||
module.exports = { ApiError, envelope, errorMiddleware };
|
||||
110
src/gcode/units.js
Normal file
110
src/gcode/units.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* G-Code-Einheiten & Zeilenformat der appRobotFileservice.
|
||||
*
|
||||
* Vertrag (siehe doc/draft_filehandeling.md §7):
|
||||
* - GESPEICHERT wird in GRAD (standardnahe .gcode): a/b/c/e in Grad, x/y/z in mm.
|
||||
* - Der DRIVER erwartet am Eingang RADIAN für a/b/c/e.
|
||||
* - Diese Umrechnung passiert AUSSCHLIESSLICH hier (an der Storage-Grenze);
|
||||
* der Driver rechnet nie um.
|
||||
*
|
||||
* Zeitstempel und Cursor liegen im G-Code-Kommentarfeld (standardkonform):
|
||||
* - jede Zeile endet mit ';<epoch>'
|
||||
* - die Cursor-Zeile zusätzlich mit '!': ';<epoch>!'
|
||||
*/
|
||||
|
||||
// Achsen, die Winkel/Greifer sind und Grad↔Radian umgerechnet werden.
|
||||
const ANGLE_AXES = ['a', 'b', 'c', 'e'];
|
||||
|
||||
const degToRad = (deg) => (deg * Math.PI) / 180;
|
||||
const radToDeg = (rad) => (rad * 180) / Math.PI;
|
||||
|
||||
const isNumeric = (s) => s !== '' && s != null && !Number.isNaN(Number(s));
|
||||
|
||||
// Zahl ohne überflüssige Nachkommastellen (z. B. 90.000000 → 90, 1.500 → 1.5).
|
||||
const trimNum = (v, digits = 6) => String(Number(Number(v).toFixed(digits)));
|
||||
|
||||
/**
|
||||
* Zerlegt eine gespeicherte Zeile in Code-Teil, Kommentar und Cursor-Flag.
|
||||
* @returns {{code:string, comment:string, cursor:boolean}}
|
||||
*/
|
||||
function splitComment(line) {
|
||||
const s = String(line);
|
||||
const idx = s.indexOf(';');
|
||||
if (idx === -1) return { code: s.trim(), comment: '', cursor: false };
|
||||
const code = s.slice(0, idx).trim();
|
||||
let comment = s.slice(idx + 1).trim();
|
||||
const cursor = comment.endsWith('!');
|
||||
if (cursor) comment = comment.slice(0, -1).trim();
|
||||
return { code, comment, cursor };
|
||||
}
|
||||
|
||||
/** Baut eine gespeicherte Zeile aus Code-Teil, Kommentar und Cursor-Flag. */
|
||||
function buildLine(code, comment = '', cursor = false) {
|
||||
let out = String(code).trim();
|
||||
if (comment || cursor) out += ` ;${comment}${cursor ? '!' : ''}`;
|
||||
return out;
|
||||
}
|
||||
|
||||
const hasCursorMarker = (line) => splitComment(line).cursor;
|
||||
|
||||
const addCursorMarker = (line) => {
|
||||
const { code, comment } = splitComment(line);
|
||||
return buildLine(code, comment, true);
|
||||
};
|
||||
|
||||
const removeCursorMarker = (line) => {
|
||||
const { code, comment } = splitComment(line);
|
||||
return buildLine(code, comment, false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wandelt eine gespeicherte Zeile (Grad, mit Kommentar) in eine driver-native,
|
||||
* ausführbare Zeile um (Radian, ohne Kommentar).
|
||||
*/
|
||||
function toExecutable(storedLine) {
|
||||
const { code } = splitComment(storedLine);
|
||||
return code
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((tok) => {
|
||||
const axis = tok[0].toLowerCase();
|
||||
const rest = tok.slice(1);
|
||||
if (ANGLE_AXES.includes(axis) && isNumeric(rest)) {
|
||||
return axis + trimNum(degToRad(Number(rest)));
|
||||
}
|
||||
return tok;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut eine zu speichernde G-Code-Zeile (Grad + Zeitstempel-Kommentar) aus einer
|
||||
* Driver-Pose (Radian).
|
||||
* @param {{x:number,y:number,z:number,a:number,b:number,c:number,e:number}} pose a/b/c/e in RADIAN
|
||||
* @param {number} feedrate
|
||||
* @param {number} epoch Zeitstempel (ms seit Epoch)
|
||||
*/
|
||||
function formatPointLine(pose, feedrate = 1000, epoch = Date.now()) {
|
||||
const deg = (r) => radToDeg(Number(r) || 0).toFixed(2);
|
||||
const mm = (v) => trimNum(Number(v) || 0, 3);
|
||||
const code = [
|
||||
'G90', 'G1',
|
||||
`x${mm(pose.x)}`, `y${mm(pose.y)}`, `z${mm(pose.z)}`,
|
||||
`a${deg(pose.a)}`, `b${deg(pose.b)}`, `c${deg(pose.c)}`, `e${deg(pose.e)}`,
|
||||
`f${trimNum(Number(feedrate) || 1000, 3)}`,
|
||||
].join(' ');
|
||||
return buildLine(code, String(Math.floor(epoch)), false);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ANGLE_AXES,
|
||||
degToRad,
|
||||
radToDeg,
|
||||
splitComment,
|
||||
buildLine,
|
||||
hasCursorMarker,
|
||||
addCursorMarker,
|
||||
removeCursorMarker,
|
||||
toExecutable,
|
||||
formatPointLine,
|
||||
};
|
||||
72
src/routes/active.js
Normal file
72
src/routes/active.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// Aktives Programm + Cursor (FLoad/FClear/FPlus/FMinus/FFirst/FLast/FGoto/
|
||||
// FPoint/FPlay/FStop). Die zurückgegebenen Playback-Zeilen sind driver-nativ
|
||||
// (Radian) — ausgeführt werden sie vom Driver.
|
||||
const express = require('express');
|
||||
const { active } = require('../active/activeState');
|
||||
const requireAuth = require('../auth');
|
||||
const { ApiError } = require('../errors');
|
||||
|
||||
const router = express.Router();
|
||||
const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
|
||||
// GET /api/active
|
||||
router.get('/', (req, res) => res.json(active.getState()));
|
||||
|
||||
// PUT /api/active (FLoad) — existiert nicht → leer anlegen (für Teaching)
|
||||
router.put(
|
||||
'/',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
const { id, name } = req.body || {};
|
||||
if (!id) throw new ApiError(400, 'INVALID_NAME', 'id required');
|
||||
res.json(await active.load(id, name));
|
||||
})
|
||||
);
|
||||
|
||||
// POST /api/active/clear (FClear)
|
||||
router.post('/clear', requireAuth, asyncH(async (req, res) => res.json(await active.clear())));
|
||||
|
||||
// Stepping (synchron; ApiError wird von Express an die Fehler-Middleware gereicht)
|
||||
router.post('/next', requireAuth, (req, res) => res.json(active.next()));
|
||||
router.post('/prev', requireAuth, (req, res) => res.json(active.prev()));
|
||||
router.post('/first', requireAuth, (req, res) => res.json(active.first()));
|
||||
router.post('/last', requireAuth, (req, res) => res.json(active.last()));
|
||||
router.post('/goto', requireAuth, (req, res) => res.json(active.goto((req.body || {}).index)));
|
||||
|
||||
// Teaching / Editieren
|
||||
router.post(
|
||||
'/points',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
const { pose, feedrate } = req.body || {};
|
||||
res.status(201).json(await active.appendPoint(pose, feedrate));
|
||||
})
|
||||
);
|
||||
router.post(
|
||||
'/lines',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
const { line, atIndex } = req.body || {};
|
||||
res.status(201).json(await active.appendLine(line, atIndex));
|
||||
})
|
||||
);
|
||||
router.put(
|
||||
'/lines/:index',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
res.json(await active.replaceLine(req.params.index, (req.body || {}).line));
|
||||
})
|
||||
);
|
||||
router.delete(
|
||||
'/lines/:index',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
res.json(await active.deleteLine(req.params.index));
|
||||
})
|
||||
);
|
||||
|
||||
// Playback
|
||||
router.post('/play', requireAuth, (req, res) => res.json(active.play(req.body || {})));
|
||||
router.post('/stop', requireAuth, (req, res) => res.json(active.stop()));
|
||||
|
||||
module.exports = router;
|
||||
75
src/routes/programs.js
Normal file
75
src/routes/programs.js
Normal file
@@ -0,0 +1,75 @@
|
||||
// Programm-Verwaltung (FList/FShow/FSave/…). Storage-agnostisch über id/Name.
|
||||
const express = require('express');
|
||||
const store = require('../store/fileStore');
|
||||
const { active } = require('../active/activeState');
|
||||
const requireAuth = require('../auth');
|
||||
const { ApiError } = require('../errors');
|
||||
|
||||
const router = express.Router();
|
||||
const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
|
||||
// GET /api/programs (FList)
|
||||
router.get(
|
||||
'/',
|
||||
asyncH(async (req, res) => {
|
||||
res.json({ programs: await store.list() });
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/programs/:id (FShow) — Inhalt in Grad, wie gespeichert.
|
||||
router.get(
|
||||
'/:id',
|
||||
asyncH(async (req, res) => {
|
||||
const prog = await store.read(req.params.id);
|
||||
res.json({
|
||||
id: prog.id,
|
||||
name: prog.name,
|
||||
displayUnit: prog.meta.angleUnit || 'deg',
|
||||
lines: prog.lines,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// POST /api/programs (FSave) — aus aktivem Puffer ODER expliziter Inhalt.
|
||||
router.post(
|
||||
'/',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
const { name, fromActive, lines } = req.body || {};
|
||||
if (!name) throw new ApiError(400, 'INVALID_NAME', 'name required');
|
||||
if (fromActive) {
|
||||
return res.status(201).json(await active.saveAs(name));
|
||||
}
|
||||
const id = store.slugify(name);
|
||||
if (!id) throw new ApiError(400, 'INVALID_NAME', `invalid name: ${name}`);
|
||||
const meta = await store.write(id, { name, lines: Array.isArray(lines) ? lines : [] });
|
||||
res.status(201).json({ id: meta.id, lineCount: meta.lineCount });
|
||||
})
|
||||
);
|
||||
|
||||
// PUT /api/programs/:id — Inhalt ersetzen / umbenennen.
|
||||
router.put(
|
||||
'/:id',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
const { name, lines } = req.body || {};
|
||||
const existing = await store.read(req.params.id); // 404, falls nicht vorhanden
|
||||
const meta = await store.write(req.params.id, {
|
||||
name: name || existing.name,
|
||||
lines: Array.isArray(lines) ? lines : existing.lines,
|
||||
});
|
||||
res.json({ id: meta.id, lineCount: meta.lineCount });
|
||||
})
|
||||
);
|
||||
|
||||
// DELETE /api/programs/:id
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
await store.remove(req.params.id);
|
||||
res.status(204).end();
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
21
src/server.js
Normal file
21
src/server.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// Express-App der appRobotFileservice. createApp() ist test-freundlich (kein listen).
|
||||
const express = require('express');
|
||||
const programsRouter = require('./routes/programs');
|
||||
const activeRouter = require('./routes/active');
|
||||
const { errorMiddleware, envelope } = require('./errors');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
|
||||
app.get('/api/health', (req, res) => res.json({ ok: true, service: 'appRobotFileservice' }));
|
||||
app.use('/api/programs', programsRouter);
|
||||
app.use('/api/active', activeRouter);
|
||||
|
||||
// Unbekannter Pfad → 404-Envelope
|
||||
app.use((req, res) => res.status(404).json(envelope('NOT_FOUND', 'unknown endpoint', req.path)));
|
||||
app.use(errorMiddleware);
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = { createApp };
|
||||
139
src/store/fileStore.js
Normal file
139
src/store/fileStore.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Datei-basierte Persistenz der Programme:
|
||||
* <id>.<ext> — G-Code (Grad), standardnah, Zeitstempel/Cursor im Kommentar
|
||||
* <id>.json — Sidecar mit Metadaten (Name, Zeiten, lineCount, angleUnit)
|
||||
*
|
||||
* Nach außen werden Programme NUR über die id angesprochen — niemals über Pfade.
|
||||
* Storage-Details bleiben hier gekapselt (Konzept §8/§10).
|
||||
*/
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const cfg = require('../config');
|
||||
const { ApiError } = require('../errors');
|
||||
|
||||
const ID_RE = /^[a-z0-9_]+$/;
|
||||
|
||||
/** Wandelt einen Anzeigenamen in eine sichere id (keine Pfade, kein '../'). */
|
||||
function slugify(name) {
|
||||
return String(name || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
/** Stellt sicher, dass eine id keine Pfad-Trenner o. Ä. enthält. */
|
||||
function assertValidId(id) {
|
||||
if (!ID_RE.test(String(id || ''))) {
|
||||
throw new ApiError(400, 'INVALID_NAME', `invalid program id: ${id}`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
const gcodePath = (id) => path.join(cfg.storageDir, `${id}.${cfg.fileExt}`);
|
||||
const jsonPath = (id) => path.join(cfg.storageDir, `${id}.json`);
|
||||
|
||||
async function ensureDir() {
|
||||
await fsp.mkdir(cfg.storageDir, { recursive: true });
|
||||
}
|
||||
|
||||
async function exists(id) {
|
||||
try {
|
||||
await fsp.access(gcodePath(id));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Zerlegt Datei-Text in nicht-leere Zeilen (CR/LF-tolerant). */
|
||||
function splitLines(text) {
|
||||
return String(text)
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/\s+$/, ''))
|
||||
.filter((l) => l.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Liest ein Programm: { id, name, lines (Grad, mit Kommentaren), meta }. */
|
||||
async function read(id) {
|
||||
assertValidId(id);
|
||||
if (!(await exists(id))) {
|
||||
throw new ApiError(404, 'PROGRAM_NOT_FOUND', `no program '${id}'`);
|
||||
}
|
||||
const text = await fsp.readFile(gcodePath(id), 'utf8');
|
||||
const lines = splitLines(text);
|
||||
let meta = {};
|
||||
try {
|
||||
meta = JSON.parse(await fsp.readFile(jsonPath(id), 'utf8'));
|
||||
} catch {
|
||||
/* Sidecar ist optional */
|
||||
}
|
||||
return { id, name: meta.name || id, lines, meta };
|
||||
}
|
||||
|
||||
/** Schreibt .gcode + .json. lines = gespeicherte Zeilen (Grad, inkl. Kommentar/Cursor). */
|
||||
async function write(id, { name, lines }) {
|
||||
assertValidId(id);
|
||||
await ensureDir();
|
||||
const now = new Date().toISOString();
|
||||
let createdAt = now;
|
||||
try {
|
||||
const prev = JSON.parse(await fsp.readFile(jsonPath(id), 'utf8'));
|
||||
createdAt = prev.createdAt || now;
|
||||
} catch {
|
||||
/* neues Programm */
|
||||
}
|
||||
const meta = {
|
||||
id,
|
||||
name: name || id,
|
||||
lineCount: lines.length,
|
||||
angleUnit: cfg.storeAngleUnit,
|
||||
createdAt,
|
||||
updatedAt: now,
|
||||
};
|
||||
const body = lines.join('\n') + (lines.length ? '\n' : '');
|
||||
await fsp.writeFile(gcodePath(id), body, 'utf8');
|
||||
await fsp.writeFile(jsonPath(id), JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
||||
return meta;
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
assertValidId(id);
|
||||
if (!(await exists(id))) {
|
||||
throw new ApiError(404, 'PROGRAM_NOT_FOUND', `no program '${id}'`);
|
||||
}
|
||||
await fsp.rm(gcodePath(id), { force: true });
|
||||
await fsp.rm(jsonPath(id), { force: true });
|
||||
}
|
||||
|
||||
/** Liste aller Programme (id, name, lineCount). */
|
||||
async function list() {
|
||||
await ensureDir();
|
||||
const entries = await fsp.readdir(cfg.storageDir);
|
||||
const ext = `.${cfg.fileExt}`;
|
||||
const ids = entries.filter((f) => f.endsWith(ext)).map((f) => f.slice(0, -ext.length));
|
||||
const out = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const { name, lines, meta } = await read(id);
|
||||
out.push({ id, name, lineCount: meta.lineCount ?? lines.length });
|
||||
} catch {
|
||||
/* defekte Einträge überspringen */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
slugify,
|
||||
assertValidId,
|
||||
exists,
|
||||
read,
|
||||
write,
|
||||
remove,
|
||||
list,
|
||||
ensureDir,
|
||||
gcodePath,
|
||||
jsonPath,
|
||||
};
|
||||
80
test/activeState.test.js
Normal file
80
test/activeState.test.js
Normal file
@@ -0,0 +1,80 @@
|
||||
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 { ActiveState } = require('../src/active/activeState');
|
||||
|
||||
let tmp;
|
||||
beforeEach(async () => {
|
||||
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'fsvc-act-'));
|
||||
cfg.storageDir = tmp;
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('Teaching: load(leer) → appendPoint → reload findet Zeile + Cursor', async () => {
|
||||
const a = new ActiveState();
|
||||
await a.load('teach_1'); // existiert nicht → leer angelegt
|
||||
expect(a.getState().lineCount).toBe(0);
|
||||
|
||||
const pose = { x: 0, y: 300, z: 0, a: Math.PI / 2, b: -Math.PI / 2, c: 0, e: 0 };
|
||||
const r = await a.appendPoint(pose, 1000);
|
||||
expect(r.index).toBe(0);
|
||||
expect(r.line).toContain('a90.00'); // in Grad gespeichert
|
||||
|
||||
const b = new ActiveState();
|
||||
await b.load('teach_1');
|
||||
expect(b.getState().lineCount).toBe(1);
|
||||
expect(b.getState().cursor).toBe(0);
|
||||
});
|
||||
|
||||
test('Playback: stepping liefert driver-native (Radian) Zeilen, Grenzen werfen', async () => {
|
||||
await store.write('play_1', {
|
||||
name: 'Play 1',
|
||||
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('play_1');
|
||||
|
||||
const first = a.first();
|
||||
expect(first.cursor).toBe(0);
|
||||
expect(first.line).not.toMatch(/;/);
|
||||
const aVal = Number(first.line.split(/\s+/).find((t) => t.startsWith('a')).slice(1));
|
||||
expect(aVal).toBeCloseTo(Math.PI / 2, 4);
|
||||
|
||||
expect(a.next().cursor).toBe(1);
|
||||
expect(() => a.next()).toThrow(); // über das Ende → CURSOR_OUT_OF_RANGE
|
||||
});
|
||||
|
||||
test('Cursor wird beim Speichern als !-Kommentar abgelegt (genau eine Zeile)', async () => {
|
||||
await store.write('cur_1', {
|
||||
name: 'Cur',
|
||||
lines: [
|
||||
'G90 G1 x0 y0 z0 a0 b0 c0 e0 f1000 ;1',
|
||||
'G90 G1 x1 y0 z0 a0 b0 c0 e0 f1000 ;2',
|
||||
],
|
||||
});
|
||||
const a = new ActiveState();
|
||||
await a.load('cur_1');
|
||||
a.next(); // cursor → 1 (kein Persist)
|
||||
await a.appendLine('G4 P0.1'); // persistiert, cursor → 2
|
||||
|
||||
const prog = await store.read('cur_1');
|
||||
const marked = prog.lines.filter(units.hasCursorMarker);
|
||||
expect(marked).toHaveLength(1);
|
||||
expect(units.splitComment(marked[0]).code).toBe('G4 P0.1');
|
||||
});
|
||||
|
||||
test('Aktion ohne aktives Programm → NO_ACTIVE_PROGRAM', async () => {
|
||||
const a = new ActiveState();
|
||||
expect(() => a.next()).toThrow(); // NO_ACTIVE_PROGRAM
|
||||
await expect(a.appendPoint({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, e: 0 })).rejects.toMatchObject({
|
||||
code: 'NO_ACTIVE_PROGRAM',
|
||||
});
|
||||
});
|
||||
50
test/fileStore.test.js
Normal file
50
test/fileStore.test.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const cfg = require('../src/config');
|
||||
const store = require('../src/store/fileStore');
|
||||
|
||||
let tmp;
|
||||
beforeAll(async () => {
|
||||
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'fsvc-store-'));
|
||||
cfg.storageDir = tmp; // Storage-Verzeichnis für den Test umbiegen
|
||||
});
|
||||
afterAll(async () => {
|
||||
await fsp.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('write + read + list + remove', async () => {
|
||||
await store.write('demo_a', {
|
||||
name: 'Demo A',
|
||||
lines: ['G90 G1 x0 y0 z0 a0 b0 c0 e0 f1000 ;1'],
|
||||
});
|
||||
expect(await store.exists('demo_a')).toBe(true);
|
||||
|
||||
const prog = await store.read('demo_a');
|
||||
expect(prog.name).toBe('Demo A');
|
||||
expect(prog.lines).toHaveLength(1);
|
||||
expect(prog.meta.angleUnit).toBe('deg');
|
||||
expect(prog.meta.createdAt).toBeTruthy();
|
||||
|
||||
const ls = await store.list();
|
||||
expect(ls.find((p) => p.id === 'demo_a')).toBeTruthy();
|
||||
|
||||
await store.remove('demo_a');
|
||||
expect(await store.exists('demo_a')).toBe(false);
|
||||
});
|
||||
|
||||
test('read von unbekanntem Programm → PROGRAM_NOT_FOUND', async () => {
|
||||
await expect(store.read('gibtsnicht')).rejects.toMatchObject({ code: 'PROGRAM_NOT_FOUND' });
|
||||
});
|
||||
|
||||
test('slugify entfernt Pfade & Sonderzeichen', () => {
|
||||
expect(store.slugify('../etc/passwd')).toBe('etc_passwd');
|
||||
expect(store.slugify('Demo C!')).toBe('demo_c');
|
||||
expect(store.slugify(' Mehr Worte ')).toBe('mehr_worte');
|
||||
});
|
||||
|
||||
test('assertValidId lehnt Pfad-Trenner ab', () => {
|
||||
expect(() => store.assertValidId('a/b')).toThrow();
|
||||
expect(() => store.assertValidId('..')).toThrow();
|
||||
expect(store.assertValidId('ok_123')).toBe('ok_123');
|
||||
});
|
||||
51
test/units.test.js
Normal file
51
test/units.test.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const units = require('../src/gcode/units');
|
||||
|
||||
test('degToRad / radToDeg', () => {
|
||||
expect(units.degToRad(180)).toBeCloseTo(Math.PI, 10);
|
||||
expect(units.radToDeg(Math.PI)).toBeCloseTo(180, 10);
|
||||
});
|
||||
|
||||
test('toExecutable: a/b/c/e Grad→Radian, Kommentar entfernt, mm/Feedrate unverändert', () => {
|
||||
const stored = 'G90 G1 x10 y20 z0 a90.00 b-90.00 c0.00 e0.00 f1000 ;1759566014';
|
||||
const exec = units.toExecutable(stored);
|
||||
|
||||
expect(exec).not.toMatch(/;/); // kein Kommentar mehr
|
||||
expect(exec).toContain('x10'); // mm unverändert
|
||||
expect(exec).toContain('f1000'); // Feedrate unverändert
|
||||
expect(exec.startsWith('G90 G1')).toBe(true);
|
||||
|
||||
const val = (axis) => Number(exec.split(/\s+/).find((t) => t.startsWith(axis)).slice(1));
|
||||
expect(val('a')).toBeCloseTo(Math.PI / 2, 4);
|
||||
expect(val('b')).toBeCloseTo(-Math.PI / 2, 4);
|
||||
expect(val('c')).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
test('formatPointLine: Grad-Zeile mit Zeitstempel-Kommentar', () => {
|
||||
const pose = { x: 0, y: 300, z: 0, a: Math.PI / 2, b: -Math.PI / 2, c: 0, e: 0 };
|
||||
const line = units.formatPointLine(pose, 1000, 1759566014000);
|
||||
expect(line.startsWith('G90 G1 ')).toBe(true);
|
||||
expect(line).toContain('a90.00');
|
||||
expect(line).toContain('b-90.00');
|
||||
expect(line).toContain('f1000');
|
||||
expect(line).toContain(';1759566014000');
|
||||
});
|
||||
|
||||
test('Round-trip Pose → gespeichert (Grad) → ausführbar (Radian)', () => {
|
||||
const pose = { x: 5, y: 100, z: -3, a: 1, b: -0.5, c: 0.25, e: 0.1 };
|
||||
const exec = units.toExecutable(units.formatPointLine(pose, 800));
|
||||
const val = (axis) => Number(exec.split(/\s+/).find((t) => t.startsWith(axis)).slice(1));
|
||||
expect(val('a')).toBeCloseTo(1, 3);
|
||||
expect(val('b')).toBeCloseTo(-0.5, 3);
|
||||
expect(val('e')).toBeCloseTo(0.1, 3);
|
||||
});
|
||||
|
||||
test('Cursor-Marker hinzufügen/entfernen/erkennen', () => {
|
||||
const base = 'G90 G1 x0 y0 z0 a0 b0 c0 e0 f1000 ;123';
|
||||
const withCursor = units.addCursorMarker(base);
|
||||
expect(withCursor.endsWith('!')).toBe(true);
|
||||
expect(units.hasCursorMarker(withCursor)).toBe(true);
|
||||
|
||||
const without = units.removeCursorMarker(withCursor);
|
||||
expect(units.hasCursorMarker(without)).toBe(false);
|
||||
expect(without).toBe(base);
|
||||
});
|
||||
Reference in New Issue
Block a user