Compare commits

..

7 Commits

Author SHA1 Message Date
Sean O'Connor
d772aecc54 Update plugin validation script
Some checks failed
Deploy to GitHub Pages / deploy (push) Failing after 10s
Validate Plugins / validate (push) Successful in 9m24s
2026-03-21 20:21:31 -04:00
Sean O'Connor
14137ba631 Fix say_with_emotion, add wave_goodbye and play_animation actions, fix topic mappings 2026-03-21 19:29:15 -04:00
Sean O'Connor
9e0921c69c Fix: change category from gesture to movement (valid categories are movement, interaction, sensors) 2026-03-21 18:58:25 -04:00
Sean O'Connor
d3a9093d67 Fix say_with_emotion and move_arm implementation configs
- say_with_emotion: add NAOqi emotion markup to messageTemplate
- move_arm: add implementation config
2026-03-21 18:56:56 -04:00
Sean O'Connor
817ee69b87 Add combo gesture actions: bow, wave, nod, shake_head, point, greet 2026-03-21 18:54:13 -04:00
Sean O'Connor
31beaffc5b Add implementation properties for trial execution
Some checks failed
Deploy to GitHub Pages / deploy (push) Failing after 10s
Validate Plugins / validate (push) Successful in 9m26s
2026-03-21 18:28:03 -04:00
HRIStudio Integration
6a805aaa91 fix: Update ros2Config topics to match naoqi_driver namespace
- Remove /naoqi_driver/ prefix from all topic names
- Topics are published/subscribed without prefix in naoqi_driver
- Affects: cmd_vel, joint_states, joint_angles, cameras, imu, speech, bumper, touch sensors, sonar, info

This fixes the mismatch between plugin topic definitions and actual ROS topics exposed by the naoqi_driver node.
2026-03-21 17:56:47 -04:00
2 changed files with 593 additions and 196 deletions

View File

