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:
chk
2026-06-14 10:12:41 +02:00
commit b68bdfa9b4
20 changed files with 6085 additions and 0 deletions

10
.gitignore vendored Normal file
View 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
View File

129
README.md Normal file
View 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
View 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)

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

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
});