Konfig in robot.json

This commit is contained in:
chk
2026-06-11 22:05:45 +02:00
parent 05355facf1
commit 66a8e247b5
18 changed files with 1761 additions and 151 deletions

4
.gitignore vendored
View File

@@ -1,5 +1,9 @@
# Node
node_modules
# Robot-Config: Snapshots und generierter API-Key (nicht ins Repo)
data/robot/robot_*.json
data/robot/.apikey
logs
npm-debug.log
yarn-debug.log

View File

@@ -11,6 +11,8 @@ Dieses Projekt empfängt G-Code und Robotersteuerbefehle, berechnet Inverse Kine
- `robot/GCode.js` verarbeitet G-Code, übersetzt ihn in Roboter-Koordinaten und triggert `robot.sendCommand()`.
- `robot/RobotBase.js` ist die abstrakte Basisklasse / der Interface-Vertrag: generische Infrastruktur (Zustand, `sendCommand`, Motor-Geschwindigkeiten) plus die zwei abstrakten Kinematik-Methoden.
- `robot/kinematics/Arm3SegmentLinearX.js` ist die konkrete Kinematik (Inverse + Vorwärts) für den aktuellen Arm. Die Auswahl der Kinematik erfolgt über `robot/KinematicsFactory.js` (Umgebungsvariablen `ROBOT_KINEMATICS` / `ROBOT_KINEMATICS_PARAMS`). Siehe `doc/ToDo_12_InverseKinematikConfig_ROADMAP.md`.
- `robot/RobotConfig.js` liest `data/robot/robot.json` beim Start synchron und gibt einen typisierten Konfigurations-Record zurück (Kinematik-Parameter, Bewegungs-Defaults, Controller-Endpunkte). Env-Variablen überschreiben die JSON-Werte.
- `server/RobotConfigService.js` stellt `GET/PUT /api/robot` und `GET /api/robot/history` über den InfoServer bereit (Single Source of Truth für alle Apps). Schreibzugriffe erfordern `Authorization: Bearer <ROBOT_API_KEY>`.
- `robot/TelnetSenderGRBL.js` formatiert die Motor-Positionen in GRBL-kompatible Befehle und sendet sie per Telnet an einen Zielcontroller.
## Eingaben
@@ -89,22 +91,65 @@ Die Eingaben kommen per WebSocket an den HTTPS-Server und werden in `server/Inpu
- Interner Schalter, ob `robot.calculateSpeeds()` rechnet. Ändert allein **nicht** die
Sender-Ausgabe — dafür ist `ROBOT_SPEED_MODE=correct` nötig. Vom Korrekt-Modus
automatisch aktiviert.
- `ROBOT_KINEMATICS`
- Standard: `arm3segmentlinearx`
- Bezeichner der Kinematik-Klasse (case-insensitive). Bekannte Werte: `arm3segmentlinearx`,
`grabit` / `robot02` (Joy-IT Grab-It). Unbekannter Bezeichner → Fehler beim Start.
- `ROBOT_KINEMATICS_PARAMS`
- JSON-Objekt mit Konstruktor-Parametern, z. B. `{"l1":250,"l2":264,"l3":100}`.
Überschreibt die Werte aus `robot.json`. Wird von der Factory als viertes Argument
an den Konstruktor weitergereicht (erlaubt kinematik-spezifische Parameter wie `baseHeight`).
- `ROBOT_API_KEY`
- Statischer Bearer-Token für `PUT /api/robot`. Fehlt die Variable, generiert
`RobotConfigService` beim ersten Start einen zufälligen Key und speichert ihn in
`data/robot/.apikey` (nicht im Repo). Der Key wird beim Start einmalig geloggt.
### HTTPS-Konfiguration
- `https/localhost.key`
- `https/localhost.pem`
- Passphrase aktuell hart codiert als `abcd`
- Passphrase: Env-Variable `HTTPS_PASSPHRASE`, Default `abcd`
### robot.json
`data/robot/robot.json` ist die zentrale Konfigurationsdatei für einen konkreten Roboter.
Sie wird von `robot/RobotConfig.js` beim Start synchron gelesen. Fehlt die Datei, startet
der Driver mit Fallback-Defaults und loggt eine Warnung.
Relevante Abschnitte für den Driver:
```json
{
"kinematics": { "type": "arm3segmentlinearx" },
"motion": { "defaultFeedrate": 1000, "speedMode": "legacy" },
"controllers": {
"base": { "ip": "fluidNcBase.local", "port": 2300, "protocol": "telnet", "axes": ["x","y","z"] },
"elbow": { "ip": "fluidNcEllbow.local", "port": 5000, "protocol": "telnet", "axes": ["a",null,null] },
"hand": { "ip": "fluidNcHand.local", "port": 5000, "protocol": "telnet", "axes": ["c","e","b"] }
}
}
```
Armlängen werden aus dem `links`-Abschnitt abgeleitet (`Arm1.skeleton.to[1]` → l1 usw.).
Env-Variablen haben Vorrang vor robot.json (nützlich für Tests und schnelle Korrekturen).
Snapshots werden automatisch vor jedem PUT angelegt (`data/robot/robot_YYYYMMDD_HHmmss.json`).
Snapshots sind nicht im Repo (`.gitignore`), `robot.json` selbst schon.
### Telnet-Sender-Konfiguration
In `startRobot.js` werden drei `TelnetSenderGRBL` Instanzen erzeugt:
`startRobot.js` erzeugt die `TelnetSenderGRBL`-Instanzen dynamisch aus `cfg.controllers`
(geladen von `robot/RobotConfig.js`). Die Defaults entsprechen drei Controllern:
- `telnetSender1` → Basisachse(n): `x`, `y`, `z`
- `telnetSender2` → Ellbogenachse: `a`
- `telnetSender3` → Handachsen: `c`, `e`, `b`
| Key | Default-IP | Port | Achsen |
|-----|-----------|------|--------|
| `base` | `fluidNcBase.local` | 2300 | `x, y, z` |
| `elbow` | `fluidNcEllbow.local` | 5000 | `a` |
| `hand` | `fluidNcHand.local` | 5000 | `c, e, b` |
Die Achszuordnung kann in `robot/TelnetSenderGRBL.js` durch Anpassung der Konstruktorparameter geändert werden.
IPs können per Env-Variable überschrieben werden (`GRBL_BASE_IP`, `GRBL_ELLBOW_IP`,
`GRBL_HAND_IP`). Alles andere (Port, Achsen, Controller-Anzahl) wird in `robot.json`
konfiguriert.
## Serverschnittstellen
@@ -124,6 +169,9 @@ Die Achszuordnung kann in `robot/TelnetSenderGRBL.js` durch Anpassung der Konstr
- API-Endpunkte:
- `/api/status`
- `/api/position`
- `/api/robot``GET`: aktuelle `robot.json`; `PUT`: überschreibt sie (Auth erforderlich)
- `/api/robot/history` — Liste aller Snapshots
- `/api/robot/history/:ts` — einen bestimmten Snapshot abrufen
## Wichtige Dateien
@@ -134,6 +182,9 @@ Die Achszuordnung kann in `robot/TelnetSenderGRBL.js` durch Anpassung der Konstr
- `robot/kinematics/Arm3SegmentLinearX.js` — konkrete Kinematik (Modell + Inverse/Vorwärts), Default-Arm
- `robot/kinematics/Arm3SegmentRotaryBase.js` — Kinematik für den Joy-IT „Grab-It" (Robot02), 5 Achsen + Greifer mit Drehbasis (`ROBOT_KINEMATICS=grabit`)
- `robot/KinematicsFactory.js` — wählt die Kinematik per Umgebungsvariable
- `robot/RobotConfig.js` — liest `data/robot/robot.json`, gibt typisierten Konfigurations-Record zurück
- `server/RobotConfigService.js` — REST-Endpunkte `/api/robot*` (lesen/schreiben, Snapshots, Auth)
- `data/robot/robot.json` — zentrale Roboter-Konfiguration (Single Source of Truth)
- `robot/GCodeParser.js` — wandelt rohe Nachrichten in strukturierte Befehlsobjekte
- `robot/RobotController.js` — wendet geparste Befehle auf das Modell an (Steuerlogik)
- `robot/GCode.js` — Fassade + Datei-Befehle
@@ -143,9 +194,10 @@ Die Achszuordnung kann in `robot/TelnetSenderGRBL.js` durch Anpassung der Konstr
## Laufzeitvoraussetzungen
- Das Verzeichnis `logs/` muss im Arbeitsverzeichnis existieren, da `InputWS.js` dort `pings.log` und `gcode_commands.log` schreibt.
- HTTPS-Zertifikate: `https/localhost.key` und `https/localhost.pem` (Passphrase aktuell hardcoded `abcd`).
- Die Telnet-Sender werden erst nach 5 Sekunden zum Roboter hinzugefügt, damit die Verbindungen Zeit haben aufzubauen.
- HTTPS-Zertifikate: `https/localhost.key` und `https/localhost.pem` (Passphrase via `HTTPS_PASSPHRASE`, Default `abcd`).
- `data/robot/robot.json` — wird beim Start eingelesen; fehlt die Datei, startet der Driver mit Defaults + Warnung.
- `logs/` wird beim Start automatisch angelegt (`fs.mkdirSync('logs', { recursive: true })` in `startRobot.js`).
- Telnet-Sender werden sofort beim Start als `cmdReceivers` registriert; interne Reconnect-Logik überbrückt verzögerte Controller-Verbindungen automatisch.
## ToDo / Open Tasks
@@ -163,9 +215,10 @@ Architektur- und Refactoring-Aufgaben sind in `doc/ToDo_*.md` dokumentiert:
| `doc/ToDo_6b_FileHandling.md` | File-Handling: fehlende Befehle, Cursor im Speicher, Fehler-Feedback | offen |
| `doc/ToDo_7_Tests.md` | Testabdeckung und Stabilität | teilweise |
| `doc/ToDo_8_Bugs.md` | Bekannte konkrete Bugs | teilweise |
| `doc/ToDo_9_HardwareFeedback.md` | Hardware-Feedback-Loop (GRBL-Antworten, Command-Queue, Positionsabgleich) | offen |
| `doc/ToDo_9_HardwareFeedback.md` | Hardware-Feedback-Loop (GRBL-Antworten, Command-Queue, Positionsabgleich) | teilweise (Baustein Port→Motor ✅, Pakete 16 offen) |
| `doc/ToDo_10_VerbindungsVerlust.md` | Verbindungsverlust erkennen, Watchdog, UI-Statusanzeige | offen |
| `doc/ToDo_12_InverseKinematikConfig_ROADMAP.md` | Austauschbare Kinematik: RobotBase + Robot7M, Env-Konfiguration | offen |
| `doc/ToDo_12_InverseKinematikConfig_ROADMAP.md` | Austauschbare Kinematik: RobotBase, KinematicsFactory, Grab-It | ✅ erledigt |
| `doc/ToDo_14_robot_json_service.md` | robot.json als REST-Service, RobotConfigService, RobotConfig | teilweise (Schritte 14 in appRobotDriver ✅, Schritte 57 offen) |
| `doc/ToDo_49_Cleanup.md` | Pre-Release-Cleanup: tote Code, Zertifikate, ToDos, README | offen |
### Empfohlene Bearbeitungsreihenfolge
@@ -191,5 +244,5 @@ Kurzübersicht weiterer offener Punkte:
- [ ] `FFirst`/`FLast`-Befehle in `GCode.receiveFC()` implementieren
- [ ] `ROBOT_USE_SPEED_CALC` und `motorSpeeds` im echten Betrieb prüfen
- [ ] `FluidNCClient.js` evaluieren: als Ersatz oder Ergänzung zu `TelnetSenderGRBL`?
- [ ] HTTPS-Konfiguration und Zertifikatsverwaltung verbessern (Passphrase aus Env-Variable)
- [ ] `logs/`-Verzeichnis beim Start automatisch anlegen
- [x] HTTPS-Passphrase aus Env-Variable (`HTTPS_PASSPHRASE`) — erledigt
- [x] `logs/`-Verzeichnis beim Start automatisch anlegen — erledigt (`startRobot.js`)

0
data/robot/.gitkeep Normal file
View File

502
data/robot/robot.json Normal file
View File

@@ -0,0 +1,502 @@
{
"_label": "todo3_2026-06-11",
"coordinateSystem": {"handedness": "right", "x": "right", "y": "backward", "z": "up"},
"units": {"_owner": "appRobotDriver", "length": "mm", "rotation": "degree"},
"kinematics": {
"_owner": "appRobotDriver",
"type": "arm3segmentlinearx"
},
"motion": {
"_owner": "appRobotDriver",
"defaultFeedrate": 2300,
"speedMode": "legacy",
"speedModeOptions": ["legacy", "correct"]
},
"controllers": {
"_owner": "appRobotDriver",
"base": { "ip": "fluidNcBase.local", "port": 2300, "protocol": "telnet", "axes": ["x", "y", "z"] },
"elbow": { "ip": "fluidNcEllbow.local", "port": 5000, "protocol": "telnet", "axes": ["a", null, null] },
"hand": { "ip": "fluidNcHand.local", "port": 5000, "protocol": "telnet", "axes": ["c", "e", "b"] }
},
"vision_config": {"MarkerType": "DICT_4X4_250", "MarkerSize": 0.025},
"renderingInfo": {
"width": 1280,
"height": 720,
"renderDefaults": {"width": 1280, "height": 720, "dofFStop": 11},
"cameraPosition__1": [-10, -800, 500],
"cameraPosition__2": [-500, 300, 1200],
"cameraPosition__3": [-200, -900, 200],
"cameraPosition__4": [1200, 200, 300],
"cameraPosition_a": [-300, -800, 500],
"cameraPosition": [-200, 200, 1400],
"cameraPosition_c": [600, -500, 600],
"cameraTarget": [200, -200, 180],
"cameraUpVector": [0, 0, 1],
"lightPosition": [-500, -500, 500],
"lightTarget": [0, 0, 0],
"lightUpVector": [0, 0, 1],
"metric": "mm",
"showSkeleton": true,
"showMarkers": true,
"backgroundColor": [0.7, 0.85, 1.0],
"backgroundStrength": 0.2,
"sunEnergy": 0.35,
"areaEnergy": 120,
"exposure": -1.5,
"lensDirt": true,
"lensDirtStrength": 0.08,
"dofEnabled": true,
"dofFStop": 11.0,
"arucoDust": true,
"arucoDustStrength": 1.6,
"markerOffsetMaxMm": 4.0,
"markerOffsetSeed": 0,
"markerRotationMaxDeg": 3,
"motionBlur": true,
"motionBlurMaxPx": 5.5,
"focalErrorPct": 0.5,
"principalErrorPx": 3.0,
"residualDistortion": [0.02, -0.01],
"localizedBlur": false,
"localizedBlurStrength": 0.15,
"vignette": true,
"vignetteStrength": 0.08,
"sensorNoise": true,
"sensorNoiseStrength": 0.01,
"lensDistortion": true,
"lensDistortionStrength": 0.002,
"materials": {
"wood": {"baseColor": [0.72, 0.52, 0.33], "roughness": 0.85, "metallic": 0.0},
"plaWhite": {"baseColor": [0.95, 0.95, 0.95], "roughness": 0.45, "metallic": 0.0},
"steel": {"baseColor": [0.72, 0.72, 0.75], "roughness": 0.25, "metallic": 1.0},
"powderCoatBlue": {"baseColor": [0.15, 0.25, 0.7], "roughness": 0.55, "metallic": 0.0},
"defaultPlastic": {"baseColor": [0.95, 0.95, 0.95], "roughness": 0.4, "metallic": 0.0},
"skeletonRed": {"baseColor": [0.85, 0.2, 0.2], "roughness": 0.35, "metallic": 0.0},
"markerBlack": {"baseColor": [0.04, 0.04, 0.04], "roughness": 0.8, "metallic": 0.0}
},
"skeletonDefaults": {"radius": 4, "color": [0.85, 0.2, 0.2]},
"markerDefaults": {"size": 25, "thickness": 1, "color": [0.04, 0.04, 0.04]},
"defaultPosition": {"x": 80, "y": 20, "z": 80, "a": -120, "b": 23, "c": 9, "e": 3}
},
"defaultPosition__": {"x": 10, "y": 4, "z": 20, "a": 10, "b": 2, "c": 9, "e": 1},
"defaultPosition": {"x": 50, "y": 4, "z": 176, "a": 20, "b": 60, "c": 9, "e": 5},
"recognized": {"x": null, "y": null, "z": null, "a": null, "b": null, "c": null, "e": null},
"constraint_rules": {
"rigid_distance": {"enabled": true, "mode": "mst", "weight": 1.0},
"joint_axis_projection": {"enabled": true, "max_pairs": 2, "weight": 0.35},
"chain_axis_projection": {"enabled": false, "max_depth": 3, "max_pairs": 2, "weight": 0.15},
"axis_alignment_threshold": 0.95
},
"observation_weighting": {"enabled": true, "distance_weight": true, "marker_size_weight": true, "view_angle_weight": true},
"multiview_calculation": {
"combine_mode": "mean",
"size_ref_px": 50.0,
"border_ref_px": 120.0,
"center_ref_norm": 0.01,
"sharpness_ref": 2500.0,
"homography_ref": 0.18,
"size_factor": 0.3,
"aspect_factor": 0.3,
"border_factor": 0.01,
"center_factor": 0.01,
"sharpness_factor": 0.5,
"homography_factor": 0.2,
"normal_visibility_factor": 0.01,
"spin_factor": 0.3,
"weight_floor": 0.3
},
"pose_estimation": {
"method": "hybrid",
"marker_observation": "corner_pose",
"use_normals": true,
"normal_weight": 100.0,
"robust_loss": "huber",
"huber_delta_mm": 8.0,
"max_iterations": 200,
"min_cameras_per_marker": 2,
"finger_block_joints": ["b", "c", "e"],
"per_link_method": {}
},
"robot_test_poses": {
"4": {"x": 70, "y": 50, "z": -70, "a": 120, "b": 50, "c": 30, "e": 20},
"5": {"x": 180, "y": 86, "z": -120, "a": -60, "b": 22, "c": 91, "e": 10},
"6": {"x": 80, "y": 20, "z": 80, "a": -120, "b": 23, "c": 9, "e": 3},
"7": {"x": 30, "y": -2, "z": 95, "a": 20, "b": 23, "c": 9, "e": 9},
"8": {"x": 50, "y": -2, "z": 95, "a": 20, "b": 60, "c": 9, "e": 3},
"9": {"x": 60, "y": -2, "z": 95, "a": 200, "b": 60, "c": 9, "e": 8},
"9a": {
"x": 60,
"y": -2,
"z": 95,
"a": 200,
"b": 60,
"c": 9,
"e": 8,
"rendering": {"width": 1440, "height": 1080, "dofFStop": 11}
},
"9b": {
"x": 60,
"y": -2,
"z": 95,
"a": 200,
"b": 60,
"c": 9,
"e": 8,
"rendering": {"width": 4896, "height": 3264, "dofFStop": 5.6}
},
"10": {"x": 120, "y": 60, "z": -110, "a": 20, "b": 30, "c": 180, "e": 4},
"11": {"x": 50, "y": 4, "z": 176, "a": 20, "b": 60, "c": 9, "e": 5},
"12": {"x": 50, "y": 0, "z": 178, "a": 210, "b": 80, "c": 90, "e": 6}
},
"test_camera_positions": {
"a": [-300, -800, 800],
"b": [300, -900, 1200],
"c": [300, -900, 400],
"d": [700, -800, 400],
"e": [1200, -900, 400],
"f": [500, -300, 1400],
"g": [-200, 200, 1400]
},
"test_camera_targets": {
"a": [210, -100, 180],
"b": [310, -80, 180],
"c": [210, -100, 150],
"d": [210, -100, 150],
"e": [210, -100, 50],
"f": [200, -200, 180],
"g": [200, -200, 180]
},
"movements": {"x": null, "y": null, "z": null, "a": null, "b": null, "c": null, "e": null},
"state_pose_params": {
"numbers_of_Elements_to_consider_start": 3,
"numbers_of_Elements_to_consider_final": 5,
"solver_in_between_geometrical": false,
"solver_after_geometrical": false,
"geometric_passes_per_stage": 2,
"revolute_search_coarse_deg": 5.0,
"revolute_search_fine_deg": 1.0,
"root_pose_min_markers": 3,
"use_marker_normals_flip_tiebreak": true,
"normal_flip_weight": 0.05
},
"links": {
"_owner": "appRobotDriver",
"Board": {
"parent": null,
"size": [1000, 200, 25],
"mountPosition": [0, 0, 0],
"mountRotation": [0, 0, 0],
"skeleton": {"from": [0, 0, 16], "to": [1000, 0, 16], "radius": 4, "color": [0.85, 0.2, 0.2]},
"markers": [
{"id": 210, "set": "Brett", "position": [20, -20, 0.3], "normal": [0, 0, 1]},
{"id": 211, "set": "Brett", "position": [250, -10, 0.3], "normal": [0, 0, 1]},
{"id": 215, "set": "Brett", "position": [250, -90, 0.3], "normal": [0, 0, 1]},
{"id": 214, "set": "Brett", "position": [350, -10, 0.3], "normal": [0, 0, 1]},
{"id": 208, "set": "Brett", "position": [350, -90, 0.3], "normal": [0, 0, 1]},
{"id": 206, "set": "Brett", "position": [650, -10, 0.3], "normal": [0, 0, 1]},
{"id": 205, "set": "Brett", "position": [750, -90, 0.3], "normal": [0, 0, 1]},
{"id": 207, "set": "Brett", "position": [750, -10, 0.3], "normal": [0, 0, 1]},
{"id": 217, "set": "Brett", "position": [650, -90, 0.3], "normal": [0, 0, 1]},
{
"id": 46,
"set": "A0",
"position": [536.71, 185.44, -27.3],
"normal": [0, 0, 1],
"spin": 90,
"info": "is placed on a white paper, A0_60Arucos_25mm_Seet223.pdf, with the following marker placements:"
},
{"id": 47, "set": "A0", "position": [344.23, -286.54, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 48, "set": "A0", "position": [688.69, -320.72, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 49, "set": "A0", "position": [1006.0, 158.33, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 50, "set": "A0", "position": [573.41, 211.86, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 51, "set": "A0", "position": [167.8, -172.08, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 52, "set": "A0", "position": [94.68, 208.66, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 53, "set": "A0", "position": [486.25, 212.24, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 54, "set": "A0", "position": [342.27, -330.59, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 55, "set": "A0", "position": [283.72, -262.58, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 56, "set": "A0", "position": [498.68, 168.67, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 57, "set": "A0", "position": [602.86, -364.05, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 58, "set": "A0", "position": [50.09, -218.11, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 59, "set": "A0", "position": [626.21, -278.75, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 60, "set": "A0", "position": [434.36, 283.81, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 61, "set": "A0", "position": [-22.42, 335.83, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 62, "set": "A0", "position": [404.7, -175.1, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 63, "set": "A0", "position": [777.4, -236.15, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 64, "set": "A0", "position": [-21.27, -188.23, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 65, "set": "A0", "position": [803.39, -297.37, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 66, "set": "A0", "position": [209.75, -363.23, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 67, "set": "A0", "position": [523.07, 267.04, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 68, "set": "A0", "position": [573.73, 170.64, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 69, "set": "A0", "position": [7.61, -281.21, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 70, "set": "A0", "position": [601.87, 300.33, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 71, "set": "A0", "position": [749.75, -284.01, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 72, "set": "A0", "position": [440.99, 194.32, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 73, "set": "A0", "position": [221.73, 333.11, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 74, "set": "A0", "position": [93.78, 144.5, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 75, "set": "A0", "position": [-25.7, 194.58, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 76, "set": "A0", "position": [685.21, 166.8, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 77, "set": "A0", "position": [18.19, 191.57, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 78, "set": "A0", "position": [823.11, -344.38, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 79, "set": "A0", "position": [312.3, -159.11, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 80, "set": "A0", "position": [863.59, -335.92, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 81, "set": "A0", "position": [132.14, 169.03, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 82, "set": "A0", "position": [219.16, 297.24, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 83, "set": "A0", "position": [44.16, 339.22, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 84, "set": "A0", "position": [407.49, 258.42, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 85, "set": "A0", "position": [504.58, -312.75, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 86, "set": "A0", "position": [362.89, 292.01, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 87, "set": "A0", "position": [943.63, -245.76, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 88, "set": "A0", "position": [765.87, 316.04, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 89, "set": "A0", "position": [988.02, -369.14, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 90, "set": "A0", "position": [643.17, 316.43, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 91, "set": "A0", "position": [723.35, 328.05, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 92, "set": "A0", "position": [645.09, -184.84, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 93, "set": "A0", "position": [934.88, 143.6, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 94, "set": "A0", "position": [875.7, 173.65, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 95, "set": "A0", "position": [186.04, -274.07, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 96, "set": "A0", "position": [369.77, -186.49, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 97, "set": "A0", "position": [304.35, -359.67, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 98, "set": "A0", "position": [575.27, 315.06, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 99, "set": "A0", "position": [959.16, -321.55, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 100, "set": "A0", "position": [803.25, 172.36, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 101, "set": "A0", "position": [117.7, 298.66, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 102, "set": "A0", "position": [649.69, -223.0, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 103, "set": "A0", "position": [105.71, -187.71, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 104, "set": "A0", "position": [826.71, 239.16, -27.3], "normal": [0, 0, 1], "spin": 90},
{"id": 105, "set": "A0", "position": [524.84, -266.25, -27.3], "normal": [0, 0, 1], "spin": 90}
],
"model": [
{
"stlFile": "surfaces/Board.stl",
"originOfModel": [0, 0, 0],
"rotationOfModelDegree": [0, 0, -90],
"material": "wood"
},
{
"stlFile": "surfaces/BoardRail.stl",
"originOfModel": [0, 0, 0],
"rotationOfModelDegree": [0, 0, -90],
"material": "steel"
}
]
},
"Base": {
"parent": "Board",
"size": [150, 200, 150],
"mountPosition": [0, 0, 0],
"mountRotation": [0, 0, 0],
"jointToParent": {
"name": "Slider",
"type": "linear",
"axis": [1, 0, 0],
"origin": [0, 0, 16],
"rotation": [0, 0, 0],
"variable": "x",
"feedrate": 2000,
"controller": "base"
},
"skeleton": {"from": [0, 108, 45], "to": [110, 108, 45], "radius": 4, "color": [0.2, 0.8, 0.2]},
"markers": [],
"model": [
{
"stlFile": "surfaces/Base.stl",
"originOfModel": [-30, 0, -35],
"rotationOfModelDegree": [0, 0, 0],
"material": "plaWhite"
}
]
},
"Arm1": {
"parent": "Base",
"size": [70, 250, 70],
"mountPosition": [0, 0, 0],
"mountRotation": [0, 0, 0],
"jointToParent": {
"name": "Joint1",
"type": "revolute",
"axis": [-1, 0, 0],
"origin": [110, 108, 45],
"rotation": [0, 0, 0],
"variable": "y",
"feedrate": 2300,
"controller": "base"
},
"skeleton": {"from": [0, 0, 0], "to": [0, -250, 0], "radius": 4, "color": [0.2, 0.2, 0.9]},
"markers": [
{"id": 198, "name": "aruco_198", "position": [0, -160, 35], "normal": [0, 0, 1], "size": 25, "spin": 0},
{"id": 229, "name": "aruco_229", "position": [0, -250, 35], "normal": [0, 0, 1], "size": 25, "spin": 0},
{"id": 242, "name": "aruco_242", "position": [0, -250, -35], "normal": [0, 0, -1], "size": 25, "spin": 0},
{"id": 243, "name": "aruco_243", "position": [0, -285, 0], "normal": [0, -1, 0], "size": 25, "spin": 0}
],
"model": [
{
"stlFile": "surfaces/Holm.stl",
"originOfModel__": [-25, 29, -28.5],
"originOfModel": [-29, 25, 28.5],
"rotationOfModelDegree__": [0, 0, 0],
"rotationOfModelDegree": [180, 0, -90],
"material": "powderCoatBlue"
}
]
},
"Ellbow": {
"parent": "Arm1",
"mountPosition": [0, 0, 0],
"mountRotation": [0, 0, 0],
"jointToParent": {
"name": "Joint2",
"type": "revolute",
"axis": [-1, 0, 0],
"origin": [0, -250, 0],
"rotation": [0, 0, 0],
"variable": "z",
"feedrate": 2300,
"controller": "base"
},
"skeleton": {"from": [0, 0, 0], "to": [90, 0, 0], "radius": 4, "color": [0.9, 0.2, 0.2]},
"model": [
{
"stlFile": "surfaces/Ellebogen.stl",
"originOfModel": [90, 0, 0],
"rotationOfModelDegree": [0, -90, -90],
"material": "defaultPlastic"
}
],
"markers": [
{"id": 244, "name": "aruco_244", "position": [125, 0, 0], "normal": [1, 0, 0], "size": 25, "spin": 0},
{"id": 245, "name": "aruco_245", "position": [90, 0, -35], "normal": [0, 0, -1], "size": 25, "spin": 0},
{"id": 246, "name": "aruco_246", "position": [90, 0, 35], "normal": [0, 0, 1], "size": 25},
{"id": 247, "name": "aruco_247", "position": [52.5, 0, 35], "normal": [0, 0, 1], "size": 25},
{"id": 248, "name": "aruco_248", "position": [52.5, 0, -35], "normal": [0, 0, -1], "size": 25},
{"id": 232, "name": "aruco_232", "position": [90, 24.75, -24.75], "normal": [0, 1, -1], "size": 25},
{"id": 231, "name": "aruco_231", "position": [90, 24.75, 24.75], "normal": [0, 1, 1], "size": 25}
]
},
"Arm2": {
"parent": "Ellbow",
"mountPosition": [0, 0, 0],
"mountRotation": [0, 0, 0],
"jointToParent": {
"name": "Joint3",
"type": "revolute",
"axis": [0, -1, 0],
"origin": [90, 0, 0],
"rotation": [0, 0, 0],
"variable": "a",
"feedrate": 2300,
"controller": "elbow"
},
"skeleton": {"from": [0, 0, 0], "to": [0, -250, 0], "radius": 4, "color": [0.95, 0.85, 0.2]},
"model": [
{
"stlFile": "surfaces/Unterarm.stl",
"originOfModel": [0, -250, 0],
"rotationOfModelDegree": [180, 0, -90],
"material": "defaultPlastic"
}
],
"markers": [
{"id": 120, "position": [24.75, -112, -24.75], "normal": [1, 0, -1]},
{"id": 122, "name": "aruco_122", "position": [-35, -112, 0], "normal": [-1, 0, 0]},
{"id": 218, "name": "aruco_218", "position": [35, -112, 0], "normal": [1, 0, 0]},
{"id": 113, "name": "aruco_113", "position": [0, -182, 30], "normal": [0, 0, 1]},
{"id": 114, "name": "aruco_114", "position": [24.75, -182, -24.75], "normal": [1, 0, -1]},
{"id": 115, "name": "aruco_115", "position": [-24.75, -182, -24.75], "normal": [-1, 0, -1]},
{"id": 124, "name": "aruco_124", "position": [-35, -219, 0], "normal": [-1, 0, 0]},
{"id": 219, "name": "aruco_219", "position": [35, -219, 0], "normal": [1, 0, 0]}
]
},
"Hand": {
"parent": "Arm2",
"mountPosition": [0, 0, 0],
"mountRotation": [0, 0, 0],
"jointToParent": {
"name": "Joint4",
"type": "revolute",
"axis": [1, 0, 0],
"origin": [0, -250, 0],
"rotation": [0, 0, 0],
"variable": "b",
"feedrate": 2300,
"controller": "hand"
},
"skeleton": {"from": [0, 0, 0], "to": [0, -35, 0], "radius": 4, "color": [0.95, 0.55, 0.15]}
},
"Palm": {
"parent": "Hand",
"mountPosition": [0, 0, 0],
"mountRotation": [0, 0, 0],
"jointToParent": {
"name": "Joint3",
"type": "revolute",
"axis": [0, -1, 0],
"origin": [0, 0, 0],
"rotation": [0, 0, 0],
"variable": "c",
"feedrate": 2300,
"controller": "hand"
},
"skeleton": {"from": [-50, -35, 0], "to": [50, -35, 0], "radius": 7, "color": [0.95, 0.2, 0.2]}
},
"FingerA": {
"parent": "Palm",
"size": [80, 60, 20],
"mountPosition": [0, 0, 0],
"mountRotation": [0, 0, 0],
"jointToParent": {
"name": "Slider",
"type": "linear",
"axis": [1, 0, 0],
"origin": [4, -35, 0],
"rotation": [0, 0, 0],
"variable": "e",
"feedrate": 2000,
"controller": "hand"
},
"skeleton": {"from": [0, 0, 0], "to": [0, -60, 0], "radius": 4, "color": [0.2, 0.8, 0.2]},
"markers": [
{"id": 40, "position": [12, -24, -17.1], "normal": [-10.98, 0, -23.56]},
{"id": 41, "position": [1.5, -2.2, 25.8], "normal": [0, -25.6, 9.5]},
{"id": 42, "position": [13.9, -40, 0], "normal": [1, -0.35, 0.4], "spin": 27}
],
"model": [
{
"stlFile": "surfaces/Finger.stl",
"originOfModel": [24, 0, -9.1],
"rotationOfModelDegree": [90, -90, 0],
"material": "defaultPlastic"
}
]
},
"FingerB": {
"parent": "Palm",
"size": [80, 60, 20],
"mountPosition": [0, 0, 0],
"mountRotation": [0, 0, 0],
"jointToParent": {
"name": "Slider",
"type": "linear",
"axis": [-1, 0, 0],
"origin": [-4, -35, 0],
"rotation": [0, 0, 0],
"variable": "e",
"feedrate": 2000,
"controller": "hand"
},
"skeleton": {"from": [0, 0, 0], "to": [0, -60, 0], "radius": 4, "color": [0.2, 0.8, 0.2]},
"markers": [
{"id": 43, "position": [-12, -24, 17.1], "normal": [10.98, 0, 23.56], "spin": 90},
{"id": 44, "position": [-1.5, -2.2, -25.8], "normal": [0, -25.6, -9.5], "spin": 90},
{"id": 45, "position": [-13.9, -40, 0], "normal": [-1, -0.35, -0.4], "spin": -27}
],
"model": [
{
"stlFile": "surfaces/Finger.stl",
"originOfModel": [-24, 0, 9.1],
"rotationOfModelDegree": [90, 90, 0],
"material": "defaultPlastic"
}
]
}
}
}

View File

@@ -0,0 +1,172 @@
# Roadmap: robot.json als zentraler Service
## Ist-Zustand (Problem)
`robot.json` existiert aktuell an mindestens zwei unabhängigen Orten:
| App | Pfad | Rolle |
|-----|------|-------|
| `appRobotHoming` | `scripts/robot_<ts>.json` (Env: `ROBOT_JSON`) | Lesen + Schreiben (editRobot.js) |
| `appRobotRendering` | `data/robot/robot.json` | Lesen (Rendering) |
| `appRobotDriver` | — | Armlängen hardcodiert (`250, 264, 100`) |
Es gibt keine Versionierung, keinen Single Point of Truth, und wenn Homing die Geometrie kalibriert, bekommen Driver und Rendering davon nichts mit.
---
## Ziel-Architektur
`appRobotDriver` ist das **Zentrum des Systems** (er steuert die Hardware). Er besitzt daher auch die `robot.json` und stellt sie über einen REST-Endpunkt bereit. Alle anderen Apps lesen/schreiben ausschließlich über diesen Endpunkt.
```
appRobotDriver
└── data/robot/
├── robot.json ← aktueller Stand (Single Source of Truth)
├── robot_20260101_143522.json ← Snapshot vor jeder Änderung
└── robot_20260115_091010.json
appRobotHoming ──GET/PUT──► appRobotDriver :2098/api/robot
appRobotRendering ──GET──────► appRobotDriver :2098/api/robot
appRobotSimulation ──GET────► appRobotDriver :2098/api/robot
```
Der InfoServer (Port 2098) bekommt die neuen Endpunkte. Der WebSocket-Server (Port 2095) bleibt unverändert.
---
## API-Design
Alle Endpunkte unter dem bereits laufenden InfoServer auf Port 2098.
```
GET /api/robot → robot.json als JSON (kein Auth nötig)
PUT /api/robot → ersetzt robot.json, legt vorher Snapshot an (Auth nötig)
GET /api/robot/history → Liste aller Snapshots [{ filename, timestamp }]
GET /api/robot/history/:ts → einen bestimmten Snapshot abrufen
```
### Sicherheit (trivial, da Netz sicher)
PUT-Requests brauchen einen statischen API-Key als HTTP-Header:
```
Authorization: Bearer <ROBOT_API_KEY>
```
Key wird per Umgebungsvariable konfiguriert (`ROBOT_API_KEY`). Fehlt die Variable, wird ein zufälliger Key generiert und beim Start geloggt. Für GET braucht es keinen Key — Lesen ist überall erlaubt.
Das ist die einzige Absicherung. Kein JWT, keine Sessions, kein Rate-Limiting — Netz ist sicher, und ein versehentlicher Schreib-Request aus einem Browser soll trotzdem nicht funktionieren.
---
## Status
| Schritt | Bereich | Status |
|---------|---------|--------|
| 1 — Datei anlegen | appRobotDriver | ✅ erledigt |
| 2 — RobotConfigService | appRobotDriver | ✅ erledigt |
| 3 — Registrierung InfoServer | appRobotDriver | ✅ erledigt |
| 4 — Driver liest Armlängen | appRobotDriver | ✅ erledigt |
| 5 — appRobotHoming umstellen | appRobotHoming | ⬜ offen |
| 6 — appRobotRendering umstellen | appRobotRendering | ⬜ offen |
| 7 — Aufräumen | alle Repos | ⬜ offen |
Die Schritte 14 betreffen ausschließlich `appRobotDriver` und sind vollständig umgesetzt.
Schritte 57 betreffen `appRobotHoming` und `appRobotRendering` — separate Tickets/Sessions.
---
## Umsetzungsschritte
### Schritt 1 — Datei anlegen (appRobotDriver) ✅
- Verzeichnis `data/robot/` anlegen
- Default-`robot.json` wird vom Nutzer geliefert und dort abgelegt
- `.gitignore`-Eintrag für `data/robot/robot_*.json` (Snapshots gehören nicht ins Repo, `robot.json` selbst schon)
- Driver startet mit Defaults (`l1: 250, l2: 264, l3: 100`) wenn Datei fehlt, loggt Warnung
### Schritt 2 — RobotConfigService (appRobotDriver) ✅
Neue, **in sich geschlossene** Datei `server/RobotConfigService.js`. Sie hat keine Abhängigkeiten auf andere Teile des Drivers und kann in jeden Express- oder `https.createServer`-basierten Server mit einer Zeile eingehängt werden:
```js
const robotConfigService = require('./server/RobotConfigService');
robotConfigService.register(app, { apiKey: process.env.ROBOT_API_KEY });
// fertig — alle /api/robot*-Routen sind registriert
```
Das Modul kapselt intern:
```
readRobotJson() → Promise<object>
writeRobotJson(data) → Promise<{ snapshotFile }> // legt robot_<ts>.json an + pruning
listHistory() → Promise<{ filename, day, timestamp }[]>
readSnapshot(ts) → Promise<object>
pruneSnapshots() → löscht überschüssige Snapshots (s. Regel unten)
```
**Snapshot-Pruning-Regel:** Pro Tag maximal 100 Snapshots. Sind es mehr, wird nur der neueste des Tages behalten. Diese Bereinigung läuft automatisch nach jedem Schreibvorgang.
Timestamp-Format: `YYYYMMDD_HHmmss` (konsistent mit appRobotHoming).
**API-Key:** Wird per Option übergeben. Fehlt der Key (undefined), generiert das Modul beim ersten Start einen zufälligen Key, loggt ihn einmalig und speichert ihn in `data/robot/.apikey` (nicht im Repo). So funktioniert es ohne Konfiguration, ist aber trotzdem nicht offen.
### Schritt 3 — Registrierung in InfoServer.js (appRobotDriver) ✅
Eine Zeile in `server/InfoServer.js` am Anfang der Request-Handler:
```js
robotConfigService.register(httpsServer, { apiKey });
```
Da `InfoServer.js` kein Express nutzt (rohes `https.createServer`), bekommt `RobotConfigService` intern einen minimalen Router, der url-Matching selbst macht — oder `InfoServer.js` wird auf Express umgestellt (kleiner Schritt, bringt mehr Flexibilität für spätere Endpunkte).
### Schritt 4 — Driver liest Armlängen aus robot.json ✅
`startRobot.js` liest beim Start arm-lengths aus `data/robot/robot.json`.
Fallback auf `{ l1: 250, l2: 264, l3: 100 }` mit Log-Warnung wenn Datei fehlt oder Keys fehlen.
```js
// links.Arm1.size[1] → l1
// links.Arm2.size[1] → l2
// links.Ellbow.size[0] → l3
```
### Schritt 5 — appRobotHoming auf Driver-API umstellen ⬜
`server/server.js` in appRobotHoming:
- `ROBOT_JSON`-Env-Variable durch `ROBOT_DRIVER_URL` ersetzen (z.B. `https://appRobotDriver:2098`)
- Neue Hilfsfunktionen `fetchRobotJson()` / `pushRobotJson(data)` (HTTP GET/PUT mit API-Key)
- Alle `fsPromises.readFile(ROBOT_JSON)`-Aufrufe durch `await fetchRobotJson()` ersetzen
- Nach jeder editRobot.js-Transformation: `await pushRobotJson(updated)`
- `editRobot.js` bleibt unverändert (pure Transformationen, kein File-IO)
### Schritt 6 — appRobotRendering auf Driver-API umstellen ⬜
`robot.json` wird einmalig beim Start vom Driver geholt und gecacht. Cache wird per `GET /api/robot` aktualisierbar. Env-Variable `ROBOT_DRIVER_URL`.
### Schritt 7 — Aufräumen ⬜
- Lokale `robot.json`-Kopien in appRobotHoming und appRobotRendering entfernen
- `.gitignore`-Einträge in den betroffenen Repos anpassen
- `ROBOT_JSON`-Env-Variable aus docker-compose-Dateien entfernen, `ROBOT_DRIVER_URL` hinzufügen
---
## Entschiedene Punkte
| Frage | Entscheidung |
|-------|-------------|
| Driver-Start ohne robot.json | Mit Defaults starten + Warnung loggen |
| Snapshot-Limit | Max. 100 pro Tag; danach pro Tag nur die letzte Version behalten |
| Caching in Clients | Ja, erlaubt — Ziel ist API-only-Zugriff, kein direktes File-IO |
| Port InfoServer | 2098 bleibt; weitere Endpunkte werden dort angehängt |
---
## Nicht in dieser Roadmap
- Konflikte bei gleichzeitigen Schreibzugriffen (mutex) — vorerst nicht nötig, Homing ist der einzige Schreiber
- Diff-Anzeige zwischen Snapshots
- Rollback-Endpunkt (kann manuell über `GET /api/robot/history/:ts` + `PUT /api/robot` gemacht werden)

