Claude API

This commit is contained in:
chk
2026-06-08 19:04:31 +02:00
parent 10d306b7d4
commit c777f871cd
12 changed files with 529 additions and 28 deletions

111
doc/API.md Normal file
View File

@@ -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": "<original>" }
```
| `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`.

View File

@@ -6,16 +6,31 @@ Die Schnittstellen sollen klar und vorhersagbar antworten. Steuerbefehle brauche
## Aufgaben ## Aufgaben
- [ ] WebSocket-Antwortlogik strukturieren - [x] WebSocket-Antwortlogik strukturieren
- Steuerbefehle erhalten gezielte Responses - Steuerbefehle erhalten gezielte Responses
- `Ping`, `M114`, Statusabfragen und Fehlermeldungen getrennt behandeln - `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 - Broadcasts für Status-Updates, nicht für direkte Steuerantworten
- [ ] `InfoServer` um detaillierte Statusinformationen erweitern - → Bewegung (G-Code) broadcastet die neue Position; `Ping`/`M114` sind gezielt
- Senderverbindungen - [x] `InfoServer` um detaillierte Statusinformationen erweitern
- Health-Checks - Senderverbindungen · Health-Checks · letzte Befehle / Pings
- letzte Befehle / Pings - → bereits in ToDo 2 angelegt; ergänzt um Top-Level `health`-Summary + `generatedAt`
- [ ] API-Endpunkte klar dokumentieren - [x] API-Endpunkte klar dokumentieren
- `/api/status` - `/api/status` · `/api/position`
- `/api/position` - `doc/API.md`
- [ ] Fehlermeldungen konsistent und maschinenlesbar machen - [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.

View File

@@ -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 5457 **Datei:** `robot/TelnetSenderGRBL.js`, Zeile 5457
@@ -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 6667 und 7778 **Datei:** `server/InputWS.js`, Zeilen 6667 und 7778
@@ -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 **Datei:** `robot/GCode.js`, Zeile 12

View File

@@ -10233,3 +10233,39 @@
2026-06-08T16:16:38.051Z ::ffff:127.0.0.1: G1 X1 Y2 Z3 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.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: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

View File

@@ -14566,3 +14566,19 @@
2026-06-08T16:15:31.061Z ::ffff:127.0.0.1 : Ping 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:16:38.024Z ::ffff:127.0.0.1 : Ping
2026-06-08T16:17:00.351Z ::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

View File

@@ -10,7 +10,7 @@ class GCode{
static fileName = "GCodeFiles/log.gcode"; static fileName = "GCodeFiles/log.gcode";
static containsMCode(s){ static containsMCode(s){
return s.indexOf('M1') == 0 return s === 'M1' || s.startsWith('M1 ');
} }
static receiveMCode(robot, m){ static receiveMCode(robot, m){

View File

@@ -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 = { const status = {
generatedAt: new Date().toISOString(),
health,
clients: sharedState.connectedClients, clients: sharedState.connectedClients,
senders: sendersStatus, senders: sendersStatus,
lastCommands: sharedState.lastCommands, lastCommands: sharedState.lastCommands,

View File

@@ -2,7 +2,19 @@
const fs = require('fs'); const fs = require('fs');
const WebSocket = require('ws'); 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) { function initInputWS(server, robot, GCode, sharedState) {
ensureLogDir();
const wss = new WebSocket.Server({ server }); const wss = new WebSocket.Server({ server });
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
@@ -19,34 +31,53 @@ function initInputWS(server, robot, GCode, sharedState) {
ws.on('message', (msg) => { ws.on('message', (msg) => {
const message = msg.toString(); const message = msg.toString();
/* ---------- Ping ---------- */ /* ---------- Ping (heartbeat) → targeted reply to requester ---------- */
if (message === "Ping") { if (message === "Ping") {
logPing(sharedState, clientIP); logPing(sharedState, clientIP);
broadcast(wss, message); reply(ws, "Ping");
return; 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)) { if (GCode.containsCommand(message)) {
console.log("🔵 GCode.receiveGCode: Incoming command: " + message); console.log("🔵 GCode.receiveGCode: Incoming command: " + message);
logCommand(sharedState, clientIP, message); logCommand(sharedState, clientIP, message);
try {
GCode.receiveGCode(robot, message); GCode.receiveGCode(robot, message);
} catch (err) {
return sendError(ws, 'GCODE_ERROR', err.message, message);
}
broadcast(wss, GCode.getM114(robot)); broadcast(wss, GCode.getM114(robot));
return; 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)) { if (GCode.ContainsFilesCommand(message)) {
logCommand(sharedState, clientIP, 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; return;
} }
/* ---------- Status ---------- */ /* ---------- Unknown input → targeted machine-readable error ---------- */
if (message === "M114") { sendError(ws, 'UNKNOWN_COMMAND', 'Unrecognized input', message);
logCommand(sharedState, clientIP, message);
broadcast(wss, GCode.getM114(robot));
}
}); });
}); });
@@ -55,6 +86,21 @@ function initInputWS(server, robot, GCode, sharedState) {
/* ---------- Helpers ---------- */ /* ---------- 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) { function broadcast(wss, payload) {
wss.clients.forEach(client => { wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) { if (client.readyState === WebSocket.OPEN) {
@@ -65,7 +111,7 @@ function broadcast(wss, payload) {
function logCommand(state, ip, message) { function logCommand(state, ip, message) {
fs.appendFileSync( fs.appendFileSync(
'./logs/gcode_commands.log', `${LOG_DIR}/gcode_commands.log`,
`${new Date().toISOString()} ${ip}: ${message}\n` `${new Date().toISOString()} ${ip}: ${message}\n`
); );
state.lastCommands.push(`${new Date().toISOString()}: ${message}`); state.lastCommands.push(`${new Date().toISOString()}: ${message}`);
@@ -74,7 +120,7 @@ function logCommand(state, ip, message) {
function logPing(state, ip) { function logPing(state, ip) {
fs.appendFileSync( fs.appendFileSync(
'./logs/pings.log', `${LOG_DIR}/pings.log`,
`${new Date().toISOString()} ${ip} : Ping\n` `${new Date().toISOString()} ${ip} : Ping\n`
); );
state.lastPings.push(`${new Date().toISOString()} ${ip} : Ping`); state.lastPings.push(`${new Date().toISOString()} ${ip} : Ping`);
@@ -82,3 +128,4 @@ function logPing(state, ip) {
} }
module.exports = initInputWS; module.exports = initInputWS;
module.exports.ensureLogDir = ensureLogDir;

View File

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

View File

@@ -78,6 +78,12 @@ describe('InfoServer', () => {
expect(status.clients).toEqual(['127.0.0.1']); expect(status.clients).toEqual(['127.0.0.1']);
expect(status.lastCommands).toEqual(['G1 X10 Y10']); expect(status.lastCommands).toEqual(['G1 X10 Y10']);
expect(status.lastPings).toEqual(['Ping']); 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([ expect(status.senders).toEqual([
{ {
name: 'Base', name: 'Base',

184
test/InputWS.api.test.js Normal file
View File

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

View File

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