Skip to main content

mecha10_behavior_runtime/actions/
movement.rs

1//! Action nodes that publish velocity commands to move the robot.
2//!
3//! `WanderNode`/`MoveNode`/`StopNode` all drive the same "desired linear/angular velocity"
4//! shape. Which message type and topic that actually becomes is resolved once per tick via
5//! [`controller_mode`] - the same capability-based output-selection mechanism `mecha10-nodes`'
6//! `teleop`/`rl`/`llm-command` controller nodes use to decide whether a project wants a
7//! whole-robot [`Twist`] or a per-joint [`JointState`] (see [`resolve_movement_output`]).
8//! Previously these nodes always published a `Twist` to whatever raw `topic` string the
9//! behavior author configured, so a behavior tree using `wander`/`move` on a joints-only
10//! platform (e.g. a pan-tilt-only `sentry`-style project, with no drivetrain at all) silently
11//! did nothing - the `Twist` it published had no subscriber.
12
13use crate::{BehaviorNode, Context, NodeStatus, TreeStatusSnapshot};
14use async_trait::async_trait;
15use mecha10_core::actuator::Twist;
16use mecha10_core::config::{controller_mode, generate_joint_command, ControllerMode};
17use mecha10_core::messages::JointState;
18use mecha10_core::topics::actuator as actuator_topics;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21use std::time::{Duration, Instant};
22
23use super::elapsed_timer::{reset_timer_field, ElapsedTimer};
24
25/// The locomotion topic `wander`/`move`/`stop` configs default to when the behavior author
26/// doesn't override `topic` - matches `navigation.rs`'s `FollowPathNode::default_motor_topic`
27/// literal, the project-wide convention for "the" drivetrain command topic.
28fn default_motor_topic() -> String {
29    "/motor/cmd_vel".to_string()
30}
31
32/// What a `wander`/`move`/`stop` node actually publishes this tick, resolved by
33/// [`resolve_movement_output`].
34#[derive(Debug)]
35enum MovementOutput {
36    /// Locomotion-capable project: a whole-robot velocity [`Twist`], published to `topic`
37    /// (today's exact behavior, unchanged).
38    Twist { topic: String, twist: Twist },
39    /// Joints-only project (e.g. pan-tilt): a per-joint [`JointState`], published to `topic`.
40    Joints { topic: String, joint_state: JointState },
41}
42
43/// Resolve what a movement action node should publish for a desired `linear`/`angular`
44/// velocity, given the behavior author's `configured_topic`.
45///
46/// - **Locomotion** (the default, and today's only behavior): builds a [`Twist`] exactly as
47///   before, published to `configured_topic` unchanged - existing behavior JSONs for
48///   locomotion robots keep working with no changes required.
49/// - **Joints**: `linear`/`angular` map onto the first/second entry of the project's declared
50///   joint names (clamped via [`generate_joint_command`]/joint limits), the same convention
51///   `mecha10-nodes`' `rl` controller's `build_output_command` uses. The output topic defaults
52///   to the project-wide joint command topic (`/actuator/joint_cmd`, same as
53///   `teleop`/`rl`/`pan-tilt`) *unless* the author explicitly set `topic` to something other
54///   than [`default_motor_topic`] - in that case we trust the author's explicit choice and keep
55///   using it verbatim, on the theory that an author who bothered to override the topic away
56///   from the locomotion default already picked one that matches whatever this project
57///   actually publishes/subscribes to. This avoids the node silently publishing a `JointState`
58///   to a topic the author pointed at a `Twist` subscriber (or vice versa) while still letting
59///   an author who genuinely wants a non-default joint topic have one.
60fn resolve_movement_output(configured_topic: &str, linear: f64, angular: f64) -> MovementOutput {
61    match controller_mode() {
62        ControllerMode::Locomotion => MovementOutput::Twist {
63            topic: configured_topic.to_string(),
64            twist: Twist {
65                linear: linear as f32,
66                angular: angular as f32,
67            },
68        },
69        ControllerMode::Joints { joint_names } => {
70            let topic = if configured_topic == default_motor_topic() {
71                actuator_topics::JOINT_CMD.path().to_string()
72            } else {
73                configured_topic.to_string()
74            };
75            MovementOutput::Joints {
76                topic,
77                joint_state: joint_command(&joint_names, linear as f32, angular as f32),
78            }
79        }
80    }
81}
82
83/// Build a [`JointState`] driving the first named joint to `linear` and the second to
84/// `angular` (each clamped via [`generate_joint_command`]/the project's configured joint
85/// limits) - the same linear-first/angular-second convention `mecha10-nodes`' `rl` controller
86/// uses. If fewer than two joint names are configured, only the available ones are driven;
87/// this never fails, it just produces a shorter `JointState`.
88fn joint_command(joint_names: &[String], linear: f32, angular: f32) -> JointState {
89    let mut names = Vec::new();
90    let mut positions = Vec::new();
91
92    if let Some(name) = joint_names.first() {
93        let cmd = generate_joint_command(name, linear);
94        names.extend(cmd.names);
95        positions.extend(cmd.positions);
96    }
97    if let Some(name) = joint_names.get(1) {
98        let cmd = generate_joint_command(name, angular);
99        names.extend(cmd.names);
100        positions.extend(cmd.positions);
101    }
102
103    JointState::with_positions(names, positions)
104}
105
106/// Publish `linear`/`angular` to whatever [`resolve_movement_output`] resolves to for this
107/// project's [`controller_mode`].
108async fn publish_movement(ctx: &Context, configured_topic: &str, linear: f64, angular: f64) -> anyhow::Result<()> {
109    match resolve_movement_output(configured_topic, linear, angular) {
110        MovementOutput::Twist { topic, twist } => {
111            ctx.publish_to_path(&topic, &twist).await?;
112        }
113        MovementOutput::Joints { topic, joint_state } => {
114            ctx.publish_to_path(&topic, &joint_state).await?;
115        }
116    }
117    Ok(())
118}
119
120#[derive(Debug)]
121pub struct WanderNode {
122    topic: String,
123    linear_speed: f64,
124    angular_speed: f64,
125    change_interval: Duration,
126    last_change: Option<Instant>,
127    current_angular: f64,
128    last_status: Option<NodeStatus>,
129}
130
131#[derive(Debug, Serialize, Deserialize)]
132struct WanderConfig {
133    #[serde(default = "default_motor_topic")]
134    topic: String,
135    #[serde(default = "default_linear_speed")]
136    linear_speed: f64,
137    #[serde(default = "default_angular_speed")]
138    angular_speed: f64,
139    #[serde(default = "default_change_interval")]
140    change_interval_secs: f64,
141}
142
143fn default_linear_speed() -> f64 {
144    0.3
145}
146
147fn default_angular_speed() -> f64 {
148    0.5
149}
150
151fn default_change_interval() -> f64 {
152    3.0
153}
154
155impl WanderNode {
156    /// Create a new WanderNode from JSON configuration
157    pub fn from_json(config: Value) -> anyhow::Result<Self> {
158        let cfg: WanderConfig = serde_json::from_value(config)?;
159        Ok(Self {
160            topic: cfg.topic,
161            linear_speed: cfg.linear_speed,
162            angular_speed: cfg.angular_speed,
163            change_interval: Duration::from_secs_f64(cfg.change_interval_secs),
164            last_change: None,
165            current_angular: 0.0,
166            last_status: None,
167        })
168    }
169}
170
171#[async_trait]
172impl BehaviorNode for WanderNode {
173    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
174        let now = Instant::now();
175
176        if self.last_change.is_none() || now.duration_since(self.last_change.unwrap()) >= self.change_interval {
177            self.current_angular = (rand::random::<f64>() * 2.0 - 1.0) * self.angular_speed;
178            self.last_change = Some(now);
179        }
180
181        publish_movement(ctx, &self.topic, self.linear_speed, self.current_angular).await?;
182        self.last_status = Some(NodeStatus::Running);
183        Ok(NodeStatus::Running)
184    }
185
186    fn name(&self) -> &str {
187        "WanderNode"
188    }
189
190    fn status_snapshot(&self) -> TreeStatusSnapshot {
191        TreeStatusSnapshot {
192            name: self.name().to_string(),
193            status: self.last_status,
194            active_capability: self.active_capability().map(String::from),
195            children: Vec::new(),
196        }
197    }
198}
199
200#[derive(Debug, Default)]
201pub struct MoveNode {
202    topic: String,
203    linear: f64,
204    angular: f64,
205    duration: Option<Duration>,
206    timer: ElapsedTimer,
207    last_status: Option<NodeStatus>,
208}
209
210#[derive(Debug, Serialize, Deserialize)]
211struct MoveConfig {
212    #[serde(default = "default_motor_topic")]
213    topic: String,
214    linear: f64,
215    angular: f64,
216    duration_secs: Option<f64>,
217}
218
219impl MoveNode {
220    /// Create a new MoveNode from JSON configuration
221    pub fn from_json(config: Value) -> anyhow::Result<Self> {
222        let cfg: MoveConfig = serde_json::from_value(config)?;
223        Ok(Self {
224            topic: cfg.topic,
225            linear: cfg.linear,
226            angular: cfg.angular,
227            duration: cfg.duration_secs.map(Duration::from_secs_f64),
228            timer: ElapsedTimer::default(),
229            last_status: None,
230        })
231    }
232}
233
234#[async_trait]
235impl BehaviorNode for MoveNode {
236    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
237        if let Some(duration) = self.duration {
238            if self.timer.elapsed_since_start(duration) {
239                self.last_status = Some(NodeStatus::Success);
240                return Ok(NodeStatus::Success);
241            }
242        }
243
244        publish_movement(ctx, &self.topic, self.linear, self.angular).await?;
245
246        self.last_status = Some(NodeStatus::Running);
247        Ok(NodeStatus::Running)
248    }
249
250    reset_timer_field!(timer);
251
252    fn name(&self) -> &str {
253        "MoveNode"
254    }
255
256    fn status_snapshot(&self) -> TreeStatusSnapshot {
257        TreeStatusSnapshot {
258            name: self.name().to_string(),
259            status: self.last_status,
260            active_capability: self.active_capability().map(String::from),
261            children: Vec::new(),
262        }
263    }
264}
265
266#[derive(Debug)]
267pub struct StopNode {
268    topic: String,
269    last_status: Option<NodeStatus>,
270}
271
272#[derive(Debug, Serialize, Deserialize)]
273struct StopConfig {
274    #[serde(default = "default_motor_topic")]
275    topic: String,
276}
277
278impl StopNode {
279    /// Create a new StopNode from JSON configuration
280    pub fn from_json(config: Value) -> anyhow::Result<Self> {
281        let cfg: StopConfig = serde_json::from_value(config)?;
282        Ok(Self {
283            topic: cfg.topic,
284            last_status: None,
285        })
286    }
287}
288
289#[async_trait]
290impl BehaviorNode for StopNode {
291    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
292        publish_movement(ctx, &self.topic, 0.0, 0.0).await?;
293        tracing::debug!("StopNode: published stop command");
294        self.last_status = Some(NodeStatus::Success);
295        Ok(NodeStatus::Success)
296    }
297
298    fn name(&self) -> &str {
299        "StopNode"
300    }
301
302    fn status_snapshot(&self) -> TreeStatusSnapshot {
303        TreeStatusSnapshot {
304            name: self.name().to_string(),
305            status: self.last_status,
306            active_capability: self.active_capability().map(String::from),
307            children: Vec::new(),
308        }
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use mecha10_core::config::{ACTUATION_JOINT_NAMES_ENV_VAR, ACTUATOR_JOINT_LIMITS_ENV_VAR, CAPABILITIES_ENV_VAR};
316    use std::env;
317    use std::sync::Mutex;
318
319    // `controller_mode()`/`generate_joint_command()` read process-global env vars; serialize
320    // the tests that touch them so they can't observe each other's in-flight state (mirrors
321    // `mecha10_core::config`'s own test convention).
322    static CONTROLLER_ENV_LOCK: Mutex<()> = Mutex::new(());
323
324    fn clear_controller_env() {
325        env::remove_var(CAPABILITIES_ENV_VAR);
326        env::remove_var(ACTUATION_JOINT_NAMES_ENV_VAR);
327        env::remove_var(ACTUATOR_JOINT_LIMITS_ENV_VAR);
328    }
329
330    #[test]
331    fn resolve_movement_output_is_a_twist_to_the_configured_topic_in_locomotion_mode() {
332        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
333        clear_controller_env();
334
335        let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
336
337        let MovementOutput::Twist { topic, twist } = output else {
338            panic!("expected Twist output");
339        };
340        assert_eq!(topic, "/motor/cmd_vel");
341        assert_eq!(twist.linear, 1.0);
342        assert_eq!(twist.angular, 0.5);
343
344        clear_controller_env();
345    }
346
347    #[test]
348    fn resolve_movement_output_ignores_declared_joints_when_locomotion_also_declared() {
349        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
350        clear_controller_env();
351        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints","locomotion"]"#);
352        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
353
354        let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
355
356        let MovementOutput::Twist { topic, twist } = output else {
357            panic!("expected Twist output");
358        };
359        assert_eq!(topic, "/motor/cmd_vel");
360        assert_eq!(twist.linear, 1.0);
361        assert_eq!(twist.angular, 0.5);
362
363        clear_controller_env();
364    }
365
366    #[test]
367    fn resolve_movement_output_is_a_joint_state_to_the_default_joint_topic_in_joints_mode() {
368        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
369        clear_controller_env();
370        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
371        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
372
373        // The author left `topic` at its locomotion default - we should redirect to the
374        // project-wide joint command topic rather than publish a JointState to "/motor/cmd_vel".
375        let output = resolve_movement_output(&default_motor_topic(), 0.3, -0.2);
376
377        let MovementOutput::Joints { topic, joint_state } = output else {
378            panic!("expected Joints output");
379        };
380        assert_eq!(topic, actuator_topics::JOINT_CMD.path());
381        assert_eq!(joint_state.names, vec!["pan".to_string(), "tilt".to_string()]);
382        assert_eq!(joint_state.positions, vec![0.3, -0.2]);
383
384        clear_controller_env();
385    }
386
387    #[test]
388    fn resolve_movement_output_keeps_an_explicitly_overridden_topic_in_joints_mode() {
389        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
390        clear_controller_env();
391        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
392        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
393
394        let output = resolve_movement_output("/custom/joint_topic", 0.3, -0.2);
395
396        let MovementOutput::Joints { topic, .. } = output else {
397            panic!("expected Joints output");
398        };
399        assert_eq!(topic, "/custom/joint_topic");
400
401        clear_controller_env();
402    }
403
404    #[test]
405    fn resolve_movement_output_clamps_to_configured_joint_limits_in_joints_mode() {
406        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
407        clear_controller_env();
408        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
409        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan"]"#);
410        env::set_var(
411            ACTUATOR_JOINT_LIMITS_ENV_VAR,
412            r#"[{"joint_id":"pan","min_angle_rad":-1.0,"max_angle_rad":1.0}]"#,
413        );
414
415        let output = resolve_movement_output(&default_motor_topic(), 5.0, 0.0);
416
417        let MovementOutput::Joints { joint_state, .. } = output else {
418            panic!("expected Joints output");
419        };
420        assert_eq!(joint_state.names, vec!["pan".to_string()]);
421        assert_eq!(joint_state.positions, vec![1.0]);
422
423        clear_controller_env();
424    }
425
426    #[test]
427    fn resolve_movement_output_drives_only_the_available_joint_when_fewer_than_two_are_configured() {
428        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
429        clear_controller_env();
430        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
431        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan"]"#);
432
433        let output = resolve_movement_output(&default_motor_topic(), 0.7, -0.4);
434
435        let MovementOutput::Joints { joint_state, .. } = output else {
436            panic!("expected Joints output");
437        };
438        assert_eq!(joint_state.names, vec!["pan".to_string()]);
439        assert_eq!(joint_state.positions, vec![0.7]);
440
441        clear_controller_env();
442    }
443
444    #[test]
445    fn wander_node_from_json_defaults_topic_to_the_locomotion_default() {
446        let node = WanderNode::from_json(serde_json::json!({})).unwrap();
447        assert_eq!(node.topic, default_motor_topic());
448    }
449
450    #[test]
451    fn move_node_from_json_defaults_topic_to_the_locomotion_default() {
452        let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0 })).unwrap();
453        assert_eq!(node.topic, default_motor_topic());
454    }
455
456    #[test]
457    fn stop_node_from_json_defaults_topic_to_the_locomotion_default() {
458        let node = StopNode::from_json(serde_json::json!({})).unwrap();
459        assert_eq!(node.topic, default_motor_topic());
460    }
461}