Kleine Arbeiten

This commit is contained in:
chk
2026-06-14 10:32:31 +02:00
parent 87cbd51bd2
commit 319fae944a
25 changed files with 1631 additions and 504 deletions

View File

@@ -230,11 +230,13 @@ Architektur- und Refactoring-Aufgaben sind in `doc/ToDo_*.md` dokumentiert:
| `doc/ToDo_1_Parsing.md` | G-Code-Parser-Schicht einführen | ✅ erledigt | | `doc/ToDo_1_Parsing.md` | G-Code-Parser-Schicht einführen | ✅ erledigt |
| `doc/ToDo_2_Anbindung.md` | Sender-Interface und Orchestrierung | ✅ erledigt | | `doc/ToDo_2_Anbindung.md` | Sender-Interface und Orchestrierung | ✅ erledigt |
| `doc/ToDo_3_Config.md` | Zentralisierte Konfiguration | offen | | `doc/ToDo_3_Config.md` | Zentralisierte Konfiguration | offen |
| `doc/ToDo_4_GCode.md` | G-Code- und Datei-Handling trennen | offen | | `doc/ToDo_4_GCode.md` | G-Code- und Datei-Handling trennen | ✅ ausgelagert → `appRobotFileservice` |
| `doc/ToDo_5_API.md` | WebSocket-Antwortlogik strukturieren | ✅ erledigt | | `doc/ToDo_5_API.md` | WebSocket-Antwortlogik strukturieren | ✅ erledigt |
| `doc/ToDo_6_RobotController.md` | RobotController-Klasse einführen | ✅ erledigt | | `doc/ToDo_6_RobotController.md` | RobotController-Klasse einführen | ✅ erledigt |
| `doc/ToDo_6a_Speed.md` | Speed-Steuerung: Schalter, `calculateSpeeds()`-Fix, koordinierte Feedrate | ✅ erledigt (WS-Sender offen) | | `doc/ToDo_6a_Speed.md` | Speed-Steuerung: Schalter, `calculateSpeeds()`-Fix, koordinierte Feedrate | ✅ erledigt (WS-Sender offen) |
| `doc/ToDo_6b_FileHandling.md` | File-Handling: fehlende Befehle, Cursor im Speicher, Fehler-Feedback | offen | | `doc/ToDo_6b_FileHandling.md` | File-Handling: fehlende Befehle, Cursor im Speicher, Fehler-Feedback | ✅ ausgelagert → `appRobotFileservice` |
| `doc/draft_filehandeling.md` | File-Handling als externes Projekt `appRobotFileservice` (Driver als Gateway, FCode-Pass-through) | Entwurf |
| `doc/draft_filehandeling_API.md` | API der `appRobotFileservice` (Programme, aktiver Cursor, Teaching/Playback) | Entwurf |
| `doc/ToDo_7_Tests.md` | Testabdeckung und Stabilität | teilweise | | `doc/ToDo_7_Tests.md` | Testabdeckung und Stabilität | teilweise |
| `doc/ToDo_8_Bugs.md` | Bekannte konkrete Bugs | teilweise | | `doc/ToDo_8_Bugs.md` | Bekannte konkrete Bugs | teilweise |
| `doc/ToDo_9_HardwareFeedback.md` | Hardware-Feedback-Loop (GRBL-Antworten, Command-Queue, Positionsabgleich) | teilweise (Baustein Port→Motor ✅, Pakete 16 offen) | | `doc/ToDo_9_HardwareFeedback.md` | Hardware-Feedback-Loop (GRBL-Antworten, Command-Queue, Positionsabgleich) | teilweise (Baustein Port→Motor ✅, Pakete 16 offen) |
@@ -251,9 +253,9 @@ ToDo_8 Bugs beheben — kurz, blockiert nichts anderes
ToDo_3 Config — Fundament für alles Weitere ToDo_3 Config — Fundament für alles Weitere
ToDo_1 Parser ┐ ToDo_1 Parser ┐
ToDo_6 RobotController ┘ zusammen, da eng verzahnt ToDo_6 RobotController ┘ zusammen, da eng verzahnt
ToDo_4 Datei-Handling — danach, klar abgrenzbar ToDo_4 Datei-Handling — ausgelagert → appRobotFileservice (siehe drafts)
ToDo_6a Speed-Steuerung — calculateSpeeds bugfix, dann Sender-Integration ToDo_6a Speed-Steuerung — calculateSpeeds bugfix, dann Sender-Integration
ToDo_6b File-Handling Detail — fehlende F-Befehle, Cursor im Speicher ToDo_6b File-Handling Detail — ausgelagert → appRobotFileservice (siehe drafts)
ToDo_2 Sender-Interface — mit Entscheidung: Telnet vs. FluidNC-WebSocket ToDo_2 Sender-Interface — mit Entscheidung: Telnet vs. FluidNC-WebSocket
ToDo_9 Hardware-Feedback — baut auf ToDo_2 auf ToDo_9 Hardware-Feedback — baut auf ToDo_2 auf
ToDo_10 Verbindungsverlust — baut auf ToDo_2 auf, parallel zu ToDo_9 möglich ToDo_10 Verbindungsverlust — baut auf ToDo_2 auf, parallel zu ToDo_9 möglich
@@ -264,7 +266,7 @@ ToDo_7 Tests — begleitend zu allen obigen
Kurzübersicht weiterer offener Punkte: Kurzübersicht weiterer offener Punkte:
- [ ] Dokumentation der vollständigen G-Code-Syntax erweitern - [ ] Dokumentation der vollständigen G-Code-Syntax erweitern
- [ ] `FFirst`/`FLast`-Befehle in `GCode.receiveFC()` implementieren - [x] `FFirst`/`FLast` (und übriges File-Handling) → ausgelagert in `appRobotFileservice` (siehe `doc/draft_filehandeling.md`)
- [ ] `ROBOT_USE_SPEED_CALC` und `motorSpeeds` im echten Betrieb prüfen - [ ] `ROBOT_USE_SPEED_CALC` und `motorSpeeds` im echten Betrieb prüfen
- [ ] `FluidNCClient.js` evaluieren: als Ersatz oder Ergänzung zu `TelnetSenderGRBL`? - [ ] `FluidNCClient.js` evaluieren: als Ersatz oder Ergänzung zu `TelnetSenderGRBL`?
- [x] HTTPS-Passphrase aus Env-Variable (`HTTPS_PASSPHRASE`) — erledigt - [x] HTTPS-Passphrase aus Env-Variable (`HTTPS_PASSPHRASE`) — erledigt

View File

@@ -3,8 +3,6 @@ services:
appRobotGuacamole: appRobotGuacamole:
image: abesnier/guacamole:latest image: abesnier/guacamole:latest
container_name: appRobot_guacamole container_name: appRobot_guacamole
ports:
- "8080:8080"
volumes: volumes:
- /home/chk/Documents/appServerInstallation/guacamole/config:/config/guacamole - /home/chk/Documents/appServerInstallation/guacamole/config:/config/guacamole
- /home/chk/Documents/appServerInstallation/guacamole/postgres:/config/postgres - /home/chk/Documents/appServerInstallation/guacamole/postgres:/config/postgres
@@ -29,6 +27,9 @@ services:
- GRBL_ELLBOW_IP=192.168.0.202 - GRBL_ELLBOW_IP=192.168.0.202
- GRBL_HAND_IP=192.168.0.250 - GRBL_HAND_IP=192.168.0.250
- ROBOT_SPEED_MODE=correct - ROBOT_SPEED_MODE=correct
- ROBOT_KINEMATICS=arm3segmentlinearx
- ROBOT_GRBL_AUTOREPORT=true
- ROBOT_GRBL_REPORT_INTERVAL=200
ports: ports:
- "2098:2098" - "2098:2098"
- "2081:2081" - "2081:2081"
@@ -36,6 +37,7 @@ services:
- default - default
appRobotSimulation: appRobotSimulation:
container_name: appRobot_Simulation container_name: appRobot_Simulation
image: node:24-alpine image: node:24-alpine
@@ -45,6 +47,8 @@ services:
environment: environment:
- TARGET_SERVER=wss://appRobot_Driver:2095 - TARGET_SERVER=wss://appRobot_Driver:2095
command: npm start command: npm start
depends_on:
- appRobotDriver
restart: unless-stopped restart: unless-stopped
ports: ports:
- "1003:1003" - "1003:1003"
@@ -70,50 +74,29 @@ services:
- appRobotDriver - appRobotDriver
restart: unless-stopped restart: unless-stopped
appRobotHoming: appRobotHoming:
image: node:24-alpine image: node:20-bullseye
container_name: appRobot_Homing container_name: appRobot_Homing
working_dir: /app working_dir: /app
volumes: volumes:
- /home/chk/Documents/appRobotHoming:/app - /home/chk/Documents/appRobotHoming:/app
- /home/chk/Documents/AppRobotVideo/public/snapshots:/app/public/snapshots
environment: environment:
- WSS_VIDEO_DRIVER=wss://localhost:8448 - WSS_VIDEO_DRIVER=wss://appRobot_Webcam:8448
- WSS_URL=wss://appRobot_Driver:2095 - WSS_URL=wss://appRobot_Driver:2095
- HTTPS_PORT=2093 - HTTPS_PORT=2093
- WEBCAM_URL=http://appRobotWebcam:8444 - WEBCAM_URL=http://appRobot_Webcam:8444
- BODYTRACKER_URL=http://appRobotBodyTracker:8446 - BODYTRACKER_URL=http://appRobotBodyTracker:8446
ports: ports:
- "2093:2093" - "2093:2093"
depends_on: depends_on:
- appRobotDriver - appRobotDriver
command: > command: >
/bin/sh -lc "npm ci || npm install && node server/server.js" /bin/bash -lc "apt-get update -qq && apt-get install -y --no-install-recommends python3-pip && pip3 install --quiet --no-cache-dir opencv-python-headless numpy && npm ci || npm install && node server/server.js"
networks: networks:
- default - default
restart: unless-stopped restart: unless-stopped
# appRobotDirectBase:
# image: node:20-bullseye
# container_name: appRobot_DirectBase
# network_mode: host
# working_dir: /app
# volumes:
# - /home/chk/Documents/appRobotDirectControlBase:/app
# environment:
# - FluidNcHost=192.168.0.183
# - FluidNcPort=80
# - PORT=2098
# ports:
# - "2098:2098"
# command: >
# /bin/bash -lc "
# npm ci || npm install &&
# node server/server.js
# "
# restart: unless-stopped
appRobot_Tunnel: appRobot_Tunnel:
image: alpine:latest image: alpine:latest
@@ -137,13 +120,14 @@ services:
-R 0.0.0.0:9710:appRobot_Control:10010 \ -R 0.0.0.0:9710:appRobot_Control:10010 \
-R 0.0.0.0:9798:appRobot_Driver:2098 \ -R 0.0.0.0:9798:appRobot_Driver:2098 \
-R 0.0.0.0:9712:appRobot_Simulation:1003\ -R 0.0.0.0:9712:appRobot_Simulation:1003\
-R 0.0.0.0:9743:AppRobotWebcam:8444 \ -R 0.0.0.0:9743:appRobot_Webcam:8444 \
-R 0.0.0.0:9780:appRobot_guacamole:8080 \ -R 0.0.0.0:9780:appRobot_guacamole:8080 \
-R 0.0.0.0:9793:appRobot_Homing:2093 \ -R 0.0.0.0:9793:appRobot_Homing:2093 \
-R 0.0.0.0:9725:appRobot_AccessBase:443 \ -R 0.0.0.0:9725:appRobot_AccessBase:443 \
-R 0.0.0.0:9726:appRobot_AccessEllbow:443 \ -R 0.0.0.0:9726:appRobot_AccessEllbow:443 \
-R 0.0.0.0:9727:appRobot_AccessHand:443 \ -R 0.0.0.0:9727:appRobot_AccessHand:443 \
-R 0.0.0.0:9744:appRobot_CodeServer:8443 \ -R 0.0.0.0:9744:appRobot_CodeServer:8443 \
-R 0.0.0.0:7060:overleaf:80 \
tunnel@server.schooltech.ch -p 2255 tunnel@server.schooltech.ch -p 2255
" "
@@ -151,44 +135,9 @@ services:
image: cloudflare/cloudflared:latest image: cloudflare/cloudflared:latest
container_name: appServer_cloudflare container_name: appServer_cloudflare
command: tunnel --no-autoupdate run --token eyJhIjoiOWUyYzk0OTI1ZWVlNmE4NjRiZjllZGRiM2ZmMDRmMTUiLCJ0IjoiZDc2YzI2MjAtZGE0ZC00OTJmLWI5YjgtODNjMjgwNjQ5MTFlIiwicyI6IllUbGpPREJtTURndFpHSTVZUzAwWkRnekxXRTRNek10TXpaaE56WTBabUpsT1RBMSJ9 command: tunnel --no-autoupdate run --token eyJhIjoiOWUyYzk0OTI1ZWVlNmE4NjRiZjllZGRiM2ZmMDRmMTUiLCJ0IjoiZDc2YzI2MjAtZGE0ZC00OTJmLWI5YjgtODNjMjgwNjQ5MTFlIiwicyI6IllUbGpPREJtTURndFpHSTVZUzAwWkRnekxXRTRNek10TXpaaE56WTBabUpsT1RBMSJ9
# networks:
# - default
# - appRobotNet
restart: unless-stopped restart: unless-stopped
appRobot_nextcloud_db:
image: mariadb:latest
container_name: appRobot_nextcloud_db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: KantiWattwilABC
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: KantiABC122nextcloudX3!
volumes:
- /home/chk/Documents/appRobotNextCloud/db:/var/lib/mysql
appRobot_nextcloud:
image: nextcloud:latest
container_name: appRobot_nextcloud
restart: unless-stopped
environment:
MYSQL_PASSWORD: KantiABC122nextcloudX3!
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_HOST: appRobot_nextcloud_db
volumes:
- /home/chk/Documents/appRobotNextCloud/nextcloud:/var/www/html
- /home/chk/Documents:/mnt/server:rw
depends_on:
- appRobot_nextcloud_db
ports:
- "9183:80"
appRobot_AccessBase: appRobot_AccessBase:
image: node:20-bullseye image: node:20-bullseye
# Alternativ: node:20-alpine (kleiner, aber evtl. openssl/ca/certs nachziehen) # Alternativ: node:20-alpine (kleiner, aber evtl. openssl/ca/certs nachziehen)
@@ -228,7 +177,6 @@ services:
appRobot_AccessHand: appRobot_AccessHand:
image: node:20-bullseye image: node:20-bullseye
# Alternativ: node:20-alpine (kleiner, aber evtl. openssl/ca/certs nachziehen)
container_name: appRobot_AccessHand container_name: appRobot_AccessHand
working_dir: /app working_dir: /app
volumes: volumes:
@@ -245,24 +193,7 @@ services:
restart: unless-stopped restart: unless-stopped
appRobot_CodeServer:
image: lscr.io/linuxserver/code-server
container_name: appRobot_CodeServer
restart: unless-stopped
environment:
PUID: "1000"
PGID: "1000"
TZ: "Europe/Rome"
AUTH: "none"
PASSWORD: "Albula60"
ports:
- "9743:8443"
volumes:
- /home/chk/Documents/appRobotDriver:/workspace/appRobotDriver
- /home/chk/Documents/appRobotHoming:/workspace/appRobotHoming
- /home/chk/Documents/appRobotControl:/workspace/appRobotControl
- /home/chk/Documents/AppRobotVideo/public/snapshots:/workspace/appRobotHoming/public/snapshots
working_dir: /workspace
yolo: yolo:
image: ultralytics/ultralytics:latest image: ultralytics/ultralytics:latest
@@ -276,7 +207,6 @@ services:
webcam: webcam:
build: build:
context: /tmp context: /tmp
@@ -285,28 +215,29 @@ services:
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app WORKDIR /usr/src/app
EXPOSE 8444 EXPOSE 8424
image: approbotwebcam:latest image: approbotwebcam:latest
container_name: AppRobotWebcam container_name: appRobot_Webcam
restart: unless-stopped restart: unless-stopped
# 8444 am Host veröffentlicht → direkter LAN-Zugriff (http://<host>:8444) bleibt.
# Wenn ALLES über den Proxy läuft, diesen ports-Block entfernen → proxy-only.
ports: ports:
- "8444:8444" - "8444:8444"
command: sh -c "npm install --omit=dev && node server.js" command: >
sh -c "(apt-get update && apt-get install -y --no-install-recommends i965-va-driver libva-drm2 vainfo) || echo 'WARN: VA-Treiber-Install fehlgeschlagen H.264 evtl. nicht verfuegbar';
npm install --omit=dev && node server.js"
volumes: volumes:
- ${APP_PATH:-.}:/usr/src/app - /home/chk/Documents/appRobotWebcam:/usr/src/app
devices: devices:
# by-id (Host) → /dev/videoN (Container) stabil über Reboots und USB-Re-Plugs.
# Rechte Seite = Pfad den cameras.json + FFmpeg im Container sehen.
- /dev/v4l/by-id/usb-046d_0825_3BB3FE20-video-index0:/dev/video0 # cam0 C270 (046d:0825) - /dev/v4l/by-id/usb-046d_0825_3BB3FE20-video-index0:/dev/video0 # cam0 C270 (046d:0825)
- /dev/v4l/by-id/usb-046d_081b_342D4F40-video-index0:/dev/video2 # cam1 C270 (046d:081b) - /dev/v4l/by-id/usb-046d_081b_342D4F40-video-index0:/dev/video2 # cam1 C270 (046d:081b)
- /dev/v4l/by-id/usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0:/dev/video4 # cam2 C920 - /dev/v4l/by-id/usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0:/dev/video4 # cam2 C920
- /dev/dri:/dev/dri
group_add: group_add:
- video - video
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=8444 - PORT=8444
- LIBVA_DRIVER_NAME=i965
networks: networks:
default: default:

