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

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