diff --git a/data/robot/robot.json b/data/robot/robot.json
deleted file mode 100644
index b11013e..0000000
--- a/data/robot/robot.json
+++ /dev/null
@@ -1,502 +0,0 @@
-{
- "_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"], "heartbeatInterval": 5000 },
- "elbow": { "ip": "fluidNcEllbow.local", "port": 5000, "protocol": "telnet", "axes": ["a", null, null], "heartbeatInterval": 5000 },
- "hand": { "ip": "fluidNcHand.local", "port": 5000, "protocol": "telnet", "axes": ["c", "e", "b"], "heartbeatInterval": 5000 }
- },
- "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/logs/gcode_commands.log b/logs/gcode_commands.log
index 90a6cf5..d310304 100644
--- a/logs/gcode_commands.log
+++ b/logs/gcode_commands.log
@@ -10389,3 +10389,13 @@
2026-06-12T14:54:13.350Z ::ffff:127.0.0.1: M114
2026-06-12T14:54:13.572Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
2026-06-12T14:54:13.800Z ::ffff:127.0.0.1: G1 X1
+2026-06-12T16:00:23.273Z ::ffff:127.0.0.1: M114
+2026-06-12T16:00:23.316Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
+2026-06-12T16:00:23.399Z ::ffff:127.0.0.1: M114
+2026-06-12T16:00:23.649Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
+2026-06-12T16:00:23.906Z ::ffff:127.0.0.1: G1 X1
+2026-06-12T16:12:57.786Z ::ffff:127.0.0.1: M114
+2026-06-12T16:12:57.813Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
+2026-06-12T16:12:57.932Z ::ffff:127.0.0.1: M114
+2026-06-12T16:12:58.165Z ::ffff:127.0.0.1: G1 X1 Y2 Z3
+2026-06-12T16:12:58.438Z ::ffff:127.0.0.1: G1 X1
diff --git a/logs/pings.log b/logs/pings.log
index d35a39e..c7b21bc 100644
--- a/logs/pings.log
+++ b/logs/pings.log
@@ -14630,3 +14630,7 @@
2026-06-12T14:53:40.079Z ::ffff:127.0.0.1 : Ping
2026-06-12T14:54:12.378Z ::ffff:127.0.0.1 : Ping
2026-06-12T14:54:13.124Z ::ffff:127.0.0.1 : Ping
+2026-06-12T16:00:23.157Z ::ffff:127.0.0.1 : Ping
+2026-06-12T16:00:23.226Z ::ffff:127.0.0.1 : Ping
+2026-06-12T16:12:57.687Z ::ffff:127.0.0.1 : Ping
+2026-06-12T16:12:57.751Z ::ffff:127.0.0.1 : Ping
diff --git a/public/app.js b/public/app.js
index 1478d7b..4e8a8ee 100644
--- a/public/app.js
+++ b/public/app.js
@@ -173,6 +173,91 @@ document.addEventListener('DOMContentLoaded', function() {
.catch(err => console.error('Error fetching robot history:', err));
}
+ // ── Emergency Stop Panel ─────────────────────────────────────────────
+
+ // SVG-Button: Farbe + Text + Click-Handler je nach armed-Zustand wechseln.
+ // armed=true → rot "EMERGENCY STOP" → POST /api/emergency-stop
+ // armed=false → grün "START ROBOT" → POST /api/power-on
+ let _lastArmed = null;
+
+ function updateEmergencyStopButton(armed) {
+ if (armed === _lastArmed) return;
+ _lastArmed = armed;
+
+ const stops = document.querySelectorAll('#estopGrad stop');
+ const textPath = document.querySelector('#emergency-stop textPath');
+ const btnInner = document.querySelector('#emergency-stop circle:last-of-type');
+ const label = document.getElementById('armed-status');
+
+ if (armed) {
+ // Rot: Roboter bestromt → Klick = Emergency Stop
+ if (stops[0]) stops[0].setAttribute('stop-color', '#ff5555');
+ if (stops[1]) stops[1].setAttribute('stop-color', '#cc0000');
+ if (stops[2]) stops[2].setAttribute('stop-color', '#880000');
+ if (btnInner) btnInner.setAttribute('stroke', '#660000');
+ if (textPath) textPath.textContent = 'EMERGENCY STOP';
+ if (label) { label.textContent = '● Bestromt'; label.className = 'estop-armed-label armed'; }
+ } else {
+ // Grün: Strom AUS → Klick = Strom einschalten (Start Robot)
+ if (stops[0]) stops[0].setAttribute('stop-color', '#88ff99');
+ if (stops[1]) stops[1].setAttribute('stop-color', '#00aa44');
+ if (stops[2]) stops[2].setAttribute('stop-color', '#005522');
+ if (btnInner) btnInner.setAttribute('stroke', '#003311');
+ if (textPath) textPath.textContent = 'START ROBOT';
+ if (label) { label.textContent = '○ Kein Strom'; label.className = 'estop-armed-label disarmed'; }
+ }
+
+ const div = document.getElementById('emergency-stop');
+ if (div) {
+ div.onclick = armed
+ ? () => fetch('/api/emergency-stop', { method: 'POST' })
+ : () => fetch('/api/power-on', { method: 'POST' });
+ }
+ }
+
+ async function pollPowerStatus() {
+ try {
+ const res = await fetch('/api/power-status');
+ if (!res.ok) return;
+ const data = await res.json();
+ if (data.ok) updateEmergencyStopButton(data.armed);
+ } catch { /* Netzwerkfehler → Button bleibt im letzten Zustand */ }
+ }
+
+ pollPowerStatus();
+ setInterval(pollPowerStatus, 2000);
+
+ const btnAlarmUnlock = document.getElementById('btn-alarm-unlock');
+ const alarmUnlockStatus = document.getElementById('alarm-unlock-status');
+
+ if (btnAlarmUnlock) {
+ btnAlarmUnlock.addEventListener('click', async () => {
+ btnAlarmUnlock.disabled = true;
+ alarmUnlockStatus.textContent = 'Wird ausgeführt…';
+ alarmUnlockStatus.className = 'estop-status';
+ try {
+ const res = await fetch('/api/alarm-unlock', { method: 'POST' });
+ const data = await res.json();
+ if (data.ok) {
+ alarmUnlockStatus.textContent = '✅ Alarm entsperrt';
+ alarmUnlockStatus.className = 'estop-status ok';
+ } else {
+ const failed = (data.results || [])
+ .filter(r => !r.ok && !r.skipped)
+ .map(r => r.name)
+ .join(', ');
+ alarmUnlockStatus.textContent = `⚠️ Fehlgeschlagen: ${failed || 'unbekannt'}`;
+ alarmUnlockStatus.className = 'estop-status err';
+ }
+ } catch (err) {
+ alarmUnlockStatus.textContent = `❌ Fehler: ${err.message}`;
+ alarmUnlockStatus.className = 'estop-status err';
+ } finally {
+ btnAlarmUnlock.disabled = false;
+ }
+ });
+ }
+
updateStatus();
updatePosition();
updateRobotJson();
diff --git a/public/index.html b/public/index.html
index 6f7e044..c57a8e6 100644
--- a/public/index.html
+++ b/public/index.html
@@ -76,6 +76,43 @@
Robot.json History
+
+
+
Emergency Stop
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/style.css b/public/style.css
index 248852c..40b9acd 100644
--- a/public/style.css
+++ b/public/style.css
@@ -256,4 +256,90 @@ h1 {
#robotHistoryList li.rh-active::before {
content: "▶ ";
font-size: 10px;
-}
\ No newline at end of file
+}
+
+/* ── Emergency Stop Panel ────────────────────────────────────────────────── */
+
+.estop-actions {
+ margin-top: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+/* Zentriert den SVG-Button horizontal im Panel */
+.estop-center {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+}
+
+/* SVG E-Stop / Start-Button */
+#emergency-stop {
+ display: block; /* override framework display:none wenn nötig */
+ position: relative;
+ width: 78px;
+ height: 78px;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: transform 0.1s ease;
+}
+
+#emergency-stop:hover svg {
+ filter: brightness(1.12);
+}
+
+#emergency-stop:active {
+ transform: scale(0.93);
+}
+
+/* Zustandsanzeige unter dem Button */
+.estop-armed-label {
+ font-size: 12px;
+ font-weight: bold;
+ min-height: 16px;
+ color: var(--muted);
+}
+
+.estop-armed-label.armed { color: #e74c3c; }
+.estop-armed-label.disarmed { color: #2ecc71; }
+
+.btn {
+ display: inline-block;
+ padding: 9px 18px;
+ border: none;
+ border-radius: 5px;
+ font-size: 14px;
+ font-family: Arial, sans-serif;
+ font-weight: bold;
+ cursor: pointer;
+ transition: background 0.15s, opacity 0.15s;
+ align-self: flex-start;
+}
+
+.btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+/* Alarm-Unlock: Bernstein / Orange — Recovery-Aktion */
+.btn-unlock {
+ background: #c87800;
+ color: #fff;
+}
+
+.btn-unlock:hover:not(:disabled) {
+ background: #a56200;
+}
+
+/* Status-Zeile unterhalb des Buttons */
+.estop-status {
+ font-size: 13px;
+ min-height: 18px;
+ color: var(--text);
+ opacity: 0.7;
+}
+
+.estop-status.ok { color: #2ecc71; opacity: 1; }
+.estop-status.err { color: #e74c3c; opacity: 1; }
\ No newline at end of file
diff --git a/robot/RobotConfig.js b/robot/RobotConfig.js
index a5ff7d5..53f0bef 100644
--- a/robot/RobotConfig.js
+++ b/robot/RobotConfig.js
@@ -13,9 +13,12 @@ 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'], heartbeatInterval: 10000 },
- elbow: { ip: 'fluidNcEllbow.local', port: 5000, protocol: 'telnet', axes: ['a', null, null], heartbeatInterval: 10000 },
- hand: { ip: 'fluidNcHand.local', port: 5000, protocol: 'telnet', axes: ['c', 'e', 'b'], heartbeatInterval: 10000 }
+ base: { ip: 'fluidNcBase.local', port: 2300, protocol: 'telnet', axes: ['x', 'y', 'z'], heartbeatInterval: 10000 },
+ elbow: { ip: 'fluidNcEllbow.local', port: 5000, protocol: 'telnet', axes: ['a', null, null], heartbeatInterval: 10000 },
+ hand: { ip: 'fluidNcHand.local', port: 5000, protocol: 'telnet', axes: ['c', 'e', 'b'], heartbeatInterval: 10000 },
+ // Shelly Smart Plug: schaltet Strom für Emergency Stop.
+ // url: null → deaktiviert, wenn nicht in robot.json konfiguriert.
+ emergencyStop: { protocol: 'shelly', url: null }
}
};
@@ -82,16 +85,26 @@ function load(fsModule, processEnv, consoleObj) {
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,
- // Heartbeat-Intervall in ms: wie oft '?' gesendet wird.
- // deadTimeout = 2 × heartbeatInterval (zwei verpasste Heartbeats → Verbindung tot).
- heartbeatInterval: cfg.heartbeatInterval ?? def.heartbeatInterval,
- };
+
+ if (def.protocol === 'shelly') {
+ // Shelly Smart Plug: nur protocol + url nötig (kein IP/Port/Axes/Heartbeat)
+ controllers[key] = {
+ protocol: cfg.protocol ?? def.protocol,
+ url: cfg.url ?? def.url,
+ };
+ } else {
+ // Telnet (FluidNC): IP + Port + Achsen + Heartbeat
+ 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,
+ // Heartbeat-Intervall in ms: wie oft '?' gesendet wird.
+ // deadTimeout = 2 × heartbeatInterval (zwei verpasste Heartbeats → Verbindung tot).
+ heartbeatInterval: cfg.heartbeatInterval ?? def.heartbeatInterval,
+ };
+ }
}
function axesByController(key) {
diff --git a/robot/SenderInterface.js b/robot/SenderInterface.js
index 2643f09..1f3639f 100644
--- a/robot/SenderInterface.js
+++ b/robot/SenderInterface.js
@@ -14,6 +14,23 @@ class SenderInterface {
disconnect() {
throw new Error('disconnect() must be implemented by sender classes');
}
+
+ /**
+ * Emergency Stop: Bewegung sofort stoppen (best-effort).
+ * Standard-Implementierung: no-op (wird übersprungen).
+ * Override in TelnetSenderGRBL → sendet '!' (FluidNC Feed Hold).
+ * Override in ShellyEmergencyStop → schneidet Strom ab.
+ * @returns {Promise<{ok: boolean, skipped?: boolean}>}
+ */
+ async emergencyStop() { return { ok: true, skipped: true }; }
+
+ /**
+ * Alarm-Unlock: Controller nach Strom-Neustart entsperren.
+ * Standard-Implementierung: no-op (wird übersprungen).
+ * Override in TelnetSenderGRBL → sendet '$X'.
+ * @returns {Promise<{ok: boolean, skipped?: boolean}>}
+ */
+ async alarmUnlock() { return { ok: true, skipped: true }; }
}
module.exports = SenderInterface;
diff --git a/robot/ShellyEmergencyStop.js b/robot/ShellyEmergencyStop.js
new file mode 100644
index 0000000..e1acdff
--- /dev/null
+++ b/robot/ShellyEmergencyStop.js
@@ -0,0 +1,166 @@
+'use strict';
+
+const http = require('http');
+const SenderInterface = require('./SenderInterface');
+
+/**
+ * Steuert einen Shelly Smart Plug als Emergency-Stop-Aktor.
+ *
+ * emergencyStop() → Switch.Set?id=0&on=false (Strom abschalten)
+ * powerOn() → Switch.Set?id=0&on=true (Strom einschalten)
+ * alarmUnlock() → no-op / skipped (kein FluidNC-Alarm)
+ *
+ * Implementiert SenderInterface — empfängt aber keinen GCode (send() ist no-op).
+ * startRobot.js trägt diese Klasse NICHT in robot.cmdReceivers ein.
+ */
+module.exports = class ShellyEmergencyStop extends SenderInterface {
+
+ /**
+ * @param {string|null} url Shelly-RPC-URL für Switch.Set?on=false,
+ * z.B. "http://shelly.local/rpc/Switch.Set?id=0&on=false".
+ * null → kein Shelly konfiguriert (alle Methoden liefern {ok:false}).
+ * @param {object} options
+ * @param {function} [options.httpGetFn] DI für Tests; Standard: Node http.get
+ */
+ constructor(url, options = {}) {
+ super();
+ this._offUrl = url || null;
+ this._onUrl = url ? url.replace('on=false', 'on=true') : null;
+ // Switch.GetStatus?id=0 — leitet Status-URL aus der Switch.Set-URL ab
+ this._statusUrl = url ? url.replace(/\/rpc\/.*$/, '/rpc/Switch.GetStatus?id=0') : null;
+ this.url = url || null; // für getStatus / InfoServer-Anzeige
+ this.state = 'ready';
+ this.error = null;
+ this._httpGet = options.httpGetFn || ShellyEmergencyStop._defaultHttpGet;
+ this._httpGetJson = options.httpGetJsonFn || ShellyEmergencyStop._defaultHttpGetJson;
+ }
+
+ /**
+ * Standard-HTTP-GET über Node's eingebautes http-Modul (kein npm-Paket nötig).
+ * Response-Body wird verworfen (nur Status-Code relevant).
+ * @private
+ */
+ static _defaultHttpGet(url) {
+ return new Promise((resolve, reject) => {
+ http.get(url, res => {
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode });
+ res.resume(); // Response-Body verwerfen
+ }).on('error', reject);
+ });
+ }
+
+ /**
+ * Standard-HTTP-GET mit JSON-Body-Parsing.
+ * Für Switch.GetStatus — Response-Body enthält { output, apower, voltage, ... }.
+ * @private
+ */
+ static _defaultHttpGetJson(url) {
+ return new Promise((resolve, reject) => {
+ http.get(url, res => {
+ let body = '';
+ res.on('data', chunk => { body += chunk; });
+ res.on('end', () => {
+ try {
+ const data = JSON.parse(body);
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, data });
+ } catch {
+ resolve({ ok: false, status: res.statusCode, data: null, error: 'JSON parse error' });
+ }
+ });
+ }).on('error', reject);
+ });
+ }
+
+ // ── SenderInterface ──────────────────────────────────────────────────────
+
+ /** Shelly braucht kein persistentes Socket — sofort ready. */
+ async connect() { return this; }
+
+ /** Legt keine Ressourcen frei (kein Socket), aber aktualisiert den State. */
+ disconnect() { this.state = 'disconnected'; }
+
+ /** Kein GCode-Empfänger — wird immer ignoriert. */
+ send(/* cmd */) { return false; }
+
+ getStatus() {
+ return { state: this.state, url: this.url, error: this.error };
+ }
+
+ // ── Emergency Stop / Power ───────────────────────────────────────────────
+
+ /**
+ * Strom abschalten.
+ * Wird von POST /api/emergency-stop im InfoServer aufgerufen.
+ * @returns {{ ok: boolean, status?: number, error?: string }}
+ */
+ async emergencyStop() {
+ if (!this._offUrl) return { ok: false, error: 'no shelly url configured' };
+ try {
+ const result = await this._httpGet(this._offUrl);
+ this.state = result.ok ? 'stopped' : 'error';
+ this.error = result.ok ? null : `HTTP ${result.status}`;
+ console.log(`[Shelly] power OFF → ${result.ok ? 'OK' : `HTTP ${result.status}`}`);
+ return result;
+ } catch (err) {
+ this.state = 'error';
+ this.error = err.message;
+ console.error(`[Shelly] power OFF failed: ${err.message}`);
+ return { ok: false, error: err.message };
+ }
+ }
+
+ /**
+ * Strom wieder einschalten.
+ * Wird von POST /api/power-on im InfoServer aufgerufen.
+ * @returns {{ ok: boolean, status?: number, error?: string }}
+ */
+ async powerOn() {
+ if (!this._onUrl) return { ok: false, error: 'no shelly url configured' };
+ try {
+ const result = await this._httpGet(this._onUrl);
+ this.state = result.ok ? 'ready' : 'error';
+ this.error = result.ok ? null : `HTTP ${result.status}`;
+ console.log(`[Shelly] power ON → ${result.ok ? 'OK' : `HTTP ${result.status}`}`);
+ return result;
+ } catch (err) {
+ this.state = 'error';
+ this.error = err.message;
+ console.error(`[Shelly] power ON failed: ${err.message}`);
+ return { ok: false, error: err.message };
+ }
+ }
+
+ /**
+ * Shelly hat keine FluidNC-Alarme — no-op, damit InfoServer-Sammeldurchlauf
+ * den Shelly einfach überspringen kann.
+ */
+ async alarmUnlock() { return { ok: true, skipped: true }; }
+
+ // ── Power Status ─────────────────────────────────────────────────────────
+
+ /**
+ * Liest den aktuellen Schaltzustand vom Shelly (Switch.GetStatus?id=0).
+ * `armed = true` → output:true → Strom AN → Roboter bestromt
+ * `armed = false` → output:false → Strom AUS → Roboter stromlos
+ *
+ * Gibt zusätzlich Spannung und Wirkleistung zurück (für Diagnosezwecke).
+ * @returns {{ ok: boolean, armed: boolean, voltage?: number, power?: number, error?: string }}
+ */
+ async getArmed() {
+ if (!this._statusUrl) return { ok: false, armed: false, error: 'no shelly url configured' };
+ try {
+ const result = await this._httpGetJson(this._statusUrl);
+ if (!result.ok || !result.data) {
+ return { ok: false, armed: false, error: result.error || `HTTP ${result.status}` };
+ }
+ return {
+ ok: true,
+ armed: result.data.output === true,
+ voltage: result.data.voltage,
+ power: result.data.apower,
+ };
+ } catch (err) {
+ return { ok: false, armed: false, error: err.message };
+ }
+ }
+};
diff --git a/robot/TelnetSenderGRBL.js b/robot/TelnetSenderGRBL.js
index 949a951..024098f 100755
--- a/robot/TelnetSenderGRBL.js
+++ b/robot/TelnetSenderGRBL.js
@@ -305,6 +305,36 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
this.error = null;
}
+ /**
+ * Emergency Stop: sendet '!' (Feed Hold) an FluidNC.
+ * Best-effort — falls nicht verbunden, wird {ok:false} zurückgegeben.
+ * '!' ist ein FluidNC-Realtime-Byte (kein Zeilenende nötig, sofortige Wirkung).
+ */
+ async emergencyStop() {
+ if (!this.tSocket) return { ok: false, error: 'not connected' };
+ try {
+ this.tSocket.write('!');
+ return { ok: true };
+ } catch (err) {
+ return { ok: false, error: err.message };
+ }
+ }
+
+ /**
+ * Alarm-Unlock: sendet '$X\r\n' an FluidNC.
+ * Entsperrt den Controller nach einem Power-Loss-Alarm (ALARM:1).
+ * Nur aufrufen, nachdem sichergestellt wurde, dass der Roboter frei steht.
+ */
+ async alarmUnlock() {
+ if (!this.tSocket) return { ok: false, error: 'not connected' };
+ try {
+ this.tSocket.write('$X\r\n');
+ return { ok: true };
+ } catch (err) {
+ return { ok: false, error: err.message };
+ }
+ }
+
moveTo(mOld, mNew){
this.execCommand("G1", mOld, mNew)
}
diff --git a/server/InfoServer.js b/server/InfoServer.js
index 18e4672..1d2bae2 100644
--- a/server/InfoServer.js
+++ b/server/InfoServer.js
@@ -77,6 +77,80 @@ function createInfoServer(httpsOptions, sharedState, robot, GCode, senders, opti
// ── Robot-Config-Service ─────────────────────────────────────────────────
robotConfigService.register(app, { apiKey: options.apiKey });
+ // ── Power Status (Shelly) ────────────────────────────────────────────────
+ //
+ // GET /api/power-status
+ // Fragt den Shelly-Controller nach dem aktuellen Schaltzustand.
+ // Antwort: { ok, armed: bool, voltage?, power? }
+ // armed:true → output:true → Strom AN → Roboter bestromt ("armed")
+ // armed:false → output:false → Strom AUS → Roboter stromlos
+
+ app.get('/api/power-status', async (req, res) => {
+ const shelly = senders.find(({ instance }) => typeof instance.getArmed === 'function');
+ if (!shelly) {
+ return res.json({ ok: false, armed: false, error: 'no shelly configured' });
+ }
+ const result = await shelly.instance.getArmed();
+ res.json(result);
+ });
+
+ // ── Emergency Stop ───────────────────────────────────────────────────────
+ //
+ // POST /api/emergency-stop
+ // Ruft auf allen Sendern emergencyStop() auf (parallel).
+ // FluidNC-Sender: sendet '!' (Feed Hold).
+ // Shelly-Sender: schaltet Strom ab.
+ // Aufruf vom Framework (Kopfzeile-Button): POST https://:2098/api/emergency-stop
+ //
+ // POST /api/power-on
+ // Schaltet Strom über Shelly wieder ein (nach NotAus-Restart).
+ //
+ // POST /api/alarm-unlock
+ // Sendet '$X' an alle FluidNC-Controller (entsperrt ALARM-Zustand nach Strom-Neustart).
+ // Nur aufrufen, nachdem Roboterstellung manuell geprüft wurde.
+
+ app.post('/api/emergency-stop', async (req, res) => {
+ const settled = await Promise.allSettled(
+ senders.map(({ name, instance }) =>
+ instance.emergencyStop().then(r => ({ name, ...r }))
+ )
+ );
+ const results = settled.map((r, i) =>
+ r.status === 'fulfilled'
+ ? r.value
+ : { name: senders[i].name, ok: false, error: r.reason?.message }
+ );
+ console.log(`[EmergencyStop] triggered at ${new Date().toISOString()}`);
+ res.json({ ok: results.every(r => r.ok || r.skipped), at: new Date().toISOString(), results });
+ });
+
+ app.post('/api/power-on', async (req, res) => {
+ const shellyEntries = senders.filter(({ instance }) =>
+ typeof instance.powerOn === 'function'
+ );
+ const settled = await Promise.allSettled(
+ shellyEntries.map(({ name, instance }) => instance.powerOn().then(r => ({ name, ...r })))
+ );
+ const results = settled.map(r =>
+ r.status === 'fulfilled' ? r.value : { ok: false, error: r.reason?.message }
+ );
+ res.json({ ok: results.length > 0 && results.every(r => r.ok), at: new Date().toISOString(), results });
+ });
+
+ app.post('/api/alarm-unlock', async (req, res) => {
+ const settled = await Promise.allSettled(
+ senders.map(({ name, instance }) =>
+ instance.alarmUnlock().then(r => ({ name, ...r }))
+ )
+ );
+ const results = settled.map((r, i) =>
+ r.status === 'fulfilled'
+ ? r.value
+ : { name: senders[i].name, ok: false, error: r.reason?.message }
+ );
+ res.json({ ok: results.every(r => r.ok || r.skipped), at: new Date().toISOString(), results });
+ });
+
// ── 404 ──────────────────────────────────────────────────────────────────
app.use((req, res) => res.status(404).end('Not found'));
diff --git a/startRobot.js b/startRobot.js
index 9af12e0..5a2f935 100755
--- a/startRobot.js
+++ b/startRobot.js
@@ -3,6 +3,7 @@ const https = require('https');
const { createRobotFromEnv } = require('./robot/KinematicsFactory');
const GCode = require('./robot/GCode');
const TelnetSender = require('./robot/TelnetSenderGRBL');
+const ShellySender = require('./robot/ShellyEmergencyStop');
const RobotConfig = require('./robot/RobotConfig');
const initInputWS = require('./server/InputWS');
@@ -47,6 +48,7 @@ function createApp(options = {}) {
RobotClass = null,
GCodeModule = GCode,
TelnetSenderClass = TelnetSender,
+ ShellyClass = ShellySender,
initInputWSFn = initInputWS,
createInfoServerFn = createInfoServer,
setTimeoutFn = setTimeout,
@@ -93,16 +95,22 @@ function createApp(options = {}) {
for (const [key, ctrl] of Object.entries(cfg.controllers)) {
const name = key.charAt(0).toUpperCase() + key.slice(1);
- // Konstruktor erwartet 7 Achsen-Slots (x y z a b c e) vor dem Options-Objekt.
- // Auf genau 7 auffüllen, damit heartbeatInterval nicht als Achsen-Arg landet.
- const axes7 = [...(ctrl.axes ?? [])];
- while (axes7.length < 7) axes7.push(null);
-
- const instance = new TelnetSenderClass(ctrl.ip, ctrl.port, ...axes7, {
- heartbeatInterval: ctrl.heartbeatInterval,
- deadTimeout: 2 * ctrl.heartbeatInterval,
- });
- senders.push({ name, instance });
+ if (ctrl.protocol === 'shelly') {
+ // Shelly Smart Plug: kein GCode-Empfänger, nur Emergency-Stop-Aktor
+ const instance = new ShellyClass(ctrl.url);
+ senders.push({ name, instance, isGCodeReceiver: false });
+ } else {
+ // Telnet (FluidNC): Konstruktor erwartet 7 Achsen-Slots (x y z a b c e) vor dem
+ // Options-Objekt. Auf genau 7 auffüllen, damit heartbeatInterval nicht als
+ // Achsen-Arg landet.
+ const axes7 = [...(ctrl.axes ?? [])];
+ while (axes7.length < 7) axes7.push(null);
+ const instance = new TelnetSenderClass(ctrl.ip, ctrl.port, ...axes7, {
+ heartbeatInterval: ctrl.heartbeatInterval,
+ deadTimeout: 2 * ctrl.heartbeatInterval,
+ });
+ senders.push({ name, instance, isGCodeReceiver: true });
+ }
}
startupStatus.senders = senders.map(getSenderConnectionStatus);
@@ -111,13 +119,9 @@ function createApp(options = {}) {
consoleObj.warn(`Startup warning: ${disconnectedSenders.length} sender(s) disconnected at startup.`);
}
- // Register all senders as command receivers immediately and permanently.
- // Each sender's execCommand() guards internally against sending while it is
- // disconnected, and resumes automatically once it (re)connects — so there is
- // no need to wait for a socket or to re-register after a reconnect. The old
- // 5s one-shot registration silently dropped senders that connected later
- // (e.g. after EHOSTUNREACH backoff) and never registered WebSocket senders.
- senders.forEach(s => robot.cmdReceivers.push(s.instance));
+ // Nur Telnet-Sender (FluidNC) empfangen GCode — Shelly wird ausgeschlossen.
+ // Jeder Sender reconnectet automatisch, daher sofortige Registrierung ohne Delay.
+ senders.filter(s => s.isGCodeReceiver).forEach(s => robot.cmdReceivers.push(s.instance));
const port = Number(processEnv.PORT) || 2095;
httpsServer.listen(port);
diff --git a/test/InfoServer.test.js b/test/InfoServer.test.js
index 0d621cd..abb1677 100644
--- a/test/InfoServer.test.js
+++ b/test/InfoServer.test.js
@@ -32,6 +32,28 @@ function request(url) {
});
}
+function post(url) {
+ return new Promise((resolve, reject) => {
+ const agent = new https.Agent({ rejectUnauthorized: false });
+ const parsed = new URL(url);
+ const options = {
+ hostname: parsed.hostname,
+ port: parsed.port,
+ path: parsed.pathname,
+ method: 'POST',
+ agent,
+ headers: { 'Content-Length': 0 }
+ };
+ const req = https.request(options, res => {
+ let data = '';
+ res.on('data', c => { data += c.toString(); });
+ res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
+ });
+ req.on('error', reject);
+ req.end();
+ });
+}
+
describe('InfoServer', () => {
let server;
let port;
@@ -189,4 +211,152 @@ describe('InfoServer', () => {
const { statusCode } = await request(`https://127.0.0.1:${port}/api/unknown`);
expect(statusCode).toBe(404);
});
+
+ // ── Emergency Stop Endpoints ─────────────────────────────────────────────
+
+ test('POST /api/emergency-stop ruft emergencyStop() auf allen Sendern auf', async () => {
+ const key = fs.readFileSync('https/localhost.key');
+ const cert = fs.readFileSync('https/localhost.pem');
+ const httpsOptions = { key, cert, passphrase: 'abcd' };
+
+ const sharedState = { connectedClients: [], lastCommands: [], lastPings: [] };
+ const robot = { x: 0, y: 0, z: 0, phi: 0, theta: 0, psi: 0, xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0 };
+
+ const stopA = jest.fn(() => Promise.resolve({ ok: true }));
+ const stopB = jest.fn(() => Promise.resolve({ ok: true, skipped: true }));
+ const senders = [
+ { name: 'Base', instance: { emergencyStop: stopA, alarmUnlock: jest.fn(() => Promise.resolve({ ok: true })) } },
+ { name: 'EmergencyStop', instance: { emergencyStop: stopB, alarmUnlock: jest.fn(() => Promise.resolve({ ok: true, skipped: true })) } }
+ ];
+
+ server = createInfoServer(httpsOptions, sharedState, robot, GCode, senders);
+ port = await listen(server);
+
+ const { statusCode, body } = await post(`https://127.0.0.1:${port}/api/emergency-stop`);
+ expect(statusCode).toBe(200);
+ const json = JSON.parse(body);
+ expect(json.ok).toBe(true);
+ expect(stopA).toHaveBeenCalledTimes(1);
+ expect(stopB).toHaveBeenCalledTimes(1);
+ expect(json.results).toHaveLength(2);
+ });
+
+ test('POST /api/alarm-unlock ruft alarmUnlock() auf allen Sendern auf', async () => {
+ const key = fs.readFileSync('https/localhost.key');
+ const cert = fs.readFileSync('https/localhost.pem');
+ const httpsOptions = { key, cert, passphrase: 'abcd' };
+
+ const sharedState = { connectedClients: [], lastCommands: [], lastPings: [] };
+ const robot = { x: 0, y: 0, z: 0, phi: 0, theta: 0, psi: 0, xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0 };
+
+ const unlockA = jest.fn(() => Promise.resolve({ ok: true }));
+ const unlockB = jest.fn(() => Promise.resolve({ ok: true, skipped: true }));
+ const senders = [
+ { name: 'Base', instance: { emergencyStop: jest.fn(() => Promise.resolve({ ok: true })), alarmUnlock: unlockA } },
+ { name: 'EmergencyStop', instance: { emergencyStop: jest.fn(() => Promise.resolve({ ok: true })), alarmUnlock: unlockB } }
+ ];
+
+ server = createInfoServer(httpsOptions, sharedState, robot, GCode, senders);
+ port = await listen(server);
+
+ const { statusCode, body } = await post(`https://127.0.0.1:${port}/api/alarm-unlock`);
+ expect(statusCode).toBe(200);
+ const json = JSON.parse(body);
+ expect(json.ok).toBe(true);
+ expect(unlockA).toHaveBeenCalledTimes(1);
+ expect(unlockB).toHaveBeenCalledTimes(1);
+ });
+
+ test('POST /api/emergency-stop ok=false wenn ein Sender fehlschlägt', async () => {
+ const key = fs.readFileSync('https/localhost.key');
+ const cert = fs.readFileSync('https/localhost.pem');
+ const httpsOptions = { key, cert, passphrase: 'abcd' };
+
+ const sharedState = { connectedClients: [], lastCommands: [], lastPings: [] };
+ const robot = { x: 0, y: 0, z: 0, phi: 0, theta: 0, psi: 0, xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0 };
+
+ const senders = [
+ { name: 'Base', instance: { emergencyStop: () => Promise.resolve({ ok: false, error: 'not connected' }), alarmUnlock: () => Promise.resolve({ ok: true }) } }
+ ];
+
+ server = createInfoServer(httpsOptions, sharedState, robot, GCode, senders);
+ port = await listen(server);
+
+ const { statusCode, body } = await post(`https://127.0.0.1:${port}/api/emergency-stop`);
+ expect(statusCode).toBe(200);
+ const json = JSON.parse(body);
+ expect(json.ok).toBe(false);
+ expect(json.results[0].ok).toBe(false);
+ });
+
+ test('GET /api/power-status gibt armed=true zurück wenn Shelly output:true', async () => {
+ const key = fs.readFileSync('https/localhost.key');
+ const cert = fs.readFileSync('https/localhost.pem');
+ const httpsOptions = { key, cert, passphrase: 'abcd' };
+
+ const sharedState = { connectedClients: [], lastCommands: [], lastPings: [] };
+ const robot = { x: 0, y: 0, z: 0, phi: 0, theta: 0, psi: 0, xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0 };
+
+ const getArmedFn = jest.fn(() => Promise.resolve({ ok: true, armed: true, voltage: 234.9, power: 15.5 }));
+ const senders = [
+ { name: 'EmergencyStop', instance: { emergencyStop: () => Promise.resolve({ ok: true }), alarmUnlock: () => Promise.resolve({ ok: true }), getArmed: getArmedFn } }
+ ];
+
+ server = createInfoServer(httpsOptions, sharedState, robot, GCode, senders);
+ port = await listen(server);
+
+ const { statusCode, body } = await request(`https://127.0.0.1:${port}/api/power-status`);
+ expect(statusCode).toBe(200);
+ const json = JSON.parse(body);
+ expect(json.ok).toBe(true);
+ expect(json.armed).toBe(true);
+ expect(getArmedFn).toHaveBeenCalledTimes(1);
+ });
+
+ test('GET /api/power-status gibt armed=false zurück wenn kein Shelly konfiguriert', async () => {
+ const key = fs.readFileSync('https/localhost.key');
+ const cert = fs.readFileSync('https/localhost.pem');
+ const httpsOptions = { key, cert, passphrase: 'abcd' };
+
+ const sharedState = { connectedClients: [], lastCommands: [], lastPings: [] };
+ const robot = { x: 0, y: 0, z: 0, phi: 0, theta: 0, psi: 0, xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0 };
+
+ const senders = [
+ { name: 'Base', instance: { emergencyStop: () => Promise.resolve({ ok: true }), alarmUnlock: () => Promise.resolve({ ok: true }) } }
+ ];
+
+ server = createInfoServer(httpsOptions, sharedState, robot, GCode, senders);
+ port = await listen(server);
+
+ const { statusCode, body } = await request(`https://127.0.0.1:${port}/api/power-status`);
+ expect(statusCode).toBe(200);
+ const json = JSON.parse(body);
+ expect(json.ok).toBe(false);
+ expect(json.armed).toBe(false);
+ expect(json.error).toMatch(/no shelly/);
+ });
+
+ test('POST /api/power-on ruft powerOn() auf Shelly-Sender auf', async () => {
+ const key = fs.readFileSync('https/localhost.key');
+ const cert = fs.readFileSync('https/localhost.pem');
+ const httpsOptions = { key, cert, passphrase: 'abcd' };
+
+ const sharedState = { connectedClients: [], lastCommands: [], lastPings: [] };
+ const robot = { x: 0, y: 0, z: 0, phi: 0, theta: 0, psi: 0, xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0 };
+
+ const powerOnFn = jest.fn(() => Promise.resolve({ ok: true, status: 200 }));
+ const senders = [
+ { name: 'Base', instance: { emergencyStop: () => Promise.resolve({ ok: true }), alarmUnlock: () => Promise.resolve({ ok: true }) } },
+ { name: 'EmergencyStop', instance: { emergencyStop: () => Promise.resolve({ ok: true }), alarmUnlock: () => Promise.resolve({ ok: true }), powerOn: powerOnFn } }
+ ];
+
+ server = createInfoServer(httpsOptions, sharedState, robot, GCode, senders);
+ port = await listen(server);
+
+ const { statusCode, body } = await post(`https://127.0.0.1:${port}/api/power-on`);
+ expect(statusCode).toBe(200);
+ const json = JSON.parse(body);
+ expect(json.ok).toBe(true);
+ expect(powerOnFn).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/test/RobotConfig.test.js b/test/RobotConfig.test.js
index 109d4b2..a8d56ea 100644
--- a/test/RobotConfig.test.js
+++ b/test/RobotConfig.test.js
@@ -177,3 +177,39 @@ describe('RobotConfig.load — heartbeatInterval', () => {
expect(cfg.controllers.base.heartbeatInterval).toBe(10000);
});
});
+
+describe('RobotConfig.load — emergencyStop (Shelly)', () => {
+ test('DEFAULTS.controllers.emergencyStop hat protocol=shelly und url=null', () => {
+ expect(DEFAULTS.controllers.emergencyStop.protocol).toBe('shelly');
+ expect(DEFAULTS.controllers.emergencyStop.url).toBeNull();
+ });
+
+ test('emergencyStop.url aus robot.json wird übernommen', () => {
+ const shellyUrl = 'http://shelly.local/rpc/Switch.Set?id=0&on=false';
+ const json = {
+ ...FULL_ROBOT_JSON,
+ controllers: {
+ ...FULL_ROBOT_JSON.controllers,
+ emergencyStop: { protocol: 'shelly', url: shellyUrl }
+ }
+ };
+ const cfg = load(makeFs(JSON.stringify(json)), {}, log);
+ expect(cfg.controllers.emergencyStop.protocol).toBe('shelly');
+ expect(cfg.controllers.emergencyStop.url).toBe(shellyUrl);
+ });
+
+ test('fehlendes emergencyStop in robot.json → url=null (Default)', () => {
+ // FULL_ROBOT_JSON hat kein emergencyStop → fällt auf Default zurück
+ const cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), {}, log);
+ expect(cfg.controllers.emergencyStop.protocol).toBe('shelly');
+ expect(cfg.controllers.emergencyStop.url).toBeNull();
+ });
+
+ test('emergencyStop hat keine ip/port/axes/heartbeatInterval Felder', () => {
+ const cfg = load(makeFailFs(), {}, log);
+ expect(cfg.controllers.emergencyStop.ip).toBeUndefined();
+ expect(cfg.controllers.emergencyStop.port).toBeUndefined();
+ expect(cfg.controllers.emergencyStop.axes).toBeUndefined();
+ expect(cfg.controllers.emergencyStop.heartbeatInterval).toBeUndefined();
+ });
+});
diff --git a/test/Sender.Telnet.emergencyStop.test.js b/test/Sender.Telnet.emergencyStop.test.js
new file mode 100644
index 0000000..0b6b9fa
--- /dev/null
+++ b/test/Sender.Telnet.emergencyStop.test.js
@@ -0,0 +1,119 @@
+'use strict';
+// Tests für TelnetSenderGRBL.emergencyStop() und alarmUnlock()
+
+const { EventEmitter } = require('events');
+const TelnetSenderGRBL = require('../robot/TelnetSenderGRBL');
+
+function makeTelnetSocket() {
+ const em = new EventEmitter();
+ return Object.assign(em, {
+ write: jest.fn(),
+ end: jest.fn(),
+ destroy: jest.fn(),
+ });
+}
+
+/** Sender im Test-Modus (URL "test.test") — tSocket ist gesetzt. */
+function makeConnectedSender() {
+ const sender = new TelnetSenderGRBL('test.test');
+ sender.tSocket = makeTelnetSocket();
+ return sender;
+}
+
+/** Sender ohne tSocket (disconnected). */
+function makeDisconnectedSender() {
+ const sender = new TelnetSenderGRBL('test.test');
+ sender.tSocket = null;
+ return sender;
+}
+
+// ── emergencyStop() ──────────────────────────────────────────────────────────
+
+describe('TelnetSenderGRBL.emergencyStop()', () => {
+
+ test('sendet "!" wenn verbunden', async () => {
+ const sender = makeConnectedSender();
+ const result = await sender.emergencyStop();
+ expect(result.ok).toBe(true);
+ expect(sender.tSocket.write).toHaveBeenCalledWith('!');
+ });
+
+ test('sendet nur "!" — kein Zeilenende (FluidNC Realtime-Byte)', async () => {
+ const sender = makeConnectedSender();
+ await sender.emergencyStop();
+ const arg = sender.tSocket.write.mock.calls[0][0];
+ expect(arg).toBe('!');
+ expect(arg).not.toContain('\r\n');
+ });
+
+ test('gibt {ok:false} zurück wenn nicht verbunden', async () => {
+ const sender = makeDisconnectedSender();
+ const result = await sender.emergencyStop();
+ expect(result.ok).toBe(false);
+ expect(result.error).toBe('not connected');
+ });
+
+ test('gibt {ok:false} zurück wenn write() wirft', async () => {
+ const sender = makeConnectedSender();
+ sender.tSocket.write.mockImplementation(() => { throw new Error('socket closed'); });
+ const result = await sender.emergencyStop();
+ expect(result.ok).toBe(false);
+ expect(result.error).toBe('socket closed');
+ });
+});
+
+// ── alarmUnlock() ────────────────────────────────────────────────────────────
+
+describe('TelnetSenderGRBL.alarmUnlock()', () => {
+
+ test('sendet "$X\\r\\n" wenn verbunden', async () => {
+ const sender = makeConnectedSender();
+ const result = await sender.alarmUnlock();
+ expect(result.ok).toBe(true);
+ expect(sender.tSocket.write).toHaveBeenCalledWith('$X\r\n');
+ });
+
+ test('gibt {ok:false} zurück wenn nicht verbunden', async () => {
+ const sender = makeDisconnectedSender();
+ const result = await sender.alarmUnlock();
+ expect(result.ok).toBe(false);
+ expect(result.error).toBe('not connected');
+ });
+
+ test('gibt {ok:false} zurück wenn write() wirft', async () => {
+ const sender = makeConnectedSender();
+ sender.tSocket.write.mockImplementation(() => { throw new Error('write error'); });
+ const result = await sender.alarmUnlock();
+ expect(result.ok).toBe(false);
+ expect(result.error).toBe('write error');
+ });
+});
+
+// ── SenderInterface-Defaults ─────────────────────────────────────────────────
+
+describe('SenderInterface — Default-Implementierungen (no-op)', () => {
+ test('SenderInterface.emergencyStop() gibt {ok:true, skipped:true}', async () => {
+ const SenderInterface = require('../robot/SenderInterface');
+ // Subclass, die nur emergencyStop von der Basis erbt
+ class MinimalSender extends SenderInterface {
+ async connect() { return this; }
+ send() { return false; }
+ getStatus() { return {}; }
+ disconnect() {}
+ }
+ const s = new MinimalSender();
+ await expect(s.emergencyStop()).resolves.toEqual({ ok: true, skipped: true });
+ });
+
+ test('SenderInterface.alarmUnlock() gibt {ok:true, skipped:true}', async () => {
+ const SenderInterface = require('../robot/SenderInterface');
+ class MinimalSender extends SenderInterface {
+ async connect() { return this; }
+ send() { return false; }
+ getStatus() { return {}; }
+ disconnect() {}
+ }
+ const s = new MinimalSender();
+ await expect(s.alarmUnlock()).resolves.toEqual({ ok: true, skipped: true });
+ });
+});
diff --git a/test/ShellyEmergencyStop.test.js b/test/ShellyEmergencyStop.test.js
new file mode 100644
index 0000000..fd3fd4c
--- /dev/null
+++ b/test/ShellyEmergencyStop.test.js
@@ -0,0 +1,198 @@
+'use strict';
+
+const ShellyEmergencyStop = require('../robot/ShellyEmergencyStop');
+
+const OFF_URL = 'http://shelly.local/rpc/Switch.Set?id=0&on=false';
+const ON_URL = 'http://shelly.local/rpc/Switch.Set?id=0&on=true';
+const STATUS_URL = 'http://shelly.local/rpc/Switch.GetStatus?id=0';
+
+function makeHttpGetJson(data, statusCode = 200) {
+ return jest.fn(() => Promise.resolve({
+ ok: statusCode >= 200 && statusCode < 300,
+ status: statusCode,
+ data
+ }));
+}
+
+function makeHttpGet(statusCode = 200) {
+ return jest.fn(() => Promise.resolve({
+ ok: statusCode >= 200 && statusCode < 300,
+ status: statusCode
+ }));
+}
+
+describe('ShellyEmergencyStop — Konstruktor', () => {
+ test('url wird korrekt gesetzt', () => {
+ const s = new ShellyEmergencyStop(OFF_URL);
+ expect(s.url).toBe(OFF_URL);
+ });
+
+ test('_onUrl ersetzt on=false durch on=true', () => {
+ const s = new ShellyEmergencyStop(OFF_URL);
+ expect(s._onUrl).toBe(ON_URL);
+ });
+
+ test('_statusUrl zeigt auf Switch.GetStatus', () => {
+ const s = new ShellyEmergencyStop(OFF_URL);
+ expect(s._statusUrl).toBe(STATUS_URL);
+ });
+
+ test('null-URL → _offUrl, _onUrl und _statusUrl sind null', () => {
+ const s = new ShellyEmergencyStop(null);
+ expect(s._offUrl).toBeNull();
+ expect(s._onUrl).toBeNull();
+ expect(s._statusUrl).toBeNull();
+ });
+
+ test('Initialzustand ist ready', () => {
+ const s = new ShellyEmergencyStop(OFF_URL);
+ expect(s.state).toBe('ready');
+ expect(s.error).toBeNull();
+ });
+});
+
+describe('ShellyEmergencyStop — SenderInterface', () => {
+ test('connect() gibt Instanz zurück', async () => {
+ const s = new ShellyEmergencyStop(OFF_URL);
+ await expect(s.connect()).resolves.toBe(s);
+ });
+
+ test('send() gibt false zurück (kein GCode-Empfänger)', () => {
+ const s = new ShellyEmergencyStop(OFF_URL);
+ expect(s.send('G1 X10')).toBe(false);
+ });
+
+ test('getStatus() enthält state und url', () => {
+ const s = new ShellyEmergencyStop(OFF_URL);
+ const st = s.getStatus();
+ expect(st.state).toBe('ready');
+ expect(st.url).toBe(OFF_URL);
+ });
+
+ test('alarmUnlock() gibt {ok:true, skipped:true} zurück', async () => {
+ const s = new ShellyEmergencyStop(OFF_URL);
+ await expect(s.alarmUnlock()).resolves.toEqual({ ok: true, skipped: true });
+ });
+});
+
+describe('ShellyEmergencyStop — emergencyStop()', () => {
+ test('ruft _offUrl auf', async () => {
+ const httpGet = makeHttpGet(200);
+ const s = new ShellyEmergencyStop(OFF_URL, { httpGetFn: httpGet });
+ await s.emergencyStop();
+ expect(httpGet).toHaveBeenCalledWith(OFF_URL);
+ });
+
+ test('setzt state=stopped bei HTTP 200', async () => {
+ const s = new ShellyEmergencyStop(OFF_URL, { httpGetFn: makeHttpGet(200) });
+ const result = await s.emergencyStop();
+ expect(result.ok).toBe(true);
+ expect(s.state).toBe('stopped');
+ expect(s.error).toBeNull();
+ });
+
+ test('setzt state=error bei HTTP 500', async () => {
+ const s = new ShellyEmergencyStop(OFF_URL, { httpGetFn: makeHttpGet(500) });
+ const result = await s.emergencyStop();
+ expect(result.ok).toBe(false);
+ expect(s.state).toBe('error');
+ expect(s.error).toBe('HTTP 500');
+ });
+
+ test('setzt state=error bei Netzwerkfehler', async () => {
+ const httpGet = jest.fn(() => Promise.reject(new Error('ECONNREFUSED')));
+ const s = new ShellyEmergencyStop(OFF_URL, { httpGetFn: httpGet });
+ const result = await s.emergencyStop();
+ expect(result.ok).toBe(false);
+ expect(result.error).toBe('ECONNREFUSED');
+ expect(s.state).toBe('error');
+ });
+
+ test('gibt {ok:false} wenn url null ist', async () => {
+ const s = new ShellyEmergencyStop(null);
+ const result = await s.emergencyStop();
+ expect(result.ok).toBe(false);
+ expect(result.error).toMatch(/no shelly url/);
+ });
+});
+
+describe('ShellyEmergencyStop — powerOn()', () => {
+ test('ruft _onUrl auf (on=true)', async () => {
+ const httpGet = makeHttpGet(200);
+ const s = new ShellyEmergencyStop(OFF_URL, { httpGetFn: httpGet });
+ await s.powerOn();
+ expect(httpGet).toHaveBeenCalledWith(ON_URL);
+ });
+
+ test('setzt state=ready bei HTTP 200', async () => {
+ const s = new ShellyEmergencyStop(OFF_URL, { httpGetFn: makeHttpGet(200) });
+ const result = await s.powerOn();
+ expect(result.ok).toBe(true);
+ expect(s.state).toBe('ready');
+ });
+
+ test('setzt state=error bei HTTP 503', async () => {
+ const s = new ShellyEmergencyStop(OFF_URL, { httpGetFn: makeHttpGet(503) });
+ const result = await s.powerOn();
+ expect(result.ok).toBe(false);
+ expect(s.state).toBe('error');
+ });
+
+ test('gibt {ok:false} wenn url null ist', async () => {
+ const s = new ShellyEmergencyStop(null);
+ const result = await s.powerOn();
+ expect(result.ok).toBe(false);
+ });
+});
+
+describe('ShellyEmergencyStop — getArmed()', () => {
+ const SHELLY_ON = { id: 0, source: 'WS_in', output: true, apower: 15.5, voltage: 234.9, freq: 50.1, current: 0.144 };
+ const SHELLY_OFF = { id: 0, source: 'WS_in', output: false, apower: 0.0, voltage: 235.2, freq: 50.1, current: 0.000 };
+
+ test('ruft _statusUrl (Switch.GetStatus?id=0) auf', async () => {
+ const httpGetJson = makeHttpGetJson(SHELLY_ON);
+ const s = new ShellyEmergencyStop(OFF_URL, { httpGetJsonFn: httpGetJson });
+ await s.getArmed();
+ expect(httpGetJson).toHaveBeenCalledWith(STATUS_URL);
+ });
+
+ test('armed=true wenn output:true', async () => {
+ const s = new ShellyEmergencyStop(OFF_URL, { httpGetJsonFn: makeHttpGetJson(SHELLY_ON) });
+ const result = await s.getArmed();
+ expect(result.ok).toBe(true);
+ expect(result.armed).toBe(true);
+ expect(result.voltage).toBe(234.9);
+ expect(result.power).toBe(15.5);
+ });
+
+ test('armed=false wenn output:false', async () => {
+ const s = new ShellyEmergencyStop(OFF_URL, { httpGetJsonFn: makeHttpGetJson(SHELLY_OFF) });
+ const result = await s.getArmed();
+ expect(result.ok).toBe(true);
+ expect(result.armed).toBe(false);
+ });
+
+ test('armed=false bei HTTP-Fehler (503)', async () => {
+ const s = new ShellyEmergencyStop(OFF_URL, { httpGetJsonFn: makeHttpGetJson(null, 503) });
+ const result = await s.getArmed();
+ expect(result.ok).toBe(false);
+ expect(result.armed).toBe(false);
+ });
+
+ test('armed=false bei Netzwerkfehler', async () => {
+ const httpGetJson = jest.fn(() => Promise.reject(new Error('ECONNREFUSED')));
+ const s = new ShellyEmergencyStop(OFF_URL, { httpGetJsonFn: httpGetJson });
+ const result = await s.getArmed();
+ expect(result.ok).toBe(false);
+ expect(result.armed).toBe(false);
+ expect(result.error).toBe('ECONNREFUSED');
+ });
+
+ test('armed=false wenn url null ist', async () => {
+ const s = new ShellyEmergencyStop(null);
+ const result = await s.getArmed();
+ expect(result.ok).toBe(false);
+ expect(result.armed).toBe(false);
+ expect(result.error).toMatch(/no shelly url/);
+ });
+});
diff --git a/test/StartRobot.test.js b/test/StartRobot.test.js
index 4b86ba2..110ad1c 100644
--- a/test/StartRobot.test.js
+++ b/test/StartRobot.test.js
@@ -21,6 +21,10 @@ describe('startRobot orchestrator', () => {
const createInfoServer = jest.fn(() => infoServerMock);
const TelnetSenderClass = jest.fn(() => ({ tSocket: null }));
+ // Shelly-Mock: getStatus() liefert state='ready' (kein tSocket, kein isTestMode)
+ const ShellyClass = jest.fn(() => ({
+ getStatus: () => ({ state: 'ready', url: null, error: null })
+ }));
const robotInstances = [];
class RobotClass {
@@ -37,6 +41,7 @@ describe('startRobot orchestrator', () => {
RobotClass,
GCodeModule: { dummy: true },
TelnetSenderClass,
+ ShellyClass,
initInputWSFn: initInputWS,
createInfoServerFn: createInfoServer,
setTimeoutFn: (fn) => fn(),
@@ -58,9 +63,10 @@ describe('startRobot orchestrator', () => {
expect.any(RobotClass),
{ dummy: true },
expect.arrayContaining([
- 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({ name: 'Base', instance: expect.any(Object) }),
+ expect.objectContaining({ name: 'Elbow', instance: expect.any(Object) }),
+ expect.objectContaining({ name: 'Hand', instance: expect.any(Object) }),
+ expect.objectContaining({ name: 'EmergencyStop', instance: expect.any(Object) })
]),
expect.objectContaining({}) // options: { apiKey }
);
@@ -71,13 +77,18 @@ describe('startRobot orchestrator', () => {
expect(result).toHaveProperty('httpsServer', httpsServerMock);
expect(result).toHaveProperty('infoServer', infoServerMock);
expect(result).toHaveProperty('senders');
- expect(result.senders).toHaveLength(3);
+ expect(result.senders).toHaveLength(4); // base, elbow, hand, emergencyStop
+
+ // Nur Telnet-Sender (3) landen in cmdReceivers — Shelly nicht
+ expect(robotInstances[0].cmdReceivers).toHaveLength(3);
+
expect(result.startupStatus).toEqual({
https: { ok: true },
senders: [
- { name: 'Base', status: 'disconnected', reason: 'no active socket connection' },
- { name: 'Elbow', status: 'disconnected', reason: 'no active socket connection' },
- { name: 'Hand', status: 'disconnected', reason: 'no active socket connection' }
+ { name: 'Base', status: 'disconnected', reason: 'no active socket connection' },
+ { name: 'Elbow', status: 'disconnected', reason: 'no active socket connection' },
+ { name: 'Hand', status: 'disconnected', reason: 'no active socket connection' },
+ { name: 'EmergencyStop', status: 'ready', reason: undefined }
]
});
expect(result.sharedState.connectedClients).toEqual([]);