Heartbeat

This commit is contained in:
chk
2026-06-12 17:03:38 +02:00
parent 4db6c472b7
commit 6fc6605080
11 changed files with 342 additions and 32 deletions

View File

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

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

@@ -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,
};
}

View File

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

View File

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

View File

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

View 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);
});
});

View File

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