diff --git a/README.md b/README.md index dd99996..cf0aabf 100644 --- a/README.md +++ b/README.md @@ -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_2_Anbindung.md` | Sender-Interface und Orchestrierung | ✅ erledigt | | `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_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_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_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 1–6 offen) | @@ -251,9 +253,9 @@ ToDo_8 Bugs beheben — kurz, blockiert nichts anderes ToDo_3 Config — Fundament für alles Weitere ToDo_1 Parser ┐ 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_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_9 Hardware-Feedback — baut auf ToDo_2 auf 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: - [ ] 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 - [ ] `FluidNCClient.js` evaluieren: als Ersatz oder Ergänzung zu `TelnetSenderGRBL`? - [x] HTTPS-Passphrase aus Env-Variable (`HTTPS_PASSPHRASE`) — erledigt diff --git a/appRobot_full_portainer_Stack.yaml b/appRobot_full_portainer_Stack.yaml index 82ddb56..2576c9f 100644 --- a/appRobot_full_portainer_Stack.yaml +++ b/appRobot_full_portainer_Stack.yaml @@ -1,10 +1,8 @@ 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 @@ -13,7 +11,7 @@ services: - "host.docker.internal:host-gateway" networks: - default - + appRobotDriver: container_name: appRobot_Driver image: node:24-alpine @@ -28,7 +26,10 @@ services: - GRBL_BASE_IP=192.168.0.183 - GRBL_ELLBOW_IP=192.168.0.202 - 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: - "2098:2098" - "2081:2081" @@ -36,6 +37,7 @@ services: - default + appRobotSimulation: container_name: appRobot_Simulation image: node:24-alpine @@ -45,6 +47,8 @@ services: environment: - TARGET_SERVER=wss://appRobot_Driver:2095 command: npm start + depends_on: + - appRobotDriver restart: unless-stopped ports: - "1003:1003" @@ -69,51 +73,30 @@ services: depends_on: - appRobotDriver restart: unless-stopped - + + appRobotHoming: - image: node:24-alpine + image: node:20-bullseye 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_VIDEO_DRIVER=wss://appRobot_Webcam:8448 - WSS_URL=wss://appRobot_Driver:2095 - - HTTPS_PORT=2093 - - WEBCAM_URL=http://appRobotWebcam:8444 + - HTTPS_PORT=2093 + - WEBCAM_URL=http://appRobot_Webcam:8444 - BODYTRACKER_URL=http://appRobotBodyTracker:8446 ports: - "2093:2093" depends_on: - appRobotDriver 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: - 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 @@ -137,13 +120,14 @@ services: -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: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: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 \ -R 0.0.0.0:9744:appRobot_CodeServer:8443 \ + -R 0.0.0.0:7060:overleaf:80 \ tunnel@server.schooltech.ch -p 2255 " @@ -151,44 +135,9 @@ services: 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) @@ -228,7 +177,6 @@ services: 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: @@ -245,25 +193,8 @@ services: 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: image: ultralytics/ultralytics:latest container_name: appRobot_Yolo @@ -276,7 +207,6 @@ services: - webcam: build: context: /tmp @@ -285,28 +215,29 @@ services: RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \ && rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/app - EXPOSE 8444 + EXPOSE 8424 image: approbotwebcam:latest - container_name: AppRobotWebcam + container_name: appRobot_Webcam restart: unless-stopped - # 8444 am Host veröffentlicht → direkter LAN-Zugriff (http://:8444) bleibt. - # Wenn ALLES über den Proxy läuft, diesen ports-Block entfernen → proxy-only. ports: - "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: - - ${APP_PATH:-.}:/usr/src/app + - /home/chk/Documents/appRobotWebcam:/usr/src/app + 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_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: - video environment: - NODE_ENV=production - PORT=8444 + - LIBVA_DRIVER_NAME=i965 networks: default: diff --git a/doc/ToDo_4_GCode.md b/doc/ToDo_4_GCode.md index c238ed0..d9c88b4 100644 --- a/doc/ToDo_4_GCode.md +++ b/doc/ToDo_4_GCode.md @@ -1,5 +1,13 @@ # 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 G-Code-Logik sauber von Datei-Management trennen. Die Bewegungssteuerung soll nicht durch Dateibefehle oder File-IO verwässert werden. diff --git a/doc/ToDo_6b_FileHandling.md b/doc/ToDo_6b_FileHandling.md index 237bf38..e111ccc 100644 --- a/doc/ToDo_6b_FileHandling.md +++ b/doc/ToDo_6b_FileHandling.md @@ -1,5 +1,14 @@ # 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 `GCode.receiveFC()` implementiert nur einen Bruchteil der erkannten Befehle: diff --git a/doc/ToDo_9_HardwareFeedback.md b/doc/ToDo_9_HardwareFeedback.md index ae9cd32..b39e371 100644 --- a/doc/ToDo_9_HardwareFeedback.md +++ b/doc/ToDo_9_HardwareFeedback.md @@ -79,6 +79,8 @@ erstmals einen Promise zurückgeben/awaiten können. | `ROBOT_SPEED_MODE` | `legacy` / `correct` | `legacy` | koordinierte Feedrate (ToDo_6a) | | `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_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` > 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 - - GRBL antwortet auf jeden G-Code-Befehl mit `ok` oder `error: ` - - Antworten parsen und ins Log schreiben -- [ ] Fehlerantworten nach außen meldbar machen - - an `InfoServer` oder über einen EventEmitter - - damit der WebSocket-Client Feedback bekommt, ob ein Befehl angenommen wurde +> Umgesetzt in `robot/TelnetSenderGRBL.js`. Der vorher blinde Kanal wird gelesen: +> `tSocket.on('data', …)` → `_handleIncomingData()` (zeilen-gepuffert, fragmentierungs- +> sicher) → `_handleResponseLine()` (demultiplext nach Typ). Tests: +> `test/Sender.Telnet.responseParsing.test.js`. + +- [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 @@ -108,20 +119,29 @@ erstmals einen Promise zurückgeben/awaiten können. - [ ] Timeout für ausbleibende `ok`-Antworten definieren - 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: - - `$10=3` setzen → Report enthält `MPos` **und** `Bf` (Protokoll-Fakt 2) - - `$Report/Interval=N` setzen (z. B. `N=100…200`) → FluidNC **pusht** den Status während - der Bewegung selbst (Protokoll-Fakt 4). Kein `?`-Polling-Loop nötig; `?` bleibt nur als - Einzelabfrage on demand (z. B. für Sync, Paket 4). -- [ ] `data`-Handler (Paket 1) parst die gepushten `<…>`-Reports: `state`, `MPos`, `Bf` - - robust gegen Cross-Channel-Fremdzeilen (Protokoll-Fakt 5) — nach Typ demultiplexen -- [ ] Gemeldete Hardware-Position (`MPos`) mit Softwareposition vergleichen - - bei Abweichung: warnen oder synchronisieren (→ Paket 4) - - schützt gegen Drift durch Endschalter-Auslösung, Motor-Stall, Verbindungsunterbrechung -- [ ] Status (`Idle`, `Run`, `Alarm`, `Hold`) + `Bf` für den `InfoServer` bereitstellen - - `/api/status` um GRBL-Zustand erweitern +> Report-Parsing + InfoServer-Anbindung umgesetzt. Die Settings-Writes (`$10`, +> `$Report/Interval`) sind **opt-in** (Env `ROBOT_GRBL_AUTOREPORT`, Default AUS), weil sie +> persistente FluidNC-NVS-Settings schreiben und auf der eingesetzten FluidNC-Version +> verifiziert werden müssen. Tests: `test/Sender.Telnet.responseParsing.test.js`, +> `test/InfoServer.test.js`. + +- [x] Beim Verbindungsaufbau je Controller konfigurieren — **opt-in** (`_configureAutoReport()`): + - `$10=3` → Report enthält `MPos` **und** `Bf` (Protokoll-Fakt 2) + - `$Report/Interval=N` (Default `N=200`) → FluidNC **pusht** den Status selbst (Protokoll-Fakt 4) + - **nur** wenn `ROBOT_GRBL_AUTOREPORT=true`; ohne Flag bleibt der `?`-Heartbeat (alle 10 s) + die einzige Statusquelle — ausreichend für Anzeige, kein Schreibzugriff auf die Hardware +- [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 > 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 - Kinematik-Helfer) und im Sync verdrahten -- [ ] **Round-Trip-Invariante** als Dauer-Test mitführen: `portValue(motorStateFromPorts(p)) ≈ p` - — schützt gegen Drift, falls sich die Verkabelung in `startRobot.js` ändert +- [x] `motorStateFromPorts()` in den Produktiv-Code gehoben: **`robot/portInverse.js`**. + `test/Robot.PortInverse.test.js` importiert jetzt diese Funktion (statt inline) — die + 15 Verifikations-Tests sind damit der Dauer-Guard für den Produktivcode. +- [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, > 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, ü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ß sonst nicht, wo der Roboter physisch wirklich steht. -- [ ] **G-Code-Befehl** (B6), z. B. `M114 R` (Read-Hardware) — durch `GCodeParser` + - `RobotController` geroutet, **nicht** als Sonderfall in `InputWS` wie heute `M114` - - klar abgegrenzt vom bestehenden `M114`, das nur die **Software**-Position zurückgibt - (`GCode.getM114(robot)` in `server/InputWS.js`) -- [ ] **Async-Dispatch (B6-Folge):** `RobotController.applyCommand` muss für diesen Befehl - einen Promise zurückgeben und auf die `?`-Antworten warten — der erste asynchrone Befehl - im bisher synchronen Dispatch-Pfad. -- [ ] Ablauf des Sync: - 1. an alle drei Sender einmalig `?` senden, je `MPos` aus der Antwort parsen (Paket 3) - 2. `motorStateFromPorts(...)` → sieben Motorwerte rekonstruieren (Baustein oben — linear/eindeutig, ToDo_9a) - 3. diese auf den Roboter schreiben: `robot.xMotor/alpha/beta/a/b/c/eMotor = …` - 4. **Vorwärtskinematik** anstoßen: `robot.calculatePositionFromMotorAngles()` - → füllt `robot.x/y/z` und `phi/theta/psi` aus den Hardwarewerten - 5. `motorPosition`/`motorPositionOld` zurücksetzen, damit der nächste Move sauber von - der echten Position aus rechnet (sonst falscher Speed-Delta im Korrekt-Modus) -- [ ] dem anfragenden Client die übernommene Pose zurückmelden (`reply(ws, …)`) -- [ ] **kein** automatisches Nachfahren — Sync ändert nur den Soll-Zustand, sendet keinen Move +> Umgesetzt. Befehl: **`M114 R`**. Pfad: `GCodeParser` → `RobotController.applyCommand` +> (M114-R-Branch) → `RobotController.syncFromHardware()` (async). Tests: +> `test/RobotController.sync.test.js`, `test/Sender.Telnet.responseParsing.test.js` +> (requestStatusReport). **Datenquelle: aktiv `?` + await** (gewählt; funktioniert auch +> ohne Auto-Report). + +- [x] **G-Code-Befehl** `M114 R` — durch `GCodeParser` + `RobotController` geroutet + - `GCodeParser` erhält jetzt Flag-Token ohne Wert (`R` → `params.R = true`) + - `GCode.containsCommand('M114 …')` erkennt es; das bestehende exakte `M114` (Software- + Position) in `InputWS` bleibt unberührt (wird vorher per `=== "M114"` abgefangen) +- [x] **Async-Dispatch:** `applyCommand` gibt für `M114 R` ein Promise zurück; `receive` + reicht es nur dann hoch (sonst synchron wie bisher). `InputWS` wartet auf das Promise und + broadcastet erst danach die neue Pose; Fehler gehen als Fehler-Envelope an den Anfrager. + **Alle anderen Befehle bleiben byte-identisch synchron.** +- [x] Ablauf des Sync (`syncFromHardware`): + 1. an `base`/`elbow`/`hand` je `requestStatusReport()` → sendet `?`, wartet auf `<…>` (Timeout 1 s) + 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/ > Stall von dem abweicht, was zuletzt gesendet wurde — genau diese Differenz soll Sync auflösen. diff --git a/doc/draft_filehandeling.md b/doc/draft_filehandeling.md new file mode 100644 index 0000000..352f3d5 --- /dev/null +++ b/doc/draft_filehandeling.md @@ -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 ` | Programm aktiv setzen | `PUT /active` | +| `FSave ` | 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 ` | 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 `;` (Aufnahme-Zeitstempel), + - die **Cursor-Zeile** trägt zusätzlich ein `!`: `;!`. + + ``` + 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) diff --git a/doc/draft_filehandeling_API.md b/doc/draft_filehandeling_API.md new file mode 100644 index 0000000..b80b078 --- /dev/null +++ b/doc/draft_filehandeling_API.md @@ -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://: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 ` 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 ` | `PUT /active` | ActiveState (gezielt) | +| `FSave ` | `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 ` | `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 `;` = 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 `;`) 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 diff --git a/docker-compose-robot.yaml b/docker-compose-robot.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/docker-compose-win.yaml b/docker-compose-win.yaml deleted file mode 100644 index 583658c..0000000 --- a/docker-compose-win.yaml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 6725701..74447cd 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,246 +1,28 @@ 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: - container_name: appRobot_Driver - image: node:24-alpine - working_dir: /usr/src/app - volumes: - - /home/chk/Documents/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 - # Austauschbare Kinematik (siehe doc/ToDo_12_InverseKinematikConfig_ROADMAP.md). - # Defaults entsprechen exakt dem bisherigen Verhalten. - - ROBOT_KINEMATICS=arm3segmentlinearx - - ROBOT_KINEMATICS_PARAMS={"l1": 250, "l2": 264, "l3": 100} - ports: - - "2096:2095" - - "2098:2098" - expose: - - "2095" - networks: - - 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 + appRobotDriver: + container_name: appRobot_Driver + image: node:24-alpine + working_dir: /usr/src/app + volumes: + - /home/chk/Documents/appRobotDriver:/usr/src/app + command: npm start + restart: unless-stopped + environment: + - NODE_ENV=development + - NODE_OPTIONS=--inspect=0.0.0.0:2081 + - GRBL_BASE_IP=192.168.0.183 + - GRBL_ELLBOW_IP=192.168.0.202 + - GRBL_HAND_IP=192.168.0.250 + - ROBOT_SPEED_MODE=correct + - ROBOT_KINEMATICS=arm3segmentlinearx + - ROBOT_GRBL_AUTOREPORT=true + - ROBOT_GRBL_REPORT_INTERVAL=200 + ports: + - "2098:2098" + - "2081:2081" + networks: + - default networks: default: diff --git a/logs/gcode_commands.log b/logs/gcode_commands.log index e3fa3da..7a74abf 100644 --- a/logs/gcode_commands.log +++ b/logs/gcode_commands.log @@ -10479,3 +10479,89 @@ 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: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 diff --git a/logs/pings.log b/logs/pings.log index 7751a87..6dcd8b6 100644 --- a/logs/pings.log +++ b/logs/pings.log @@ -14666,3 +14666,37 @@ 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.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 diff --git a/public/app.js b/public/app.js index 1b7edac..406241f 100644 --- a/public/app.js +++ b/public/app.js @@ -210,7 +210,7 @@ document.addEventListener('DOMContentLoaded', function() { if (stops[2]) stops[2].setAttribute('stop-color', '#880000'); if (textEl) textEl.setAttribute('fill', '#1a1000'); 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 { // ── POWER ON: Dunkler Navy-Ring + blauer Knopf — ruhig, klar ──────────── // Kein gelber Ring → kein E-Stop-Charakter. diff --git a/robot/GCode.js b/robot/GCode.js index 67fdcdf..fbe315c 100755 --- a/robot/GCode.js +++ b/robot/GCode.js @@ -31,6 +31,7 @@ class GCode{ static containsCommand(s){ 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('G90') == 0){return true;} if(s.indexOf('G91') == 0){return true;} @@ -88,7 +89,9 @@ class GCode{ * funktionieren. */ 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 diff --git a/robot/GCodeParser.js b/robot/GCodeParser.js index 05b727e..e34104f 100644 --- a/robot/GCodeParser.js +++ b/robot/GCodeParser.js @@ -43,7 +43,14 @@ class GCodeParser { const params = {}; for (let i = 1; i < tokens.length; i++) { 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; } diff --git a/robot/RobotController.js b/robot/RobotController.js index 37c419d..638173e 100644 --- a/robot/RobotController.js +++ b/robot/RobotController.js @@ -8,18 +8,26 @@ * der Controller kennt nur strukturierte Befehle, keine rohen Textstrings. */ const GCodeParser = require('./GCodeParser'); +const { motorStateFromPorts } = require('./portInverse'); class RobotController { /** * 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 {string|Buffer} message rohe G-Code-Nachricht */ static receive(robot, message) { const commands = GCodeParser.parse(message); 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; } + 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') { robot.createMotorPosition(); if (Number.isFinite(params.X)) { robot.xMotor = params.X; robot.xMotorChanged = true; } @@ -121,6 +135,80 @@ class RobotController { 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; diff --git a/robot/TelnetSenderGRBL.js b/robot/TelnetSenderGRBL.js index bae197c..92a6d46 100755 --- a/robot/TelnetSenderGRBL.js +++ b/robot/TelnetSenderGRBL.js @@ -54,6 +54,33 @@ module.exports = class TelnetSenderGRBL extends SenderInterface { this.autoConnect = options.autoConnect !== 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") { this.tSocket = { written: "", write(txt){ this.written = txt; } }; this.isTestMode = true; @@ -128,6 +155,7 @@ module.exports = class TelnetSenderGRBL extends SenderInterface { this.tSocket.on('close', () => { console.log("Telnet Closed " + this.urlGRBLstr); this._stopHeartbeat(); + this._rejectStatusWaiters(new Error(`${this.urlGRBLstr}: Verbindung geschlossen`)); this.tSocket = null; this._rawSocket = null; if (this.shouldReconnect) { @@ -141,6 +169,13 @@ module.exports = class TelnetSenderGRBL extends SenderInterface { // Zeitstempel jeder eingehenden Nachricht festhalten (für Heartbeat-Timeout). 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.error = null; this.reconnectAttempt = 0; @@ -153,6 +188,9 @@ module.exports = class TelnetSenderGRBL extends SenderInterface { // Heartbeat starten: erkennt NotAus / tote Verbindungen. this._startHeartbeat(); + + // Auto-Reporting konfigurieren (Paket 3) — nur bei aktivem Opt-in. + this._configureAutoReport(); }); 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: . + * 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(/^$/, ''); + 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) { if (!this.tSocket || typeof this.tSocket.write !== 'function') { return false; @@ -259,12 +473,22 @@ module.exports = class TelnetSenderGRBL extends SenderInterface { error: this.error, isTestMode: !!this.isTestMode, 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() { this._stopHeartbeat(); + this._rejectStatusWaiters(new Error(`${this.urlGRBLstr}: disconnect`)); if (this.isTestMode) { this.tSocket = null; diff --git a/robot/portInverse.js b/robot/portInverse.js new file mode 100644 index 0000000..3741628 --- /dev/null +++ b/robot/portInverse.js @@ -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←(beta−alpha)·D + * elbow: GRBL x←a·D + * hand: GRBL x←(c−b)·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 = (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 }; +} + +module.exports = { motorStateFromPorts, D }; diff --git a/server/InfoServer.js b/server/InfoServer.js index 6072498..8ea7b3d 100644 --- a/server/InfoServer.js +++ b/server/InfoServer.js @@ -52,7 +52,14 @@ function createInfoServer(httpsOptions, sharedState, robot, GCode, senders, opti reconnectAttempt: status.reconnectAttempt || 0, reconnectTimer: !!status.reconnectTimer, 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 }; }); diff --git a/server/InputWS.js b/server/InputWS.js index bdb91e6..799b439 100644 --- a/server/InputWS.js +++ b/server/InputWS.js @@ -52,11 +52,20 @@ function initInputWS(server, robot, GCode, sharedState) { if (GCode.containsCommand(message)) { console.log("🔵 GCode.receiveGCode: Incoming command: " + message); logCommand(sharedState, clientIP, message); + let result; try { - GCode.receiveGCode(robot, message); + result = GCode.receiveGCode(robot, message); } catch (err) { 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)); return; } diff --git a/startRobot.js b/startRobot.js index 34e45bc..076a375 100755 --- a/startRobot.js +++ b/startRobot.js @@ -112,6 +112,8 @@ function createApp(options = {}) { heartbeatInterval: 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 }); } } diff --git a/test/InfoServer.test.js b/test/InfoServer.test.js index 0ffdb28..1914e6a 100644 --- a/test/InfoServer.test.js +++ b/test/InfoServer.test.js @@ -117,7 +117,13 @@ describe('InfoServer', () => { reconnectAttempt: 0, reconnectTimer: false, health: 'ok', - reason: undefined + reason: undefined, + grblState: null, + machinePosition: null, + plannerBlocksFree: null, + rxBytesFree: null, + lastError: null, + lastReportAt: null }, { name: 'Hand', @@ -129,7 +135,13 @@ describe('InfoServer', () => { reconnectAttempt: 0, reconnectTimer: false, 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, reconnectTimer: true, health: 'warning', - reason: undefined + reason: undefined, + grblState: null, + machinePosition: null, + plannerBlocksFree: null, + rxBytesFree: null, + lastError: null, + lastReportAt: null } ]); }); diff --git a/test/Robot.PortInverse.test.js b/test/Robot.PortInverse.test.js index fc9e7a8..8ae7227 100644 --- a/test/Robot.PortInverse.test.js +++ b/test/Robot.PortInverse.test.js @@ -14,6 +14,8 @@ const TelnetSender = require('../robot/TelnetSenderGRBL'); const MotorPosition = require('../robot/RobotMotorPosition'); 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; @@ -27,18 +29,9 @@ function buildSenders() { }; } -/* ---------- Die Umkehrung: GRBL-Readings → 7 Motorwerte ---------- */ -// r = { base:{x,y,z}, elbow:{x}, hand:{x,y,z} } (GRBL-Achswerte, Grad bzw. mm) -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 = (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 }; -} +/* ---------- Die Umkehrung kommt jetzt aus robot/portInverse.js ---------- + * (vorher hier inline; extrahiert für ToDo_9 Paket 4, damit Produktivcode und + * dieser Verifikations-Test dieselbe Funktion nutzen.) */ /* ---------- Hilfen ---------- */ // {x,y,z,a,b,c,e}-pos aus Motorwerten (Feld-Mapping der RobotMotorPosition) diff --git a/test/RobotController.sync.test.js b/test/RobotController.sync.test.js new file mode 100644 index 0000000..e0a853d --- /dev/null +++ b/test/RobotController.sync.test.js @@ -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, (beta−alpha)·D], + // elbow[x]=a·D, hand[x,y,z]=[(c−b)·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()); + }); +}); diff --git a/test/Sender.Telnet.responseParsing.test.js b/test/Sender.Telnet.responseParsing.test.js new file mode 100644 index 0000000..58595f4 --- /dev/null +++ b/test/Sender.Telnet.responseParsing.test.js @@ -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('\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('\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('\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('\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\r\n'); + expect(sender.grblState).toBe('Idle'); + expect(sender.lastResponse).toBe(''); + 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('\r\n'); + expect(() => recv('\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(' { + const { sender, recv } = setup(); + recv('\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('\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('\r\n'); + const [s1, s2] = await Promise.all([p1, p2]); + expect(s1.machinePosition).toEqual([4, 5, 6]); + expect(s2.machinePosition).toEqual([4, 5, 6]); + }); +});