View File

@@ -1,20 +1,194 @@
# ToDo 3 — Konfiguration
## Ziel der Verbesserung
## Ausgangslage
Zentralisierte Konfiguration statt verstreuter Hardcodierung. Konfiguration soll transparent, testbar und leicht anpassbar sein.
Konfiguration ist aktuell über drei Orte verstreut:
## Aufgaben
| Ort | Beispiele |
|-----|-----------|
| Hardcodiert im Code | Controller-Ports (2300, 5000), Passphrase `'abcd'` |
| Env-Variablen | `GRBL_BASE_IP`, `ROBOT_DEFAULT_FEEDRATE`, `PORT`, … |
| `robot.json` | Geometrie, Rendering-Parameter, Pose-Estimation-Optionen |
- [ ] `config.js` oder ein zentrales Config-Modul anlegen
- [ ] Alle Umgebungsvariablen an einer Stelle lesen und validieren
- `PORT`
- `GRBL_BASE_IP`, `GRBL_ELLBOW_IP`, `GRBL_HAND_IP`
- `ROBOT_DEFAULT_FEEDRATE`
- `ROBOT_USE_SPEED_CALC`
- HTTPS-Zertifikatpfade und Passphrase
- [ ] `startRobot.js`, `TelnetSenderGRBL`, `InfoServer.js` und weitere Module mit dem Config-Modul arbeiten lassen
- [ ] Optional: `config/default.json` oder `.env` als Konfigurationsbasis bereitstellen
- [ ] Fehlende oder ungültige Konfiguration frühzeitig mit klarer Fehlermeldung melden
- [ ] HTTPS-Passphrase aus Umgebungsvariable lesen statt hardcoded `'abcd'` in `startRobot.js`
- [ ] `logs/`-Verzeichnis beim Start automatisch anlegen (aktuell crash wenn nicht vorhanden — siehe `doc/ToDo_8_Bugs.md` Bug 4)
Ergebnis: Wer einen Roboter in einer anderen Umgebung betreibt, muss Code lesen, um zu wissen was anzupassen ist. Und es ist unklar, welches Programm für welchen Abschnitt zuständig ist.
---
## Architektur-Entscheidung
**`robot.json` ist die einzige Wahrheitsquelle für alles, was roboter-spezifisch ist.**
Eine robot.json beschreibt einen konkreten Roboter vollständig — sie kann weitergegeben werden, wenn der Roboter in einer anderen Umgebung eingesetzt wird.
**Env-Variablen bleiben nur für deployment-spezifische Werte,** die nichts mit dem Roboter selbst zu tun haben:
| Variable | Grund |
|----------|-------|
| `PORT` (2095/2098) | Server-Port ändert sich je Deployment, nicht je Roboter |
| `HTTPS_KEY_PATH`, `HTTPS_CERT_PATH`, `HTTPS_PASSPHRASE` | Sicherheits-Infrastruktur |
| `ROBOT_API_KEY` | Geheimnis, darf nie in einer weitergebbaren Datei stehen |
---
## Änderungen an `robot.json`
### 1 — Kinematik-Typ (`kinematics`)
Die Kinematik-Parameter (Armlängen, Achsen, Gelenk-Kette) sind bereits vollständig in `links` enthalten und werden von `robot/RobotConfig.js` daraus abgeleitet. Einzig der Name des Solver-Algorithmus fehlt noch:
```json
"kinematics": {
"_owner": "appRobotDriver",
"type": "arm3segmentlinearx"
}
```
Kein Duplizieren von `links`-Daten. `RobotConfig.js` liest z.B.:
- `links.Arm1.skeleton.to[1]` → l1
- `links.Ellbow.skeleton.to[0]` → l3
### 2 — Feedrate und Controller-Zuordnung in `jointToParent`
Beide Infos gehören direkt zum Gelenk, weil sie die Hardware-Eigenschaft eines konkreten Joints beschreiben:
```json
"jointToParent": {
"name": "Slider",
"type": "linear",
"axis": [1, 0, 0],
"origin": [0, 0, 16],
"variable": "x",
"feedrate": 2000,
"controller": "base"
}
```
- `feedrate` — maximale Vorschubgeschwindigkeit dieses Joints in mm/min (linear) oder °/min (revolute). Überschreibt den globalen Default aus `motion.defaultFeedrate`.
- `controller` — Verweis auf den Schlüssel in `controllers`. Darüber weiss der Driver, welche Variable auf welchem Controller-Kanal liegt. Die heutigen hardcodierten Achszuordnungen (`'x','y','z'` / `'a',null,null` / …) werden damit überflüssig.
### 3 — Controller-Endpunkte (`controllers`)
Top-Level-Abschnitt, nur IP und Port. Die Achszuordnung ergibt sich aus den `controller`-Verweisen in den Joints (kein Duplikat).
```json
"controllers": {
"_owner": "appRobotDriver",
"base": { "ip": "fluidNcBase.local", "port": 2300, "protocol": "telnet" },
"elbow": { "ip": "fluidNcEllbow.local", "port": 5000, "protocol": "telnet" },
"hand": { "ip": "fluidNcHand.local", "port": 5000, "protocol": "telnet" }
}
```
Der Driver liest `controllers` für IP/Port, scannt dann `links` nach `jointToParent.controller === "base"` und bekommt so `["x", "y", "z"]` als Achsliste.
### 4 — Globale Bewegungs-Defaults (`motion`)
Werte, die für alle Joints gelten, sofern kein `feedrate` im Joint hinterlegt ist. Ausserdem Software-Flags, die keine natürliche Heimat in einem einzelnen Joint haben:
```json
"motion": {
"_owner": "appRobotDriver",
"defaultFeedrate": 2300,
"speedMode": "legacy",
"speedModeOptions": ["legacy", "correct"]
}
```
`speedMode` und `defaultFeedrate` lösen die Env-Vars `ROBOT_DEFAULT_FEEDRATE` und `ROBOT_SPEED_MODE` ab.
---
## Interne Abhängigkeiten in `robot.json`
Mit `controller`-Verweisen in den Joints entstehen interne Cross-References. Die robot.json darf solche haben — das ist bewusste Design-Entscheidung, die Redundanz vermeidet. Ein späterer `validator.py` muss diese Konsistenz prüfen:
| Referenz | Regel |
|----------|-------|
| `jointToParent.controller``controllers.*` | Jeder Verweis muss auf einen existierenden Controller-Schlüssel zeigen |
| Marker-IDs | Jede ID darf global nur einmal vergeben sein |
| `parent` in `links` | Muss auf einen existierenden Link zeigen, kein Zyklus |
---
## Verantwortlichkeits-Tabelle
Jeder Abschnitt hat genau einen **Eigentümer** (das Programm, das diesen Abschnitt schreiben darf). Konsumenten lesen nur.
| Abschnitt | Eigentümer (`_owner`) | Konsumenten |
|-----------|----------------------|-------------|
| `kinematics` | **appRobotDriver** | appRobotDriver (via RobotConfig.js) |
| `motion` | **appRobotDriver** | appRobotDriver |
| `controllers` | **appRobotDriver** | appRobotDriver |
| `units` | **appRobotDriver** | alle |
| `links` (inkl. `jointToParent.feedrate`, `.controller`) | **appRobotDriver** — Homing darf Marker-Positionen/-Normalen aktualisieren (via PUT /api/robot), aber keine Driver-Felder (`feedrate`, `controller`) verändern | alle |
| `vision_config` | — | appRobotHoming |
| `constraint_rules`, `observation_weighting`, `multiview_calculation`, `pose_estimation`, `state_pose_params` | — | appRobotHoming |
| `renderingInfo` | — | appRobotRendering |
| `robot_test_poses`, `test_camera_positions/targets` | — | appRobotHoming, appRobotRendering |
| `defaultPosition` | — | appRobotDriver, appRobotRendering |
| `coordinateSystem` | — | alle |
Sektionen ohne `_owner` sind manuell konfiguriert und werden von keinem Programm automatisch überschrieben. Der Validator prüft, dass Homing in `links` nur die erlaubten Felder (`position`, `normal`, `spin`, `size` in Markern) verändert.
---
## Zugriffsmuster
### appRobotDriver — neues Modul `robot/RobotConfig.js`
Der Driver liest robot.json synchron beim Start (direkt vom Disk, vor dem HTTP-Server-Start). `RobotConfig.js` ist der einzige Ort im Driver-Code, der robot.json kennt:
```js
const cfg = RobotConfig.load(); // synchron, gibt typisierten Record zurück
cfg.kinematics.type // → 'arm3segmentlinearx'
cfg.kinematics.l1 // → 250 (abgeleitet aus links.Arm1.skeleton)
cfg.motion.defaultFeedrate // → 2300
cfg.controllers // → { base: {ip, port}, elbow: {ip, port}, hand: {ip, port} }
cfg.axesByController('base') // → ['x', 'y', 'z'] (abgeleitet aus links)
```
Alle `process.env`-Lesungen für roboter-spezifische Werte wandern hierher. Env-Vars bleiben als Override-Ebene (Env hat Vorrang vor robot.json — nützlich für Tests und schnelle Korrekturen ohne Datei-Änderung).
### Alle anderen Apps — HTTP GET
```js
const robot = await fetch('https://appRobotDriver:2098/api/robot').then(r => r.json());
```
Kein eigenes Zugriffs-Modul nötig.
---
## Umsetzungsschritte
### ✅ Schritt 1 — `robot.json` erweitern
- Abschnitte `kinematics`, `motion`, `controllers` eintragen
- In jedem `jointToParent`: `feedrate` und `controller` ergänzen
- Hinweis: `controllers` enthält explizite `axes`-Reihenfolge (Baum-Traversal liefert für Hand-Controller falsche Reihenfolge)
- Beide Kopien aktualisiert: `appRobotRendering/data/robot/robot.json` und `appRobotDriver/data/robot/robot.json`
### ✅ Schritt 2 — `robot/RobotConfig.js` anlegen
- Liest robot.json synchron
- Leitet l1/l2/l3 aus `links.*.skeleton.to` ab (behebt alten Bug: `Ellbow.size[0]` existierte nicht)
- Gibt `axesByController(key)` aus `controllers[key].axes` zurück
- Gibt typisierten Record mit Fallbacks zurück
- Env-Override-Ebene: GRBL_*_IP, ROBOT_DEFAULT_FEEDRATE, ROBOT_SPEED_MODE, ROBOT_USE_SPEED_CALC
### ✅ Schritt 3 — `startRobot.js` und `RobotBase.js` umstellen
- `startRobot.js`: `readArmLengthsFromConfig` entfernt, ersetzt durch `RobotConfig.load()`; Controller-Setup config-getrieben (keine hardcodierten IPs/Ports/Achsen mehr)
- `RobotBase.js`: akzeptiert optionalen `config`-Parameter im Konstruktor; Env-Vars bleiben als Fallback
- `Arm3SegmentLinearX` und `Arm3SegmentRotaryBase`: reichen `config`/`params` an `super()` durch
### ✅ Schritt 4 — HTTPS-Passphrase aus Env-Variable
`HTTPS_PASSPHRASE` lesen statt hardcodiertem `'abcd'`. Default bleibt `'abcd'` für lokale Entwicklung.
### ✅ Schritt 5 — `logs/`-Verzeichnis automatisch anlegen
`fsModule.mkdirSync?.('logs', { recursive: true })` in `startRobot.js` nach erfolgreichem HTTPS-Load. Bestehende `ensureLogDir()` in `InputWS.js` bleibt als zweite Absicherung.
---
## Nicht in dieser ToDo
- JSON-Schema-Validierung (separates Ticket, koordiniert mit dem geplanten `validator.py`)
- Laufzeit-Reload von robot.json ohne Neustart
- `speedMode` Details → ToDo 6a

