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