diff --git a/doc/API.md b/doc/API.md new file mode 100644 index 0000000..8f4e598 --- /dev/null +++ b/doc/API.md @@ -0,0 +1,111 @@ +# API-Referenz + +Der Driver stellt zwei Schnittstellen bereit: + +| Schnittstelle | Zweck | Port (Container) | Port (Host, docker-compose) | +|---------------|-------|------------------|------------------------------| +| **Input WebSocket** | Steuerbefehle empfangen, Status-Antworten senden | `2095` (`PORT`) | `2096` | +| **Info-/Status-Server (HTTPS)** | Statusübersicht + statische Web-UI | `2098` | `2098` | + +--- + +## 1. Input WebSocket (`wss://…:2095`) + +Implementiert in `server/InputWS.js`. Eingehende Nachrichten sind **Plain-Text-Strings**. +Die Antwortlogik unterscheidet bewusst zwischen **gezielten Antworten** (nur an den +Anfrager) und **Broadcasts** (an alle verbundenen Clients). + +### Antwort-Routing + +| Eingabe | Verarbeitung | Antwort | Empfänger | +|---------|--------------|---------|-----------| +| `Ping` | Heartbeat, wird geloggt | `Ping` | **nur Anfrager** (gezielt) | +| `M114` | Statusabfrage | Positions-JSON (siehe unten) | **nur Anfrager** (gezielt) | +| G-Code (`G1`, `G90`, `G91`, `G28`, `M1`, `M92`, …) | Bewegung/Zustandsänderung | aktuelles Positions-JSON | **alle Clients** (Broadcast) | +| Datei-Befehle (`FShow`, `FList`, `FPoint`, `FPlus`, `FMinus`, `FLoad`, `FSave`, `FClear`, `M20/23/28/29`) | Datei-/Log-Verwaltung | Befehlsergebnis | **alle Clients** (Broadcast) | +| alles andere | – | Fehler-Envelope | **nur Anfrager** (gezielt) | + +**Begründung der Trennung:** Eine Bewegung ändert die Roboterposition — das ist ein +Status-Update, das jeder Client (z. B. die Simulation) sehen soll → Broadcast. Eine +reine Abfrage (`Ping`, `M114`) ist eine direkte Antwort an den Anfrager → gezielt. + +> **Hinweis:** Feinere Zielsteuerung der Datei-Befehle (z. B. `FShow` als +> Anfrager-only-Antwort) sowie `FFirst`/`FLast` gehören zur Datei-Verwaltung in +> **ToDo 4** und bleiben hier bewusst unverändert. + +### Positions-JSON (`M114` / Broadcast nach Bewegung) + +```json +{ + "position": { "x": 0, "y": 30, "z": 0, "a": 0, "b": 0, "c": 0 }, + "motorCounts":{ "x": 0, "y": 0, "z": 0, "a": 0, "b": 0, "c": 0, "e": 0 } +} +``` + +- `position` — kartesische Pose + Orientierung (`a`/`b`/`c` = ϕ/ϴ/Ψ in rad). +- `motorCounts` — aktuelle Motor-/Achswerte. + +### Fehler-Envelope (maschinenlesbar) + +Bei unbekannter Eingabe oder Verarbeitungsfehler erhält **nur der Anfrager**: + +```json +{ "type": "error", "code": "UNKNOWN_COMMAND", "message": "Unrecognized input", "input": "" } +``` + +| `code` | Bedeutung | +|--------|-----------| +| `UNKNOWN_COMMAND` | Eingabe passt auf keinen bekannten Befehl | +| `GCODE_ERROR` | Fehler beim Parsen/Ausführen eines G-Code-Befehls | +| `FILE_ERROR` | Fehler bei einem Datei-Befehl | + +Erfolgs-Antworten (`Ping`, Positions-JSON) bleiben aus Kompatibilitätsgründen im +bisherigen Rohformat; das Envelope gilt nur für Fehler. + +--- + +## 2. Info-/Status-Server (`https://…:2098`) + +Implementiert in `server/InfoServer.js`. Selbstsigniertes Zertifikat. + +### Statische Web-UI +`GET /` · `GET /app.js` · `GET /style.css` · `GET /allApps.css` + +### `GET /api/status` + +Liefert Verbindungs- und Health-Informationen als JSON: + +```json +{ + "generatedAt": "2026-06-08T12:00:00.000Z", + "health": { "ok": false, "connectedSenders": 1, "totalSenders": 3 }, + "clients": ["127.0.0.1"], + "senders": [ + { + "name": "Base", + "state": "connected", + "url": "fluidNcBase.local", + "isTestMode": false, + "error": null, + "reconnectAttempt": 0, + "reconnectTimer": false, + "health": "ok", + "reason": null + } + ], + "lastCommands": ["2026-06-08T…: G1 X10 Y10"], + "lastPings": ["2026-06-08T… 127.0.0.1 : Ping"] +} +``` + +- `health.ok` — `true`, wenn **alle** Sender verbunden sind. +- pro Sender: `state` ∈ `connected | connecting | reconnecting | disconnected`; + `health` ∈ `ok | warning | disconnected`. + +### `GET /api/position` + +Liefert die aktuelle Roboterposition (gleiches Positions-JSON wie oben), +**unabhängig** von laufenden Senderverbindungen. + +### Sonstige Pfade +`404 Not found`. diff --git a/doc/ToDo_5_API.md b/doc/ToDo_5_API.md index 27d1a43..fb2a120 100644 --- a/doc/ToDo_5_API.md +++ b/doc/ToDo_5_API.md @@ -6,16 +6,31 @@ Die Schnittstellen sollen klar und vorhersagbar antworten. Steuerbefehle brauche ## Aufgaben -- [ ] WebSocket-Antwortlogik strukturieren +- [x] WebSocket-Antwortlogik strukturieren - Steuerbefehle erhalten gezielte Responses - `Ping`, `M114`, Statusabfragen und Fehlermeldungen getrennt behandeln -- [ ] Broadcasts nur dort verwenden, wo sie sinnvoll sind + - → `server/InputWS.js` als klarer Router; `Ping`/`M114` antworten gezielt an den Anfrager +- [x] Broadcasts nur dort verwenden, wo sie sinnvoll sind - Broadcasts für Status-Updates, nicht für direkte Steuerantworten -- [ ] `InfoServer` um detaillierte Statusinformationen erweitern - - Senderverbindungen - - Health-Checks - - letzte Befehle / Pings -- [ ] API-Endpunkte klar dokumentieren - - `/api/status` - - `/api/position` -- [ ] Fehlermeldungen konsistent und maschinenlesbar machen \ No newline at end of file + - → Bewegung (G-Code) broadcastet die neue Position; `Ping`/`M114` sind gezielt +- [x] `InfoServer` um detaillierte Statusinformationen erweitern + - Senderverbindungen · Health-Checks · letzte Befehle / Pings + - → bereits in ToDo 2 angelegt; ergänzt um Top-Level `health`-Summary + `generatedAt` +- [x] API-Endpunkte klar dokumentieren + - `/api/status` · `/api/position` + - → `doc/API.md` +- [x] Fehlermeldungen konsistent und maschinenlesbar machen + - → einheitliches Fehler-Envelope `{ type, code, message, input }` + (`UNKNOWN_COMMAND` / `GCODE_ERROR` / `FILE_ERROR`) + +## Tests + +- `test/InputWS.api.test.js` — gezielte vs. Broadcast-Antworten, Fehler-Envelope +- `test/InfoServer.test.js` — Health-Summary + `generatedAt` + +## Bewusst nicht in diesem ToDo + +- Feinere Zielsteuerung der Datei-Befehle (z. B. `FShow` nur an Anfrager) und + `FFirst`/`FLast` → gehören zur Datei-Verwaltung in **ToDo 4**. +- Erfolgs-Payloads (`Ping`, Positions-JSON) bleiben aus Rückwärtskompatibilität im + Rohformat (externe Clients: Simulation/Gamepad); nur Fehler nutzen das Envelope. \ No newline at end of file diff --git a/doc/ToDo_8_Bugs.md b/doc/ToDo_8_Bugs.md index 910cb32..c9887f9 100644 --- a/doc/ToDo_8_Bugs.md +++ b/doc/ToDo_8_Bugs.md @@ -6,7 +6,10 @@ Konkrete, im Code identifizierte Fehler beheben — unabhängig von den Architek --- -## Bug 1: `TelnetSenderGRBL` — `close`-Event verliert `this`-Kontext +## Bug 1: `TelnetSenderGRBL` — `close`-Event verliert `this`-Kontext ✅ ERLEDIGT + +> Behoben im ToDo-2-Refactoring: Der `close`-Handler nutzt jetzt eine Arrow-Function +> (`robot/TelnetSenderGRBL.js`), `this` zeigt korrekt auf die Sender-Instanz. **Datei:** `robot/TelnetSenderGRBL.js`, Zeile 54–57 @@ -48,7 +51,11 @@ this.tSocket.on("close", () => { --- -## Bug 4: `logs/`-Verzeichnis wird nicht sichergestellt +## Bug 4: `logs/`-Verzeichnis wird nicht sichergestellt ✅ ERLEDIGT + +> Behoben: `initInputWS()` ruft `ensureLogDir()` (`fs.mkdirSync('./logs', { recursive: true })`) +> beim Start auf. `ensureLogDir` ist exportiert und idempotent. +> Test: `test/InputWS.logDir.test.js`. **Datei:** `server/InputWS.js`, Zeilen 66–67 und 77–78 @@ -70,7 +77,11 @@ Der Check prüft `mNew.y` statt `mNew.x`. Wenn `mNew.x` `NaN` oder `Infinity` w --- -## Bug 6: `containsMCode` matcht zu breit +## Bug 6: `containsMCode` matcht zu breit ✅ ERLEDIGT + +> Behoben: `containsMCode` nutzt jetzt `s === 'M1' || s.startsWith('M1 ')`. +> Test: `test/GCode.containsMCode.test.js`. +> (Hinweis bleibt: Methode wird im Produktivcode noch nicht aufgerufen.) **Datei:** `robot/GCode.js`, Zeile 12 diff --git a/logs/gcode_commands.log b/logs/gcode_commands.log index 5fc8472..43dea3c 100644 --- a/logs/gcode_commands.log +++ b/logs/gcode_commands.log @@ -10233,3 +10233,39 @@ 2026-06-08T16:16:38.051Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 2026-06-08T16:17:00.361Z ::ffff:127.0.0.1: M114 2026-06-08T16:17:00.370Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T16:44:54.055Z ::ffff:127.0.0.1: M114 +2026-06-08T16:44:54.064Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T16:45:02.185Z ::ffff:127.0.0.1: M114 +2026-06-08T16:45:02.193Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T16:45:21.621Z ::ffff:127.0.0.1: M114 +2026-06-08T16:45:21.633Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T16:45:29.483Z ::ffff:127.0.0.1: M114 +2026-06-08T16:45:29.492Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T16:45:36.146Z ::ffff:127.0.0.1: M114 +2026-06-08T16:45:36.158Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T16:45:45.612Z ::ffff:127.0.0.1: M114 +2026-06-08T16:45:45.618Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T16:45:54.326Z ::ffff:127.0.0.1: M114 +2026-06-08T16:45:54.336Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T17:01:06.147Z ::ffff:127.0.0.1: M114 +2026-06-08T17:01:13.169Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T17:01:32.479Z ::ffff:127.0.0.1: M114 +2026-06-08T17:01:32.487Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T17:01:32.700Z ::ffff:127.0.0.1: M114 +2026-06-08T17:01:32.930Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T17:01:33.177Z ::ffff:127.0.0.1: G1 X1 +2026-06-08T17:01:39.364Z ::ffff:127.0.0.1: M114 +2026-06-08T17:01:39.374Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T17:01:39.587Z ::ffff:127.0.0.1: M114 +2026-06-08T17:01:39.807Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T17:01:40.037Z ::ffff:127.0.0.1: G1 X1 +2026-06-08T17:01:45.119Z ::ffff:127.0.0.1: M114 +2026-06-08T17:01:45.126Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T17:01:45.344Z ::ffff:127.0.0.1: M114 +2026-06-08T17:01:45.562Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T17:01:45.788Z ::ffff:127.0.0.1: G1 X1 +2026-06-08T17:01:57.245Z ::ffff:127.0.0.1: M114 +2026-06-08T17:01:57.256Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T17:01:57.362Z ::ffff:127.0.0.1: M114 +2026-06-08T17:01:57.582Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 +2026-06-08T17:01:57.813Z ::ffff:127.0.0.1: G1 X1 diff --git a/logs/pings.log b/logs/pings.log index 68a16ef..0f128a1 100644 --- a/logs/pings.log +++ b/logs/pings.log @@ -14566,3 +14566,19 @@ 2026-06-08T16:15:31.061Z ::ffff:127.0.0.1 : Ping 2026-06-08T16:16:38.024Z ::ffff:127.0.0.1 : Ping 2026-06-08T16:17:00.351Z ::ffff:127.0.0.1 : Ping +2026-06-08T16:44:54.037Z ::ffff:127.0.0.1 : Ping +2026-06-08T16:45:02.161Z ::ffff:127.0.0.1 : Ping +2026-06-08T16:45:21.597Z ::ffff:127.0.0.1 : Ping +2026-06-08T16:45:29.461Z ::ffff:127.0.0.1 : Ping +2026-06-08T16:45:36.122Z ::ffff:127.0.0.1 : Ping +2026-06-08T16:45:45.603Z ::ffff:127.0.0.1 : Ping +2026-06-08T16:45:54.315Z ::ffff:127.0.0.1 : Ping +2026-06-08T17:00:59.109Z ::ffff:127.0.0.1 : Ping +2026-06-08T17:01:32.454Z ::ffff:127.0.0.1 : Ping +2026-06-08T17:01:32.471Z ::ffff:127.0.0.1 : Ping +2026-06-08T17:01:39.337Z ::ffff:127.0.0.1 : Ping +2026-06-08T17:01:39.356Z ::ffff:127.0.0.1 : Ping +2026-06-08T17:01:45.106Z ::ffff:127.0.0.1 : Ping +2026-06-08T17:01:45.128Z ::ffff:127.0.0.1 : Ping +2026-06-08T17:01:57.161Z ::ffff:127.0.0.1 : Ping +2026-06-08T17:01:57.231Z ::ffff:127.0.0.1 : Ping diff --git a/robot/GCode.js b/robot/GCode.js index bb05cab..c6e9559 100755 --- a/robot/GCode.js +++ b/robot/GCode.js @@ -10,7 +10,7 @@ class GCode{ static fileName = "GCodeFiles/log.gcode"; static containsMCode(s){ - return s.indexOf('M1') == 0 + return s === 'M1' || s.startsWith('M1 '); } static receiveMCode(robot, m){ diff --git a/server/InfoServer.js b/server/InfoServer.js index 2e2fe6d..0009db2 100644 --- a/server/InfoServer.js +++ b/server/InfoServer.js @@ -56,7 +56,16 @@ function createInfoServer(httpsOptions, sharedState, robot, GCode, senders) { }; }); + const connectedSenders = sendersStatus.filter(s => s.health === 'ok').length; + const health = { + ok: sendersStatus.length > 0 && sendersStatus.every(s => s.health === 'ok'), + connectedSenders, + totalSenders: sendersStatus.length + }; + const status = { + generatedAt: new Date().toISOString(), + health, clients: sharedState.connectedClients, senders: sendersStatus, lastCommands: sharedState.lastCommands, diff --git a/server/InputWS.js b/server/InputWS.js index 4ffd4d6..bdb91e6 100644 --- a/server/InputWS.js +++ b/server/InputWS.js @@ -2,7 +2,19 @@ const fs = require('fs'); const WebSocket = require('ws'); +const LOG_DIR = './logs'; + +/** + * Ensures the log directory exists so the first appendFileSync() call cannot + * crash on a fresh container/checkout. Idempotent thanks to { recursive: true }. + */ +function ensureLogDir(dir = LOG_DIR) { + fs.mkdirSync(dir, { recursive: true }); +} + function initInputWS(server, robot, GCode, sharedState) { + ensureLogDir(); + const wss = new WebSocket.Server({ server }); wss.on('connection', (ws) => { @@ -19,34 +31,53 @@ function initInputWS(server, robot, GCode, sharedState) { ws.on('message', (msg) => { const message = msg.toString(); - /* ---------- Ping ---------- */ + /* ---------- Ping (heartbeat) → targeted reply to requester ---------- */ if (message === "Ping") { logPing(sharedState, clientIP); - broadcast(wss, message); + reply(ws, "Ping"); return; } - /* ---------- GCode ---------- */ + /* ---------- M114 (status query) → targeted reply to requester ---------- */ + if (message === "M114") { + logCommand(sharedState, clientIP, message); + reply(ws, GCode.getM114(robot)); + return; + } + + /* ---------- G-code (motion command) → broadcast new state to all ---------- + * A move changes the robot position, which is a status update every client + * (e.g. the simulation) should see → broadcast. Parsing/execution failures + * are reported back to the sender as a machine-readable error. */ if (GCode.containsCommand(message)) { console.log("🔵 GCode.receiveGCode: Incoming command: " + message); logCommand(sharedState, clientIP, message); - GCode.receiveGCode(robot, message); + try { + GCode.receiveGCode(robot, message); + } catch (err) { + return sendError(ws, 'GCODE_ERROR', err.message, message); + } broadcast(wss, GCode.getM114(robot)); return; } - /* ---------- File Commands ---------- */ + /* ---------- File commands → broadcast result ---------- + * Behaviour kept as-is on purpose: file/log management (and finer-grained + * targeting, e.g. FShow as a requester-only reply) is owned by ToDo 4. */ if (GCode.ContainsFilesCommand(message)) { logCommand(sharedState, clientIP, message); - broadcast(wss, GCode.receiveFC(robot, message)); + let result; + try { + result = GCode.receiveFC(robot, message); + } catch (err) { + return sendError(ws, 'FILE_ERROR', err.message, message); + } + if (result !== undefined) broadcast(wss, result); return; } - /* ---------- Status ---------- */ - if (message === "M114") { - logCommand(sharedState, clientIP, message); - broadcast(wss, GCode.getM114(robot)); - } + /* ---------- Unknown input → targeted machine-readable error ---------- */ + sendError(ws, 'UNKNOWN_COMMAND', 'Unrecognized input', message); }); }); @@ -55,6 +86,21 @@ function initInputWS(server, robot, GCode, sharedState) { /* ---------- Helpers ---------- */ +/** Targeted reply to a single client (status updates use broadcast instead). */ +function reply(ws, payload) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(payload); + } +} + +/** + * Machine-readable error response, sent only to the requesting client. + * Shape: { type: "error", code, message, input } + */ +function sendError(ws, code, message, input) { + reply(ws, JSON.stringify({ type: 'error', code, message, input })); +} + function broadcast(wss, payload) { wss.clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { @@ -65,7 +111,7 @@ function broadcast(wss, payload) { function logCommand(state, ip, message) { fs.appendFileSync( - './logs/gcode_commands.log', + `${LOG_DIR}/gcode_commands.log`, `${new Date().toISOString()} ${ip}: ${message}\n` ); state.lastCommands.push(`${new Date().toISOString()}: ${message}`); @@ -74,11 +120,12 @@ function logCommand(state, ip, message) { function logPing(state, ip) { fs.appendFileSync( - './logs/pings.log', + `${LOG_DIR}/pings.log`, `${new Date().toISOString()} ${ip} : Ping\n` ); state.lastPings.push(`${new Date().toISOString()} ${ip} : Ping`); if (state.lastPings.length > 10) state.lastPings.shift(); } -module.exports = initInputWS; \ No newline at end of file +module.exports = initInputWS; +module.exports.ensureLogDir = ensureLogDir; \ No newline at end of file diff --git a/test/GCode.containsMCode.test.js b/test/GCode.containsMCode.test.js new file mode 100644 index 0000000..9a94116 --- /dev/null +++ b/test/GCode.containsMCode.test.js @@ -0,0 +1,25 @@ +const GCode = require('../robot/GCode'); + +// Bug 6: containsMCode previously used indexOf('M1') == 0, which also matched +// M10, M11, M114, ... It must only match the standalone M1 motor command. +describe('GCode.containsMCode', () => { + test('matches the exact M1 command', () => { + expect(GCode.containsMCode('M1')).toBe(true); + }); + + test('matches M1 with parameters', () => { + expect(GCode.containsMCode('M1 X10 Y20 Z30')).toBe(true); + }); + + test('does NOT match higher M-codes that start with M1', () => { + expect(GCode.containsMCode('M10')).toBe(false); + expect(GCode.containsMCode('M11 X1')).toBe(false); + expect(GCode.containsMCode('M12')).toBe(false); + expect(GCode.containsMCode('M114')).toBe(false); + }); + + test('does NOT match unrelated commands or empty input', () => { + expect(GCode.containsMCode('G1 X1')).toBe(false); + expect(GCode.containsMCode('')).toBe(false); + }); +}); diff --git a/test/InfoServer.test.js b/test/InfoServer.test.js index 9177bdc..0d621cd 100644 --- a/test/InfoServer.test.js +++ b/test/InfoServer.test.js @@ -78,6 +78,12 @@ describe('InfoServer', () => { expect(status.clients).toEqual(['127.0.0.1']); expect(status.lastCommands).toEqual(['G1 X10 Y10']); expect(status.lastPings).toEqual(['Ping']); + + // Machine-readable top-level health summary (one of two senders connected) + expect(status.health).toEqual({ ok: false, connectedSenders: 1, totalSenders: 2 }); + expect(typeof status.generatedAt).toBe('string'); + expect(Number.isNaN(Date.parse(status.generatedAt))).toBe(false); + expect(status.senders).toEqual([ { name: 'Base', diff --git a/test/InputWS.api.test.js b/test/InputWS.api.test.js new file mode 100644 index 0000000..fdca0b8 --- /dev/null +++ b/test/InputWS.api.test.js @@ -0,0 +1,184 @@ +const http = require('http'); +const WebSocket = require('ws'); +const initInputWS = require('../server/InputWS'); +const GCode = require('../robot/GCode'); +const createDummyRobot = require('./helpers/createDummyRobot'); + +/* ---------- helpers ---------- */ + +function listen(server) { + return new Promise((resolve, reject) => { + server.listen(0, () => { + const address = server.address(); + if (address && address.port) resolve(address.port); + else reject(new Error('Failed to get server port')); + }); + server.on('error', reject); + }); +} + +function connect(port) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + ws.on('open', () => resolve(ws)); + ws.on('error', reject); + }); +} + +function nextMessage(ws) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Timeout waiting for message')), 2000); + ws.once('message', (data) => { + clearTimeout(timer); + resolve(data.toString()); + }); + }); +} + +/** Resolves to true if NO message arrives within `ms`, false otherwise. */ +function expectSilence(ws, ms = 200) { + return new Promise((resolve) => { + let gotMessage = false; + const onMessage = () => { gotMessage = true; }; + ws.on('message', onMessage); + setTimeout(() => { + ws.off('message', onMessage); + resolve(!gotMessage); + }, ms); + }); +} + +/* ---------- tests ---------- */ + +describe('InputWS API response routing', () => { + let server; + + function startServer(robot, gcode = GCode) { + server = http.createServer(); + const sharedState = { connectedClients: [], lastCommands: [], lastPings: [] }; + initInputWS(server, robot, gcode, sharedState); + return { sharedState }; + } + + afterEach(async () => { + if (server) { + await new Promise((resolve) => server.close(resolve)); + server = null; + } + }); + + test('Ping is answered to the requester only (not broadcast)', async () => { + startServer(createDummyRobot()); + const port = await listen(server); + + const a = await connect(port); + const b = await connect(port); + + const aReply = nextMessage(a); + const bSilent = expectSilence(b); + + a.send('Ping'); + + expect(await aReply).toBe('Ping'); + expect(await bSilent).toBe(true); + + a.close(); + b.close(); + }); + + test('M114 status query is answered to the requester only', async () => { + const robot = createDummyRobot(); + robot.x = 5; robot.y = 6; robot.z = 7; + startServer(robot); + const port = await listen(server); + + const a = await connect(port); + const b = await connect(port); + + const aReply = nextMessage(a); + const bSilent = expectSilence(b); + + a.send('M114'); + + const parsed = JSON.parse(await aReply); + expect(parsed.position).toEqual({ x: 5, y: 6, z: 7, a: 0, b: 0, c: 0 }); + expect(await bSilent).toBe(true); + + a.close(); + b.close(); + }); + + test('G-code motion command broadcasts the new position to all clients', async () => { + const robot = createDummyRobot(); + startServer(robot); + const port = await listen(server); + + const a = await connect(port); + const b = await connect(port); + + const aReply = nextMessage(a); + const bReply = nextMessage(b); + + a.send('G1 X1 Y2 Z3'); + + const aParsed = JSON.parse(await aReply); + const bParsed = JSON.parse(await bReply); + expect(aParsed.position).toEqual({ x: 1, y: 2, z: 3, a: 0, b: 0, c: 0 }); + expect(bParsed.position).toEqual({ x: 1, y: 2, z: 3, a: 0, b: 0, c: 0 }); + expect(robot.sendCommand).toHaveBeenCalled(); + + a.close(); + b.close(); + }); + + test('unknown input returns a machine-readable error to the sender only', async () => { + startServer(createDummyRobot()); + const port = await listen(server); + + const a = await connect(port); + const b = await connect(port); + + const aReply = nextMessage(a); + const bSilent = expectSilence(b); + + a.send('TOTALLY_UNKNOWN'); + + const err = JSON.parse(await aReply); + expect(err).toEqual({ + type: 'error', + code: 'UNKNOWN_COMMAND', + message: 'Unrecognized input', + input: 'TOTALLY_UNKNOWN' + }); + expect(await bSilent).toBe(true); + + a.close(); + b.close(); + }); + + test('G-code processing errors are reported as a machine-readable error', async () => { + const throwingGCode = { + containsCommand: () => true, + ContainsFilesCommand: () => false, + receiveGCode: () => { throw new Error('boom'); }, + getM114: () => '{}' + }; + startServer(createDummyRobot(), throwingGCode); + const port = await listen(server); + + const a = await connect(port); + const aReply = nextMessage(a); + + a.send('G1 X1'); + + const err = JSON.parse(await aReply); + expect(err).toMatchObject({ + type: 'error', + code: 'GCODE_ERROR', + message: 'boom', + input: 'G1 X1' + }); + + a.close(); + }); +}); diff --git a/test/InputWS.logDir.test.js b/test/InputWS.logDir.test.js new file mode 100644 index 0000000..d366695 --- /dev/null +++ b/test/InputWS.logDir.test.js @@ -0,0 +1,41 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const initInputWS = require('../server/InputWS'); + +// Bug 4: logs/ directory must be ensured so the first appendFileSync() call +// cannot crash on a fresh container/checkout. +describe('InputWS.ensureLogDir', () => { + function tmpPath() { + return path.join(os.tmpdir(), `inputws-logtest-${Date.now()}-${Math.random().toString(36).slice(2)}`); + } + + test('creates a missing log directory', () => { + const dir = tmpPath(); + expect(fs.existsSync(dir)).toBe(false); + + initInputWS.ensureLogDir(dir); + + expect(fs.existsSync(dir)).toBe(true); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('creates nested directories recursively', () => { + const base = tmpPath(); + const nested = path.join(base, 'a', 'b', 'logs'); + + initInputWS.ensureLogDir(nested); + + expect(fs.existsSync(nested)).toBe(true); + fs.rmSync(base, { recursive: true, force: true }); + }); + + test('is idempotent when the directory already exists', () => { + const dir = tmpPath(); + fs.mkdirSync(dir, { recursive: true }); + + expect(() => initInputWS.ensureLogDir(dir)).not.toThrow(); + expect(fs.existsSync(dir)).toBe(true); + fs.rmSync(dir, { recursive: true, force: true }); + }); +});