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" },
|
||||
"motion": { "defaultFeedrate": 1000, "speedMode": "legacy" },
|
||||
"controllers": {
|
||||
"base": { "ip": "fluidNcBase.local", "port": 2300, "protocol": "telnet", "axes": ["x","y","z"] },
|
||||
"elbow": { "ip": "fluidNcEllbow.local", "port": 5000, "protocol": "telnet", "axes": ["a",null,null] },
|
||||
"hand": { "ip": "fluidNcHand.local", "port": 5000, "protocol": "telnet", "axes": ["c","e","b"] }
|
||||
"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], "heartbeatInterval": 10000 },
|
||||
"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`
|
||||
(geladen von `robot/RobotConfig.js`). Die Defaults entsprechen drei Controllern:
|
||||
|
||||
| Key | Default-IP | Port | Achsen |
|
||||
|-----|-----------|------|--------|
|
||||
| `base` | `fluidNcBase.local` | 2300 | `x, y, z` |
|
||||
| `elbow` | `fluidNcEllbow.local` | 5000 | `a` |
|
||||
| `hand` | `fluidNcHand.local` | 5000 | `c, e, b` |
|
||||
| Key | Default-IP | Port | Achsen | `heartbeatInterval` |
|
||||
|-----|-----------|------|--------|---------------------|
|
||||
| `base` | `fluidNcBase.local` | 2300 | `x, y, z` | 10 000 ms |
|
||||
| `elbow` | `fluidNcEllbow.local` | 5000 | `a` | 10 000 ms |
|
||||
| `hand` | `fluidNcHand.local` | 5000 | `c, e, b` | 10 000 ms |
|
||||
|
||||
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`
|
||||
konfiguriert.
|
||||
`GRBL_HAND_IP`). Alles andere (Port, Achsen, Controller-Anzahl, Heartbeat) wird in
|
||||
`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
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
},
|
||||
"controllers": {
|
||||
"_owner": "appRobotDriver",
|
||||
"base": { "ip": "fluidNcBase.local", "port": 2300, "protocol": "telnet", "axes": ["x", "y", "z"] },
|
||||
"elbow": { "ip": "fluidNcEllbow.local", "port": 5000, "protocol": "telnet", "axes": ["a", null, null] },
|
||||
"hand": { "ip": "fluidNcHand.local", "port": 5000, "protocol": "telnet", "axes": ["c", "e", "b"] }
|
||||
"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], "heartbeatInterval": 5000 },
|
||||
"hand": { "ip": "fluidNcHand.local", "port": 5000, "protocol": "telnet", "axes": ["c", "e", "b"], "heartbeatInterval": 5000 }
|
||||
},
|
||||
"vision_config": {"MarkerType": "DICT_4X4_250", "MarkerSize": 0.025},
|
||||
"renderingInfo": {
|
||||
|
||||
@@ -66,7 +66,9 @@ Das ist die einzige Absicherung. Kein JWT, keine Sessions, kein Rate-Limiting
|
||||
| 1 — Datei anlegen | appRobotDriver | ✅ erledigt |
|
||||
| 2 — RobotConfigService | 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 |
|
||||
| 6 — appRobotRendering umstellen | appRobotRendering | ⬜ 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) ✅
|
||||
|
||||
Eine Zeile in `server/InfoServer.js` am Anfang der Request-Handler:
|
||||
`InfoServer.js` wurde auf Express umgestellt. Registrierung mit einer Zeile:
|
||||
|
||||
```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 ✅
|
||||
|
||||
`startRobot.js` liest beim Start arm-lengths aus `data/robot/robot.json`.
|
||||
Fallback auf `{ l1: 250, l2: 264, l3: 100 }` mit Log-Warnung wenn Datei fehlt oder Keys fehlen.
|
||||
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`):
|
||||
|
||||
```js
|
||||
// links.Arm1.size[1] → l1
|
||||
// links.Arm2.size[1] → l2
|
||||
// links.Ellbow.size[0] → l3
|
||||
// links.Arm1.skeleton.to[1] → l1 (Math.abs)
|
||||
// links.Arm2.skeleton.to[1] → l2 (Math.abs)
|
||||
// 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 ⬜
|
||||
|
||||
`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.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-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-11T19:43:39.197Z ::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 },
|
||||
motion: { defaultFeedrate: 1000, speedMode: 'legacy', useSpeedCalc: false },
|
||||
controllers: {
|
||||
base: { ip: 'fluidNcBase.local', port: 2300, protocol: 'telnet', axes: ['x', 'y', 'z'] },
|
||||
elbow: { ip: 'fluidNcEllbow.local', port: 5000, protocol: 'telnet', axes: ['a', null, null] },
|
||||
hand: { ip: 'fluidNcHand.local', port: 5000, protocol: 'telnet', axes: ['c', 'e', 'b'] }
|
||||
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], heartbeatInterval: 10000 },
|
||||
hand: { ip: 'fluidNcHand.local', port: 5000, protocol: 'telnet', axes: ['c', 'e', 'b'], heartbeatInterval: 10000 }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -87,7 +87,10 @@ function load(fsModule, processEnv, consoleObj) {
|
||||
ip: env_[envIpKey] ?? cfg.ip ?? def.ip,
|
||||
port: cfg.port ?? def.port,
|
||||
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.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 : 1000;
|
||||
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.reconnectTimer = null;
|
||||
this.shouldReconnect = true;
|
||||
@@ -108,10 +118,18 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
|
||||
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.on('close', () => {
|
||||
console.log("Telnet Closed " + this.urlGRBLstr);
|
||||
this._stopHeartbeat();
|
||||
this.tSocket = null;
|
||||
this._rawSocket = null;
|
||||
if (this.shouldReconnect) {
|
||||
this.state = 'reconnecting';
|
||||
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.error = null;
|
||||
this.reconnectAttempt = 0;
|
||||
@@ -130,6 +150,9 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
|
||||
this.connectRejecter = null;
|
||||
}
|
||||
this.connectPromise = null;
|
||||
|
||||
// Heartbeat starten: erkennt NotAus / tote Verbindungen.
|
||||
this._startHeartbeat();
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
@@ -167,6 +190,54 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
|
||||
}, 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) {
|
||||
if (!this.tSocket || typeof this.tSocket.write !== 'function') {
|
||||
return false;
|
||||
@@ -193,8 +264,11 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._stopHeartbeat();
|
||||
|
||||
if (this.isTestMode) {
|
||||
this.tSocket = null;
|
||||
this._rawSocket = null;
|
||||
this.state = 'disconnected';
|
||||
this.shouldReconnect = false;
|
||||
if (this.reconnectTimer) {
|
||||
|
||||
@@ -92,7 +92,16 @@ function createApp(options = {}) {
|
||||
const senders = [];
|
||||
for (const [key, ctrl] of Object.entries(cfg.controllers)) {
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,12 @@ describe('RobotConfig.load — Vollständige robot.json', () => {
|
||||
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', () => {
|
||||
expect(cfg.axesByController('base')).toEqual(['x', 'y', 'z']);
|
||||
expect(cfg.axesByController('elbow')).toEqual(['a', null, null]);
|
||||
@@ -148,3 +154,26 @@ describe('RobotConfig.load — speedMode correct', () => {
|
||||
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: () => {
|
||||
const socket = new EventEmitter();
|
||||
socket.end = jest.fn();
|
||||
socket.setKeepAlive = jest.fn(); // benötigt für TCP-Keepalive im connect-Handler
|
||||
|
||||
process.nextTick(() => {
|
||||
connectAttempts += 1;
|
||||
@@ -68,6 +69,8 @@ describe('Sender Interface and TelnetSenderGRBL implementation', () => {
|
||||
netModule: netMock,
|
||||
TelnetSocketClass: DummyTelnetSocket,
|
||||
setTimeoutFn: (fn) => fn(),
|
||||
setIntervalFn: jest.fn(() => 1), // Heartbeat-Timer: kein echter Intervall
|
||||
clearIntervalFn: jest.fn(),
|
||||
autoConnect: false,
|
||||
reconnectDelay: 1,
|
||||
maxReconnectDelay: 2
|
||||
|
||||
Reference in New Issue
Block a user