i2c vorbereiten
This commit is contained in:
@@ -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
|
||||
|
||||
271
doc/ToDo_15_AccelerationSensor.md
Normal file
271
doc/ToDo_15_AccelerationSensor.md
Normal 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.).
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
284
test/Sender.WS.responseParsing.test.js
Normal file
284
test/Sender.WS.responseParsing.test.js
Normal 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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user