Implements PID auto-tuning ROS2 node using relay feedback (Astrom-Hagglund) method. Service-triggered relay oscillation measures ultimate gain (Ku) and ultimate period (Tu), then computes Ziegler-Nichols PD gains. Safety abort on >25deg tilt. Features: - Service /saltybot/autotune_pid (std_srvs/Trigger) starts tuning - Relay oscillation method for accurate gain measurement - Measures Ku (ultimate gain) and Tu (ultimate period) - Computes Z-N PD gains: Kp=0.6*Ku, Kd=0.075*Ku*Tu - Real-time safety abort >25° tilt angle - JSON telemetry on /saltybot/autotune_info - Relay commands on /saltybot/autotune_cmd_vel Tuning Process: 1. Settle phase: zero command, allow oscillations to die 2. Relay oscillation: apply +/-relay_magnitude commands 3. Measure peaks: detect zero crossings, record extrema 4. Analysis: calculate Ku from peak amplitude, Tu from period 5. Gain computation: Ziegler-Nichols formulas 6. Publish results: Ku, Tu, Kp, Kd Safety Features: - IMU tilt monitoring (abort >25°) - Max tuning duration timeout - Configurable settle time and oscillation cycles Published Topics: - /saltybot/autotune_info (std_msgs/String) - JSON with Ku, Tu, Kp, Kd - /saltybot/autotune_cmd_vel (geometry_msgs/Twist) - Relay control Subscribed Topics: - /imu/data (sensor_msgs/Imu) - IMU tilt safety check - /saltybot/balance_feedback (std_msgs/Float32) - Balance feedback Package: saltybot_pid_autotune Entry point: pid_autotune_node Service: /saltybot/autotune_pid Tests: 20+ unit tests covering: - IMU tilt extraction - Relay oscillation analysis - Ku/Tu measurement - Ziegler-Nichols gain computation - Peak detection and averaging - Safety limits (tilt, timeout) - State machine transitions - JSON telemetry format Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
340 lines
10 KiB
JavaScript
340 lines
10 KiB
JavaScript
/**
|
|
* JoystickTeleop.jsx — Virtual joystick for robot teleop
|
|
*
|
|
* Features:
|
|
* - Virtual thumbstick UI with visual feedback
|
|
* - Touch & mouse support
|
|
* - Publishes geometry_msgs/Twist to /cmd_vel
|
|
* - 10% deadzone for both axes
|
|
* - Max linear velocity: 0.5 m/s
|
|
* - Max angular velocity: 1.0 rad/s
|
|
* - Real-time velocity display
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
const DEADZONE = 0.1; // 10%
|
|
const MAX_LINEAR_VEL = 0.5; // m/s
|
|
const MAX_ANGULAR_VEL = 1.0; // rad/s
|
|
const RADIUS = 80; // pixels
|
|
const STICK_RADIUS = 25; // pixels
|
|
|
|
function JoystickTeleop({ publish }) {
|
|
const canvasRef = useRef(null);
|
|
const containerRef = useRef(null);
|
|
const [joystickPos, setJoystickPos] = useState({ x: 0, y: 0 });
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [linearVel, setLinearVel] = useState(0);
|
|
const [angularVel, setAngularVel] = useState(0);
|
|
const velocityTimerRef = useRef(null);
|
|
|
|
// Draw joystick on canvas
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
const rect = canvas.getBoundingClientRect();
|
|
const centerX = canvas.width / 2;
|
|
const centerY = canvas.height / 2;
|
|
|
|
// Clear canvas
|
|
ctx.fillStyle = '#1f2937';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Draw outer circle (background)
|
|
ctx.strokeStyle = '#374151';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.arc(centerX, centerY, RADIUS, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// Draw crosshair
|
|
ctx.strokeStyle = '#4b5563';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(centerX - 30, centerY);
|
|
ctx.lineTo(centerX + 30, centerY);
|
|
ctx.moveTo(centerX, centerY - 30);
|
|
ctx.lineTo(centerX, centerY + 30);
|
|
ctx.stroke();
|
|
|
|
// Draw deadzone indicator
|
|
ctx.strokeStyle = '#6b7280';
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([2, 2]);
|
|
ctx.beginPath();
|
|
ctx.arc(centerX, centerY, RADIUS * DEADZONE, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
// Draw stick
|
|
const stickX = centerX + joystickPos.x * RADIUS;
|
|
const stickY = centerY + joystickPos.y * RADIUS;
|
|
|
|
ctx.fillStyle = isDragging ? '#06b6d4' : '#0891b2';
|
|
ctx.beginPath();
|
|
ctx.arc(stickX, stickY, STICK_RADIUS, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Draw direction indicator (arrow)
|
|
if (Math.abs(joystickPos.x) > 0.01 || Math.abs(joystickPos.y) > 0.01) {
|
|
const angle = Math.atan2(-joystickPos.y, joystickPos.x);
|
|
const arrowLen = 40;
|
|
const arrowX = centerX + Math.cos(angle) * arrowLen;
|
|
const arrowY = centerY - Math.sin(angle) * arrowLen;
|
|
|
|
ctx.strokeStyle = '#06b6d4';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(centerX, centerY);
|
|
ctx.lineTo(arrowX, arrowY);
|
|
ctx.stroke();
|
|
}
|
|
}, [joystickPos, isDragging]);
|
|
|
|
// Handle joystick input
|
|
const handleInput = (clientX, clientY) => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const centerX = canvas.width / 2;
|
|
const centerY = canvas.height / 2;
|
|
|
|
// Calculate relative position
|
|
let x = (clientX - rect.left - centerX) / RADIUS;
|
|
let y = (clientY - rect.top - centerY) / RADIUS;
|
|
|
|
// Normalize to unit circle
|
|
const distance = Math.sqrt(x * x + y * y);
|
|
if (distance > 1) {
|
|
x /= distance;
|
|
y /= distance;
|
|
}
|
|
|
|
// Apply deadzone
|
|
const deadzoneMagnitude = Math.sqrt(x * x + y * y);
|
|
let adjustedX = x;
|
|
let adjustedY = y;
|
|
|
|
if (deadzoneMagnitude < DEADZONE) {
|
|
adjustedX = 0;
|
|
adjustedY = 0;
|
|
} else {
|
|
// Scale values above deadzone
|
|
const scale = (deadzoneMagnitude - DEADZONE) / (1 - DEADZONE);
|
|
adjustedX = (x / deadzoneMagnitude) * scale;
|
|
adjustedY = (y / deadzoneMagnitude) * scale;
|
|
}
|
|
|
|
setJoystickPos({ x: adjustedX, y: adjustedY });
|
|
|
|
// Calculate velocities
|
|
// X-axis (left-right) = angular velocity
|
|
// Y-axis (up-down) = linear velocity (negative because canvas Y is inverted)
|
|
const linear = -adjustedY * MAX_LINEAR_VEL;
|
|
const angular = adjustedX * MAX_ANGULAR_VEL;
|
|
|
|
setLinearVel(linear);
|
|
setAngularVel(angular);
|
|
|
|
// Publish Twist message
|
|
if (publish) {
|
|
const twist = {
|
|
linear: { x: linear, y: 0, z: 0 },
|
|
angular: { x: 0, y: 0, z: angular }
|
|
};
|
|
publish('/cmd_vel', 'geometry_msgs/Twist', twist);
|
|
}
|
|
};
|
|
|
|
const handleMouseDown = (e) => {
|
|
setIsDragging(true);
|
|
handleInput(e.clientX, e.clientY);
|
|
};
|
|
|
|
const handleMouseMove = (e) => {
|
|
if (isDragging) {
|
|
handleInput(e.clientX, e.clientY);
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsDragging(false);
|
|
setJoystickPos({ x: 0, y: 0 });
|
|
setLinearVel(0);
|
|
setAngularVel(0);
|
|
|
|
if (publish) {
|
|
const twist = {
|
|
linear: { x: 0, y: 0, z: 0 },
|
|
angular: { x: 0, y: 0, z: 0 }
|
|
};
|
|
publish('/cmd_vel', 'geometry_msgs/Twist', twist);
|
|
}
|
|
};
|
|
|
|
// Touch support
|
|
const handleTouchStart = (e) => {
|
|
setIsDragging(true);
|
|
const touch = e.touches[0];
|
|
handleInput(touch.clientX, touch.clientY);
|
|
};
|
|
|
|
const handleTouchMove = (e) => {
|
|
if (isDragging) {
|
|
e.preventDefault();
|
|
const touch = e.touches[0];
|
|
handleInput(touch.clientX, touch.clientY);
|
|
}
|
|
};
|
|
|
|
const handleTouchEnd = () => {
|
|
setIsDragging(false);
|
|
setJoystickPos({ x: 0, y: 0 });
|
|
setLinearVel(0);
|
|
setAngularVel(0);
|
|
|
|
if (publish) {
|
|
const twist = {
|
|
linear: { x: 0, y: 0, z: 0 },
|
|
angular: { x: 0, y: 0, z: 0 }
|
|
};
|
|
publish('/cmd_vel', 'geometry_msgs/Twist', twist);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
canvas.addEventListener('mousedown', handleMouseDown);
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
canvas.addEventListener('touchstart', handleTouchStart);
|
|
canvas.addEventListener('touchmove', handleTouchMove);
|
|
canvas.addEventListener('touchend', handleTouchEnd);
|
|
|
|
return () => {
|
|
canvas.removeEventListener('mousedown', handleMouseDown);
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
window.removeEventListener('mouseup', handleMouseUp);
|
|
canvas.removeEventListener('touchstart', handleTouchStart);
|
|
canvas.removeEventListener('touchmove', handleTouchMove);
|
|
canvas.removeEventListener('touchend', handleTouchEnd);
|
|
};
|
|
}, [isDragging]);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (velocityTimerRef.current) {
|
|
clearTimeout(velocityTimerRef.current);
|
|
}
|
|
// Send stop command on unmount
|
|
if (publish) {
|
|
const twist = {
|
|
linear: { x: 0, y: 0, z: 0 },
|
|
angular: { x: 0, y: 0, z: 0 }
|
|
};
|
|
publish('/cmd_vel', 'geometry_msgs/Twist', twist);
|
|
}
|
|
};
|
|
}, [publish]);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full space-y-4">
|
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4 space-y-3">
|
|
<div className="text-cyan-700 text-xs font-bold tracking-widest">
|
|
JOYSTICK TELEOP
|
|
</div>
|
|
|
|
<div className="flex gap-6">
|
|
{/* Joystick canvas */}
|
|
<div
|
|
ref={containerRef}
|
|
className="relative bg-gray-900 rounded border border-gray-800 p-2"
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={320}
|
|
height={320}
|
|
className="rounded cursor-grab active:cursor-grabbing touch-none"
|
|
style={{ userSelect: 'none' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Controls and info */}
|
|
<div className="flex flex-col justify-center space-y-4 flex-1 min-w-64">
|
|
{/* Velocity displays */}
|
|
<div className="space-y-2">
|
|
<div className="text-sm">
|
|
<div className="text-gray-500 text-xs mb-1">LINEAR VELOCITY</div>
|
|
<div className="flex items-end gap-2">
|
|
<span className="text-xl font-mono text-cyan-400">
|
|
{linearVel.toFixed(3)}
|
|
</span>
|
|
<span className="text-xs text-gray-600 mb-0.5">m/s</span>
|
|
</div>
|
|
<div className="text-xs text-gray-700 mt-1">
|
|
Range: -0.500 to +0.500 m/s
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full h-px bg-gray-800" />
|
|
|
|
<div className="space-y-2">
|
|
<div className="text-sm">
|
|
<div className="text-gray-500 text-xs mb-1">ANGULAR VELOCITY</div>
|
|
<div className="flex items-end gap-2">
|
|
<span className="text-xl font-mono text-yellow-400">
|
|
{angularVel.toFixed(3)}
|
|
</span>
|
|
<span className="text-xs text-gray-600 mb-0.5">rad/s</span>
|
|
</div>
|
|
<div className="text-xs text-gray-700 mt-1">
|
|
Range: -1.000 to +1.000 rad/s
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full h-px bg-gray-800" />
|
|
|
|
{/* Instructions */}
|
|
<div className="space-y-1 text-xs text-gray-600">
|
|
<div className="font-bold text-gray-500">CONTROLS:</div>
|
|
<div>▲ Forward / ▼ Backward</div>
|
|
<div>◄ Rotate Left / ► Rotate Right</div>
|
|
<div className="text-gray-700 mt-2">Deadzone: 10%</div>
|
|
</div>
|
|
|
|
{/* Status indicators */}
|
|
<div className="flex gap-2">
|
|
<div className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${
|
|
isDragging
|
|
? 'bg-cyan-950 border border-cyan-700 text-cyan-400'
|
|
: 'bg-gray-900 border border-gray-800 text-gray-600'
|
|
}`}>
|
|
<div className={`w-2 h-2 rounded-full ${isDragging ? 'bg-cyan-400' : 'bg-gray-700'}`} />
|
|
{isDragging ? 'ACTIVE' : 'READY'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Topic info */}
|
|
<div className="bg-gray-950 rounded border border-gray-800 p-3 text-xs text-gray-600">
|
|
<div className="flex justify-between">
|
|
<span>Publishing to: <code className="text-gray-500">/cmd_vel</code></span>
|
|
<span className="text-gray-700">geometry_msgs/Twist</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default JoystickTeleop;
|