View File

@@ -1,5 +1,13 @@
# ToDo 4 — G-Code und Datei-Handling # ToDo 4 — G-Code und Datei-Handling
> **✅ Erledigt / abgelöst:** Das Datei-Handling wird **nicht** mehr Driver-intern
> (`GCodeFileManager`) gebaut, sondern in das eigenständige Projekt
> **`appRobotFileservice`** ausgelagert und über FCodes durch den Driver
> weitergereicht. Im Driver bleibt nur ein dünner Proxy. Konzept & Schnittstelle:
> [`draft_filehandeling.md`](draft_filehandeling.md) ·
> [`draft_filehandeling_API.md`](draft_filehandeling_API.md).
> Die folgenden Punkte sind als Vorlage für die Umsetzung *dort* zu lesen.
## Ziel der Verbesserung ## Ziel der Verbesserung
G-Code-Logik sauber von Datei-Management trennen. Die Bewegungssteuerung soll nicht durch Dateibefehle oder File-IO verwässert werden. G-Code-Logik sauber von Datei-Management trennen. Die Bewegungssteuerung soll nicht durch Dateibefehle oder File-IO verwässert werden.

View File

@@ -1,5 +1,14 @@
# ToDo 6b — File-Handling # ToDo 6b — File-Handling
> **✅ Erledigt / abgelöst:** Das File-Handling wird in das eigenständige Projekt
> **`appRobotFileservice`** ausgelagert (Driver als Gateway, FCodes als Pass-through).
> Die hier beschriebenen Detailprobleme werden **dort** gelöst: Cursor als In-Memory-
> Index (Paket 2), explizite Grad↔Radian-Umrechnung im Fileservice (Paket 3),
> Fehler-Envelope (Paket 4), asynchrones IO (Paket 5). Konzept & Schnittstelle:
> [`draft_filehandeling.md`](draft_filehandeling.md) ·
> [`draft_filehandeling_API.md`](draft_filehandeling_API.md).
> Die folgende Analyse bleibt als Umsetzungs-Vorlage für *jenes* Projekt erhalten.
## Ist-Zustand ## Ist-Zustand
`GCode.receiveFC()` implementiert nur einen Bruchteil der erkannten Befehle: `GCode.receiveFC()` implementiert nur einen Bruchteil der erkannten Befehle:

View File

