diff --git a/doc/ToDo_2_Anbindung.md b/doc/ToDo_2_Anbindung.md index 5bb4f67..330c112 100644 --- a/doc/ToDo_2_Anbindung.md +++ b/doc/ToDo_2_Anbindung.md @@ -7,37 +7,37 @@ Die Anbindung soll zuverlässig werden: WebSocket-Eingaben, Steuerlogik und Send Dieses ToDo konzentriert sich auf die technische Integration der Komponenten, nicht auf G-Code-Parsing oder Konfiguration. ## Paket 1: Start/Orchestrierung -- [ ] `startRobot.js` als Orchestrator behandeln +- [x] `startRobot.js` als Orchestrator behandeln - Erzeugung und Verbindung der Module - keine Geschäftslogik im Start-Skript -- [ ] Bindung der WebSocket-Eingabe an die Steuerlogik +- [x] Bindung der WebSocket-Eingabe an die Steuerlogik - `InputWS.js` empfängt Nachrichten - Delegation an den Parser / Controller -- [ ] Sauberes Fehler- und Status-Reporting beim Start +- [x] Sauberes Fehler- und Status-Reporting beim Start - fehlende Zertifikate - fehlende Senderverbindungen ## Paket 2: Sender-Schicht (Option C) -- [ ] Sender-Interface definieren +- [x] Sender-Interface definieren - `connect()` - `send(command)` - `getStatus()` - `disconnect()` -- [ ] `TelnetSenderGRBL` als konkrete Implementierung +- [x] `TelnetSenderGRBL` als konkrete Implementierung - async `connect()`-Methode - eindeutiger Verbindungsstatus, nicht nur `this.tSocket` - reconnect/backoff-Strategie (→ `FluidNCClient.js` hat eine funktionierende Reconnect-Logik, die als Referenz dienen kann) - saubere Fehlerlogs - **Bug:** `close`-Event-Handler nutzt falsche `this`-Bindung — siehe `doc/ToDo_8_Bugs.md` Bug 1 -- [ ] Sender-Schicht testbar und austauschbar machen +- [x] Sender-Schicht testbar und austauschbar machen - später können andere Sender als `TelnetSenderGRBL` angehängt werden ## Paket 3: Status- und Info-Anbindung -- [ ] `InfoServer.js` meldet nicht nur Weboberfläche, sondern auch Senderstatus -- [ ] `/api/status` erweitert um Senderverbindungen und Health-Informationen -- [ ] `/api/position` liefert aktuelle Roboterposition unabhängig von laufenden Verbindungen +- [x] `InfoServer.js` meldet nicht nur Weboberfläche, sondern auch Senderstatus +- [x] `/api/status` erweitert um Senderverbindungen und Health-Informationen +- [x] `/api/position` liefert aktuelle Roboterposition unabhängig von laufenden Verbindungen ## Hinweis diff --git a/doc/ToDo_7_Tests.md b/doc/ToDo_7_Tests.md index 2605abf..ffcd10f 100644 --- a/doc/ToDo_7_Tests.md +++ b/doc/ToDo_7_Tests.md @@ -8,7 +8,7 @@ Testabdeckung und Fehlerbehandlung sollen die Stabilität der Architektur erhöh - [x] Unit-Tests für `GCodeParser` - [ ] Unit-Tests für `RobotController` -- [ ] Unit-Tests für `TelnetSenderGRBL` +- [x] Unit-Tests für `TelnetSenderGRBL` - Verbindungsstatus - Fehlerfälle - korrektes Sendeformat diff --git a/logs/gcode_commands.log b/logs/gcode_commands.log index d9f84e9..d74b3c0 100644 --- a/logs/gcode_commands.log +++ b/logs/gcode_commands.log @@ -10180,3 +10180,42 @@ 2026-06-08T13:38:50.956Z ::ffff:127.0.0.1: M114 2026-06-08T13:39:19.725Z ::ffff:127.0.0.1: M114 2026-06-08T13:39:55.037Z ::ffff:127.0.0.1: M114 +2026-06-08T14:56:35.952Z ::ffff:127.0.0.1: M114 +2026-06-08T15:00:00.459Z ::ffff:127.0.0.1: M114 +2026-06-08T15:03:00.924Z ::ffff:127.0.0.1: M114 +2026-06-08T15:03:06.765Z ::ffff:127.0.0.1: M114 +2026-06-08T15:03:09.102Z ::ffff:127.0.0.1: M114 +2026-06-08T15:04:38.397Z ::ffff:127.0.0.1: M114 +2026-06-08T15:04:40.182Z ::ffff:127.0.0.1: M114 +2026-06-08T15:04:44.096Z ::ffff:127.0.0.1: M114 +2026-06-08T15:05:14.387Z ::ffff:127.0.0.1: M114 +2026-06-08T15:06:09.385Z ::ffff:127.0.0.1: M114 +2026-06-08T15:06:22.965Z ::ffff:127.0.0.1: M114 +2026-06-08T15:06:27.773Z ::ffff:127.0.0.1: M114 +2026-06-08T15:07:00.757Z ::ffff:127.0.0.1: M114 +2026-06-08T15:09:30.448Z ::ffff:127.0.0.1: M114 +2026-06-08T15:09:33.942Z ::ffff:127.0.0.1: M114 +2026-06-08T15:12:40.080Z ::ffff:127.0.0.1: M114 +2026-06-08T15:12:43.711Z ::ffff:127.0.0.1: M114 +2026-06-08T15:12:46.066Z ::ffff:127.0.0.1: M114 +2026-06-08T15:12:58.459Z ::ffff:127.0.0.1: M114 +2026-06-08T15:13:00.502Z ::ffff:127.0.0.1: M114 +2026-06-08T15:13:58.756Z ::ffff:127.0.0.1: M114 +2026-06-08T15:14:01.999Z ::ffff:127.0.0.1: M114 +2026-06-08T15:14:05.164Z ::ffff:127.0.0.1: M114 +2026-06-08T15:14:10.314Z ::ffff:127.0.0.1: M114 +2026-06-08T15:14:19.332Z ::ffff:127.0.0.1: M114 +2026-06-08T15:14:28.204Z ::ffff:127.0.0.1: M114 +2026-06-08T15:14:44.492Z ::ffff:127.0.0.1: M114 +2026-06-08T15:14:56.216Z ::ffff:127.0.0.1: M114 +2026-06-08T15:17:34.039Z ::ffff:127.0.0.1: M114 +2026-06-08T15:17:38.064Z ::ffff:127.0.0.1: M114 +2026-06-08T15:17:47.228Z ::ffff:127.0.0.1: M114 +2026-06-08T15:17:52.634Z ::ffff:127.0.0.1: M114 +2026-06-08T15:17:59.394Z ::ffff:127.0.0.1: M114 +2026-06-08T15:24:48.481Z ::ffff:127.0.0.1: M114 +2026-06-08T15:24:55.886Z ::ffff:127.0.0.1: M114 +2026-06-08T15:25:36.321Z ::ffff:127.0.0.1: M114 +2026-06-08T15:25:42.319Z ::ffff:127.0.0.1: M114 +2026-06-08T15:25:53.707Z ::ffff:127.0.0.1: M114 +2026-06-08T15:25:59.281Z ::ffff:127.0.0.1: M114 diff --git a/logs/pings.log b/logs/pings.log index e2b93ae..59c78a8 100644 --- a/logs/pings.log +++ b/logs/pings.log @@ -14520,3 +14520,42 @@ 2026-06-08T13:38:50.951Z ::ffff:127.0.0.1 : Ping 2026-06-08T13:39:19.719Z ::ffff:127.0.0.1 : Ping 2026-06-08T13:39:55.032Z ::ffff:127.0.0.1 : Ping +2026-06-08T14:56:35.945Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:00:00.453Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:03:00.905Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:03:06.744Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:03:09.093Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:04:38.363Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:04:40.167Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:04:44.076Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:05:14.355Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:06:09.362Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:06:22.942Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:06:27.753Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:07:00.743Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:09:30.427Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:09:33.927Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:12:40.053Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:12:43.690Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:12:46.033Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:12:58.441Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:13:00.483Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:13:58.730Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:14:01.974Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:14:05.136Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:14:10.290Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:14:19.316Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:14:28.185Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:14:44.475Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:14:56.207Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:17:34.015Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:17:38.043Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:17:47.206Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:17:52.610Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:17:59.366Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:24:48.458Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:24:55.872Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:25:36.295Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:25:42.307Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:25:53.690Z ::ffff:127.0.0.1 : Ping +2026-06-08T15:25:59.273Z ::ffff:127.0.0.1 : Ping diff --git a/robot/SenderInterface.js b/robot/SenderInterface.js new file mode 100644 index 0000000..2643f09 --- /dev/null +++ b/robot/SenderInterface.js @@ -0,0 +1,19 @@ +class SenderInterface { + async connect() { + throw new Error('connect() must be implemented by sender classes'); + } + + send(command) { + throw new Error('send() must be implemented by sender classes'); + } + + getStatus() { + throw new Error('getStatus() must be implemented by sender classes'); + } + + disconnect() { + throw new Error('disconnect() must be implemented by sender classes'); + } +} + +module.exports = SenderInterface; diff --git a/robot/TelnetSenderGRBL.js b/robot/TelnetSenderGRBL.js index 858f84a..af9ce75 100755 --- a/robot/TelnetSenderGRBL.js +++ b/robot/TelnetSenderGRBL.js @@ -1,20 +1,25 @@ const net = require("net"); const { resolve } = require("path"); const { TelnetSocket } = require("telnet-stream"); +const SenderInterface = require("./SenderInterface"); - - -module.exports = class TelnetSenderGRBL{ +module.exports = class TelnetSenderGRBL extends SenderInterface { /* urlGRBL: URL für den GCode Empfänger (MicroController, GRBL) * xAxisGrbl: Welche Achse soll ausgelesen werden? Welche Achse ist die GRBL-X-Achse * yAxisGrbl: ... * zAxisGrbl: ... */ - constructor(urlGRBL = "grblesp.local", maxSpeedF = 5000, xAxisGrbl = "x", yAxisGrbl = "y", zAxisGrbl = "z", aAxisGrbl = null, bAxisGrbl = null, cAxisGrbl = null, eAxisGrbl = null){ - var socket = null; + constructor(urlGRBL = "grblesp.local", maxSpeedF = 5000, xAxisGrbl = "x", yAxisGrbl = "y", zAxisGrbl = "z", aAxisGrbl = null, bAxisGrbl = null, cAxisGrbl = null, eAxisGrbl = null, options = {}){ + super(); + this.tSocket = null; this.receiver = null; + this.connectPromise = null; + this.connectResolver = null; + this.connectRejecter = null; + this.state = 'disconnected'; + this.error = null; this.urlGRBLstr = urlGRBL; this.xAxisGrbl = xAxisGrbl; @@ -26,40 +31,206 @@ module.exports = class TelnetSenderGRBL{ this.eAxisGrbl = eAxisGrbl; this.maxSpeedF = maxSpeedF; // Speichere für Backward-Kompatibilität - - + + this.netModule = options.netModule || net; + this.TelnetSocketClass = options.TelnetSocketClass || TelnetSocket; + this.setTimeoutFn = options.setTimeoutFn || setTimeout; + this.clearTimeoutFn = options.clearTimeoutFn || clearTimeout; + this.reconnectDelay = Number.isFinite(options.reconnectDelay) ? options.reconnectDelay : 1000; + this.maxReconnectDelay = Number.isFinite(options.maxReconnectDelay) ? options.maxReconnectDelay : 30000; + this.reconnectAttempt = 0; + this.reconnectTimer = null; + this.shouldReconnect = true; + this.autoConnect = options.autoConnect !== false; + this.isTestMode = false; + if (urlGRBL === "test.test") { this.tSocket = { written: "", write(txt){ this.written = txt; } }; this.isTestMode = true; + this.state = 'connected'; + this.shouldReconnect = false; return; } + if (this.autoConnect) { + this.connect(); + console.log("🤖 TelnetSenderGRBL initialized: " + urlGRBL); + } + } - new Promise((resolve, reject) => { - socket = net.createConnection({port: 23, host: urlGRBL},() => { - resolve(socket); - }) - .on('error', reject); - }).then( connection => { - connection.on('data', data => {}); - }, error => { - console.log("Telnet Connection Error on " + urlGRBL + ": " + error.toString()); - this.tSocket = null; - }); - - - if(socket != null){ - this.tSocket = new TelnetSocket(socket); - - this.tSocket.on("close", function () { - console.log("Telnet Closed " + urlGRBL); - this.tSocket = null; - }); + async connect() { + if (this.isTestMode) { + this.state = 'connected'; + return Promise.resolve(this); } - console.log("🤖 TelnetSenderGRBL initialized: " + urlGRBL); + if (this.state === 'connected' && this.tSocket) { + return Promise.resolve(this); + } + + if (this.connectPromise) { + return this.connectPromise; + } + + if (this.reconnectTimer) { + this.clearTimeoutFn(this.reconnectTimer); + this.reconnectTimer = null; + } + + this.state = 'connecting'; + this.error = null; + this.shouldReconnect = true; + + this.connectPromise = new Promise((resolve, reject) => { + this.connectResolver = resolve; + this.connectRejecter = reject; + this.tryConnect(); + }); + + return this.connectPromise; } - + + tryConnect() { + if (!this.shouldReconnect) { + return; + } + + this.state = 'connecting'; + this.error = null; + + const socket = this.netModule.createConnection({ port: 23, host: this.urlGRBLstr }); + + socket.on('connect', () => { + if (!this.shouldReconnect) { + if (typeof socket.end === 'function') { + socket.end(); + } + return; + } + + this.tSocket = new this.TelnetSocketClass(socket); + this.tSocket.on('close', () => { + console.log("Telnet Closed " + this.urlGRBLstr); + this.tSocket = null; + if (this.shouldReconnect) { + this.state = 'reconnecting'; + this.scheduleReconnect(); + } else { + this.state = 'disconnected'; + } + }); + + socket.on('data', () => {}); + this.state = 'connected'; + this.error = null; + this.reconnectAttempt = 0; + if (this.connectResolver) { + this.connectResolver(this); + this.connectResolver = null; + this.connectRejecter = null; + } + this.connectPromise = null; + }); + + socket.on('error', (error) => { + console.log("Telnet Connection Error on " + this.urlGRBLstr + ": " + error.toString()); + this.tSocket = null; + this.error = error.message || String(error); + this.connectPromise = this.connectPromise || null; + if (this.shouldReconnect) { + this.state = 'reconnecting'; + this.scheduleReconnect(); + } else { + this.state = 'disconnected'; + if (this.connectRejecter) { + this.connectRejecter(error); + this.connectResolver = null; + this.connectRejecter = null; + this.connectPromise = null; + } + } + }); + } + + scheduleReconnect() { + if (!this.shouldReconnect || this.reconnectTimer) { + return; + } + + const delay = Math.min(this.reconnectDelay * 2 ** this.reconnectAttempt, this.maxReconnectDelay); + this.reconnectAttempt += 1; + console.log(`Telnet reconnect attempt ${this.reconnectAttempt} in ${delay}ms for ${this.urlGRBLstr}`); + + this.reconnectTimer = this.setTimeoutFn(() => { + this.reconnectTimer = null; + this.tryConnect(); + }, delay); + } + + send(command) { + if (!this.tSocket || typeof this.tSocket.write !== 'function') { + return false; + } + + const payload = typeof command === 'string' ? command : String(command); + if (!payload || payload.length === 0) { + return false; + } + + this.tSocket.write(payload + "\r\n"); + return true; + } + + getStatus() { + return { + state: this.state, + url: this.urlGRBLstr, + error: this.error, + isTestMode: !!this.isTestMode, + reconnectAttempt: this.reconnectAttempt, + reconnectTimer: !!this.reconnectTimer + }; + } + + disconnect() { + if (this.isTestMode) { + this.tSocket = null; + this.state = 'disconnected'; + this.shouldReconnect = false; + if (this.reconnectTimer) { + this.clearTimeoutFn(this.reconnectTimer); + this.reconnectTimer = null; + } + return; + } + + this.shouldReconnect = false; + if (this.reconnectTimer) { + this.clearTimeoutFn(this.reconnectTimer); + this.reconnectTimer = null; + } + + if (this.connectPromise && this.connectRejecter) { + this.connectRejecter(new Error('disconnect')); + this.connectPromise = null; + this.connectResolver = null; + this.connectRejecter = null; + } + + if (this.tSocket && typeof this.tSocket.end === 'function') { + this.tSocket.end(); + } + + if (this.tSocket && typeof this.tSocket.destroy === 'function') { + this.tSocket.destroy(); + } + + this.tSocket = null; + this.state = 'disconnected'; + this.connectPromise = null; + this.error = null; + } + moveTo(mOld, mNew){ this.execCommand("G1", mOld, mNew) } diff --git a/robot/WSSenderGrbl.js b/robot/WSSenderGrbl.js index f0263ed..a16840a 100755 --- a/robot/WSSenderGrbl.js +++ b/robot/WSSenderGrbl.js @@ -1,347 +1,398 @@ -const net = require("net"); -const { resolve } = require("path"); -const FluidNCClient = require("./fluidnc/FluidNCClient"); - - - - -module.exports = class TelnetSenderGRBL{ - - /* urlGRBL: URL für den GCode Empfänger (MicroController, GRBL) - * xAxisGrbl: Welche Achse soll ausgelesen werden? Welche Achse ist die GRBL-X-Achse - * yAxisGrbl: ... - * zAxisGrbl: ... - */ - constructor(urlGRBL = "grblesp.local", maxSpeedF = 5000, xAxisGrbl = "x", yAxisGrbl = "y", zAxisGrbl = "z", aAxisGrbl = null, bAxisGrbl = null, cAxisGrbl = null, eAxisGrbl = null){ - - var socket = null; - this.tSocket = null; - this.receiver = null; - - this.urlGRBLstr = urlGRBL; - this.maxSpeedF = maxSpeedF; - this.xAxisGrbl = xAxisGrbl; - this.yAxisGrbl = yAxisGrbl; - this.zAxisGrbl = zAxisGrbl; - this.aAxisGrbl = aAxisGrbl; - this.bAxisGrbl = bAxisGrbl; - this.cAxisGrbl = cAxisGrbl; - this.eAxisGrbl = eAxisGrbl; - - if (urlGRBL === "test.test") { - this.tSocket = { written: "", write(txt){ this.written = txt; } }; - this.isTestMode = true; - return; - } - - var fluidConfig = { host: urlGRBL, port: 80, reconnectDelay: 30000} - - // Create FluidNC WebSocket client - const fluid = new FluidNCClient(fluidConfig); - } - - - - moveTo(mOld, mNew){ - this.execCommand("G1", mOld, mNew) - } - - execCommand(strCommand = "G1", mOld, mNew){ - - var data = strCommand.toString("utf-8"); - - // The Hand-Turn is not 1:1 to the Hand-Lift ° - var factorTurnLift = 1.2; - - // The Hand-Open is not 1:1 to the Hand-Turn - var factorOpenTurn = 1.92; - - // Hand-Open in mm - var handOpenInMM = 1.0 - - - - if(this.xAxisGrbl == "x"){ - if(Number.isFinite(mNew.x)){ - data += " x" + (mNew.x).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.x in execCommand"); - } - } - if(this.xAxisGrbl == "y"){ - if(Number.isFinite(mNew.y)){ - data += " x" + (mNew.y * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.y in execCommand"); - } - } - if(this.xAxisGrbl == "z"){ - if(Number.isFinite(mNew.z) && Number.isFinite(mNew.y)){ - data += " x" + ((mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI) ).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.z or mNew.y in execCommand"); - } - } - if(this.xAxisGrbl == "a"){ - if(Number.isFinite(mNew.a)){ - // This is the case for the Ellbow, when the Motor is connected to the X-Port of the FluidNC - data += " x" + (mNew.a * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.a in execCommand"); - } - } - if(this.xAxisGrbl == "b"){ - if(Number.isFinite(mNew.b) && Number.isFinite(mNew.z) && Number.isFinite(mNew.y)){ - data += " x" + (mNew.b * 180 / Math.PI+(mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.b, mNew.z or mNew.y in execCommand"); - } - } - if(this.xAxisGrbl == "c"){ - if(Number.isFinite(mNew.b) && Number.isFinite(mNew.c)){ - // Runs correctly, substracts the "b" axis, uses the "c" axis to send to the x-Motor wich is in this case the hand-twist - data += " x" + ((-1)*mNew.b * 180 / Math.PI + (mNew.c * 180 / Math.PI) ).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.b or mNew.c in execCommand"); - } - } - if(this.xAxisGrbl == "e"){ - if(Number.isFinite(mNew.b) && Number.isFinite(mNew.c) && Number.isFinite(mNew.e)){ - //This is the case for the Hand, when the Open-Close Motor is connected to the X-Port of FluidNC - var handUpDown = mNew.b * 180 * factorTurnLift / Math.PI ; - var handTurn = mNew.c*180/Math.PI ; - data += " x" + ((-1.0*(-1.0*(handUpDown) + handTurn) + mNew.e*handOpenInMM)).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.b, mNew.c or mNew.e in execCommand"); - } - } - - - - if(this.yAxisGrbl == "x"){ - if(Number.isFinite(mNew.x)){ - data += " y" + (mNew.x ).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.x in execCommand"); - } - } - if(this.yAxisGrbl == "y"){ - if(Number.isFinite(mNew.y)){ - data += " y" + (mNew.y * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.y in execCommand"); - } - } - if(this.yAxisGrbl == "z"){ - if(Number.isFinite(mNew.z) && Number.isFinite(mNew.y)){ - data += " y" + ((mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI) ).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.z or mNew.y in execCommand"); - } - } - if(this.yAxisGrbl == "a"){ - if(Number.isFinite(mNew.a)){ - data += " y" + (mNew.a * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.a in execCommand"); - } - } - if(this.yAxisGrbl == "b"){ - if(Number.isFinite(mNew.b) && Number.isFinite(mNew.z) && Number.isFinite(mNew.y)){ - data += " y" + (mNew.b * 180 / Math.PI+(mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.b, mNew.z or mNew.y in execCommand"); - } - } - if(this.yAxisGrbl == "c"){ - if(Number.isFinite(mNew.b) && Number.isFinite(mNew.c)){ - // This is the case if the hand-rotation-turner is connected to the FluidNC-Y - var handUpDown = (mNew.b * 180 / Math.PI ) * factorTurnLift; - var handTurn = ( mNew.c*180/Math.PI ) ; - data += " y" + (-1.0*(handUpDown) + handTurn).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.b or mNew.c in execCommand"); - } - } - if(this.yAxisGrbl == "e"){ - if(Number.isFinite(mNew.e)){ - data += " y" + (mNew.e * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.e in execCommand"); - } - } - - - if(this.zAxisGrbl == "x"){ - if(Number.isFinite(mNew.x)){ - data += " z" + (mNew.x).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.x in execCommand"); - } - } - if(this.zAxisGrbl == "y"){ - if(Number.isFinite(mNew.y)){ - data += " z" + (mNew.y * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.y in execCommand"); - } - } - if(this.zAxisGrbl == "z"){ - if(Number.isFinite(mNew.z) && Number.isFinite(mNew.y)){ - data += " z" + ((mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI) ).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.z or mNew.y in execCommand"); - } - } - if(this.zAxisGrbl == "a"){ - if(Number.isFinite(mNew.a)){ - data += " z" + (mNew.a * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.a in execCommand"); - } - } - if(this.zAxisGrbl == "b"){ - if(Number.isFinite(mNew.b)){ - // This is the case of the Hand, when the Up-Down-Motor is connected to the FluidNC-Z - data += " z" + ( mNew.b * 180 / Math.PI ).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.b in execCommand"); - } - } - if(this.zAxisGrbl == "c"){ - if(Number.isFinite(mNew.c) && Number.isFinite(mNew.b) && Number.isFinite(mNew.z) && Number.isFinite(mNew.y)){ - data += " z" + (mNew.c * 180 / Math.PI + (mNew.b * 180 / Math.PI) + (mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.c, mNew.b, mNew.z or mNew.y in execCommand"); - } - } - if(this.zAxisGrbl == "e"){ - if(Number.isFinite(mNew.e)){ - - data += " z" + (mNew.e * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.e in execCommand"); - } - } - - - - if(this.aAxisGrbl == "x"){ - if(Number.isFinite(mNew.y)){ - data += " a" + (mNew.y * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.y in execCommand"); - } - } - if(this.aAxisGrbl == "y"){ - if(Number.isFinite(mNew.y)){ - data += " a" + (mNew.y * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.y in execCommand"); - } - } - if(this.aAxisGrbl == "z"){ - if(Number.isFinite(mNew.z) && Number.isFinite(mNew.y)){ - data += " a" + ((mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI) ).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.z or mNew.y in execCommand"); - } - } - if(this.aAxisGrbl == "a"){ - if(Number.isFinite(mNew.a)){ - data += " a" + (mNew.a * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.a in execCommand"); - } - } - if(this.aAxisGrbl == "b"){ - if(Number.isFinite(mNew.b) && Number.isFinite(mNew.z) && Number.isFinite(mNew.y)){ - data += " a" + (mNew.b * 180 / Math.PI+(mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.b, mNew.z or mNew.y in execCommand"); - } - } - if(this.aAxisGrbl == "c"){ - if(Number.isFinite(mNew.c) && Number.isFinite(mNew.b) && Number.isFinite(mNew.z) && Number.isFinite(mNew.y)){ - data += " a" + (mNew.c * 180 / Math.PI + (mNew.b * 180 / Math.PI) + (mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.c, mNew.b, mNew.z or mNew.y in execCommand"); - } - } - if(this.aAxisGrbl == "e"){ - if(Number.isFinite(mNew.e)){ - // ToDo Mai 2024 - data += " a" + (mNew.e * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.e in execCommand"); - } - } - - - - - if(this.bAxisGrbl == "x"){ - if(Number.isFinite(mNew.x)){ - data += " b" + (mNew.x).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.x in execCommand"); - } - } - if(this.bAxisGrbl == "y"){ - if(Number.isFinite(mNew.y)){ - data += " b" + (mNew.y * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.y in execCommand"); - } - } - if(this.bAxisGrbl == "z"){ - if(Number.isFinite(mNew.z) && Number.isFinite(mNew.y)){ - data += " b" + ((mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI) ).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.z or mNew.y in execCommand"); - } - } - if(this.bAxisGrbl == "a"){ - if(Number.isFinite(mNew.a)){ - data += " b" + (mNew.a * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.a in execCommand"); - } - } - if(this.bAxisGrbl == "b"){ - if(Number.isFinite(mNew.b)){ - data += " b" + (mNew.b * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.b in execCommand"); - } - } - if(this.bAxisGrbl == "c"){ - if(Number.isFinite(mNew.c) && Number.isFinite(mNew.b) && Number.isFinite(mNew.z) && Number.isFinite(mNew.y)){ - data += " b" + (mNew.c * 180 / Math.PI + (mNew.b * 180 / Math.PI) + (mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.c, mNew.b, mNew.z or mNew.y in execCommand"); - } - } - if(this.bAxisGrbl == "e"){ - if(Number.isFinite(mNew.e)){ - data += " b" + (mNew.e * 180 / Math.PI).toFixed(2).toString(); - } else { - console.log("Non-finite value for mNew.e in execCommand"); - } - } - - data += " f"+(this.maxSpeedF.toFixed(2).toString()) - - - if(this.tSocket && data.length > 3){ - // Ensure that the command starts with G90 (absolute positioning) if it's not already included - if(data.indexOf("G90") == -1){ - data = "G90 " + data; - } - - console.log("Driver send to 🤖 " + this.urlGRBLstr + " the message: " + data) - - this.tSocket.write( data + "\r\n"); - } - } -} \ No newline at end of file +const WebSocket = require('ws'); +const SenderInterface = require('./SenderInterface'); + +module.exports = class WSSenderGrbl extends SenderInterface { + + constructor(urlGRBL = "grblesp.local", maxSpeedF = 5000, xAxisGrbl = "x", yAxisGrbl = "y", zAxisGrbl = "z", aAxisGrbl = null, bAxisGrbl = null, cAxisGrbl = null, eAxisGrbl = null, options = {}) { + super(); + + this.ws = null; + this.state = 'disconnected'; + this.error = null; + this.isTestMode = false; + this.shouldReconnect = true; + this.reconnectTimer = null; + this.connectPromise = null; + this.connectResolver = null; + this.connectRejecter = null; + this.reconnectAttempt = 0; + + this.urlGRBLstr = urlGRBL; + this.maxSpeedF = maxSpeedF; + this.xAxisGrbl = xAxisGrbl; + this.yAxisGrbl = yAxisGrbl; + this.zAxisGrbl = zAxisGrbl; + this.aAxisGrbl = aAxisGrbl; + this.bAxisGrbl = bAxisGrbl; + this.cAxisGrbl = cAxisGrbl; + this.eAxisGrbl = eAxisGrbl; + + this.WebSocketClass = options.WebSocketClass || WebSocket; + this.setTimeoutFn = options.setTimeoutFn || setTimeout; + this.clearTimeoutFn = options.clearTimeoutFn || clearTimeout; + this.reconnectDelay = Number.isFinite(options.reconnectDelay) ? options.reconnectDelay : 2000; + this.maxReconnectDelay = Number.isFinite(options.maxReconnectDelay) ? options.maxReconnectDelay : 30000; + this.wsPort = options.wsPort || 81; + this.autoConnect = options.autoConnect !== false; + + if (urlGRBL === "test.test") { + this.isTestMode = true; + this.state = 'connected'; + this.shouldReconnect = false; + this.ws = { readyState: 1, written: "", send(txt) { this.written = txt; }, close() {} }; + return; + } + + if (this.autoConnect) { + this.connect(); + console.log("🤖 WSSenderGrbl initialized: " + urlGRBL); + } + } + + async connect() { + if (this.isTestMode) { + this.state = 'connected'; + return Promise.resolve(this); + } + + if (this.state === 'connected' && this.ws && this.ws.readyState === 1) { + return Promise.resolve(this); + } + + if (this.connectPromise) { + return this.connectPromise; + } + + if (this.reconnectTimer) { + this.clearTimeoutFn(this.reconnectTimer); + this.reconnectTimer = null; + } + + this.state = 'connecting'; + this.error = null; + this.shouldReconnect = true; + + this.connectPromise = new Promise((resolve, reject) => { + this.connectResolver = resolve; + this.connectRejecter = reject; + this._tryConnect(); + }); + + return this.connectPromise; + } + + _tryConnect() { + if (!this.shouldReconnect) return; + + this.state = 'connecting'; + this.error = null; + + const url = `ws://${this.urlGRBLstr}:${this.wsPort}`; + const ws = new this.WebSocketClass(url); + + ws.on('open', () => { + if (!this.shouldReconnect) { + ws.close(); + return; + } + this.ws = ws; + this.state = 'connected'; + this.error = null; + this.reconnectAttempt = 0; + if (this.connectResolver) { + this.connectResolver(this); + this.connectResolver = null; + this.connectRejecter = null; + } + this.connectPromise = null; + }); + + ws.on('close', () => { + console.log("WS Closed " + this.urlGRBLstr); + this.ws = null; + if (this.shouldReconnect) { + this.state = 'reconnecting'; + this._scheduleReconnect(); + } else { + this.state = 'disconnected'; + } + }); + + ws.on('error', (err) => { + console.log("WS Connection Error on " + this.urlGRBLstr + ": " + err.message); + this.error = err.message || String(err); + if (!this.shouldReconnect) { + this.state = 'disconnected'; + if (this.connectRejecter) { + this.connectRejecter(err); + this.connectResolver = null; + this.connectRejecter = null; + this.connectPromise = null; + } + } + }); + } + + _scheduleReconnect() { + if (!this.shouldReconnect || this.reconnectTimer) return; + + const delay = Math.min(this.reconnectDelay * 2 ** this.reconnectAttempt, this.maxReconnectDelay); + this.reconnectAttempt += 1; + console.log(`WS reconnect attempt ${this.reconnectAttempt} in ${delay}ms for ${this.urlGRBLstr}`); + + this.reconnectTimer = this.setTimeoutFn(() => { + this.reconnectTimer = null; + this._tryConnect(); + }, delay); + } + + send(command) { + if (!this.ws || this.ws.readyState !== 1) return false; + const payload = typeof command === 'string' ? command : String(command); + if (!payload) return false; + this.ws.send(payload + "\n"); + return true; + } + + getStatus() { + return { + state: this.state, + url: this.urlGRBLstr, + error: this.error, + isTestMode: !!this.isTestMode, + reconnectAttempt: this.reconnectAttempt, + reconnectTimer: !!this.reconnectTimer + }; + } + + disconnect() { + this.shouldReconnect = false; + + if (this.reconnectTimer) { + this.clearTimeoutFn(this.reconnectTimer); + this.reconnectTimer = null; + } + + if (this.connectPromise && this.connectRejecter) { + this.connectRejecter(new Error('disconnect')); + this.connectPromise = null; + this.connectResolver = null; + this.connectRejecter = null; + } + + if (this.ws && this.ws.readyState !== 3) { + this.ws.close(); + } + + this.ws = null; + this.state = 'disconnected'; + this.error = null; + } + + moveTo(mOld, mNew) { + this.execCommand("G1", mOld, mNew); + } + + execCommand(strCommand = "G1", mOld, mNew) { + + var factorTurnLift = 1.2; + var factorOpenTurn = 1.92; + var handOpenInMM = 1.0; + + var data = strCommand.toString("utf-8"); + + if (this.xAxisGrbl == "x") { + if (Number.isFinite(mNew.x)) { + data += " x" + (mNew.x).toFixed(2).toString(); + } + } + if (this.xAxisGrbl == "y") { + if (Number.isFinite(mNew.y)) { + data += " x" + (mNew.y * 180 / Math.PI).toFixed(2).toString(); + } + } + if (this.xAxisGrbl == "z") { + if (Number.isFinite(mNew.z) && Number.isFinite(mNew.y)) { + data += " x" + ((mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); + } + } + if (this.xAxisGrbl == "a") { + if (Number.isFinite(mNew.a)) { + data += " x" + (mNew.a * 180 / Math.PI).toFixed(2).toString(); + } + } + if (this.xAxisGrbl == "b") { + if (Number.isFinite(mNew.b) && Number.isFinite(mNew.z) && Number.isFinite(mNew.y)) { + data += " x" + (mNew.b * 180 / Math.PI + (mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); + } + } + if (this.xAxisGrbl == "c") { + if (Number.isFinite(mNew.b) && Number.isFinite(mNew.c)) { + data += " x" + ((-1) * mNew.b * 180 / Math.PI + (mNew.c * 180 / Math.PI)).toFixed(2).toString(); + } + } + if (this.xAxisGrbl == "e") { + if (Number.isFinite(mNew.b) && Number.isFinite(mNew.c) && Number.isFinite(mNew.e)) { + var handUpDown = mNew.b * 180 * factorTurnLift / Math.PI; + var handTurn = mNew.c * 180 / Math.PI; + data += " x" + ((-1.0 * (-1.0 * (handUpDown) + handTurn) + mNew.e * handOpenInMM)).toFixed(2).toString(); + } + } + + if (this.yAxisGrbl == "x") { + if (Number.isFinite(mNew.x)) { + data += " y" + (mNew.x).toFixed(2).toString(); + } + } + if (this.yAxisGrbl == "y") { + if (Number.isFinite(mNew.y)) { + data += " y" + (mNew.y * 180 / Math.PI).toFixed(2).toString(); + } + } + if (this.yAxisGrbl == "z") { + if (Number.isFinite(mNew.z) && Number.isFinite(mNew.y)) { + data += " y" + ((mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); + } + } + if (this.yAxisGrbl == "a") { + if (Number.isFinite(mNew.a)) { + data += " y" + (mNew.a * 180 / Math.PI).toFixed(2).toString(); + } + } + if (this.yAxisGrbl == "b") { + if (Number.isFinite(mNew.b) && Number.isFinite(mNew.z) && Number.isFinite(mNew.y)) { + data += " y" + (mNew.b * 180 / Math.PI + (mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); + } + } + if (this.yAxisGrbl == "c") { + if (Number.isFinite(mNew.b) && Number.isFinite(mNew.c)) { + var handUpDown = (mNew.b * 180 / Math.PI) * factorTurnLift; + var handTurn = (mNew.c * 180 / Math.PI); + data += " y" + (-1.0 * (handUpDown) + handTurn).toFixed(2).toString(); + } + } + if (this.yAxisGrbl == "e") { + if (Number.isFinite(mNew.e)) { + data += " y" + (mNew.e * 180 / Math.PI).toFixed(2).toString(); + } + } + + if (this.zAxisGrbl == "x") { + if (Number.isFinite(mNew.x)) { + data += " z" + (mNew.x).toFixed(2).toString(); + } + } + if (this.zAxisGrbl == "y") { + if (Number.isFinite(mNew.y)) { + data += " z" + (mNew.y * 180 / Math.PI).toFixed(2).toString(); + } + } + if (this.zAxisGrbl == "z") { + if (Number.isFinite(mNew.z) && Number.isFinite(mNew.y)) { + data += " z" + ((mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); + } + } + if (this.zAxisGrbl == "a") { + if (Number.isFinite(mNew.a)) { + data += " z" + (mNew.a * 180 / Math.PI).toFixed(2).toString(); + } + } + if (this.zAxisGrbl == "b") { + if (Number.isFinite(mNew.b)) { + data += " z" + (mNew.b * 180 / Math.PI).toFixed(2).toString(); + } + } + if (this.zAxisGrbl == "c") { + if (Number.isFinite(mNew.c) && Number.isFinite(mNew.b) && Number.isFinite(mNew.z) && Number.isFinite(mNew.y)) { + data += " z" + (mNew.c * 180 / Math.PI + (mNew.b * 180 / Math.PI) + (mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); + } + } + if (this.zAxisGrbl == "e") { + if (Number.isFinite(mNew.e)) { + data += " z" + (mNew.e * 180 / Math.PI).toFixed(2).toString(); + } + } + + if (this.aAxisGrbl == "x") { + if (Number.isFinite(mNew.y)) { + data += " a" + (mNew.y * 180 / Math.PI).toFixed(2).toString(); + } + } + if (this.aAxisGrbl == "y") { + if (Number.isFinite(mNew.y)) { + data += " a" + (mNew.y * 180 / Math.PI).toFixed(2).toString(); + } + } + if (this.aAxisGrbl == "z") { + if (Number.isFinite(mNew.z) && Number.isFinite(mNew.y)) { + data += " a" + ((mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); + } + } + if (this.aAxisGrbl == "a") { + if (Number.isFinite(mNew.a)) { + data += " a" + (mNew.a * 180 / Math.PI).toFixed(2).toString(); + } + } + if (this.aAxisGrbl == "b") { + if (Number.isFinite(mNew.b) && Number.isFinite(mNew.z) && Number.isFinite(mNew.y)) { + data += " a" + (mNew.b * 180 / Math.PI + (mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); + } + } + if (this.aAxisGrbl == "c") { + if (Number.isFinite(mNew.c) && Number.isFinite(mNew.b) && Number.isFinite(mNew.z) && Number.isFinite(mNew.y)) { + data += " a" + (mNew.c * 180 / Math.PI + (mNew.b * 180 / Math.PI) + (mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); + } + } + if (this.aAxisGrbl == "e") { + if (Number.isFinite(mNew.e)) { + data += " a" + (mNew.e * 180 / Math.PI).toFixed(2).toString(); + } + } + + if (this.bAxisGrbl == "x") { + if (Number.isFinite(mNew.x)) { + data += " b" + (mNew.x).toFixed(2).toString(); + } + } + if (this.bAxisGrbl == "y") { + if (Number.isFinite(mNew.y)) { + data += " b" + (mNew.y * 180 / Math.PI).toFixed(2).toString(); + } + } + if (this.bAxisGrbl == "z") { + if (Number.isFinite(mNew.z) && Number.isFinite(mNew.y)) { + data += " b" + ((mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); + } + } + if (this.bAxisGrbl == "a") { + if (Number.isFinite(mNew.a)) { + data += " b" + (mNew.a * 180 / Math.PI).toFixed(2).toString(); + } + } + if (this.bAxisGrbl == "b") { + if (Number.isFinite(mNew.b)) { + data += " b" + (mNew.b * 180 / Math.PI).toFixed(2).toString(); + } + } + if (this.bAxisGrbl == "c") { + if (Number.isFinite(mNew.c) && Number.isFinite(mNew.b) && Number.isFinite(mNew.z) && Number.isFinite(mNew.y)) { + data += " b" + (mNew.c * 180 / Math.PI + (mNew.b * 180 / Math.PI) + (mNew.z * 180 / Math.PI) - (mNew.y * 180 / Math.PI)).toFixed(2).toString(); + } + } + if (this.bAxisGrbl == "e") { + if (Number.isFinite(mNew.e)) { + data += " b" + (mNew.e * 180 / Math.PI).toFixed(2).toString(); + } + } + + data += " f" + (this.maxSpeedF.toFixed(2).toString()); + + if (data.length > 3) { + if (data.indexOf("G90") == -1) { + data = "G90 " + data; + } + console.log("Driver send to 🤖 " + this.urlGRBLstr + " the message: " + data); + this.send(data); + } + } +}; diff --git a/server/InfoServer.js b/server/InfoServer.js index 6c5bee9..2e2fe6d 100644 --- a/server/InfoServer.js +++ b/server/InfoServer.js @@ -23,12 +23,42 @@ function createInfoServer(httpsOptions, sharedState, robot, GCode, senders) { /* ---------- API ---------- */ if (req.url === '/api/status') { + const sendersStatus = senders.map(({ name, instance }) => { + const status = instance?.getStatus ? instance.getStatus() : { + state: instance?.isTestMode ? 'connected' : instance?.tSocket ? 'connected' : 'disconnected', + url: instance?.url || null, + error: instance?.error || null, + isTestMode: !!instance?.isTestMode, + reconnectAttempt: instance?.reconnectAttempt || 0, + reconnectTimer: !!instance?.reconnectTimer + }; + + const state = status.state || (instance?.tSocket ? 'connected' : 'disconnected'); + const health = state === 'connected' + ? 'ok' + : state === 'reconnecting' + ? 'warning' + : 'disconnected'; + const reason = state === 'disconnected' + ? status.error || 'no active socket connection' + : undefined; + + return { + name, + state, + url: status.url || null, + isTestMode: !!status.isTestMode, + error: status.error || null, + reconnectAttempt: status.reconnectAttempt || 0, + reconnectTimer: !!status.reconnectTimer, + health, + reason + }; + }); + const status = { clients: sharedState.connectedClients, - senders: senders.map(s => ({ - name: s.name, - status: s.instance?.tSocket ? 'connected' : 'disconnected' - })), + senders: sendersStatus, lastCommands: sharedState.lastCommands, lastPings: sharedState.lastPings }; diff --git a/startRobot.js b/startRobot.js index 6b6f560..e05b2ef 100755 --- a/startRobot.js +++ b/startRobot.js @@ -2,64 +2,138 @@ const fs = require('fs'); const https = require('https'); const Robot = require('./robot/Robot'); const GCode = require('./robot/GCode'); -const TenetSender = require('./robot/TelnetSenderGRBL'); - - -const robot = new Robot(250, 264, 100); +const TelnetSender = require('./robot/TelnetSenderGRBL'); const initInputWS = require('./server/InputWS'); const createInfoServer = require('./server/InfoServer'); -/* ---------- HTTPS Server to connect to---------- */ -const httpsOptions = { - enable: true, - key: fs.readFileSync('https/localhost.key'), - cert: fs.readFileSync('https/localhost.pem'), - passphrase: 'abcd' -}; -const httpsServer = https.createServer(httpsOptions); +function loadHttpsOptions(fsModule) { + try { + return { + enable: true, + key: fsModule.readFileSync('https/localhost.key'), + cert: fsModule.readFileSync('https/localhost.pem'), + passphrase: 'abcd' + }; + } catch (err) { + throw new Error(`Failed to load HTTPS certificate/key: ${err.message}`); + } +} -/* ---------- WebSocket Input ---------- */ -const sharedState = { - connectedClients: [], - lastCommands: [], - lastPings: [] -}; -initInputWS(httpsServer, robot, GCode, sharedState); +function getSenderConnectionStatus(senderInfo) { + const { name, instance } = senderInfo; -/* ---------- GRBL Sender take commands from robot to FluidNC or Hardware---------- */ -const baseIP = process.env.GRBL_BASE_IP ?? "fluidNcBase.local"; -const elbowIP= process.env.GRBL_ELLBOW_IP ?? "fluidNcEllbow.local"; -const handIP = process.env.GRBL_HAND_IP ?? "fluidNcHand.local"; + let status = 'disconnected'; + let reason = 'no active socket connection'; -const telnetSender1 = new TenetSender(baseIP, 2300, "x", "y", "z"); -const telnetSender2 = new TenetSender(elbowIP, 5000, "a", null, null); -const telnetSender3 = new TenetSender(handIP, 5000, "c", "e", "b"); + if (instance?.getStatus) { + const senderStatus = instance.getStatus(); + status = senderStatus.state || status; + reason = senderStatus.state === 'disconnected' ? senderStatus.error || reason : undefined; + } else if (instance?.isTestMode || instance?.tSocket) { + status = 'connected'; + reason = undefined; + } -setTimeout(() => { - [telnetSender1, telnetSender2, telnetSender3].forEach(s => { - if (s?.tSocket) robot.cmdReceivers.push(s); - }); -}, 5000); + return { name, status, reason }; +} -/* ---------- Start Input Server ---------- */ -const port = Number(process.env.PORT) || 2095; -httpsServer.listen(port); -console.log(`Input HTTPS/WebSocket on https://localhost:${port}`); +function createApp(options = {}) { + const { + fsModule = fs, + httpsModule = https, + processEnv = process.env, + RobotClass = Robot, + GCodeModule = GCode, + TelnetSenderClass = TelnetSender, + initInputWSFn = initInputWS, + createInfoServerFn = createInfoServer, + setTimeoutFn = setTimeout, + consoleObj = console + } = options; -/* ---------- Info Server ---------- */ -const infoServer = createInfoServer( - httpsOptions, - sharedState, - robot, - GCode, - [ - { name: "Base", instance: telnetSender1 }, - { name: "Elbow", instance: telnetSender2 }, - { name: "Hand", instance: telnetSender3 } - ] -); + const startupStatus = { + https: { ok: false, error: null }, + senders: [] + }; -const infoPort = 2098; -infoServer.listen(infoPort); -console.log(`Info server on https://localhost:${infoPort}`); \ No newline at end of file + let httpsOptions; + try { + httpsOptions = loadHttpsOptions(fsModule); + startupStatus.https = { ok: true }; + } catch (err) { + startupStatus.https = { ok: false, error: err.message }; + consoleObj.error(startupStatus.https.error); + return { startupStatus }; + } + + const httpsServer = httpsModule.createServer(httpsOptions); + + const robot = new RobotClass(250, 264, 100); + + const sharedState = { + connectedClients: [], + lastCommands: [], + lastPings: [] + }; + + initInputWSFn(httpsServer, robot, GCodeModule, sharedState); + + const baseIP = processEnv.GRBL_BASE_IP ?? 'fluidNcBase.local'; + const elbowIP = processEnv.GRBL_ELLBOW_IP ?? 'fluidNcEllbow.local'; + const handIP = processEnv.GRBL_HAND_IP ?? 'fluidNcHand.local'; + + const telnetSender1 = new TelnetSenderClass(baseIP, 2300, 'x', 'y', 'z'); + const telnetSender2 = new TelnetSenderClass(elbowIP, 5000, 'a', null, null); + const telnetSender3 = new TelnetSenderClass(handIP, 5000, 'c', 'e', 'b'); + + const senders = [ + { name: 'Base', instance: telnetSender1 }, + { name: 'Elbow', instance: telnetSender2 }, + { name: 'Hand', instance: telnetSender3 } + ]; + + startupStatus.senders = senders.map(getSenderConnectionStatus); + const disconnectedSenders = startupStatus.senders.filter(s => s.status === 'disconnected'); + if (disconnectedSenders.length > 0) { + consoleObj.warn(`Startup warning: ${disconnectedSenders.length} sender(s) disconnected at startup.`); + } + + setTimeoutFn(() => { + senders.forEach(s => { + if (s.instance?.tSocket) robot.cmdReceivers.push(s.instance); + }); + }, 5000); + + const port = Number(processEnv.PORT) || 2095; + httpsServer.listen(port); + consoleObj.log(`Input HTTPS/WebSocket on https://localhost:${port}`); + + const infoServer = createInfoServerFn( + httpsOptions, + sharedState, + robot, + GCodeModule, + senders + ); + + const infoPort = 2098; + infoServer.listen(infoPort); + consoleObj.log(`Info server on https://localhost:${infoPort}`); + + return { + httpsServer, + infoServer, + robot, + senders: senders.map(s => s.instance), + sharedState, + httpsOptions, + startupStatus + }; +} + +if (require.main === module) { + createApp(); +} + +module.exports = { createApp }; diff --git a/test/InfoServer.test.js b/test/InfoServer.test.js index f2f7766..9177bdc 100644 --- a/test/InfoServer.test.js +++ b/test/InfoServer.test.js @@ -79,8 +79,73 @@ describe('InfoServer', () => { expect(status.lastCommands).toEqual(['G1 X10 Y10']); expect(status.lastPings).toEqual(['Ping']); expect(status.senders).toEqual([ - { name: 'Base', status: 'connected' }, - { name: 'Hand', status: 'disconnected' } + { + name: 'Base', + state: 'connected', + url: null, + isTestMode: false, + error: null, + reconnectAttempt: 0, + reconnectTimer: false, + health: 'ok', + reason: undefined + }, + { + name: 'Hand', + state: 'disconnected', + url: null, + isTestMode: false, + error: null, + reconnectAttempt: 0, + reconnectTimer: false, + health: 'disconnected', + reason: 'no active socket connection' + } + ]); + }); + + test('returns sender health details from instance.getStatus()', async () => { + const key = fs.readFileSync('https/localhost.key'); + const cert = fs.readFileSync('https/localhost.pem'); + const httpsOptions = { key, cert, passphrase: 'abcd' }; + + const sharedState = { connectedClients: [], lastCommands: [], lastPings: [] }; + const robot = { x: 0, y: 0, z: 0, phi: 0, theta: 0, psi: 0 }; + const senders = [ + { + name: 'Reconnect', + instance: { + getStatus: () => ({ + state: 'reconnecting', + url: 'reconnect.test', + error: 'timeout', + isTestMode: false, + reconnectAttempt: 2, + reconnectTimer: true + }) + } + } + ]; + + server = createInfoServer(httpsOptions, sharedState, robot, GCode, senders); + port = await listen(server); + + const { statusCode, body } = await request(`https://127.0.0.1:${port}/api/status`); + expect(statusCode).toBe(200); + + const status = JSON.parse(body); + expect(status.senders).toEqual([ + { + name: 'Reconnect', + state: 'reconnecting', + url: 'reconnect.test', + isTestMode: false, + error: 'timeout', + reconnectAttempt: 2, + reconnectTimer: true, + health: 'warning', + reason: undefined + } ]); }); diff --git a/test/InputWS.test.js b/test/InputWS.test.js index 76a9c65..81786f3 100644 --- a/test/InputWS.test.js +++ b/test/InputWS.test.js @@ -93,4 +93,24 @@ describe('InputWS', () => { client.close(); }); + + test('receives GCode text and broadcasts updated position', async () => { + server = http.createServer(); + const sharedState = { connectedClients: [], lastCommands: [], lastPings: [] }; + const robot = createDummyRobot(); + + wss = initInputWS(server, robot, GCode, sharedState); + port = await listen(server); + const client = await connectWebSocket(port); + + const messagePromise = waitForMessage(client); + client.send('G1 X1 Y2 Z3'); + + const message = await messagePromise; + const parsed = JSON.parse(message); + expect(parsed.position).toEqual({ x: 1, y: 2, z: 3, a: 0, b: 0, c: 0 }); + expect(robot.sendCommand).toHaveBeenCalled(); + + client.close(); + }); }); diff --git a/test/Sender.WS.test.js b/test/Sender.WS.test.js index ee6b4fe..915fa61 100755 --- a/test/Sender.WS.test.js +++ b/test/Sender.WS.test.js @@ -1,76 +1,72 @@ -var Sender = require('../robot/WSSenderGrbl.js') - - -describe("WS-SenderGRBL.execCommand", () => { - - test("writes correct G-code to mocked WS tSocket", () => { - - // Create instance (will try real connection, but we override tSocket immediately) - const sender = new Sender(urlGRBL = "test.test", maxSpeedF = 2300, xAxisGrbl = "x", yAxisGrbl = "y", zAxisGrbl = "z"); - - // Mock tSocket.write - sender.tSocket = { - written: "", - write: function(txt) { - this.written = txt; // store what was written - } - }; - - // Provide some sample motion data - const mOld = { x: 0, y: 0, z: 0, a:0, b:0, c:0, e:0 }; // not used in your code - const mNew = { x: 12.34, y: 1.0, z: 2.0, a:0, b:0, c:0, e:0 }; - - sender.execCommand("G1", mOld, mNew); - - // ✅ verify output - expect(sender.tSocket.written).toBe("G90 G1 x12.34 y57.30 z57.30 f2300.00\r\n"); - }); - - - test("writes correct G-code to mocked tSocket Ellbow", () => { - - // Create instance (will try real connection, but we override tSocket immediately) - const sender = new Sender(urlGRBL = "test.test", maxSpeedF = 2300, xAxisGrbl = "a", yAxisGrbl = null, zAxisGrbl = null ); - - // Mock tSocket.write - sender.tSocket = { - written: "", - write: function(txt) { - this.written = txt; // store what was written - } - }; - - // Provide some sample motion data - const mOld = { x: 0, y: 0, z: 0, a:Math.PI, b:0, c:0, e:0 }; // not used in your code - const mNew = { x: 12.34, y: Math.PI/2, z: 0, a:Math.PI/8, b:0, c:0, e:0 }; - - sender.execCommand("G1", mOld, mNew); - - // ✅ verify output - expect(sender.tSocket.written).toBe("G90 G1 x22.50 f2300.00\r\n"); - }); - - test("writes correct G-code G92 to mocked WS tSocket", () => { - - // Create instance (will try real connection, but we override tSocket immediately) - const sender = new Sender(urlGRBL = "test.test", maxSpeedF = 2300, xAxisGrbl = "x", yAxisGrbl = "y", zAxisGrbl = "z"); - - // Mock tSocket.write - sender.tSocket = { - written: "", - write: function(txt) { - this.written = txt; // store what was written - } - }; - - // Provide some sample motion data - const mOld = { x: 0, y: 0, z: 0, a:0, b:0, c:0, e:0 }; // not used in your code - const mNew = { x: 12.34, y: 1.0, z: 2.0, a:0, b:0, c:0, e:0 }; - - sender.execCommand("G92", mOld, mNew); - - // ✅ verify output - expect(sender.tSocket.written.replace("G90 ","")).toBe("G92 x12.34 y57.30 z57.30 f2300.00\r\n"); - }); - -}); \ No newline at end of file +const WSSenderGrbl = require('../robot/WSSenderGrbl.js'); + +describe("WSSenderGrbl implements SenderInterface", () => { + + test("is an instance of SenderInterface and exposes required methods", () => { + const SenderInterface = require('../robot/SenderInterface'); + const sender = new WSSenderGrbl("test.test", 2300, "x", "y", "z"); + + expect(sender).toBeInstanceOf(SenderInterface); + expect(typeof sender.connect).toBe('function'); + expect(typeof sender.send).toBe('function'); + expect(typeof sender.getStatus).toBe('function'); + expect(typeof sender.disconnect).toBe('function'); + }); + + test("test mode has connected status", () => { + const sender = new WSSenderGrbl("test.test", 2300, "x", "y", "z"); + expect(sender.getStatus()).toMatchObject({ state: 'connected', isTestMode: true }); + }); + + test("send() returns false when not connected, true in test mode", () => { + const sender = new WSSenderGrbl("test.test", 2300, "x", "y", "z"); + expect(sender.send("G1 x1")).toBe(true); + + sender.ws = null; + expect(sender.send("G1 x1")).toBe(false); + }); + + test("disconnect() transitions to disconnected state", () => { + const sender = new WSSenderGrbl("test.test", 2300, "x", "y", "z"); + sender.disconnect(); + expect(sender.getStatus()).toMatchObject({ state: 'disconnected' }); + }); + +}); + +describe("WSSenderGrbl.execCommand writes correct G-code via send()", () => { + + test("writes correct G-code for xyz axes", () => { + const sender = new WSSenderGrbl("test.test", 2300, "x", "y", "z"); + + const mOld = { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, e: 0 }; + const mNew = { x: 12.34, y: 1.0, z: 2.0, a: 0, b: 0, c: 0, e: 0 }; + + sender.execCommand("G1", mOld, mNew); + + expect(sender.ws.written).toBe("G90 G1 x12.34 y57.30 z57.30 f2300.00\n"); + }); + + test("writes correct G-code for elbow (a axis)", () => { + const sender = new WSSenderGrbl("test.test", 2300, "a", null, null); + + const mOld = { x: 0, y: 0, z: 0, a: Math.PI, b: 0, c: 0, e: 0 }; + const mNew = { x: 12.34, y: Math.PI / 2, z: 0, a: Math.PI / 8, b: 0, c: 0, e: 0 }; + + sender.execCommand("G1", mOld, mNew); + + expect(sender.ws.written).toBe("G90 G1 x22.50 f2300.00\n"); + }); + + test("G92 command is sent without extra G90 prefix", () => { + const sender = new WSSenderGrbl("test.test", 2300, "x", "y", "z"); + + const mOld = { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, e: 0 }; + const mNew = { x: 12.34, y: 1.0, z: 2.0, a: 0, b: 0, c: 0, e: 0 }; + + sender.execCommand("G92", mOld, mNew); + + expect(sender.ws.written.replace("G90 ", "")).toBe("G92 x12.34 y57.30 z57.30 f2300.00\n"); + }); + +}); diff --git a/test/SenderInterface.test.js b/test/SenderInterface.test.js new file mode 100644 index 0000000..810f2a4 --- /dev/null +++ b/test/SenderInterface.test.js @@ -0,0 +1,103 @@ +const EventEmitter = require('events'); +const SenderInterface = require('../robot/SenderInterface'); +const TelnetSender = require('../robot/TelnetSenderGRBL'); + +describe('Sender Interface and TelnetSenderGRBL implementation', () => { + test('TelnetSenderGRBL implements the required sender interface methods', () => { + const sender = new TelnetSender('test.test', 2300, 'x', 'y', 'z'); + + expect(sender).toBeInstanceOf(SenderInterface); + expect(typeof sender.connect).toBe('function'); + expect(typeof sender.send).toBe('function'); + expect(typeof sender.getStatus).toBe('function'); + expect(typeof sender.disconnect).toBe('function'); + }); + + test('test mode sender rejects invalid send payloads and supports idempotent disconnect', () => { + const sender = new TelnetSender('test.test', 2300, 'x', 'y', 'z'); + + expect(sender.getStatus()).toMatchObject({ state: 'connected' }); + + sender.tSocket = null; + expect(sender.send('HELLO')).toBe(false); + expect(sender.send('')).toBe(false); + + sender.disconnect(); + expect(sender.getStatus()).toMatchObject({ state: 'disconnected' }); + + sender.disconnect(); + expect(sender.getStatus()).toMatchObject({ state: 'disconnected' }); + }); + + test('sender reconnects on connection failure and eventually connects', async () => { + let connectAttempts = 0; + const netMock = { + createConnection: () => { + const socket = new EventEmitter(); + socket.end = jest.fn(); + + process.nextTick(() => { + connectAttempts += 1; + if (connectAttempts === 1) { + socket.emit('error', new Error('connection failed')); + } else { + socket.emit('connect'); + } + }); + + return socket; + } + }; + + class DummyTelnetSocket { + constructor(connection) { + this.connection = connection; + } + + on(event, listener) { + this.connection.on(event, listener); + return this; + } + + write(txt) { + this.connection.written = txt; + } + } + + const sender = new TelnetSender('localhost', 2300, 'x', 'y', 'z', null, null, null, null, { + netModule: netMock, + TelnetSocketClass: DummyTelnetSocket, + setTimeoutFn: (fn) => fn(), + autoConnect: false, + reconnectDelay: 1, + maxReconnectDelay: 2 + }); + + const result = await sender.connect(); + expect(connectAttempts).toBe(2); + expect(result.getStatus()).toMatchObject({ + state: 'connected', + url: 'localhost' + }); + expect(result.getStatus().reconnectTimer).toBe(false); + }); + + test('test mode sender has connected status and can send/disconnect', async () => { + const sender = new TelnetSender('test.test', 2300, 'x', 'y', 'z'); + + expect(sender.getStatus()).toMatchObject({ + state: 'connected', + url: 'test.test', + isTestMode: true + }); + + expect(sender.send('HELLO')).toBe(true); + expect(sender.tSocket.written).toBe('HELLO\r\n'); + + sender.disconnect(); + expect(sender.getStatus()).toMatchObject({ state: 'disconnected' }); + + await expect(sender.connect()).resolves.toBe(sender); + expect(sender.getStatus()).toMatchObject({ state: 'connected' }); + }); +}); diff --git a/test/StartRobot.test.js b/test/StartRobot.test.js new file mode 100644 index 0000000..5887a28 --- /dev/null +++ b/test/StartRobot.test.js @@ -0,0 +1,114 @@ +const { createApp } = require('../startRobot'); + +describe('startRobot orchestrator', () => { + test('creates HTTPS and info servers and binds modules', () => { + const readFileSync = jest.fn() + .mockReturnValueOnce('fake-key') + .mockReturnValueOnce('fake-cert'); + + const httpsServerMock = { + listen: jest.fn() + }; + + const httpsModuleMock = { + createServer: jest.fn(() => httpsServerMock) + }; + + const initInputWS = jest.fn(); + const infoServerMock = { + listen: jest.fn() + }; + const createInfoServer = jest.fn(() => infoServerMock); + + const TelnetSenderClass = jest.fn(() => ({ tSocket: null })); + + const robotInstances = []; + class RobotClass { + constructor() { + this.cmdReceivers = []; + robotInstances.push(this); + } + } + + const result = createApp({ + fsModule: { readFileSync }, + httpsModule: httpsModuleMock, + processEnv: {}, + RobotClass, + GCodeModule: { dummy: true }, + TelnetSenderClass, + initInputWSFn: initInputWS, + createInfoServerFn: createInfoServer, + setTimeoutFn: (fn) => fn(), + consoleObj: { log: jest.fn(), warn: jest.fn(), error: jest.fn() } + }); + + expect(readFileSync).toHaveBeenCalledTimes(2); + expect(httpsModuleMock.createServer).toHaveBeenCalledWith({ + enable: true, + key: 'fake-key', + cert: 'fake-cert', + passphrase: 'abcd' + }); + + expect(initInputWS).toHaveBeenCalledWith(httpsServerMock, expect.any(RobotClass), { dummy: true }, expect.any(Object)); + expect(createInfoServer).toHaveBeenCalledWith( + expect.objectContaining({ key: 'fake-key', cert: 'fake-cert', passphrase: 'abcd' }), + expect.any(Object), + expect.any(RobotClass), + { dummy: true }, + expect.arrayContaining([ + expect.objectContaining({ name: 'Base', instance: expect.any(Object) }), + expect.objectContaining({ name: 'Elbow', instance: expect.any(Object) }), + expect.objectContaining({ name: 'Hand', instance: expect.any(Object) }) + ]) + ); + + expect(httpsServerMock.listen).toHaveBeenCalledWith(2095); + expect(infoServerMock.listen).toHaveBeenCalledWith(2098); + + expect(result).toHaveProperty('httpsServer', httpsServerMock); + expect(result).toHaveProperty('infoServer', infoServerMock); + expect(result).toHaveProperty('senders'); + expect(result.senders).toHaveLength(3); + expect(result.startupStatus).toEqual({ + https: { ok: true }, + senders: [ + { name: 'Base', status: 'disconnected', reason: 'no active socket connection' }, + { name: 'Elbow', status: 'disconnected', reason: 'no active socket connection' }, + { name: 'Hand', status: 'disconnected', reason: 'no active socket connection' } + ] + }); + expect(result.sharedState.connectedClients).toEqual([]); + }); + + test('reports missing HTTPS certificates on startup', () => { + const readFileSync = jest.fn().mockImplementation(() => { + throw new Error('ENOENT: no such file or directory'); + }); + + const httpsModuleMock = { + createServer: jest.fn() + }; + + const result = createApp({ + fsModule: { readFileSync }, + httpsModule: httpsModuleMock, + processEnv: {}, + RobotClass: class {}, + GCodeModule: { dummy: true }, + TelnetSenderClass: jest.fn(), + initInputWSFn: jest.fn(), + createInfoServerFn: jest.fn(), + setTimeoutFn: jest.fn(), + consoleObj: { log: jest.fn(), error: jest.fn(), warn: jest.fn() } + }); + + expect(result.startupStatus.https.ok).toBe(false); + expect(result.startupStatus.https.error).toMatch(/Failed to load HTTPS certificate\/key/); + expect(httpsModuleMock.createServer).not.toHaveBeenCalled(); + expect(result.httpsServer).toBeUndefined(); + expect(result.infoServer).toBeUndefined(); + expect(result.senders).toBeUndefined(); + }); +});