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

+
+ + +
+
+ + + + + + + + + + + + + + + + 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([]);