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};
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}
129
130#[derive(Debug, Serialize, Deserialize)]
131struct WanderConfig {
132    #[serde(default = "default_motor_topic")]
133    topic: String,
134    #[serde(default = "default_linear_speed")]
135    linear_speed: f64,
136    #[serde(default = "default_angular_speed")]
137    angular_speed: f64,
138    #[serde(default = "default_change_interval")]
139    change_interval_secs: f64,
140}
141
142fn default_linear_speed() -> f64 {
143    0.3
144}
145
146fn default_angular_speed() -> f64 {
147    0.5
148}
149
150fn default_change_interval() -> f64 {
151    3.0
152}
153
154impl WanderNode {
155    /// Create a new WanderNode from JSON configuration
156    pub fn from_json(config: Value) -> anyhow::Result<Self> {
157        let cfg: WanderConfig = serde_json::from_value(config)?;
158        Ok(Self {
159            topic: cfg.topic,
160            linear_speed: cfg.linear_speed,
161            angular_speed: cfg.angular_speed,
162            change_interval: Duration::from_secs_f64(cfg.change_interval_secs),
163            last_change: None,
164            current_angular: 0.0,
165        })
166    }
167}
168
169#[async_trait]
170impl BehaviorNode for WanderNode {
171    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
172        let now = Instant::now();
173
174        if self.last_change.is_none() || now.duration_since(self.last_change.unwrap()) >= self.change_interval {
175            self.current_angular = (rand::random::<f64>() * 2.0 - 1.0) * self.angular_speed;
176            self.last_change = Some(now);
177        }
178
179        publish_movement(ctx, &self.topic, self.linear_speed, self.current_angular).await?;
180        Ok(NodeStatus::Running)
181    }
182
183    fn name(&self) -> &str {
184        "WanderNode"
185    }
186}
187
188#[derive(Debug, Default)]
189pub struct MoveNode {
190    topic: String,
191    linear: f64,
192    angular: f64,
193    duration: Option<Duration>,
194    timer: ElapsedTimer,
195}
196
197#[derive(Debug, Serialize, Deserialize)]
198struct MoveConfig {
199    #[serde(default = "default_motor_topic")]
200    topic: String,
201    linear: f64,
202    angular: f64,
203    duration_secs: Option<f64>,
204}
205
206impl MoveNode {
207    /// Create a new MoveNode from JSON configuration
208    pub fn from_json(config: Value) -> anyhow::Result<Self> {
209        let cfg: MoveConfig = serde_json::from_value(config)?;
210        Ok(Self {
211            topic: cfg.topic,
212            linear: cfg.linear,
213            angular: cfg.angular,
214            duration: cfg.duration_secs.map(Duration::from_secs_f64),
215            timer: ElapsedTimer::default(),
216        })
217    }
218}
219
220#[async_trait]
221impl BehaviorNode for MoveNode {
222    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
223        if let Some(duration) = self.duration {
224            if self.timer.elapsed_since_start(duration) {
225                return Ok(NodeStatus::Success);
226            }
227        }
228
229        publish_movement(ctx, &self.topic, self.linear, self.angular).await?;
230
231        Ok(NodeStatus::Running)
232    }
233
234    reset_timer_field!(timer);
235
236    fn name(&self) -> &str {
237        "MoveNode"
238    }
239}
240
241#[derive(Debug)]
242pub struct StopNode {
243    topic: String,
244}
245
246#[derive(Debug, Serialize, Deserialize)]
247struct StopConfig {
248    #[serde(default = "default_motor_topic")]
249    topic: String,
250}
251
252impl StopNode {
253    /// Create a new StopNode from JSON configuration
254    pub fn from_json(config: Value) -> anyhow::Result<Self> {
255        let cfg: StopConfig = serde_json::from_value(config)?;
256        Ok(Self { topic: cfg.topic })
257    }
258}
259
260#[async_trait]
261impl BehaviorNode for StopNode {
262    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
263        publish_movement(ctx, &self.topic, 0.0, 0.0).await?;
264        tracing::debug!("StopNode: published stop command");
265        Ok(NodeStatus::Success)
266    }
267
268    fn name(&self) -> &str {
269        "StopNode"
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use mecha10_core::config::{ACTUATION_JOINT_NAMES_ENV_VAR, ACTUATOR_JOINT_LIMITS_ENV_VAR, CAPABILITIES_ENV_VAR};
277    use std::env;
278    use std::sync::Mutex;
279
280    // `controller_mode()`/`generate_joint_command()` read process-global env vars; serialize
281    // the tests that touch them so they can't observe each other's in-flight state (mirrors
282    // `mecha10_core::config`'s own test convention).
283    static CONTROLLER_ENV_LOCK: Mutex<()> = Mutex::new(());
284
285    fn clear_controller_env() {
286        env::remove_var(CAPABILITIES_ENV_VAR);
287        env::remove_var(ACTUATION_JOINT_NAMES_ENV_VAR);
288        env::remove_var(ACTUATOR_JOINT_LIMITS_ENV_VAR);
289    }
290
291    #[test]
292    fn resolve_movement_output_is_a_twist_to_the_configured_topic_in_locomotion_mode() {
293        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
294        clear_controller_env();
295
296        let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
297
298        let MovementOutput::Twist { topic, twist } = output else {
299            panic!("expected Twist output");
300        };
301        assert_eq!(topic, "/motor/cmd_vel");
302        assert_eq!(twist.linear, 1.0);
303        assert_eq!(twist.angular, 0.5);
304
305        clear_controller_env();
306    }
307
308    #[test]
309    fn resolve_movement_output_ignores_declared_joints_when_locomotion_also_declared() {
310        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
311        clear_controller_env();
312        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints","locomotion"]"#);
313        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
314
315        let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
316
317        let MovementOutput::Twist { topic, twist } = output else {
318            panic!("expected Twist output");
319        };
320        assert_eq!(topic, "/motor/cmd_vel");
321        assert_eq!(twist.linear, 1.0);
322        assert_eq!(twist.angular, 0.5);
323
324        clear_controller_env();
325    }
326
327    #[test]
328    fn resolve_movement_output_is_a_joint_state_to_the_default_joint_topic_in_joints_mode() {
329        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
330        clear_controller_env();
331        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
332        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
333
334        // The author left `topic` at its locomotion default - we should redirect to the
335        // project-wide joint command topic rather than publish a JointState to "/motor/cmd_vel".
336        let output = resolve_movement_output(&default_motor_topic(), 0.3, -0.2);
337
338        let MovementOutput::Joints { topic, joint_state } = output else {
339            panic!("expected Joints output");
340        };
341        assert_eq!(topic, actuator_topics::JOINT_CMD.path());
342        assert_eq!(joint_state.names, vec!["pan".to_string(), "tilt".to_string()]);
343        assert_eq!(joint_state.positions, vec![0.3, -0.2]);
344
345        clear_controller_env();
346    }
347
348    #[test]
349    fn resolve_movement_output_keeps_an_explicitly_overridden_topic_in_joints_mode() {
350        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
351        clear_controller_env();
352        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
353        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
354
355        let output = resolve_movement_output("/custom/joint_topic", 0.3, -0.2);
356
357        let MovementOutput::Joints { topic, .. } = output else {
358            panic!("expected Joints output");
359        };
360        assert_eq!(topic, "/custom/joint_topic");
361
362        clear_controller_env();
363    }
364
365    #[test]
366    fn resolve_movement_output_clamps_to_configured_joint_limits_in_joints_mode() {
367        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
368        clear_controller_env();
369        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
370        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan"]"#);
371        env::set_var(
372            ACTUATOR_JOINT_LIMITS_ENV_VAR,
373            r#"[{"joint_id":"pan","min_angle_rad":-1.0,"max_angle_rad":1.0}]"#,
374        );
375
376        let output = resolve_movement_output(&default_motor_topic(), 5.0, 0.0);
377
378        let MovementOutput::Joints { joint_state, .. } = output else {
379            panic!("expected Joints output");
380        };
381        assert_eq!(joint_state.names, vec!["pan".to_string()]);
382        assert_eq!(joint_state.positions, vec![1.0]);
383
384        clear_controller_env();
385    }
386
387    #[test]
388    fn resolve_movement_output_drives_only_the_available_joint_when_fewer_than_two_are_configured() {
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"]"#);
393
394        let output = resolve_movement_output(&default_motor_topic(), 0.7, -0.4);
395
396        let MovementOutput::Joints { joint_state, .. } = output else {
397            panic!("expected Joints output");
398        };
399        assert_eq!(joint_state.names, vec!["pan".to_string()]);
400        assert_eq!(joint_state.positions, vec![0.7]);
401
402        clear_controller_env();
403    }
404
405    #[test]
406    fn wander_node_from_json_defaults_topic_to_the_locomotion_default() {
407        let node = WanderNode::from_json(serde_json::json!({})).unwrap();
408        assert_eq!(node.topic, default_motor_topic());
409    }
410
411    #[test]
412    fn move_node_from_json_defaults_topic_to_the_locomotion_default() {
413        let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0 })).unwrap();
414        assert_eq!(node.topic, default_motor_topic());
415    }
416
417    #[test]
418    fn stop_node_from_json_defaults_topic_to_the_locomotion_default() {
419        let node = StopNode::from_json(serde_json::json!({})).unwrap();
420        assert_eq!(node.topic, default_motor_topic());
421    }
422}