Skip to main content

mecha10_behavior_runtime/actions/
movement.rs

1//! Action nodes that publish velocity commands to move the robot.
2
3use super::elapsed_timer::{reset_timer_field, ElapsedTimer};
4use crate::{BehaviorNode, Context, NodeStatus};
5use async_trait::async_trait;
6use mecha10_core::actuator::Twist;
7use mecha10_core::topics::Topic;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::time::{Duration, Instant};
11
12/// WanderNode - Generates random movement commands
13///
14/// This node publishes random velocity commands to make a robot wander around.
15/// It periodically changes direction to create natural wandering behavior.
16///
17/// # Configuration
18///
19/// ```json
20/// {
21///   "type": "wander",
22///   "config": {
23///     "topic": "/motor/cmd_vel",
24///     "linear_speed": 0.3,
25///     "angular_speed": 0.5,
26///     "change_interval_secs": 3.0
27///   }
28/// }
29/// ```
30#[derive(Debug)]
31pub struct WanderNode {
32    topic: String,
33    linear_speed: f64,
34    angular_speed: f64,
35    change_interval: Duration,
36    last_change: Option<Instant>,
37    current_angular: f64,
38}
39
40#[derive(Debug, Serialize, Deserialize)]
41struct WanderConfig {
42    topic: String,
43    #[serde(default = "default_linear_speed")]
44    linear_speed: f64,
45    #[serde(default = "default_angular_speed")]
46    angular_speed: f64,
47    #[serde(default = "default_change_interval")]
48    change_interval_secs: f64,
49}
50
51fn default_linear_speed() -> f64 {
52    0.3
53}
54
55fn default_angular_speed() -> f64 {
56    0.5
57}
58
59fn default_change_interval() -> f64 {
60    3.0
61}
62
63impl WanderNode {
64    /// Create a new WanderNode from JSON configuration
65    pub fn from_json(config: Value) -> anyhow::Result<Self> {
66        let cfg: WanderConfig = serde_json::from_value(config)?;
67        Ok(Self {
68            topic: cfg.topic,
69            linear_speed: cfg.linear_speed,
70            angular_speed: cfg.angular_speed,
71            change_interval: Duration::from_secs_f64(cfg.change_interval_secs),
72            last_change: None,
73            current_angular: 0.0,
74        })
75    }
76}
77
78#[async_trait]
79impl BehaviorNode for WanderNode {
80    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
81        let now = Instant::now();
82
83        if self.last_change.is_none() || now.duration_since(self.last_change.unwrap()) >= self.change_interval {
84            self.current_angular = (rand::random::<f64>() * 2.0 - 1.0) * self.angular_speed;
85            self.last_change = Some(now);
86        }
87
88        let twist = Twist {
89            linear: self.linear_speed as f32,
90            angular: self.current_angular as f32,
91        };
92
93        let topic_static: &'static str = Box::leak(self.topic.clone().into_boxed_str());
94        let topic = Topic::<Twist>::new(topic_static);
95
96        ctx.publish_to(topic, &twist).await?;
97        Ok(NodeStatus::Running)
98    }
99
100    fn name(&self) -> &str {
101        "WanderNode"
102    }
103}
104
105/// MoveNode - Executes a velocity command
106///
107/// Publishes a fixed velocity command to control robot movement.
108///
109/// # Configuration
110///
111/// ```json
112/// {
113///   "type": "move",
114///   "config": {
115///     "topic": "/motor/cmd_vel",
116///     "linear": 0.5,
117///     "angular": 0.0,
118///     "duration_secs": 2.0
119///   }
120/// }
121/// ```
122#[derive(Debug, Default)]
123pub struct MoveNode {
124    topic: String,
125    linear: f64,
126    angular: f64,
127    duration: Option<Duration>,
128    timer: ElapsedTimer,
129}
130
131#[derive(Debug, Serialize, Deserialize)]
132struct MoveConfig {
133    topic: String,
134    linear: f64,
135    angular: f64,
136    duration_secs: Option<f64>,
137}
138
139impl MoveNode {
140    /// Create a new MoveNode from JSON configuration
141    pub fn from_json(config: Value) -> anyhow::Result<Self> {
142        let cfg: MoveConfig = serde_json::from_value(config)?;
143        Ok(Self {
144            topic: cfg.topic,
145            linear: cfg.linear,
146            angular: cfg.angular,
147            duration: cfg.duration_secs.map(Duration::from_secs_f64),
148            timer: ElapsedTimer::default(),
149        })
150    }
151}
152
153#[async_trait]
154impl BehaviorNode for MoveNode {
155    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
156        if let Some(duration) = self.duration {
157            if self.timer.elapsed_since_start(duration) {
158                return Ok(NodeStatus::Success);
159            }
160        }
161
162        let twist = Twist {
163            linear: self.linear as f32,
164            angular: self.angular as f32,
165        };
166
167        let topic_static: &'static str = Box::leak(self.topic.clone().into_boxed_str());
168        let topic = Topic::<Twist>::new(topic_static);
169
170        ctx.publish_to(topic, &twist).await?;
171
172        Ok(NodeStatus::Running)
173    }
174
175    reset_timer_field!(timer);
176
177    fn name(&self) -> &str {
178        "MoveNode"
179    }
180}
181
182/// StopNode - Publishes zero velocity to stop the robot
183///
184/// This node publishes a zero velocity command to stop the robot immediately.
185/// Returns Success after publishing the stop command.
186///
187/// # Configuration
188///
189/// ```json
190/// {
191///   "type": "stop",
192///   "config": {
193///     "topic": "/motor/cmd_vel"
194///   }
195/// }
196/// ```
197#[derive(Debug)]
198pub struct StopNode {
199    topic: String,
200}
201
202#[derive(Debug, Serialize, Deserialize)]
203struct StopConfig {
204    topic: String,
205}
206
207impl StopNode {
208    // duplicate: false positive — matches `PlanPathNode::from_json`/`CheckArrivalNode::from_json`
209    // (packages/behavior-runtime/src/actions/navigation.rs) on AST shape only: parse a
210    // `serde_json::Value` into a node-specific `*Config` struct, then map its fields into
211    // `Self`. Unlike `TimerNode`/`PauseNode`'s `from_json` (unified behind the
212    // `from_json_with_duration!` macro in `elapsed_timer.rs` because both bodies were
213    // byte-for-byte identical), each of these maps a different field set/count, so a shared
214    // macro would just relocate the boilerplate rather than remove real duplication.
215    /// Create a new StopNode from JSON configuration
216    pub fn from_json(config: Value) -> anyhow::Result<Self> {
217        let cfg: StopConfig = serde_json::from_value(config)?;
218        Ok(Self { topic: cfg.topic })
219    }
220}
221
222#[async_trait]
223impl BehaviorNode for StopNode {
224    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
225        let twist = Twist {
226            linear: 0.0,
227            angular: 0.0,
228        };
229
230        let topic_static: &'static str = Box::leak(self.topic.clone().into_boxed_str());
231        let topic = Topic::<Twist>::new(topic_static);
232
233        ctx.publish_to(topic, &twist).await?;
234        tracing::debug!("StopNode: published stop command");
235        Ok(NodeStatus::Success)
236    }
237
238    fn name(&self) -> &str {
239        "StopNode"
240    }
241}