@@ -79,6 +79,8 @@ erstmals einen Promise zurückgeben/awaiten können.
| `ROBOT_SPEED_MODE` | `legacy` / `correct` | `legacy` | koordinierte Feedrate (ToDo_6a) | | `ROBOT_SPEED_MODE` | `legacy` / `correct` | `legacy` | koordinierte Feedrate (ToDo_6a) |
| `ROBOT_USE_QUEUE` | `false` / `true` | `false` | zeitgesteuerte Sende-Queue (Paket 6) | | `ROBOT_USE_QUEUE` | `false` / `true` | `false` | zeitgesteuerte Sende-Queue (Paket 6) |
| `ROBOT_MOTION_SYNC` | `freerun` / `lockstep` | `freerun` | Schritt-für-Schritt-Synchronisation der 3 Controller | | `ROBOT_MOTION_SYNC` | `freerun` / `lockstep` | `freerun` | Schritt-für-Schritt-Synchronisation der 3 Controller |
| `ROBOT_GRBL_AUTOREPORT` | `false` / `true` | `false` | **umgesetzt (Paket 3).** Schreibt beim Connect `$10=3` + `$Report/Interval` in die persistenten FluidNC-Settings. Default AUS → ohne Flag wird NICHTS an die Hardware geschrieben; geparst werden nur die Antworten des ohnehin laufenden `?`-Heartbeats. |
| `ROBOT_GRBL_REPORT_INTERVAL` | ms (Zahl) | `200` | **umgesetzt (Paket 3).** Intervall für `$Report/Interval`, nur wirksam bei `ROBOT_GRBL_AUTOREPORT=true`. |
> **Konsistenz-Regel:** `ROBOT_MOTION_SYNC=lockstep` ergibt nur mit `ROBOT_SPEED_MODE=correct` > **Konsistenz-Regel:** `ROBOT_MOTION_SYNC=lockstep` ergibt nur mit `ROBOT_SPEED_MODE=correct`
> Sinn (sonst kommen die Controller zu unterschiedlichen Zeiten an und Lockstep müsste hart > Sinn (sonst kommen die Controller zu unterschiedlichen Zeiten an und Lockstep müsste hart
@@ -86,14 +88,23 @@ erstmals einen Promise zurückgeben/awaiten können.
--- ---
## Paket 1: GRBL-Antworten lesen ## Paket 1: GRBL-Antworten lesen — ✅ ERLEDIGT
- [ ] `connection.on('data', data => {})` in `TelnetSenderGRBL` ersetzen durch echtes Lesen > Umgesetzt in `robot/TelnetSenderGRBL.js`. Der vorher blinde Kanal wird gelesen:
- GRBL antwortet auf jeden G-Code-Befehl mit `ok` oder `error: <Meldung>` > `tSocket.on('data', …)` → `_handleIncomingData()` (zeilen-gepuffert, fragmentierungs-
- Antworten parsen und ins Log schreiben > sicher) → `_handleResponseLine()` (demultiplext nach Typ). Tests:
- [ ] Fehlerantworten nach außen meldbar machen > `test/Sender.Telnet.responseParsing.test.js`.
- an `InfoServer` oder über einen EventEmitter
- damit der WebSocket-Client Feedback bekommt, ob ein Befehl angenommen wurde - [x] `connection.on('data', …)` durch echtes Lesen ersetzt
- `ok``lastOk`-Zeitstempel; `error:`/`ALARM:``lastError` + Log
- `<…>`-Reports → `_parseStatusReport()` (siehe Paket 3)
- **wirft nie** (try/catch um den data-Handler) — ein Parsefehler darf den Prozess nicht abreißen
- [x] Fehlerantworten nach außen meldbar gemacht
- über `getStatus()` (`lastError`) → `InfoServer` `/api/status`; kein EventEmitter-Umbau nötig
- der raw-`socket.on('data')`-Handler für `_lastDataAt` (Heartbeat-Liveness) bleibt unverändert
> **Bewusst nicht hier:** ein `ok`-Handshake / Sende-Queue (das ist Paket 2 / Paket 6).
> Paket 1 *liest und meldet* nur — der Sende-Pfad bleibt unangetastet (fire-and-forget).
## Paket 2: Command-Queue mit ok-Handshake ## Paket 2: Command-Queue mit ok-Handshake
@@ -108,20 +119,29 @@ erstmals einen Promise zurückgeben/awaiten können.
- [ ] Timeout für ausbleibende `ok`-Antworten definieren - [ ] Timeout für ausbleibende `ok`-Antworten definieren
- nach X ms ohne Antwort: Fehler loggen, ggf. Verbindung zurücksetzen - nach X ms ohne Antwort: Fehler loggen, ggf. Verbindung zurücksetzen
## Paket 3: Hardwareposition lesen (Auto-Report statt Polling) ## Paket 3: Hardwareposition lesen (Auto-Report statt Polling) — ✅ weitgehend erledigt
- [ ] Beim Verbindungsaufbau je Controller konfigurieren: > Report-Parsing + InfoServer-Anbindung umgesetzt. Die Settings-Writes (`$10`,
- `$10=3` setzen → Report enthält `MPos` **und** `Bf` (Protokoll-Fakt 2) > `$Report/Interval`) sind **opt-in** (Env `ROBOT_GRBL_AUTOREPORT`, Default AUS), weil sie
- `$Report/Interval=N` setzen (z. B. `N=100…200`) → FluidNC **pusht** den Status während > persistente FluidNC-NVS-Settings schreiben und auf der eingesetzten FluidNC-Version
der Bewegung selbst (Protokoll-Fakt 4). Kein `?`-Polling-Loop nötig; `?` bleibt nur als > verifiziert werden müssen. Tests: `test/Sender.Telnet.responseParsing.test.js`,
Einzelabfrage on demand (z. B. für Sync, Paket 4). > `test/InfoServer.test.js`.
- [ ] `data`-Handler (Paket 1) parst die gepushten `<…>`-Reports: `state`, `MPos`, `Bf`
- robust gegen Cross-Channel-Fremdzeilen (Protokoll-Fakt 5) — nach Typ demultiplexen - [x] Beim Verbindungsaufbau je Controller konfigurieren — **opt-in** (`_configureAutoReport()`):
- [ ] Gemeldete Hardware-Position (`MPos`) mit Softwareposition vergleichen - `$10=3` → Report enthält `MPos` **und** `Bf` (Protokoll-Fakt 2)
- bei Abweichung: warnen oder synchronisieren (→ Paket 4) - `$Report/Interval=N` (Default `N=200`) → FluidNC **pusht** den Status selbst (Protokoll-Fakt 4)
- schützt gegen Drift durch Endschalter-Auslösung, Motor-Stall, Verbindungsunterbrechung - **nur** wenn `ROBOT_GRBL_AUTOREPORT=true`; ohne Flag bleibt der `?`-Heartbeat (alle 10 s)
- [ ] Status (`Idle`, `Run`, `Alarm`, `Hold`) + `Bf` für den `InfoServer` bereitstellen die einzige Statusquelle — ausreichend für Anzeige, kein Schreibzugriff auf die Hardware
- `/api/status` um GRBL-Zustand erweitern - [x] `data`-Handler (Paket 1) parst die `<…>`-Reports: `state`, `MPos`/`WPos`, `Bf`
- robust gegen Cross-Channel-Fremdzeilen (Protokoll-Fakt 5) — nach Typ demultiplext, fremde
Zeilen werden ignoriert; zerstörte Felder lassen den alten Wert stehen
- [ ] **Offen (→ Paket 4):** Gemeldete `MPos` mit Softwareposition vergleichen / bei Abweichung
warnen. Bewusst aufgeschoben: der Vergleich braucht die `motorStateFromPorts()`-Rückrechnung
(ToDo_9a) und eine Roboter-Referenz im Sender — beides gehört zum Sync-Command (Paket 4).
Der Sender liest und meldet die Hardwareposition bereits; der *Abgleich* fehlt noch.
- [x] Status (`Idle`/`Run`/`Alarm`/`Hold`) + `MPos` + `Bf` für den `InfoServer` bereitgestellt
- `getStatus()` um `grblState`, `machinePosition`, `plannerBlocksFree`, `rxBytesFree`,
`lastError`, `lastReportAt` erweitert; `/api/status` reicht sie durch
--- ---
@@ -155,42 +175,56 @@ eMotor = r.hand.y / D
> diese Richtung nicht — er geht `MPos → Motorwerte → Vorwärtskinematik → Pose`, beide > diese Richtung nicht — er geht `MPos → Motorwerte → Vorwärtskinematik → Pose`, beide
> Schritte eindeutig. Der gesamte Sync-Pfad ist damit eindeutig. > Schritte eindeutig. Der gesamte Sync-Pfad ist damit eindeutig.
Offen für die spätere **Umsetzung** (Paket 4, nicht mehr Analyse): Umsetzung (Paket 4) — ✅ ERLEDIGT:
- [ ] `motorStateFromPorts()` aus der Analyse in den Produktiv-Code heben (Ort: Sender oder - [x] `motorStateFromPorts()` in den Produktiv-Code gehoben: **`robot/portInverse.js`**.
Kinematik-Helfer) und im Sync verdrahten `test/Robot.PortInverse.test.js` importiert jetzt diese Funktion (statt inline) — die
- [ ] **Round-Trip-Invariante** als Dauer-Test mitführen: `portValue(motorStateFromPorts(p)) ≈ p` 15 Verifikations-Tests sind damit der Dauer-Guard für den Produktivcode.
— schützt gegen Drift, falls sich die Verkabelung in `startRobot.js` ändert - [x] Im Sync verdrahtet: `RobotController.syncFromHardware()` ruft `motorStateFromPorts()`.
Die Round-Trip-Invariante ist über `test/Robot.PortInverse.test.js` (A/B/C) abgedeckt.
> Hinweis: Gelesen wird auf dem **aktiven** Sender `TelnetSenderGRBL` (im `data`-Handler, > Hinweis: Gelesen wird auf dem **aktiven** Sender `TelnetSenderGRBL` (im `data`-Handler,
> siehe Paket 1) — nicht auf `FluidNCClient.js`. > siehe Paket 1) — nicht auf `FluidNCClient.js`.
--- ---
## Paket 4: Hardware-Position auslesen und übernehmen (Sync-Command) ## Paket 4: Hardware-Position auslesen und übernehmen (Sync-Command) — ✅ ERLEDIGT
**Ziel:** Ein Befehl liest die echten Motor-Koordinaten aller drei Controller aus, **Ziel:** Ein Befehl liest die echten Motor-Koordinaten aller drei Controller aus,
übernimmt sie als neuen Soll-Zustand und passt die berechnete Roboter-Pose entsprechend an. übernimmt sie als neuen Soll-Zustand und passt die berechnete Roboter-Pose entsprechend an.
Nötig nach Homing, manuellem Jog, Endschalter-Auslösung oder Reconnect — die Software weiß Nötig nach Homing, manuellem Jog, Endschalter-Auslösung oder Reconnect — die Software weiß
sonst nicht, wo der Roboter physisch wirklich steht. sonst nicht, wo der Roboter physisch wirklich steht.
- [ ] **G-Code-Befehl** (B6), z. B. `M114 R` (Read-Hardware) — durch `GCodeParser` + > Umgesetzt. Befehl: **`M114 R`**. Pfad: `GCodeParser` → `RobotController.applyCommand`
`RobotController` geroutet, **nicht** als Sonderfall in `InputWS` wie heute `M114` > (M114-R-Branch) → `RobotController.syncFromHardware()` (async). Tests:
- klar abgegrenzt vom bestehenden `M114`, das nur die **Software**-Position zurückgibt > `test/RobotController.sync.test.js`, `test/Sender.Telnet.responseParsing.test.js`
(`GCode.getM114(robot)` in `server/InputWS.js`) > (requestStatusReport). **Datenquelle: aktiv `?` + await** (gewählt; funktioniert auch
- [ ] **Async-Dispatch (B6-Folge):** `RobotController.applyCommand` muss für diesen Befehl > ohne Auto-Report).
einen Promise zurückgeben und auf die `?`-Antworten warten — der erste asynchrone Befehl
im bisher synchronen Dispatch-Pfad. - [x] **G-Code-Befehl** `M114 R` — durch `GCodeParser` + `RobotController` geroutet
- [ ] Ablauf des Sync: - `GCodeParser` erhält jetzt Flag-Token ohne Wert (`R``params.R = true`)
1. an alle drei Sender einmalig `?` senden, je `MPos` aus der Antwort parsen (Paket 3) - `GCode.containsCommand('M114 …')` erkennt es; das bestehende exakte `M114` (Software-
2. `motorStateFromPorts(...)` → sieben Motorwerte rekonstruieren (Baustein oben — linear/eindeutig, ToDo_9a) Position) in `InputWS` bleibt unberührt (wird vorher per `=== "M114"` abgefangen)
3. diese auf den Roboter schreiben: `robot.xMotor/alpha/beta/a/b/c/eMotor = …` - [x] **Async-Dispatch:** `applyCommand` gibt für `M114 R` ein Promise zurück; `receive`
4. **Vorwärtskinematik** anstoßen: `robot.calculatePositionFromMotorAngles()` reicht es nur dann hoch (sonst synchron wie bisher). `InputWS` wartet auf das Promise und
→ füllt `robot.x/y/z` und `phi/theta/psi` aus den Hardwarewerten broadcastet erst danach die neue Pose; Fehler gehen als Fehler-Envelope an den Anfrager.
5. `motorPosition`/`motorPositionOld` zurücksetzen, damit der nächste Move sauber von **Alle anderen Befehle bleiben byte-identisch synchron.**
der echten Position aus rechnet (sonst falscher Speed-Delta im Korrekt-Modus) - [x] Ablauf des Sync (`syncFromHardware`):
- [ ] dem anfragenden Client die übernommene Pose zurückmelden (`reply(ws, …)`) 1. an `base`/`elbow`/`hand` je `requestStatusReport()` → sendet `?`, wartet auf `<…>` (Timeout 1 s)
- [ ] **kein** automatisches Nachfahren — Sync ändert nur den Soll-Zustand, sendet keinen Move 2. `motorStateFromPorts(...)` → sieben Motorwerte (linear/eindeutig, ToDo_9a)
3. auf den Roboter schreiben (`robot.xMotor/alpha/beta/a/b/c/eMotor`)
4. `robot.calculatePositionFromMotorAngles()` → Pose `x/y/z` + `phi/theta/psi`
5. `motorPosition`/`motorPositionOld` auf `null` → nächster Move rechnet von der echten Position
- [x] dem Client die übernommene Pose zurückmelden — als Broadcast von `getM114` (konsistent
mit Bewegungen; erreicht auch die Simulation, nicht nur den Anfrager)
- [x] **kein** automatisches Nachfahren — Sync ruft **kein** `sendCommand()`; es geht garantiert
nichts an die Controller (per Test abgesichert: `execCommand` wird nie aufgerufen)
- [x] **Guards:** fehlende Controller-Rolle, ungültige/zu kurze `MPos` und `?`-Timeout führen
zu einem sauberen Fehler an den Client — **nie** zu Müll im Roboterzustand
**Sender→Rolle-Zuordnung:** `startRobot.js` setzt `instance.controllerRole = key`
(`base`/`elbow`/`hand`); `syncFromHardware` mappt darüber. Ändert sich die Verkabelung, muss
`portInverse.js` mitgezogen werden — der Round-Trip-Test schützt davor.
> Warum nicht einfach „letzten gesendeten Wert" merken? Weil die Hardware nach Homing/Jog/ > Warum nicht einfach „letzten gesendeten Wert" merken? Weil die Hardware nach Homing/Jog/
> Stall von dem abweicht, was zuletzt gesendet wurde — genau diese Differenz soll Sync auflösen. > Stall von dem abweicht, was zuletzt gesendet wurde — genau diese Differenz soll Sync auflösen.

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

View File

@@ -1,94 +0,0 @@
services:
appRobotGuacamole:
image: abesnier/guacamole:latest
container_name: appRobot_guacamole
ports:
- "9080:8080"
volumes:
- C:/Users/kech/SynologyDrive/2026-AppServer-AppRobot/guacamole/guacamole/config:/config/guacamole
- C:/Users/kech/SynologyDrive/2026-AppServer-AppRobot/guacamole/postgres:/config/postgres
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- default
appRobotDriver:
container_name: appRobot_Driver
image: node:24-alpine
working_dir: /usr/src/app
volumes:
- C:/Users/kech/SynologyDrive/2026-AppServer-AppRobot/appRobotDriver:/usr/src/app
command: npm start
restart: unless-stopped
environment:
- GRBL_BASE_IP=192.168.0.183
- GRBL_ELLBOW_IP=192.168.0.202
- GRBL_HAND_IP=192.168.0.250
ports:
- "2096:2095"
- "2098:2098"
expose:
- "2095"
networks:
- default
appRobotSimulation:
container_name: appRobot_Simulation
image: node:24-alpine
working_dir: /usr/src/app
volumes:
- C:/Users/kech/SynologyDrive/2026-AppServer-AppRobot/appRobotSimulation:/usr/src/app
environment:
- TARGET_SERVER=wss://appRobot_Driver:2095
command: npm start
restart: unless-stopped
ports:
- "1003:1003"
networks:
- default
appRobotControl:
image: node:24-alpine
container_name: appRobot_Control
working_dir: /app
command: sh -c "npm install && node 3DInput.js"
ports:
- "10010:10010"
volumes:
- C:/Users/kech/SynologyDrive/2026-AppServer-AppRobot/appRobotControl:/app
environment:
- NODE_ENV=production
- TARGET_SERVER=wss://appRobot_Driver:2095
depends_on:
- appRobotDriver
restart: unless-stopped
appRobotHoming:
image: node:24-alpine
container_name: appRobot_Homing
working_dir: /app
volumes:
- C:/Users/kech/SynologyDrive/2026-AppServer-AppRobot/appRobotHoming:/app
- C:/Users/kech/SynologyDrive/2026-AppServer-AppRobot/AppRobotVideo/public/snapshots:/app/public/snapshots
environment:
- WSS_VIDEO_DRIVER=wss://localhost:8448
- WSS_URL=wss://appRobot_Driver:2095
- HTTPS_PORT=2093
ports:
- "2093:2093"
depends_on:
- appRobotDriver
command: >
/bin/sh -lc "npm ci || npm install && node server/server.js"
networks:
- default
restart: unless-stopped
networks:
default:
driver: bridge

View File

@@ -1,19 +1,5 @@
services: services:
appRobotGuacamole:
image: abesnier/guacamole:latest
container_name: appRobot_guacamole
ports:
- "8080:8080"
volumes:
- /home/chk/Documents/appServerInstallation/guacamole/config:/config/guacamole
- /home/chk/Documents/appServerInstallation/guacamole/postgres:/config/postgres
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- default
appRobotDriver: appRobotDriver:
container_name: appRobot_Driver container_name: appRobot_Driver
image: node:24-alpine image: node:24-alpine
@@ -23,225 +9,21 @@ services:
command: npm start command: npm start
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=development
- NODE_OPTIONS=--inspect=0.0.0.0:2081
- GRBL_BASE_IP=192.168.0.183 - GRBL_BASE_IP=192.168.0.183
- GRBL_ELLBOW_IP=192.168.0.202 - GRBL_ELLBOW_IP=192.168.0.202
- GRBL_HAND_IP=192.168.0.250 - GRBL_HAND_IP=192.168.0.250
# Austauschbare Kinematik (siehe doc/ToDo_12_InverseKinematikConfig_ROADMAP.md). - ROBOT_SPEED_MODE=correct
# Defaults entsprechen exakt dem bisherigen Verhalten.
- ROBOT_KINEMATICS=arm3segmentlinearx - ROBOT_KINEMATICS=arm3segmentlinearx
- ROBOT_KINEMATICS_PARAMS={"l1": 250, "l2": 264, "l3": 100} - ROBOT_GRBL_AUTOREPORT=true
- ROBOT_GRBL_REPORT_INTERVAL=200
ports: ports:
- "2096:2095"
- "2098:2098" - "2098:2098"
expose: - "2081:2081"
- "2095"
networks: networks:
- default - default
appRobotSimulation:
container_name: appRobot_Simulation
image: node:24-alpine
working_dir: /usr/src/app
volumes:
- /home/chk/Documents/appRobotSimulation:/usr/src/app
environment:
- TARGET_SERVER=wss://appRobot_Driver:2095
command: npm start
restart: unless-stopped
ports:
- "1003:1003"
networks:
- default
appRobotControl:
image: node:24-alpine
container_name: appRobot_Control
working_dir: /app
command: sh -c "npm install && node 3DInput.js"
ports:
- "10010:10010"
volumes:
- /home/chk/Documents/appRobotControl:/app
environment:
- NODE_ENV=production
- TARGET_SERVER=wss://appRobot_Driver:2095
depends_on:
- appRobotDriver
restart: unless-stopped
appRobotHoming:
image: node:24-alpine
container_name: appRobot_Homing
working_dir: /app
volumes:
- /home/chk/Documents/appRobotHoming:/app
- /home/chk/Documents/AppRobotVideo/public/snapshots:/app/public/snapshots
environment:
- WSS_VIDEO_DRIVER=wss://localhost:8448
- WSS_URL=wss://appRobot_Driver:2095
- HTTPS_PORT=2093
ports:
- "2093:2093"
depends_on:
- appRobotDriver
command: >
/bin/sh -lc "npm ci || npm install && node server/server.js"
networks:
- default
restart: unless-stopped
# appRobotDirectBase:
# image: node:20-bullseye
# container_name: appRobot_DirectBase
# network_mode: host
# working_dir: /app
# volumes:
# - /home/chk/Documents/appRobotDirectControlBase:/app
# environment:
# - FluidNcHost=192.168.0.183
# - FluidNcPort=80
# - PORT=2098
# ports:
# - "2098:2098"
# command: >
# /bin/bash -lc "
# npm ci || npm install &&
# node server/server.js
# "
# restart: unless-stopped
appRobot_Tunnel:
image: alpine:latest
container_name: appRobot_Tunnel
restart: unless-stopped
environment:
- TZ=Europe/Zurich
volumes:
- /home/chk/Documents/AppServerPortalUI/.ssh:/root/.ssh:ro
command: >
/bin/sh -c "
apk add --no-cache openssh-client autossh &&
autossh -M 0 -N -o StrictHostKeyChecking=no \
-i /root/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no \
-o ServerAliveInterval=60 \
-o ServerAliveCountMax=10 \
-o ExitOnForwardFailure=yes \
-N \
-R 0.0.0.0:9703:portainer:9000 \
-R 0.0.0.0:9710:appRobot_Control:10010 \
-R 0.0.0.0:9798:appRobot_Driver:2098 \
-R 0.0.0.0:9712:appRobot_Simulation:1003\
-R 0.0.0.0:9743:AppRobotVideo:8443 \
-R 0.0.0.0:9780:appRobot_guacamole:8080 \
-R 0.0.0.0:9793:appRobot_Homing:2093 \
-R 0.0.0.0:9725:appRobot_AccessBase:443 \
-R 0.0.0.0:9726:appRobot_AccessEllbow:443 \
-R 0.0.0.0:9727:appRobot_AccessHand:443 \
tunnel@server.schooltech.ch -p 2255
"
cloudflared:
image: cloudflare/cloudflared:latest
container_name: appServer_cloudflare
command: tunnel --no-autoupdate run --token eyJhIjoiOWUyYzk0OTI1ZWVlNmE4NjRiZjllZGRiM2ZmMDRmMTUiLCJ0IjoiZDc2YzI2MjAtZGE0ZC00OTJmLWI5YjgtODNjMjgwNjQ5MTFlIiwicyI6IllUbGpPREJtTURndFpHSTVZUzAwWkRnekxXRTRNek10TXpaaE56WTBabUpsT1RBMSJ9
# networks:
# - default
# - appRobotNet
restart: unless-stopped
appRobot_nextcloud_db:
image: mariadb:latest
container_name: appRobot_nextcloud_db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: KantiWattwilABC
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: KantiABC122nextcloudX3!
volumes:
- /home/chk/Documents/appRobotNextCloud/db:/var/lib/mysql
appRobot_nextcloud:
image: nextcloud:latest
container_name: appRobot_nextcloud
restart: unless-stopped
environment:
MYSQL_PASSWORD: KantiABC122nextcloudX3!
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_HOST: appRobot_nextcloud_db
volumes:
- /home/chk/Documents/appRobotNextCloud/nextcloud:/var/www/html
- /home/chk/Documents:/mnt/server:rw
depends_on:
- appRobot_nextcloud_db
ports:
- "9183:80"
appRobot_AccessBase:
image: node:20-bullseye
# Alternativ: node:20-alpine (kleiner, aber evtl. openssl/ca/certs nachziehen)
container_name: appRobot_AccessBase
working_dir: /app
volumes:
- /home/chk/Documents/appRobotControlScara:/app
environment:
- FluidNcHost=192.168.0.183 #fluidncbase.local
- FluidNcPort=80
- PORT=443
command: >
/bin/bash -lc "
npm ci || npm install &&
node server/server.js
"
restart: unless-stopped
appRobot_AccessEllbow:
image: node:20-bullseye
# Alternativ: node:20-alpine (kleiner, aber evtl. openssl/ca/certs nachziehen)
container_name: appRobot_AccessEllbow
working_dir: /app
volumes:
- /home/chk/Documents/appRobotControlScara:/app
environment:
- FluidNcHost=192.168.0.202 #fluidncellbow.local
- FluidNcPort=80
- PORT=443
command: >
/bin/bash -lc "
npm ci || npm install &&
node server/server.js
"
restart: unless-stopped
appRobot_AccessHand:
image: node:20-bullseye
# Alternativ: node:20-alpine (kleiner, aber evtl. openssl/ca/certs nachziehen)
container_name: appRobot_AccessHand
working_dir: /app
volumes:
- /home/chk/Documents/appRobotControlScara:/app
environment:
- FluidNcHost=192.168.0.250 #fluidnchand.local
- FluidNcPort=80
- PORT=443
command: >
/bin/bash -lc "
npm ci || npm install &&
node server/server.js
"
restart: unless-stopped
networks: networks:
default: default:
driver: bridge driver: bridge

View File

@@ -10479,3 +10479,89 @@
2026-06-14T04:33:51.805Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 2026-06-14T04:33:51.805Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T04:33:51.860Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 2026-06-14T04:33:51.860Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T04:33:52.102Z ::ffff:127.0.0.1: G1 X1 2026-06-14T04:33:52.102Z ::ffff:127.0.0.1: G1 X1
2026-06-14T05:17:34.207Z ::ffff:127.0.0.1: M114
2026-06-14T05:17:34.440Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:17:34.682Z ::ffff:127.0.0.1: G1 X1
2026-06-14T05:19:19.662Z ::ffff:127.0.0.1: M114
2026-06-14T05:19:19.899Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:19:20.136Z ::ffff:127.0.0.1: G1 X1
2026-06-14T05:19:23.482Z ::ffff:127.0.0.1: M114
2026-06-14T05:19:23.492Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:20:40.271Z ::ffff:127.0.0.1: M114
2026-06-14T05:20:40.492Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:20:40.722Z ::ffff:127.0.0.1: G1 X1
2026-06-14T05:20:42.484Z ::ffff:127.0.0.1: M114
2026-06-14T05:20:42.498Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:22:42.150Z ::ffff:127.0.0.1: M114
2026-06-14T05:22:42.372Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:22:42.591Z ::ffff:127.0.0.1: G1 X1
2026-06-14T05:22:44.666Z ::ffff:127.0.0.1: M114
2026-06-14T05:22:44.682Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:59:06.033Z ::ffff:127.0.0.1: M114
2026-06-14T05:59:06.060Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:59:06.230Z ::ffff:127.0.0.1: M114
2026-06-14T05:59:06.459Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:59:06.682Z ::ffff:127.0.0.1: G1 X1
2026-06-14T05:59:14.364Z ::ffff:127.0.0.1: M114
2026-06-14T05:59:14.389Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:59:14.579Z ::ffff:127.0.0.1: M114
2026-06-14T05:59:14.810Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:59:15.047Z ::ffff:127.0.0.1: G1 X1
2026-06-14T05:59:21.641Z ::ffff:127.0.0.1: M114
2026-06-14T05:59:21.662Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:59:21.915Z ::ffff:127.0.0.1: M114
2026-06-14T05:59:22.141Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:59:22.378Z ::ffff:127.0.0.1: G1 X1
2026-06-14T05:59:33.235Z ::ffff:127.0.0.1: M114
2026-06-14T05:59:33.253Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:59:33.774Z ::ffff:127.0.0.1: M114
2026-06-14T05:59:33.992Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:59:34.219Z ::ffff:127.0.0.1: G1 X1
2026-06-14T05:59:43.288Z ::ffff:127.0.0.1: M114
2026-06-14T05:59:43.321Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:59:43.617Z ::ffff:127.0.0.1: M114
2026-06-14T05:59:43.843Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T05:59:44.068Z ::ffff:127.0.0.1: G1 X1
2026-06-14T06:00:01.712Z ::ffff:127.0.0.1: M114
2026-06-14T06:00:01.736Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T06:00:02.053Z ::ffff:127.0.0.1: M114
2026-06-14T06:00:02.286Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T06:00:02.515Z ::ffff:127.0.0.1: G1 X1
2026-06-14T06:00:13.944Z ::ffff:127.0.0.1: M114
2026-06-14T06:00:14.028Z ::ffff:127.0.0.1: M114
2026-06-14T06:00:14.037Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T06:00:14.160Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T06:00:14.388Z ::ffff:127.0.0.1: G1 X1
2026-06-14T06:00:26.793Z ::ffff:127.0.0.1: M114
2026-06-14T06:00:26.826Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T06:00:27.032Z ::ffff:127.0.0.1: M114
2026-06-14T06:00:27.255Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T06:00:27.510Z ::ffff:127.0.0.1: G1 X1
2026-06-14T06:00:33.304Z ::ffff:127.0.0.1: M114
2026-06-14T06:00:33.520Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T06:00:33.747Z ::ffff:127.0.0.1: G1 X1
2026-06-14T06:01:08.852Z ::ffff:127.0.0.1: M114
2026-06-14T06:01:09.006Z ::ffff:127.0.0.1: M114
2026-06-14T06:01:09.043Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T06:01:09.114Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T06:01:09.368Z ::ffff:127.0.0.1: G1 X1
2026-06-14T06:01:35.823Z ::ffff:127.0.0.1: M114
2026-06-14T06:01:35.982Z ::ffff:127.0.0.1: M114
2026-06-14T06:01:36.013Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T06:01:36.056Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T06:01:36.309Z ::ffff:127.0.0.1: G1 X1
2026-06-14T07:30:33.026Z ::ffff:127.0.0.1: M114
2026-06-14T07:30:33.246Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T07:30:33.477Z ::ffff:127.0.0.1: G1 X1
2026-06-14T07:30:35.424Z ::ffff:127.0.0.1: M114
2026-06-14T07:30:35.432Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T07:30:37.899Z ::ffff:127.0.0.1: M114
2026-06-14T07:30:38.024Z ::ffff:127.0.0.1: M114
2026-06-14T07:30:38.042Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T07:30:38.131Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T07:30:38.364Z ::ffff:127.0.0.1: G1 X1
2026-06-14T07:32:17.457Z ::ffff:127.0.0.1: M114
2026-06-14T07:32:17.699Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T07:32:17.813Z ::ffff:127.0.0.1: M114
2026-06-14T07:32:17.848Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-14T07:32:17.959Z ::ffff:127.0.0.1: G1 X1

View File

@@ -14666,3 +14666,37 @@
2026-06-14T04:33:34.893Z ::ffff:127.0.0.1 : Ping 2026-06-14T04:33:34.893Z ::ffff:127.0.0.1 : Ping
2026-06-14T04:33:51.379Z ::ffff:127.0.0.1 : Ping 2026-06-14T04:33:51.379Z ::ffff:127.0.0.1 : Ping
2026-06-14T04:33:51.767Z ::ffff:127.0.0.1 : Ping 2026-06-14T04:33:51.767Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:17:33.987Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:19:19.429Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:19:23.471Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:20:40.050Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:20:42.471Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:22:41.934Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:22:44.654Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:59:05.974Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:59:06.000Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:59:14.310Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:59:14.333Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:59:21.602Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:59:21.676Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:59:33.204Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:59:33.547Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:59:43.223Z ::ffff:127.0.0.1 : Ping
2026-06-14T05:59:43.380Z ::ffff:127.0.0.1 : Ping
2026-06-14T06:00:01.676Z ::ffff:127.0.0.1 : Ping
2026-06-14T06:00:01.788Z ::ffff:127.0.0.1 : Ping
2026-06-14T06:00:13.713Z ::ffff:127.0.0.1 : Ping
2026-06-14T06:00:14.008Z ::ffff:127.0.0.1 : Ping
2026-06-14T06:00:26.750Z ::ffff:127.0.0.1 : Ping
2026-06-14T06:00:26.782Z ::ffff:127.0.0.1 : Ping
2026-06-14T06:00:33.082Z ::ffff:127.0.0.1 : Ping
2026-06-14T06:01:08.583Z ::ffff:127.0.0.1 : Ping
2026-06-14T06:01:08.951Z ::ffff:127.0.0.1 : Ping
2026-06-14T06:01:35.569Z ::ffff:127.0.0.1 : Ping
2026-06-14T06:01:35.944Z ::ffff:127.0.0.1 : Ping
2026-06-14T07:30:32.804Z ::ffff:127.0.0.1 : Ping
2026-06-14T07:30:35.400Z ::ffff:127.0.0.1 : Ping
2026-06-14T07:30:37.637Z ::ffff:127.0.0.1 : Ping
2026-06-14T07:30:37.987Z ::ffff:127.0.0.1 : Ping
2026-06-14T07:32:17.191Z ::ffff:127.0.0.1 : Ping
2026-06-14T07:32:17.745Z ::ffff:127.0.0.1 : Ping

View File

@@ -210,7 +210,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (stops[2]) stops[2].setAttribute('stop-color', '#880000'); if (stops[2]) stops[2].setAttribute('stop-color', '#880000');
if (textEl) textEl.setAttribute('fill', '#1a1000'); if (textEl) textEl.setAttribute('fill', '#1a1000');
if (textPath) textPath.textContent = 'EMERGENCY STOP'; if (textPath) textPath.textContent = 'EMERGENCY STOP';
if (label) { label.textContent = '● Bestromt'; label.className = 'estop-armed-label armed'; } if (label) { label.textContent = 'Strom AN'; label.className = 'estop-armed-label armed'; }
} else { } else {
// ── POWER ON: Dunkler Navy-Ring + blauer Knopf — ruhig, klar ──────────── // ── POWER ON: Dunkler Navy-Ring + blauer Knopf — ruhig, klar ────────────
// Kein gelber Ring → kein E-Stop-Charakter. // Kein gelber Ring → kein E-Stop-Charakter.

View File

@@ -31,6 +31,7 @@ class GCode{
static containsCommand(s){ static containsCommand(s){
if(s.indexOf('M1 ') !== -1){return true;} // M1-Commands = G1-Command only for Motor-Coordinates if(s.indexOf('M1 ') !== -1){return true;} // M1-Commands = G1-Command only for Motor-Coordinates
if(s.indexOf('M114') === 0){return true;} // M114 R - Hardware-Sync (MPos lesen, ToDo_9 Paket 4)
if(s.indexOf('G') !== 0){return false;} if(s.indexOf('G') !== 0){return false;}
if(s.indexOf('G90') == 0){return true;} if(s.indexOf('G90') == 0){return true;}
if(s.indexOf('G91') == 0){return true;} if(s.indexOf('G91') == 0){return true;}
@@ -88,7 +89,9 @@ class GCode{
* funktionieren. * funktionieren.
*/ */
static receiveGCode(robot, g){ static receiveGCode(robot, g){
RobotController.receive(robot, g); // Rückgabe durchreichen: synchron `undefined` wie bisher, oder ein Promise,
// falls ein asynchroner Befehl (Hardware-Sync, ToDo_9 Paket 4) enthalten war.
return RobotController.receive(robot, g);
} }
//////////////////////////////////////////////////////////////////////////////////////////////////////////////77 //////////////////////////////////////////////////////////////////////////////////////////////////////////////77

View File

@@ -43,7 +43,14 @@ class GCodeParser {
const params = {}; const params = {};
for (let i = 1; i < tokens.length; i++) { for (let i = 1; i < tokens.length; i++) {
const token = tokens[i].trim(); const token = tokens[i].trim();
if (token.length < 2) { if (token.length === 0) {
continue;
}
if (token.length === 1) {
// Einzelner Buchstabe = Flag ohne Zahlenwert (z. B. 'R' in 'M114 R').
if (/^[A-Za-z]$/.test(token)) {
params[token.toUpperCase()] = true;
}
continue; continue;
} }

View File

@@ -8,18 +8,26 @@
* der Controller kennt nur strukturierte Befehle, keine rohen Textstrings. * der Controller kennt nur strukturierte Befehle, keine rohen Textstrings.
*/ */
const GCodeParser = require('./GCodeParser'); const GCodeParser = require('./GCodeParser');
const { motorStateFromPorts } = require('./portInverse');
class RobotController { class RobotController {
/** /**
* Parst eine rohe Nachricht und wendet alle enthaltenen Befehle der Reihe nach an. * Parst eine rohe Nachricht und wendet alle enthaltenen Befehle der Reihe nach an.
*
* Rückgabe: `undefined` (synchron, wie bisher) für alle gewöhnlichen Befehle.
* Nur wenn ein asynchroner Befehl enthalten war (Hardware-Sync, ToDo_9 Paket 4)
* wird ein Promise zurückgegeben, das auf dessen Abschluss wartet.
* @param {object} robot Robotermodell * @param {object} robot Robotermodell
* @param {string|Buffer} message rohe G-Code-Nachricht * @param {string|Buffer} message rohe G-Code-Nachricht
*/ */
static receive(robot, message) { static receive(robot, message) {
const commands = GCodeParser.parse(message); const commands = GCodeParser.parse(message);
if (!commands.length) return; if (!commands.length) return;
commands.forEach(parsed => this.applyCommand(robot, parsed)); const results = commands.map(parsed => this.applyCommand(robot, parsed));
const pending = results.filter(r => r && typeof r.then === 'function');
if (pending.length === 0) return; // synchroner Pfad unverändert
return Promise.all(pending).then(arr => arr[arr.length - 1]);
} }
/** /**
@@ -106,6 +114,12 @@ class RobotController {
return; return;
} }
if (cmd === 'M114' && params.R === true) {
// Hardware-Sync (ToDo_9 Paket 4): liest die echten MPos aller Controller
// und übernimmt sie als Soll-Zustand. Asynchron → gibt ein Promise zurück.
return this.syncFromHardware(robot);
}
if (cmd === 'M92' || cmd === 'G92') { if (cmd === 'M92' || cmd === 'G92') {
robot.createMotorPosition(); robot.createMotorPosition();
if (Number.isFinite(params.X)) { robot.xMotor = params.X; robot.xMotorChanged = true; } if (Number.isFinite(params.X)) { robot.xMotor = params.X; robot.xMotorChanged = true; }
@@ -121,6 +135,80 @@ class RobotController {
return; return;
} }
} }
/**
* Hardware-Sync (ToDo_9 Paket 4): liest die echten Achs-Positionen (`MPos`) der
* drei Controller, rekonstruiert daraus die sieben Motorwerte und übernimmt sie
* als neuen Soll-Zustand. Danach Vorwärtskinematik → Pose.
*
* Bewegt den Roboter NICHT — es wird kein `sendCommand()`/Move ausgelöst, nur der
* interne Zustand an die Realität angeglichen (nach Homing/Jog/Stall/Reconnect).
*
* @param {object} robot
* @param {{timeoutMs?: number}} [options]
* @returns {Promise<{x,y,z,phi,theta,psi}>} die übernommene Pose
*/
static async syncFromHardware(robot, options = {}) {
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 1000;
const receivers = (robot && robot.cmdReceivers) || [];
// Sender nach Rolle zuordnen (controllerRole wird in startRobot.js gesetzt).
const byRole = {};
for (const s of receivers) {
if (s && s.controllerRole) byRole[s.controllerRole] = s;
}
for (const role of ['base', 'elbow', 'hand']) {
if (!byRole[role]) {
throw new Error(`Sync: Controller '${role}' fehlt (kein Sender mit controllerRole='${role}')`);
}
}
// Frische Reports von allen drei Controllern anfordern (aktiv '?', mit await).
const [baseSnap, elbowSnap, handSnap] = await Promise.all([
byRole.base.requestStatusReport(timeoutMs),
byRole.elbow.requestStatusReport(timeoutMs),
byRole.hand.requestStatusReport(timeoutMs),
]);
// MPos-Arrays validieren (FluidNC meldet ggf. mehr Achsen; nur die nötigen lesen).
const need = (snap, role, n) => {
const mp = snap && snap.machinePosition;
if (!Array.isArray(mp) || mp.length < n || !mp.slice(0, n).every(Number.isFinite)) {
throw new Error(`Sync: ${role} lieferte keine gültige MPos (${JSON.stringify(mp)})`);
}
return mp;
};
const b = need(baseSnap, 'base', 3);
const e = need(elbowSnap, 'elbow', 1);
const h = need(handSnap, 'hand', 3);
// Port → Motorwerte (linear/eindeutig, ToDo_9a) → auf den Roboter schreiben.
const m = motorStateFromPorts({
base: { x: b[0], y: b[1], z: b[2] },
elbow: { x: e[0] },
hand: { x: h[0], y: h[1], z: h[2] },
});
robot.xMotor = m.xMotor;
robot.alpha = m.alpha;
robot.beta = m.beta;
robot.a = m.a;
robot.b = m.b;
robot.c = m.c;
robot.eMotor = m.eMotor;
// Vorwärtskinematik: füllt x/y/z + phi/theta/psi aus den Hardwarewerten.
robot.calculatePositionFromMotorAngles();
// motorPosition zurücksetzen, damit der nächste Move den Speed-Delta von der
// echten Position aus rechnet (sonst falscher Feedrate im Korrekt-Modus).
robot.motorPosition = null;
robot.motorPositionOld = null;
return {
x: robot.x, y: robot.y, z: robot.z,
phi: robot.phi, theta: robot.theta, psi: robot.psi,
};
}
} }
module.exports = RobotController; module.exports = RobotController;

View File

@@ -54,6 +54,33 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
this.autoConnect = options.autoConnect !== false; this.autoConnect = options.autoConnect !== false;
this.isTestMode = false; this.isTestMode = false;
// ── Hardware-Feedback (ToDo_9 Paket 1/3) ──────────────────────────────
// Eingehende GRBL/FluidNC-Antworten werden geparst (vorher verworfen).
this._rxBuffer = ''; // Zeilen-Puffer für fragmentierte data-Events
this.grblState = null; // 'Idle' | 'Run' | 'Alarm' | 'Hold' | ...
this.machinePosition = null; // [x, y, z, …] aus MPos (oder WPos)
this.machinePositionType = null; // 'MPos' | 'WPos'
this.plannerBlocksFree = null; // erste Bf-Zahl (freie Planner-Blöcke)
this.rxBytesFree = null; // zweite Bf-Zahl (freie RX-Bytes)
this.lastResponse = null; // letzte empfangene Zeile (roh)
this.lastError = null; // letzte error:/ALARM:-Zeile
this.lastOk = 0; // Zeitstempel des letzten 'ok'
this.lastReportAt = 0; // Zeitstempel des letzten <…>-Reports
this._statusWaiters = []; // offene requestStatusReport()-Promises (Sync, Paket 4)
// Auto-Reporting (Paket 3) — opt-in, schreibt persistente FluidNC-Settings.
// Default AUS: ohne Flag wird KEIN $10/$Report-Kommando an die Hardware
// gesendet; der Treiber liest nur die Antworten des ohnehin laufenden
// ?-Heartbeats.
this.autoReport = options.autoReport !== undefined
? !!options.autoReport
: (process.env.ROBOT_GRBL_AUTOREPORT === 'true');
this.reportInterval = Number.isFinite(options.reportInterval)
? options.reportInterval
: (Number.isFinite(Number(process.env.ROBOT_GRBL_REPORT_INTERVAL))
? Number(process.env.ROBOT_GRBL_REPORT_INTERVAL)
: 200);
if (urlGRBL === "test.test") { if (urlGRBL === "test.test") {
this.tSocket = { written: "", write(txt){ this.written = txt; } }; this.tSocket = { written: "", write(txt){ this.written = txt; } };
this.isTestMode = true; this.isTestMode = true;
@@ -128,6 +155,7 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
this.tSocket.on('close', () => { this.tSocket.on('close', () => {
console.log("Telnet Closed " + this.urlGRBLstr); console.log("Telnet Closed " + this.urlGRBLstr);
this._stopHeartbeat(); this._stopHeartbeat();
this._rejectStatusWaiters(new Error(`${this.urlGRBLstr}: Verbindung geschlossen`));
this.tSocket = null; this.tSocket = null;
this._rawSocket = null; this._rawSocket = null;
if (this.shouldReconnect) { if (this.shouldReconnect) {
@@ -141,6 +169,13 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
// Zeitstempel jeder eingehenden Nachricht festhalten (für Heartbeat-Timeout). // Zeitstempel jeder eingehenden Nachricht festhalten (für Heartbeat-Timeout).
socket.on('data', () => { this._lastDataAt = Date.now(); }); socket.on('data', () => { this._lastDataAt = Date.now(); });
// Eingehende GRBL/FluidNC-Antworten lesen (ToDo_9 Paket 1).
// Vorher wurde dieser Kanal verworfen — jetzt werden ok/error/<…>-Reports
// geparst. Frischer Zeilen-Puffer pro Verbindung (Reste der toten
// Verbindung dürfen die neue nicht verfälschen).
this._rxBuffer = '';
this.tSocket.on('data', (chunk) => this._handleIncomingData(chunk));
this.state = 'connected'; this.state = 'connected';
this.error = null; this.error = null;
this.reconnectAttempt = 0; this.reconnectAttempt = 0;
@@ -153,6 +188,9 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
// Heartbeat starten: erkennt NotAus / tote Verbindungen. // Heartbeat starten: erkennt NotAus / tote Verbindungen.
this._startHeartbeat(); this._startHeartbeat();
// Auto-Reporting konfigurieren (Paket 3) — nur bei aktivem Opt-in.
this._configureAutoReport();
}); });
socket.on('error', (error) => { socket.on('error', (error) => {
@@ -238,6 +276,182 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
} }
} }
/**
* Konfiguriert FluidNC-Auto-Reporting (ToDo_9 Paket 3) — nur bei aktivem Opt-in.
*
* Schreibt persistente Controller-Settings:
* $10=3 → Statusreport enthält MPos und Bf
* $Report/Interval=N → FluidNC pusht den Status während der Bewegung selbst
*
* Default AUS (ROBOT_GRBL_AUTOREPORT != 'true'): ohne Opt-in wird NICHTS gesendet,
* der Treiber liest nur die Antworten des ohnehin laufenden ?-Heartbeats.
* @private
*/
_configureAutoReport() {
if (!this.autoReport || !this.tSocket) return;
try {
this.tSocket.write('$10=3\r\n');
this.tSocket.write(`$Report/Interval=${this.reportInterval}\r\n`);
console.log(
`[TelnetSenderGRBL] ${this.urlGRBLstr}: Auto-Reporting aktiviert ` +
`($10=3, $Report/Interval=${this.reportInterval})`
);
} catch (err) {
// Fehler kommt ohnehin über das 'error'-Event; hier nur defensiv loggen.
console.log(
`[TelnetSenderGRBL] ${this.urlGRBLstr}: Auto-Report-Setup fehlgeschlagen: ${err.message}`
);
}
}
/**
* Verarbeitet einen eingehenden data-Chunk vom TelnetSocket (ToDo_9 Paket 1).
* Puffert über Zeilengrenzen (TCP-Chunks zerteilen Nachrichten beliebig) und
* gibt jede vollständige Zeile an _handleResponseLine().
*
* Wirft nie — ein Parsefehler darf den data-Handler (und damit den Prozess)
* nicht abreißen lassen.
* @private
*/
_handleIncomingData(chunk) {
try {
this._rxBuffer += chunk.toString('utf8');
// Schutz gegen unbegrenztes Wachstum, falls Zeilenenden ausbleiben.
if (this._rxBuffer.length > 8192) {
this._rxBuffer = this._rxBuffer.slice(-8192);
}
let idx;
while ((idx = this._rxBuffer.search(/\r?\n/)) !== -1) {
const line = this._rxBuffer.slice(0, idx);
const nlLen = this._rxBuffer[idx] === '\r' ? 2 : 1;
this._rxBuffer = this._rxBuffer.slice(idx + nlLen);
this._handleResponseLine(line);
}
} catch (err) {
console.log(`[TelnetSenderGRBL] ${this.urlGRBLstr}: data-Parse-Fehler: ${err.message}`);
}
}
/**
* Klassifiziert eine einzelne Antwortzeile und aktualisiert den geparsten Zustand.
* Demultiplext nach Nachrichtentyp (ToDo_9, Protokoll-Fakt 5): toleriert fremde
* Zeilen (Cross-Channel-Bleed-Through), nimmt kein striktes 1:1 Request→Response an.
* @private
*/
_handleResponseLine(line) {
const trimmed = line.trim();
if (!trimmed) return;
this.lastResponse = trimmed;
if (trimmed[0] === '<') {
this._parseStatusReport(trimmed);
return;
}
if (trimmed === 'ok') {
this.lastOk = Date.now();
return;
}
if (/^error:/i.test(trimmed) || /^ALARM/i.test(trimmed)) {
this.lastError = trimmed;
console.log(`[TelnetSenderGRBL] ${this.urlGRBLstr}: GRBL meldet ${trimmed}`);
return;
}
// Alles andere (Start-Banner, [MSG:…], Echo, fremde Kanäle): bewusst ignorieren.
}
/**
* Parst einen FluidNC-Statusreport: <State|MPos:x,y,z|Bf:blocks,bytes|…>.
* Speichert State, Maschinenposition (MPos bevorzugt, sonst WPos) und Bf.
* Defensiv: unvollständige/zerstörte Felder werden übersprungen, nie geworfen.
* @private
*/
_parseStatusReport(line) {
const inner = line.replace(/^</, '').replace(/>$/, '');
const fields = inner.split('|');
if (!fields.length) return;
if (fields[0]) this.grblState = fields[0];
for (let i = 1; i < fields.length; i++) {
const sep = fields[i].indexOf(':');
if (sep === -1) continue;
const key = fields[i].slice(0, sep);
const value = fields[i].slice(sep + 1);
if (!value) continue;
if (key === 'MPos' || key === 'WPos') {
const nums = value.split(',').map(Number);
if (nums.length && nums.every(Number.isFinite)) {
this.machinePosition = nums;
this.machinePositionType = key;
}
} else if (key === 'Bf') {
const nums = value.split(',').map(Number);
if (Number.isFinite(nums[0])) this.plannerBlocksFree = nums[0];
if (Number.isFinite(nums[1])) this.rxBytesFree = nums[1];
}
}
this.lastReportAt = Date.now();
this._resolveStatusWaiters();
}
/**
* Fordert einen frischen Statusreport an (ToDo_9 Paket 4): sendet das
* FluidNC-Realtime-Byte '?' und wartet auf den nächsten geparsten `<…>`-Report.
*
* Löst mit einem Snapshot ({grblState, machinePosition, …}) auf, sobald ein
* Report eintrifft, oder wirft nach `timeoutMs` ohne Antwort. Verändert den
* Roboterzustand NICHT — reines Lesen.
*
* @param {number} timeoutMs
* @returns {Promise<{grblState, machinePosition, machinePositionType, plannerBlocksFree, rxBytesFree}>}
*/
requestStatusReport(timeoutMs = 1000) {
if (!this.tSocket || typeof this.tSocket.write !== 'function') {
return Promise.reject(new Error(`${this.urlGRBLstr}: not connected`));
}
return new Promise((resolve, reject) => {
const waiter = { resolve, reject, timer: null };
waiter.timer = this.setTimeoutFn(() => {
this._statusWaiters = this._statusWaiters.filter(w => w !== waiter);
reject(new Error(`${this.urlGRBLstr}: Statusreport-Timeout nach ${timeoutMs}ms`));
}, timeoutMs);
this._statusWaiters.push(waiter);
try {
this.tSocket.write('?');
} catch (err) {
this.clearTimeoutFn(waiter.timer);
this._statusWaiters = this._statusWaiters.filter(w => w !== waiter);
reject(err);
}
});
}
/** Löst alle offenen requestStatusReport()-Promises mit dem aktuellen Snapshot auf. @private */
_resolveStatusWaiters() {
if (!this._statusWaiters || this._statusWaiters.length === 0) return;
const snapshot = {
grblState: this.grblState,
machinePosition: this.machinePosition,
machinePositionType: this.machinePositionType,
plannerBlocksFree: this.plannerBlocksFree,
rxBytesFree: this.rxBytesFree,
};
const waiters = this._statusWaiters;
this._statusWaiters = [];
waiters.forEach(w => { this.clearTimeoutFn(w.timer); w.resolve(snapshot); });
}
/** Bricht offene requestStatusReport()-Promises ab (z. B. bei Verbindungsverlust). @private */
_rejectStatusWaiters(reason) {
if (!this._statusWaiters || this._statusWaiters.length === 0) return;
const waiters = this._statusWaiters;
this._statusWaiters = [];
waiters.forEach(w => { this.clearTimeoutFn(w.timer); w.reject(reason); });
}
send(command) { send(command) {
if (!this.tSocket || typeof this.tSocket.write !== 'function') { if (!this.tSocket || typeof this.tSocket.write !== 'function') {
return false; return false;
@@ -259,12 +473,22 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
error: this.error, error: this.error,
isTestMode: !!this.isTestMode, isTestMode: !!this.isTestMode,
reconnectAttempt: this.reconnectAttempt, reconnectAttempt: this.reconnectAttempt,
reconnectTimer: !!this.reconnectTimer reconnectTimer: !!this.reconnectTimer,
// Hardware-Feedback (ToDo_9 Paket 1/3)
grblState: this.grblState,
machinePosition: this.machinePosition,
machinePositionType: this.machinePositionType,
plannerBlocksFree: this.plannerBlocksFree,
rxBytesFree: this.rxBytesFree,
lastError: this.lastError,
lastReportAt: this.lastReportAt,
autoReport: !!this.autoReport
}; };
} }
disconnect() { disconnect() {
this._stopHeartbeat(); this._stopHeartbeat();
this._rejectStatusWaiters(new Error(`${this.urlGRBLstr}: disconnect`));
if (this.isTestMode) { if (this.isTestMode) {
this.tSocket = null; this.tSocket = null;

38
robot/portInverse.js Normal file
View File

@@ -0,0 +1,38 @@
/**
* Port→Motor-Rückrechnung (ToDo_9 Paket 4, Baustein).
*
* Rekonstruiert aus den von den drei GRBL/FluidNC-Controllern gemeldeten
* Maschinen-Achswerten (`MPos`) die sieben Motorwerte des Roboters.
*
* Herleitung + Verifikation: doc/ToDo_9a_PortRueckrechnung.md
* Tests: test/Robot.PortInverse.test.js (15 Tests)
*
* Gilt für die PRODUKTIV-Verkabelung (startRobot.js):
* base: GRBL x←xMotor, y←alpha·D, z←(betaalpha)·D
* elbow: GRBL x←a·D
* hand: GRBL x←(cb)·D, y←eMotor·D, z←b·D
*
* Die Abbildung ist linear und EINDEUTIG umkehrbar — keine Zweig-Wahl nötig.
* Ändert sich die Verkabelung in startRobot.js, muss diese Umkehrung mitgezogen
* werden; der Round-Trip-Test `portValue(motorStateFromPorts(p)) ≈ p` schützt davor.
*/
const D = 180 / Math.PI;
/**
* @param {{base:{x:number,y:number,z:number}, elbow:{x:number}, hand:{x:number,y:number,z:number}}} r
* GRBL-Readings (Grad bzw. mm) der drei Controller.
* @returns {{xMotor:number, alpha:number, beta:number, a:number, b:number, c:number, eMotor:number}}
*/
function motorStateFromPorts(r) {
const xMotor = r.base.x; // x-Port = xMotor (mm, direkt)
const alpha = r.base.y / D; // y-Port = alpha·D
const beta = (r.base.z + r.base.y) / D; // z-Port = (betaalpha)·D ⇒ beta = z/D + alpha
const a = r.elbow.x / D; // Elbow x-Port = a·D
const b = r.hand.z / D; // Hand z-Port = b·D
const c = (r.hand.x + r.hand.z) / D; // Hand x-Port = (cb)·D ⇒ c = x/D + b
const eMotor = r.hand.y / D; // Hand y-Port = eMotor·D
return { xMotor, alpha, beta, a, b, c, eMotor };
}
module.exports = { motorStateFromPorts, D };

View File

@@ -52,7 +52,14 @@ function createInfoServer(httpsOptions, sharedState, robot, GCode, senders, opti
reconnectAttempt: status.reconnectAttempt || 0, reconnectAttempt: status.reconnectAttempt || 0,
reconnectTimer: !!status.reconnectTimer, reconnectTimer: !!status.reconnectTimer,
health, health,
reason reason,
// Hardware-Feedback (ToDo_9 Paket 1/3): GRBL-Zustand aus dem Sender.
grblState: status.grblState ?? null,
machinePosition: status.machinePosition ?? null,
plannerBlocksFree: status.plannerBlocksFree ?? null,
rxBytesFree: status.rxBytesFree ?? null,
lastError: status.lastError ?? null,
lastReportAt: status.lastReportAt ?? null
}; };
}); });

View File

@@ -52,11 +52,20 @@ function initInputWS(server, robot, GCode, sharedState) {
if (GCode.containsCommand(message)) { if (GCode.containsCommand(message)) {
console.log("🔵 GCode.receiveGCode: Incoming command: " + message); console.log("🔵 GCode.receiveGCode: Incoming command: " + message);
logCommand(sharedState, clientIP, message); logCommand(sharedState, clientIP, message);
let result;
try { try {
GCode.receiveGCode(robot, message); result = GCode.receiveGCode(robot, message);
} catch (err) { } catch (err) {
return sendError(ws, 'GCODE_ERROR', err.message, message); return sendError(ws, 'GCODE_ERROR', err.message, message);
} }
// Asynchroner Befehl (z. B. Hardware-Sync M114 R, ToDo_9 Paket 4): erst nach
// Abschluss antworten; Fehler maschinenlesbar an den Anfrager zurückgeben.
if (result && typeof result.then === 'function') {
result
.then(() => broadcast(wss, GCode.getM114(robot)))
.catch(err => sendError(ws, 'GCODE_ERROR', err.message, message));
return;
}
broadcast(wss, GCode.getM114(robot)); broadcast(wss, GCode.getM114(robot));
return; return;
} }

View File

@@ -112,6 +112,8 @@ function createApp(options = {}) {
heartbeatInterval: ctrl.heartbeatInterval, heartbeatInterval: ctrl.heartbeatInterval,
deadTimeout: 2 * ctrl.heartbeatInterval, deadTimeout: 2 * ctrl.heartbeatInterval,
}); });
// Rolle (base/elbow/hand) für den Hardware-Sync (ToDo_9 Paket 4) festhalten.
instance.controllerRole = key;
senders.push({ name, instance, isGCodeReceiver: true }); senders.push({ name, instance, isGCodeReceiver: true });
} }
} }

View File

@@ -117,7 +117,13 @@ describe('InfoServer', () => {
reconnectAttempt: 0, reconnectAttempt: 0,
reconnectTimer: false, reconnectTimer: false,
health: 'ok', health: 'ok',
reason: undefined reason: undefined,
grblState: null,
machinePosition: null,
plannerBlocksFree: null,
rxBytesFree: null,
lastError: null,
lastReportAt: null
}, },
{ {
name: 'Hand', name: 'Hand',
@@ -129,7 +135,13 @@ describe('InfoServer', () => {
reconnectAttempt: 0, reconnectAttempt: 0,
reconnectTimer: false, reconnectTimer: false,
health: 'disconnected', health: 'disconnected',
reason: 'no active socket connection' reason: 'no active socket connection',
grblState: null,
machinePosition: null,
plannerBlocksFree: null,
rxBytesFree: null,
lastError: null,
lastReportAt: null
} }
]); ]);
}); });
@@ -175,7 +187,13 @@ describe('InfoServer', () => {
reconnectAttempt: 2, reconnectAttempt: 2,
reconnectTimer: true, reconnectTimer: true,
health: 'warning', health: 'warning',
reason: undefined reason: undefined,
grblState: null,
machinePosition: null,
plannerBlocksFree: null,
rxBytesFree: null,
lastError: null,
lastReportAt: null
} }
]); ]);
}); });

