i2c vorbereiten

This commit is contained in:
chk
2026-06-26 23:23:54 +02:00
parent 81664ac0c9
commit 05f0dd619a
13 changed files with 676 additions and 21 deletions

View File

@@ -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 (`180b` 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 = ** (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
(`180b``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 (`180b``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

View File

@@ -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
`<Idle|MPos:…>`, `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
<div class="section">
<h3>Acceleration (LIS3DH)</h3>
<table>
<tr><td>X</td><td id="acc-x">—</td><td>g</td></tr>
<tr><td>Y</td><td id="acc-y">—</td><td>g</td></tr>
<tr><td>Z</td><td id="acc-z">—</td><td>g</td></tr>
</table>
<!-- Phase 2 -->
<table id="acc-angle-table" style="display:none">
<tr><td>Winkel (Sensor)</td><td id="acc-angle">—</td><td>°</td></tr>
<tr><td>Winkel (Driver b)</td><td id="driver-b">—</td><td>°</td></tr>
<tr><td>Differenz</td><td id="acc-diff">—</td><td>°</td></tr>
</table>
</div>
```
---
### 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.).

View File

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

View File

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

View File

@@ -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');
}

View File

@@ -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) {
// <Idle|MPos:0.00,0.00,0.00|Bf:15,128>
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);
}

View File

@@ -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('<Idle|MPos:1.00,2.00,3.00|Bf:15,128>');
expect(sender.grblState).toBe('Idle');
});
test('sets grblState for Run', () => {
sender._handleMessage('<Run|MPos:0.00,0.00,0.00>');
expect(sender.grblState).toBe('Run');
});
test('sets machinePosition from MPos', () => {
sender._handleMessage('<Idle|MPos:10.50,-3.25,0.75>');
expect(sender.machinePosition).toEqual([10.50, -3.25, 0.75]);
});
test('sets lastReportAt to current timestamp', () => {
const before = Date.now();
sender._handleMessage('<Idle|MPos:0.00,0.00,0.00>');
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('<Idle|MPos:1.00,2.00,3.00>');
expect(listener).toHaveBeenCalledWith({
grblState: 'Idle',
machinePosition: [1.00, 2.00, 3.00],
});
});
test('getStatus reflects parsed values', () => {
sender._handleMessage('<Alarm|MPos:5.00,6.00,7.00>');
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', '<Idle|MPos:1.00,2.00,3.00>');
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', '<Run|MPos:5.00,0.00,0.00>');
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']);
});
});