Heartbeat
This commit is contained in:
25
README.md
25
README.md
@@ -123,9 +123,9 @@ Relevante Abschnitte für den Driver:
|
|||||||
"kinematics": { "type": "arm3segmentlinearx" },
|
"kinematics": { "type": "arm3segmentlinearx" },
|
||||||
"motion": { "defaultFeedrate": 1000, "speedMode": "legacy" },
|
"motion": { "defaultFeedrate": 1000, "speedMode": "legacy" },
|
||||||
"controllers": {
|
"controllers": {
|
||||||
"base": { "ip": "fluidNcBase.local", "port": 2300, "protocol": "telnet", "axes": ["x","y","z"] },
|
"base": { "ip": "fluidNcBase.local", "port": 2300, "protocol": "telnet", "axes": ["x","y","z"], "heartbeatInterval": 10000 },
|
||||||
"elbow": { "ip": "fluidNcEllbow.local", "port": 5000, "protocol": "telnet", "axes": ["a",null,null] },
|
"elbow": { "ip": "fluidNcEllbow.local", "port": 5000, "protocol": "telnet", "axes": ["a",null,null], "heartbeatInterval": 10000 },
|
||||||
"hand": { "ip": "fluidNcHand.local", "port": 5000, "protocol": "telnet", "axes": ["c","e","b"] }
|
"hand": { "ip": "fluidNcHand.local", "port": 5000, "protocol": "telnet", "axes": ["c","e","b"], "heartbeatInterval": 10000 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -141,15 +141,20 @@ Snapshots sind nicht im Repo (`.gitignore`), `robot.json` selbst schon.
|
|||||||
`startRobot.js` erzeugt die `TelnetSenderGRBL`-Instanzen dynamisch aus `cfg.controllers`
|
`startRobot.js` erzeugt die `TelnetSenderGRBL`-Instanzen dynamisch aus `cfg.controllers`
|
||||||
(geladen von `robot/RobotConfig.js`). Die Defaults entsprechen drei Controllern:
|
(geladen von `robot/RobotConfig.js`). Die Defaults entsprechen drei Controllern:
|
||||||
|
|
||||||
| Key | Default-IP | Port | Achsen |
|
| Key | Default-IP | Port | Achsen | `heartbeatInterval` |
|
||||||
|-----|-----------|------|--------|
|
|-----|-----------|------|--------|---------------------|
|
||||||
| `base` | `fluidNcBase.local` | 2300 | `x, y, z` |
|
| `base` | `fluidNcBase.local` | 2300 | `x, y, z` | 10 000 ms |
|
||||||
| `elbow` | `fluidNcEllbow.local` | 5000 | `a` |
|
| `elbow` | `fluidNcEllbow.local` | 5000 | `a` | 10 000 ms |
|
||||||
| `hand` | `fluidNcHand.local` | 5000 | `c, e, b` |
|
| `hand` | `fluidNcHand.local` | 5000 | `c, e, b` | 10 000 ms |
|
||||||
|
|
||||||
IPs können per Env-Variable überschrieben werden (`GRBL_BASE_IP`, `GRBL_ELLBOW_IP`,
|
IPs können per Env-Variable überschrieben werden (`GRBL_BASE_IP`, `GRBL_ELLBOW_IP`,
|
||||||
`GRBL_HAND_IP`). Alles andere (Port, Achsen, Controller-Anzahl) wird in `robot.json`
|
`GRBL_HAND_IP`). Alles andere (Port, Achsen, Controller-Anzahl, Heartbeat) wird in
|
||||||
konfiguriert.
|
`robot.json` konfiguriert.
|
||||||
|
|
||||||
|
**`heartbeatInterval`** (ms) steuert, wie oft `?` an den FluidNC-Controller gesendet wird.
|
||||||
|
Der Sender erkennt eine tote Verbindung (z.B. nach NotAus), wenn zwei aufeinanderfolgende
|
||||||
|
Heartbeats ohne Antwort bleiben (`deadTimeout = 2 × heartbeatInterval`). Danach wird der
|
||||||
|
Socket geschlossen und der bestehende Reconnect-Mechanismus startet automatisch.
|
||||||
|
|
||||||
## Serverschnittstellen
|
## Serverschnittstellen
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
},
|
},
|
||||||
"controllers": {
|
"controllers": {
|
||||||
"_owner": "appRobotDriver",
|
"_owner": "appRobotDriver",
|
||||||
"base": { "ip": "fluidNcBase.local", "port": 2300, "protocol": "telnet", "axes": ["x", "y", "z"] },
|
"base": { "ip": "fluidNcBase.local", "port": 2300, "protocol": "telnet", "axes": ["x", "y", "z"], "heartbeatInterval": 5000 },
|
||||||
"elbow": { "ip": "fluidNcEllbow.local", "port": 5000, "protocol": "telnet", "axes": ["a", null, null] },
|
"elbow": { "ip": "fluidNcEllbow.local", "port": 5000, "protocol": "telnet", "axes": ["a", null, null], "heartbeatInterval": 5000 },
|
||||||
"hand": { "ip": "fluidNcHand.local", "port": 5000, "protocol": "telnet", "axes": ["c", "e", "b"] }
|
"hand": { "ip": "fluidNcHand.local", "port": 5000, "protocol": "telnet", "axes": ["c", "e", "b"], "heartbeatInterval": 5000 }
|
||||||
},
|
},
|
||||||
"vision_config": {"MarkerType": "DICT_4X4_250", "MarkerSize": 0.025},
|
"vision_config": {"MarkerType": "DICT_4X4_250", "MarkerSize": 0.025},
|
||||||
"renderingInfo": {
|
"renderingInfo": {
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ Das ist die einzige Absicherung. Kein JWT, keine Sessions, kein Rate-Limiting
|
|||||||
| 1 — Datei anlegen | appRobotDriver | ✅ erledigt |
|
| 1 — Datei anlegen | appRobotDriver | ✅ erledigt |
|
||||||
| 2 — RobotConfigService | appRobotDriver | ✅ erledigt |
|
| 2 — RobotConfigService | appRobotDriver | ✅ erledigt |
|
||||||
| 3 — Registrierung InfoServer | appRobotDriver | ✅ erledigt |
|
| 3 — Registrierung InfoServer | appRobotDriver | ✅ erledigt |
|
||||||
| 4 — Driver liest Armlängen | appRobotDriver | ✅ erledigt |
|
| 4 — Driver liest Armlängen | appRobotDriver | ✅ erledigt (→ ToDo 3) |
|
||||||
|
| 4a — InfoServer auf Express umgestellt | appRobotDriver | ✅ erledigt |
|
||||||
|
| 4b — UI: Robot.json + History in index.html | appRobotDriver | ✅ erledigt |
|
||||||
| 5 — appRobotHoming umstellen | appRobotHoming | ⬜ offen |
|
| 5 — appRobotHoming umstellen | appRobotHoming | ⬜ offen |
|
||||||
| 6 — appRobotRendering umstellen | appRobotRendering | ⬜ offen |
|
| 6 — appRobotRendering umstellen | appRobotRendering | ⬜ offen |
|
||||||
| 7 — Aufräumen | alle Repos | ⬜ offen |
|
| 7 — Aufräumen | alle Repos | ⬜ offen |
|
||||||
@@ -113,25 +115,35 @@ Timestamp-Format: `YYYYMMDD_HHmmss` (konsistent mit appRobotHoming).
|
|||||||
|
|
||||||
### Schritt 3 — Registrierung in InfoServer.js (appRobotDriver) ✅
|
### Schritt 3 — Registrierung in InfoServer.js (appRobotDriver) ✅
|
||||||
|
|
||||||
Eine Zeile in `server/InfoServer.js` am Anfang der Request-Handler:
|
`InfoServer.js` wurde auf Express umgestellt. Registrierung mit einer Zeile:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
robotConfigService.register(httpsServer, { apiKey });
|
robotConfigService.register(app, { apiKey: options.apiKey });
|
||||||
```
|
```
|
||||||
|
|
||||||
Da `InfoServer.js` kein Express nutzt (rohes `https.createServer`), bekommt `RobotConfigService` intern einen minimalen Router, der url-Matching selbst macht — oder `InfoServer.js` wird auf Express umgestellt (kleiner Schritt, bringt mehr Flexibilität für spätere Endpunkte).
|
|
||||||
|
|
||||||
### Schritt 4 — Driver liest Armlängen aus robot.json ✅
|
### Schritt 4 — Driver liest Armlängen aus robot.json ✅
|
||||||
|
|
||||||
`startRobot.js` liest beim Start arm-lengths aus `data/robot/robot.json`.
|
Ausgelagert nach `robot/RobotConfig.js` (→ ToDo 3). Liest beim Start synchron `data/robot/robot.json`, leitet l1/l2/l3 aus `links.*.skeleton.to` ab (nicht mehr aus `size`):
|
||||||
Fallback auf `{ l1: 250, l2: 264, l3: 100 }` mit Log-Warnung wenn Datei fehlt oder Keys fehlen.
|
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// links.Arm1.size[1] → l1
|
// links.Arm1.skeleton.to[1] → l1 (Math.abs)
|
||||||
// links.Arm2.size[1] → l2
|
// links.Arm2.skeleton.to[1] → l2 (Math.abs)
|
||||||
// links.Ellbow.size[0] → l3
|
// links.Ellbow.skeleton.to[0] → l3
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Fallback auf Defaults wenn Datei fehlt. Controller-IPs/-Ports/-Achsen ebenfalls aus `robot.json` (keine hardcodierten Werte mehr in `startRobot.js`).
|
||||||
|
|
||||||
|
### Schritt 4a — InfoServer auf Express umgestellt ✅
|
||||||
|
|
||||||
|
`server/InfoServer.js` nutzt jetzt Express statt rohem `https.createServer`. Ermöglicht saubere Router-Registrierung und ist Grundlage für alle weiteren API-Endpunkte.
|
||||||
|
|
||||||
|
### Schritt 4b — UI: Robot.json + History in index.html ✅
|
||||||
|
|
||||||
|
Zwei neue Panels im InfoServer-Frontend (`public/index.html` + `app.js`):
|
||||||
|
|
||||||
|
- **Robot.json** — zeigt aktuelle `robot.json` mit aufklappbaren Top-Level-Abschnitten (`<details>`). Aktualisiert sich automatisch, solange kein Snapshot ausgewählt ist. Label (`_label`-Feld) wird oben angezeigt.
|
||||||
|
- **Robot.json History** — listet alle Snapshots aus `GET /api/robot/history`. Klick auf Eintrag lädt diesen Snapshot in das Robot.json-Panel. Klick auf „aktuell" schaltet zurück auf Live-Ansicht.
|
||||||
|
|
||||||
### Schritt 5 — appRobotHoming auf Driver-API umstellen ⬜
|
### Schritt 5 — appRobotHoming auf Driver-API umstellen ⬜
|
||||||
|
|
||||||
`server/server.js` in appRobotHoming:
|
`server/server.js` in appRobotHoming:
|
||||||
|
|||||||
@@ -10369,3 +10369,23 @@
|
|||||||
2026-06-11T19:43:39.506Z ::ffff:127.0.0.1: M114
|
2026-06-11T19:43:39.506Z ::ffff:127.0.0.1: M114
|
||||||
2026-06-11T19:43:39.745Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
|
2026-06-11T19:43:39.745Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
|
||||||
2026-06-11T19:43:40.028Z ::ffff:127.0.0.1: G1 X1
|
2026-06-11T19:43:40.028Z ::ffff:127.0.0.1: G1 X1
|
||||||
|
2026-06-12T14:47:24.268Z ::ffff:127.0.0.1: M114
|
||||||
|
2026-06-12T14:47:24.318Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
|
||||||
|
2026-06-12T14:47:24.540Z ::ffff:127.0.0.1: M114
|
||||||
|
2026-06-12T14:47:24.786Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
|
||||||
|
2026-06-12T14:47:25.041Z ::ffff:127.0.0.1: G1 X1
|
||||||
|
2026-06-12T14:47:59.569Z ::ffff:127.0.0.1: M114
|
||||||
|
2026-06-12T14:47:59.802Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
|
||||||
|
2026-06-12T14:48:00.058Z ::ffff:127.0.0.1: G1 X1
|
||||||
|
2026-06-12T14:48:00.337Z ::ffff:127.0.0.1: M114
|
||||||
|
2026-06-12T14:48:00.354Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
|
||||||
|
2026-06-12T14:53:38.858Z ::ffff:127.0.0.1: M114
|
||||||
|
2026-06-12T14:53:38.922Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
|
||||||
|
2026-06-12T14:53:40.308Z ::ffff:127.0.0.1: M114
|
||||||
|
2026-06-12T14:53:40.541Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
|
||||||
|
2026-06-12T14:53:40.775Z ::ffff:127.0.0.1: G1 X1
|
||||||
|
2026-06-12T14:54:12.416Z ::ffff:127.0.0.1: M114
|
||||||
|
2026-06-12T14:54:12.455Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
|
||||||
|
2026-06-12T14:54:13.350Z ::ffff:127.0.0.1: M114
|
||||||
|
2026-06-12T14:54:13.572Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
|
||||||
|
2026-06-12T14:54:13.800Z ::ffff:127.0.0.1: G1 X1
|
||||||
|
|||||||
@@ -14622,3 +14622,11 @@
|
|||||||
2026-06-11T18:37:29.724Z ::ffff:127.0.0.1 : Ping
|
2026-06-11T18:37:29.724Z ::ffff:127.0.0.1 : Ping
|
||||||
2026-06-11T19:43:39.197Z ::ffff:127.0.0.1 : Ping
|
2026-06-11T19:43:39.197Z ::ffff:127.0.0.1 : Ping
|
||||||
2026-06-11T19:43:39.241Z ::ffff:127.0.0.1 : Ping
|
2026-06-11T19:43:39.241Z ::ffff:127.0.0.1 : Ping
|
||||||
|
2026-06-12T14:47:24.231Z ::ffff:127.0.0.1 : Ping
|
||||||
|
2026-06-12T14:47:24.304Z ::ffff:127.0.0.1 : Ping
|
||||||
|
2026-06-12T14:47:59.299Z ::ffff:127.0.0.1 : Ping
|
||||||
|
2026-06-12T14:48:00.320Z ::ffff:127.0.0.1 : Ping
|
||||||
|
2026-06-12T14:53:38.800Z ::ffff:127.0.0.1 : Ping
|
||||||
|
2026-06-12T14:53:40.079Z ::ffff:127.0.0.1 : Ping
|
||||||
|
2026-06-12T14:54:12.378Z ::ffff:127.0.0.1 : Ping
|
||||||
|
2026-06-12T14:54:13.124Z ::ffff:127.0.0.1 : Ping
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ const DEFAULTS = {
|
|||||||
kinematics: { type: 'arm3segmentlinearx', l1: 250, l2: 264, l3: 100 },
|
kinematics: { type: 'arm3segmentlinearx', l1: 250, l2: 264, l3: 100 },
|
||||||
motion: { defaultFeedrate: 1000, speedMode: 'legacy', useSpeedCalc: false },
|
motion: { defaultFeedrate: 1000, speedMode: 'legacy', useSpeedCalc: false },
|
||||||
controllers: {
|
controllers: {
|
||||||
base: { ip: 'fluidNcBase.local', port: 2300, protocol: 'telnet', axes: ['x', 'y', 'z'] },
|
base: { ip: 'fluidNcBase.local', port: 2300, protocol: 'telnet', axes: ['x', 'y', 'z'], heartbeatInterval: 10000 },
|
||||||
elbow: { ip: 'fluidNcEllbow.local', port: 5000, protocol: 'telnet', axes: ['a', null, null] },
|
elbow: { ip: 'fluidNcEllbow.local', port: 5000, protocol: 'telnet', axes: ['a', null, null], heartbeatInterval: 10000 },
|
||||||
hand: { ip: 'fluidNcHand.local', port: 5000, protocol: 'telnet', axes: ['c', 'e', 'b'] }
|
hand: { ip: 'fluidNcHand.local', port: 5000, protocol: 'telnet', axes: ['c', 'e', 'b'], heartbeatInterval: 10000 }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,10 +84,13 @@ function load(fsModule, processEnv, consoleObj) {
|
|||||||
const cfg = jsonControllers[key] ?? {};
|
const cfg = jsonControllers[key] ?? {};
|
||||||
const envIpKey = ENV_IP_MAP[key];
|
const envIpKey = ENV_IP_MAP[key];
|
||||||
controllers[key] = {
|
controllers[key] = {
|
||||||
ip: env_[envIpKey] ?? cfg.ip ?? def.ip,
|
ip: env_[envIpKey] ?? cfg.ip ?? def.ip,
|
||||||
port: cfg.port ?? def.port,
|
port: cfg.port ?? def.port,
|
||||||
protocol: cfg.protocol ?? def.protocol,
|
protocol: cfg.protocol ?? def.protocol,
|
||||||
axes: cfg.axes ?? def.axes
|
axes: cfg.axes ?? def.axes,
|
||||||
|
// Heartbeat-Intervall in ms: wie oft '?' gesendet wird.
|
||||||
|
// deadTimeout = 2 × heartbeatInterval (zwei verpasste Heartbeats → Verbindung tot).
|
||||||
|
heartbeatInterval: cfg.heartbeatInterval ?? def.heartbeatInterval,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,18 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
|
|||||||
this.TelnetSocketClass = options.TelnetSocketClass || TelnetSocket;
|
this.TelnetSocketClass = options.TelnetSocketClass || TelnetSocket;
|
||||||
this.setTimeoutFn = options.setTimeoutFn || setTimeout;
|
this.setTimeoutFn = options.setTimeoutFn || setTimeout;
|
||||||
this.clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
|
this.clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
|
||||||
|
this.setIntervalFn = options.setIntervalFn || setInterval;
|
||||||
|
this.clearIntervalFn = options.clearIntervalFn || clearInterval;
|
||||||
this.reconnectDelay = Number.isFinite(options.reconnectDelay) ? options.reconnectDelay : 1000;
|
this.reconnectDelay = Number.isFinite(options.reconnectDelay) ? options.reconnectDelay : 1000;
|
||||||
this.maxReconnectDelay = Number.isFinite(options.maxReconnectDelay) ? options.maxReconnectDelay : 30000;
|
this.maxReconnectDelay = Number.isFinite(options.maxReconnectDelay) ? options.maxReconnectDelay : 30000;
|
||||||
|
// Heartbeat: erkennt tote Verbindungen (z.B. nach NotAus).
|
||||||
|
// Sendet alle heartbeatInterval ms '?' an den Controller.
|
||||||
|
// Kommt deadTimeout ms lang keine Antwort, gilt die Verbindung als tot.
|
||||||
|
this.heartbeatInterval = Number.isFinite(options.heartbeatInterval) ? options.heartbeatInterval : 10000;
|
||||||
|
this.deadTimeout = Number.isFinite(options.deadTimeout) ? options.deadTimeout : 20000;
|
||||||
|
this._heartbeatTimer = null;
|
||||||
|
this._lastDataAt = 0;
|
||||||
|
this._rawSocket = null;
|
||||||
this.reconnectAttempt = 0;
|
this.reconnectAttempt = 0;
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
@@ -108,10 +118,18 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._rawSocket = socket;
|
||||||
|
|
||||||
|
// TCP-Keepalive aktivieren: OS sendet Keepalive-Proben auf Netzwerk-Ebene.
|
||||||
|
// Schützt als Fallback, falls der Heartbeat-Timer ausfällt.
|
||||||
|
socket.setKeepAlive(true, 2000);
|
||||||
|
|
||||||
this.tSocket = new this.TelnetSocketClass(socket);
|
this.tSocket = new this.TelnetSocketClass(socket);
|
||||||
this.tSocket.on('close', () => {
|
this.tSocket.on('close', () => {
|
||||||
console.log("Telnet Closed " + this.urlGRBLstr);
|
console.log("Telnet Closed " + this.urlGRBLstr);
|
||||||
|
this._stopHeartbeat();
|
||||||
this.tSocket = null;
|
this.tSocket = null;
|
||||||
|
this._rawSocket = null;
|
||||||
if (this.shouldReconnect) {
|
if (this.shouldReconnect) {
|
||||||
this.state = 'reconnecting';
|
this.state = 'reconnecting';
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
@@ -120,7 +138,9 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('data', () => {});
|
// Zeitstempel jeder eingehenden Nachricht festhalten (für Heartbeat-Timeout).
|
||||||
|
socket.on('data', () => { this._lastDataAt = Date.now(); });
|
||||||
|
|
||||||
this.state = 'connected';
|
this.state = 'connected';
|
||||||
this.error = null;
|
this.error = null;
|
||||||
this.reconnectAttempt = 0;
|
this.reconnectAttempt = 0;
|
||||||
@@ -130,6 +150,9 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
|
|||||||
this.connectRejecter = null;
|
this.connectRejecter = null;
|
||||||
}
|
}
|
||||||
this.connectPromise = null;
|
this.connectPromise = null;
|
||||||
|
|
||||||
|
// Heartbeat starten: erkennt NotAus / tote Verbindungen.
|
||||||
|
this._startHeartbeat();
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
socket.on('error', (error) => {
|
||||||
@@ -167,6 +190,54 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
|
|||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet den Heartbeat-Timer.
|
||||||
|
*
|
||||||
|
* Sendet alle `heartbeatInterval` ms den FluidNC-Realtime-Command '?' und
|
||||||
|
* prüft, ob seit `deadTimeout` ms keine Daten mehr empfangen wurden.
|
||||||
|
* Bleibt die Antwort aus (z.B. nach NotAus), wird der Socket zerstört —
|
||||||
|
* der bestehende 'close'-Handler leitet danach den Reconnect ein.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_startHeartbeat() {
|
||||||
|
this._stopHeartbeat();
|
||||||
|
this._lastDataAt = Date.now();
|
||||||
|
|
||||||
|
this._heartbeatTimer = this.setIntervalFn(() => {
|
||||||
|
if (!this.tSocket) {
|
||||||
|
this._stopHeartbeat();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const silent = Date.now() - this._lastDataAt;
|
||||||
|
if (silent >= this.deadTimeout) {
|
||||||
|
console.log(
|
||||||
|
`[TelnetSenderGRBL] ${this.urlGRBLstr}: ` +
|
||||||
|
`keine Daten seit ${silent}ms (deadTimeout=${this.deadTimeout}ms) — ` +
|
||||||
|
`Verbindung wird als tot eingestuft und beendet`
|
||||||
|
);
|
||||||
|
this._stopHeartbeat();
|
||||||
|
// rawSocket.destroy() löst 'close' auf tSocket aus → bestehende Reconnect-Logik
|
||||||
|
if (this._rawSocket) this._rawSocket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leichtgewichtiger Probe: FluidNC antwortet mit <Idle|…> oder <Run|…>
|
||||||
|
try { this.tSocket.write('?'); } catch { /* Fehler kommt über 'error'-Event */ }
|
||||||
|
}, this.heartbeatInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stoppt den Heartbeat-Timer.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_stopHeartbeat() {
|
||||||
|
if (this._heartbeatTimer) {
|
||||||
|
this.clearIntervalFn(this._heartbeatTimer);
|
||||||
|
this._heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
send(command) {
|
send(command) {
|
||||||
if (!this.tSocket || typeof this.tSocket.write !== 'function') {
|
if (!this.tSocket || typeof this.tSocket.write !== 'function') {
|
||||||
return false;
|
return false;
|
||||||
@@ -193,8 +264,11 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
|
this._stopHeartbeat();
|
||||||
|
|
||||||
if (this.isTestMode) {
|
if (this.isTestMode) {
|
||||||
this.tSocket = null;
|
this.tSocket = null;
|
||||||
|
this._rawSocket = null;
|
||||||
this.state = 'disconnected';
|
this.state = 'disconnected';
|
||||||
this.shouldReconnect = false;
|
this.shouldReconnect = false;
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
|
|||||||
@@ -92,7 +92,16 @@ function createApp(options = {}) {
|
|||||||
const senders = [];
|
const senders = [];
|
||||||
for (const [key, ctrl] of Object.entries(cfg.controllers)) {
|
for (const [key, ctrl] of Object.entries(cfg.controllers)) {
|
||||||
const name = key.charAt(0).toUpperCase() + key.slice(1);
|
const name = key.charAt(0).toUpperCase() + key.slice(1);
|
||||||
const instance = new TelnetSenderClass(ctrl.ip, ctrl.port, ...ctrl.axes);
|
|
||||||
|
// Konstruktor erwartet 7 Achsen-Slots (x y z a b c e) vor dem Options-Objekt.
|
||||||
|
// Auf genau 7 auffüllen, damit heartbeatInterval nicht als Achsen-Arg landet.
|
||||||
|
const axes7 = [...(ctrl.axes ?? [])];
|
||||||
|
while (axes7.length < 7) axes7.push(null);
|
||||||
|
|
||||||
|
const instance = new TelnetSenderClass(ctrl.ip, ctrl.port, ...axes7, {
|
||||||
|
heartbeatInterval: ctrl.heartbeatInterval,
|
||||||
|
deadTimeout: 2 * ctrl.heartbeatInterval,
|
||||||
|
});
|
||||||
senders.push({ name, instance });
|
senders.push({ name, instance });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ describe('RobotConfig.load — Vollständige robot.json', () => {
|
|||||||
expect(cfg.controllers.hand.ip).toBe('fluidNcHand.local');
|
expect(cfg.controllers.hand.ip).toBe('fluidNcHand.local');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('heartbeatInterval Default wenn nicht in robot.json', () => {
|
||||||
|
expect(cfg.controllers.base.heartbeatInterval).toBe(DEFAULTS.controllers.base.heartbeatInterval);
|
||||||
|
expect(cfg.controllers.elbow.heartbeatInterval).toBe(DEFAULTS.controllers.elbow.heartbeatInterval);
|
||||||
|
expect(cfg.controllers.hand.heartbeatInterval).toBe(DEFAULTS.controllers.hand.heartbeatInterval);
|
||||||
|
});
|
||||||
|
|
||||||
test('axesByController gibt korrektes Array zurück', () => {
|
test('axesByController gibt korrektes Array zurück', () => {
|
||||||
expect(cfg.axesByController('base')).toEqual(['x', 'y', 'z']);
|
expect(cfg.axesByController('base')).toEqual(['x', 'y', 'z']);
|
||||||
expect(cfg.axesByController('elbow')).toEqual(['a', null, null]);
|
expect(cfg.axesByController('elbow')).toEqual(['a', null, null]);
|
||||||
@@ -148,3 +154,26 @@ describe('RobotConfig.load — speedMode correct', () => {
|
|||||||
expect(cfg.motion.useSpeedCalc).toBe(true);
|
expect(cfg.motion.useSpeedCalc).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('RobotConfig.load — heartbeatInterval', () => {
|
||||||
|
test('heartbeatInterval aus robot.json überschreibt Default', () => {
|
||||||
|
const json = {
|
||||||
|
...FULL_ROBOT_JSON,
|
||||||
|
controllers: {
|
||||||
|
...FULL_ROBOT_JSON.controllers,
|
||||||
|
base: { ...FULL_ROBOT_JSON.controllers.base, heartbeatInterval: 5000 },
|
||||||
|
elbow: { ...FULL_ROBOT_JSON.controllers.elbow, heartbeatInterval: 30000 },
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cfg = load(makeFs(JSON.stringify(json)), {}, log);
|
||||||
|
expect(cfg.controllers.base.heartbeatInterval).toBe(5000);
|
||||||
|
expect(cfg.controllers.elbow.heartbeatInterval).toBe(30000);
|
||||||
|
// hand nicht gesetzt → Default
|
||||||
|
expect(cfg.controllers.hand.heartbeatInterval).toBe(DEFAULTS.controllers.hand.heartbeatInterval);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fehlende heartbeatInterval → Default (10 000 ms)', () => {
|
||||||
|
const cfg = load(makeFailFs(), {}, log);
|
||||||
|
expect(cfg.controllers.base.heartbeatInterval).toBe(10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
147
test/Sender.Telnet.heartbeat.test.js
Normal file
147
test/Sender.Telnet.heartbeat.test.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'use strict';
|
||||||
|
// Heartbeat-Tests für TelnetSenderGRBL:
|
||||||
|
// Erkennung toter Verbindungen nach NotAus / Stromausfall beim Roboter.
|
||||||
|
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
const TelnetSenderGRBL = require('../robot/TelnetSenderGRBL');
|
||||||
|
|
||||||
|
// ── Hilfsfunktionen ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeRawSocket() {
|
||||||
|
const em = new EventEmitter();
|
||||||
|
return Object.assign(em, {
|
||||||
|
write: jest.fn(),
|
||||||
|
end: jest.fn(),
|
||||||
|
destroy: jest.fn(),
|
||||||
|
setKeepAlive: jest.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTelnetSocket() {
|
||||||
|
const em = new EventEmitter();
|
||||||
|
return Object.assign(em, {
|
||||||
|
write: jest.fn(),
|
||||||
|
end: jest.fn(),
|
||||||
|
destroy: jest.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen Sender mit gemockten Abhängigkeiten und simuliert
|
||||||
|
* einen erfolgreichen Verbindungsaufbau.
|
||||||
|
*/
|
||||||
|
function setup({ heartbeatInterval = 1000, deadTimeout = 5000 } = {}) {
|
||||||
|
const rawSocket = makeRawSocket();
|
||||||
|
const telnetSock = makeTelnetSocket();
|
||||||
|
|
||||||
|
// Intervall-Verwaltung: injiziert, damit kein echter setInterval läuft.
|
||||||
|
const activeIntervals = new Map();
|
||||||
|
let nextId = 1;
|
||||||
|
const setIntervalFn = jest.fn((cb) => { const id = nextId++; activeIntervals.set(id, cb); return id; });
|
||||||
|
const clearIntervalFn = jest.fn((id) => { activeIntervals.delete(id); });
|
||||||
|
|
||||||
|
// Timeout-Mock: verhindert echte setTimeout-Aufrufe bei Reconnect-Scheduling.
|
||||||
|
const setTimeoutFn = jest.fn(() => 99);
|
||||||
|
const clearTimeoutFn = jest.fn();
|
||||||
|
|
||||||
|
const sender = new TelnetSenderGRBL(
|
||||||
|
'robot.local', 5000,
|
||||||
|
'x', 'y', 'z', null, null, null, null,
|
||||||
|
{
|
||||||
|
netModule: { createConnection: jest.fn(() => rawSocket) },
|
||||||
|
TelnetSocketClass: jest.fn(() => telnetSock),
|
||||||
|
setIntervalFn,
|
||||||
|
clearIntervalFn,
|
||||||
|
setTimeoutFn,
|
||||||
|
clearTimeoutFn,
|
||||||
|
heartbeatInterval,
|
||||||
|
deadTimeout,
|
||||||
|
autoConnect: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verbindungsaufbau simulieren
|
||||||
|
sender.connect();
|
||||||
|
rawSocket.emit('connect');
|
||||||
|
|
||||||
|
/** Heartbeat-Callback manuell auslösen. */
|
||||||
|
const fireHeartbeat = () => {
|
||||||
|
const entries = [...activeIntervals.values()];
|
||||||
|
if (entries.length > 0) entries[0]();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { sender, rawSocket, telnetSock, activeIntervals, fireHeartbeat };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('TelnetSenderGRBL — Heartbeat / NotAus-Erkennung', () => {
|
||||||
|
|
||||||
|
test('TCP-Keepalive wird beim Verbinden aktiviert', () => {
|
||||||
|
const { rawSocket } = setup();
|
||||||
|
expect(rawSocket.setKeepAlive).toHaveBeenCalledWith(true, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Heartbeat-Timer wird nach Verbindung gestartet', () => {
|
||||||
|
const { activeIntervals } = setup();
|
||||||
|
expect(activeIntervals.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Heartbeat sendet ? an den Controller', () => {
|
||||||
|
const { telnetSock, fireHeartbeat } = setup();
|
||||||
|
fireHeartbeat();
|
||||||
|
expect(telnetSock.write).toHaveBeenCalledWith('?');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Socket wird zerstört wenn deadTimeout überschritten', () => {
|
||||||
|
const { sender, rawSocket, fireHeartbeat } = setup({ deadTimeout: 5000 });
|
||||||
|
sender._lastDataAt = Date.now() - 20_000; // älter als deadTimeout → tot
|
||||||
|
fireHeartbeat();
|
||||||
|
expect(rawSocket.destroy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Heartbeat-Timer wird nach Timeout-Erkennung gestoppt', () => {
|
||||||
|
const { sender, activeIntervals, fireHeartbeat } = setup({ deadTimeout: 5000 });
|
||||||
|
sender._lastDataAt = Date.now() - 20_000;
|
||||||
|
fireHeartbeat();
|
||||||
|
expect(activeIntervals.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Kein Destroy wenn Daten innerhalb deadTimeout empfangen wurden', () => {
|
||||||
|
const { sender, rawSocket, telnetSock, fireHeartbeat } = setup({ deadTimeout: 5000 });
|
||||||
|
sender._lastDataAt = Date.now(); // soeben empfangen → kein Timeout
|
||||||
|
fireHeartbeat();
|
||||||
|
expect(rawSocket.destroy).not.toHaveBeenCalled();
|
||||||
|
expect(telnetSock.write).toHaveBeenCalledWith('?');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Eingehende Daten aktualisieren _lastDataAt', () => {
|
||||||
|
const { sender, rawSocket } = setup();
|
||||||
|
const before = sender._lastDataAt;
|
||||||
|
rawSocket.emit('data', Buffer.from('<Idle|MPos:0,0,0>'));
|
||||||
|
expect(sender._lastDataAt).toBeGreaterThanOrEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Heartbeat-Timer wird bei socket close gestoppt', () => {
|
||||||
|
const { activeIntervals, telnetSock } = setup();
|
||||||
|
expect(activeIntervals.size).toBe(1);
|
||||||
|
telnetSock.emit('close');
|
||||||
|
expect(activeIntervals.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Heartbeat-Timer wird bei disconnect() gestoppt', () => {
|
||||||
|
const { sender, activeIntervals } = setup();
|
||||||
|
expect(activeIntervals.size).toBe(1);
|
||||||
|
sender.disconnect();
|
||||||
|
expect(activeIntervals.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Heartbeat stoppt sich selbst wenn tSocket null ist', () => {
|
||||||
|
const { sender, rawSocket, telnetSock, activeIntervals, fireHeartbeat } = setup();
|
||||||
|
sender.tSocket = null;
|
||||||
|
fireHeartbeat();
|
||||||
|
expect(rawSocket.destroy).not.toHaveBeenCalled();
|
||||||
|
expect(telnetSock.write).not.toHaveBeenCalled();
|
||||||
|
expect(activeIntervals.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,7 @@ describe('Sender Interface and TelnetSenderGRBL implementation', () => {
|
|||||||
createConnection: () => {
|
createConnection: () => {
|
||||||
const socket = new EventEmitter();
|
const socket = new EventEmitter();
|
||||||
socket.end = jest.fn();
|
socket.end = jest.fn();
|
||||||
|
socket.setKeepAlive = jest.fn(); // benötigt für TCP-Keepalive im connect-Handler
|
||||||
|
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
connectAttempts += 1;
|
connectAttempts += 1;
|
||||||
@@ -68,6 +69,8 @@ describe('Sender Interface and TelnetSenderGRBL implementation', () => {
|
|||||||
netModule: netMock,
|
netModule: netMock,
|
||||||
TelnetSocketClass: DummyTelnetSocket,
|
TelnetSocketClass: DummyTelnetSocket,
|
||||||
setTimeoutFn: (fn) => fn(),
|
setTimeoutFn: (fn) => fn(),
|
||||||
|
setIntervalFn: jest.fn(() => 1), // Heartbeat-Timer: kein echter Intervall
|
||||||
|
clearIntervalFn: jest.fn(),
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
reconnectDelay: 1,
|
reconnectDelay: 1,
|
||||||
maxReconnectDelay: 2
|
maxReconnectDelay: 2
|
||||||
|
|||||||
Reference in New Issue
Block a user