diff --git a/.gitignore b/.gitignore index 445d458..f566e0b 100755 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 2c093a2..10c37a4 100644 --- a/README.md +++ b/README.md @@ -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/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 1–6 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 1–4 in appRobotDriver ✅, Schritte 5–7 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`) diff --git a/data/robot/.gitkeep b/data/robot/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/robot/robot.json b/data/robot/robot.json new file mode 100644 index 0000000..4fe4b44 --- /dev/null +++ b/data/robot/robot.json @@ -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" + } + ] + } + } +} diff --git a/doc/ToDo_14_robot_json_service.md b/doc/ToDo_14_robot_json_service.md new file mode 100644 index 0000000..1c6f502 --- /dev/null +++ b/doc/ToDo_14_robot_json_service.md @@ -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_.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 +``` + +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 1–4 betreffen ausschließlich `appRobotDriver` und sind vollständig umgesetzt. +Schritte 5–7 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 +writeRobotJson(data) → Promise<{ snapshotFile }> // legt robot_.json an + pruning +listHistory() → Promise<{ filename, day, timestamp }[]> +readSnapshot(ts) → Promise +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) diff --git a/doc/ToDo_3_Config.md b/doc/ToDo_3_Config.md index 489004f..0d4e6d0 100644 --- a/doc/ToDo_3_Config.md +++ b/doc/ToDo_3_Config.md @@ -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) \ No newline at end of file +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 diff --git a/logs/gcode_commands.log b/logs/gcode_commands.log index 5d21b48..a0fc5c2 100644 --- a/logs/gcode_commands.log +++ b/logs/gcode_commands.log @@ -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 diff --git a/logs/pings.log b/logs/pings.log index 809a735..ffa904b 100644 --- a/logs/pings.log +++ b/logs/pings.log @@ -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 diff --git a/robot/RobotBase.js b/robot/RobotBase.js index a595efa..1d6af0c 100644 --- a/robot/RobotBase.js +++ b/robot/RobotBase.js @@ -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; diff --git a/robot/RobotConfig.js b/robot/RobotConfig.js new file mode 100644 index 0000000..39ede47 --- /dev/null +++ b/robot/RobotConfig.js @@ -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 }; diff --git a/robot/kinematics/Arm3SegmentLinearX.js b/robot/kinematics/Arm3SegmentLinearX.js index bbfa634..3b83794 100644 --- a/robot/kinematics/Arm3SegmentLinearX.js +++ b/robot/kinematics/Arm3SegmentLinearX.js @@ -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; diff --git a/robot/kinematics/Arm3SegmentRotaryBase.js b/robot/kinematics/Arm3SegmentRotaryBase.js index 181e83b..71239fb 100644 --- a/robot/kinematics/Arm3SegmentRotaryBase.js +++ b/robot/kinematics/Arm3SegmentRotaryBase.js @@ -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; diff --git a/server/InfoServer.js b/server/InfoServer.js index 0009db2..18e4672 100644 --- a/server/InfoServer.js +++ b/server/InfoServer.js @@ -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 + }); }); + + 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); } -/* ---------- 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); - }); -} - -module.exports = createInfoServer; \ No newline at end of file +module.exports = createInfoServer; diff --git a/server/RobotConfigService.js b/server/RobotConfigService.js new file mode 100644 index 0000000..801b73d --- /dev/null +++ b/server/RobotConfigService.js @@ -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 }; diff --git a/startRobot.js b/startRobot.js index f8a2e9b..f020768 100755 --- a/startRobot.js +++ b/startRobot.js @@ -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; diff --git a/test/RobotConfig.test.js b/test/RobotConfig.test.js new file mode 100644 index 0000000..af5884f --- /dev/null +++ b/test/RobotConfig.test.js @@ -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); + }); +}); diff --git a/test/RobotConfigService.test.js b/test/RobotConfigService.test.js new file mode 100644 index 0000000..6090faf --- /dev/null +++ b/test/RobotConfigService.test.js @@ -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); + }); +}); diff --git a/test/StartRobot.test.js b/test/StartRobot.test.js index 5887a28..4b86ba2 100644 --- a/test/StartRobot.test.js +++ b/test/StartRobot.test.js @@ -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);