//! Drive Tool - Motor control for omni-directional movement //! //! Supports multiple backends: //! - ROS2: Publishes geometry_msgs/Twist to cmd_vel topic //! - GPIO: Direct PWM control via rppal //! - Serial: Arduino/motor controller via serial commands //! - Mock: Logs commands for testing use crate::config::RobotConfig; use crate::traits::{Tool, ToolResult}; use anyhow::Result; use async_trait::async_trait; use serde_json::{json, Value}; use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; /// Drive backend abstraction #[async_trait] trait DriveBackend: Send + Sync { async fn move_robot( &self, linear_x: f64, linear_y: f64, angular_z: f64, duration_ms: u64, ) -> Result<()>; async fn stop(&self) -> Result<()>; #[allow(dead_code)] async fn get_odometry(&self) -> Result<(f64, f64, f64)>; // x, y, theta - reserved for future odometry integration } /// Mock backend for testing struct MockDrive; #[async_trait] impl DriveBackend for MockDrive { async fn move_robot( &self, linear_x: f64, linear_y: f64, angular_z: f64, duration_ms: u64, ) -> Result<()> { tracing::info!( "MOCK DRIVE: linear=({:.2}, {:.2}), angular={:.2}, duration={}ms", linear_x, linear_y, angular_z, duration_ms ); tokio::time::sleep(Duration::from_millis(duration_ms.min(100))).await; Ok(()) } async fn stop(&self) -> Result<()> { tracing::info!("MOCK DRIVE: STOP"); Ok(()) } async fn get_odometry(&self) -> Result<(f64, f64, f64)> { Ok((0.0, 0.0, 0.0)) } } /// ROS2 backend - shells out to ros2 topic pub struct Ros2Drive { topic: String, } #[async_trait] impl DriveBackend for Ros2Drive { async fn move_robot( &self, linear_x: f64, linear_y: f64, angular_z: f64, duration_ms: u64, ) -> Result<()> { // Publish Twist message via ros2 CLI // In production, use rclrs (Rust ROS2 bindings) instead let msg = format!( "{{linear: {{x: {:.2}, y: {:.2}, z: 0.0}}, angular: {{x: 0.0, y: 0.0, z: {:.2}}}}}", linear_x, linear_y, angular_z ); let output = tokio::process::Command::new("ros2") .args([ "topic", "pub", "--once", &self.topic, "geometry_msgs/msg/Twist", &msg, ]) .output() .await?; if !output.status.success() { anyhow::bail!( "ROS2 publish failed: {}", String::from_utf8_lossy(&output.stderr) ); } // Hold for duration then stop tokio::time::sleep(Duration::from_millis(duration_ms)).await; self.stop().await?; Ok(()) } async fn stop(&self) -> Result<()> { let msg = "{linear: {x: 0.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}"; tokio::process::Command::new("ros2") .args([ "topic", "pub", "--once", &self.topic, "geometry_msgs/msg/Twist", msg, ]) .output() .await?; Ok(()) } async fn get_odometry(&self) -> Result<(f64, f64, f64)> { // Would subscribe to /odom topic in production Ok((0.0, 0.0, 0.0)) } } /// Serial backend - sends commands to Arduino/motor controller struct SerialDrive { port: String, } #[async_trait] impl DriveBackend for SerialDrive { async fn move_robot( &self, linear_x: f64, linear_y: f64, angular_z: f64, duration_ms: u64, ) -> Result<()> { // Protocol: "M \n" // The motor controller interprets this and drives motors let cmd = format!( "M {:.2} {:.2} {:.2} {}\n", linear_x, linear_y, angular_z, duration_ms ); // Use blocking serial in spawn_blocking let port = self.port.clone(); tokio::task::spawn_blocking(move || { use std::io::Write; let mut serial = std::fs::OpenOptions::new().write(true).open(&port)?; serial.write_all(cmd.as_bytes())?; serial.flush()?; Ok::<_, anyhow::Error>(()) }) .await??; tokio::time::sleep(Duration::from_millis(duration_ms)).await; Ok(()) } async fn stop(&self) -> Result<()> { self.move_robot(0.0, 0.0, 0.0, 0).await } async fn get_odometry(&self) -> Result<(f64, f64, f64)> { Ok((0.0, 0.0, 0.0)) } } /// Main Drive Tool pub struct DriveTool { config: RobotConfig, backend: Arc, last_command: Arc>>, } impl DriveTool { pub fn new(config: RobotConfig) -> Self { let backend: Arc = match config.drive.backend.as_str() { "ros2" => Arc::new(Ros2Drive { topic: config.drive.ros2_topic.clone(), }), "serial" => Arc::new(SerialDrive { port: config.drive.serial_port.clone(), }), // "gpio" => Arc::new(GpioDrive::new(&config)), // Would use rppal _ => Arc::new(MockDrive), }; Self { config, backend, last_command: Arc::new(Mutex::new(None)), } } } #[async_trait] impl Tool for DriveTool { fn name(&self) -> &str { "drive" } fn description(&self) -> &str { "Move the robot. Supports omni-directional movement (forward, backward, strafe left/right, rotate). \ Use 'stop' action to halt immediately. Distance is in meters, rotation in degrees." } fn parameters_schema(&self) -> Value { json!({ "type": "object", "properties": { "action": { "type": "string", "enum": ["forward", "backward", "left", "right", "rotate_left", "rotate_right", "stop", "custom"], "description": "Movement action. 'left'/'right' are strafe (omni wheels). 'rotate_*' spins in place." }, "distance": { "type": "number", "description": "Distance in meters (for linear moves) or degrees (for rotation). Default 0.5m or 90deg." }, "speed": { "type": "number", "description": "Speed multiplier 0.0-1.0. Default 0.5 (half speed for safety)." }, "linear_x": { "type": "number", "description": "Custom: forward/backward velocity (-1.0 to 1.0)" }, "linear_y": { "type": "number", "description": "Custom: left/right strafe velocity (-1.0 to 1.0)" }, "angular_z": { "type": "number", "description": "Custom: rotation velocity (-1.0 to 1.0)" } }, "required": ["action"] }) } async fn execute(&self, args: Value) -> Result { let action = args["action"] .as_str() .ok_or_else(|| anyhow::anyhow!("Missing 'action' parameter"))?; // Safety: check max drive duration { let mut last = self.last_command.lock().await; if let Some(instant) = *last { if instant.elapsed() < Duration::from_secs(1) { return Ok(ToolResult { success: false, output: String::new(), error: Some( "Rate limited: wait 1 second between drive commands".to_string(), ), }); } } *last = Some(std::time::Instant::now()); } let speed = args["speed"].as_f64().unwrap_or(0.5).clamp(0.0, 1.0); let max_speed = self.config.drive.max_speed * speed; let max_rotation = self.config.drive.max_rotation * speed; let (linear_x, linear_y, angular_z, duration_ms) = match action { "stop" => { self.backend.stop().await?; return Ok(ToolResult { success: true, output: "Robot stopped".to_string(), error: None, }); } "forward" => { let dist = args["distance"].as_f64().unwrap_or(0.5); let duration = (dist / max_speed * 1000.0) as u64; ( max_speed, 0.0, 0.0, duration.min(self.config.safety.max_drive_duration * 1000), ) } "backward" => { let dist = args["distance"].as_f64().unwrap_or(0.5); let duration = (dist / max_speed * 1000.0) as u64; ( -max_speed, 0.0, 0.0, duration.min(self.config.safety.max_drive_duration * 1000), ) } "left" => { let dist = args["distance"].as_f64().unwrap_or(0.5); let duration = (dist / max_speed * 1000.0) as u64; ( 0.0, max_speed, 0.0, duration.min(self.config.safety.max_drive_duration * 1000), ) } "right" => { let dist = args["distance"].as_f64().unwrap_or(0.5); let duration = (dist / max_speed * 1000.0) as u64; ( 0.0, -max_speed, 0.0, duration.min(self.config.safety.max_drive_duration * 1000), ) } "rotate_left" => { let degrees = args["distance"].as_f64().unwrap_or(90.0); let radians = degrees.to_radians(); let duration = (radians / max_rotation * 1000.0) as u64; ( 0.0, 0.0, max_rotation, duration.min(self.config.safety.max_drive_duration * 1000), ) } "rotate_right" => { let degrees = args["distance"].as_f64().unwrap_or(90.0); let radians = degrees.to_radians(); let duration = (radians / max_rotation * 1000.0) as u64; ( 0.0, 0.0, -max_rotation, duration.min(self.config.safety.max_drive_duration * 1000), ) } "custom" => { let lx = args["linear_x"].as_f64().unwrap_or(0.0).clamp(-1.0, 1.0) * max_speed; let ly = args["linear_y"].as_f64().unwrap_or(0.0).clamp(-1.0, 1.0) * max_speed; let az = args["angular_z"].as_f64().unwrap_or(0.0).clamp(-1.0, 1.0) * max_rotation; let duration = args["duration_ms"].as_u64().unwrap_or(1000); ( lx, ly, az, duration.min(self.config.safety.max_drive_duration * 1000), ) } _ => { return Ok(ToolResult { success: false, output: String::new(), error: Some(format!("Unknown action: {action}")), }); } }; self.backend .move_robot(linear_x, linear_y, angular_z, duration_ms) .await?; Ok(ToolResult { success: true, output: format!( "Moved: action={}, linear=({:.2}, {:.2}), angular={:.2}, duration={}ms", action, linear_x, linear_y, angular_z, duration_ms ), error: None, }) } } #[cfg(test)] mod tests { use super::*; #[test] fn drive_tool_name() { let tool = DriveTool::new(RobotConfig::default()); assert_eq!(tool.name(), "drive"); } #[test] fn drive_tool_schema_has_action() { let tool = DriveTool::new(RobotConfig::default()); let schema = tool.parameters_schema(); assert!(schema["properties"]["action"].is_object()); } #[tokio::test] async fn drive_forward_mock() { let tool = DriveTool::new(RobotConfig::default()); let result = tool .execute(json!({"action": "forward", "distance": 1.0})) .await .unwrap(); assert!(result.success); assert!(result.output.contains("forward")); } #[tokio::test] async fn drive_stop() { let tool = DriveTool::new(RobotConfig::default()); let result = tool.execute(json!({"action": "stop"})).await.unwrap(); assert!(result.success); assert!(result.output.contains("stopped")); } #[tokio::test] async fn drive_unknown_action() { let tool = DriveTool::new(RobotConfig::default()); let result = tool.execute(json!({"action": "fly"})).await.unwrap(); assert!(!result.success); } }