View File

@@ -14,6 +14,8 @@
const TelnetSender = require('../robot/TelnetSenderGRBL'); const TelnetSender = require('../robot/TelnetSenderGRBL');
const MotorPosition = require('../robot/RobotMotorPosition'); const MotorPosition = require('../robot/RobotMotorPosition');
const Robot = require('../robot/kinematics/Arm3SegmentLinearX'); const Robot = require('../robot/kinematics/Arm3SegmentLinearX');
// Produktiv-Funktion unter Test (vorher inline in dieser Datei, jetzt extrahiert).
const { motorStateFromPorts } = require('../robot/portInverse');
const D = 180 / Math.PI; const D = 180 / Math.PI;
@@ -27,18 +29,9 @@ function buildSenders() {
}; };
} }
/* ---------- Die Umkehrung: GRBL-Readings → 7 Motorwerte ---------- */ /* ---------- Die Umkehrung kommt jetzt aus robot/portInverse.js ----------
// r = { base:{x,y,z}, elbow:{x}, hand:{x,y,z} } (GRBL-Achswerte, Grad bzw. mm) * (vorher hier inline; extrahiert für ToDo_9 Paket 4, damit Produktivcode und
function motorStateFromPorts(r) { * dieser Verifikations-Test dieselbe Funktion nutzen.) */
const xMotor = r.base.x; // x-Port = xMotor (mm, direkt)
const alpha = r.base.y / D; // y-Port = alpha·D
const beta = (r.base.z + r.base.y) / D; // z-Port = (beta-alpha)·D ⇒ beta = z/D + alpha
const a = r.elbow.x / D; // Elbow x-Port = a·D
const b = r.hand.z / D; // Hand z-Port = b·D
const c = (r.hand.x + r.hand.z) / D; // Hand x-Port = (c-b)·D ⇒ c = x/D + b
const eMotor = r.hand.y / D; // Hand y-Port = eMotor·D
return { xMotor, alpha, beta, a, b, c, eMotor };
}
/* ---------- Hilfen ---------- */ /* ---------- Hilfen ---------- */
// {x,y,z,a,b,c,e}-pos aus Motorwerten (Feld-Mapping der RobotMotorPosition) // {x,y,z,a,b,c,e}-pos aus Motorwerten (Feld-Mapping der RobotMotorPosition)