@@ -62,18 +62,18 @@
"defaultTopics": { "defaultTopics": {
"cmd_vel": "/cmd_vel", "cmd_vel": "/cmd_vel",
"odom": "/odom", "odom": "/odom",
"joint_states": "/naoqi_driver/joint_states", "joint_states": "/joint_states",
"joint_angles": "/joint_angles", "joint_angles": "/joint_angles",
"camera_front": "/naoqi_driver/camera/front/image_raw", "camera_front": "/camera/front/image_raw",
"camera_bottom": "/naoqi_driver/camera/bottom/image_raw", "camera_bottom": "/camera/bottom/image_raw",
"imu": "/naoqi_driver/imu/torso", "imu": "/imu/torso",
"speech": "/speech", "speech": "/speech",
"bumper": "/naoqi_driver/bumper", "bumper": "/bumper",
"hand_touch": "/naoqi_driver/hand_touch", "hand_touch": "/hand_touch",
"head_touch": "/naoqi_driver/head_touch", "head_touch": "/head_touch",
"sonar_left": "/naoqi_driver/sonar/left", "sonar_left": "/sonar/left",
"sonar_right": "/naoqi_driver/sonar/right", "sonar_right": "/sonar/right",
"info": "/naoqi_driver/info" "info": "/info"
} }
}, },
"settingsSchema": { "settingsSchema": {
@@ -243,10 +243,7 @@
"description": "Angular velocity in rad/s" "description": "Angular velocity in rad/s"
} }
}, },
"required": [ "required": ["linear", "angular"]
"linear",
"angular"
]
}, },
"ros2": { "ros2": {
"messageType": "geometry_msgs/msg/Twist", "messageType": "geometry_msgs/msg/Twist",
@@ -289,9 +286,7 @@
"description": "Duration to walk in seconds (0 = indefinite)" "description": "Duration to walk in seconds (0 = indefinite)"
} }
}, },
"required": [ "required": ["speed"]
"speed"
]
}, },
"ros2": { "ros2": {
"messageType": "geometry_msgs/msg/Twist", "messageType": "geometry_msgs/msg/Twist",
@@ -311,6 +306,23 @@
} }
} }
} }
},
"implementation": {
"type": "ros2_topic",
"topic": "/cmd_vel",
"messageType": "geometry_msgs/msg/Twist",
"messageTemplate": {
"linear": {
"x": "{{speed}}",
"y": 0,
"z": 0
},
"angular": {
"x": 0,
"y": 0,
"z": 0
}
}
} }
}, },
{ {
@@ -339,9 +351,7 @@
"description": "Duration to walk in seconds (0 = indefinite)" "description": "Duration to walk in seconds (0 = indefinite)"
} }
}, },
"required": [ "required": ["speed"]
"speed"
]
}, },
"ros2": { "ros2": {
"messageType": "geometry_msgs/msg/Twist", "messageType": "geometry_msgs/msg/Twist",
@@ -361,6 +371,23 @@
} }
} }
} }
},
"implementation": {
"type": "ros2_topic",
"topic": "/cmd_vel",
"messageType": "geometry_msgs/msg/Twist",
"messageTemplate": {
"linear": {
"x": "-{{speed}}",
"y": 0,
"z": 0
},
"angular": {
"x": 0,
"y": 0,
"z": 0
}
}
} }
}, },
{ {
@@ -389,9 +416,7 @@
"description": "Duration to turn in seconds (0 = indefinite)" "description": "Duration to turn in seconds (0 = indefinite)"
} }
}, },
"required": [ "required": ["speed"]
"speed"
]
}, },
"ros2": { "ros2": {
"messageType": "geometry_msgs/msg/Twist", "messageType": "geometry_msgs/msg/Twist",
@@ -411,6 +436,23 @@
} }
} }
} }
},
"implementation": {
"type": "ros2_topic",
"topic": "/cmd_vel",
"messageType": "geometry_msgs/msg/Twist",
"messageTemplate": {
"linear": {
"x": 0,
"y": 0,
"z": 0
},
"angular": {
"x": 0,
"y": 0,
"z": "{{speed}}"
}
}
} }
}, },
{ {
@@ -439,9 +481,7 @@
"description": "Duration to turn in seconds (0 = indefinite)" "description": "Duration to turn in seconds (0 = indefinite)"
} }
}, },
"required": [ "required": ["speed"]
"speed"
]
}, },
"ros2": { "ros2": {
"messageType": "geometry_msgs/msg/Twist", "messageType": "geometry_msgs/msg/Twist",
@@ -461,6 +501,23 @@
} }
} }
} }
},
"implementation": {
"type": "ros2_topic",
"topic": "/cmd_vel",
"messageType": "geometry_msgs/msg/Twist",
"messageTemplate": {
"linear": {
"x": 0,
"y": 0,
"z": 0
},
"angular": {
"x": 0,
"y": 0,
"z": "-{{speed}}"
}
}
} }
}, },
{ {
@@ -519,9 +576,7 @@
"description": "Text to speak" "description": "Text to speak"
} }
}, },
"required": [ "required": ["text"]
"text"
]
}, },
"ros2": { "ros2": {
"messageType": "std_msgs/msg/String", "messageType": "std_msgs/msg/String",
@@ -534,15 +589,23 @@
"reliability": "reliable", "reliability": "reliable",
"durability": "volatile" "durability": "volatile"
} }
},
"implementation": {
"type": "ros2_topic",
"topic": "/speech",
"messageType": "std_msgs/msg/String",
"messageTemplate": {
"data": "{{text}}"
}
} }
}, },
{ {
"id": "say_with_emotion", "id": "say_with_emotion",
"name": "Say Text with Emotion", "name": "Say Text with Emotion",
"description": "Speak text with emotional expression using SSML-like markup", "description": "Speak text with emotional expression and animated gestures. Emotions: happy (excited gestures), sad (slower, lower pitch), neutral, excited (fast, animated), calm (slower, relaxed)",
"category": "interaction", "category": "interaction",
"icon": "heart", "icon": "heart",
"timeout": 15000, "timeout": 20000,
"retryable": true, "retryable": true,
"parameterSchema": { "parameterSchema": {
"type": "object", "type": "object",
@@ -554,15 +617,9 @@
}, },
"emotion": { "emotion": {
"type": "string", "type": "string",
"enum": [ "enum": ["neutral", "happy", "sad", "excited", "calm"],
"neutral",
"happy",
"sad",
"excited",
"calm"
],
"default": "neutral", "default": "neutral",
"description": "Emotional tone for speech" "description": "Emotional tone: happy (animated), sad (slow/low pitch), excited (fast + gestures), calm (slower + relaxed)"
}, },
"speed": { "speed": {
"type": "number", "type": "number",
@@ -572,18 +629,22 @@
"description": "Speech speed multiplier" "description": "Speech speed multiplier"
} }
}, },
"required": [ "required": ["text"]
"text"
]
}, },
"ros2": { "ros2": {
"messageType": "std_msgs/msg/String", "messageType": "std_msgs/msg/String",
"topic": "/speech", "topic": "/speech",
"payloadMapping": { "payloadMapping": {
"type": "static", "type": "transform",
"payload": { "transformFn": "transformToEmotionalSpeech"
"data": "\\rspd={{speed}}\\\\rst={{emotion}}\\{{text}}" }
} },
"implementation": {
"type": "ros2_topic",
"topic": "/speech",
"messageType": "std_msgs/msg/String",
"messageTemplate": {
"data": "{{text}}"
} }
} }
}, },
@@ -606,9 +667,7 @@
"description": "Volume level (0.0 = silent, 1.0 = maximum)" "description": "Volume level (0.0 = silent, 1.0 = maximum)"
} }
}, },
"required": [ "required": ["volume"]
"volume"
]
}, },
"ros2": { "ros2": {
"messageType": "std_msgs/msg/Float32", "messageType": "std_msgs/msg/Float32",
@@ -649,9 +708,7 @@
"description": "Speech language" "description": "Speech language"
} }
}, },
"required": [ "required": ["language"]
"language"
]
}, },
"ros2": { "ros2": {
"messageType": "std_msgs/msg/String", "messageType": "std_msgs/msg/String",
@@ -697,10 +754,7 @@
"description": "Movement speed (0.1 = slow, 1.0 = fast)" "description": "Movement speed (0.1 = slow, 1.0 = fast)"
} }
}, },
"required": [ "required": ["yaw", "pitch"]
"yaw",
"pitch"
]
}, },
"ros2": { "ros2": {
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed", "messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
@@ -708,17 +762,21 @@
"payloadMapping": { "payloadMapping": {
"type": "static", "type": "static",
"payload": { "payload": {
"joint_names": [ "joint_names": ["HeadYaw", "HeadPitch"],
"HeadYaw", "joint_angles": ["{{yaw}}", "{{pitch}}"],
"HeadPitch"
],
"joint_angles": [
"{{yaw}}",
"{{pitch}}"
],
"speed": "{{speed}}" "speed": "{{speed}}"
} }
} }
},
"implementation": {
"type": "ros2_topic",
"topic": "/joint_angles",
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
"messageTemplate": {
"joint_names": ["HeadYaw", "HeadPitch"],
"joint_angles": ["{{yaw}}", "{{pitch}}"],
"speed": "{{speed}}"
}
} }
}, },
{ {
@@ -734,10 +792,7 @@
"properties": { "properties": {
"arm": { "arm": {
"type": "string", "type": "string",
"enum": [ "enum": ["left", "right"],
"left",
"right"
],
"default": "right", "default": "right",
"description": "Which arm to control" "description": "Which arm to control"
}, },
@@ -806,6 +861,26 @@
"speed": "{{speed}}" "speed": "{{speed}}"
} }
} }
},
"implementation": {
"type": "ros2_topic",
"topic": "/joint_angles",
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
"messageTemplate": {
"joint_names": [
"{{arm === 'left' ? 'L' : 'R'}}ShoulderPitch",
"{{arm === 'left' ? 'L' : 'R'}}ShoulderRoll",
"{{arm === 'left' ? 'L' : 'R'}}ElbowYaw",
"{{arm === 'left' ? 'L' : 'R'}}ElbowRoll"
],
"joint_angles": [
"{{shoulder_pitch}}",
"{{shoulder_roll}}",
"{{elbow_yaw}}",
"{{elbow_roll}}"
],
"speed": "{{speed}}"
}
} }
}, },
{ {
@@ -864,10 +939,7 @@
"description": "Movement speed (fraction of max)" "description": "Movement speed (fraction of max)"
} }
}, },
"required": [ "required": ["joint_name", "angle"]
"joint_name",
"angle"
]
}, },
"ros2": { "ros2": {
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed", "messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
@@ -911,10 +983,7 @@
"description": "Movement speed fraction" "description": "Movement speed fraction"
} }
}, },
"required": [ "required": ["yaw", "pitch"]
"yaw",
"pitch"
]
}, },
"ros2": { "ros2": {
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed", "messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
@@ -923,6 +992,16 @@
"type": "transform", "type": "transform",
"transformFn": "transformToHeadMovement" "transformFn": "transformToHeadMovement"
} }
},
"implementation": {
"type": "ros2_topic",
"topic": "/joint_angles",
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
"messageTemplate": {
"joint_names": ["HeadYaw", "HeadPitch"],
"joint_angles": ["{{yaw}}", "{{pitch}}"],
"speed": "{{speed}}"
}
} }
}, },
{ {
@@ -938,21 +1017,16 @@
"properties": { "properties": {
"camera": { "camera": {
"type": "string", "type": "string",
"enum": [ "enum": ["front", "bottom"],
"front",
"bottom"
],
"default": "front", "default": "front",
"description": "Camera to use" "description": "Camera to use"
} }
}, },
"required": [ "required": ["camera"]
"camera"
]
}, },
"ros2": { "ros2": {
"messageType": "sensor_msgs/msg/Image", "messageType": "sensor_msgs/msg/Image",
"topic": "/naoqi_driver/camera/{camera}/image_raw", "topic": "/camera/{camera}/image_raw",
"payloadMapping": { "payloadMapping": {
"type": "transform", "type": "transform",
"transformFn": "getCameraImage" "transformFn": "getCameraImage"
@@ -978,7 +1052,7 @@
}, },
"ros2": { "ros2": {
"messageType": "sensor_msgs/msg/JointState", "messageType": "sensor_msgs/msg/JointState",
"topic": "/naoqi_driver/joint_states", "topic": "/joint_states",
"payloadMapping": { "payloadMapping": {
"type": "transform", "type": "transform",
"transformFn": "getJointStates" "transformFn": "getJointStates"
@@ -1004,7 +1078,7 @@
}, },
"ros2": { "ros2": {
"messageType": "sensor_msgs/msg/Imu", "messageType": "sensor_msgs/msg/Imu",
"topic": "/naoqi_driver/imu/torso", "topic": "/imu/torso",
"payloadMapping": { "payloadMapping": {
"type": "transform", "type": "transform",
"transformFn": "getImuData" "transformFn": "getImuData"
@@ -1030,7 +1104,7 @@
}, },
"ros2": { "ros2": {
"messageType": "naoqi_bridge_msgs/msg/Bumper", "messageType": "naoqi_bridge_msgs/msg/Bumper",
"topic": "/naoqi_driver/bumper", "topic": "/bumper",
"payloadMapping": { "payloadMapping": {
"type": "transform", "type": "transform",
"transformFn": "getBumperStatus" "transformFn": "getBumperStatus"
@@ -1050,21 +1124,16 @@
"properties": { "properties": {
"sensor_type": { "sensor_type": {
"type": "string", "type": "string",
"enum": [ "enum": ["hand", "head"],
"hand",
"head"
],
"default": "hand", "default": "hand",
"description": "Touch sensor type to read" "description": "Touch sensor type to read"
} }
}, },
"required": [ "required": ["sensor_type"]
"sensor_type"
]
}, },
"ros2": { "ros2": {
"messageType": "naoqi_bridge_msgs/msg/HandTouch", "messageType": "naoqi_bridge_msgs/msg/HandTouch",
"topic": "/naoqi_driver/{sensor_type}_touch", "topic": "/{sensor_type}_touch",
"payloadMapping": { "payloadMapping": {
"type": "transform", "type": "transform",
"transformFn": "getTouchSensors" "transformFn": "getTouchSensors"
@@ -1084,22 +1153,16 @@
"properties": { "properties": {
"sensor": { "sensor": {
"type": "string", "type": "string",
"enum": [ "enum": ["left", "right", "both"],
"left",
"right",
"both"
],
"default": "both", "default": "both",
"description": "Sonar sensor to read" "description": "Sonar sensor to read"
} }
}, },
"required": [ "required": ["sensor"]
"sensor"
]
}, },
"ros2": { "ros2": {
"messageType": "sensor_msgs/msg/Range", "messageType": "sensor_msgs/msg/Range",
"topic": "/naoqi_driver/sonar/{sensor}", "topic": "/sonar/{sensor}",
"payloadMapping": { "payloadMapping": {
"type": "transform", "type": "transform",
"transformFn": "getSonarRange" "transformFn": "getSonarRange"
@@ -1121,7 +1184,7 @@
}, },
"ros2": { "ros2": {
"messageType": "naoqi_bridge_msgs/msg/RobotInfo", "messageType": "naoqi_bridge_msgs/msg/RobotInfo",
"topic": "/naoqi_driver/info", "topic": "/info",
"payloadMapping": { "payloadMapping": {
"type": "transform", "type": "transform",
"transformFn": "getRobotInfo" "transformFn": "getRobotInfo"
@@ -1173,6 +1236,312 @@
} }
} }
} }
},
{
"id": "bow",
"name": "Bow",
"description": "Perform a polite bow gesture (head down + lean forward + return)",
"category": "movement",
"icon": "user-check",
"timeout": 2000,
"retryable": true,
"parameterSchema": {
"type": "object",
"properties": {}
},
"ros2": {
"topic": "/joint_angles",
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
"payloadMapping": {
"type": "static",
"payload": {
"joint_names": ["HeadYaw", "HeadPitch"],
"joint_angles": [0, 0.5],
"speed": 0.3
}
}
},
"implementation": {
"type": "ros2_topic",
"topic": "/joint_angles",
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
"messageTemplate": {
"joint_names": ["HeadYaw", "HeadPitch"],
"joint_angles": [0, 0.5],
"speed": 0.3
}
}
},
{
"id": "wave",
"name": "Wave",
"description": "Perform a friendly wave gesture with right arm",
"category": "movement",
"icon": "hand",
"timeout": 2000,
"retryable": true,
"parameterSchema": {
"type": "object",
"properties": {}
},
"ros2": {
"topic": "/joint_angles",
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
"payloadMapping": {
"type": "static",
"payload": {
"joint_names": [
"RShoulderPitch",
"RShoulderRoll",
"RElbowYaw",
"RElbowRoll"
],
"joint_angles": [1.5, 0.2, -1.0, 0.5],
"speed": 0.4
}
}
},
"implementation": {
"type": "ros2_topic",
"topic": "/joint_angles",
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
"messageTemplate": {
"joint_names": [
"RShoulderPitch",
"RShoulderRoll",
"RElbowYaw",
"RElbowRoll"
],
"joint_angles": [1.5, 0.2, -1.0, 0.5],
"speed": 0.4
}
}
},
{
"id": "nod",
"name": "Nod",
"description": "Perform a nodding gesture (head up and down)",
"category": "movement",
"icon": "chevrons-down",
"timeout": 1500,
"retryable": true,
"parameterSchema": {
"type": "object",
"properties": {}
},
"ros2": {
"topic": "/joint_angles",
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
"payloadMapping": {
"type": "static",
"payload": {
"joint_names": ["HeadPitch"],
"joint_angles": [0.3],
"speed": 0.5
}
}
},
"implementation": {
"type": "ros2_topic",
"topic": "/joint_angles",
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
"messageTemplate": {
"joint_names": ["HeadPitch"],
"joint_angles": [0.3],
"speed": 0.5
}
}
},
{
"id": "shake_head",
"name": "Shake Head",
"description": "Perform a head shaking gesture (no)",
"category": "movement",
"icon": "x-circle",
"timeout": 1500,
"retryable": true,
"parameterSchema": {
"type": "object",
"properties": {}
},
"ros2": {
"topic": "/joint_angles",
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
"payloadMapping": {
"type": "static",
"payload": {
"joint_names": ["HeadYaw"],
"joint_angles": [0.4],
"speed": 0.5
}
}
},
"implementation": {
"type": "ros2_topic",
"topic": "/joint_angles",
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
"messageTemplate": {
"joint_names": ["HeadYaw"],
"joint_angles": [0.4],
"speed": 0.5
}
}
},
{
"id": "point",
"name": "Point",
"description": "Point at something with left arm",
"category": "movement",
"icon": "finger-pointer",
"timeout": 1500,
"retryable": true,
"parameterSchema": {
"type": "object",
"properties": {}
},
"ros2": {
"topic": "/joint_angles",
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
"payloadMapping": {
"type": "static",
"payload": {
"joint_names": [
"LShoulderPitch",
"LShoulderRoll",
"LElbowYaw",
"LElbowRoll",
"LWristYaw"
],
"joint_angles": [0.8, 0.3, -1.0, 0.1, 0],
"speed": 0.4
}
}
},
"implementation": {
"type": "ros2_topic",
"topic": "/joint_angles",
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
"messageTemplate": {
"joint_names": [
"LShoulderPitch",
"LShoulderRoll",
"LElbowYaw",
"LElbowRoll",
"LWristYaw"
],
"joint_angles": [0.8, 0.3, -1.0, 0.1, 0],
"speed": 0.4
}
}
},
{
"id": "greet",
"name": "Greet",
"description": "Combined greeting gesture: bow + wave",
"category": "movement",
"icon": "sparkles",
"timeout": 3000,
"retryable": true,
"parameterSchema": {
"type": "object",
"properties": {}
},
"ros2": {
"topic": "/joint_angles",
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
"payloadMapping": {
"type": "static",
"payload": {
"joint_names": ["HeadYaw", "HeadPitch"],
"joint_angles": [0, 0.3],
"speed": 0.4
}
}
}
},
{
"id": "wave_goodbye",
"name": "Wave Goodbye",
"description": "Animated wave goodbye gesture with speech",
"category": "interaction",
"icon": "hand",
"timeout": 4000,
"retryable": true,
"parameterSchema": {
"type": "object",
"properties": {
"text": {
"type": "string",
"default": "Goodbye!",
"description": "Text to say while waving"
}
}
},
"ros2": {
"messageType": "std_msgs/msg/String",
"topic": "/speech",
"payloadMapping": {
"type": "transform",
"transformFn": "transformToWaveGoodbye"
}
},
"implementation": {
"type": "ros2_topic",
"topic": "/speech",
"messageType": "std_msgs/msg/String",
"messageTemplate": {
"data": "{{text}}"
}
}
},
{
"id": "play_animation",
"name": "Play Animation",
"description": "Play a predefined NAO animation/gesture",
"category": "movement",
"icon": "play",
"timeout": 5000,
"retryable": true,
"parameterSchema": {
"type": "object",
"properties": {
"animation": {
"type": "string",
"enum": [
"Hey_1",
"Happy_1",
"Happy_4",
"Enthusiastic_1",
"Yes_1",
"Yes_2",
"No_1",
"Blow_1",
"Gesture_Ok_1",
"Gesture_Nice_1",
"Gesture_You_1"
],
"default": "Hey_1",
"description": "Animation to play"
}
},
"required": ["animation"]
},
"ros2": {
"messageType": "std_msgs/msg/String",
"topic": "/speech",
"payloadMapping": {
"type": "transform",
"transformFn": "transformToAnimation"
}
},
"implementation": {
"type": "ros2_topic",
"topic": "/speech",
"messageType": "std_msgs/msg/String",
"messageTemplate": {
"data": "^start(animations/Stand/Gestures/{{animation}})"
}
}
} }
] ]
} }

View File

@@ -1,35 +1,35 @@
#!/usr/bin/env node #!/usr/bin/env node
const fs = require('fs'); const fs = require("fs");
const path = require('path'); const path = require("path");
// Color output helpers // Color output helpers
const colors = { const colors = {
red: '\x1b[31m', red: "\x1b[31m",
green: '\x1b[32m', green: "\x1b[32m",
yellow: '\x1b[33m', yellow: "\x1b[33m",
blue: '\x1b[34m', blue: "\x1b[34m",
reset: '\x1b[0m' reset: "\x1b[0m",
}; };
function log(message, color = 'reset') { function log(message, color = "reset") {
console.log(`${colors[color]}${message}${colors.reset}`); console.log(`${colors[color]}${message}${colors.reset}`);
} }
function error(message) { function error(message) {
log(`${message}`, 'red'); log(`${message}`, "red");
} }
function success(message) { function success(message) {
log(`${message}`, 'green'); log(`${message}`, "green");
} }
function warn(message) { function warn(message) {
log(`⚠️ ${message}`, 'yellow'); log(`⚠️ ${message}`, "yellow");
} }
function info(message) { function info(message) {
log(` ${message}`, 'blue'); log(` ${message}`, "blue");
} }
// Plugin schema validation // Plugin schema validation
@@ -40,7 +40,7 @@ function validatePlugin(pluginPath) {
let plugin; let plugin;
try { try {
plugin = JSON.parse(fs.readFileSync(pluginPath, 'utf8')); plugin = JSON.parse(fs.readFileSync(pluginPath, "utf8"));
} catch (e) { } catch (e) {
throw new Error(`Invalid JSON syntax: ${e.message}`); throw new Error(`Invalid JSON syntax: ${e.message}`);
} }
@@ -50,17 +50,17 @@ function validatePlugin(pluginPath) {
// Required fields validation // Required fields validation
const requiredFields = [ const requiredFields = [
'robotId', "robotId",
'name', "name",
'platform', "platform",
'version', "version",
'pluginApiVersion', "pluginApiVersion",
'hriStudioVersion', "hriStudioVersion",
'trustLevel', "trustLevel",
'category' "category",
]; ];
requiredFields.forEach(field => { requiredFields.forEach((field) => {
if (!plugin[field]) { if (!plugin[field]) {
errors.push(`Missing required field: ${field}`); errors.push(`Missing required field: ${field}`);
} }
@@ -68,36 +68,43 @@ function validatePlugin(pluginPath) {
// Field format validation // Field format validation
if (plugin.robotId && !/^[a-z0-9-]+$/.test(plugin.robotId)) { if (plugin.robotId && !/^[a-z0-9-]+$/.test(plugin.robotId)) {
errors.push('robotId must be lowercase with hyphens only'); errors.push("robotId must be lowercase with hyphens only");
} }
if (plugin.version && !/^\d+\.\d+\.\d+/.test(plugin.version)) { if (plugin.version && !/^\d+\.\d+\.\d+/.test(plugin.version)) {
errors.push('version must follow semantic versioning (e.g., 1.0.0)'); errors.push("version must follow semantic versioning (e.g., 1.0.0)");
} }
if (plugin.trustLevel && !['official', 'verified', 'community'].includes(plugin.trustLevel)) { if (
errors.push(`Invalid trustLevel: ${plugin.trustLevel}. Must be: official, verified, or community`); plugin.trustLevel &&
!["official", "verified", "community"].includes(plugin.trustLevel)
) {
errors.push(
`Invalid trustLevel: ${plugin.trustLevel}. Must be: official, verified, or community`,
);
} }
// Category validation // Category validation
const validCategories = [ const validCategories = [
'mobile-robot', "mobile-robot",
'humanoid-robot', "humanoid-robot",
'manipulator', "manipulator",
'drone', "drone",
'sensor-platform', "sensor-platform",
'simulation' "simulation",
]; ];
if (plugin.category && !validCategories.includes(plugin.category)) { if (plugin.category && !validCategories.includes(plugin.category)) {
errors.push(`Invalid category: ${plugin.category}. Valid categories: ${validCategories.join(', ')}`); errors.push(
`Invalid category: ${plugin.category}. Valid categories: ${validCategories.join(", ")}`,
);
} }
// Actions validation // Actions validation
if (!plugin.actions || !Array.isArray(plugin.actions)) { if (!plugin.actions || !Array.isArray(plugin.actions)) {
errors.push('Plugin must have an actions array'); errors.push("Plugin must have an actions array");
} else if (plugin.actions.length === 0) { } else if (plugin.actions.length === 0) {
warnings.push('Plugin has no actions defined'); warnings.push("Plugin has no actions defined");
} else { } else {
plugin.actions.forEach((action, index) => { plugin.actions.forEach((action, index) => {
const actionErrors = validateAction(action, index); const actionErrors = validateAction(action, index);
@@ -108,37 +115,43 @@ function validatePlugin(pluginPath) {
// Assets validation // Assets validation
if (plugin.assets) { if (plugin.assets) {
if (!plugin.assets.thumbnailUrl) { if (!plugin.assets.thumbnailUrl) {
errors.push('assets.thumbnailUrl is required'); errors.push("assets.thumbnailUrl is required");
} }
// Check if asset paths exist // Check if asset paths exist
const assetChecks = [ const assetChecks = [
['thumbnailUrl', plugin.assets.thumbnailUrl], ["thumbnailUrl", plugin.assets.thumbnailUrl],
['main image', plugin.assets.images?.main], ["main image", plugin.assets.images?.main],
['logo', plugin.assets.images?.logo] ["logo", plugin.assets.images?.logo],
]; ];
if (plugin.assets.images?.angles) { if (plugin.assets.images?.angles) {
Object.entries(plugin.assets.images.angles).forEach(([angle, assetPath]) => { Object.entries(plugin.assets.images.angles).forEach(
assetChecks.push([`${angle} angle`, assetPath]); ([angle, assetPath]) => {
}); assetChecks.push([`${angle} angle`, assetPath]);
},
);
} }
assetChecks.forEach(([description, assetPath]) => { assetChecks.forEach(([description, assetPath]) => {
if (assetPath && assetPath.startsWith('assets/')) { if (assetPath && assetPath.startsWith("assets/")) {
const fullPath = path.resolve(path.dirname(pluginPath), '..', assetPath); const fullPath = path.resolve(
path.dirname(pluginPath),
"..",
assetPath,
);
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
warnings.push(`Asset not found: ${description} (${assetPath})`); warnings.push(`Asset not found: ${description} (${assetPath})`);
} }
} }
}); });
} else { } else {
errors.push('Plugin must have assets definition'); errors.push("Plugin must have assets definition");
} }
// Manufacturer validation // Manufacturer validation
if (!plugin.manufacturer?.name) { if (!plugin.manufacturer?.name) {
warnings.push('manufacturer.name is recommended'); warnings.push("manufacturer.name is recommended");
} }
return { errors, warnings, plugin }; return { errors, warnings, plugin };
@@ -148,8 +161,8 @@ function validateAction(action, index) {
const errors = []; const errors = [];
// Required action fields // Required action fields
const requiredFields = ['id', 'name', 'category', 'parameterSchema']; const requiredFields = ["id", "name", "category", "parameterSchema"];
requiredFields.forEach(field => { requiredFields.forEach((field) => {
if (!action[field]) { if (!action[field]) {
errors.push(`Action ${index}: missing required field '${field}'`); errors.push(`Action ${index}: missing required field '${field}'`);
} }
@@ -157,18 +170,22 @@ function validateAction(action, index) {
// Action ID format // Action ID format
if (action.id && !/^[a-z_]+$/.test(action.id)) { if (action.id && !/^[a-z_]+$/.test(action.id)) {
errors.push(`Action ${index}: id must be snake_case (lowercase with underscores)`); errors.push(
`Action ${index}: id must be snake_case (lowercase with underscores)`,
);
} }
// Action category validation // Action category validation
const validActionCategories = ['movement', 'interaction', 'sensors', 'logic']; const validActionCategories = ["movement", "interaction", "sensors", "logic"];
if (action.category && !validActionCategories.includes(action.category)) { if (action.category && !validActionCategories.includes(action.category)) {
errors.push(`Action ${index}: invalid category '${action.category}'. Valid: ${validActionCategories.join(', ')}`); errors.push(
`Action ${index}: invalid category '${action.category}'. Valid: ${validActionCategories.join(", ")}`,
);
} }
// Parameter schema validation // Parameter schema validation
if (action.parameterSchema) { if (action.parameterSchema) {
if (action.parameterSchema.type !== 'object') { if (action.parameterSchema.type !== "object") {
errors.push(`Action ${index}: parameterSchema.type must be 'object'`); errors.push(`Action ${index}: parameterSchema.type must be 'object'`);
} }
@@ -187,7 +204,9 @@ function validateAction(action, index) {
const hasRestApi = action.restApi; const hasRestApi = action.restApi;
if (!hasRos2 && !hasNaoqi && !hasRestApi) { if (!hasRos2 && !hasNaoqi && !hasRestApi) {
errors.push(`Action ${index}: must have at least one communication protocol (ros2, naoqi, or restApi)`); errors.push(
`Action ${index}: must have at least one communication protocol (ros2, naoqi, or restApi)`,
);
} }
return errors; return errors;
@@ -195,15 +214,15 @@ function validateAction(action, index) {
// Repository validation // Repository validation
function validateRepository() { function validateRepository() {
const repoPath = path.resolve('repository.json'); const repoPath = path.resolve("repository.json");
if (!fs.existsSync(repoPath)) { if (!fs.existsSync(repoPath)) {
throw new Error('repository.json not found'); throw new Error("repository.json not found");
} }
let repo; let repo;
try { try {
repo = JSON.parse(fs.readFileSync(repoPath, 'utf8')); repo = JSON.parse(fs.readFileSync(repoPath, "utf8"));
} catch (e) { } catch (e) {
throw new Error(`Invalid repository.json: ${e.message}`); throw new Error(`Invalid repository.json: ${e.message}`);
} }
@@ -212,22 +231,30 @@ function validateRepository() {
const warnings = []; const warnings = [];
// Required repository fields // Required repository fields
const requiredFields = ['id', 'name', 'apiVersion', 'pluginApiVersion', 'trust']; const requiredFields = [
requiredFields.forEach(field => { "id",
"name",
"apiVersion",
"pluginApiVersion",
"trust",
];
requiredFields.forEach((field) => {
if (!repo[field]) { if (!repo[field]) {
errors.push(`Missing required repository field: ${field}`); errors.push(`Missing required repository field: ${field}`);
} }
}); });
// Validate plugin count // Validate plugin count
const indexPath = path.resolve('plugins/index.json'); const indexPath = path.resolve("plugins/index.json");
if (fs.existsSync(indexPath)) { if (fs.existsSync(indexPath)) {
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8')); const index = JSON.parse(fs.readFileSync(indexPath, "utf8"));
const actualCount = index.length; const actualCount = index.length;
const reportedCount = repo.stats?.plugins || 0; const reportedCount = repo.stats?.plugins || 0;
if (actualCount !== reportedCount) { if (actualCount !== reportedCount) {
errors.push(`Plugin count mismatch: reported ${reportedCount}, actual ${actualCount}`); errors.push(
`Plugin count mismatch: reported ${reportedCount}, actual ${actualCount}`,
);
} }
} }
@@ -236,24 +263,25 @@ function validateRepository() {
// Update plugin index // Update plugin index
function updateIndex() { function updateIndex() {
const pluginsDir = path.resolve('plugins'); const pluginsDir = path.resolve("plugins");
const indexPath = path.join(pluginsDir, 'index.json'); const indexPath = path.join(pluginsDir, "index.json");
if (!fs.existsSync(pluginsDir)) { if (!fs.existsSync(pluginsDir)) {
throw new Error('plugins directory not found'); throw new Error("plugins directory not found");
} }
const pluginFiles = fs.readdirSync(pluginsDir) const pluginFiles = fs
.filter(file => file.endsWith('.json') && file !== 'index.json') .readdirSync(pluginsDir)
.filter((file) => file.endsWith(".json") && file !== "index.json")
.sort(); .sort();
fs.writeFileSync(indexPath, JSON.stringify(pluginFiles, null, 2)); fs.writeFileSync(indexPath, JSON.stringify(pluginFiles, null, 2));
success(`Updated index.json with ${pluginFiles.length} plugins`); success(`Updated index.json with ${pluginFiles.length} plugins`);
// Update repository stats // Update repository stats
const repoPath = path.resolve('repository.json'); const repoPath = path.resolve("repository.json");
if (fs.existsSync(repoPath)) { if (fs.existsSync(repoPath)) {
const repo = JSON.parse(fs.readFileSync(repoPath, 'utf8')); const repo = JSON.parse(fs.readFileSync(repoPath, "utf8"));
repo.stats = repo.stats || {}; repo.stats = repo.stats || {};
repo.stats.plugins = pluginFiles.length; repo.stats.plugins = pluginFiles.length;
fs.writeFileSync(repoPath, JSON.stringify(repo, null, 2)); fs.writeFileSync(repoPath, JSON.stringify(repo, null, 2));
@@ -268,10 +296,10 @@ function main() {
try { try {
switch (command) { switch (command) {
case 'validate': case "validate":
const pluginPath = args[1]; const pluginPath = args[1];
if (!pluginPath) { if (!pluginPath) {
error('Usage: validate <plugin-file>'); error("Usage: validate <plugin-file>");
process.exit(1); process.exit(1);
} }
@@ -279,53 +307,53 @@ function main() {
const { errors, warnings } = validatePlugin(pluginPath); const { errors, warnings } = validatePlugin(pluginPath);
if (errors.length > 0) { if (errors.length > 0) {
error('Validation failed:'); error("Validation failed:");
errors.forEach(err => console.log(` - ${err}`)); errors.forEach((err) => console.log(` - ${err}`));
} }
if (warnings.length > 0) { if (warnings.length > 0) {
warn('Warnings:'); warn("Warnings:");
warnings.forEach(warn => console.log(` - ${warn}`)); warnings.forEach((warn) => console.log(` - ${warn}`));
} }
if (errors.length === 0) { if (errors.length === 0) {
success('Plugin validation passed!'); success("Plugin validation passed!");
if (warnings.length === 0) { if (warnings.length === 0) {
success('No warnings found'); success("No warnings found");
} }
} else { } else {
process.exit(1); process.exit(1);
} }
break; break;
case 'validate-all': case "validate-all":
info('Validating all plugins...'); info("Validating all plugins...");
// Validate repository // Validate repository
const repoResult = validateRepository(); const repoResult = validateRepository();
if (repoResult.errors.length > 0) { if (repoResult.errors.length > 0) {
error('Repository validation failed:'); error("Repository validation failed:");
repoResult.errors.forEach(err => console.log(` - ${err}`)); repoResult.errors.forEach((err) => console.log(` - ${err}`));
process.exit(1); process.exit(1);
} }
// Validate all plugins // Validate all plugins
const indexPath = path.resolve('plugins/index.json'); const indexPath = path.resolve("plugins/index.json");
if (!fs.existsSync(indexPath)) { if (!fs.existsSync(indexPath)) {
error('plugins/index.json not found'); error("plugins/index.json not found");
process.exit(1); process.exit(1);
} }
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8')); const index = JSON.parse(fs.readFileSync(indexPath, "utf8"));
let allValid = true; let allValid = true;
for (const pluginFile of index) { for (const pluginFile of index) {
const pluginPath = path.resolve('plugins', pluginFile); const pluginPath = path.resolve("plugins", pluginFile);
try { try {
const { errors } = validatePlugin(pluginPath); const { errors } = validatePlugin(pluginPath);
if (errors.length > 0) { if (errors.length > 0) {
error(`${pluginFile}: ${errors.length} errors`); error(`${pluginFile}: ${errors.length} errors`);
errors.forEach(err => console.log(` - ${err}`)); errors.forEach((err) => console.log(` - ${err}`));
allValid = false; allValid = false;
} else { } else {
success(`${pluginFile}: valid`); success(`${pluginFile}: valid`);
@@ -337,18 +365,18 @@ function main() {
} }
if (allValid) { if (allValid) {
success('All plugins are valid!'); success("All plugins are valid!");
} else { } else {
process.exit(1); process.exit(1);
} }
break; break;
case 'update-index': case "update-index":
info('Updating plugin index...'); info("Updating plugin index...");
updateIndex(); updateIndex();
break; break;
case 'help': case "help":
default: default:
console.log(` console.log(`
HRIStudio Plugin Validator HRIStudio Plugin Validator
@@ -379,5 +407,5 @@ if (require.main === module) {
module.exports = { module.exports = {
validatePlugin, validatePlugin,
validateRepository, validateRepository,
updateIndex updateIndex,
}; };