Punkt 2 implementieren GitHub CoPilot

This commit is contained in:
chk
2026-06-08 17:28:43 +02:00
parent 172606c7a3
commit 0109066946
14 changed files with 1239 additions and 518 deletions

View File

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

View File

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

View File

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

View File

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

19
robot/SenderInterface.js Normal file
View File

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

View File

@@ -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;
@@ -27,37 +32,203 @@ module.exports = class TelnetSenderGRBL{
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){

View File

@@ -1,22 +1,21 @@
const net = require("net");
const { resolve } = require("path");
const FluidNCClient = require("./fluidnc/FluidNCClient");
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();
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.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;
@@ -28,320 +27,372 @@ module.exports = class TelnetSenderGRBL{
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.tSocket = { written: "", write(txt){ this.written = txt; } };
this.isTestMode = true;
this.state = 'connected';
this.shouldReconnect = false;
this.ws = { readyState: 1, written: "", send(txt) { this.written = txt; }, close() {} };
return;
}
var fluidConfig = { host: urlGRBL, port: 80, reconnectDelay: 30000}
// Create FluidNC WebSocket client
const fluid = new FluidNCClient(fluidConfig);
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);
}
moveTo(mOld, mNew){
this.execCommand("G1", mOld, mNew)
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;
}
execCommand(strCommand = "G1", mOld, mNew){
_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");
// 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)){
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)){
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 == "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)){
// This is the case for the Ellbow, when the Motor is connected to the X-Port of the FluidNC
if (this.xAxisGrbl == "a") {
if (Number.isFinite(mNew.a)) {
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 == "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)){
// 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 == "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)){
//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.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();
} else {
console.log("Non-finite value for mNew.x in execCommand");
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)){
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 == "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)){
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 == "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)){
// 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 == "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)){
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)){
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)){
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 == "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)){
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 == "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)){
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)){
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)){
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)){
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 == "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)){
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 == "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)){
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
if (this.aAxisGrbl == "e") {
if (Number.isFinite(mNew.e)) {
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)){
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)){
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 == "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)){
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)){
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)){
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)){
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())
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){
if (data.length > 3) {
if (data.indexOf("G90") == -1) {
data = "G90 " + data;
}
console.log("Driver send to 🤖 " + this.urlGRBLstr + " the message: " + data)
this.tSocket.write( data + "\r\n");
console.log("Driver send to 🤖 " + this.urlGRBLstr + " the message: " + data);
this.send(data);
}
}
}
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,76 +1,72 @@
var Sender = require('../robot/WSSenderGrbl.js')
const WSSenderGrbl = require('../robot/WSSenderGrbl.js');
describe("WSSenderGrbl implements SenderInterface", () => {
describe("WS-SenderGRBL.execCommand", () => {
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");
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");
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("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("test mode has connected status", () => {
const sender = new WSSenderGrbl("test.test", 2300, "x", "y", "z");
expect(sender.getStatus()).toMatchObject({ state: 'connected', isTestMode: true });
});
test("writes correct G-code G92 to mocked WS tSocket", () => {
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);
// 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");
sender.ws = null;
expect(sender.send("G1 x1")).toBe(false);
});
// 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");
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");
});
});

View File

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

114
test/StartRobot.test.js Normal file
View File

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