View File

@@ -0,0 +1,141 @@
'use strict';
// Tests für ToDo_9 Paket 4: Hardware-Sync-Command (M114 R).
// - syncFromHardware liest MPos aller 3 Controller → Motorwerte → Pose
// - bewegt den Roboter NICHT (kein sendCommand)
// - robust gegen fehlende Rolle / ungültige/fehlende MPos
const RobotController = require('../robot/RobotController');
const { motorStateFromPorts, D } = require('../robot/portInverse');
const Robot = require('../robot/kinematics/Arm3SegmentLinearX');
// ── Fake-Sender mit Rolle und vorgegebenem MPos-Snapshot ─────────────────────
function fakeSender(role, machinePosition, { fail } = {}) {
return {
controllerRole: role,
requestStatusReport: jest.fn(() =>
fail
? Promise.reject(new Error(`${role}: timeout`))
: Promise.resolve({ grblState: 'Idle', machinePosition, machinePositionType: 'MPos' })
),
};
}
/** Roboter mit den drei Sendern, deren MPos die gegebenen Motorwerte kodieren. */
function robotWithMotors(m) {
const robot = new Robot(250, 264, 100);
// Produktiv-Verkabelung: base[x,y,z]=[xMotor, alpha·D, (betaalpha)·D],
// elbow[x]=a·D, hand[x,y,z]=[(cb)·D, eMotor·D, b·D]
const base = [m.xMotor, m.alpha * D, (m.beta - m.alpha) * D];
const elbow = [m.a * D];
const hand = [(m.c - m.b) * D, m.eMotor * D, m.b * D];
robot.cmdReceivers = [
fakeSender('base', base),
fakeSender('elbow', elbow),
fakeSender('hand', hand),
];
return robot;
}
describe('RobotController.syncFromHardware (ToDo_9 Paket 4)', () => {
beforeAll(() => { jest.spyOn(console, 'log').mockImplementation(() => {}); });
afterAll(() => { jest.restoreAllMocks(); });
test('rekonstruiert Motorwerte aus MPos und übernimmt sie', async () => {
const motors = { xMotor: 100, alpha: 0.30, beta: 0.50, a: 0.20, b: 0.40, c: 0.90, eMotor: 1.0 };
const robot = robotWithMotors(motors);
await RobotController.syncFromHardware(robot);
expect(robot.xMotor).toBeCloseTo(motors.xMotor, 6);
expect(robot.alpha).toBeCloseTo(motors.alpha, 6);
expect(robot.beta).toBeCloseTo(motors.beta, 6);
expect(robot.a).toBeCloseTo(motors.a, 6);
expect(robot.b).toBeCloseTo(motors.b, 6);
expect(robot.c).toBeCloseTo(motors.c, 6);
expect(robot.eMotor).toBeCloseTo(motors.eMotor, 6);
});
test('rechnet die Pose vor und gibt sie zurück (gleiche wie Referenz-Roboter)', async () => {
const motors = { xMotor: 42, alpha: 0.10, beta: 0.10, a: 0.05, b: 1.20, c: 1.25, eMotor: 0.0 };
const robot = robotWithMotors(motors);
const pose = await RobotController.syncFromHardware(robot);
const ref = new Robot(250, 264, 100);
Object.assign(ref, motors);
ref.calculatePositionFromMotorAngles();
for (const k of ['x', 'y', 'z', 'phi', 'theta', 'psi']) {
expect(pose[k]).toBeCloseTo(ref[k], 6);
}
});
test('bewegt den Roboter NICHT (kein execCommand auf den Sendern)', async () => {
const robot = robotWithMotors({ xMotor: 10, alpha: 0.1, beta: 0.2, a: 0.1, b: 0.2, c: 0.3, eMotor: 0 });
// execCommand-Spy auf alle Sender; darf nie aufgerufen werden
robot.cmdReceivers.forEach(s => { s.execCommand = jest.fn(); });
await RobotController.syncFromHardware(robot);
robot.cmdReceivers.forEach(s => expect(s.execCommand).not.toHaveBeenCalled());
});
test('setzt motorPosition zurück (nächster Move rechnet von echter Position)', async () => {
const robot = robotWithMotors({ xMotor: 5, alpha: 0, beta: 0, a: 0, b: 0, c: 0, eMotor: 0 });
robot.motorPosition = { dummy: true };
robot.motorPositionOld = { dummy: true };
await RobotController.syncFromHardware(robot);
expect(robot.motorPosition).toBeNull();
expect(robot.motorPositionOld).toBeNull();
});
test('fragt alle drei Controller aktiv ab (requestStatusReport)', async () => {
const robot = robotWithMotors({ xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0, eMotor: 0 });
await RobotController.syncFromHardware(robot);
robot.cmdReceivers.forEach(s => expect(s.requestStatusReport).toHaveBeenCalledTimes(1));
});
test('wirft, wenn eine Controller-Rolle fehlt', async () => {
const robot = new Robot(250, 264, 100);
robot.cmdReceivers = [fakeSender('base', [0, 0, 0]), fakeSender('hand', [0, 0, 0])]; // elbow fehlt
await expect(RobotController.syncFromHardware(robot)).rejects.toThrow(/elbow/);
});
test('wirft bei ungültiger MPos (zu wenige Achsen)', async () => {
const robot = new Robot(250, 264, 100);
robot.cmdReceivers = [
fakeSender('base', [1, 2]), // nur 2 statt 3 Werte
fakeSender('elbow', [0]),
fakeSender('hand', [0, 0, 0]),
];
await expect(RobotController.syncFromHardware(robot)).rejects.toThrow(/base/);
});
test('wirft, wenn ein Controller nicht antwortet (Timeout propagiert)', async () => {
const robot = new Robot(250, 264, 100);
robot.cmdReceivers = [
fakeSender('base', [0, 0, 0]),
fakeSender('elbow', [0], { fail: true }),
fakeSender('hand', [0, 0, 0]),
];
await expect(RobotController.syncFromHardware(robot)).rejects.toThrow(/timeout/);
});
test('Dispatch: applyCommand(M114 R) liefert ein Promise', () => {
const robot = robotWithMotors({ xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0, eMotor: 0 });
const result = RobotController.applyCommand(robot, { command: 'M114', params: { R: true } });
expect(result && typeof result.then).toBe('function');
return result; // sauber auflösen lassen
});
test('receive(): bare M114 ohne R löst keinen Sync aus (synchron, undefined)', () => {
const robot = robotWithMotors({ xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0, eMotor: 0 });
robot.cmdReceivers.forEach(s => s.requestStatusReport.mockClear());
const result = RobotController.receive(robot, 'M114');
expect(result).toBeUndefined();
robot.cmdReceivers.forEach(s => expect(s.requestStatusReport).not.toHaveBeenCalled());
});
});