View File

@@ -10354,3 +10354,18 @@
2026-06-10T21:18:23.947Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-10T21:18:24.131Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-10T21:18:24.377Z ::ffff:127.0.0.1: G1 X1
2026-06-11T18:35:07.352Z ::ffff:127.0.0.1: M114
2026-06-11T18:35:07.382Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-11T18:35:07.818Z ::ffff:127.0.0.1: M114
2026-06-11T18:35:08.029Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-11T18:35:08.254Z ::ffff:127.0.0.1: G1 X1
2026-06-11T18:37:29.305Z ::ffff:127.0.0.1: M114
2026-06-11T18:37:29.323Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-11T18:37:29.955Z ::ffff:127.0.0.1: M114
2026-06-11T18:37:30.191Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-11T18:37:30.431Z ::ffff:127.0.0.1: G1 X1
2026-06-11T19:43:39.299Z ::ffff:127.0.0.1: M114
2026-06-11T19:43:39.339Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-11T19:43:39.506Z ::ffff:127.0.0.1: M114
2026-06-11T19:43:39.745Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-11T19:43:40.028Z ::ffff:127.0.0.1: G1 X1

View File

@@ -14616,3 +14616,9 @@
2026-06-10T21:03:42.257Z ::ffff:127.0.0.1 : Ping
2026-06-10T21:18:23.643Z ::ffff:127.0.0.1 : Ping
2026-06-10T21:18:23.899Z ::ffff:127.0.0.1 : Ping
2026-06-11T18:35:07.333Z ::ffff:127.0.0.1 : Ping
2026-06-11T18:35:07.597Z ::ffff:127.0.0.1 : Ping
2026-06-11T18:37:29.284Z ::ffff:127.0.0.1 : Ping
2026-06-11T18:37:29.724Z ::ffff:127.0.0.1 : Ping
2026-06-11T19:43:39.197Z ::ffff:127.0.0.1 : Ping
2026-06-11T19:43:39.241Z ::ffff:127.0.0.1 : Ping

