mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 14:44:44 -05:00
Compare commits
3 Commits
main
...
70882b9dbb
| Author | SHA1 | Date | |
|---|---|---|---|
| 70882b9dbb | |||
| 7072ee487b | |||
| c206f86047 |
@@ -112,10 +112,16 @@ This documentation suite provides everything needed to understand, build, deploy
|
|||||||
- Technical debt resolution
|
- Technical debt resolution
|
||||||
- UI/UX enhancements
|
- UI/UX enhancements
|
||||||
|
|
||||||
|
### **🤖 Robot Integration Guides**
|
||||||
|
|
||||||
|
14. **[NAO6 Complete Integration Guide](./nao6-integration-complete-guide.md)** - Comprehensive NAO6 setup, troubleshooting, and production deployment
|
||||||
|
15. **[NAO6 Quick Reference](./nao6-quick-reference.md)** - Essential commands and troubleshooting for NAO6 integration
|
||||||
|
16. **[NAO6 ROS2 Setup](./nao6-ros2-setup.md)** - Basic NAO6 ROS2 driver installation guide
|
||||||
|
|
||||||
### **📖 Academic References**
|
### **📖 Academic References**
|
||||||
|
|
||||||
14. **[Research Paper](./root.tex)** - Academic LaTeX document
|
17. **[Research Paper](./root.tex)** - Academic LaTeX document
|
||||||
15. **[Bibliography](./refs.bib)** - Research references
|
18. **[Bibliography](./refs.bib)** - Research references
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -152,8 +158,14 @@ This documentation suite provides everything needed to understand, build, deploy
|
|||||||
### **For Researchers**
|
### **For Researchers**
|
||||||
1. **[Project Overview](./project-overview.md)** - Research platform capabilities
|
1. **[Project Overview](./project-overview.md)** - Research platform capabilities
|
||||||
2. **[Feature Requirements](./feature-requirements.md)** - User workflows and features
|
2. **[Feature Requirements](./feature-requirements.md)** - User workflows and features
|
||||||
3. **[ROS2 Integration](./ros2-integration.md)** - Robot platform integration
|
3. **[NAO6 Quick Reference](./nao6-quick-reference.md)** - Essential NAO6 robot control commands
|
||||||
4. **[Research Paper](./root.tex)** - Academic context and methodology
|
4. **[ROS2 Integration](./ros2-integration.md)** - Robot platform integration
|
||||||
|
5. **[Research Paper](./root.tex)** - Academic context and methodology
|
||||||
|
|
||||||
|
### **For Robot Integration**
|
||||||
|
1. **[NAO6 Complete Integration Guide](./nao6-integration-complete-guide.md)** - Full NAO6 setup and troubleshooting
|
||||||
|
2. **[NAO6 Quick Reference](./nao6-quick-reference.md)** - Essential commands and quick fixes
|
||||||
|
3. **[ROS2 Integration](./ros2-integration.md)** - General robot integration patterns
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -219,6 +231,13 @@ bun dev
|
|||||||
- **Comprehensive Testing**: Realistic seed data with complete scenarios
|
- **Comprehensive Testing**: Realistic seed data with complete scenarios
|
||||||
- **Developer Friendly**: Clear patterns and extensive documentation
|
- **Developer Friendly**: Clear patterns and extensive documentation
|
||||||
|
|
||||||
|
### **Robot Integration**
|
||||||
|
- **NAO6 Full Support**: Complete ROS2 integration with movement, speech, and sensor control
|
||||||
|
- **Real-time Control**: WebSocket-based robot control through web interface
|
||||||
|
- **Safety Features**: Emergency stops, movement limits, and comprehensive monitoring
|
||||||
|
- **Production Ready**: Tested with NAO V6.0 / NAOqi 2.8.7.4 / ROS2 Humble
|
||||||
|
- **Troubleshooting Guides**: Complete documentation for setup and problem resolution
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎊 **Project Status: Production Ready**
|
## 🎊 **Project Status: Production Ready**
|
||||||
@@ -238,6 +257,7 @@ bun dev
|
|||||||
- ✅ **Core Blocks System** - 26 blocks across events, wizard, control, observation
|
- ✅ **Core Blocks System** - 26 blocks across events, wizard, control, observation
|
||||||
- ✅ **Plugin Architecture** - Unified system for core blocks and robot actions
|
- ✅ **Plugin Architecture** - Unified system for core blocks and robot actions
|
||||||
- ✅ **Development Environment** - Realistic test data and scenarios
|
- ✅ **Development Environment** - Realistic test data and scenarios
|
||||||
|
- ✅ **NAO6 Robot Integration** - Full ROS2 integration with comprehensive control and monitoring
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -271,7 +291,7 @@ The platform is considered production-ready when:
|
|||||||
- ✅ Performance targets are achieved
|
- ✅ Performance targets are achieved
|
||||||
- ✅ Type safety is complete throughout
|
- ✅ Type safety is complete throughout
|
||||||
|
|
||||||
**All success criteria have been met. HRIStudio is ready for production deployment.**
|
**All success criteria have been met. HRIStudio is ready for production deployment with full NAO6 robot integration support.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
630
docs/nao6-integration-complete-guide.md
Normal file
630
docs/nao6-integration-complete-guide.md
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
# NAO6 HRIStudio Integration: Complete Setup and Troubleshooting Guide
|
||||||
|
|
||||||
|
This comprehensive guide documents the complete process of integrating a NAO6 robot with HRIStudio, including all troubleshooting steps and solutions discovered during implementation.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
NAO6 integration with HRIStudio provides full robot control through a web-based interface, enabling researchers to conduct Human-Robot Interaction experiments with real-time robot control, sensor monitoring, and data collection.
|
||||||
|
|
||||||
|
**Integration Architecture:**
|
||||||
|
```
|
||||||
|
HRIStudio Web Interface → WebSocket → ROS Bridge → NAOqi Driver → NAO6 Robot
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Hardware Requirements
|
||||||
|
- NAO6 robot (NAOqi OS 2.8.7+)
|
||||||
|
- Ubuntu 22.04 LTS computer
|
||||||
|
- Network connectivity between computer and NAO6
|
||||||
|
- Administrative access to both systems
|
||||||
|
|
||||||
|
### Software Requirements
|
||||||
|
- ROS2 Humble
|
||||||
|
- NAOqi Driver2 for ROS2
|
||||||
|
- rosbridge-suite
|
||||||
|
- HRIStudio platform
|
||||||
|
- SSH access to NAO robot
|
||||||
|
|
||||||
|
## Part 1: ROS2 and NAO Driver Setup
|
||||||
|
|
||||||
|
### 1.1 Install ROS2 Humble
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update system
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
|
||||||
|
# Install ROS2 Humble
|
||||||
|
sudo apt install software-properties-common
|
||||||
|
sudo add-apt-repository universe
|
||||||
|
sudo apt update && sudo apt install curl -y
|
||||||
|
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.asc | sudo apt-key add -
|
||||||
|
sudo sh -c 'echo "deb http://packages.ros.org/ros2/ubuntu $(lsb_release -cs) main" > /etc/apt/sources.list.d/ros2-latest.list'
|
||||||
|
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install ros-humble-desktop
|
||||||
|
sudo apt install ros-dev-tools
|
||||||
|
|
||||||
|
# Source ROS2
|
||||||
|
echo "source /opt/ros/humble/setup.bash" >> ~/.bashrc
|
||||||
|
source ~/.bashrc
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Install Required ROS2 Packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install rosbridge for HRIStudio communication
|
||||||
|
sudo apt install ros-humble-rosbridge-suite
|
||||||
|
|
||||||
|
# Install additional useful packages
|
||||||
|
sudo apt install ros-humble-rqt
|
||||||
|
sudo apt install ros-humble-rqt-common-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Set Up NAO Workspace
|
||||||
|
|
||||||
|
**Note:** We assume you already have a NAO workspace at `~/naoqi_ros2_ws` with the NAOqi driver installed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify workspace exists
|
||||||
|
ls ~/naoqi_ros2_ws/src/naoqi_driver2
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need to set up the workspace from scratch, refer to the NAOqi ROS2 documentation.
|
||||||
|
|
||||||
|
### 1.4 Create Integrated Launch Package
|
||||||
|
|
||||||
|
Create a launch package that combines NAOqi driver with rosbridge:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/naoqi_ros2_ws
|
||||||
|
mkdir -p src/nao_launch/launch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create launch file** (`src/nao_launch/launch/nao6_hristudio.launch.py`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch.actions import DeclareLaunchArgument
|
||||||
|
from launch.substitutions import LaunchConfiguration
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
return LaunchDescription([
|
||||||
|
# NAO IP configuration
|
||||||
|
DeclareLaunchArgument("nao_ip", default_value="nao.local"),
|
||||||
|
DeclareLaunchArgument("nao_port", default_value="9559"),
|
||||||
|
DeclareLaunchArgument("username", default_value="nao"),
|
||||||
|
DeclareLaunchArgument("password", default_value="nao"),
|
||||||
|
DeclareLaunchArgument("network_interface", default_value="eth0"),
|
||||||
|
DeclareLaunchArgument("qi_listen_url", default_value="tcp://0.0.0.0:0"),
|
||||||
|
DeclareLaunchArgument("namespace", default_value="naoqi_driver"),
|
||||||
|
DeclareLaunchArgument("bridge_port", default_value="9090"),
|
||||||
|
|
||||||
|
# NAOqi Driver
|
||||||
|
Node(
|
||||||
|
package="naoqi_driver",
|
||||||
|
executable="naoqi_driver_node",
|
||||||
|
name="naoqi_driver",
|
||||||
|
namespace=LaunchConfiguration("namespace"),
|
||||||
|
parameters=[{
|
||||||
|
"nao_ip": LaunchConfiguration("nao_ip"),
|
||||||
|
"nao_port": LaunchConfiguration("nao_port"),
|
||||||
|
"username": LaunchConfiguration("username"),
|
||||||
|
"password": LaunchConfiguration("password"),
|
||||||
|
"network_interface": LaunchConfiguration("network_interface"),
|
||||||
|
"qi_listen_url": LaunchConfiguration("qi_listen_url"),
|
||||||
|
"publish_joint_states": True,
|
||||||
|
"publish_odometry": True,
|
||||||
|
"publish_camera": True,
|
||||||
|
"publish_sensors": True,
|
||||||
|
"joint_states_frequency": 30.0,
|
||||||
|
"odom_frequency": 30.0,
|
||||||
|
"camera_frequency": 15.0,
|
||||||
|
"sensor_frequency": 10.0,
|
||||||
|
}],
|
||||||
|
output="screen",
|
||||||
|
),
|
||||||
|
|
||||||
|
# Rosbridge WebSocket Server for HRIStudio
|
||||||
|
Node(
|
||||||
|
package="rosbridge_server",
|
||||||
|
executable="rosbridge_websocket",
|
||||||
|
name="rosbridge_websocket",
|
||||||
|
parameters=[{
|
||||||
|
"port": LaunchConfiguration("bridge_port"),
|
||||||
|
"address": "0.0.0.0",
|
||||||
|
"authenticate": False,
|
||||||
|
"fragment_timeout": 600,
|
||||||
|
"delay_between_messages": 0,
|
||||||
|
"max_message_size": 10000000,
|
||||||
|
}],
|
||||||
|
output="screen",
|
||||||
|
),
|
||||||
|
|
||||||
|
# ROS API Server (required for rosbridge functionality)
|
||||||
|
Node(
|
||||||
|
package="rosapi",
|
||||||
|
executable="rosapi_node",
|
||||||
|
name="rosapi",
|
||||||
|
output="screen",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create package.xml**:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypeid="pf3"?>
|
||||||
|
<package format="3">
|
||||||
|
<name>nao_launch</name>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<description>Launch files for NAO6 HRIStudio integration</description>
|
||||||
|
<maintainer email="your@email.com">Your Name</maintainer>
|
||||||
|
<license>MIT</license>
|
||||||
|
|
||||||
|
<buildtool_depend>ament_cmake</buildtool_depend>
|
||||||
|
<exec_depend>launch</exec_depend>
|
||||||
|
<exec_depend>launch_ros</exec_depend>
|
||||||
|
<exec_depend>naoqi_driver</exec_depend>
|
||||||
|
<exec_depend>rosbridge_server</exec_depend>
|
||||||
|
<exec_depend>rosapi</exec_depend>
|
||||||
|
</package>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create CMakeLists.txt**:
|
||||||
|
|
||||||
|
```cmake
|
||||||
|
cmake_minimum_required(VERSION 3.8)
|
||||||
|
project(nao_launch)
|
||||||
|
|
||||||
|
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
|
||||||
|
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
find_package(ament_cmake REQUIRED)
|
||||||
|
|
||||||
|
install(DIRECTORY launch/
|
||||||
|
DESTINATION share/${PROJECT_NAME}/launch/
|
||||||
|
)
|
||||||
|
|
||||||
|
if(BUILD_TESTING)
|
||||||
|
find_package(ament_lint_auto REQUIRED)
|
||||||
|
ament_lint_auto_find_test_dependencies()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
ament_package()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 Build the Workspace
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/naoqi_ros2_ws
|
||||||
|
I_AGREE_TO_NAO_MESHES_LICENSE=1 I_AGREE_TO_PEPPER_MESHES_LICENSE=1 colcon build --symlink-install
|
||||||
|
source install/setup.bash
|
||||||
|
```
|
||||||
|
|
||||||
|
## Part 2: NAO Network Configuration and Connection
|
||||||
|
|
||||||
|
### 2.1 Verify NAO Network Connectivity
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test basic connectivity
|
||||||
|
ping -c 4 nao.local
|
||||||
|
|
||||||
|
# Test NAOqi service port
|
||||||
|
timeout 5 bash -c 'echo "test" | nc nao.local 9559' && echo "NAOqi port is open!" || echo "NAOqi port might be closed"
|
||||||
|
|
||||||
|
# Alternative test
|
||||||
|
telnet nao.local 9559
|
||||||
|
# Press Ctrl+C to exit if connection succeeds
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Find NAO Credentials
|
||||||
|
|
||||||
|
The default NAO credentials are typically:
|
||||||
|
- Username: `nao`
|
||||||
|
- Password: Usually `nao`, but can be custom
|
||||||
|
|
||||||
|
**Common passwords to try:**
|
||||||
|
- `nao` (default)
|
||||||
|
- Institution name (e.g., `bucknell`)
|
||||||
|
- Custom password set by administrator
|
||||||
|
|
||||||
|
## Part 3: HRIStudio Database Integration
|
||||||
|
|
||||||
|
### 3.1 Update Database Schema
|
||||||
|
|
||||||
|
The HRIStudio database needs to include NAO6 robot definitions and plugins.
|
||||||
|
|
||||||
|
**Update robots in seed script** (`scripts/seed-dev.ts`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const robots = [
|
||||||
|
{
|
||||||
|
name: "TurtleBot3 Burger",
|
||||||
|
manufacturer: "ROBOTIS",
|
||||||
|
model: "TurtleBot3 Burger",
|
||||||
|
description: "A compact, affordable, programmable, ROS2-based mobile robot for education and research",
|
||||||
|
capabilities: ["differential_drive", "lidar", "imu", "odometry"],
|
||||||
|
communicationProtocol: "ros2" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "NAO Humanoid Robot",
|
||||||
|
manufacturer: "SoftBank Robotics",
|
||||||
|
model: "NAO V6",
|
||||||
|
description: "Humanoid robot designed for education, research, and social interaction with ROS2 integration",
|
||||||
|
capabilities: [
|
||||||
|
"speech",
|
||||||
|
"vision",
|
||||||
|
"walking",
|
||||||
|
"gestures",
|
||||||
|
"joint_control",
|
||||||
|
"touch_sensors",
|
||||||
|
"sonar_sensors",
|
||||||
|
"camera_feed",
|
||||||
|
"imu",
|
||||||
|
"odometry",
|
||||||
|
],
|
||||||
|
communicationProtocol: "ros2" as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Create NAO6 Plugin Repository
|
||||||
|
|
||||||
|
Create local plugin repository at `public/nao6-plugins/`:
|
||||||
|
|
||||||
|
**Repository metadata** (`public/nao6-plugins/repository.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "NAO6 ROS2 Integration Repository",
|
||||||
|
"description": "Official NAO6 robot plugins for ROS2-based Human-Robot Interaction experiments",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "HRIStudio Team",
|
||||||
|
"email": "support@hristudio.com"
|
||||||
|
},
|
||||||
|
"trust": "official",
|
||||||
|
"license": "MIT",
|
||||||
|
"robots": [
|
||||||
|
{
|
||||||
|
"name": "NAO6",
|
||||||
|
"manufacturer": "SoftBank Robotics",
|
||||||
|
"model": "NAO V6",
|
||||||
|
"communicationProtocol": "ros2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ros2": {
|
||||||
|
"distro": "humble",
|
||||||
|
"packages": ["naoqi_driver2", "naoqi_bridge_msgs", "rosbridge_suite"],
|
||||||
|
"bridge": {
|
||||||
|
"protocol": "websocket",
|
||||||
|
"defaultPort": 9090
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Seed Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start database
|
||||||
|
sudo docker compose up -d
|
||||||
|
|
||||||
|
# Push schema changes
|
||||||
|
bun db:push
|
||||||
|
|
||||||
|
# Seed with NAO6 data
|
||||||
|
bun db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Part 4: Web Interface Integration
|
||||||
|
|
||||||
|
### 4.1 Create NAO Test Page
|
||||||
|
|
||||||
|
Create `src/app/(dashboard)/nao-test/page.tsx` with the robot control interface.
|
||||||
|
|
||||||
|
**Key points:**
|
||||||
|
- Use `~` import alias (not `@`)
|
||||||
|
- Connect to WebSocket at `ws://YOUR_IP:9090`
|
||||||
|
- Use correct ROS topic names (without `/naoqi_driver` prefix for control topics)
|
||||||
|
|
||||||
|
**Important Topic Mapping:**
|
||||||
|
- Speech: `/speech` (not `/naoqi_driver/speech`)
|
||||||
|
- Movement: `/cmd_vel` (not `/naoqi_driver/cmd_vel`)
|
||||||
|
- Joint control: `/joint_angles` (not `/naoqi_driver/joint_angles`)
|
||||||
|
- Sensor data: `/naoqi_driver/joint_states`, `/naoqi_driver/bumper`, etc.
|
||||||
|
|
||||||
|
## Part 5: Critical Troubleshooting
|
||||||
|
|
||||||
|
### 5.1 Robot Not Responding to Commands
|
||||||
|
|
||||||
|
**Symptom:** ROS topics receive commands but robot doesn't move.
|
||||||
|
|
||||||
|
**Root Cause:** NAO robots start in "safe mode" with loose joints and need to be "awakened."
|
||||||
|
|
||||||
|
**Solution - SSH Wake-Up Method:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install sshpass for automated SSH
|
||||||
|
sudo apt install sshpass -y
|
||||||
|
|
||||||
|
# Wake up robot via SSH
|
||||||
|
sshpass -p "YOUR_NAO_PASSWORD" ssh nao@nao.local "python2 -c \"
|
||||||
|
import sys
|
||||||
|
sys.path.append('/opt/aldebaran/lib/python2.7/site-packages')
|
||||||
|
import naoqi
|
||||||
|
|
||||||
|
try:
|
||||||
|
motion = naoqi.ALProxy('ALMotion', '127.0.0.1', 9559)
|
||||||
|
print 'Connected to ALMotion'
|
||||||
|
print 'Current stiffness:', motion.getStiffnesses('Body')[0] if motion.getStiffnesses('Body') else 'No stiffness data'
|
||||||
|
|
||||||
|
print 'Waking up robot...'
|
||||||
|
motion.wakeUp()
|
||||||
|
|
||||||
|
print 'Robot should now be awake!'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print 'Error:', str(e)
|
||||||
|
\""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative Physical Method:**
|
||||||
|
1. Press and hold the chest button for 3 seconds
|
||||||
|
2. Wait for the robot to stiffen and stand up
|
||||||
|
3. Robot should now respond to movement commands
|
||||||
|
|
||||||
|
### 5.2 Connection Issues
|
||||||
|
|
||||||
|
**Port Already in Use:**
|
||||||
|
```bash
|
||||||
|
# Kill existing processes
|
||||||
|
sudo fuser -k 9090/tcp
|
||||||
|
pkill -f "rosbridge\|naoqi\|ros2"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Database Connection Issues:**
|
||||||
|
```bash
|
||||||
|
# Check Docker containers
|
||||||
|
sudo docker ps
|
||||||
|
|
||||||
|
# Restart database
|
||||||
|
sudo docker compose down
|
||||||
|
sudo docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Import Alias Issues
|
||||||
|
|
||||||
|
**Error:** Module import failures in React components.
|
||||||
|
|
||||||
|
**Solution:** Use `~` import alias consistently:
|
||||||
|
```typescript
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
// NOT: import { Button } from "@/components/ui/button";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Part 6: Verification and Testing
|
||||||
|
|
||||||
|
### 6.1 System Verification Script
|
||||||
|
|
||||||
|
Create verification script to test all components:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
echo "=== NAO6 HRIStudio Integration Verification ==="
|
||||||
|
|
||||||
|
# Test 1: ROS2 Setup
|
||||||
|
echo "✓ ROS2 Humble: $ROS_DISTRO"
|
||||||
|
|
||||||
|
# Test 2: NAO Connectivity
|
||||||
|
ping -c 1 nao.local && echo "✓ NAO reachable" || echo "✗ NAO not reachable"
|
||||||
|
|
||||||
|
# Test 3: Workspace Build
|
||||||
|
[ -f ~/naoqi_ros2_ws/install/setup.bash ] && echo "✓ Workspace built" || echo "✗ Workspace not built"
|
||||||
|
|
||||||
|
# Test 4: Database Running
|
||||||
|
sudo docker ps | grep -q postgres && echo "✓ Database running" || echo "✗ Database not running"
|
||||||
|
|
||||||
|
echo "=== Verification Complete ==="
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 End-to-End Test Procedure
|
||||||
|
|
||||||
|
**Terminal 1: Start ROS Integration**
|
||||||
|
```bash
|
||||||
|
cd ~/naoqi_ros2_ws
|
||||||
|
source install/setup.bash
|
||||||
|
ros2 launch install/nao_launch/share/nao_launch/launch/nao6_hristudio.launch.py nao_ip:=nao.local password:=YOUR_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
**Terminal 2: Wake Up Robot**
|
||||||
|
```bash
|
||||||
|
# Use SSH method from Section 5.1
|
||||||
|
sshpass -p "YOUR_PASSWORD" ssh nao@nao.local "python2 -c \"...\""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Terminal 3: Start HRIStudio**
|
||||||
|
```bash
|
||||||
|
cd /path/to/hristudio
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web Interface Test:**
|
||||||
|
1. Go to `http://localhost:3000/nao-test`
|
||||||
|
2. Click "Connect" - should show "Connected"
|
||||||
|
3. Test speech: Enter text and click "Say Text"
|
||||||
|
4. Test movement: Use arrow buttons to make robot walk
|
||||||
|
5. Test head control: Move sliders to control head position
|
||||||
|
6. Monitor sensor data in tabs
|
||||||
|
|
||||||
|
### 6.3 Command-Line Testing
|
||||||
|
|
||||||
|
**Test Speech:**
|
||||||
|
```bash
|
||||||
|
ros2 topic pub --once /speech std_msgs/String "data: 'Hello from ROS2'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Movement:**
|
||||||
|
```bash
|
||||||
|
ros2 topic pub --times 3 /cmd_vel geometry_msgs/msg/Twist '{linear: {x: 0.05, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Head Movement:**
|
||||||
|
```bash
|
||||||
|
ros2 topic pub --once /joint_angles naoqi_bridge_msgs/msg/JointAnglesWithSpeed '{joint_names: ["HeadYaw"], joint_angles: [0.5], speed: 0.3}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Part 7: Production Deployment
|
||||||
|
|
||||||
|
### 7.1 Launch Script Creation
|
||||||
|
|
||||||
|
Create production-ready launch script (`scripts/launch_nao6.sh`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# NAO6 HRIStudio Integration Launch Script
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
NAO_IP="${NAO_IP:-nao.local}"
|
||||||
|
NAO_PASSWORD="${NAO_PASSWORD:-nao}"
|
||||||
|
BRIDGE_PORT="${BRIDGE_PORT:-9090}"
|
||||||
|
|
||||||
|
# Function to wake up robot
|
||||||
|
wake_up_robot() {
|
||||||
|
echo "Waking up NAO robot..."
|
||||||
|
sshpass -p "$NAO_PASSWORD" ssh nao@$NAO_IP "python2 -c \"
|
||||||
|
import sys
|
||||||
|
sys.path.append('/opt/aldebaran/lib/python2.7/site-packages')
|
||||||
|
import naoqi
|
||||||
|
motion = naoqi.ALProxy('ALMotion', '127.0.0.1', 9559)
|
||||||
|
motion.wakeUp()
|
||||||
|
print 'Robot awakened'
|
||||||
|
\""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main execution
|
||||||
|
echo "Starting NAO6 HRIStudio Integration"
|
||||||
|
echo "NAO IP: $NAO_IP"
|
||||||
|
echo "Bridge Port: $BRIDGE_PORT"
|
||||||
|
|
||||||
|
# Check connections
|
||||||
|
ping -c 1 $NAO_IP || { echo "Cannot reach NAO"; exit 1; }
|
||||||
|
|
||||||
|
# Start ROS integration
|
||||||
|
cd ~/naoqi_ros2_ws
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
# Wake up robot in background
|
||||||
|
wake_up_robot &
|
||||||
|
|
||||||
|
# Launch ROS system
|
||||||
|
exec ros2 launch install/nao_launch/share/nao_launch/launch/nao6_hristudio.launch.py \
|
||||||
|
nao_ip:="$NAO_IP" \
|
||||||
|
password:="$NAO_PASSWORD" \
|
||||||
|
bridge_port:="$BRIDGE_PORT"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Service Integration (Optional)
|
||||||
|
|
||||||
|
Create systemd service for automatic startup:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=NAO6 HRIStudio Integration
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=your_user
|
||||||
|
Environment=NAO_IP=nao.local
|
||||||
|
Environment=NAO_PASSWORD=your_password
|
||||||
|
ExecStart=/path/to/launch_nao6.sh
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
## Part 8: Safety and Best Practices
|
||||||
|
|
||||||
|
### 8.1 Safety Guidelines
|
||||||
|
|
||||||
|
- **Always keep emergency stop accessible** in the web interface
|
||||||
|
- **Start with small movements and low speeds** when testing
|
||||||
|
- **Monitor robot battery level** during long sessions
|
||||||
|
- **Ensure clear space around robot** before movement commands
|
||||||
|
- **Never leave robot unattended** during operation
|
||||||
|
|
||||||
|
### 8.2 Performance Optimization
|
||||||
|
|
||||||
|
**Network Optimization:**
|
||||||
|
```bash
|
||||||
|
# Increase network buffer sizes for camera data
|
||||||
|
sudo sysctl -w net.core.rmem_max=26214400
|
||||||
|
sudo sysctl -w net.core.rmem_default=26214400
|
||||||
|
```
|
||||||
|
|
||||||
|
**ROS2 Optimization:**
|
||||||
|
```bash
|
||||||
|
# Use optimized RMW implementation
|
||||||
|
export RMW_IMPLEMENTATION=rmw_cyclonedx_cpp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Troubleshooting Checklist
|
||||||
|
|
||||||
|
**Before Starting:**
|
||||||
|
- [ ] NAO robot powered on and connected to network
|
||||||
|
- [ ] ROS2 Humble installed and sourced
|
||||||
|
- [ ] NAO workspace built successfully
|
||||||
|
- [ ] Database running (Docker container)
|
||||||
|
- [ ] Correct NAO password known
|
||||||
|
|
||||||
|
**During Operation:**
|
||||||
|
- [ ] rosbridge WebSocket server running on port 9090
|
||||||
|
- [ ] NAO robot in standing position (not crouching)
|
||||||
|
- [ ] Robot joints stiffened (not loose)
|
||||||
|
- [ ] HRIStudio web interface connected to ROS bridge
|
||||||
|
|
||||||
|
**If Commands Not Working:**
|
||||||
|
1. Check robot is awake and standing
|
||||||
|
2. Verify topic names in web interface match ROS topics
|
||||||
|
3. Test commands from command line first
|
||||||
|
4. Check rosbridge logs for errors
|
||||||
|
|
||||||
|
## Part 9: Future Enhancements
|
||||||
|
|
||||||
|
### 9.1 Advanced Features
|
||||||
|
|
||||||
|
- **Multi-camera streaming** for experiment recording
|
||||||
|
- **Advanced gesture recognition** through touch sensors
|
||||||
|
- **Autonomous behavior integration** with navigation
|
||||||
|
- **Multi-robot coordination** for group interaction studies
|
||||||
|
|
||||||
|
### 9.2 Plugin Development
|
||||||
|
|
||||||
|
The NAO6 integration supports the HRIStudio plugin system for adding custom behaviors and extending robot capabilities.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
This guide provides a complete integration of NAO6 robots with HRIStudio, enabling researchers to conduct sophisticated Human-Robot Interaction experiments with full robot control, real-time data collection, and web-based interfaces.
|
||||||
|
|
||||||
|
The key insight discovered during implementation is that NAO robots require explicit "wake-up" commands to enable motor control, which must be performed before any movement commands will be executed.
|
||||||
|
|
||||||
|
**Support Resources:**
|
||||||
|
- NAO Documentation: https://developer.softbankrobotics.com/nao6
|
||||||
|
- naoqi_driver2: https://github.com/ros-naoqi/naoqi_driver2
|
||||||
|
- ROS2 Humble: https://docs.ros.org/en/humble/
|
||||||
|
- HRIStudio Documentation: See `docs/` folder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Integration Status: Production Ready ✅**
|
||||||
|
|
||||||
|
*Last Updated: January 2025*
|
||||||
|
*Tested With: NAO V6.0 / NAOqi 2.8.7.4 / ROS2 Humble / HRIStudio v1.0*
|
||||||
218
docs/nao6-quick-reference.md
Normal file
218
docs/nao6-quick-reference.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# NAO6 HRIStudio Quick Reference
|
||||||
|
|
||||||
|
**Essential commands for NAO6 robot integration with HRIStudio**
|
||||||
|
|
||||||
|
## 🚀 Quick Start (5 Steps)
|
||||||
|
|
||||||
|
### 1. Start ROS Integration
|
||||||
|
```bash
|
||||||
|
cd ~/naoqi_ros2_ws
|
||||||
|
source install/setup.bash
|
||||||
|
ros2 launch install/nao_launch/share/nao_launch/launch/nao6_hristudio.launch.py nao_ip:=nao.local password:=robolab
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Wake Up Robot (CRITICAL!)
|
||||||
|
```bash
|
||||||
|
sshpass -p "robolab" ssh nao@nao.local "python2 -c \"
|
||||||
|
import sys
|
||||||
|
sys.path.append('/opt/aldebaran/lib/python2.7/site-packages')
|
||||||
|
import naoqi
|
||||||
|
motion = naoqi.ALProxy('ALMotion', '127.0.0.1', 9559)
|
||||||
|
motion.wakeUp()
|
||||||
|
print 'Robot awakened'
|
||||||
|
\""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start HRIStudio
|
||||||
|
```bash
|
||||||
|
cd /home/robolab/Documents/Projects/hristudio
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Access Test Interface
|
||||||
|
- URL: `http://localhost:3000/nao-test`
|
||||||
|
- Login: `sean@soconnor.dev` / `password123`
|
||||||
|
|
||||||
|
### 5. Test Robot
|
||||||
|
- Click "Connect" to WebSocket
|
||||||
|
- Try speech: "Hello from HRIStudio!"
|
||||||
|
- Use movement buttons to control robot
|
||||||
|
|
||||||
|
## 🛠️ Essential Commands
|
||||||
|
|
||||||
|
### Connection Testing
|
||||||
|
```bash
|
||||||
|
# Test NAO connectivity
|
||||||
|
ping nao.local
|
||||||
|
|
||||||
|
# Test NAOqi service
|
||||||
|
telnet nao.local 9559
|
||||||
|
|
||||||
|
# Check ROS topics
|
||||||
|
ros2 topic list | grep naoqi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Robot Control
|
||||||
|
```bash
|
||||||
|
# Speech test
|
||||||
|
ros2 topic pub --once /speech std_msgs/String "data: 'Hello world'"
|
||||||
|
|
||||||
|
# Movement test (robot must be awake!)
|
||||||
|
ros2 topic pub --times 3 /cmd_vel geometry_msgs/msg/Twist '{linear: {x: 0.05, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}'
|
||||||
|
|
||||||
|
# Head movement test
|
||||||
|
ros2 topic pub --once /joint_angles naoqi_bridge_msgs/msg/JointAnglesWithSpeed '{joint_names: ["HeadYaw"], joint_angles: [0.5], speed: 0.3}'
|
||||||
|
|
||||||
|
# Stop all movement
|
||||||
|
ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist '{linear: {x: 0.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Checks
|
||||||
|
```bash
|
||||||
|
# Check robot info
|
||||||
|
ros2 service call /naoqi_driver/get_robot_config naoqi_bridge_msgs/srv/GetRobotInfo
|
||||||
|
|
||||||
|
# Monitor joint states
|
||||||
|
ros2 topic echo /naoqi_driver/joint_states --once
|
||||||
|
|
||||||
|
# Check ROS nodes
|
||||||
|
ros2 node list
|
||||||
|
|
||||||
|
# Check WebSocket connection
|
||||||
|
ss -an | grep 9090
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
|
### Robot Not Moving
|
||||||
|
**Problem:** Commands sent but robot doesn't move
|
||||||
|
**Solution:** Robot needs to be awakened first
|
||||||
|
```bash
|
||||||
|
# Wake up via SSH (see step 2 above)
|
||||||
|
# OR press chest button for 3 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection Issues
|
||||||
|
```bash
|
||||||
|
# Kill existing processes
|
||||||
|
sudo fuser -k 9090/tcp
|
||||||
|
pkill -f "rosbridge\|naoqi\|ros2"
|
||||||
|
|
||||||
|
# Restart database
|
||||||
|
sudo docker compose down && sudo docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Errors in Web Interface
|
||||||
|
**Problem:** React component import failures
|
||||||
|
**Solution:** Use `~` import alias consistently:
|
||||||
|
```typescript
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
// NOT: import { Button } from "@/components/ui/button";
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Key Topics
|
||||||
|
|
||||||
|
### Input Topics (Robot Control)
|
||||||
|
- `/speech` - Text-to-speech
|
||||||
|
- `/cmd_vel` - Movement commands
|
||||||
|
- `/joint_angles` - Joint position control
|
||||||
|
|
||||||
|
### Output Topics (Sensor Data)
|
||||||
|
- `/naoqi_driver/joint_states` - Joint positions/velocities
|
||||||
|
- `/naoqi_driver/bumper` - Foot sensors
|
||||||
|
- `/naoqi_driver/hand_touch` - Hand touch sensors
|
||||||
|
- `/naoqi_driver/head_touch` - Head touch sensors
|
||||||
|
- `/naoqi_driver/sonar/left` - Left ultrasonic sensor
|
||||||
|
- `/naoqi_driver/sonar/right` - Right ultrasonic sensor
|
||||||
|
- `/naoqi_driver/camera/front/image_raw` - Front camera
|
||||||
|
- `/naoqi_driver/camera/bottom/image_raw` - Bottom camera
|
||||||
|
|
||||||
|
## 🔗 WebSocket Integration
|
||||||
|
|
||||||
|
**ROS Bridge URL:** `ws://134.82.159.25:9090`
|
||||||
|
|
||||||
|
**Message Format:**
|
||||||
|
```javascript
|
||||||
|
// Publish command
|
||||||
|
{
|
||||||
|
"op": "publish",
|
||||||
|
"topic": "/speech",
|
||||||
|
"type": "std_msgs/String",
|
||||||
|
"msg": {"data": "Hello world"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to topic
|
||||||
|
{
|
||||||
|
"op": "subscribe",
|
||||||
|
"topic": "/naoqi_driver/joint_states",
|
||||||
|
"type": "sensor_msgs/JointState"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Common Use Cases
|
||||||
|
|
||||||
|
### Make Robot Speak
|
||||||
|
```bash
|
||||||
|
ros2 topic pub --once /speech std_msgs/String "data: 'Welcome to the experiment'"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Walk Forward 3 Steps
|
||||||
|
```bash
|
||||||
|
ros2 topic pub --times 3 /cmd_vel geometry_msgs/msg/Twist '{linear: {x: 0.1, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Turn Head Left
|
||||||
|
```bash
|
||||||
|
ros2 topic pub --once /joint_angles naoqi_bridge_msgs/msg/JointAnglesWithSpeed '{joint_names: ["HeadYaw"], joint_angles: [0.8], speed: 0.2}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Emergency Stop
|
||||||
|
```bash
|
||||||
|
ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist '{linear: {x: 0.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Safety Notes
|
||||||
|
|
||||||
|
- **Always wake up robot before movement commands**
|
||||||
|
- **Keep emergency stop accessible**
|
||||||
|
- **Start with small movements (0.05 m/s)**
|
||||||
|
- **Monitor battery level during experiments**
|
||||||
|
- **Ensure clear space around robot**
|
||||||
|
|
||||||
|
## 📝 Credentials
|
||||||
|
|
||||||
|
**Default NAO Login:**
|
||||||
|
- Username: `nao`
|
||||||
|
- Password: `robolab` (institution-specific)
|
||||||
|
|
||||||
|
**HRIStudio Login:**
|
||||||
|
- Email: `sean@soconnor.dev`
|
||||||
|
- Password: `password123`
|
||||||
|
|
||||||
|
## 🔄 Complete Restart Procedure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Kill all processes
|
||||||
|
sudo fuser -k 9090/tcp
|
||||||
|
pkill -f "rosbridge\|naoqi\|ros2"
|
||||||
|
|
||||||
|
# 2. Restart database
|
||||||
|
sudo docker compose down && sudo docker compose up -d
|
||||||
|
|
||||||
|
# 3. Start ROS integration
|
||||||
|
cd ~/naoqi_ros2_ws && source install/setup.bash
|
||||||
|
ros2 launch install/nao_launch/share/nao_launch/launch/nao6_hristudio.launch.py nao_ip:=nao.local password:=robolab
|
||||||
|
|
||||||
|
# 4. Wake up robot (in another terminal)
|
||||||
|
sshpass -p "robolab" ssh nao@nao.local "python2 -c \"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; naoqi.ALProxy('ALMotion', '127.0.0.1', 9559).wakeUp()\""
|
||||||
|
|
||||||
|
# 5. Start HRIStudio (in another terminal)
|
||||||
|
cd /home/robolab/Documents/Projects/hristudio && bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**📖 For detailed setup instructions, see:** [NAO6 Complete Integration Guide](./nao6-integration-complete-guide.md)
|
||||||
|
|
||||||
|
**✅ Integration Status:** Production Ready
|
||||||
|
**🤖 Tested With:** NAO V6.0 / NAOqi 2.8.7.4 / ROS2 Humble
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
This guide walks you through setting up your NAO6 robot with ROS2 integration for use with HRIStudio's experiment platform.
|
This guide walks you through setting up your NAO6 robot with ROS2 integration for use with HRIStudio's experiment platform.
|
||||||
|
|
||||||
|
> **📋 For Complete Integration Guide:** See [NAO6 Complete Integration Guide](./nao6-integration-complete-guide.md) for comprehensive setup, troubleshooting, and production deployment instructions.
|
||||||
|
|
||||||
|
**⚠️ Critical Note:** NAO robots must be "awakened" (motors stiffened and standing) before movement commands will work. See the troubleshooting section below.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- NAO6 robot with NAOqi OS 2.8.7+
|
- NAO6 robot with NAOqi OS 2.8.7+
|
||||||
|
|||||||
7
public/nao6-plugins/plugins/index.json
Normal file
7
public/nao6-plugins/plugins/index.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
"nao6-movement.json",
|
||||||
|
"nao6-speech.json",
|
||||||
|
"nao6-sensors.json",
|
||||||
|
"nao6-vision.json",
|
||||||
|
"nao6-interaction.json"
|
||||||
|
]
|
||||||
342
public/nao6-plugins/plugins/nao6-movement.json
Normal file
342
public/nao6-plugins/plugins/nao6-movement.json
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
{
|
||||||
|
"name": "NAO6 Movement Control",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Complete movement control for NAO6 robot including walking, turning, and joint manipulation",
|
||||||
|
"platform": "NAO6",
|
||||||
|
"category": "movement",
|
||||||
|
"manufacturer": {
|
||||||
|
"name": "SoftBank Robotics",
|
||||||
|
"website": "https://www.softbankrobotics.com"
|
||||||
|
},
|
||||||
|
"documentation": {
|
||||||
|
"mainUrl": "https://docs.hristudio.com/robots/nao6/movement",
|
||||||
|
"quickStart": "https://docs.hristudio.com/robots/nao6/movement/quickstart"
|
||||||
|
},
|
||||||
|
"ros2Config": {
|
||||||
|
"namespace": "/naoqi_driver",
|
||||||
|
"topics": {
|
||||||
|
"cmd_vel": {
|
||||||
|
"type": "geometry_msgs/Twist",
|
||||||
|
"description": "Velocity commands for robot base movement"
|
||||||
|
},
|
||||||
|
"joint_angles": {
|
||||||
|
"type": "naoqi_bridge_msgs/JointAnglesWithSpeed",
|
||||||
|
"description": "Individual joint angle control with speed"
|
||||||
|
},
|
||||||
|
"joint_states": {
|
||||||
|
"type": "sensor_msgs/JointState",
|
||||||
|
"description": "Current joint positions and velocities"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "walk_forward",
|
||||||
|
"name": "Walk Forward",
|
||||||
|
"description": "Make the robot walk forward at specified speed",
|
||||||
|
"category": "movement",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "speed",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Walking speed in m/s",
|
||||||
|
"required": true,
|
||||||
|
"min": 0.01,
|
||||||
|
"max": 0.3,
|
||||||
|
"default": 0.1,
|
||||||
|
"step": 0.01
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "duration",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Duration to walk in seconds (0 = indefinite)",
|
||||||
|
"required": false,
|
||||||
|
"min": 0,
|
||||||
|
"max": 30,
|
||||||
|
"default": 0,
|
||||||
|
"step": 0.1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/cmd_vel",
|
||||||
|
"messageType": "geometry_msgs/Twist",
|
||||||
|
"messageTemplate": {
|
||||||
|
"linear": { "x": "{{speed}}", "y": 0, "z": 0 },
|
||||||
|
"angular": { "x": 0, "y": 0, "z": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "walk_backward",
|
||||||
|
"name": "Walk Backward",
|
||||||
|
"description": "Make the robot walk backward at specified speed",
|
||||||
|
"category": "movement",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "speed",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Walking speed in m/s",
|
||||||
|
"required": true,
|
||||||
|
"min": 0.01,
|
||||||
|
"max": 0.3,
|
||||||
|
"default": 0.1,
|
||||||
|
"step": 0.01
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "duration",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Duration to walk in seconds (0 = indefinite)",
|
||||||
|
"required": false,
|
||||||
|
"min": 0,
|
||||||
|
"max": 30,
|
||||||
|
"default": 0,
|
||||||
|
"step": 0.1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/cmd_vel",
|
||||||
|
"messageType": "geometry_msgs/Twist",
|
||||||
|
"messageTemplate": {
|
||||||
|
"linear": { "x": "-{{speed}}", "y": 0, "z": 0 },
|
||||||
|
"angular": { "x": 0, "y": 0, "z": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "turn_left",
|
||||||
|
"name": "Turn Left",
|
||||||
|
"description": "Make the robot turn left at specified angular speed",
|
||||||
|
"category": "movement",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "speed",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Angular speed in rad/s",
|
||||||
|
"required": true,
|
||||||
|
"min": 0.1,
|
||||||
|
"max": 1.0,
|
||||||
|
"default": 0.3,
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "duration",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Duration to turn in seconds (0 = indefinite)",
|
||||||
|
"required": false,
|
||||||
|
"min": 0,
|
||||||
|
"max": 30,
|
||||||
|
"default": 0,
|
||||||
|
"step": 0.1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/cmd_vel",
|
||||||
|
"messageType": "geometry_msgs/Twist",
|
||||||
|
"messageTemplate": {
|
||||||
|
"linear": { "x": 0, "y": 0, "z": 0 },
|
||||||
|
"angular": { "x": 0, "y": 0, "z": "{{speed}}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "turn_right",
|
||||||
|
"name": "Turn Right",
|
||||||
|
"description": "Make the robot turn right at specified angular speed",
|
||||||
|
"category": "movement",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "speed",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Angular speed in rad/s",
|
||||||
|
"required": true,
|
||||||
|
"min": 0.1,
|
||||||
|
"max": 1.0,
|
||||||
|
"default": 0.3,
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "duration",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Duration to turn in seconds (0 = indefinite)",
|
||||||
|
"required": false,
|
||||||
|
"min": 0,
|
||||||
|
"max": 30,
|
||||||
|
"default": 0,
|
||||||
|
"step": 0.1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/cmd_vel",
|
||||||
|
"messageType": "geometry_msgs/Twist",
|
||||||
|
"messageTemplate": {
|
||||||
|
"linear": { "x": 0, "y": 0, "z": 0 },
|
||||||
|
"angular": { "x": 0, "y": 0, "z": "-{{speed}}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "stop_movement",
|
||||||
|
"name": "Stop Movement",
|
||||||
|
"description": "Immediately stop all robot movement",
|
||||||
|
"category": "movement",
|
||||||
|
"parameters": [],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/cmd_vel",
|
||||||
|
"messageType": "geometry_msgs/Twist",
|
||||||
|
"messageTemplate": {
|
||||||
|
"linear": { "x": 0, "y": 0, "z": 0 },
|
||||||
|
"angular": { "x": 0, "y": 0, "z": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "move_head",
|
||||||
|
"name": "Move Head",
|
||||||
|
"description": "Control head orientation (yaw and pitch)",
|
||||||
|
"category": "movement",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "yaw",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Head yaw angle in radians",
|
||||||
|
"required": true,
|
||||||
|
"min": -2.09,
|
||||||
|
"max": 2.09,
|
||||||
|
"default": 0,
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pitch",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Head pitch angle in radians",
|
||||||
|
"required": true,
|
||||||
|
"min": -0.67,
|
||||||
|
"max": 0.51,
|
||||||
|
"default": 0,
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "speed",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Movement speed (0.1 = slow, 1.0 = fast)",
|
||||||
|
"required": false,
|
||||||
|
"min": 0.1,
|
||||||
|
"max": 1.0,
|
||||||
|
"default": 0.3,
|
||||||
|
"step": 0.1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/joint_angles",
|
||||||
|
"messageType": "naoqi_bridge_msgs/JointAnglesWithSpeed",
|
||||||
|
"messageTemplate": {
|
||||||
|
"joint_names": ["HeadYaw", "HeadPitch"],
|
||||||
|
"joint_angles": ["{{yaw}}", "{{pitch}}"],
|
||||||
|
"speed": "{{speed}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "move_arm",
|
||||||
|
"name": "Move Arm",
|
||||||
|
"description": "Control arm joint positions",
|
||||||
|
"category": "movement",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "arm",
|
||||||
|
"type": "select",
|
||||||
|
"description": "Which arm to control",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{ "value": "left", "label": "Left Arm" },
|
||||||
|
{ "value": "right", "label": "Right Arm" }
|
||||||
|
],
|
||||||
|
"default": "right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "shoulder_pitch",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Shoulder pitch angle in radians",
|
||||||
|
"required": true,
|
||||||
|
"min": -2.09,
|
||||||
|
"max": 2.09,
|
||||||
|
"default": 1.4,
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "shoulder_roll",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Shoulder roll angle in radians",
|
||||||
|
"required": true,
|
||||||
|
"min": -0.31,
|
||||||
|
"max": 1.33,
|
||||||
|
"default": 0.2,
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "elbow_yaw",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Elbow yaw angle in radians",
|
||||||
|
"required": true,
|
||||||
|
"min": -2.09,
|
||||||
|
"max": 2.09,
|
||||||
|
"default": 0,
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "elbow_roll",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Elbow roll angle in radians",
|
||||||
|
"required": true,
|
||||||
|
"min": -1.54,
|
||||||
|
"max": -0.03,
|
||||||
|
"default": -0.5,
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "speed",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Movement speed (0.1 = slow, 1.0 = fast)",
|
||||||
|
"required": false,
|
||||||
|
"min": 0.1,
|
||||||
|
"max": 1.0,
|
||||||
|
"default": 0.3,
|
||||||
|
"step": 0.1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/joint_angles",
|
||||||
|
"messageType": "naoqi_bridge_msgs/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}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"safety": {
|
||||||
|
"maxSpeed": 0.3,
|
||||||
|
"emergencyStop": {
|
||||||
|
"action": "stop_movement",
|
||||||
|
"description": "Immediately stops all movement"
|
||||||
|
},
|
||||||
|
"jointLimits": {
|
||||||
|
"HeadYaw": { "min": -2.09, "max": 2.09 },
|
||||||
|
"HeadPitch": { "min": -0.67, "max": 0.51 },
|
||||||
|
"LShoulderPitch": { "min": -2.09, "max": 2.09 },
|
||||||
|
"RShoulderPitch": { "min": -2.09, "max": 2.09 },
|
||||||
|
"LShoulderRoll": { "min": -0.31, "max": 1.33 },
|
||||||
|
"RShoulderRoll": { "min": -1.33, "max": 0.31 },
|
||||||
|
"LElbowYaw": { "min": -2.09, "max": 2.09 },
|
||||||
|
"RElbowYaw": { "min": -2.09, "max": 2.09 },
|
||||||
|
"LElbowRoll": { "min": 0.03, "max": 1.54 },
|
||||||
|
"RElbowRoll": { "min": -1.54, "max": -0.03 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
464
public/nao6-plugins/plugins/nao6-sensors.json
Normal file
464
public/nao6-plugins/plugins/nao6-sensors.json
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
{
|
||||||
|
"name": "NAO6 Sensors & Feedback",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Complete sensor suite for NAO6 robot including touch sensors, sonar, IMU, cameras, and joint state monitoring",
|
||||||
|
"platform": "NAO6",
|
||||||
|
"category": "sensors",
|
||||||
|
"manufacturer": {
|
||||||
|
"name": "SoftBank Robotics",
|
||||||
|
"website": "https://www.softbankrobotics.com"
|
||||||
|
},
|
||||||
|
"documentation": {
|
||||||
|
"mainUrl": "https://docs.hristudio.com/robots/nao6/sensors",
|
||||||
|
"quickStart": "https://docs.hristudio.com/robots/nao6/sensors/quickstart"
|
||||||
|
},
|
||||||
|
"ros2Config": {
|
||||||
|
"namespace": "/naoqi_driver",
|
||||||
|
"topics": {
|
||||||
|
"joint_states": {
|
||||||
|
"type": "sensor_msgs/JointState",
|
||||||
|
"description": "Current positions, velocities, and efforts of all joints"
|
||||||
|
},
|
||||||
|
"imu": {
|
||||||
|
"type": "sensor_msgs/Imu",
|
||||||
|
"description": "Inertial measurement unit data (acceleration, angular velocity, orientation)"
|
||||||
|
},
|
||||||
|
"bumper": {
|
||||||
|
"type": "naoqi_bridge_msgs/Bumper",
|
||||||
|
"description": "Foot bumper sensor states"
|
||||||
|
},
|
||||||
|
"hand_touch": {
|
||||||
|
"type": "naoqi_bridge_msgs/HandTouch",
|
||||||
|
"description": "Hand tactile sensor states"
|
||||||
|
},
|
||||||
|
"head_touch": {
|
||||||
|
"type": "naoqi_bridge_msgs/HeadTouch",
|
||||||
|
"description": "Head tactile sensor states"
|
||||||
|
},
|
||||||
|
"sonar/left": {
|
||||||
|
"type": "sensor_msgs/Range",
|
||||||
|
"description": "Left ultrasonic range sensor"
|
||||||
|
},
|
||||||
|
"sonar/right": {
|
||||||
|
"type": "sensor_msgs/Range",
|
||||||
|
"description": "Right ultrasonic range sensor"
|
||||||
|
},
|
||||||
|
"camera/front/image_raw": {
|
||||||
|
"type": "sensor_msgs/Image",
|
||||||
|
"description": "Front camera image feed"
|
||||||
|
},
|
||||||
|
"camera/bottom/image_raw": {
|
||||||
|
"type": "sensor_msgs/Image",
|
||||||
|
"description": "Bottom camera image feed"
|
||||||
|
},
|
||||||
|
"battery": {
|
||||||
|
"type": "sensor_msgs/BatteryState",
|
||||||
|
"description": "Battery level and charging status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "get_joint_states",
|
||||||
|
"name": "Get Joint States",
|
||||||
|
"description": "Read current positions and velocities of all robot joints",
|
||||||
|
"category": "sensors",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "specific_joints",
|
||||||
|
"type": "multiselect",
|
||||||
|
"description": "Specific joints to monitor (empty = all joints)",
|
||||||
|
"required": false,
|
||||||
|
"options": [
|
||||||
|
{ "value": "HeadYaw", "label": "Head Yaw" },
|
||||||
|
{ "value": "HeadPitch", "label": "Head Pitch" },
|
||||||
|
{ "value": "LShoulderPitch", "label": "Left Shoulder Pitch" },
|
||||||
|
{ "value": "LShoulderRoll", "label": "Left Shoulder Roll" },
|
||||||
|
{ "value": "LElbowYaw", "label": "Left Elbow Yaw" },
|
||||||
|
{ "value": "LElbowRoll", "label": "Left Elbow Roll" },
|
||||||
|
{ "value": "RShoulderPitch", "label": "Right Shoulder Pitch" },
|
||||||
|
{ "value": "RShoulderRoll", "label": "Right Shoulder Roll" },
|
||||||
|
{ "value": "RElbowYaw", "label": "Right Elbow Yaw" },
|
||||||
|
{ "value": "RElbowRoll", "label": "Right Elbow Roll" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/joint_states",
|
||||||
|
"messageType": "sensor_msgs/JointState",
|
||||||
|
"mode": "subscribe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "get_touch_sensors",
|
||||||
|
"name": "Get Touch Sensors",
|
||||||
|
"description": "Monitor all tactile sensors on head and hands",
|
||||||
|
"category": "sensors",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "sensor_type",
|
||||||
|
"type": "select",
|
||||||
|
"description": "Type of touch sensors to monitor",
|
||||||
|
"required": false,
|
||||||
|
"options": [
|
||||||
|
{ "value": "all", "label": "All Touch Sensors" },
|
||||||
|
{ "value": "head", "label": "Head Touch Only" },
|
||||||
|
{ "value": "hands", "label": "Hand Touch Only" }
|
||||||
|
],
|
||||||
|
"default": "all"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topics": [
|
||||||
|
"/naoqi_driver/head_touch",
|
||||||
|
"/naoqi_driver/hand_touch"
|
||||||
|
],
|
||||||
|
"messageTypes": [
|
||||||
|
"naoqi_bridge_msgs/HeadTouch",
|
||||||
|
"naoqi_bridge_msgs/HandTouch"
|
||||||
|
],
|
||||||
|
"mode": "subscribe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "get_sonar_distance",
|
||||||
|
"name": "Get Sonar Distance",
|
||||||
|
"description": "Read ultrasonic distance sensors for obstacle detection",
|
||||||
|
"category": "sensors",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "sensor_side",
|
||||||
|
"type": "select",
|
||||||
|
"description": "Which sonar sensor to read",
|
||||||
|
"required": false,
|
||||||
|
"options": [
|
||||||
|
{ "value": "both", "label": "Both Sensors" },
|
||||||
|
{ "value": "left", "label": "Left Sensor Only" },
|
||||||
|
{ "value": "right", "label": "Right Sensor Only" }
|
||||||
|
],
|
||||||
|
"default": "both"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "min_range",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Minimum detection range in meters",
|
||||||
|
"required": false,
|
||||||
|
"min": 0.1,
|
||||||
|
"max": 1.0,
|
||||||
|
"default": 0.25,
|
||||||
|
"step": 0.05
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "max_range",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Maximum detection range in meters",
|
||||||
|
"required": false,
|
||||||
|
"min": 1.0,
|
||||||
|
"max": 3.0,
|
||||||
|
"default": 2.55,
|
||||||
|
"step": 0.05
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topics": [
|
||||||
|
"/naoqi_driver/sonar/left",
|
||||||
|
"/naoqi_driver/sonar/right"
|
||||||
|
],
|
||||||
|
"messageType": "sensor_msgs/Range",
|
||||||
|
"mode": "subscribe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "get_imu_data",
|
||||||
|
"name": "Get IMU Data",
|
||||||
|
"description": "Read inertial measurement unit data (acceleration, gyroscope, orientation)",
|
||||||
|
"category": "sensors",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "data_type",
|
||||||
|
"type": "select",
|
||||||
|
"description": "Type of IMU data to monitor",
|
||||||
|
"required": false,
|
||||||
|
"options": [
|
||||||
|
{ "value": "all", "label": "All IMU Data" },
|
||||||
|
{ "value": "orientation", "label": "Orientation Only" },
|
||||||
|
{ "value": "acceleration", "label": "Linear Acceleration" },
|
||||||
|
{ "value": "angular_velocity", "label": "Angular Velocity" }
|
||||||
|
],
|
||||||
|
"default": "all"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/imu",
|
||||||
|
"messageType": "sensor_msgs/Imu",
|
||||||
|
"mode": "subscribe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "get_camera_image",
|
||||||
|
"name": "Get Camera Image",
|
||||||
|
"description": "Capture image from robot's cameras",
|
||||||
|
"category": "sensors",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "camera",
|
||||||
|
"type": "select",
|
||||||
|
"description": "Which camera to use",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{ "value": "front", "label": "Front Camera" },
|
||||||
|
{ "value": "bottom", "label": "Bottom Camera" }
|
||||||
|
],
|
||||||
|
"default": "front"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resolution",
|
||||||
|
"type": "select",
|
||||||
|
"description": "Image resolution",
|
||||||
|
"required": false,
|
||||||
|
"options": [
|
||||||
|
{ "value": "160x120", "label": "QQVGA (160x120)" },
|
||||||
|
{ "value": "320x240", "label": "QVGA (320x240)" },
|
||||||
|
{ "value": "640x480", "label": "VGA (640x480)" }
|
||||||
|
],
|
||||||
|
"default": "320x240"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fps",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Frames per second",
|
||||||
|
"required": false,
|
||||||
|
"min": 1,
|
||||||
|
"max": 30,
|
||||||
|
"default": 15,
|
||||||
|
"step": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/camera/{{camera}}/image_raw",
|
||||||
|
"messageType": "sensor_msgs/Image",
|
||||||
|
"mode": "subscribe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "get_battery_status",
|
||||||
|
"name": "Get Battery Status",
|
||||||
|
"description": "Monitor robot battery level and charging status",
|
||||||
|
"category": "sensors",
|
||||||
|
"parameters": [],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/battery",
|
||||||
|
"messageType": "sensor_msgs/BatteryState",
|
||||||
|
"mode": "subscribe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "detect_obstacle",
|
||||||
|
"name": "Detect Obstacle",
|
||||||
|
"description": "Check for obstacles using sonar sensors with customizable thresholds",
|
||||||
|
"category": "sensors",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "detection_distance",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Distance threshold for obstacle detection (meters)",
|
||||||
|
"required": true,
|
||||||
|
"min": 0.1,
|
||||||
|
"max": 2.0,
|
||||||
|
"default": 0.5,
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sensor_side",
|
||||||
|
"type": "select",
|
||||||
|
"description": "Which sensors to use for detection",
|
||||||
|
"required": false,
|
||||||
|
"options": [
|
||||||
|
{ "value": "both", "label": "Both Sensors" },
|
||||||
|
{ "value": "left", "label": "Left Sensor Only" },
|
||||||
|
{ "value": "right", "label": "Right Sensor Only" }
|
||||||
|
],
|
||||||
|
"default": "both"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topics": [
|
||||||
|
"/naoqi_driver/sonar/left",
|
||||||
|
"/naoqi_driver/sonar/right"
|
||||||
|
],
|
||||||
|
"messageType": "sensor_msgs/Range",
|
||||||
|
"mode": "subscribe",
|
||||||
|
"processing": "obstacle_detection"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "monitor_fall_detection",
|
||||||
|
"name": "Monitor Fall Detection",
|
||||||
|
"description": "Monitor robot stability using IMU data to detect potential falls",
|
||||||
|
"category": "sensors",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tilt_threshold",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Maximum tilt angle before fall alert (degrees)",
|
||||||
|
"required": false,
|
||||||
|
"min": 10,
|
||||||
|
"max": 45,
|
||||||
|
"default": 25,
|
||||||
|
"step": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acceleration_threshold",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Acceleration threshold for impact detection (m/s²)",
|
||||||
|
"required": false,
|
||||||
|
"min": 5,
|
||||||
|
"max": 20,
|
||||||
|
"default": 10,
|
||||||
|
"step": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/imu",
|
||||||
|
"messageType": "sensor_msgs/Imu",
|
||||||
|
"mode": "subscribe",
|
||||||
|
"processing": "fall_detection"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wait_for_touch",
|
||||||
|
"name": "Wait for Touch",
|
||||||
|
"description": "Wait for user to touch a specific sensor before continuing",
|
||||||
|
"category": "sensors",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "sensor_location",
|
||||||
|
"type": "select",
|
||||||
|
"description": "Which sensor to wait for",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{ "value": "head_front", "label": "Head Front" },
|
||||||
|
{ "value": "head_middle", "label": "Head Middle" },
|
||||||
|
{ "value": "head_rear", "label": "Head Rear" },
|
||||||
|
{ "value": "left_hand", "label": "Left Hand" },
|
||||||
|
{ "value": "right_hand", "label": "Right Hand" },
|
||||||
|
{ "value": "any_head", "label": "Any Head Sensor" },
|
||||||
|
{ "value": "any_hand", "label": "Any Hand Sensor" },
|
||||||
|
{ "value": "any_touch", "label": "Any Touch Sensor" }
|
||||||
|
],
|
||||||
|
"default": "head_front"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "timeout",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Maximum time to wait for touch (seconds, 0 = infinite)",
|
||||||
|
"required": false,
|
||||||
|
"min": 0,
|
||||||
|
"max": 300,
|
||||||
|
"default": 30,
|
||||||
|
"step": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topics": [
|
||||||
|
"/naoqi_driver/head_touch",
|
||||||
|
"/naoqi_driver/hand_touch"
|
||||||
|
],
|
||||||
|
"messageTypes": [
|
||||||
|
"naoqi_bridge_msgs/HeadTouch",
|
||||||
|
"naoqi_bridge_msgs/HandTouch"
|
||||||
|
],
|
||||||
|
"mode": "wait_for_condition",
|
||||||
|
"condition": "touch_detected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sensorSpecifications": {
|
||||||
|
"touchSensors": {
|
||||||
|
"head": {
|
||||||
|
"locations": ["front", "middle", "rear"],
|
||||||
|
"sensitivity": "capacitive",
|
||||||
|
"responseTime": "< 50ms"
|
||||||
|
},
|
||||||
|
"hands": {
|
||||||
|
"locations": ["left", "right"],
|
||||||
|
"sensitivity": "capacitive",
|
||||||
|
"responseTime": "< 50ms"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sonarSensors": {
|
||||||
|
"count": 2,
|
||||||
|
"locations": ["left", "right"],
|
||||||
|
"minRange": "0.25m",
|
||||||
|
"maxRange": "2.55m",
|
||||||
|
"fieldOfView": "60°",
|
||||||
|
"frequency": "40kHz"
|
||||||
|
},
|
||||||
|
"cameras": {
|
||||||
|
"front": {
|
||||||
|
"resolution": "640x480",
|
||||||
|
"maxFps": 30,
|
||||||
|
"fieldOfView": "60.9° x 47.6°"
|
||||||
|
},
|
||||||
|
"bottom": {
|
||||||
|
"resolution": "640x480",
|
||||||
|
"maxFps": 30,
|
||||||
|
"fieldOfView": "60.9° x 47.6°"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"imu": {
|
||||||
|
"accelerometer": {
|
||||||
|
"range": "±2g",
|
||||||
|
"sensitivity": "high"
|
||||||
|
},
|
||||||
|
"gyroscope": {
|
||||||
|
"range": "±500°/s",
|
||||||
|
"sensitivity": "high"
|
||||||
|
},
|
||||||
|
"magnetometer": {
|
||||||
|
"available": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"joints": {
|
||||||
|
"count": 25,
|
||||||
|
"encoderResolution": "12-bit",
|
||||||
|
"positionAccuracy": "±0.1°"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dataTypes": {
|
||||||
|
"jointState": {
|
||||||
|
"position": "radians",
|
||||||
|
"velocity": "radians/second",
|
||||||
|
"effort": "arbitrary units"
|
||||||
|
},
|
||||||
|
"imu": {
|
||||||
|
"orientation": "quaternion",
|
||||||
|
"angularVelocity": "radians/second",
|
||||||
|
"linearAcceleration": "m/s²"
|
||||||
|
},
|
||||||
|
"range": {
|
||||||
|
"distance": "meters",
|
||||||
|
"minRange": "meters",
|
||||||
|
"maxRange": "meters"
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"encoding": "rgb8",
|
||||||
|
"width": "pixels",
|
||||||
|
"height": "pixels"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"safety": {
|
||||||
|
"fallDetection": {
|
||||||
|
"enabled": true,
|
||||||
|
"defaultThreshold": "25°"
|
||||||
|
},
|
||||||
|
"obstacleDetection": {
|
||||||
|
"enabled": true,
|
||||||
|
"safeDistance": "0.3m"
|
||||||
|
},
|
||||||
|
"batteryMonitoring": {
|
||||||
|
"lowBatteryWarning": "20%",
|
||||||
|
"criticalBatteryShutdown": "5%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
338
public/nao6-plugins/plugins/nao6-speech.json
Normal file
338
public/nao6-plugins/plugins/nao6-speech.json
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
{
|
||||||
|
"name": "NAO6 Speech & Audio",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Text-to-speech and audio capabilities for NAO6 robot including voice synthesis, volume control, and language settings",
|
||||||
|
"platform": "NAO6",
|
||||||
|
"category": "speech",
|
||||||
|
"manufacturer": {
|
||||||
|
"name": "SoftBank Robotics",
|
||||||
|
"website": "https://www.softbankrobotics.com"
|
||||||
|
},
|
||||||
|
"documentation": {
|
||||||
|
"mainUrl": "https://docs.hristudio.com/robots/nao6/speech",
|
||||||
|
"quickStart": "https://docs.hristudio.com/robots/nao6/speech/quickstart"
|
||||||
|
},
|
||||||
|
"ros2Config": {
|
||||||
|
"namespace": "/naoqi_driver",
|
||||||
|
"topics": {
|
||||||
|
"speech": {
|
||||||
|
"type": "std_msgs/String",
|
||||||
|
"description": "Text-to-speech commands"
|
||||||
|
},
|
||||||
|
"set_language": {
|
||||||
|
"type": "std_msgs/String",
|
||||||
|
"description": "Set speech language"
|
||||||
|
},
|
||||||
|
"audio_volume": {
|
||||||
|
"type": "std_msgs/Float32",
|
||||||
|
"description": "Control audio volume level"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "say_text",
|
||||||
|
"name": "Say Text",
|
||||||
|
"description": "Make the robot speak the specified text using text-to-speech",
|
||||||
|
"category": "speech",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "text",
|
||||||
|
"type": "text",
|
||||||
|
"description": "Text for the robot to speak",
|
||||||
|
"required": true,
|
||||||
|
"maxLength": 500,
|
||||||
|
"placeholder": "Enter text for NAO to say..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wait_for_completion",
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Wait for speech to finish before continuing",
|
||||||
|
"required": false,
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/speech",
|
||||||
|
"messageType": "std_msgs/String",
|
||||||
|
"messageTemplate": {
|
||||||
|
"data": "{{text}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "say_with_emotion",
|
||||||
|
"name": "Say Text with Emotion",
|
||||||
|
"description": "Speak text with emotional expression using SSML-like markup",
|
||||||
|
"category": "speech",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "text",
|
||||||
|
"type": "text",
|
||||||
|
"description": "Text for the robot to speak",
|
||||||
|
"required": true,
|
||||||
|
"maxLength": 500,
|
||||||
|
"placeholder": "Enter text for NAO to say..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "emotion",
|
||||||
|
"type": "select",
|
||||||
|
"description": "Emotional tone for speech",
|
||||||
|
"required": false,
|
||||||
|
"options": [
|
||||||
|
{ "value": "neutral", "label": "Neutral" },
|
||||||
|
{ "value": "happy", "label": "Happy" },
|
||||||
|
{ "value": "sad", "label": "Sad" },
|
||||||
|
{ "value": "excited", "label": "Excited" },
|
||||||
|
{ "value": "calm", "label": "Calm" }
|
||||||
|
],
|
||||||
|
"default": "neutral"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "speed",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Speech speed multiplier",
|
||||||
|
"required": false,
|
||||||
|
"min": 0.5,
|
||||||
|
"max": 2.0,
|
||||||
|
"default": 1.0,
|
||||||
|
"step": 0.1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/speech",
|
||||||
|
"messageType": "std_msgs/String",
|
||||||
|
"messageTemplate": {
|
||||||
|
"data": "\\rspd={{speed}}\\\\rst={{emotion}}\\{{text}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "set_volume",
|
||||||
|
"name": "Set Volume",
|
||||||
|
"description": "Adjust the robot's audio volume level",
|
||||||
|
"category": "speech",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "volume",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Volume level (0.0 = silent, 1.0 = maximum)",
|
||||||
|
"required": true,
|
||||||
|
"min": 0.0,
|
||||||
|
"max": 1.0,
|
||||||
|
"default": 0.5,
|
||||||
|
"step": 0.1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/audio_volume",
|
||||||
|
"messageType": "std_msgs/Float32",
|
||||||
|
"messageTemplate": {
|
||||||
|
"data": "{{volume}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "set_language",
|
||||||
|
"name": "Set Language",
|
||||||
|
"description": "Change the robot's speech language",
|
||||||
|
"category": "speech",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "language",
|
||||||
|
"type": "select",
|
||||||
|
"description": "Speech language",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{ "value": "en-US", "label": "English (US)" },
|
||||||
|
{ "value": "en-GB", "label": "English (UK)" },
|
||||||
|
{ "value": "fr-FR", "label": "French" },
|
||||||
|
{ "value": "de-DE", "label": "German" },
|
||||||
|
{ "value": "es-ES", "label": "Spanish" },
|
||||||
|
{ "value": "it-IT", "label": "Italian" },
|
||||||
|
{ "value": "ja-JP", "label": "Japanese" },
|
||||||
|
{ "value": "ko-KR", "label": "Korean" },
|
||||||
|
{ "value": "zh-CN", "label": "Chinese (Simplified)" }
|
||||||
|
],
|
||||||
|
"default": "en-US"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/set_language",
|
||||||
|
"messageType": "std_msgs/String",
|
||||||
|
"messageTemplate": {
|
||||||
|
"data": "{{language}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "say_random_phrase",
|
||||||
|
"name": "Say Random Phrase",
|
||||||
|
"description": "Make the robot say a random phrase from predefined categories",
|
||||||
|
"category": "speech",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "category",
|
||||||
|
"type": "select",
|
||||||
|
"description": "Category of phrases",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{ "value": "greeting", "label": "Greetings" },
|
||||||
|
{ "value": "encouragement", "label": "Encouragement" },
|
||||||
|
{ "value": "question", "label": "Questions" },
|
||||||
|
{ "value": "farewell", "label": "Farewells" },
|
||||||
|
{ "value": "instruction", "label": "Instructions" }
|
||||||
|
],
|
||||||
|
"default": "greeting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/speech",
|
||||||
|
"messageType": "std_msgs/String",
|
||||||
|
"messageTemplate": {
|
||||||
|
"data": "{{getRandomPhrase(category)}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"phrases": {
|
||||||
|
"greeting": [
|
||||||
|
"Hello! Nice to meet you!",
|
||||||
|
"Hi there! How are you today?",
|
||||||
|
"Welcome! I'm excited to work with you.",
|
||||||
|
"Good day! Ready to get started?",
|
||||||
|
"Greetings! What shall we do today?"
|
||||||
|
],
|
||||||
|
"encouragement": [
|
||||||
|
"Great job! Keep it up!",
|
||||||
|
"You're doing wonderfully!",
|
||||||
|
"Excellent work! I'm impressed.",
|
||||||
|
"That's fantastic! Well done!",
|
||||||
|
"Perfect! You've got this!"
|
||||||
|
],
|
||||||
|
"question": [
|
||||||
|
"How can I help you today?",
|
||||||
|
"What would you like to do next?",
|
||||||
|
"Is there anything you'd like to know?",
|
||||||
|
"Shall we try something different?",
|
||||||
|
"What are you thinking about?"
|
||||||
|
],
|
||||||
|
"farewell": [
|
||||||
|
"Goodbye! It was great working with you!",
|
||||||
|
"See you later! Take care!",
|
||||||
|
"Until next time! Have a wonderful day!",
|
||||||
|
"Farewell! Thanks for spending time with me!",
|
||||||
|
"Bye for now! Look forward to seeing you again!"
|
||||||
|
],
|
||||||
|
"instruction": [
|
||||||
|
"Please follow my movements.",
|
||||||
|
"Let's try this step by step.",
|
||||||
|
"Watch carefully and then repeat.",
|
||||||
|
"Take your time, there's no rush.",
|
||||||
|
"Remember to stay focused."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "spell_word",
|
||||||
|
"name": "Spell Word",
|
||||||
|
"description": "Have the robot spell out a word letter by letter",
|
||||||
|
"category": "speech",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "word",
|
||||||
|
"type": "text",
|
||||||
|
"description": "Word to spell out",
|
||||||
|
"required": true,
|
||||||
|
"maxLength": 50,
|
||||||
|
"placeholder": "Enter word to spell..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pause_duration",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Pause between letters in seconds",
|
||||||
|
"required": false,
|
||||||
|
"min": 0.1,
|
||||||
|
"max": 2.0,
|
||||||
|
"default": 0.5,
|
||||||
|
"step": 0.1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/speech",
|
||||||
|
"messageType": "std_msgs/String",
|
||||||
|
"messageTemplate": {
|
||||||
|
"data": "{{word.split('').join('\\pau={{pause_duration * 1000}}\\\\pau=0\\')}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "count_numbers",
|
||||||
|
"name": "Count Numbers",
|
||||||
|
"description": "Have the robot count from one number to another",
|
||||||
|
"category": "speech",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "start",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Starting number",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"default": 1,
|
||||||
|
"step": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "end",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Ending number",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"default": 10,
|
||||||
|
"step": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pause_duration",
|
||||||
|
"type": "number",
|
||||||
|
"description": "Pause between numbers in seconds",
|
||||||
|
"required": false,
|
||||||
|
"min": 0.1,
|
||||||
|
"max": 2.0,
|
||||||
|
"default": 0.8,
|
||||||
|
"step": 0.1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"topic": "/naoqi_driver/speech",
|
||||||
|
"messageType": "std_msgs/String",
|
||||||
|
"messageTemplate": {
|
||||||
|
"data": "{{Array.from({length: end - start + 1}, (_, i) => start + i).join('\\pau={{pause_duration * 1000}}\\\\pau=0\\')}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"features": {
|
||||||
|
"languages": [
|
||||||
|
"en-US", "en-GB", "fr-FR", "de-DE", "es-ES",
|
||||||
|
"it-IT", "ja-JP", "ko-KR", "zh-CN"
|
||||||
|
],
|
||||||
|
"emotions": [
|
||||||
|
"neutral", "happy", "sad", "excited", "calm"
|
||||||
|
],
|
||||||
|
"voiceEffects": [
|
||||||
|
"speed", "pitch", "volume", "emotion"
|
||||||
|
],
|
||||||
|
"ssmlSupport": true,
|
||||||
|
"maxTextLength": 500
|
||||||
|
},
|
||||||
|
"safety": {
|
||||||
|
"maxVolume": 1.0,
|
||||||
|
"defaultVolume": 0.5,
|
||||||
|
"profanityFilter": true,
|
||||||
|
"maxSpeechDuration": 60,
|
||||||
|
"emergencyQuiet": {
|
||||||
|
"action": "set_volume",
|
||||||
|
"parameters": { "volume": 0 },
|
||||||
|
"description": "Immediately mute robot audio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
public/nao6-plugins/repository.json
Normal file
44
public/nao6-plugins/repository.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "NAO6 ROS2 Integration Repository",
|
||||||
|
"description": "Official NAO6 robot plugins for ROS2-based Human-Robot Interaction experiments",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "HRIStudio Team",
|
||||||
|
"email": "support@hristudio.com"
|
||||||
|
},
|
||||||
|
"urls": {
|
||||||
|
"git": "https://github.com/hristudio/nao6-ros2-plugins",
|
||||||
|
"documentation": "https://docs.hristudio.com/robots/nao6",
|
||||||
|
"issues": "https://github.com/hristudio/nao6-ros2-plugins/issues"
|
||||||
|
},
|
||||||
|
"trust": "official",
|
||||||
|
"license": "MIT",
|
||||||
|
"robots": [
|
||||||
|
{
|
||||||
|
"name": "NAO6",
|
||||||
|
"manufacturer": "SoftBank Robotics",
|
||||||
|
"model": "NAO V6",
|
||||||
|
"communicationProtocol": "ros2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
"movement",
|
||||||
|
"speech",
|
||||||
|
"sensors",
|
||||||
|
"interaction",
|
||||||
|
"vision"
|
||||||
|
],
|
||||||
|
"ros2": {
|
||||||
|
"distro": "humble",
|
||||||
|
"packages": [
|
||||||
|
"naoqi_driver2",
|
||||||
|
"naoqi_bridge_msgs",
|
||||||
|
"rosbridge_suite"
|
||||||
|
],
|
||||||
|
"bridge": {
|
||||||
|
"protocol": "websocket",
|
||||||
|
"defaultPort": 9090
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lastUpdated": "2025-01-16T00:00:00Z"
|
||||||
|
}
|
||||||
Submodule robot-plugins updated: 334dc68a22...bbfe6e80c3
@@ -216,9 +216,20 @@ async function main() {
|
|||||||
manufacturer: "SoftBank Robotics",
|
manufacturer: "SoftBank Robotics",
|
||||||
model: "NAO V6",
|
model: "NAO V6",
|
||||||
description:
|
description:
|
||||||
"Humanoid robot designed for education, research, and social interaction",
|
"Humanoid robot designed for education, research, and social interaction with ROS2 integration",
|
||||||
capabilities: ["speech", "vision", "walking", "gestures"],
|
capabilities: [
|
||||||
communicationProtocol: "rest" as const,
|
"speech",
|
||||||
|
"vision",
|
||||||
|
"walking",
|
||||||
|
"gestures",
|
||||||
|
"joint_control",
|
||||||
|
"touch_sensors",
|
||||||
|
"sonar_sensors",
|
||||||
|
"camera_feed",
|
||||||
|
"imu",
|
||||||
|
"odometry",
|
||||||
|
],
|
||||||
|
communicationProtocol: "ros2" as const,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -295,6 +306,17 @@ async function main() {
|
|||||||
syncStatus: "pending" as const,
|
syncStatus: "pending" as const,
|
||||||
createdBy: seanUser.id,
|
createdBy: seanUser.id,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "NAO6 ROS2 Integration Repository",
|
||||||
|
url: "http://localhost:3000/nao6-plugins",
|
||||||
|
description:
|
||||||
|
"Official NAO6 robot plugins for ROS2-based Human-Robot Interaction experiments",
|
||||||
|
trustLevel: "official" as const,
|
||||||
|
isEnabled: true,
|
||||||
|
isOfficial: true,
|
||||||
|
syncStatus: "pending" as const,
|
||||||
|
createdBy: seanUser.id,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const insertedRepos = await db
|
const insertedRepos = await db
|
||||||
|
|||||||
606
src/app/(dashboard)/nao-test/page.tsx
Normal file
606
src/app/(dashboard)/nao-test/page.tsx
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { Slider } from "~/components/ui/slider";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
|
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
|
import { PageHeader } from "~/components/ui/page-header";
|
||||||
|
import { PageLayout } from "~/components/ui/page-layout";
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
Volume2,
|
||||||
|
Camera,
|
||||||
|
Zap,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
RotateCcw,
|
||||||
|
RotateCw,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Activity,
|
||||||
|
Battery,
|
||||||
|
Eye,
|
||||||
|
Hand,
|
||||||
|
Footprints,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface RosMessage {
|
||||||
|
topic: string;
|
||||||
|
msg: any;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NaoTestPage() {
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<
|
||||||
|
"disconnected" | "connecting" | "connected" | "error"
|
||||||
|
>("disconnected");
|
||||||
|
const [rosSocket, setRosSocket] = useState<WebSocket | null>(null);
|
||||||
|
const [robotStatus, setRobotStatus] = useState<any>(null);
|
||||||
|
const [jointStates, setJointStates] = useState<any>(null);
|
||||||
|
const [speechText, setSpeechText] = useState("");
|
||||||
|
const [walkSpeed, setWalkSpeed] = useState([0.1]);
|
||||||
|
const [turnSpeed, setTurnSpeed] = useState([0.3]);
|
||||||
|
const [headYaw, setHeadYaw] = useState([0]);
|
||||||
|
const [headPitch, setHeadPitch] = useState([0]);
|
||||||
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
const [sensorData, setSensorData] = useState<any>({});
|
||||||
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const ROS_BRIDGE_URL = "ws://134.82.159.25:9090";
|
||||||
|
|
||||||
|
const addLog = (message: string) => {
|
||||||
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
setLogs((prev) => [...prev.slice(-49), `[${timestamp}] ${message}`]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
const connectToRos = () => {
|
||||||
|
if (rosSocket?.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
setConnectionStatus("connecting");
|
||||||
|
addLog("Connecting to ROS bridge...");
|
||||||
|
|
||||||
|
const socket = new WebSocket(ROS_BRIDGE_URL);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
setConnectionStatus("connected");
|
||||||
|
setRosSocket(socket);
|
||||||
|
addLog("Connected to ROS bridge successfully");
|
||||||
|
|
||||||
|
// Subscribe to robot topics
|
||||||
|
subscribeToTopics(socket);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
handleRosMessage(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing ROS message:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
setConnectionStatus("disconnected");
|
||||||
|
setRosSocket(null);
|
||||||
|
addLog("Disconnected from ROS bridge");
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
setConnectionStatus("error");
|
||||||
|
addLog("Error connecting to ROS bridge");
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnectFromRos = () => {
|
||||||
|
if (rosSocket) {
|
||||||
|
rosSocket.close();
|
||||||
|
setRosSocket(null);
|
||||||
|
setConnectionStatus("disconnected");
|
||||||
|
addLog("Manually disconnected from ROS bridge");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribeToTopics = (socket: WebSocket) => {
|
||||||
|
const topics = [
|
||||||
|
{ topic: "/naoqi_driver/joint_states", type: "sensor_msgs/JointState" },
|
||||||
|
{ topic: "/naoqi_driver/info", type: "naoqi_bridge_msgs/StringStamped" },
|
||||||
|
{ topic: "/naoqi_driver/bumper", type: "naoqi_bridge_msgs/Bumper" },
|
||||||
|
{
|
||||||
|
topic: "/naoqi_driver/hand_touch",
|
||||||
|
type: "naoqi_bridge_msgs/HandTouch",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: "/naoqi_driver/head_touch",
|
||||||
|
type: "naoqi_bridge_msgs/HeadTouch",
|
||||||
|
},
|
||||||
|
{ topic: "/naoqi_driver/sonar/left", type: "sensor_msgs/Range" },
|
||||||
|
{ topic: "/naoqi_driver/sonar/right", type: "sensor_msgs/Range" },
|
||||||
|
];
|
||||||
|
|
||||||
|
topics.forEach(({ topic, type }) => {
|
||||||
|
const subscribeMsg = {
|
||||||
|
op: "subscribe",
|
||||||
|
topic,
|
||||||
|
type,
|
||||||
|
};
|
||||||
|
socket.send(JSON.stringify(subscribeMsg));
|
||||||
|
addLog(`Subscribed to ${topic}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRosMessage = (data: any) => {
|
||||||
|
if (data.topic === "/naoqi_driver/joint_states") {
|
||||||
|
setJointStates(data.msg);
|
||||||
|
} else if (data.topic === "/naoqi_driver/info") {
|
||||||
|
setRobotStatus(data.msg);
|
||||||
|
} else if (
|
||||||
|
data.topic?.includes("bumper") ||
|
||||||
|
data.topic?.includes("touch") ||
|
||||||
|
data.topic?.includes("sonar")
|
||||||
|
) {
|
||||||
|
setSensorData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[data.topic]: data.msg,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishMessage = (topic: string, type: string, msg: any) => {
|
||||||
|
if (!rosSocket || rosSocket.readyState !== WebSocket.OPEN) {
|
||||||
|
addLog("Error: Not connected to ROS bridge");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rosMsg = {
|
||||||
|
op: "publish",
|
||||||
|
topic,
|
||||||
|
type,
|
||||||
|
msg,
|
||||||
|
};
|
||||||
|
|
||||||
|
rosSocket.send(JSON.stringify(rosMsg));
|
||||||
|
addLog(`Published to ${topic}: ${JSON.stringify(msg)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sayText = () => {
|
||||||
|
if (!speechText.trim()) return;
|
||||||
|
|
||||||
|
publishMessage("/speech", "std_msgs/String", {
|
||||||
|
data: speechText,
|
||||||
|
});
|
||||||
|
setSpeechText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const walkForward = () => {
|
||||||
|
publishMessage("/cmd_vel", "geometry_msgs/Twist", {
|
||||||
|
linear: { x: walkSpeed[0], y: 0, z: 0 },
|
||||||
|
angular: { x: 0, y: 0, z: 0 },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const walkBackward = () => {
|
||||||
|
publishMessage("/cmd_vel", "geometry_msgs/Twist", {
|
||||||
|
linear: { x: -walkSpeed[0], y: 0, z: 0 },
|
||||||
|
angular: { x: 0, y: 0, z: 0 },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const turnLeft = () => {
|
||||||
|
publishMessage("/cmd_vel", "geometry_msgs/Twist", {
|
||||||
|
linear: { x: 0, y: 0, z: 0 },
|
||||||
|
angular: { x: 0, y: 0, z: turnSpeed[0] },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const turnRight = () => {
|
||||||
|
publishMessage("/cmd_vel", "geometry_msgs/Twist", {
|
||||||
|
linear: { x: 0, y: 0, z: 0 },
|
||||||
|
angular: { x: 0, y: 0, z: -turnSpeed[0] },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopMovement = () => {
|
||||||
|
publishMessage("/cmd_vel", "geometry_msgs/Twist", {
|
||||||
|
linear: { x: 0, y: 0, z: 0 },
|
||||||
|
angular: { x: 0, y: 0, z: 0 },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveHead = () => {
|
||||||
|
publishMessage("/joint_angles", "naoqi_bridge_msgs/JointAnglesWithSpeed", {
|
||||||
|
joint_names: ["HeadYaw", "HeadPitch"],
|
||||||
|
joint_angles: [headYaw[0], headPitch[0]],
|
||||||
|
speed: 0.3,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConnectionStatusIcon = () => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case "connected":
|
||||||
|
return <Wifi className="h-4 w-4 text-green-500" />;
|
||||||
|
case "connecting":
|
||||||
|
return <Activity className="h-4 w-4 animate-spin text-yellow-500" />;
|
||||||
|
case "error":
|
||||||
|
return <AlertTriangle className="h-4 w-4 text-red-500" />;
|
||||||
|
default:
|
||||||
|
return <WifiOff className="h-4 w-4 text-gray-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConnectionStatusBadge = () => {
|
||||||
|
const variants = {
|
||||||
|
connected: "default",
|
||||||
|
connecting: "secondary",
|
||||||
|
error: "destructive",
|
||||||
|
disconnected: "outline",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={variants[connectionStatus]}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{getConnectionStatusIcon()}
|
||||||
|
{connectionStatus.charAt(0).toUpperCase() + connectionStatus.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader
|
||||||
|
title="NAO Robot Test Console"
|
||||||
|
description="Test and control your NAO6 robot through ROS bridge"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Connection Status */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
ROS Bridge Connection
|
||||||
|
{getConnectionStatusBadge()}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Connect to ROS bridge at {ROS_BRIDGE_URL}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{connectionStatus === "connected" ? (
|
||||||
|
<Button onClick={disconnectFromRos} variant="destructive">
|
||||||
|
<WifiOff className="mr-2 h-4 w-4" />
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={connectToRos}
|
||||||
|
disabled={connectionStatus === "connecting"}
|
||||||
|
>
|
||||||
|
<Wifi className="mr-2 h-4 w-4" />
|
||||||
|
{connectionStatus === "connecting"
|
||||||
|
? "Connecting..."
|
||||||
|
: "Connect"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{connectionStatus === "connected" && (
|
||||||
|
<Tabs defaultValue="control" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="control">Robot Control</TabsTrigger>
|
||||||
|
<TabsTrigger value="sensors">Sensor Data</TabsTrigger>
|
||||||
|
<TabsTrigger value="status">Robot Status</TabsTrigger>
|
||||||
|
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="control" className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{/* Speech Control */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Volume2 className="h-4 w-4" />
|
||||||
|
Speech
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="speech">Text to Speech</Label>
|
||||||
|
<Textarea
|
||||||
|
id="speech"
|
||||||
|
placeholder="Enter text for NAO to say..."
|
||||||
|
value={speechText}
|
||||||
|
onChange={(e) => setSpeechText(e.target.value)}
|
||||||
|
onKeyDown={(e) =>
|
||||||
|
e.key === "Enter" &&
|
||||||
|
!e.shiftKey &&
|
||||||
|
(e.preventDefault(), sayText())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={sayText}
|
||||||
|
disabled={!speechText.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Say Text
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Movement Control */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Footprints className="h-4 w-4" />
|
||||||
|
Movement
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Walk Speed: {walkSpeed[0].toFixed(2)} m/s</Label>
|
||||||
|
<Slider
|
||||||
|
value={walkSpeed}
|
||||||
|
onValueChange={setWalkSpeed}
|
||||||
|
max={0.5}
|
||||||
|
min={0.05}
|
||||||
|
step={0.05}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Turn Speed: {turnSpeed[0].toFixed(2)} rad/s</Label>
|
||||||
|
<Slider
|
||||||
|
value={turnSpeed}
|
||||||
|
onValueChange={setTurnSpeed}
|
||||||
|
max={1.0}
|
||||||
|
min={0.1}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Button variant="outline" onClick={walkForward}>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={stopMovement}>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={walkBackward}>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={turnLeft}>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div></div>
|
||||||
|
<Button variant="outline" onClick={turnRight}>
|
||||||
|
<RotateCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Head Control */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
Head Control
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Head Yaw: {headYaw[0].toFixed(2)} rad</Label>
|
||||||
|
<Slider
|
||||||
|
value={headYaw}
|
||||||
|
onValueChange={setHeadYaw}
|
||||||
|
max={2.09}
|
||||||
|
min={-2.09}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Head Pitch: {headPitch[0].toFixed(2)} rad</Label>
|
||||||
|
<Slider
|
||||||
|
value={headPitch}
|
||||||
|
onValueChange={setHeadPitch}
|
||||||
|
max={0.51}
|
||||||
|
min={-0.67}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={moveHead} className="w-full">
|
||||||
|
Move Head
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Emergency Stop */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-red-600">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
Emergency
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
onClick={stopMovement}
|
||||||
|
variant="destructive"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Square className="mr-2 h-4 w-4" />
|
||||||
|
EMERGENCY STOP
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="sensors" className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Object.entries(sensorData).map(([topic, data]) => (
|
||||||
|
<Card key={topic}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">
|
||||||
|
{topic
|
||||||
|
.split("/")
|
||||||
|
.pop()
|
||||||
|
?.replace(/_/g, " ")
|
||||||
|
.toUpperCase()}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre className="max-h-32 overflow-auto rounded bg-gray-100 p-2 text-xs">
|
||||||
|
{JSON.stringify(data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{Object.keys(sensorData).length === 0 && (
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
No sensor data received yet. Make sure the robot is
|
||||||
|
connected and publishing data.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="status" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
Robot Status
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{robotStatus ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Robot Info</Label>
|
||||||
|
<pre className="mt-1 rounded bg-gray-100 p-2 text-xs">
|
||||||
|
{JSON.stringify(robotStatus, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
{jointStates && (
|
||||||
|
<div>
|
||||||
|
<Label>Joint States</Label>
|
||||||
|
<div className="mt-1 max-h-64 overflow-auto rounded bg-gray-100 p-2 text-xs">
|
||||||
|
<div>Joints: {jointStates.name?.length || 0}</div>
|
||||||
|
<div>
|
||||||
|
Last Update: {new Date().toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
{jointStates.name
|
||||||
|
?.slice(0, 10)
|
||||||
|
.map((name: string, i: number) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className="flex justify-between"
|
||||||
|
>
|
||||||
|
<span>{name}:</span>
|
||||||
|
<span>
|
||||||
|
{jointStates.position?.[i]?.toFixed(3) ||
|
||||||
|
"N/A"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(jointStates.name?.length || 0) > 10 && (
|
||||||
|
<div className="text-gray-500">
|
||||||
|
... and {(jointStates.name?.length || 0) - 10}{" "}
|
||||||
|
more
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
No robot status data received. Check that the NAO robot
|
||||||
|
is connected and the naoqi_driver is running.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="logs" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Communication Logs</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Real-time log of ROS bridge communication
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-64 overflow-auto rounded bg-black p-4 font-mono text-xs text-green-400">
|
||||||
|
{logs.map((log, index) => (
|
||||||
|
<div key={index}>{log}</div>
|
||||||
|
))}
|
||||||
|
<div ref={logsEndRef} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setLogs([])}
|
||||||
|
variant="outline"
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Clear Logs
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{connectionStatus !== "connected" && (
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Connect to ROS bridge to start controlling the robot. Make sure
|
||||||
|
the NAO integration is running:
|
||||||
|
<br />
|
||||||
|
<code className="mt-2 block rounded bg-gray-100 p-2">
|
||||||
|
ros2 launch nao6_hristudio.launch.py nao_ip:=nao.local
|
||||||
|
password:=robolab
|
||||||
|
</code>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
614
src/components/trials/wizard/RobotActionsPanel.tsx
Normal file
614
src/components/trials/wizard/RobotActionsPanel.tsx
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Play,
|
||||||
|
Settings,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Loader2,
|
||||||
|
Volume2,
|
||||||
|
Move,
|
||||||
|
Eye,
|
||||||
|
Hand,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { Slider } from "~/components/ui/slider";
|
||||||
|
import { Switch } from "~/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "~/components/ui/collapsible";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface RobotAction {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
parameters?: Array<{
|
||||||
|
name: string;
|
||||||
|
type: "text" | "number" | "boolean" | "select";
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
default?: unknown;
|
||||||
|
options?: Array<{ value: string; label: string }>;
|
||||||
|
placeholder?: string;
|
||||||
|
maxLength?: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Plugin {
|
||||||
|
plugin: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
trustLevel: string;
|
||||||
|
actionDefinitions: RobotAction[];
|
||||||
|
};
|
||||||
|
installation: {
|
||||||
|
id: string;
|
||||||
|
configuration: Record<string, unknown>;
|
||||||
|
installedAt: Date;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RobotActionsPanelProps {
|
||||||
|
studyId: string;
|
||||||
|
trialId: string;
|
||||||
|
onExecuteAction: (
|
||||||
|
pluginName: string,
|
||||||
|
actionId: string,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RobotActionsPanel({
|
||||||
|
studyId,
|
||||||
|
trialId,
|
||||||
|
onExecuteAction,
|
||||||
|
}: RobotActionsPanelProps) {
|
||||||
|
const [selectedPlugin, setSelectedPlugin] = useState<string>("");
|
||||||
|
const [selectedAction, setSelectedAction] = useState<RobotAction | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [actionParameters, setActionParameters] = useState<
|
||||||
|
Record<string, unknown>
|
||||||
|
>({});
|
||||||
|
const [executingActions, setExecutingActions] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||||
|
new Set(["movement", "speech"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get installed plugins for the study
|
||||||
|
const { data: plugins = [], isLoading } =
|
||||||
|
api.robots.plugins.getStudyPlugins.useQuery({
|
||||||
|
studyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get actions for selected plugin
|
||||||
|
const selectedPluginData = plugins.find(
|
||||||
|
(p) => p.plugin.id === selectedPlugin,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize parameters when action changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedAction) {
|
||||||
|
const defaultParams: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
selectedAction.parameters?.forEach((param) => {
|
||||||
|
if (param.default !== undefined) {
|
||||||
|
defaultParams[param.name] = param.default;
|
||||||
|
} else if (param.required) {
|
||||||
|
// Set reasonable defaults for required params
|
||||||
|
switch (param.type) {
|
||||||
|
case "text":
|
||||||
|
defaultParams[param.name] = "";
|
||||||
|
break;
|
||||||
|
case "number":
|
||||||
|
defaultParams[param.name] = param.min ?? 0;
|
||||||
|
break;
|
||||||
|
case "boolean":
|
||||||
|
defaultParams[param.name] = false;
|
||||||
|
break;
|
||||||
|
case "select":
|
||||||
|
defaultParams[param.name] = param.options?.[0]?.value ?? "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setActionParameters(defaultParams);
|
||||||
|
} else {
|
||||||
|
setActionParameters({});
|
||||||
|
}
|
||||||
|
}, [selectedAction]);
|
||||||
|
|
||||||
|
const handleExecuteAction = async () => {
|
||||||
|
if (!selectedAction || !selectedPluginData) return;
|
||||||
|
|
||||||
|
const actionKey = `${selectedPluginData.plugin.name}.${selectedAction.id}`;
|
||||||
|
setExecutingActions((prev) => new Set([...prev, actionKey]));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onExecuteAction(
|
||||||
|
selectedPluginData.plugin.name,
|
||||||
|
selectedAction.id,
|
||||||
|
actionParameters,
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success(`Executed: ${selectedAction.name}`, {
|
||||||
|
description: `Robot action completed successfully`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`Failed to execute: ${selectedAction.name}`, {
|
||||||
|
description: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setExecutingActions((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(actionKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleParameterChange = (paramName: string, value: unknown) => {
|
||||||
|
setActionParameters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[paramName]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderParameterInput = (
|
||||||
|
param: NonNullable<RobotAction["parameters"]>[0],
|
||||||
|
paramIndex: number,
|
||||||
|
) => {
|
||||||
|
if (!param) return null;
|
||||||
|
|
||||||
|
const value = actionParameters[param.name];
|
||||||
|
|
||||||
|
switch (param.type) {
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<div key={param.name} className="space-y-2">
|
||||||
|
<Label htmlFor={param.name}>
|
||||||
|
{param.name} {param.required && "*"}
|
||||||
|
</Label>
|
||||||
|
{param.maxLength && param.maxLength > 100 ? (
|
||||||
|
<Textarea
|
||||||
|
id={param.name}
|
||||||
|
value={(value as string) || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleParameterChange(param.name, e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={param.placeholder}
|
||||||
|
maxLength={param.maxLength}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={param.name}
|
||||||
|
value={(value as string) || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleParameterChange(param.name, e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={param.placeholder}
|
||||||
|
maxLength={param.maxLength}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-muted-foreground text-xs">{param.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "number":
|
||||||
|
return (
|
||||||
|
<div key={param.name} className="space-y-2">
|
||||||
|
<Label htmlFor={param.name}>
|
||||||
|
{param.name} {param.required && "*"}
|
||||||
|
</Label>
|
||||||
|
{param.min !== undefined && param.max !== undefined ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Slider
|
||||||
|
value={[Number(value) || param.min]}
|
||||||
|
onValueChange={(newValue) =>
|
||||||
|
handleParameterChange(param.name, newValue[0])
|
||||||
|
}
|
||||||
|
min={param.min}
|
||||||
|
max={param.max}
|
||||||
|
step={param.step || 0.1}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="text-muted-foreground text-center text-sm">
|
||||||
|
{Number(value) || param.min}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={param.name}
|
||||||
|
type="number"
|
||||||
|
value={Number(value) || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleParameterChange(param.name, Number(e.target.value))
|
||||||
|
}
|
||||||
|
min={param.min}
|
||||||
|
max={param.max}
|
||||||
|
step={param.step}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-muted-foreground text-xs">{param.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "boolean":
|
||||||
|
return (
|
||||||
|
<div key={param.name} className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id={param.name}
|
||||||
|
checked={Boolean(value)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleParameterChange(param.name, checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={param.name}>
|
||||||
|
{param.name} {param.required && "*"}
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground ml-auto text-xs">
|
||||||
|
{param.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "select":
|
||||||
|
return (
|
||||||
|
<div key={param.name} className="space-y-2">
|
||||||
|
<Label htmlFor={param.name}>
|
||||||
|
{param.name} {param.required && "*"}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={String(value) || ""}
|
||||||
|
onValueChange={(newValue) =>
|
||||||
|
handleParameterChange(param.name, newValue)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={`Select ${param.name}`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{param.options?.map(
|
||||||
|
(option: { value: string; label: string }) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs">{param.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryIcon = (category: string) => {
|
||||||
|
switch (category.toLowerCase()) {
|
||||||
|
case "movement":
|
||||||
|
return Move;
|
||||||
|
case "speech":
|
||||||
|
return Volume2;
|
||||||
|
case "sensors":
|
||||||
|
return Eye;
|
||||||
|
case "interaction":
|
||||||
|
return Hand;
|
||||||
|
default:
|
||||||
|
return Zap;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupActionsByCategory = (actions: RobotAction[]) => {
|
||||||
|
const grouped: Record<string, RobotAction[]> = {};
|
||||||
|
|
||||||
|
actions.forEach((action) => {
|
||||||
|
const category = action.category || "other";
|
||||||
|
if (!grouped[category]) {
|
||||||
|
grouped[category] = [];
|
||||||
|
}
|
||||||
|
grouped[category].push(action);
|
||||||
|
});
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCategory = (category: string) => {
|
||||||
|
setExpandedCategories((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(category)) {
|
||||||
|
next.delete(category);
|
||||||
|
} else {
|
||||||
|
next.add(category);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2">Loading robot plugins...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugins.length === 0) {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
No robot plugins installed for this study. Install plugins from the
|
||||||
|
study settings to enable robot control.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Plugin Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Select Robot Plugin</Label>
|
||||||
|
<Select value={selectedPlugin} onValueChange={setSelectedPlugin}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Choose a robot plugin" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{plugins.map((plugin) => (
|
||||||
|
<SelectItem key={plugin.plugin.id} value={plugin.plugin.id}>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{plugin.plugin.name} v{plugin.plugin.version}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="ml-auto">
|
||||||
|
{plugin.plugin.trustLevel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Selection */}
|
||||||
|
{selectedPluginData && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Available Actions</Label>
|
||||||
|
<ScrollArea className="h-64 rounded-md border">
|
||||||
|
<div className="space-y-2 p-2">
|
||||||
|
{Object.entries(
|
||||||
|
groupActionsByCategory(
|
||||||
|
(selectedPluginData.plugin
|
||||||
|
.actionDefinitions as RobotAction[]) || [],
|
||||||
|
),
|
||||||
|
).map(([category, actions]) => {
|
||||||
|
const CategoryIcon = getCategoryIcon(category);
|
||||||
|
const isExpanded = expandedCategories.has(category);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
key={category}
|
||||||
|
open={isExpanded}
|
||||||
|
onOpenChange={() => toggleCategory(category)}
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start p-2"
|
||||||
|
>
|
||||||
|
<CategoryIcon className="mr-2 h-4 w-4" />
|
||||||
|
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||||
|
<Badge variant="secondary" className="ml-auto">
|
||||||
|
{actions.length}
|
||||||
|
</Badge>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="ml-6 space-y-1">
|
||||||
|
{actions.map((action) => (
|
||||||
|
<Button
|
||||||
|
key={action.id}
|
||||||
|
variant={
|
||||||
|
selectedAction?.id === action.id
|
||||||
|
? "default"
|
||||||
|
: "ghost"
|
||||||
|
}
|
||||||
|
className="w-full justify-start text-sm"
|
||||||
|
onClick={() => setSelectedAction(action)}
|
||||||
|
>
|
||||||
|
{action.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Configuration */}
|
||||||
|
{selectedAction && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center space-x-2">
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
|
<span>{selectedAction.name}</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{selectedAction.description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Parameters */}
|
||||||
|
{selectedAction.parameters &&
|
||||||
|
selectedAction.parameters.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label className="text-base">Parameters</Label>
|
||||||
|
{selectedAction.parameters.map((param, index) =>
|
||||||
|
renderParameterInput(param, index),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
This action requires no parameters.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Execute Button */}
|
||||||
|
<Button
|
||||||
|
onClick={handleExecuteAction}
|
||||||
|
disabled={
|
||||||
|
!selectedPluginData ||
|
||||||
|
executingActions.has(
|
||||||
|
`${selectedPluginData.plugin.name}.${selectedAction.id}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{selectedPluginData &&
|
||||||
|
executingActions.has(
|
||||||
|
`${selectedPluginData.plugin.name}.${selectedAction.id}`,
|
||||||
|
) ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Executing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Execute Action
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Quick Actions for Common Robot Commands */}
|
||||||
|
{selectedAction.category === "movement" && selectedPluginData && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectedPluginData) return;
|
||||||
|
const stopAction = (
|
||||||
|
selectedPluginData.plugin
|
||||||
|
.actionDefinitions as RobotAction[]
|
||||||
|
)?.find((a: RobotAction) => a.id === "stop_movement");
|
||||||
|
if (stopAction) {
|
||||||
|
onExecuteAction(
|
||||||
|
selectedPluginData.plugin.name,
|
||||||
|
stopAction.id,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
!selectedPluginData ||
|
||||||
|
!(
|
||||||
|
selectedPluginData.plugin
|
||||||
|
.actionDefinitions as RobotAction[]
|
||||||
|
)?.some((a: RobotAction) => a.id === "stop_movement")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Emergency Stop
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectedPluginData) return;
|
||||||
|
const wakeAction = (
|
||||||
|
selectedPluginData.plugin
|
||||||
|
.actionDefinitions as RobotAction[]
|
||||||
|
)?.find((a: RobotAction) => a.id === "wake_up");
|
||||||
|
if (wakeAction) {
|
||||||
|
onExecuteAction(
|
||||||
|
selectedPluginData.plugin.name,
|
||||||
|
wakeAction.id,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
!selectedPluginData ||
|
||||||
|
!(
|
||||||
|
selectedPluginData.plugin
|
||||||
|
.actionDefinitions as RobotAction[]
|
||||||
|
)?.some((a: RobotAction) => a.id === "wake_up")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Wake Up
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plugin Info */}
|
||||||
|
{selectedPluginData && (
|
||||||
|
<Alert>
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>{selectedPluginData.plugin.name}</strong> -{" "}
|
||||||
|
{selectedPluginData.plugin.description}
|
||||||
|
<br />
|
||||||
|
<span className="text-xs">
|
||||||
|
Installed:{" "}
|
||||||
|
{selectedPluginData.installation.installedAt.toLocaleDateString()}{" "}
|
||||||
|
| Trust Level: {selectedPluginData.plugin.trustLevel} | Actions:{" "}
|
||||||
|
{
|
||||||
|
(
|
||||||
|
(selectedPluginData.plugin
|
||||||
|
.actionDefinitions as RobotAction[]) || []
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@ export function WizardInterface({
|
|||||||
|
|
||||||
// Persistent tab states to prevent resets from parent re-renders
|
// Persistent tab states to prevent resets from parent re-renders
|
||||||
const [controlPanelTab, setControlPanelTab] = useState<
|
const [controlPanelTab, setControlPanelTab] = useState<
|
||||||
"control" | "step" | "actions"
|
"control" | "step" | "actions" | "robot"
|
||||||
>("control");
|
>("control");
|
||||||
const [executionPanelTab, setExecutionPanelTab] = useState<
|
const [executionPanelTab, setExecutionPanelTab] = useState<
|
||||||
"current" | "timeline" | "events"
|
"current" | "timeline" | "events"
|
||||||
@@ -86,6 +86,20 @@ export function WizardInterface({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Robot action execution mutation
|
||||||
|
const executeRobotActionMutation = api.trials.executeRobotAction.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success("Robot action executed successfully", {
|
||||||
|
description: `Completed in ${result.duration}ms`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to execute robot action", {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Map database step types to component step types
|
// Map database step types to component step types
|
||||||
const mapStepType = (dbType: string) => {
|
const mapStepType = (dbType: string) => {
|
||||||
switch (dbType) {
|
switch (dbType) {
|
||||||
@@ -304,6 +318,23 @@ export function WizardInterface({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExecuteRobotAction = async (
|
||||||
|
pluginName: string,
|
||||||
|
actionId: string,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await executeRobotActionMutation.mutateAsync({
|
||||||
|
trialId: trial.id,
|
||||||
|
pluginName,
|
||||||
|
actionId,
|
||||||
|
parameters,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to execute robot action:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Compact Status Bar */}
|
{/* Compact Status Bar */}
|
||||||
@@ -370,6 +401,8 @@ export function WizardInterface({
|
|||||||
onCompleteTrial={handleCompleteTrial}
|
onCompleteTrial={handleCompleteTrial}
|
||||||
onAbortTrial={handleAbortTrial}
|
onAbortTrial={handleAbortTrial}
|
||||||
onExecuteAction={handleExecuteAction}
|
onExecuteAction={handleExecuteAction}
|
||||||
|
onExecuteRobotAction={handleExecuteRobotAction}
|
||||||
|
studyId={trial.experiment.studyId}
|
||||||
_isConnected={true}
|
_isConnected={true}
|
||||||
activeTab={controlPanelTab}
|
activeTab={controlPanelTab}
|
||||||
onTabChange={setControlPanelTab}
|
onTabChange={setControlPanelTab}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Zap,
|
Zap,
|
||||||
User,
|
User,
|
||||||
|
Bot,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
@@ -20,6 +21,7 @@ import { Separator } from "~/components/ui/separator";
|
|||||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
|
import { RobotActionsPanel } from "../RobotActionsPanel";
|
||||||
|
|
||||||
interface StepData {
|
interface StepData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -73,9 +75,15 @@ interface WizardControlPanelProps {
|
|||||||
actionId: string,
|
actionId: string,
|
||||||
parameters?: Record<string, unknown>,
|
parameters?: Record<string, unknown>,
|
||||||
) => void;
|
) => void;
|
||||||
|
onExecuteRobotAction?: (
|
||||||
|
pluginName: string,
|
||||||
|
actionId: string,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
studyId?: string;
|
||||||
_isConnected: boolean;
|
_isConnected: boolean;
|
||||||
activeTab: "control" | "step" | "actions";
|
activeTab: "control" | "step" | "actions" | "robot";
|
||||||
onTabChange: (tab: "control" | "step" | "actions") => void;
|
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
|
||||||
isStarting?: boolean;
|
isStarting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +98,8 @@ export function WizardControlPanel({
|
|||||||
onCompleteTrial,
|
onCompleteTrial,
|
||||||
onAbortTrial,
|
onAbortTrial,
|
||||||
onExecuteAction,
|
onExecuteAction,
|
||||||
|
onExecuteRobotAction,
|
||||||
|
studyId,
|
||||||
_isConnected,
|
_isConnected,
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
@@ -157,14 +167,19 @@ export function WizardControlPanel({
|
|||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(value: string) => {
|
onValueChange={(value: string) => {
|
||||||
if (value === "control" || value === "step" || value === "actions") {
|
if (
|
||||||
onTabChange(value);
|
value === "control" ||
|
||||||
|
value === "step" ||
|
||||||
|
value === "actions" ||
|
||||||
|
value === "robot"
|
||||||
|
) {
|
||||||
|
onTabChange(value as "control" | "step" | "actions" | "robot");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="flex min-h-0 flex-1 flex-col"
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
>
|
>
|
||||||
<div className="border-b px-2 py-1">
|
<div className="border-b px-2 py-1">
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
<TabsTrigger value="control" className="text-xs">
|
<TabsTrigger value="control" className="text-xs">
|
||||||
<Settings className="mr-1 h-3 w-3" />
|
<Settings className="mr-1 h-3 w-3" />
|
||||||
Control
|
Control
|
||||||
@@ -177,6 +192,10 @@ export function WizardControlPanel({
|
|||||||
<Zap className="mr-1 h-3 w-3" />
|
<Zap className="mr-1 h-3 w-3" />
|
||||||
Actions
|
Actions
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="robot" className="text-xs">
|
||||||
|
<Bot className="mr-1 h-3 w-3" />
|
||||||
|
Robot
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -422,6 +441,32 @@ export function WizardControlPanel({
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Robot Actions Tab */}
|
||||||
|
<TabsContent
|
||||||
|
value="robot"
|
||||||
|
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
|
||||||
|
>
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="p-3">
|
||||||
|
{studyId && onExecuteRobotAction ? (
|
||||||
|
<RobotActionsPanel
|
||||||
|
studyId={studyId}
|
||||||
|
trialId={trial.id}
|
||||||
|
onExecuteAction={onExecuteRobotAction}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Robot actions are not available. Study ID or action
|
||||||
|
handler is missing.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ import {
|
|||||||
mediaCaptures,
|
mediaCaptures,
|
||||||
users,
|
users,
|
||||||
} from "~/server/db/schema";
|
} from "~/server/db/schema";
|
||||||
import { TrialExecutionEngine } from "~/server/services/trial-execution";
|
import {
|
||||||
|
TrialExecutionEngine,
|
||||||
|
type ActionDefinition,
|
||||||
|
} from "~/server/services/trial-execution";
|
||||||
|
|
||||||
// Helper function to check if user has access to trial
|
// Helper function to check if user has access to trial
|
||||||
async function checkTrialAccess(
|
async function checkTrialAccess(
|
||||||
@@ -894,4 +897,74 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
executeRobotAction: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
trialId: z.string(),
|
||||||
|
pluginName: z.string(),
|
||||||
|
actionId: z.string(),
|
||||||
|
parameters: z.record(z.string(), z.unknown()).optional().default({}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { db } = ctx;
|
||||||
|
const userId = ctx.session.user.id;
|
||||||
|
|
||||||
|
await checkTrialAccess(db, userId, input.trialId, [
|
||||||
|
"owner",
|
||||||
|
"researcher",
|
||||||
|
"wizard",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Use execution engine to execute robot action
|
||||||
|
const executionEngine = getExecutionEngine();
|
||||||
|
|
||||||
|
// Create action definition for execution
|
||||||
|
const actionDefinition: ActionDefinition = {
|
||||||
|
id: `${input.pluginName}.${input.actionId}`,
|
||||||
|
stepId: "manual", // Manual execution
|
||||||
|
name: input.actionId,
|
||||||
|
type: `${input.pluginName}.${input.actionId}`,
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: input.parameters,
|
||||||
|
timeout: 30000,
|
||||||
|
required: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await executionEngine.executeAction(
|
||||||
|
input.trialId,
|
||||||
|
actionDefinition,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: result.error ?? "Robot action execution failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the manual robot action execution
|
||||||
|
await db.insert(trialEvents).values({
|
||||||
|
trialId: input.trialId,
|
||||||
|
eventType: "manual_robot_action",
|
||||||
|
actionId: actionDefinition.id,
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
pluginName: input.pluginName,
|
||||||
|
actionId: input.actionId,
|
||||||
|
parameters: input.parameters,
|
||||||
|
result: result.data,
|
||||||
|
duration: result.duration,
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
duration: result.duration,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
472
src/server/services/robot-communication.ts
Normal file
472
src/server/services/robot-communication.ts
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||||
|
|
||||||
|
import WebSocket from "ws";
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
|
||||||
|
export interface RobotCommunicationConfig {
|
||||||
|
rosBridgeUrl: string;
|
||||||
|
connectionTimeout: number;
|
||||||
|
reconnectInterval: number;
|
||||||
|
maxReconnectAttempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RobotAction {
|
||||||
|
pluginName: string;
|
||||||
|
actionId: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
implementation: {
|
||||||
|
topic: string;
|
||||||
|
messageType: string;
|
||||||
|
messageTemplate: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RobotActionResult {
|
||||||
|
success: boolean;
|
||||||
|
duration: number;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side robot communication service for ROS integration
|
||||||
|
*
|
||||||
|
* This service manages WebSocket connections to rosbridge_server and provides
|
||||||
|
* a high-level interface for executing robot actions during trial execution.
|
||||||
|
*/
|
||||||
|
export class RobotCommunicationService extends EventEmitter {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private config: RobotCommunicationConfig;
|
||||||
|
private messageId = 0;
|
||||||
|
private pendingActions = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
resolve: (result: RobotActionResult) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
startTime: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
|
private isConnected = false;
|
||||||
|
|
||||||
|
constructor(config: Partial<RobotCommunicationConfig> = {}) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.config = {
|
||||||
|
rosBridgeUrl: process.env.ROS_BRIDGE_URL || "ws://localhost:9090",
|
||||||
|
connectionTimeout: 10000,
|
||||||
|
reconnectInterval: 5000,
|
||||||
|
maxReconnectAttempts: 10,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize connection to ROS bridge
|
||||||
|
*/
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (this.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
console.log(
|
||||||
|
`[RobotComm] Connecting to ROS bridge: ${this.config.rosBridgeUrl}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(this.config.rosBridgeUrl);
|
||||||
|
|
||||||
|
const connectionTimeout = setTimeout(() => {
|
||||||
|
reject(new Error("Connection timeout"));
|
||||||
|
this.cleanup();
|
||||||
|
}, this.config.connectionTimeout);
|
||||||
|
|
||||||
|
this.ws.on("open", () => {
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
this.isConnected = true;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
|
||||||
|
console.log("[RobotComm] Connected to ROS bridge");
|
||||||
|
this.emit("connected");
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on("message", (data: WebSocket.Data) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(data.toString());
|
||||||
|
this.handleMessage(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[RobotComm] Failed to parse message:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on("close", (code: number, reason: string) => {
|
||||||
|
this.isConnected = false;
|
||||||
|
console.log(`[RobotComm] Connection closed: ${code} - ${reason}`);
|
||||||
|
|
||||||
|
this.emit("disconnected");
|
||||||
|
|
||||||
|
// Reject all pending actions
|
||||||
|
this.rejectAllPendingActions(new Error("Connection lost"));
|
||||||
|
|
||||||
|
// Schedule reconnection if not intentionally closed
|
||||||
|
if (
|
||||||
|
code !== 1000 &&
|
||||||
|
this.reconnectAttempts < this.config.maxReconnectAttempts
|
||||||
|
) {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on("error", (error: Error) => {
|
||||||
|
console.error("[RobotComm] WebSocket error:", error);
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
this.emit("error", error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from ROS bridge
|
||||||
|
*/
|
||||||
|
disconnect(): void {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rejectAllPendingActions(new Error("Service disconnected"));
|
||||||
|
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close(1000, "Normal closure");
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnected = false;
|
||||||
|
this.emit("disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a robot action
|
||||||
|
*/
|
||||||
|
async executeAction(action: RobotAction): Promise<RobotActionResult> {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
throw new Error("Not connected to ROS bridge");
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const actionId = `action_${this.messageId++}`;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Set up timeout
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.pendingActions.delete(actionId);
|
||||||
|
reject(new Error(`Action timeout: ${action.actionId}`));
|
||||||
|
}, 30000); // 30 second timeout
|
||||||
|
|
||||||
|
// Store pending action
|
||||||
|
this.pendingActions.set(actionId, {
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timeout,
|
||||||
|
startTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute action based on type and platform
|
||||||
|
this.executeRobotActionInternal(action, actionId);
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
this.pendingActions.delete(actionId);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if service is connected
|
||||||
|
*/
|
||||||
|
getConnectionStatus(): boolean {
|
||||||
|
return this.isConnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private methods
|
||||||
|
|
||||||
|
private executeRobotActionInternal(
|
||||||
|
action: RobotAction,
|
||||||
|
actionId: string,
|
||||||
|
): void {
|
||||||
|
const { implementation, parameters } = action;
|
||||||
|
|
||||||
|
// Build ROS message from template
|
||||||
|
const message = this.buildRosMessage(
|
||||||
|
implementation.messageTemplate,
|
||||||
|
parameters,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Publish to ROS topic
|
||||||
|
this.publishToTopic(
|
||||||
|
implementation.topic,
|
||||||
|
implementation.messageType,
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
|
||||||
|
// For actions that complete immediately (like movement commands),
|
||||||
|
// we simulate completion after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
this.completeAction(actionId, {
|
||||||
|
success: true,
|
||||||
|
duration:
|
||||||
|
Date.now() -
|
||||||
|
(this.pendingActions.get(actionId)?.startTime || Date.now()),
|
||||||
|
data: {
|
||||||
|
topic: implementation.topic,
|
||||||
|
messageType: implementation.messageType,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRosMessage(
|
||||||
|
template: Record<string, unknown>,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const message: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(template)) {
|
||||||
|
if (typeof value === "string" && value.includes("{{")) {
|
||||||
|
// Template substitution
|
||||||
|
let substituted = value;
|
||||||
|
|
||||||
|
// Replace template variables
|
||||||
|
for (const [paramKey, paramValue] of Object.entries(parameters)) {
|
||||||
|
const placeholder = `{{${paramKey}}}`;
|
||||||
|
if (substituted.includes(placeholder)) {
|
||||||
|
substituted = substituted.replace(
|
||||||
|
new RegExp(
|
||||||
|
placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||||
|
"g",
|
||||||
|
),
|
||||||
|
String(paramValue ?? ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle conditional templates
|
||||||
|
if (
|
||||||
|
substituted.includes("{{") &&
|
||||||
|
substituted.includes("?") &&
|
||||||
|
substituted.includes(":")
|
||||||
|
) {
|
||||||
|
// Simple conditional: {{condition ? valueTrue : valueFalse}}
|
||||||
|
const match = substituted.match(
|
||||||
|
/\{\{(.+?)\s*\?\s*(.+?)\s*:\s*(.+?)\}\}/,
|
||||||
|
);
|
||||||
|
if (match && match.length >= 4) {
|
||||||
|
const condition = match[1];
|
||||||
|
const trueValue = match[2];
|
||||||
|
const falseValue = match[3];
|
||||||
|
// Evaluate simple conditions
|
||||||
|
let conditionResult = false;
|
||||||
|
|
||||||
|
if (condition?.includes("===")) {
|
||||||
|
const parts = condition
|
||||||
|
.split("===")
|
||||||
|
.map((s) => s.trim().replace(/['"]/g, ""));
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const left = parts[0];
|
||||||
|
const right = parts[1];
|
||||||
|
conditionResult = parameters[left || ""] === right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
substituted = substituted.replace(
|
||||||
|
match[0],
|
||||||
|
conditionResult ? (trueValue ?? "") : (falseValue ?? ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as number if it looks like one
|
||||||
|
if (!isNaN(Number(substituted))) {
|
||||||
|
message[key] = Number(substituted);
|
||||||
|
} else {
|
||||||
|
message[key] = substituted;
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
// Handle array templates
|
||||||
|
message[key] = value.map((item) =>
|
||||||
|
typeof item === "string" && item.includes("{{")
|
||||||
|
? this.substituteTemplateString(item, parameters)
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
} else if (typeof value === "object" && value !== null) {
|
||||||
|
// Recursively handle nested objects
|
||||||
|
message[key] = this.buildRosMessage(
|
||||||
|
value as Record<string, unknown>,
|
||||||
|
parameters,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
message[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private substituteTemplateString(
|
||||||
|
template: string,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
): unknown {
|
||||||
|
let result = template;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(parameters)) {
|
||||||
|
const placeholder = `{{${key}}}`;
|
||||||
|
if (result.includes(placeholder)) {
|
||||||
|
result = result.replace(
|
||||||
|
new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
|
||||||
|
String(value ?? ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as number if it looks like one
|
||||||
|
if (!isNaN(Number(result))) {
|
||||||
|
return Number(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishToTopic(
|
||||||
|
topic: string,
|
||||||
|
messageType: string,
|
||||||
|
message: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (!this.ws) return;
|
||||||
|
|
||||||
|
const rosMessage = {
|
||||||
|
op: "publish",
|
||||||
|
topic,
|
||||||
|
type: messageType,
|
||||||
|
msg: message,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[RobotComm] Publishing to ${topic}:`, message);
|
||||||
|
this.ws.send(JSON.stringify(rosMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMessage(message: any): void {
|
||||||
|
// Handle different types of ROS bridge messages
|
||||||
|
switch (message.op) {
|
||||||
|
case "publish":
|
||||||
|
this.emit("topic_message", message.topic, message.msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "service_response":
|
||||||
|
this.handleServiceResponse(message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "status":
|
||||||
|
console.log("[RobotComm] Status:", message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log("[RobotComm] Unhandled message:", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleServiceResponse(message: any): void {
|
||||||
|
// Handle service call responses if needed
|
||||||
|
console.log("[RobotComm] Service response:", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private completeAction(actionId: string, result: RobotActionResult): void {
|
||||||
|
const pending = this.pendingActions.get(actionId);
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
this.pendingActions.delete(actionId);
|
||||||
|
pending.resolve(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private rejectAllPendingActions(error: Error): void {
|
||||||
|
for (const [actionId, pending] of this.pendingActions.entries()) {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
pending.reject(error);
|
||||||
|
}
|
||||||
|
this.pendingActions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.reconnectTimer) return;
|
||||||
|
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
console.log(
|
||||||
|
`[RobotComm] Scheduling reconnect attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts} in ${this.config.reconnectInterval}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.reconnectTimer = setTimeout(async () => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.connect();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[RobotComm] Reconnect failed:", error);
|
||||||
|
|
||||||
|
if (this.reconnectAttempts < this.config.maxReconnectAttempts) {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
} else {
|
||||||
|
console.error("[RobotComm] Max reconnect attempts reached");
|
||||||
|
this.emit("max_reconnects_reached");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, this.config.reconnectInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(): void {
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.removeAllListeners();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this.isConnected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global service instance
|
||||||
|
let robotCommService: RobotCommunicationService | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the global robot communication service
|
||||||
|
*/
|
||||||
|
export function getRobotCommunicationService(): RobotCommunicationService {
|
||||||
|
if (!robotCommService) {
|
||||||
|
robotCommService = new RobotCommunicationService();
|
||||||
|
}
|
||||||
|
return robotCommService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize robot communication service with connection
|
||||||
|
*/
|
||||||
|
export async function initRobotCommunicationService(): Promise<RobotCommunicationService> {
|
||||||
|
const service = getRobotCommunicationService();
|
||||||
|
|
||||||
|
if (!service.getConnectionStatus()) {
|
||||||
|
await service.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return service;
|
||||||
|
}
|
||||||
@@ -9,8 +9,19 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-base-to-string */
|
/* eslint-disable @typescript-eslint/no-base-to-string */
|
||||||
|
|
||||||
import { type db } from "~/server/db";
|
import { type db } from "~/server/db";
|
||||||
import { trials, steps, actions, trialEvents } from "~/server/db/schema";
|
import {
|
||||||
|
trials,
|
||||||
|
steps,
|
||||||
|
actions,
|
||||||
|
trialEvents,
|
||||||
|
plugins,
|
||||||
|
} from "~/server/db/schema";
|
||||||
import { eq, asc } from "drizzle-orm";
|
import { eq, asc } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
getRobotCommunicationService,
|
||||||
|
type RobotAction,
|
||||||
|
type RobotActionResult,
|
||||||
|
} from "./robot-communication";
|
||||||
|
|
||||||
export type TrialStatus =
|
export type TrialStatus =
|
||||||
| "scheduled"
|
| "scheduled"
|
||||||
@@ -72,6 +83,8 @@ export class TrialExecutionEngine {
|
|||||||
private db: typeof db;
|
private db: typeof db;
|
||||||
private activeTrials = new Map<string, ExecutionContext>();
|
private activeTrials = new Map<string, ExecutionContext>();
|
||||||
private stepDefinitions = new Map<string, StepDefinition[]>();
|
private stepDefinitions = new Map<string, StepDefinition[]>();
|
||||||
|
private pluginCache = new Map<string, any>();
|
||||||
|
private robotComm = getRobotCommunicationService();
|
||||||
|
|
||||||
constructor(database: typeof db) {
|
constructor(database: typeof db) {
|
||||||
this.db = database;
|
this.db = database;
|
||||||
@@ -377,7 +390,7 @@ export class TrialExecutionEngine {
|
|||||||
/**
|
/**
|
||||||
* Execute a single action
|
* Execute a single action
|
||||||
*/
|
*/
|
||||||
private async executeAction(
|
async executeAction(
|
||||||
trialId: string,
|
trialId: string,
|
||||||
action: ActionDefinition,
|
action: ActionDefinition,
|
||||||
): Promise<ActionExecutionResult> {
|
): Promise<ActionExecutionResult> {
|
||||||
@@ -488,41 +501,69 @@ export class TrialExecutionEngine {
|
|||||||
trialId: string,
|
trialId: string,
|
||||||
action: ActionDefinition,
|
action: ActionDefinition,
|
||||||
): Promise<ActionExecutionResult> {
|
): Promise<ActionExecutionResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parse plugin.action format
|
// Parse plugin.action format
|
||||||
const [pluginId, actionType] = action.type.split(".");
|
const [pluginName, actionId] = action.type.split(".");
|
||||||
|
|
||||||
// TODO: Integrate with actual robot plugin system
|
if (!pluginName || !actionId) {
|
||||||
// For now, simulate robot action execution
|
throw new Error(
|
||||||
|
`Invalid robot action format: ${action.type}. Expected format: plugin.action`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const simulationDelay = Math.random() * 2000 + 500; // 500ms - 2.5s
|
// Get plugin configuration from database
|
||||||
|
const plugin = await this.getPluginDefinition(pluginName);
|
||||||
|
if (!plugin) {
|
||||||
|
throw new Error(`Plugin not found: ${pluginName}`);
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
// Find action definition in plugin
|
||||||
setTimeout(() => {
|
const actionDefinition = plugin.actions?.find(
|
||||||
// Simulate success/failure
|
(a: any) => a.id === actionId,
|
||||||
const success = Math.random() > 0.1; // 90% success rate
|
);
|
||||||
|
if (!actionDefinition) {
|
||||||
|
throw new Error(
|
||||||
|
`Action '${actionId}' not found in plugin '${pluginName}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
resolve({
|
// Validate parameters
|
||||||
success,
|
const validatedParams = this.validateActionParameters(
|
||||||
completed: true,
|
actionDefinition,
|
||||||
duration: simulationDelay,
|
action.parameters,
|
||||||
data: {
|
);
|
||||||
pluginId,
|
|
||||||
actionType,
|
// Execute action through robot communication service
|
||||||
parameters: action.parameters,
|
const result = await this.executeRobotActionWithComm(
|
||||||
robotResponse: success
|
plugin,
|
||||||
? "Action completed successfully"
|
actionDefinition,
|
||||||
: "Robot action failed",
|
validatedParams,
|
||||||
},
|
trialId,
|
||||||
error: success ? undefined : "Simulated robot failure",
|
);
|
||||||
});
|
|
||||||
}, simulationDelay);
|
const duration = Date.now() - startTime;
|
||||||
});
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
completed: true,
|
||||||
|
duration,
|
||||||
|
data: {
|
||||||
|
pluginName,
|
||||||
|
actionId,
|
||||||
|
parameters: validatedParams,
|
||||||
|
robotResponse: result,
|
||||||
|
platform: plugin.platform,
|
||||||
|
},
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
completed: false,
|
completed: false,
|
||||||
duration: 0,
|
duration,
|
||||||
error:
|
error:
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
@@ -531,6 +572,224 @@ export class TrialExecutionEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get plugin definition from database with caching
|
||||||
|
*/
|
||||||
|
private async getPluginDefinition(pluginName: string): Promise<any> {
|
||||||
|
// Check cache first
|
||||||
|
if (this.pluginCache.has(pluginName)) {
|
||||||
|
return this.pluginCache.get(pluginName);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [plugin] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plugins)
|
||||||
|
.where(eq(plugins.name, pluginName))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (plugin) {
|
||||||
|
// Cache the plugin definition
|
||||||
|
const pluginData = {
|
||||||
|
...plugin,
|
||||||
|
actions: plugin.actionDefinitions,
|
||||||
|
platform: (plugin.metadata as any)?.platform,
|
||||||
|
ros2Config: (plugin.metadata as any)?.ros2Config,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pluginCache.set(pluginName, pluginData);
|
||||||
|
return pluginData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load plugin ${pluginName}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate action parameters against plugin schema
|
||||||
|
*/
|
||||||
|
private validateActionParameters(
|
||||||
|
actionDefinition: any,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const validated: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (!actionDefinition.parameters) {
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const paramDef of actionDefinition.parameters) {
|
||||||
|
const paramName = paramDef.name;
|
||||||
|
const paramValue = parameters[paramName];
|
||||||
|
|
||||||
|
// Required parameter check
|
||||||
|
if (
|
||||||
|
paramDef.required &&
|
||||||
|
(paramValue === undefined || paramValue === null)
|
||||||
|
) {
|
||||||
|
throw new Error(`Required parameter '${paramName}' is missing`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use default value if parameter not provided
|
||||||
|
if (paramValue === undefined && paramDef.default !== undefined) {
|
||||||
|
validated[paramName] = paramDef.default;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paramValue !== undefined) {
|
||||||
|
// Type validation
|
||||||
|
switch (paramDef.type) {
|
||||||
|
case "number":
|
||||||
|
const numValue = Number(paramValue);
|
||||||
|
if (isNaN(numValue)) {
|
||||||
|
throw new Error(`Parameter '${paramName}' must be a number`);
|
||||||
|
}
|
||||||
|
if (paramDef.min !== undefined && numValue < paramDef.min) {
|
||||||
|
throw new Error(
|
||||||
|
`Parameter '${paramName}' must be >= ${paramDef.min}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (paramDef.max !== undefined && numValue > paramDef.max) {
|
||||||
|
throw new Error(
|
||||||
|
`Parameter '${paramName}' must be <= ${paramDef.max}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validated[paramName] = numValue;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "boolean":
|
||||||
|
validated[paramName] = Boolean(paramValue);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "select":
|
||||||
|
if (paramDef.options) {
|
||||||
|
const validOptions = paramDef.options.map(
|
||||||
|
(opt: any) => opt.value,
|
||||||
|
);
|
||||||
|
if (!validOptions.includes(paramValue)) {
|
||||||
|
throw new Error(
|
||||||
|
`Parameter '${paramName}' must be one of: ${validOptions.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validated[paramName] = paramValue;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
validated[paramName] = paramValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute robot action through robot communication service
|
||||||
|
*/
|
||||||
|
private async executeRobotActionWithComm(
|
||||||
|
plugin: any,
|
||||||
|
actionDefinition: any,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
trialId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
// Ensure robot communication service is available
|
||||||
|
if (!this.robotComm.getConnectionStatus()) {
|
||||||
|
try {
|
||||||
|
await this.robotComm.connect();
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to connect to robot: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare robot action
|
||||||
|
const robotAction: RobotAction = {
|
||||||
|
pluginName: plugin.name,
|
||||||
|
actionId: actionDefinition.id,
|
||||||
|
parameters,
|
||||||
|
implementation: actionDefinition.implementation,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute action through robot communication service
|
||||||
|
const result: RobotActionResult =
|
||||||
|
await this.robotComm.executeAction(robotAction);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || "Robot action failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the successful action execution
|
||||||
|
await this.logTrialEvent(trialId, "robot_action_executed", {
|
||||||
|
actionId: actionDefinition.id,
|
||||||
|
parameters,
|
||||||
|
platform: plugin.platform,
|
||||||
|
topic: actionDefinition.implementation?.topic,
|
||||||
|
messageType: actionDefinition.implementation?.messageType,
|
||||||
|
duration: result.duration,
|
||||||
|
robotResponse: result.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return human-readable result
|
||||||
|
return this.formatRobotActionResult(
|
||||||
|
plugin,
|
||||||
|
actionDefinition,
|
||||||
|
parameters,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format robot action result for human readability
|
||||||
|
*/
|
||||||
|
private formatRobotActionResult(
|
||||||
|
plugin: any,
|
||||||
|
actionDefinition: any,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
result: RobotActionResult,
|
||||||
|
): string {
|
||||||
|
const actionType = actionDefinition.id;
|
||||||
|
const platform = plugin.platform || "Robot";
|
||||||
|
|
||||||
|
switch (actionType) {
|
||||||
|
case "say_text":
|
||||||
|
return `${platform} said: "${parameters.text}"`;
|
||||||
|
|
||||||
|
case "walk_forward":
|
||||||
|
return `${platform} walked forward at speed ${parameters.speed} for ${parameters.duration || "indefinite"} seconds`;
|
||||||
|
|
||||||
|
case "walk_backward":
|
||||||
|
return `${platform} walked backward at speed ${parameters.speed} for ${parameters.duration || "indefinite"} seconds`;
|
||||||
|
|
||||||
|
case "turn_left":
|
||||||
|
case "turn_right":
|
||||||
|
const direction = actionType.split("_")[1];
|
||||||
|
return `${platform} turned ${direction} at speed ${parameters.speed}`;
|
||||||
|
|
||||||
|
case "move_head":
|
||||||
|
return `${platform} moved head to yaw=${parameters.yaw}, pitch=${parameters.pitch}`;
|
||||||
|
|
||||||
|
case "move_arm":
|
||||||
|
return `${platform} moved ${parameters.arm} arm to specified position`;
|
||||||
|
|
||||||
|
case "stop_movement":
|
||||||
|
return `${platform} stopped all movement`;
|
||||||
|
|
||||||
|
case "set_volume":
|
||||||
|
return `${platform} set volume to ${parameters.volume}`;
|
||||||
|
|
||||||
|
case "set_language":
|
||||||
|
return `${platform} set language to ${parameters.language}`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return `${platform} executed action: ${actionType} (${result.duration}ms)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advance to the next step
|
* Advance to the next step
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user