Claude API
This commit is contained in:
111
doc/API.md
Normal file
111
doc/API.md
Normal 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`.
|
||||
@@ -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
|
||||
- → 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
module.exports = initInputWS;
|
||||
module.exports.ensureLogDir = ensureLogDir;
|
||||
25
test/GCode.containsMCode.test.js
Normal file
25
test/GCode.containsMCode.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
184
test/InputWS.api.test.js
Normal file
184
test/InputWS.api.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
41
test/InputWS.logDir.test.js
Normal file
41
test/InputWS.logDir.test.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user