From 6fc66050805e63a0974ebe718cc72ebcfd486f4e Mon Sep 17 00:00:00 2001 From: chk <79915315+ChKendel@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:03:38 +0200 Subject: [PATCH] Heartbeat --- README.md | 25 +++-- data/robot/robot.json | 6 +- doc/ToDo_14_robot_json_service.md | 32 ++++-- logs/gcode_commands.log | 20 ++++ logs/pings.log | 8 ++ robot/RobotConfig.js | 17 ++-- robot/TelnetSenderGRBL.js | 76 +++++++++++++- startRobot.js | 11 +- test/RobotConfig.test.js | 29 ++++++ test/Sender.Telnet.heartbeat.test.js | 147 +++++++++++++++++++++++++++ test/SenderInterface.test.js | 3 + 11 files changed, 342 insertions(+), 32 deletions(-) create mode 100644 test/Sender.Telnet.heartbeat.test.js diff --git a/README.md b/README.md index 10c37a4..8c6f640 100644 --- a/README.md +++ b/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 diff --git a/data/robot/robot.json b/data/robot/robot.json index 4fe4b44..b11013e 100644 --- a/data/robot/robot.json +++ b/data/robot/robot.json @@ -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": { diff --git a/doc/ToDo_14_robot_json_service.md b/doc/ToDo_14_robot_json_service.md index 1c6f502..246fd16 100644 --- a/doc/ToDo_14_robot_json_service.md +++ b/doc/ToDo_14_robot_json_service.md @@ -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 (`
`). 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: diff --git a/logs/gcode_commands.log b/logs/gcode_commands.log index a0fc5c2..90a6cf5 100644 --- a/logs/gcode_commands.log +++ b/logs/gcode_commands.log @@ -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 diff --git a/logs/pings.log b/logs/pings.log index ffa904b..d35a39e 100644 --- a/logs/pings.log +++ b/logs/pings.log @@ -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 diff --git a/robot/RobotConfig.js b/robot/RobotConfig.js index 39ede47..a5ff7d5 100644 --- a/robot/RobotConfig.js +++ b/robot/RobotConfig.js @@ -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 } } }; @@ -84,10 +84,13 @@ function load(fsModule, processEnv, consoleObj) { const cfg = jsonControllers[key] ?? {}; const envIpKey = ENV_IP_MAP[key]; controllers[key] = { - ip: env_[envIpKey] ?? cfg.ip ?? def.ip, - port: cfg.port ?? def.port, - protocol: cfg.protocol ?? def.protocol, - axes: cfg.axes ?? def.axes + ip: env_[envIpKey] ?? cfg.ip ?? def.ip, + port: cfg.port ?? def.port, + protocol: cfg.protocol ?? def.protocol, + 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, }; } diff --git a/robot/TelnetSenderGRBL.js b/robot/TelnetSenderGRBL.js index dca6ac2..949a951 100755 --- a/robot/TelnetSenderGRBL.js +++ b/robot/TelnetSenderGRBL.js @@ -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 oder + 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) { diff --git a/startRobot.js b/startRobot.js index f020768..9af12e0 100755 --- a/startRobot.js +++ b/startRobot.js @@ -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 }); } diff --git a/test/RobotConfig.test.js b/test/RobotConfig.test.js index af5884f..109d4b2 100644 --- a/test/RobotConfig.test.js +++ b/test/RobotConfig.test.js @@ -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); + }); +}); diff --git a/test/Sender.Telnet.heartbeat.test.js b/test/Sender.Telnet.heartbeat.test.js new file mode 100644 index 0000000..5312718 --- /dev/null +++ b/test/Sender.Telnet.heartbeat.test.js @@ -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('')); + 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); + }); +}); diff --git a/test/SenderInterface.test.js b/test/SenderInterface.test.js index 810f2a4..c3525a8 100644 --- a/test/SenderInterface.test.js +++ b/test/SenderInterface.test.js @@ -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