View File

@@ -0,0 +1,260 @@
'use strict';
// Tests für ToDo_9 Paket 1/3:
// Paket 1 — eingehende GRBL/FluidNC-Antworten lesen und klassifizieren
// Paket 3 — opt-in Auto-Report-Konfiguration ($10=3, $Report/Interval)
const { EventEmitter } = require('events');
const TelnetSenderGRBL = require('../robot/TelnetSenderGRBL');
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
function makeRawSocket() {
const em = new EventEmitter();
return Object.assign(em, {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
setKeepAlive: jest.fn(),
});
}
function makeTelnetSocket() {
const em = new EventEmitter();
return Object.assign(em, {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
}
/** Sender mit gemockten Abhängigkeiten; simuliert erfolgreichen Verbindungsaufbau. */
function setup(extraOptions = {}) {
const rawSocket = makeRawSocket();
const telnetSock = makeTelnetSocket();
const sender = new TelnetSenderGRBL(
'robot.local', 5000,
'x', 'y', 'z', null, null, null, null,
{
netModule: { createConnection: jest.fn(() => rawSocket) },
TelnetSocketClass: jest.fn(() => telnetSock),
setIntervalFn: jest.fn(() => 1),
clearIntervalFn: jest.fn(),
setTimeoutFn: jest.fn(() => 99),
clearTimeoutFn: jest.fn(),
autoConnect: false,
...extraOptions,
}
);
sender.connect();
rawSocket.emit('connect');
/** Simuliert vom Controller eingehende Bytes. */
const recv = (str) => telnetSock.emit('data', Buffer.from(str, 'utf8'));
return { sender, rawSocket, telnetSock, recv };
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('TelnetSenderGRBL — Antworten lesen (ToDo_9 Paket 1)', () => {
beforeAll(() => { jest.spyOn(console, 'log').mockImplementation(() => {}); });
afterAll(() => { jest.restoreAllMocks(); });
test('parst Statusreport: State, MPos und Bf', () => {
const { sender, recv } = setup();
recv('<Idle|MPos:151.000,149.000,-1.000|Bf:15,128>\r\n');
expect(sender.grblState).toBe('Idle');
expect(sender.machinePosition).toEqual([151.0, 149.0, -1.0]);
expect(sender.machinePositionType).toBe('MPos');
expect(sender.plannerBlocksFree).toBe(15);
expect(sender.rxBytesFree).toBe(128);
});
test('parst Run-Zustand', () => {
const { sender, recv } = setup();
recv('<Run|MPos:1,2,3|FS:500,0>\r\n');
expect(sender.grblState).toBe('Run');
expect(sender.machinePosition).toEqual([1, 2, 3]);
});
test('nutzt WPos, wenn kein MPos vorhanden', () => {
const { sender, recv } = setup();
recv('<Idle|WPos:10.5,20.5,30.5>\r\n');
expect(sender.machinePosition).toEqual([10.5, 20.5, 30.5]);
expect(sender.machinePositionType).toBe('WPos');
});
test('puffert über fragmentierte data-Events', () => {
const { sender, recv } = setup();
recv('<Idle|MPos:1.0');
// Noch keine vollständige Zeile → Zustand unverändert
expect(sender.machinePosition).toBeNull();
recv('00,2.000,3.000|Bf:10,120>\r\n');
expect(sender.machinePosition).toEqual([1.0, 2.0, 3.0]);
expect(sender.plannerBlocksFree).toBe(10);
});
test('verarbeitet mehrere Zeilen in einem Chunk', () => {
const { sender, recv } = setup();
recv('ok\r\nok\r\n<Idle|MPos:0,0,0>\r\n');
expect(sender.grblState).toBe('Idle');
expect(sender.lastResponse).toBe('<Idle|MPos:0,0,0>');
expect(sender.lastOk).toBeGreaterThan(0);
});
test('erkennt "ok" und setzt lastOk', () => {
const { sender, recv } = setup();
expect(sender.lastOk).toBe(0);
recv('ok\r\n');
expect(sender.lastOk).toBeGreaterThan(0);
});
test('erkennt error:-Zeile und legt sie in lastError ab', () => {
const { sender, recv } = setup();
recv('error:9\r\n');
expect(sender.lastError).toBe('error:9');
});
test('erkennt ALARM-Zeile', () => {
const { sender, recv } = setup();
recv('ALARM:1\r\n');
expect(sender.lastError).toBe('ALARM:1');
});
test('ignoriert fremde/unbekannte Zeilen ohne zu werfen (Cross-Channel)', () => {
const { sender, recv } = setup();
expect(() => recv('[MSG:some banner]\r\n')).not.toThrow();
expect(sender.grblState).toBeNull();
expect(sender.lastError).toBeNull();
// lastResponse wird trotzdem gesetzt
expect(sender.lastResponse).toBe('[MSG:some banner]');
});
test('zerstörter Report wirft nicht und lässt alten Zustand bestehen', () => {
const { sender, recv } = setup();
recv('<Idle|MPos:1,2,3>\r\n');
expect(() => recv('<Run|MPos:nope,bad,vals>\r\n')).not.toThrow();
// State wird übernommen, kaputte Position aber nicht
expect(sender.grblState).toBe('Run');
expect(sender.machinePosition).toEqual([1, 2, 3]);
});
test('Zeilen-Puffer wird bei Reconnect zurückgesetzt', () => {
const { sender, rawSocket, telnetSock, recv } = setup();
recv('<Idle|MPos:1.0'); // unvollständig im Puffer
expect(sender._rxBuffer.length).toBeGreaterThan(0);
// Reconnect: neuer Socket, connect erneut
telnetSock.emit('close');
rawSocket.emit('connect');
expect(sender._rxBuffer).toBe('');
});
test('getStatus() liefert die Hardware-Feedback-Felder', () => {
const { sender, recv } = setup();
recv('<Hold|MPos:5,6,7|Bf:12,100>\r\n');
const st = sender.getStatus();
expect(st.grblState).toBe('Hold');
expect(st.machinePosition).toEqual([5, 6, 7]);
expect(st.plannerBlocksFree).toBe(12);
expect(st.rxBytesFree).toBe(100);
expect(st.autoReport).toBe(false);
});
});
describe('TelnetSenderGRBL — Auto-Report Opt-in (ToDo_9 Paket 3)', () => {
beforeAll(() => { jest.spyOn(console, 'log').mockImplementation(() => {}); });
afterAll(() => { jest.restoreAllMocks(); });
test('Default AUS: sendet beim Connect keine Settings-Kommandos', () => {
const { telnetSock } = setup(); // autoReport nicht gesetzt
expect(telnetSock.write).not.toHaveBeenCalled();
});
test('Opt-in EIN: sendet $10=3 und $Report/Interval beim Connect', () => {
const { telnetSock } = setup({ autoReport: true, reportInterval: 150 });
expect(telnetSock.write).toHaveBeenCalledWith('$10=3\r\n');
expect(telnetSock.write).toHaveBeenCalledWith('$Report/Interval=150\r\n');
});
test('Opt-in EIN: nutzt Default-Intervall 200, wenn nicht angegeben', () => {
const { telnetSock } = setup({ autoReport: true });
expect(telnetSock.write).toHaveBeenCalledWith('$Report/Interval=200\r\n');
});
});
describe('TelnetSenderGRBL — requestStatusReport (ToDo_9 Paket 4)', () => {
beforeAll(() => { jest.spyOn(console, 'log').mockImplementation(() => {}); });
afterAll(() => { jest.restoreAllMocks(); });
// Setup mit erfassbarem Timeout-Callback (für den Timeout-Pfad).
function setupTimed() {
const rawSocket = makeRawSocket();
const telnetSock = makeTelnetSocket();
let timeoutCb = null;
const sender = new TelnetSenderGRBL(
'robot.local', 5000, 'x', 'y', 'z', null, null, null, null,
{
netModule: { createConnection: jest.fn(() => rawSocket) },
TelnetSocketClass: jest.fn(() => telnetSock),
setIntervalFn: jest.fn(() => 1),
clearIntervalFn: jest.fn(),
setTimeoutFn: jest.fn((cb) => { timeoutCb = cb; return 42; }),
clearTimeoutFn: jest.fn(),
autoConnect: false,
}
);
sender.connect();
rawSocket.emit('connect');
const recv = (str) => telnetSock.emit('data', Buffer.from(str, 'utf8'));
return { sender, rawSocket, telnetSock, recv, fireTimeout: () => timeoutCb && timeoutCb() };
}
test('sendet ? und löst beim nächsten Report auf', async () => {
const { sender, telnetSock, recv } = setupTimed();
const p = sender.requestStatusReport(1000);
expect(telnetSock.write).toHaveBeenCalledWith('?');
recv('<Idle|MPos:1,2,3|Bf:15,128>\r\n');
const snap = await p;
expect(snap.grblState).toBe('Idle');
expect(snap.machinePosition).toEqual([1, 2, 3]);
});
test('wirft bei Timeout ohne Report', async () => {
const { sender, fireTimeout } = setupTimed();
const p = sender.requestStatusReport(1000);
fireTimeout();
await expect(p).rejects.toThrow(/Timeout/);
});
test('wirft, wenn nicht verbunden', async () => {
const { sender } = setupTimed();
sender.tSocket = null;
await expect(sender.requestStatusReport(1000)).rejects.toThrow(/not connected/);
});
test('bricht offene Anfrage bei Verbindungsverlust ab', async () => {
const { sender, telnetSock } = setupTimed();
const p = sender.requestStatusReport(1000);
telnetSock.emit('close');
await expect(p).rejects.toThrow(/geschlossen/);
});
test('mehrere gleichzeitige Anfragen werden alle vom selben Report aufgelöst', async () => {
const { sender, recv } = setupTimed();
const p1 = sender.requestStatusReport(1000);
const p2 = sender.requestStatusReport(1000);
recv('<Run|MPos:4,5,6>\r\n');
const [s1, s2] = await Promise.all([p1, p2]);
expect(s1.machinePosition).toEqual([4, 5, 6]);
expect(s2.machinePosition).toEqual([4, 5, 6]);
});
});