mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 06:34: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
|
||||
- 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**
|
||||
|
||||
14. **[Research Paper](./root.tex)** - Academic LaTeX document
|
||||
15. **[Bibliography](./refs.bib)** - Research references
|
||||
17. **[Research Paper](./root.tex)** - Academic LaTeX document
|
||||
18. **[Bibliography](./refs.bib)** - Research references
|
||||
|
||||
---
|
||||
|
||||
@@ -152,8 +158,14 @@ This documentation suite provides everything needed to understand, build, deploy
|
||||
### **For Researchers**
|
||||
1. **[Project Overview](./project-overview.md)** - Research platform capabilities
|
||||
2. **[Feature Requirements](./feature-requirements.md)** - User workflows and features
|
||||
3. **[ROS2 Integration](./ros2-integration.md)** - Robot platform integration
|
||||
4. **[Research Paper](./root.tex)** - Academic context and methodology
|
||||
3. **[NAO6 Quick Reference](./nao6-quick-reference.md)** - Essential NAO6 robot control commands
|
||||
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
|
||||
- **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**
|
||||
@@ -238,6 +257,7 @@ bun dev
|
||||
- ✅ **Core Blocks System** - 26 blocks across events, wizard, control, observation
|
||||
- ✅ **Plugin Architecture** - Unified system for core blocks and robot actions
|
||||
- ✅ **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
|
||||
- ✅ 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.
|
||||
|
||||
> **📋 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
|
||||
|
||||
- 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",
|
||||
model: "NAO V6",
|
||||
description:
|
||||
"Humanoid robot designed for education, research, and social interaction",
|
||||
capabilities: ["speech", "vision", "walking", "gestures"],
|
||||
communicationProtocol: "rest" as const,
|
||||
"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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -295,6 +306,17 @@ async function main() {
|
||||
syncStatus: "pending" as const,
|
||||
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
|
||||
|
||||
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
|
||||
const [controlPanelTab, setControlPanelTab] = useState<
|
||||
"control" | "step" | "actions"
|
||||
"control" | "step" | "actions" | "robot"
|
||||
>("control");
|
||||
const [executionPanelTab, setExecutionPanelTab] = useState<
|
||||
"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
|
||||
const mapStepType = (dbType: string) => {
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Compact Status Bar */}
|
||||
@@ -370,6 +401,8 @@ export function WizardInterface({
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
onAbortTrial={handleAbortTrial}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
studyId={trial.experiment.studyId}
|
||||
_isConnected={true}
|
||||
activeTab={controlPanelTab}
|
||||
onTabChange={setControlPanelTab}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Settings,
|
||||
Zap,
|
||||
User,
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -20,6 +21,7 @@ import { Separator } from "~/components/ui/separator";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { RobotActionsPanel } from "../RobotActionsPanel";
|
||||
|
||||
interface StepData {
|
||||
id: string;
|
||||
@@ -73,9 +75,15 @@ interface WizardControlPanelProps {
|
||||
actionId: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => void;
|
||||
onExecuteRobotAction?: (
|
||||
pluginName: string,
|
||||
actionId: string,
|
||||
parameters: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
studyId?: string;
|
||||
_isConnected: boolean;
|
||||
activeTab: "control" | "step" | "actions";
|
||||
onTabChange: (tab: "control" | "step" | "actions") => void;
|
||||
activeTab: "control" | "step" | "actions" | "robot";
|
||||
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
|
||||
isStarting?: boolean;
|
||||
}
|
||||
|
||||
@@ -90,6 +98,8 @@ export function WizardControlPanel({
|
||||
onCompleteTrial,
|
||||
onAbortTrial,
|
||||
onExecuteAction,
|
||||
onExecuteRobotAction,
|
||||
studyId,
|
||||
_isConnected,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
@@ -157,14 +167,19 @@ export function WizardControlPanel({
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value: string) => {
|
||||
if (value === "control" || value === "step" || value === "actions") {
|
||||
onTabChange(value);
|
||||
if (
|
||||
value === "control" ||
|
||||
value === "step" ||
|
||||
value === "actions" ||
|
||||
value === "robot"
|
||||
) {
|
||||
onTabChange(value as "control" | "step" | "actions" | "robot");
|
||||
}
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<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">
|
||||
<Settings className="mr-1 h-3 w-3" />
|
||||
Control
|
||||
@@ -177,6 +192,10 @@ export function WizardControlPanel({
|
||||
<Zap className="mr-1 h-3 w-3" />
|
||||
Actions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="robot" className="text-xs">
|
||||
<Bot className="mr-1 h-3 w-3" />
|
||||
Robot
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -422,6 +441,32 @@ export function WizardControlPanel({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</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>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
mediaCaptures,
|
||||
users,
|
||||
} 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
|
||||
async function checkTrialAccess(
|
||||
@@ -894,4 +897,74 @@ export const trialsRouter = createTRPCRouter({
|
||||
|
||||
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 */
|
||||
|
||||
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 {
|
||||
getRobotCommunicationService,
|
||||
type RobotAction,
|
||||
type RobotActionResult,
|
||||
} from "./robot-communication";
|
||||
|
||||
export type TrialStatus =
|
||||
| "scheduled"
|
||||
@@ -72,6 +83,8 @@ export class TrialExecutionEngine {
|
||||
private db: typeof db;
|
||||
private activeTrials = new Map<string, ExecutionContext>();
|
||||
private stepDefinitions = new Map<string, StepDefinition[]>();
|
||||
private pluginCache = new Map<string, any>();
|
||||
private robotComm = getRobotCommunicationService();
|
||||
|
||||
constructor(database: typeof db) {
|
||||
this.db = database;
|
||||
@@ -377,7 +390,7 @@ export class TrialExecutionEngine {
|
||||
/**
|
||||
* Execute a single action
|
||||
*/
|
||||
private async executeAction(
|
||||
async executeAction(
|
||||
trialId: string,
|
||||
action: ActionDefinition,
|
||||
): Promise<ActionExecutionResult> {
|
||||
@@ -488,41 +501,69 @@ export class TrialExecutionEngine {
|
||||
trialId: string,
|
||||
action: ActionDefinition,
|
||||
): Promise<ActionExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Parse plugin.action format
|
||||
const [pluginId, actionType] = action.type.split(".");
|
||||
const [pluginName, actionId] = action.type.split(".");
|
||||
|
||||
// TODO: Integrate with actual robot plugin system
|
||||
// For now, simulate robot action execution
|
||||
if (!pluginName || !actionId) {
|
||||
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) => {
|
||||
setTimeout(() => {
|
||||
// Simulate success/failure
|
||||
const success = Math.random() > 0.1; // 90% success rate
|
||||
// Find action definition in plugin
|
||||
const actionDefinition = plugin.actions?.find(
|
||||
(a: any) => a.id === actionId,
|
||||
);
|
||||
if (!actionDefinition) {
|
||||
throw new Error(
|
||||
`Action '${actionId}' not found in plugin '${pluginName}'`,
|
||||
);
|
||||
}
|
||||
|
||||
resolve({
|
||||
success,
|
||||
completed: true,
|
||||
duration: simulationDelay,
|
||||
data: {
|
||||
pluginId,
|
||||
actionType,
|
||||
parameters: action.parameters,
|
||||
robotResponse: success
|
||||
? "Action completed successfully"
|
||||
: "Robot action failed",
|
||||
},
|
||||
error: success ? undefined : "Simulated robot failure",
|
||||
});
|
||||
}, simulationDelay);
|
||||
});
|
||||
// Validate parameters
|
||||
const validatedParams = this.validateActionParameters(
|
||||
actionDefinition,
|
||||
action.parameters,
|
||||
);
|
||||
|
||||
// Execute action through robot communication service
|
||||
const result = await this.executeRobotActionWithComm(
|
||||
plugin,
|
||||
actionDefinition,
|
||||
validatedParams,
|
||||
trialId,
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
completed: true,
|
||||
duration,
|
||||
data: {
|
||||
pluginName,
|
||||
actionId,
|
||||
parameters: validatedParams,
|
||||
robotResponse: result,
|
||||
platform: plugin.platform,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
success: false,
|
||||
completed: false,
|
||||
duration: 0,
|
||||
duration,
|
||||
error:
|
||||
error instanceof Error
|
||||
? 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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user