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
|
## 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.
|
||||||
@@ -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
|
**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
|
**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
|
**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: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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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){
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
GCode.receiveGCode(robot, message);
|
try {
|
||||||
|
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,11 +120,12 @@ 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`);
|
||||||
if (state.lastPings.length > 10) state.lastPings.shift();
|
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.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
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