Emergency Stop
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -76,6 +76,43 @@
|
||||
<h2>Robot.json History</h2>
|
||||
<ul id="robotHistoryList"></ul>
|
||||
</div>
|
||||
|
||||
<div id="emergencyStopPanel" class="section half" data-id="emergencystop">
|
||||
<h2>Emergency Stop</h2>
|
||||
<div class="estop-actions">
|
||||
|
||||
<!-- E-Stop / Start-Button: Farbe + Text wechseln je nach Strom-Zustand -->
|
||||
<div class="estop-center">
|
||||
<div id="emergency-stop" title="Emergency Stop">
|
||||
<svg width="78" height="78" viewBox="0 0 78 78" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<radialGradient id="estopGrad" cx="38%" cy="32%" r="62%">
|
||||
<stop offset="0%" stop-color="#ff5555"/>
|
||||
<stop offset="65%" stop-color="#cc0000"/>
|
||||
<stop offset="100%" stop-color="#880000"/>
|
||||
</radialGradient>
|
||||
<path id="estop-textarc" d="M 9,39 A 30,30 0 0,0 69,39"/>
|
||||
</defs>
|
||||
<!-- Äusserer Ring -->
|
||||
<circle cx="39" cy="39" r="36" fill="#FFD700" stroke="#C8960A" stroke-width="1.5"/>
|
||||
<!-- Knopf -->
|
||||
<circle cx="39" cy="39" r="21" fill="url(#estopGrad)" stroke="#660000" stroke-width="2"/>
|
||||
<!-- Gebogener Text -->
|
||||
<text font-size="7.5" fill="#1a1000" font-weight="bold"
|
||||
font-family="system-ui,sans-serif" letter-spacing="0.3" word-spacing="5">
|
||||
<textPath href="#estop-textarc" startOffset="50%" text-anchor="middle">EMERGENCY STOP</textPath>
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="armed-status" class="estop-armed-label"></div>
|
||||
</div>
|
||||
|
||||
<button id="btn-alarm-unlock" class="btn btn-unlock">
|
||||
Restart — Alarm-Unlock
|
||||
</button>
|
||||
<div id="alarm-unlock-status" class="estop-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
|
||||
@@ -257,3 +257,89 @@ h1 {
|
||||
content: "▶ ";
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ── 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; }
|
||||
@@ -15,7 +15,10 @@ const DEFAULTS = {
|
||||
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 }
|
||||
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,6 +85,15 @@ function load(fsModule, processEnv, consoleObj) {
|
||||
for (const key of Object.keys(DEFAULTS.controllers)) {
|
||||
const def = DEFAULTS.controllers[key];
|
||||
const cfg = jsonControllers[key] ?? {};
|
||||
|
||||
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,
|
||||
@@ -93,6 +105,7 @@ function load(fsModule, processEnv, consoleObj) {
|
||||
heartbeatInterval: cfg.heartbeatInterval ?? def.heartbeatInterval,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function axesByController(key) {
|
||||
return controllers[key]?.axes ?? [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
166
robot/ShellyEmergencyStop.js
Normal file
166
robot/ShellyEmergencyStop.js
Normal file
@@ -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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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://<host>: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'));
|
||||
|
||||
|
||||
@@ -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.
|
||||
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 });
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
119
test/Sender.Telnet.emergencyStop.test.js
Normal file
119
test/Sender.Telnet.emergencyStop.test.js
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
198
test/ShellyEmergencyStop.test.js
Normal file
198
test/ShellyEmergencyStop.test.js
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
@@ -60,7 +65,8 @@ describe('startRobot orchestrator', () => {
|
||||
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: '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: 'Hand', status: 'disconnected', reason: 'no active socket connection' },
|
||||
{ name: 'EmergencyStop', status: 'ready', reason: undefined }
|
||||
]
|
||||
});
|
||||
expect(result.sharedState.connectedClients).toEqual([]);
|
||||
|
||||
Reference in New Issue
Block a user