From 05f0dd619abc8829a4d4b55ed5a6320d90298856 Mon Sep 17 00:00:00 2001 From: chk <79915315+ChKendel@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:23:54 +0200 Subject: [PATCH] i2c vorbereiten --- doc/Info_Koordinaten.md | 52 ++-- doc/ToDo_15_AccelerationSensor.md | 271 +++++++++++++++++ doc/{ => erledigt}/15_EmergencyStop_done.md | 0 .../ToDo_12_InverseKinematikConfig_ROADMAP.md | 0 doc/{ => erledigt}/ToDo_2_Anbindung.md | 0 doc/{ => erledigt}/ToDo_4_GCode.md | 0 doc/{ => erledigt}/ToDo_6_RobotController.md | 0 doc/{ => erledigt}/ToDo_7_Tests.md | 0 logs/gcode_commands.log | 9 + logs/pings.log | 2 + robot/SenderInterface.js | 4 +- robot/WSSenderGrbl.js | 75 ++++- test/Sender.WS.responseParsing.test.js | 284 ++++++++++++++++++ 13 files changed, 676 insertions(+), 21 deletions(-) create mode 100644 doc/ToDo_15_AccelerationSensor.md rename doc/{ => erledigt}/15_EmergencyStop_done.md (100%) rename doc/{ => erledigt}/ToDo_12_InverseKinematikConfig_ROADMAP.md (100%) rename doc/{ => erledigt}/ToDo_2_Anbindung.md (100%) rename doc/{ => erledigt}/ToDo_4_GCode.md (100%) rename doc/{ => erledigt}/ToDo_6_RobotController.md (100%) rename doc/{ => erledigt}/ToDo_7_Tests.md (100%) create mode 100644 test/Sender.WS.responseParsing.test.js diff --git a/doc/Info_Koordinaten.md b/doc/Info_Koordinaten.md index 3ba2561..ff1f141 100644 --- a/doc/Info_Koordinaten.md +++ b/doc/Info_Koordinaten.md @@ -8,7 +8,16 @@ Diese Datei beschreibt > **Status:** **Phase 1 (y-Flip) ist umgesetzt und am Roboter verifiziert.** α/β werden > jetzt von **−y** aus gemessen (α=0 → Arm zeigt nach −y), passend zu robot.json und -> appRobotHoming. Offen ist **Phase 2** (Handgelenk-/Finger-Nullstellung, B/C). +> appRobotHoming. +> +> **Phase 2 B-Konvention: Entscheidung gefallen (2026-06-26).** Ein Versuch, gerade Hand +> auf B=0 umzustellen, führte zu einem Hardware-Crash (G28 fuhr von B≈179 nach B=0 → +> Hand schlug in den Arm). Die Konvention **bleibt bei b=π (180°) = gerade Hand**. +> Der Homing-Export-Fehler (`180−b` statt `180+b`) ist in `appRobotHoming` behoben; +> der Driver selbst ist korrekt und unverändert. Siehe +> [`appRobotHoming/doc/Homing_8_G92_B_Flip.md`](../../appRobotHoming/doc/Homing_8_G92_B_Flip.md). +> +> Weiterhin offen: **C-Nullpunkt** und **Greifer-Kopplung**. --- @@ -87,8 +96,8 @@ Diese drei Bedingungen erfüllt der Driver **nach Phase 1 bereits** (verifiziert | Y (α) | **0°** | Oberarm waagerecht entlang −y | ✅ Phase 1 | | Z (β) | **0°** | Unterarm waagerecht entlang −y (gestreckt) | ✅ Phase 1 | | A (a) | **0°** | Hand-Knick-Achse ∥ x | ✅ (Code erfüllt es) | -| B (b) | 0° (Ziel) | Hand gerade — **derzeit ist gerade = 180°** | ⏳ Phase 2 | -| C (c) | 0° (Ziel) | kein Hand-Roll — **derzeit neutral ≠ 0** | ⏳ Phase 2 | +| B (b) | **180°** | Hand gerade (b=π = gerade Hand, Hardware-verifiziert) | ✅ **endgültig** | +| C (c) | 0° (Ziel) | kein Hand-Roll — **derzeit neutral ≠ 0** | ⏳ offen | | E (e) | **0** | Greifer geschlossen / Referenz | — | → Resultierende Fingerspitze (α=β=a=0, gerade Hand): **(xMotor, −(l1+l2+l3), 0) ≈ (x, −590, 0)**. @@ -104,7 +113,7 @@ Diese drei Bedingungen erfüllt der Driver **nach Phase 1 bereits** (verifiziert | β=0 Unterarm | **−y** ✅ | −y | | a=0 Hand-Knick-Achse | **∥ x** ✅ | ∥ x | | G92 der Grundstellung → y | **≈ −590** ✅ | ≈ −590 | -| gerade Hand | **b = 180°** | **b = 0°** | +| gerade Hand | **b = 180°** ✅ (endgültig) | b = 180° | | neutraler Roll | **c: ψ = 90° − C** (posenabh.) | **c = 0°** | Verifiziert per FK: `FK(α=0, β=0, gerade Hand) → (0, −590, 0)`; bei `a=0` bleibt @@ -131,14 +140,16 @@ Umgesetzt: - **Migration:** `Robot.02_UpperArm` und der G28-Test auf −y umgestellt. - **Doku:** `doc/Info_G92.md` Y/Z (und C/A nach der Spiegelung) aktualisiert. -### Phase 2 — Handgelenk-/Finger-Nullstellung (B, C, Greifer) — OFFEN +### Phase 2 — Handgelenk-/Finger-Nullstellung (B, C, Greifer) -> **Voraussetzung (User):** Erst die Finger visualisieren/prüfen — dort werden noch Fehler -> vermutet. **a-Achse ist bereits korrekt** (a=0 → Knick-Achse ∥ x). Phase 2 betrifft +> **a-Achse ist korrekt** (a=0 → Knick-Achse ∥ x). Phase 2 betrifft > **B (Knick)**, **C (Roll)** und die **Greifer-Kopplung**. -**Ziel-Konvention:** gerade Hand → **B = 0°** (statt 180°); neutraler Roll → **C = 0°**; -Greifer-Kopplung konsistent und gegen die echte Mechanik kalibriert. +**B-Konvention: ENTSCHIEDEN, KEINE ÄNDERUNG.** Die gerade Hand bleibt **b = 180° (π rad)**. +Ein Umstellungs-Versuch auf B=0 wurde hardwaregetestet und hat zu einem Crash geführt (s.o.). +Der Fehler lag ausschließlich im Homing-Export — **der Driver ist korrekt**. + +**C und Greifer: offen.** #### Vorab-Erkenntnis aus dem Code (wichtig!) @@ -176,13 +187,11 @@ Fundstellen: - Aufgabe: Kopplung gegen die echte Sehnenmechanik validieren, toten x-Port-Pfad + `factorOpenTurn` aufräumen, **Vorzeichen** je nach Motor-Verkabelung prüfen. -3. **B-Konvention (gerade = 0°).** Durchgängig: - - FK/IK in `Arm3SegmentLinearX` (b-Definition / acos-Zweig), - - `gripperMotorFromOpening` nachziehen, - - G92-Eingabe (`b = B/D`) + M1 + G28, - - `portInverse.js` (Umkehrung), - - **Sender-Formeln so kompensieren, dass die FluidNC-Ports unverändert bleiben** — - ODER bewusst die Hardware-Nullpunkte neu kalibrieren (Entscheidung dokumentieren). +3. **B-Konvention: ✅ ABGESCHLOSSEN / KEINE ÄNDERUNG.** + Die gerade Hand ist und bleibt **b = 180° (π rad)**. Der Homing-Export-Bug + (`180−b` → `180+b`) ist in `appRobotHoming/server/fkStateToDriverG92.cjs` behoben. + Im Driver (`Arm3SegmentLinearX`, `RobotController`, `portInverse.js`) gibt es nichts + zu ändern. 4. **C-Nullpunkt (neutral = 0°).** Der `c↔ψ`-Bezug ist **posenabhängig** (`ψ = acos(cos β · sin a) − c`). Ein konstantes `c=0=neutral` ist **nicht global** möglich, @@ -217,8 +226,10 @@ isoliert) prüfen — das Modell allein genügt hier nicht, weil es um die Hardw - **Phase 1 (erfüllt):** G92 der Grundstellung → Driver `y ≈ −590, z ≈ 0`; appRobotHoming sendet die gemessenen α/β/a **direkt** (ohne Spiegelung); **G28 fährt sauber gestreckt nach −y** (a=0, kein Singularitäts-Müll); volle Suite grün. -- **Phase 2 (Ziel):** Grundstellung mit **allen** Gelenkwinkeln 0 (inkl. B=C=0); Greifer- - Kopplung vereinheitlicht; Finger visuell korrekt. +- **Phase 2 B (abgeschlossen):** B=180° = gerade Hand — endgültige Konvention, Hardware- + verifiziert. Homing-Export korrigiert. Driver unverändert korrekt. +- **Phase 2 C+Greifer (offen):** Grundstellung mit C=0; Greifer-Kopplung gegen echte Mechanik + kalibriert; Finger visuell korrekt. --- @@ -227,7 +238,10 @@ isoliert) prüfen — das Modell allein genügt hier nicht, weil es um die Hardw - **y-Flip (Phase 1):** Spiegelung in `Arm3SegmentLinearX` (`_mirrorWorkspaceY`, genutzt von `calculateAngles3D` und `calculatePositionFromMotorAngles`). Am Roboter bestätigt. - **G28-Singularität (Phase 1):** voll ausgestreckt setzt `RobotController` die Motorwerte - direkt (statt der singulären IK) → Finger sauber entlang −y. + direkt (statt der singulären IK) → `robot.b = Math.PI` (gerade Hand), Finger sauber entlang −y. +- **B-Konvention (endgültig):** `b = π (180°)` = gerade Hand. Hardware-verifiziert. + Kein Umbau geplant. Homing-Export-Bug (`180−b` → `180+b`) in `appRobotHoming` behoben; + Doku: [`appRobotHoming/doc/Homing_8_G92_B_Flip.md`](../../appRobotHoming/doc/Homing_8_G92_B_Flip.md). - **atan2-Fix** in der IK (`gamma = Math.atan2(pZ, pY)`): macht die interne IK für −y mathematisch korrekt — Voraussetzung des y-Flips. - **Winkel-Konventionen** (Y/Z/A/B/C/E) sind in [doc/Info_G92.md](Info_G92.md) dokumentiert diff --git a/doc/ToDo_15_AccelerationSensor.md b/doc/ToDo_15_AccelerationSensor.md new file mode 100644 index 0000000..91e3393 --- /dev/null +++ b/doc/ToDo_15_AccelerationSensor.md @@ -0,0 +1,271 @@ +# ToDo 15 — Beschleunigungssensor LIS3DH (Unterarm / Hand-Controller) + +## Kontext + +Der „Hand"-FluidNC-Controller (`fluidNcHand.local`) hat einen LIS3DH I²C-Sensor +an Adresse `0x18` (dezimal 24). Die angepasste FluidNC-Firmware unterstützt M260/M261 +für I²C-Zugriff (siehe `FluidNC/docs/i2c-gcode-integration.md`). + +Ziel: X/Y/Z-Beschleunigungsdaten periodisch auslesen, in der Info-Page anzeigen, +und später den daraus berechneten Winkel mit dem Position-Driver-Winkel `b` vergleichen. + +--- + +## Designentscheidung: WSSenderGrbl statt TelnetSenderGRBL + +**Gewählt:** `WSSenderGrbl` (WebSocket, Port 81 = FluidNC WebUI-Protokoll). + +**Begründung:** +- Genau **eine** Verbindung pro Controller, die G-Code-Moves und Sensordaten gemeinsam + trägt — kein zweiter paralleler Socket. +- Die WebSocket-Verbindung empfängt dieselben Ausgaben wie Telnet: Status-Reports + ``, `ok`/`error`-Zeilen, und `log_info`-Nachrichten wie `I2CRESP`. +- Der `grblState === 'Idle'`-Check (aus den Status-Reports) verhindert I²C-Polling + während eines laufenden Moves — genau wie TelnetSenderGRBL es macht. + +**Nicht gewählt:** TelnetSenderGRBL — hätte zwar weniger Umbauaufwand (Response-Parser +existiert dort bereits), aber der Umbau von WSSenderGrbl ist sinnvoll, weil die +Response-Infrastruktur dort früher oder später sowieso gebraucht wird. + +**Hinweis:** FluidNC sendet auf Port 81 pro WebSocket-Frame eine vollständige Zeile +(kein TCP-Fragmentierungsproblem). Das vereinfacht den Parser gegenüber Telnet. + +--- + +## Phase 1 — Sensor auslesen (Backend) + +### 1.1 Initialisierung des LIS3DH (einmalig nach Connect) + +``` +M260 P24 Q32 R87 → CTRL_REG1 0x20 = 0x57 (100 Hz, alle drei Achsen aktiv) +``` + +Muss gesendet werden, sobald der Hand-Controller verbunden ist (nach dem ersten Connect +oder Reconnect). Ohne diesen Schritt liefern OUT_X/Y/Z nur Nullen. + +### 1.2 Lesesequenz (wiederholt, z. B. alle 500 ms) + +``` +M260 P24 Q168 → Registerzeiger auf 0xA8 (= OUT_X_L | 0x80 Auto-Increment) +M261 P24 L6 → 6 Bytes lesen: X_L X_H Y_L Y_H Z_L Z_H +``` + +Antwortformat in der WebSocket-Ausgabe (via FluidNC `log_info`): +``` +I2CRESP B0 A0x18: C0 01 40 F8 80 3F +``` + +### 1.3 Byte → g-Wert Umrechnung (JavaScript) + +```js +function parseLIS3DH(hexBytes) { // z. B. ['C0','01','40','F8','80','3F'] + function toSigned16(lo, hi) { + const raw = (parseInt(hi, 16) << 8) | parseInt(lo, 16); + return raw > 32767 ? raw - 65536 : raw; + } + const x = toSigned16(hexBytes[0], hexBytes[1]) >> 4; // 12-bit rechtsbündig + const y = toSigned16(hexBytes[2], hexBytes[3]) >> 4; + const z = toSigned16(hexBytes[4], hexBytes[5]) >> 4; + // ±2 g Bereich: 1 g ≈ 1024 LSB + return { x: x / 1024, y: y / 1024, z: z / 1024 }; +} +``` + +--- + +## Phase 2 — Winkelberechnung (später, wenn Achsenorientierung bekannt) + +Wenn Schwerkraft = einzige Beschleunigung (Arm ruhig): + +```js +const roll = Math.atan2(y, z); // Rotation um X-Achse +const pitch = Math.atan2(-x, Math.hypot(y, z)); // Rotation um Y-Achse +``` + +Welche Winkelachse dem Motor `b` entspricht, muss durch Ausprobieren (Arm drehen, +Sensor beobachten) bestimmt werden. + +Vergleich mit Position Driver: +- Sensorwinkel (rad) ↔ `robot.b` (rad, Konvention: gerade Hand = π) +- Differenz = Schlupf / Kalibrierungsfehler + +--- + +## ToDo-Liste: Programmänderungen + +### A — `robot/WSSenderGrbl.js` — Response-Infrastruktur ✅ erledigt + +**A1 — EventEmitter einbinden** ✅ +- `SenderInterface` erbt jetzt von `EventEmitter` → WSSenderGrbl und TelnetSenderGRBL + bekommen die Fähigkeit automatisch, ohne eigene `class`-Zeile zu ändern. + +**A2 — Eingehende Nachrichten empfangen** ✅ +- `ws.on('message', (data) => this._handleMessage(data))` in `_tryConnect()` +- `_handleMessage(data)`: trimmt, verwirft Leerzeilen, ruft `_handleResponseLine()` + +**A3 — Response-Zeilen auswerten** ✅ +- `_handleResponseLine(line)`: dispatcht auf `_parseStatusReport`, `_handleI2CResp`, + oder setzt `lastError` + emittiert `'error-report'` +- Neue Felder im Konstruktor: `grblState`, `machinePosition`, `lastReportAt`, `lastError`, + `_i2cRespResolve`, `_i2cRespReject` + +**A4 — Status-Reports parsen** ✅ +- `_parseStatusReport(line)`: extrahiert `grblState` + `machinePosition`, setzt + `lastReportAt`, emittiert `'status'` +- `getStatus()` gibt jetzt auch `grblState`, `machinePosition`, `lastReportAt`, + `lastError` zurück + +**A5 — Heartbeat (Idle-Erkennung)** ✅ +- `_startHeartbeat()` / `_stopHeartbeat()` via injizierbare `setIntervalFn` / + `clearIntervalFn`; sendet `?` im Intervall (default 10 s) +- Startet in `ws.on('open')`, stoppt in `ws.on('close')` und `disconnect()` + +**Unit-Tests:** `test/Sender.WS.responseParsing.test.js` — alle Tests grün. + +--- + +### B — `robot/WSSenderGrbl.js` — I²C / LIS3DH + +(Baut auf Abschnitt A auf; `grblState` muss verfügbar sein.) + +**B1 — I2CRESP-Handler** +- [ ] Neue Felder: + ```js + this._i2cRespResolve = null; + this._i2cRespReject = null; + ``` +- [ ] Methode `_handleI2CResp(line)`: + - Bytes-String extrahieren (nach dem letzten `:`) + - als Array von Hex-Strings splitten + - `_i2cRespResolve(bytes)` aufrufen, Felder auf `null` zurücksetzen + +**B2 — Async-Helfer** +- [ ] Methode `async _waitI2CResp(timeoutMs = 500)` → Promise: + - setzt `_i2cRespResolve` / `_i2cRespReject` + - Timeout löst mit `null` auf (kein Fehler-Throw, damit Polling weiterläuft) +- [ ] Methode `async sendAndWaitI2CResp(gcode, timeoutMs = 500)`: + - prüft `grblState === 'Idle'` → bei nicht-Idle: return `null` + - startet `_waitI2CResp()` + - sendet `gcode` via `this.send(gcode)` + - `await`-et das Promise, gibt Bytes-Array zurück + +**B3 — Sensor-Methoden** +- [ ] Methode `async initLIS3DH()`: + ``` + M260 P24 Q32 R87 + ``` + Sendet Konfigurationsbefehl, wartet auf `ok` (oder kurzes Delay). +- [ ] Methode `async readLIS3DH()` → `{ x, y, z }` oder `null`: + 1. `M260 P24 Q168` senden (Registerzeiger), auf `ok` warten + 2. `M261 P24 L6` senden, auf `I2CRESP` warten + 3. Bytes mit `parseLIS3DH()` umrechnen + +**B4 — Polling-Loop** +- [ ] Methode `startAccelerometerPolling(intervalMs = 1000)`: + - ruft `initLIS3DH()` einmalig auf + - startet `setInterval` mit `readLIS3DH()` + - speichert Ergebnis in `this.accelerometer = { x, y, z, timestamp }` + - `emit('accelerometer', this.accelerometer)` +- [ ] Methode `stopAccelerometerPolling()`: löscht Interval +- [ ] `startAccelerometerPolling()` in `ws.on('open', …)` nach `_startHeartbeat()` aufrufen, + nur wenn `this.controllerRole === 'hand'` +- [ ] Bei `ws.on('close', …)`: `stopAccelerometerPolling()` aufrufen + +--- + +### C — `startRobot.js` — Hand-Controller auf WSSenderGrbl umstellen + +Aktuell verwendet `startRobot.js` für alle Controller denselben `TelnetSenderClass`. + +- [ ] `WSSenderGrbl` importieren: + ```js + const WSSender = require('./robot/WSSenderGrbl'); + ``` +- [ ] Beim Erstellen der Sender-Instanzen: für den Hand-Controller `WSSenderGrbl` + verwenden, für Base und Elbow weiterhin `TelnetSenderClass`: + ```js + const SenderClass = key === 'hand' ? WSSender : TelnetSenderClass; + const instance = new SenderClass(ctrl.ip, ctrl.port, ...axes7, { … }); + ``` + (WSSenderGrbl ignoriert `port` im Konstruktor und nutzt immer Port 81 — ggf. + `wsPort` als separates Option-Feld in `robot.json` ergänzen.) +- [ ] Alternativ: `WSSenderClass` als injizierbaren Parameter in `startRobot()` ergänzen + (analog zu `TelnetSenderClass`), damit Tests mocken können. + +--- + +### D — `robot/AccelerometerService.js` (neue Datei) + +- [ ] Klasse `AccelerometerService` anlegen +- [ ] Konstruktor nimmt `handSender` (WSSenderGrbl-Instanz) +- [ ] `start()`: lauscht auf `handSender.on('accelerometer', …)` und speichert Daten +- [ ] `getLatest()`: gibt `{ x, y, z, timestamp }` zurück (oder `null`) +- [ ] (Phase 2) `getAngle()`: berechnet Roll/Pitch, gibt `{ roll, pitch }` zurück + +--- + +### E — `server/InfoServer.js` + +- [ ] `AccelerometerService` importieren, Instanz mit Hand-Sender erzeugen, `start()` aufrufen +- [ ] Neuer Endpunkt `GET /api/acceleration`: + ```js + app.get('/api/acceleration', (req, res) => { + res.json(accelerometerService.getLatest() ?? { x: null, y: null, z: null }); + }); + ``` +- [ ] (Phase 2) Endpunkt um `angle` erweitern + +--- + +### F — `public/index.html` + +- [ ] Neuen Abschnitt **Acceleration** in der Info-Page einfügen (nach „Position Driver"): + ```html +
+

Acceleration (LIS3DH)

+ + + + +
Xg
Yg
Zg
+ + + + + + +
+ ``` + +--- + +### G — `public/app.js` + +- [ ] Neue Funktion `updateAcceleration()`: + - `fetch('/api/acceleration')` → JSON + - Felder `#acc-x`, `#acc-y`, `#acc-z` setzen (auf 3 Dezimalstellen) + - Fehlerfall: Felder auf `—` setzen +- [ ] `updateAcceleration()` in den 1-Sekunden-Poll-Zyklus aufnehmen + (neben `updateStatus()`, `updatePosition()`) +- [ ] (Phase 2) Winkel-Zeilen einblenden und befüllen, sobald Achse bekannt + +--- + +## Offene Fragen / nächste Schritte + +1. **WebSocket-Nachrichtenformat**: Verifizieren, dass FluidNC Port 81 `log_info`-Nachrichten + (`I2CRESP …`) als plain-text WebSocket-Frames sendet (und nicht JSON-gewrappt). + → Testbefehl: `M260` (Scan) manuell über FluidNC WebUI senden und WS-Traffic beobachten. + +2. **Achsenorientierung**: Welche LIS3DH-Achse (x/y/z) entspricht dem Motor-`b`-Winkel? + → Arm langsam kippen, Sensorwerte in der Info-Page beobachten. + +3. **Polling-Konflikt**: I²C-Befehle dürfen nicht während eines Moves gesendet werden. + Prüfen ob `grblState === 'Idle'` ausreicht, oder ob ein Queue-Mechanismus nötig ist. + +4. **`ok`-Synchronisation**: Für `initLIS3DH()` und den Zeiger-Befehl vor dem Lesen wird + ein `ok`-Wait benötigt. Prüfen, ob ein einfaches `await delay(50)` reicht oder ob + ein echter `ok`-Promise nötig ist (dann Abschnitt A3 erweitern). + +5. **Kalibrierung**: Nullpunkt und Skalierung des Sensors ggf. gegen bekannte Positionen + abgleichen (Arm senkrecht = b=0, Arm waagrecht = b=π/2 usw.). diff --git a/doc/15_EmergencyStop_done.md b/doc/erledigt/15_EmergencyStop_done.md similarity index 100% rename from doc/15_EmergencyStop_done.md rename to doc/erledigt/15_EmergencyStop_done.md diff --git a/doc/ToDo_12_InverseKinematikConfig_ROADMAP.md b/doc/erledigt/ToDo_12_InverseKinematikConfig_ROADMAP.md similarity index 100% rename from doc/ToDo_12_InverseKinematikConfig_ROADMAP.md rename to doc/erledigt/ToDo_12_InverseKinematikConfig_ROADMAP.md diff --git a/doc/ToDo_2_Anbindung.md b/doc/erledigt/ToDo_2_Anbindung.md similarity index 100% rename from doc/ToDo_2_Anbindung.md rename to doc/erledigt/ToDo_2_Anbindung.md diff --git a/doc/ToDo_4_GCode.md b/doc/erledigt/ToDo_4_GCode.md similarity index 100% rename from doc/ToDo_4_GCode.md rename to doc/erledigt/ToDo_4_GCode.md diff --git a/doc/ToDo_6_RobotController.md b/doc/erledigt/ToDo_6_RobotController.md similarity index 100% rename from doc/ToDo_6_RobotController.md rename to doc/erledigt/ToDo_6_RobotController.md diff --git a/doc/ToDo_7_Tests.md b/doc/erledigt/ToDo_7_Tests.md similarity index 100% rename from doc/ToDo_7_Tests.md rename to doc/erledigt/ToDo_7_Tests.md diff --git a/logs/gcode_commands.log b/logs/gcode_commands.log index 38d637f..44b9108 100644 --- a/logs/gcode_commands.log +++ b/logs/gcode_commands.log @@ -11461,3 +11461,12 @@ 2026-06-26T18:38:26.991Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 2026-06-26T18:38:27.132Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 2026-06-26T18:38:27.362Z ::ffff:127.0.0.1: G1 X1 +2026-06-26T21:14:13.178Z ::ffff:127.0.0.1: FList +2026-06-26T21:14:13.213Z ::ffff:127.0.0.1: FPlus +2026-06-26T21:14:13.241Z ::ffff:127.0.0.1: FLoad nichtda +2026-06-26T21:14:13.261Z ::ffff:127.0.0.1: FShow +2026-06-26T21:14:13.320Z ::ffff:127.0.0.1: M114 +2026-06-26T21:14:13.562Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-26T21:14:13.602Z ::ffff:127.0.0.1: M114 +2026-06-26T21:14:13.620Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-26T21:14:13.799Z ::ffff:127.0.0.1: G1 X1 diff --git a/logs/pings.log b/logs/pings.log index 3aa0ea2..5a7e203 100644 --- a/logs/pings.log +++ b/logs/pings.log @@ -14908,3 +14908,5 @@ 2026-06-26T14:27:33.857Z ::ffff:127.0.0.1 : Ping 2026-06-26T18:38:26.680Z ::ffff:127.0.0.1 : Ping 2026-06-26T18:38:26.965Z ::ffff:127.0.0.1 : Ping +2026-06-26T21:14:13.068Z ::ffff:127.0.0.1 : Ping +2026-06-26T21:14:13.575Z ::ffff:127.0.0.1 : Ping diff --git a/robot/SenderInterface.js b/robot/SenderInterface.js index 1f3639f..51abcb0 100644 --- a/robot/SenderInterface.js +++ b/robot/SenderInterface.js @@ -1,4 +1,6 @@ -class SenderInterface { +const EventEmitter = require('events'); + +class SenderInterface extends EventEmitter { async connect() { throw new Error('connect() must be implemented by sender classes'); } diff --git a/robot/WSSenderGrbl.js b/robot/WSSenderGrbl.js index fdb8137..f81592a 100755 --- a/robot/WSSenderGrbl.js +++ b/robot/WSSenderGrbl.js @@ -30,11 +30,22 @@ module.exports = class WSSenderGrbl extends SenderInterface { this.WebSocketClass = options.WebSocketClass || WebSocket; this.setTimeoutFn = options.setTimeoutFn || setTimeout; this.clearTimeoutFn = options.clearTimeoutFn || clearTimeout; + this.setIntervalFn = options.setIntervalFn || setInterval; + this.clearIntervalFn = options.clearIntervalFn || clearInterval; this.reconnectDelay = Number.isFinite(options.reconnectDelay) ? options.reconnectDelay : 2000; this.maxReconnectDelay = Number.isFinite(options.maxReconnectDelay) ? options.maxReconnectDelay : 30000; + this.heartbeatInterval = Number.isFinite(options.heartbeatInterval) ? options.heartbeatInterval : 10000; this.wsPort = options.wsPort || 81; this.autoConnect = options.autoConnect !== false; + this._heartbeatTimer = null; + this.grblState = null; + this.machinePosition = null; + this.lastReportAt = null; + this.lastError = null; + this._i2cRespResolve = null; + this._i2cRespReject = null; + if (urlGRBL === "test.test") { this.isTestMode = true; this.state = 'connected'; @@ -105,10 +116,12 @@ module.exports = class WSSenderGrbl extends SenderInterface { this.connectRejecter = null; } this.connectPromise = null; + this._startHeartbeat(); }); ws.on('close', () => { console.log("WS Closed " + this.urlGRBLstr); + this._stopHeartbeat(); this.ws = null; if (this.shouldReconnect) { this.state = 'reconnecting'; @@ -131,6 +144,8 @@ module.exports = class WSSenderGrbl extends SenderInterface { } } }); + + ws.on('message', (data) => this._handleMessage(data)); } _scheduleReconnect() { @@ -161,11 +176,16 @@ module.exports = class WSSenderGrbl extends SenderInterface { error: this.error, isTestMode: !!this.isTestMode, reconnectAttempt: this.reconnectAttempt, - reconnectTimer: !!this.reconnectTimer + reconnectTimer: !!this.reconnectTimer, + grblState: this.grblState, + machinePosition: this.machinePosition, + lastReportAt: this.lastReportAt, + lastError: this.lastError, }; } disconnect() { + this._stopHeartbeat(); this.shouldReconnect = false; if (this.reconnectTimer) { @@ -189,6 +209,59 @@ module.exports = class WSSenderGrbl extends SenderInterface { this.error = null; } + _handleMessage(data) { + const line = data.toString().trim(); + if (line) this._handleResponseLine(line); + } + + _handleResponseLine(line) { + if (line.startsWith('<')) { + this._parseStatusReport(line); + } else if (line.startsWith('I2CRESP')) { + this._handleI2CResp(line); + } else if (line.startsWith('error:') || line.startsWith('ALARM:')) { + this.lastError = line; + this.emit('error-report', line); + } + } + + _parseStatusReport(line) { + // + const stateMatch = line.match(/^<([^|>]+)/); + if (stateMatch) this.grblState = stateMatch[1]; + + const mposMatch = line.match(/MPos:([-\d.,]+)/); + if (mposMatch) this.machinePosition = mposMatch[1].split(',').map(Number); + + this.lastReportAt = Date.now(); + this.emit('status', { grblState: this.grblState, machinePosition: this.machinePosition }); + } + + _handleI2CResp(line) { + // I2CRESP B0 A0x18: C0 01 40 F8 80 3F + const colonIdx = line.lastIndexOf(':'); + if (colonIdx === -1) return; + const bytes = line.slice(colonIdx + 1).trim().split(/\s+/); + if (this._i2cRespResolve) { + this._i2cRespResolve(bytes); + this._i2cRespResolve = null; + this._i2cRespReject = null; + } + this.emit('i2cresp', bytes); + } + + _startHeartbeat() { + this._stopHeartbeat(); + this._heartbeatTimer = this.setIntervalFn(() => this.send('?'), this.heartbeatInterval); + } + + _stopHeartbeat() { + if (this._heartbeatTimer) { + this.clearIntervalFn(this._heartbeatTimer); + this._heartbeatTimer = null; + } + } + moveTo(mOld, mNew) { this.execCommand("G1", mOld, mNew); } diff --git a/test/Sender.WS.responseParsing.test.js b/test/Sender.WS.responseParsing.test.js new file mode 100644 index 0000000..93beb39 --- /dev/null +++ b/test/Sender.WS.responseParsing.test.js @@ -0,0 +1,284 @@ +const WSSenderGrbl = require('../robot/WSSenderGrbl'); +const EventEmitter = require('events'); + +// Minimal mock WebSocket whose events can be triggered manually +class MockWS extends EventEmitter { + constructor() { + super(); + this.readyState = 1; + this.sent = []; + } + send(data) { this.sent.push(data); } + close() { this.readyState = 3; this.emit('close'); } +} + +// Creates a sender with a controllable WS connection (not test-mode shortcut) +function makeConnectedSender(options = {}) { + let mockWs; + const WebSocketClass = function () { + mockWs = new MockWS(); + return mockWs; + }; + const sender = new WSSenderGrbl('robot.local', 2300, 'x', 'y', 'z', null, null, null, null, { + WebSocketClass, + autoConnect: false, + setIntervalFn: options.setIntervalFn || jest.fn(() => 99), + clearIntervalFn: options.clearIntervalFn || jest.fn(), + setTimeoutFn: jest.fn(), + clearTimeoutFn: jest.fn(), + ...options, + }); + sender._tryConnect(); + mockWs.emit('open'); + return { sender, mockWs }; +} + +// ─── Status-Report Parsing ──────────────────────────────────────────────────── + +describe('WSSenderGrbl._parseStatusReport (via _handleMessage)', () => { + let sender; + beforeEach(() => { sender = new WSSenderGrbl('test.test', 2300, 'x', 'y', 'z'); }); + + test('sets grblState', () => { + sender._handleMessage(''); + expect(sender.grblState).toBe('Idle'); + }); + + test('sets grblState for Run', () => { + sender._handleMessage(''); + expect(sender.grblState).toBe('Run'); + }); + + test('sets machinePosition from MPos', () => { + sender._handleMessage(''); + expect(sender.machinePosition).toEqual([10.50, -3.25, 0.75]); + }); + + test('sets lastReportAt to current timestamp', () => { + const before = Date.now(); + sender._handleMessage(''); + expect(sender.lastReportAt).toBeGreaterThanOrEqual(before); + expect(sender.lastReportAt).toBeLessThanOrEqual(Date.now()); + }); + + test('emits status event with grblState and machinePosition', () => { + const listener = jest.fn(); + sender.on('status', listener); + sender._handleMessage(''); + expect(listener).toHaveBeenCalledWith({ + grblState: 'Idle', + machinePosition: [1.00, 2.00, 3.00], + }); + }); + + test('getStatus reflects parsed values', () => { + sender._handleMessage(''); + const s = sender.getStatus(); + expect(s.grblState).toBe('Alarm'); + expect(s.machinePosition).toEqual([5.00, 6.00, 7.00]); + expect(s.lastReportAt).not.toBeNull(); + }); +}); + +// ─── Error / Alarm ──────────────────────────────────────────────────────────── + +describe('WSSenderGrbl error and alarm handling', () => { + let sender; + beforeEach(() => { sender = new WSSenderGrbl('test.test', 2300, 'x', 'y', 'z'); }); + + test('sets lastError on error: line', () => { + sender._handleMessage('error:15'); + expect(sender.lastError).toBe('error:15'); + }); + + test('sets lastError on ALARM: line', () => { + sender._handleMessage('ALARM:1'); + expect(sender.lastError).toBe('ALARM:1'); + }); + + test('emits error-report event for error:', () => { + const listener = jest.fn(); + sender.on('error-report', listener); + sender._handleMessage('error:15'); + expect(listener).toHaveBeenCalledWith('error:15'); + }); + + test('emits error-report event for ALARM:', () => { + const listener = jest.fn(); + sender.on('error-report', listener); + sender._handleMessage('ALARM:1'); + expect(listener).toHaveBeenCalledWith('ALARM:1'); + }); + + test('getStatus reflects lastError', () => { + sender._handleMessage('error:22'); + expect(sender.getStatus().lastError).toBe('error:22'); + }); +}); + +// ─── I2CRESP Handling ───────────────────────────────────────────────────────── + +describe('WSSenderGrbl._handleI2CResp (via _handleMessage)', () => { + let sender; + beforeEach(() => { sender = new WSSenderGrbl('test.test', 2300, 'x', 'y', 'z'); }); + + test('emits i2cresp with parsed byte array', () => { + const listener = jest.fn(); + sender.on('i2cresp', listener); + sender._handleMessage('I2CRESP B0 A0x18: C0 01 40 F8 80 3F'); + expect(listener).toHaveBeenCalledWith(['C0', '01', '40', 'F8', '80', '3F']); + }); + + test('resolves pending _i2cRespResolve and clears it', () => { + const resolve = jest.fn(); + sender._i2cRespResolve = resolve; + sender._i2cRespReject = jest.fn(); + sender._handleMessage('I2CRESP B0 A0x18: C0 01 40 F8 80 3F'); + expect(resolve).toHaveBeenCalledWith(['C0', '01', '40', 'F8', '80', '3F']); + expect(sender._i2cRespResolve).toBeNull(); + expect(sender._i2cRespReject).toBeNull(); + }); + + test('WROTE response (no colon) does not emit i2cresp', () => { + const listener = jest.fn(); + sender.on('i2cresp', listener); + sender._handleMessage('I2CRESP B0 A0x18 WROTE 2'); + expect(listener).not.toHaveBeenCalled(); + }); + + test('WROTE response does not call pending resolve', () => { + const resolve = jest.fn(); + sender._i2cRespResolve = resolve; + sender._handleMessage('I2CRESP B0 A0x18 WROTE 2'); + expect(resolve).not.toHaveBeenCalled(); + }); +}); + +// ─── Initial getStatus fields ───────────────────────────────────────────────── + +describe('WSSenderGrbl getStatus new fields', () => { + test('new fields are null initially', () => { + const sender = new WSSenderGrbl('test.test', 2300, 'x', 'y', 'z'); + const s = sender.getStatus(); + expect(s.grblState).toBeNull(); + expect(s.machinePosition).toBeNull(); + expect(s.lastReportAt).toBeNull(); + expect(s.lastError).toBeNull(); + }); +}); + +// ─── Heartbeat ──────────────────────────────────────────────────────────────── + +describe('WSSenderGrbl heartbeat', () => { + test('_startHeartbeat registers interval with correct delay', () => { + const setIntervalFn = jest.fn(() => 42); + const sender = new WSSenderGrbl('test.test', 2300, 'x', 'y', 'z', null, null, null, null, { + setIntervalFn, + clearIntervalFn: jest.fn(), + }); + sender._startHeartbeat(); + expect(setIntervalFn).toHaveBeenCalledWith(expect.any(Function), 10000); + expect(sender._heartbeatTimer).toBe(42); + }); + + test('heartbeat callback sends ?', () => { + let callback; + const setIntervalFn = jest.fn((fn) => { callback = fn; return 1; }); + const sender = new WSSenderGrbl('test.test', 2300, 'x', 'y', 'z', null, null, null, null, { + setIntervalFn, + clearIntervalFn: jest.fn(), + }); + sender._startHeartbeat(); + callback(); + expect(sender.ws.written).toBe('?\n'); + }); + + test('_stopHeartbeat clears interval and nulls timer', () => { + const clearIntervalFn = jest.fn(); + const sender = new WSSenderGrbl('test.test', 2300, 'x', 'y', 'z', null, null, null, null, { + setIntervalFn: jest.fn(() => 42), + clearIntervalFn, + }); + sender._startHeartbeat(); + sender._stopHeartbeat(); + expect(clearIntervalFn).toHaveBeenCalledWith(42); + expect(sender._heartbeatTimer).toBeNull(); + }); + + test('_stopHeartbeat is safe when no timer is running', () => { + const clearIntervalFn = jest.fn(); + const sender = new WSSenderGrbl('test.test', 2300, 'x', 'y', 'z', null, null, null, null, { + clearIntervalFn, + }); + expect(() => sender._stopHeartbeat()).not.toThrow(); + expect(clearIntervalFn).not.toHaveBeenCalled(); + }); + + test('custom heartbeatInterval is used', () => { + const setIntervalFn = jest.fn(() => 1); + const sender = new WSSenderGrbl('test.test', 2300, 'x', 'y', 'z', null, null, null, null, { + setIntervalFn, + clearIntervalFn: jest.fn(), + heartbeatInterval: 5000, + }); + sender._startHeartbeat(); + expect(setIntervalFn).toHaveBeenCalledWith(expect.any(Function), 5000); + }); + + test('disconnect() stops a running heartbeat', () => { + const clearIntervalFn = jest.fn(); + const sender = new WSSenderGrbl('test.test', 2300, 'x', 'y', 'z', null, null, null, null, { + setIntervalFn: jest.fn(() => 7), + clearIntervalFn, + }); + sender._startHeartbeat(); + sender.disconnect(); + expect(clearIntervalFn).toHaveBeenCalledWith(7); + expect(sender._heartbeatTimer).toBeNull(); + }); +}); + +// ─── Integration: WS open/close triggers heartbeat ─────────────────────────── + +describe('WSSenderGrbl heartbeat integration with WS lifecycle', () => { + test('heartbeat starts on WS open', () => { + const setIntervalFn = jest.fn(() => 99); + const { sender } = makeConnectedSender({ setIntervalFn }); + expect(setIntervalFn).toHaveBeenCalled(); + expect(sender._heartbeatTimer).toBe(99); + }); + + test('heartbeat stops on WS close', () => { + const clearIntervalFn = jest.fn(); + const { sender, mockWs } = makeConnectedSender({ clearIntervalFn }); + mockWs.emit('close'); + expect(clearIntervalFn).toHaveBeenCalled(); + expect(sender._heartbeatTimer).toBeNull(); + }); + + test('incoming WS message updates grblState end-to-end', () => { + const { sender, mockWs } = makeConnectedSender(); + mockWs.emit('message', ''); + expect(sender.grblState).toBe('Idle'); + expect(sender.machinePosition).toEqual([1.00, 2.00, 3.00]); + }); + + test('incoming WS message emits status event end-to-end', () => { + const { sender, mockWs } = makeConnectedSender(); + const listener = jest.fn(); + sender.on('status', listener); + mockWs.emit('message', ''); + expect(listener).toHaveBeenCalledWith({ + grblState: 'Run', + machinePosition: [5.00, 0.00, 0.00], + }); + }); + + test('incoming WS I2CRESP emits i2cresp event end-to-end', () => { + const { sender, mockWs } = makeConnectedSender(); + const listener = jest.fn(); + sender.on('i2cresp', listener); + mockWs.emit('message', 'I2CRESP B0 A0x18: C0 01 40 F8 80 3F'); + expect(listener).toHaveBeenCalledWith(['C0', '01', '40', 'F8', '80', '3F']); + }); +});