View File

@@ -21,18 +21,19 @@ const MotorPosition = require('./RobotMotorPosition.js')
class RobotBase{
constructor() {
// Umgebungsvariablen-Logik
const DEFAULT_FEEDRATE = process.env.ROBOT_DEFAULT_FEEDRATE ?
Number(process.env.ROBOT_DEFAULT_FEEDRATE) : 1000;
constructor(config = {}) {
// config-Werte haben Vorrang, Env-Variablen als Fallback (Kompatibilität).
const DEFAULT_FEEDRATE = config.defaultFeedrate
?? (process.env.ROBOT_DEFAULT_FEEDRATE ? Number(process.env.ROBOT_DEFAULT_FEEDRATE) : 1000);
// Speed-Regelung-Schalter: 'legacy' (Default — exakt wie bisher) oder 'correct'.
// Siehe doc/ToDo_6a_Speed.md.
this.speedMode = (process.env.ROBOT_SPEED_MODE || 'legacy').toLowerCase();
this.speedMode = (config.speedMode || process.env.ROBOT_SPEED_MODE || 'legacy').toLowerCase();
// ROBOT_USE_SPEED_CALC bleibt der interne Schalter für calculateSpeeds();
// der Korrekt-Modus aktiviert die Berechnung automatisch.
this.useSpeedCalc = this.speedMode === 'correct' ||
process.env.ROBOT_USE_SPEED_CALC === 'true' ||
process.env.ROBOT_USE_SPEED_CALC === '1';
this.useSpeedCalc = config.useSpeedCalc
?? (this.speedMode === 'correct' ||
process.env.ROBOT_USE_SPEED_CALC === 'true' ||
process.env.ROBOT_USE_SPEED_CALC === '1');
/** @type {number} Bewegungszeit des letzten Schritts in Minuten (für koordinierte Feedrate) */
this.lastMoveTime = 0;

101
robot/RobotConfig.js Normal file
View File

@@ -0,0 +1,101 @@
'use strict';
const ROBOT_JSON_PATH = 'data/robot/robot.json';
// Backward-compat env-var names für Controller-IPs (werden in Env als GRBL_ELLBOW_IP gespeichert)
const ENV_IP_MAP = {
base: 'GRBL_BASE_IP',
elbow: 'GRBL_ELLBOW_IP',
hand: 'GRBL_HAND_IP'
};
const DEFAULTS = {
kinematics: { type: 'arm3segmentlinearx', l1: 250, l2: 264, l3: 100 },
motion: { defaultFeedrate: 1000, speedMode: 'legacy', useSpeedCalc: false },
controllers: {
base: { ip: 'fluidNcBase.local', port: 2300, protocol: 'telnet', axes: ['x', 'y', 'z'] },
elbow: { ip: 'fluidNcEllbow.local', port: 5000, protocol: 'telnet', axes: ['a', null, null] },
hand: { ip: 'fluidNcHand.local', port: 5000, protocol: 'telnet', axes: ['c', 'e', 'b'] }
}
};
function deriveKinematicParams(links) {
if (!links) return {};
const result = {};
const l1raw = links.Arm1?.skeleton?.to?.[1];
const l2raw = links.Arm2?.skeleton?.to?.[1];
const l3raw = links.Ellbow?.skeleton?.to?.[0];
if (l1raw != null) result.l1 = Math.abs(l1raw);
if (l2raw != null) result.l2 = Math.abs(l2raw);
if (l3raw != null) result.l3 = l3raw;
return result;
}
/**
* Liest robot.json synchron und gibt einen typisierten Config-Record zurück.
* Env-Variablen haben Vorrang vor robot.json (nützlich für Tests und Notfall-Overrides).
*
* @param {Object} [fsModule] - Abhängigkeit (Default: require('fs'))
* @param {Object} [processEnv] - Abhängigkeit (Default: process.env)
* @param {Object} [consoleObj] - Abhängigkeit (Default: console)
* @returns {{ kinematics, motion, controllers, axesByController }}
*/
function load(fsModule, processEnv, consoleObj) {
const fs_ = fsModule ?? require('fs');
const env_ = processEnv ?? process.env;
const log_ = consoleObj ?? console;
let json = null;
try {
const raw = fs_.readFileSync(ROBOT_JSON_PATH, 'utf8');
json = JSON.parse(raw);
} catch {
log_.warn('[RobotConfig] data/robot/robot.json nicht lesbar — nutze Defaults');
}
// Kinematik-Typ und Armlängen (aus links abgeleitet)
const linkParams = deriveKinematicParams(json?.links);
const kinematics = {
type: json?.kinematics?.type ?? DEFAULTS.kinematics.type,
l1: linkParams.l1 ?? DEFAULTS.kinematics.l1,
l2: linkParams.l2 ?? DEFAULTS.kinematics.l2,
l3: linkParams.l3 ?? DEFAULTS.kinematics.l3
};
// Bewegungs-Defaults — Env hat Vorrang, dann robot.json, dann Hard-Default
const jsonMotion = json?.motion ?? {};
const rawFeedrate = env_.ROBOT_DEFAULT_FEEDRATE;
const rawMode = env_.ROBOT_SPEED_MODE;
const rawCalc = env_.ROBOT_USE_SPEED_CALC;
const speedMode = (rawMode || jsonMotion.speedMode || DEFAULTS.motion.speedMode).toLowerCase();
const motion = {
defaultFeedrate: rawFeedrate
? Number(rawFeedrate)
: (jsonMotion.defaultFeedrate ?? DEFAULTS.motion.defaultFeedrate),
speedMode,
useSpeedCalc: speedMode === 'correct' || rawCalc === 'true' || rawCalc === '1'
};
// Controller-Endpunkte — robot.json überschreibt Defaults, Env überschreibt IPs
const jsonControllers = json?.controllers ?? {};
const controllers = {};
for (const key of Object.keys(DEFAULTS.controllers)) {
const def = DEFAULTS.controllers[key];
const cfg = jsonControllers[key] ?? {};
const envIpKey = ENV_IP_MAP[key];
controllers[key] = {
ip: env_[envIpKey] ?? cfg.ip ?? def.ip,
port: cfg.port ?? def.port,
protocol: cfg.protocol ?? def.protocol,
axes: cfg.axes ?? def.axes
};
}
function axesByController(key) {
return controllers[key]?.axes ?? [];
}
return { kinematics, motion, controllers, axesByController };
}
module.exports = { load, DEFAULTS };

View File

@@ -20,9 +20,10 @@ class Arm3SegmentLinearX extends RobotBase {
* @param {number} l1 Länge des Oberarms in mm
* @param {number} l2 Länge des Unterarms in mm
* @param {number} l3 Länge der Hand (Endeffector) in mm
* @param {Object} [config] Bewegungs-Config (defaultFeedrate, speedMode, …)
*/
constructor(l1, l2, l3) {
super();
constructor(l1, l2, l3, config = {}) {
super(config);
/** @type {number} Länge des Oberarms in mm */
this.l1 = l1;

View File

@@ -61,7 +61,7 @@ class Arm3SegmentRotaryBase extends RobotBase {
* @param {number} [params.baseHeight=110] Höhe der Schulterachse über der Basis in mm
*/
constructor(l1 = 105, l2 = 98, l3 = 100, params = {}) {
super();
super(params);
/** @type {number} Länge des Oberarms in mm */
this.l1 = l1;

View File

@@ -1,102 +1,86 @@
// server/InfoServer.js
const fs = require('fs');
const https = require('https');
'use strict';
function createInfoServer(httpsOptions, sharedState, robot, GCode, senders) {
return https.createServer(httpsOptions, (req, res) => {
const express = require('express');
const https = require('https');
const path = require('path');
const robotConfigService = require('./RobotConfigService');
if (req.url === '/') {
return serveFile(res, './public/index.html', 'text/html');
}
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
if (req.url === '/app.js') {
return serveFile(res, './public/app.js', 'application/javascript');
}
function createInfoServer(httpsOptions, sharedState, robot, GCode, senders, options = {}) {
const app = express();
if (req.url === '/style.css') {
return serveFile(res, './public/style.css', 'text/css');
}
// ── Statische Dateien ────────────────────────────────────────────────────
const staticFile = (file) => (req, res) =>
res.sendFile(path.join(PUBLIC_DIR, file), err => {
if (err) res.status(404).end('Not found');
});
if (req.url === '/allApps.css') {
return serveFile(res, './public/allApps.css', 'text/css');
}
app.get('/', staticFile('index.html'));
app.get('/app.js', staticFile('app.js'));
app.get('/style.css', staticFile('style.css'));
app.get('/allApps.css', staticFile('allApps.css'));
/* ---------- API ---------- */
if (req.url === '/api/status') {
const sendersStatus = senders.map(({ name, instance }) => {
const status = instance?.getStatus ? instance.getStatus() : {
state: instance?.isTestMode ? 'connected' : instance?.tSocket ? 'connected' : 'disconnected',
url: instance?.url || null,
error: instance?.error || null,
isTestMode: !!instance?.isTestMode,
reconnectAttempt: instance?.reconnectAttempt || 0,
reconnectTimer: !!instance?.reconnectTimer
};
const state = status.state || (instance?.tSocket ? 'connected' : 'disconnected');
const health = state === 'connected'
? 'ok'
: state === 'reconnecting'
? 'warning'
: 'disconnected';
const reason = state === 'disconnected'
? status.error || 'no active socket connection'
: undefined;
return {
name,
state,
url: status.url || null,
isTestMode: !!status.isTestMode,
error: status.error || null,
reconnectAttempt: status.reconnectAttempt || 0,
reconnectTimer: !!status.reconnectTimer,
health,
reason
};
});
const connectedSenders = sendersStatus.filter(s => s.health === 'ok').length;
const health = {
ok: sendersStatus.length > 0 && sendersStatus.every(s => s.health === 'ok'),
connectedSenders,
totalSenders: sendersStatus.length
// ── API ──────────────────────────────────────────────────────────────────
app.get('/api/status', (req, res) => {
const sendersStatus = senders.map(({ name, instance }) => {
const status = instance?.getStatus ? instance.getStatus() : {
state: instance?.isTestMode ? 'connected' : instance?.tSocket ? 'connected' : 'disconnected',
url: instance?.url || null,
error: instance?.error || null,
isTestMode: !!instance?.isTestMode,
reconnectAttempt: instance?.reconnectAttempt || 0,
reconnectTimer: !!instance?.reconnectTimer
};
const status = {
generatedAt: new Date().toISOString(),
const state = status.state || (instance?.tSocket ? 'connected' : 'disconnected');
const health = state === 'connected' ? 'ok'
: state === 'reconnecting' ? 'warning'
: 'disconnected';
const reason = state === 'disconnected'
? status.error || 'no active socket connection'
: undefined;
return {
name,
state,
url: status.url || null,
isTestMode: !!status.isTestMode,
error: status.error || null,
reconnectAttempt: status.reconnectAttempt || 0,
reconnectTimer: !!status.reconnectTimer,
health,
clients: sharedState.connectedClients,
senders: sendersStatus,
lastCommands: sharedState.lastCommands,
lastPings: sharedState.lastPings
reason
};
});
res.writeHead(200, {'Content-Type': 'application/json'});
return res.end(JSON.stringify(status));
}
if (req.url === '/api/position') {
res.writeHead(200, {'Content-Type': 'application/json'});
return res.end(GCode.getM114(robot));
}
res.writeHead(404);
res.end('Not found');
const connectedSenders = sendersStatus.filter(s => s.health === 'ok').length;
res.json({
generatedAt: new Date().toISOString(),
health: {
ok: sendersStatus.length > 0 && sendersStatus.every(s => s.health === 'ok'),
connectedSenders,
totalSenders: sendersStatus.length
},
clients: sharedState.connectedClients,
senders: sendersStatus,
lastCommands: sharedState.lastCommands,
lastPings: sharedState.lastPings
});
});
}
/* ---------- Helper ---------- */
function serveFile(res, path, type) {
fs.readFile(path, (err, data) => {
if (err) {
res.writeHead(404);
return res.end('Not found');
}
res.writeHead(200, {'Content-Type': type});
res.end(data);
app.get('/api/position', (req, res) => {
res.json(JSON.parse(GCode.getM114(robot)));
});
// ── Robot-Config-Service ─────────────────────────────────────────────────
robotConfigService.register(app, { apiKey: options.apiKey });
// ── 404 ──────────────────────────────────────────────────────────────────
app.use((req, res) => res.status(404).end('Not found'));
return https.createServer(httpsOptions, app);
}
module.exports = createInfoServer;

View File

@@ -0,0 +1,176 @@
// server/RobotConfigService.js
//
// Self-contained Express-Modul für die robot.json-Verwaltung.
// Einbinden mit einer Zeile:
//
// const robotConfigService = require('./RobotConfigService');
// robotConfigService.register(app, { apiKey: process.env.ROBOT_API_KEY });
//
// Routen:
// GET /api/robot → aktuelle robot.json
// PUT /api/robot → überschreibt robot.json, legt Snapshot an (Auth)
// GET /api/robot/history → Liste aller Snapshots
// GET /api/robot/history/:ts → einen bestimmten Snapshot abrufen
'use strict';
const express = require('express');
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const crypto = require('crypto');
const DATA_DIR = path.join(__dirname, '..', 'data', 'robot');
const ROBOT_JSON = path.join(DATA_DIR, 'robot.json');
const KEY_FILE = path.join(DATA_DIR, '.apikey');
// ── Timestamp ────────────────────────────────────────────────────────────────
function makeTimestamp() {
const now = new Date();
const p = (n, w = 2) => String(n).padStart(w, '0');
return `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}_${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
}
// ── API-Key-Auflösung ────────────────────────────────────────────────────────
function resolveApiKey(provided) {
if (provided) return provided;
try {
return fs.readFileSync(KEY_FILE, 'utf8').trim();
} catch {
fs.mkdirSync(DATA_DIR, { recursive: true });
const key = crypto.randomBytes(24).toString('hex');
fs.writeFileSync(KEY_FILE, key, 'utf8');
console.log(`[RobotConfigService] Kein ROBOT_API_KEY gesetzt — neuer Key generiert: ${key}`);
console.log(`[RobotConfigService] Gespeichert in: ${KEY_FILE}`);
return key;
}
}
// ── Datei-Operationen ────────────────────────────────────────────────────────
async function readRobotJson() {
const raw = await fsp.readFile(ROBOT_JSON, 'utf8');
return JSON.parse(raw);
}
async function writeRobotJson(data) {
await fsp.mkdir(DATA_DIR, { recursive: true });
const ts = makeTimestamp();
const snapshotFile = `robot_${ts}.json`;
try {
const current = await fsp.readFile(ROBOT_JSON, 'utf8');
await fsp.writeFile(path.join(DATA_DIR, snapshotFile), current, 'utf8');
} catch {
// Noch kein robot.json vorhanden — kein Snapshot nötig
}
await fsp.writeFile(ROBOT_JSON, JSON.stringify(data, null, 2), 'utf8');
await pruneSnapshots();
return { snapshotFile };
}
async function listHistory() {
try {
const files = await fsp.readdir(DATA_DIR);
return files
.filter(f => /^robot_\d{8}_\d{6}\.json$/.test(f))
.sort()
.reverse()
.map(f => ({ filename: f, day: f.slice(6, 14), timestamp: f.slice(15, 21) }));
} catch {
return [];
}
}
async function readSnapshot(ts) {
const raw = await fsp.readFile(path.join(DATA_DIR, `robot_${ts}.json`), 'utf8');
return JSON.parse(raw);
}
// ── Pruning ──────────────────────────────────────────────────────────────────
//
// Regel: Pro Tag maximal 100 Snapshots.
// Hat ein Tag mehr als 100, wird nur der neueste behalten.
async function pruneSnapshots() {
let files;
try { files = await fsp.readdir(DATA_DIR); } catch { return; }
const byDay = {};
for (const f of files) {
if (!/^robot_\d{8}_\d{6}\.json$/.test(f)) continue;
const day = f.slice(6, 14);
if (!byDay[day]) byDay[day] = [];
byDay[day].push(f);
}
for (const [day, dayFiles] of Object.entries(byDay)) {
if (dayFiles.length <= 100) continue;
dayFiles.sort(); // älteste zuerst
const toDelete = dayFiles.slice(0, -1); // neueste behalten
for (const f of toDelete) {
try { await fsp.unlink(path.join(DATA_DIR, f)); } catch { /* ignorieren */ }
}
console.log(`[RobotConfigService] Tag ${day}: ${toDelete.length} Snapshots bereinigt, behalten: ${dayFiles[dayFiles.length - 1]}`);
}
}
// ── Auth ─────────────────────────────────────────────────────────────────────
function isAuthorized(req, key) {
return (req.headers['authorization'] ?? '') === `Bearer ${key}`;
}
// ── Registrierung ─────────────────────────────────────────────────────────────
function register(app, options = {}) {
const apiKey = resolveApiKey(options.apiKey);
const router = express.Router();
router.use(express.json({ limit: '5mb' }));
router.get('/api/robot/history', async (req, res) => {
try {
return res.json({ history: await listHistory() });
} catch (err) {
return res.status(500).json({ error: String(err) });
}
});
router.get('/api/robot/history/:ts', async (req, res) => {
try {
return res.json(await readSnapshot(req.params.ts));
} catch {
return res.status(404).json({ error: 'Snapshot nicht gefunden' });
}
});
router.get('/api/robot', async (req, res) => {
try {
return res.json(await readRobotJson());
} catch {
return res.status(404).json({ error: 'robot.json nicht gefunden' });
}
});
router.put('/api/robot', async (req, res) => {
if (!isAuthorized(req, apiKey)) {
return res.status(401).json({ error: 'Unauthorized' });
}
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
return res.status(400).json({ error: 'Body muss ein JSON-Objekt sein' });
}
try {
const result = await writeRobotJson(req.body);
return res.json({ ok: true, ...result });
} catch (err) {
return res.status(500).json({ error: String(err) });
}
});
app.use(router);
}
module.exports = { register, readRobotJson, writeRobotJson, listHistory, readSnapshot };

View File

@@ -3,17 +3,18 @@ const https = require('https');
const { createRobotFromEnv } = require('./robot/KinematicsFactory');
const GCode = require('./robot/GCode');
const TelnetSender = require('./robot/TelnetSenderGRBL');
const RobotConfig = require('./robot/RobotConfig');
const initInputWS = require('./server/InputWS');
const createInfoServer = require('./server/InfoServer');
function loadHttpsOptions(fsModule) {
function loadHttpsOptions(fsModule, processEnv) {
try {
return {
enable: true,
key: fsModule.readFileSync('https/localhost.key'),
cert: fsModule.readFileSync('https/localhost.pem'),
passphrase: 'abcd'
passphrase: processEnv.HTTPS_PASSPHRASE ?? 'abcd'
};
} catch (err) {
throw new Error(`Failed to load HTTPS certificate/key: ${err.message}`);
@@ -59,7 +60,7 @@ function createApp(options = {}) {
let httpsOptions;
try {
httpsOptions = loadHttpsOptions(fsModule);
httpsOptions = loadHttpsOptions(fsModule, processEnv);
startupStatus.https = { ok: true };
} catch (err) {
startupStatus.https = { ok: false, error: err.message };
@@ -67,15 +68,18 @@ function createApp(options = {}) {
return { startupStatus };
}
// logs/-Verzeichnis sicherstellen (idempotent)
fsModule.mkdirSync?.('logs', { recursive: true });
const httpsServer = httpsModule.createServer(httpsOptions);
// Kinematik wählen: explizit injizierte RobotClass (Tests) hat Vorrang,
// sonst per Umgebungsvariable (ROBOT_KINEMATICS / ROBOT_KINEMATICS_PARAMS).
// Die Armlängen der Standard-Hardware dienen als Default, wenn keine Params
// gesetzt sind — siehe doc/ToDo_12_InverseKinematikConfig_ROADMAP.md.
// robot.json lesen: Kinematik-Params, Bewegungs-Defaults, Controller-Endpunkte.
// Env-Variablen überschreiben robot.json (Override-Ebene).
const cfg = RobotConfig.load(fsModule, processEnv, consoleObj);
const robot = RobotClass
? new RobotClass(250, 264, 100)
: createRobotFromEnv(processEnv, { l1: 250, l2: 264, l3: 100 });
? new RobotClass(cfg.kinematics.l1, cfg.kinematics.l2, cfg.kinematics.l3, cfg.motion)
: createRobotFromEnv(processEnv, { ...cfg.kinematics, ...cfg.motion });
const sharedState = {
connectedClients: [],
@@ -85,19 +89,12 @@ function createApp(options = {}) {
initInputWSFn(httpsServer, robot, GCodeModule, sharedState);
const baseIP = processEnv.GRBL_BASE_IP ?? 'fluidNcBase.local';
const elbowIP = processEnv.GRBL_ELLBOW_IP ?? 'fluidNcEllbow.local';
const handIP = processEnv.GRBL_HAND_IP ?? 'fluidNcHand.local';
const telnetSender1 = new TelnetSenderClass(baseIP, 2300, 'x', 'y', 'z');
const telnetSender2 = new TelnetSenderClass(elbowIP, 5000, 'a', null, null);
const telnetSender3 = new TelnetSenderClass(handIP, 5000, 'c', 'e', 'b');
const senders = [
{ name: 'Base', instance: telnetSender1 },
{ name: 'Elbow', instance: telnetSender2 },
{ name: 'Hand', instance: telnetSender3 }
];
const senders = [];
for (const [key, ctrl] of Object.entries(cfg.controllers)) {
const name = key.charAt(0).toUpperCase() + key.slice(1);
const instance = new TelnetSenderClass(ctrl.ip, ctrl.port, ...ctrl.axes);
senders.push({ name, instance });
}
startupStatus.senders = senders.map(getSenderConnectionStatus);
const disconnectedSenders = startupStatus.senders.filter(s => s.status === 'disconnected');
@@ -122,7 +119,8 @@ function createApp(options = {}) {
sharedState,
robot,
GCodeModule,
senders
senders,
{ apiKey: processEnv.ROBOT_API_KEY }
);
const infoPort = 2098;

150
test/RobotConfig.test.js Normal file
View File

@@ -0,0 +1,150 @@
'use strict';
const { load, DEFAULTS } = require('../robot/RobotConfig');
function makeFs(content) {
return {
readFileSync: jest.fn(() => content)
};
}
function makeFailFs() {
return {
readFileSync: jest.fn(() => { throw new Error('ENOENT'); })
};
}
const log = { warn: jest.fn(), log: jest.fn(), error: jest.fn() };
const FULL_ROBOT_JSON = {
kinematics: { type: 'arm3segmentlinearx' },
motion: { defaultFeedrate: 2300, speedMode: 'legacy', speedModeOptions: ['legacy', 'correct'] },
controllers: {
base: { ip: 'fluidNcBase.local', port: 2300, protocol: 'telnet', axes: ['x', 'y', 'z'] },
elbow: { ip: 'fluidNcEllbow.local', port: 5000, protocol: 'telnet', axes: ['a', null, null] },
hand: { ip: 'fluidNcHand.local', port: 5000, protocol: 'telnet', axes: ['c', 'e', 'b'] }
},
links: {
Arm1: { skeleton: { from: [0,0,0], to: [0,-250,0] } },
Arm2: { skeleton: { from: [0,0,0], to: [0,-250,0] } },
Ellbow: { skeleton: { from: [0,0,0], to: [90,0,0] } }
}
};
describe('RobotConfig.load — Vollständige robot.json', () => {
let cfg;
beforeEach(() => {
cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), {}, log);
});
test('kinematics.type aus robot.json', () => {
expect(cfg.kinematics.type).toBe('arm3segmentlinearx');
});
test('l1/l2 aus links.Arm1/Arm2.skeleton.to[1] (Betrag)', () => {
expect(cfg.kinematics.l1).toBe(250);
expect(cfg.kinematics.l2).toBe(250);
});
test('l3 aus links.Ellbow.skeleton.to[0]', () => {
expect(cfg.kinematics.l3).toBe(90);
});
test('motion.defaultFeedrate aus robot.json', () => {
expect(cfg.motion.defaultFeedrate).toBe(2300);
});
test('motion.speedMode aus robot.json', () => {
expect(cfg.motion.speedMode).toBe('legacy');
});
test('motion.useSpeedCalc false für legacy', () => {
expect(cfg.motion.useSpeedCalc).toBe(false);
});
test('controllers enthält alle drei Endpunkte', () => {
expect(cfg.controllers.base.ip).toBe('fluidNcBase.local');
expect(cfg.controllers.base.port).toBe(2300);
expect(cfg.controllers.base.protocol).toBe('telnet');
expect(cfg.controllers.elbow.port).toBe(5000);
expect(cfg.controllers.hand.ip).toBe('fluidNcHand.local');
});
test('axesByController gibt korrektes Array zurück', () => {
expect(cfg.axesByController('base')).toEqual(['x', 'y', 'z']);
expect(cfg.axesByController('elbow')).toEqual(['a', null, null]);
expect(cfg.axesByController('hand')).toEqual(['c', 'e', 'b']);
});
test('axesByController gibt [] für unbekannten Key zurück', () => {
expect(cfg.axesByController('unknown')).toEqual([]);
});
});
describe('RobotConfig.load — Fehlerbehandlung', () => {
test('fehlende robot.json → Defaults', () => {
const cfg = load(makeFailFs(), {}, log);
expect(cfg.kinematics.l1).toBe(DEFAULTS.kinematics.l1);
expect(cfg.motion.defaultFeedrate).toBe(DEFAULTS.motion.defaultFeedrate);
expect(cfg.controllers.base.ip).toBe(DEFAULTS.controllers.base.ip);
});
test('ungültiges JSON → Defaults', () => {
const cfg = load(makeFs('{ not valid json }'), {}, log);
expect(cfg.kinematics.type).toBe(DEFAULTS.kinematics.type);
});
test('fehlende links → kinematics-Defaults', () => {
const json = { ...FULL_ROBOT_JSON };
delete json.links;
const cfg = load(makeFs(JSON.stringify(json)), {}, log);
expect(cfg.kinematics.l1).toBe(DEFAULTS.kinematics.l1);
expect(cfg.kinematics.l3).toBe(DEFAULTS.kinematics.l3);
});
});
describe('RobotConfig.load — Env-Override', () => {
test('ROBOT_DEFAULT_FEEDRATE überschreibt robot.json', () => {
const env = { ROBOT_DEFAULT_FEEDRATE: '5000' };
const cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), env, log);
expect(cfg.motion.defaultFeedrate).toBe(5000);
});
test('ROBOT_SPEED_MODE=correct setzt useSpeedCalc=true', () => {
const env = { ROBOT_SPEED_MODE: 'correct' };
const cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), env, log);
expect(cfg.motion.speedMode).toBe('correct');
expect(cfg.motion.useSpeedCalc).toBe(true);
});
test('GRBL_BASE_IP überschreibt controller.base.ip', () => {
const env = { GRBL_BASE_IP: '192.168.1.10' };
const cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), env, log);
expect(cfg.controllers.base.ip).toBe('192.168.1.10');
});
test('GRBL_ELLBOW_IP überschreibt controller.elbow.ip', () => {
const env = { GRBL_ELLBOW_IP: '192.168.1.11' };
const cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), env, log);
expect(cfg.controllers.elbow.ip).toBe('192.168.1.11');
});
test('GRBL_HAND_IP überschreibt controller.hand.ip', () => {
const env = { GRBL_HAND_IP: '192.168.1.12' };
const cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), env, log);
expect(cfg.controllers.hand.ip).toBe('192.168.1.12');
});
});
describe('RobotConfig.load — speedMode correct', () => {
test('speedMode correct aus robot.json setzt useSpeedCalc=true', () => {
const json = { ...FULL_ROBOT_JSON, motion: { ...FULL_ROBOT_JSON.motion, speedMode: 'correct' } };
const cfg = load(makeFs(JSON.stringify(json)), {}, log);
expect(cfg.motion.useSpeedCalc).toBe(true);
});
test('ROBOT_USE_SPEED_CALC=true setzt useSpeedCalc', () => {
const env = { ROBOT_USE_SPEED_CALC: 'true' };
const cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), env, log);
expect(cfg.motion.useSpeedCalc).toBe(true);
});
});

View File

@@ -0,0 +1,272 @@
const fs = require('fs');
const https = require('https');
const path = require('path');
const express = require('express');
const service = require('../server/RobotConfigService');
const DATA_DIR = path.join(__dirname, '..', 'data', 'robot');
const ROBOT_JSON = path.join(DATA_DIR, 'robot.json');
const TEST_KEY = 'test-api-key-12345';
const SAMPLE_ROBOT = {
links: {
Arm1: { size: [70, 250, 70] },
Arm2: { size: [70, 250, 70] },
Ellbow: { size: [90, 0, 0] }
}
};
// ── HTTPS-Test-Server ─────────────────────────────────────────────────────────
function makeServer(apiKey) {
const app = express();
service.register(app, { apiKey });
const key = fs.readFileSync('https/localhost.key');
const cert = fs.readFileSync('https/localhost.pem');
return https.createServer({ key, cert, passphrase: 'abcd' }, app);
}
function listen(server) {
return new Promise((resolve, reject) => {
server.listen(0, () => {
const addr = server.address();
addr ? resolve(addr.port) : reject(new Error('no port'));
});
server.on('error', reject);
});
}
function closeServer(server) {
return new Promise(resolve => { server.close(resolve); });
}
function request(method, url, { body, token } = {}) {
return new Promise((resolve, reject) => {
const agent = new https.Agent({ rejectUnauthorized: false });
const payload = body ? JSON.stringify(body) : undefined;
const headers = {
...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}),
...(token ? { 'Authorization': `Bearer ${token}` } : {})
};
const req = https.request(url, { method, agent, headers }, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
// ── Setup / Teardown ──────────────────────────────────────────────────────────
function cleanupTestFiles() {
for (const f of fs.readdirSync(DATA_DIR)) {
if (f === '.gitkeep' || f === '.apikey') continue;
fs.unlinkSync(path.join(DATA_DIR, f));
}
}
beforeEach(() => {
fs.mkdirSync(DATA_DIR, { recursive: true });
cleanupTestFiles();
});
afterEach(() => {
cleanupTestFiles();
});
// ── Einzel-Funktionen ─────────────────────────────────────────────────────────
describe('readRobotJson / writeRobotJson', () => {
test('writeRobotJson schreibt Datei und legt Snapshot an', async () => {
fs.writeFileSync(ROBOT_JSON, JSON.stringify({ version: 1 }), 'utf8');
const { snapshotFile } = await service.writeRobotJson({ version: 2 });
expect(snapshotFile).toMatch(/^robot_\d{8}_\d{6}\.json$/);
expect(fs.existsSync(path.join(DATA_DIR, snapshotFile))).toBe(true);
const written = JSON.parse(fs.readFileSync(ROBOT_JSON, 'utf8'));
expect(written).toEqual({ version: 2 });
const snapshot = JSON.parse(fs.readFileSync(path.join(DATA_DIR, snapshotFile), 'utf8'));
expect(snapshot).toEqual({ version: 1 });
});
test('kein Snapshot wenn robot.json noch nicht existiert', async () => {
const { snapshotFile } = await service.writeRobotJson({ fresh: true });
expect(fs.existsSync(path.join(DATA_DIR, snapshotFile))).toBe(false);
});
test('readRobotJson liest die aktuelle Datei', async () => {
fs.writeFileSync(ROBOT_JSON, JSON.stringify(SAMPLE_ROBOT), 'utf8');
const data = await service.readRobotJson();
expect(data).toEqual(SAMPLE_ROBOT);
});
test('readRobotJson wirft, wenn robot.json fehlt', async () => {
await expect(service.readRobotJson()).rejects.toThrow();
});
});
describe('listHistory / readSnapshot', () => {
test('listHistory gibt leere Liste zurück wenn kein Snapshot', async () => {
const list = await service.listHistory();
expect(list).toEqual([]);
});
test('listHistory listet vorhandene Snapshots, neueste zuerst', async () => {
fs.writeFileSync(path.join(DATA_DIR, 'robot_20260101_120000.json'), '{}', 'utf8');
fs.writeFileSync(path.join(DATA_DIR, 'robot_20260102_083000.json'), '{}', 'utf8');
const list = await service.listHistory();
expect(list.map(e => e.filename)).toEqual([
'robot_20260102_083000.json',
'robot_20260101_120000.json'
]);
expect(list[0]).toEqual({ filename: 'robot_20260102_083000.json', day: '20260102', timestamp: '083000' });
});
test('readSnapshot liest einen bestimmten Snapshot', async () => {
const ts = '20260101_120000';
fs.writeFileSync(path.join(DATA_DIR, `robot_${ts}.json`), JSON.stringify({ x: 42 }), 'utf8');
const data = await service.readSnapshot(ts);
expect(data).toEqual({ x: 42 });
});
test('readSnapshot wirft bei unbekanntem Timestamp', async () => {
await expect(service.readSnapshot('99999999_999999')).rejects.toThrow();
});
});
describe('Pruning', () => {
test('≤ 100 Snapshots pro Tag bleiben alle erhalten', async () => {
for (let i = 1; i <= 5; i++) {
fs.writeFileSync(path.join(DATA_DIR, `robot_2026061${i}_120000.json`), '{}', 'utf8');
}
fs.writeFileSync(ROBOT_JSON, '{}', 'utf8');
await service.writeRobotJson({ pruned: true });
const list = await service.listHistory();
// 5 bestehende (verschiedene Tage) + 1 neuer Snapshot = 6
expect(list.length).toBe(6);
});
test('> 100 Snapshots eines Tages: nur neuester bleibt', async () => {
const day = '20260101';
for (let i = 0; i < 102; i++) {
const hh = String(Math.floor(i / 3600)).padStart(2, '0');
const mm = String(Math.floor((i % 3600) / 60)).padStart(2, '0');
const ss = String(i % 60).padStart(2, '0');
fs.writeFileSync(path.join(DATA_DIR, `robot_${day}_${hh}${mm}${ss}.json`), `{"i":${i}}`, 'utf8');
}
// Neuester (lexikographisch letzter) vorher anlegen
const newest = `robot_${day}_235959.json`;
fs.writeFileSync(path.join(DATA_DIR, newest), '{"newest":true}', 'utf8');
fs.writeFileSync(ROBOT_JSON, '{}', 'utf8');
await service.writeRobotJson({ trigger: true });
const list = await service.listHistory();
const daySnapshots = list.filter(e => e.day === day);
expect(daySnapshots.length).toBe(1);
expect(daySnapshots[0].filename).toBe(newest);
});
});
// ── HTTP-Routen ───────────────────────────────────────────────────────────────
describe('HTTP GET /api/robot', () => {
let server, port;
beforeEach(async () => { server = makeServer(TEST_KEY); port = await listen(server); });
afterEach(async () => { await closeServer(server); });
test('404 wenn robot.json nicht existiert', async () => {
const { statusCode } = await request('GET', `https://127.0.0.1:${port}/api/robot`);
expect(statusCode).toBe(404);
});
test('200 mit robot.json-Inhalt', async () => {
fs.writeFileSync(ROBOT_JSON, JSON.stringify(SAMPLE_ROBOT), 'utf8');
const { statusCode, body } = await request('GET', `https://127.0.0.1:${port}/api/robot`);
expect(statusCode).toBe(200);
expect(JSON.parse(body)).toEqual(SAMPLE_ROBOT);
});
});
describe('HTTP PUT /api/robot', () => {
let server, port;
beforeEach(async () => { server = makeServer(TEST_KEY); port = await listen(server); });
afterEach(async () => { await closeServer(server); });
test('401 ohne Authorization-Header', async () => {
const { statusCode } = await request('PUT', `https://127.0.0.1:${port}/api/robot`, { body: SAMPLE_ROBOT });
expect(statusCode).toBe(401);
});
test('401 mit falschem Key', async () => {
const { statusCode } = await request('PUT', `https://127.0.0.1:${port}/api/robot`, {
body: SAMPLE_ROBOT, token: 'wrong-key'
});
expect(statusCode).toBe(401);
});
test('200 mit korrektem Key — schreibt Datei', async () => {
const { statusCode, body } = await request('PUT', `https://127.0.0.1:${port}/api/robot`, {
body: SAMPLE_ROBOT, token: TEST_KEY
});
expect(statusCode).toBe(200);
const result = JSON.parse(body);
expect(result.ok).toBe(true);
expect(result.snapshotFile).toMatch(/^robot_\d{8}_\d{6}\.json$/);
const written = JSON.parse(fs.readFileSync(ROBOT_JSON, 'utf8'));
expect(written).toEqual(SAMPLE_ROBOT);
});
});
describe('HTTP GET /api/robot/history', () => {
let server, port;
beforeEach(async () => { server = makeServer(TEST_KEY); port = await listen(server); });
afterEach(async () => { await closeServer(server); });
test('200 mit leerer History', async () => {
const { statusCode, body } = await request('GET', `https://127.0.0.1:${port}/api/robot/history`);
expect(statusCode).toBe(200);
expect(JSON.parse(body)).toEqual({ history: [] });
});
test('200 listet vorhandene Snapshots', async () => {
fs.writeFileSync(path.join(DATA_DIR, 'robot_20260611_100000.json'), '{}', 'utf8');
const { statusCode, body } = await request('GET', `https://127.0.0.1:${port}/api/robot/history`);
expect(statusCode).toBe(200);
const { history } = JSON.parse(body);
expect(history).toHaveLength(1);
expect(history[0].filename).toBe('robot_20260611_100000.json');
});
});
describe('HTTP GET /api/robot/history/:ts', () => {
let server, port;
beforeEach(async () => { server = makeServer(TEST_KEY); port = await listen(server); });
afterEach(async () => { await closeServer(server); });
test('200 für vorhandenen Snapshot', async () => {
const ts = '20260611_100000';
fs.writeFileSync(path.join(DATA_DIR, `robot_${ts}.json`), JSON.stringify({ snap: true }), 'utf8');
const { statusCode, body } = await request('GET', `https://127.0.0.1:${port}/api/robot/history/${ts}`);
expect(statusCode).toBe(200);
expect(JSON.parse(body)).toEqual({ snap: true });
});
test('404 für unbekannten Snapshot', async () => {
const { statusCode } = await request('GET', `https://127.0.0.1:${port}/api/robot/history/99991231_235959`);
expect(statusCode).toBe(404);
});
});

View File

@@ -43,7 +43,7 @@ describe('startRobot orchestrator', () => {
consoleObj: { log: jest.fn(), warn: jest.fn(), error: jest.fn() }
});
expect(readFileSync).toHaveBeenCalledTimes(2);
expect(readFileSync).toHaveBeenCalledTimes(3); // key, cert, data/robot/robot.json
expect(httpsModuleMock.createServer).toHaveBeenCalledWith({
enable: true,
key: 'fake-key',
@@ -61,7 +61,8 @@ describe('startRobot orchestrator', () => {
expect.objectContaining({ name: 'Base', instance: expect.any(Object) }),
expect.objectContaining({ name: 'Elbow', instance: expect.any(Object) }),
expect.objectContaining({ name: 'Hand', instance: expect.any(Object) })
])
]),
expect.objectContaining({}) // options: { apiKey }
);
expect(httpsServerMock.listen).toHaveBeenCalledWith(2095);