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, joint_limits, ControllerMode};
17use mecha10_core::messages::JointState;
18use mecha10_core::topics::actuator as actuator_topics;
19use mecha10_core::topics::sensor as sensor_topics;
20use mecha10_core::Receiver;
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23use std::time::{Duration, Instant};
24
25use super::elapsed_timer::{reset_timer_field, ElapsedTimer};
26
27/// The locomotion topic `wander`/`move`/`stop` configs default to when the behavior author
28/// doesn't override `topic` - matches `navigation.rs`'s `FollowPathNode::default_motor_topic`
29/// literal, the project-wide convention for "the" drivetrain command topic.
30fn default_motor_topic() -> String {
31    "/motor/cmd_vel".to_string()
32}
33
34/// What a `wander`/`move`/`stop` node actually publishes this tick, resolved by
35/// [`resolve_movement_output`].
36#[derive(Debug)]
37enum MovementOutput {
38    /// Locomotion-capable project: a whole-robot velocity [`Twist`], published to `topic`
39    /// (today's exact behavior, unchanged).
40    Twist { topic: String, twist: Twist },
41    /// Joints-only project (e.g. pan-tilt): a per-joint [`JointState`], published to `topic`.
42    Joints { topic: String, joint_state: JointState },
43}
44
45/// Resolve what a movement action node should publish for a desired `linear`/`angular`
46/// velocity, given the behavior author's `configured_topic`.
47///
48/// - **Locomotion** (the default, and today's only behavior): builds a [`Twist`] exactly as
49///   before, published to `configured_topic` unchanged - existing behavior JSONs for
50///   locomotion robots keep working with no changes required.
51/// - **Joints**: `linear`/`angular` map onto the first/second entry of the project's declared
52///   joint names (clamped via [`generate_joint_command`]/joint limits), the same convention
53///   `mecha10-nodes`' `rl` controller's `build_output_command` uses. The output topic defaults
54///   to the project-wide joint command topic (`/actuator/joint_cmd`, same as
55///   `teleop`/`rl`/`pan-tilt`) *unless* the author explicitly set `topic` to something other
56///   than [`default_motor_topic`] - in that case we trust the author's explicit choice and keep
57///   using it verbatim, on the theory that an author who bothered to override the topic away
58///   from the locomotion default already picked one that matches whatever this project
59///   actually publishes/subscribes to. This avoids the node silently publishing a `JointState`
60///   to a topic the author pointed at a `Twist` subscriber (or vice versa) while still letting
61///   an author who genuinely wants a non-default joint topic have one.
62fn resolve_movement_output(configured_topic: &str, linear: f64, angular: f64) -> MovementOutput {
63    match controller_mode() {
64        ControllerMode::Locomotion => MovementOutput::Twist {
65            topic: configured_topic.to_string(),
66            twist: Twist {
67                linear: linear as f32,
68                angular: angular as f32,
69            },
70        },
71        ControllerMode::Joints { joint_names } => MovementOutput::Joints {
72            topic: joint_command_topic(configured_topic),
73            joint_state: joint_command(&joint_names, linear as f32, angular as f32),
74        },
75    }
76}
77
78/// Resolve the topic a joints-mode `JointState` should publish to: the project-wide joint
79/// command topic (`/actuator/joint_cmd`) when the behavior author left `topic` at its
80/// locomotion default, or the author's explicit override verbatim otherwise. Shared by
81/// [`resolve_movement_output`] and [`MoveNode`]'s speed-limited stepping path so both pick the
82/// same topic for the same configured `topic` value.
83fn joint_command_topic(configured_topic: &str) -> String {
84    if configured_topic == default_motor_topic() {
85        actuator_topics::JOINT_CMD.path().to_string()
86    } else {
87        configured_topic.to_string()
88    }
89}
90
91/// Build a [`JointState`] driving the first named joint to `linear` and the second to
92/// `angular` (each clamped via [`generate_joint_command`]/the project's configured joint
93/// limits) - the same linear-first/angular-second convention `mecha10-nodes`' `rl` controller
94/// uses. If fewer than two joint names are configured, only the available ones are driven;
95/// this never fails, it just produces a shorter `JointState`.
96fn joint_command(joint_names: &[String], linear: f32, angular: f32) -> JointState {
97    let mut names = Vec::new();
98    let mut positions = Vec::new();
99
100    if let Some(name) = joint_names.first() {
101        let cmd = generate_joint_command(name, linear);
102        names.extend(cmd.names);
103        positions.extend(cmd.positions);
104    }
105    if let Some(name) = joint_names.get(1) {
106        let cmd = generate_joint_command(name, angular);
107        names.extend(cmd.names);
108        positions.extend(cmd.positions);
109    }
110
111    JointState::with_positions(names, positions)
112}
113
114/// Publish `linear`/`angular` to whatever [`resolve_movement_output`] resolves to for this
115/// project's [`controller_mode`].
116async fn publish_movement(ctx: &Context, configured_topic: &str, linear: f64, angular: f64) -> anyhow::Result<()> {
117    match resolve_movement_output(configured_topic, linear, angular) {
118        MovementOutput::Twist { topic, twist } => {
119            ctx.publish_to_path(&topic, &twist).await?;
120        }
121        MovementOutput::Joints { topic, joint_state } => {
122            ctx.publish_to_path(&topic, &joint_state).await?;
123        }
124    }
125    Ok(())
126}
127
128#[derive(Debug)]
129pub struct WanderNode {
130    topic: String,
131    linear_speed: f64,
132    angular_speed: f64,
133    change_interval: Duration,
134    last_change: Option<Instant>,
135    current_angular: f64,
136    last_status: Option<NodeStatus>,
137}
138
139#[derive(Debug, Serialize, Deserialize)]
140struct WanderConfig {
141    #[serde(default = "default_motor_topic")]
142    topic: String,
143    #[serde(default = "default_linear_speed")]
144    linear_speed: f64,
145    #[serde(default = "default_angular_speed")]
146    angular_speed: f64,
147    #[serde(default = "default_change_interval")]
148    change_interval_secs: f64,
149}
150
151fn default_linear_speed() -> f64 {
152    0.3
153}
154
155fn default_angular_speed() -> f64 {
156    0.5
157}
158
159fn default_change_interval() -> f64 {
160    3.0
161}
162
163impl WanderNode {
164    /// Create a new WanderNode from JSON configuration
165    pub fn from_json(config: Value) -> anyhow::Result<Self> {
166        let cfg: WanderConfig = serde_json::from_value(config)?;
167        Ok(Self {
168            topic: cfg.topic,
169            linear_speed: cfg.linear_speed,
170            angular_speed: cfg.angular_speed,
171            change_interval: Duration::from_secs_f64(cfg.change_interval_secs),
172            last_change: None,
173            current_angular: 0.0,
174            last_status: None,
175        })
176    }
177}
178
179#[async_trait]
180impl BehaviorNode for WanderNode {
181    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
182        let now = Instant::now();
183
184        if self.last_change.is_none() || now.duration_since(self.last_change.unwrap()) >= self.change_interval {
185            self.current_angular = (rand::random::<f64>() * 2.0 - 1.0) * self.angular_speed;
186            self.last_change = Some(now);
187        }
188
189        publish_movement(ctx, &self.topic, self.linear_speed, self.current_angular).await?;
190        self.last_status = Some(NodeStatus::Running);
191        Ok(NodeStatus::Running)
192    }
193
194    fn name(&self) -> &str {
195        "WanderNode"
196    }
197
198    fn status_snapshot(&self) -> TreeStatusSnapshot {
199        TreeStatusSnapshot {
200            name: self.name().to_string(),
201            status: self.last_status,
202            active_capability: self.active_capability().map(String::from),
203            children: Vec::new(),
204        }
205    }
206}
207
208/// Within this many radians of `target`, a `speed`-limited joint is considered "arrived" for
209/// [`MoveNode`]'s completion check - real joint feedback never settles to an exact float, so an
210/// exact-equality check would never report `Success`.
211const JOINT_POSITION_TOLERANCE_RAD: f32 = 0.01;
212
213#[derive(Default)]
214pub struct MoveNode {
215    topic: String,
216    linear: f64,
217    angular: f64,
218    duration: Option<Duration>,
219    /// Maximum rate of change, in rad/s, [`MoveNode::tick_speed_limited`] steps each joint's
220    /// published position by - unset means "unchanged": publish the static `linear`/`angular`
221    /// target every tick, exactly as before this field existed (see
222    /// [`wants_speed_limited_stepping`]).
223    speed: Option<f64>,
224    timer: ElapsedTimer,
225    /// Subscribed lazily on the first speed-limited tick, same pattern as `SensorCheckNode`'s
226    /// `receiver`/`GoToLocationNode`'s `odometry_receiver`.
227    joint_feedback: Option<Receiver<JointState>>,
228    latest_joint_state: Option<JointState>,
229    /// Wall-clock time of the last speed-limited tick, used to compute `dt` for `speed * dt`
230    /// stepping - same pattern as `GoToLocationNode::last_tick`.
231    last_step_tick: Option<Instant>,
232    last_status: Option<NodeStatus>,
233}
234
235impl std::fmt::Debug for MoveNode {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        f.debug_struct("MoveNode")
238            .field("topic", &self.topic)
239            .field("linear", &self.linear)
240            .field("angular", &self.angular)
241            .field("duration", &self.duration)
242            .field("speed", &self.speed)
243            .field("timer", &self.timer)
244            .field("joint_feedback_subscribed", &self.joint_feedback.is_some())
245            .field("latest_joint_state", &self.latest_joint_state)
246            .field("last_step_tick", &self.last_step_tick)
247            .field("last_status", &self.last_status)
248            .finish()
249    }
250}
251
252#[derive(Debug, Serialize, Deserialize)]
253struct MoveConfig {
254    #[serde(default = "default_motor_topic")]
255    topic: String,
256    linear: f64,
257    angular: f64,
258    duration_secs: Option<f64>,
259    speed: Option<f64>,
260}
261
262impl MoveNode {
263    /// Create a new MoveNode from JSON configuration
264    pub fn from_json(config: Value) -> anyhow::Result<Self> {
265        let cfg: MoveConfig = serde_json::from_value(config)?;
266        Ok(Self {
267            topic: cfg.topic,
268            linear: cfg.linear,
269            angular: cfg.angular,
270            duration: cfg.duration_secs.map(Duration::from_secs_f64),
271            speed: cfg.speed,
272            timer: ElapsedTimer::default(),
273            joint_feedback: None,
274            latest_joint_state: None,
275            last_step_tick: None,
276            last_status: None,
277        })
278    }
279
280    /// Speed-limited, real-feedback-driven joint stepping (see module-level design notes above
281    /// [`resolve_movement_output`]): step each configured joint's published position toward its
282    /// clamped `linear`/`angular` target by at most `speed * dt` this tick, using the latest
283    /// known *real* joint position (from [`sensor_topics::JOINT_STATES`]) as the starting point
284    /// rather than re-publishing the static target. Reaches `Success` once every configured
285    /// joint's real position is within [`JOINT_POSITION_TOLERANCE_RAD`] of its target.
286    async fn tick_speed_limited(
287        &mut self,
288        ctx: &Context,
289        joint_names: &[String],
290        speed: f64,
291    ) -> anyhow::Result<NodeStatus> {
292        if self.joint_feedback.is_none() {
293            self.joint_feedback = Some(ctx.subscribe(sensor_topics::JOINT_STATES).await?);
294        }
295        while let Ok(state) = self.joint_feedback.as_mut().expect("just subscribed above").try_recv() {
296            self.latest_joint_state = Some(state);
297        }
298
299        let Some(latest) = self.latest_joint_state.as_ref() else {
300            // No real feedback has arrived yet. Stepping from an assumed starting position
301            // (e.g. 0.0) could snap the servo just as badly as the bug this feature fixes -
302            // hold instead, the same "wait for the first reading" precedent
303            // `SensorCheckNode`/`GoToLocationNode` already establish for a required feedback
304            // topic that hasn't produced a message yet.
305            tracing::debug!(
306                "MoveNode: waiting for first {} reading before speed-limited joint stepping",
307                sensor_topics::JOINT_STATES.path()
308            );
309            self.last_status = Some(NodeStatus::Running);
310            return Ok(NodeStatus::Running);
311        };
312
313        let now = Instant::now();
314        let dt = self
315            .last_step_tick
316            .map(|last| now.duration_since(last).as_secs_f64())
317            .unwrap_or(0.0);
318        self.last_step_tick = Some(now);
319        let max_step = (speed * dt) as f32;
320
321        let mut names = Vec::new();
322        let mut positions = Vec::new();
323        let mut all_arrived = true;
324
325        for (name, raw_target) in [
326            (joint_names.first(), self.linear as f32),
327            (joint_names.get(1), self.angular as f32),
328        ] {
329            let Some(name) = name else { continue };
330            let Some(current) = latest.get_position(name) else {
331                // This joint hasn't shown up in a feedback message yet - same "don't step from
332                // an assumed position" reasoning as the no-feedback-at-all case above.
333                tracing::debug!(joint = %name, "MoveNode: waiting for feedback on this joint before speed-limited stepping");
334                self.last_status = Some(NodeStatus::Running);
335                return Ok(NodeStatus::Running);
336            };
337            let target = clamped_joint_target(name, raw_target);
338            let (stepped, arrived) = step_joint_position(current, target, max_step, JOINT_POSITION_TOLERANCE_RAD);
339            names.push(name.clone());
340            positions.push(stepped);
341            all_arrived &= arrived;
342        }
343
344        ctx.publish_to_path(
345            &joint_command_topic(&self.topic),
346            &JointState::with_positions(names.clone(), positions),
347        )
348        .await?;
349
350        if !names.is_empty() && all_arrived {
351            self.last_status = Some(NodeStatus::Success);
352            return Ok(NodeStatus::Success);
353        }
354
355        self.last_status = Some(NodeStatus::Running);
356        Ok(NodeStatus::Running)
357    }
358}
359
360#[async_trait]
361impl BehaviorNode for MoveNode {
362    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
363        // `duration_secs`, when configured, is always a hard safety timeout - even when
364        // `speed` is set and completion is otherwise driven by real-feedback tolerance below.
365        if let Some(duration) = self.duration {
366            if self.timer.elapsed_since_start(duration) {
367                self.last_status = Some(NodeStatus::Success);
368                return Ok(NodeStatus::Success);
369            }
370        }
371
372        let mode = controller_mode();
373        if wants_speed_limited_stepping(self.speed, &mode) {
374            let ControllerMode::Joints { joint_names } = mode else {
375                unreachable!("wants_speed_limited_stepping only returns true for ControllerMode::Joints");
376            };
377            let speed = self
378                .speed
379                .expect("wants_speed_limited_stepping only returns true when speed is set");
380            return self.tick_speed_limited(ctx, &joint_names, speed).await;
381        }
382
383        publish_movement(ctx, &self.topic, self.linear, self.angular).await?;
384
385        self.last_status = Some(NodeStatus::Running);
386        Ok(NodeStatus::Running)
387    }
388
389    reset_timer_field!(timer);
390
391    fn name(&self) -> &str {
392        "MoveNode"
393    }
394
395    fn status_snapshot(&self) -> TreeStatusSnapshot {
396        TreeStatusSnapshot {
397            name: self.name().to_string(),
398            status: self.last_status,
399            active_capability: self.active_capability().map(String::from),
400            children: Vec::new(),
401        }
402    }
403}
404
405/// Whether this tick should use `speed`-limited, feedback-driven joint stepping rather than
406/// publishing the static `linear`/`angular` target every tick: only once the behavior author
407/// configured a `speed` AND the project is in joints mode. Locomotion's `Twist` is already a
408/// velocity command the robot integrates into position over time - naturally smooth without any
409/// rate limiting here - so `speed` is ignored outside joints mode.
410fn wants_speed_limited_stepping(speed: Option<f64>, mode: &ControllerMode) -> bool {
411    speed.is_some() && matches!(mode, ControllerMode::Joints { .. })
412}
413
414/// Clamp `raw_target` to `joint_name`'s configured joint limits (if the project declares any) -
415/// the same clamp [`generate_joint_command`] applies to a joints-mode `MoveNode`'s final
416/// published target, reused here so intermediate stepped positions never aim past a joint's
417/// limits either.
418fn clamped_joint_target(joint_name: &str, raw_target: f32) -> f32 {
419    match joint_limits(joint_name) {
420        Some(limits) => raw_target.clamp(limits.min_angle_rad, limits.max_angle_rad),
421        None => raw_target,
422    }
423}
424
425/// Step `current` toward `target` by at most `max_step.abs()` (the caller supplies `speed *
426/// dt`, always non-negative in practice), without ever overshooting `target`. Returns the
427/// stepped position and whether it's already within `tolerance` of `target`.
428fn step_joint_position(current: f32, target: f32, max_step: f32, tolerance: f32) -> (f32, bool) {
429    let max_step = max_step.abs();
430    let delta = target - current;
431    let stepped = current + delta.clamp(-max_step, max_step);
432    let arrived = (target - stepped).abs() <= tolerance;
433    (stepped, arrived)
434}
435
436#[derive(Debug)]
437pub struct StopNode {
438    topic: String,
439    last_status: Option<NodeStatus>,
440}
441
442#[derive(Debug, Serialize, Deserialize)]
443struct StopConfig {
444    #[serde(default = "default_motor_topic")]
445    topic: String,
446}
447
448impl StopNode {
449    /// Create a new StopNode from JSON configuration
450    pub fn from_json(config: Value) -> anyhow::Result<Self> {
451        let cfg: StopConfig = serde_json::from_value(config)?;
452        Ok(Self {
453            topic: cfg.topic,
454            last_status: None,
455        })
456    }
457}
458
459#[async_trait]
460impl BehaviorNode for StopNode {
461    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
462        publish_movement(ctx, &self.topic, 0.0, 0.0).await?;
463        tracing::debug!("StopNode: published stop command");
464        self.last_status = Some(NodeStatus::Success);
465        Ok(NodeStatus::Success)
466    }
467
468    fn name(&self) -> &str {
469        "StopNode"
470    }
471
472    fn status_snapshot(&self) -> TreeStatusSnapshot {
473        TreeStatusSnapshot {
474            name: self.name().to_string(),
475            status: self.last_status,
476            active_capability: self.active_capability().map(String::from),
477            children: Vec::new(),
478        }
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use mecha10_core::config::{ACTUATION_JOINT_NAMES_ENV_VAR, ACTUATOR_JOINT_LIMITS_ENV_VAR, CAPABILITIES_ENV_VAR};
486    use std::env;
487    use std::sync::Mutex;
488
489    // `controller_mode()`/`generate_joint_command()` read process-global env vars; serialize
490    // the tests that touch them so they can't observe each other's in-flight state (mirrors
491    // `mecha10_core::config`'s own test convention).
492    static CONTROLLER_ENV_LOCK: Mutex<()> = Mutex::new(());
493
494    fn clear_controller_env() {
495        env::remove_var(CAPABILITIES_ENV_VAR);
496        env::remove_var(ACTUATION_JOINT_NAMES_ENV_VAR);
497        env::remove_var(ACTUATOR_JOINT_LIMITS_ENV_VAR);
498    }
499
500    #[test]
501    fn resolve_movement_output_is_a_twist_to_the_configured_topic_in_locomotion_mode() {
502        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
503        clear_controller_env();
504
505        let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
506
507        let MovementOutput::Twist { topic, twist } = output else {
508            panic!("expected Twist output");
509        };
510        assert_eq!(topic, "/motor/cmd_vel");
511        assert_eq!(twist.linear, 1.0);
512        assert_eq!(twist.angular, 0.5);
513
514        clear_controller_env();
515    }
516
517    #[test]
518    fn resolve_movement_output_ignores_declared_joints_when_locomotion_also_declared() {
519        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
520        clear_controller_env();
521        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints","locomotion"]"#);
522        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
523
524        let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
525
526        let MovementOutput::Twist { topic, twist } = output else {
527            panic!("expected Twist output");
528        };
529        assert_eq!(topic, "/motor/cmd_vel");
530        assert_eq!(twist.linear, 1.0);
531        assert_eq!(twist.angular, 0.5);
532
533        clear_controller_env();
534    }
535
536    #[test]
537    fn resolve_movement_output_is_a_joint_state_to_the_default_joint_topic_in_joints_mode() {
538        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
539        clear_controller_env();
540        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
541        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
542
543        // The author left `topic` at its locomotion default - we should redirect to the
544        // project-wide joint command topic rather than publish a JointState to "/motor/cmd_vel".
545        let output = resolve_movement_output(&default_motor_topic(), 0.3, -0.2);
546
547        let MovementOutput::Joints { topic, joint_state } = output else {
548            panic!("expected Joints output");
549        };
550        assert_eq!(topic, actuator_topics::JOINT_CMD.path());
551        assert_eq!(joint_state.names, vec!["pan".to_string(), "tilt".to_string()]);
552        assert_eq!(joint_state.positions, vec![0.3, -0.2]);
553
554        clear_controller_env();
555    }
556
557    #[test]
558    fn resolve_movement_output_keeps_an_explicitly_overridden_topic_in_joints_mode() {
559        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
560        clear_controller_env();
561        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
562        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
563
564        let output = resolve_movement_output("/custom/joint_topic", 0.3, -0.2);
565
566        let MovementOutput::Joints { topic, .. } = output else {
567            panic!("expected Joints output");
568        };
569        assert_eq!(topic, "/custom/joint_topic");
570
571        clear_controller_env();
572    }
573
574    #[test]
575    fn resolve_movement_output_clamps_to_configured_joint_limits_in_joints_mode() {
576        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
577        clear_controller_env();
578        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
579        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan"]"#);
580        env::set_var(
581            ACTUATOR_JOINT_LIMITS_ENV_VAR,
582            r#"[{"joint_id":"pan","min_angle_rad":-1.0,"max_angle_rad":1.0}]"#,
583        );
584
585        let output = resolve_movement_output(&default_motor_topic(), 5.0, 0.0);
586
587        let MovementOutput::Joints { joint_state, .. } = output else {
588            panic!("expected Joints output");
589        };
590        assert_eq!(joint_state.names, vec!["pan".to_string()]);
591        assert_eq!(joint_state.positions, vec![1.0]);
592
593        clear_controller_env();
594    }
595
596    #[test]
597    fn resolve_movement_output_drives_only_the_available_joint_when_fewer_than_two_are_configured() {
598        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
599        clear_controller_env();
600        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
601        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan"]"#);
602
603        let output = resolve_movement_output(&default_motor_topic(), 0.7, -0.4);
604
605        let MovementOutput::Joints { joint_state, .. } = output else {
606            panic!("expected Joints output");
607        };
608        assert_eq!(joint_state.names, vec!["pan".to_string()]);
609        assert_eq!(joint_state.positions, vec![0.7]);
610
611        clear_controller_env();
612    }
613
614    #[test]
615    fn wander_node_from_json_defaults_topic_to_the_locomotion_default() {
616        let node = WanderNode::from_json(serde_json::json!({})).unwrap();
617        assert_eq!(node.topic, default_motor_topic());
618    }
619
620    #[test]
621    fn move_node_from_json_defaults_topic_to_the_locomotion_default() {
622        let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0 })).unwrap();
623        assert_eq!(node.topic, default_motor_topic());
624    }
625
626    #[test]
627    fn move_node_from_json_defaults_speed_to_unset() {
628        let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0 })).unwrap();
629        assert_eq!(node.speed, None);
630    }
631
632    #[test]
633    fn move_node_from_json_reads_speed_when_set() {
634        let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0, "speed": 0.5 })).unwrap();
635        assert_eq!(node.speed, Some(0.5));
636    }
637
638    #[test]
639    fn stop_node_from_json_defaults_topic_to_the_locomotion_default() {
640        let node = StopNode::from_json(serde_json::json!({})).unwrap();
641        assert_eq!(node.topic, default_motor_topic());
642    }
643
644    // --- `speed`-limited joint stepping (LAB sentry pan-sweep jitter fix) ---
645    //
646    // `wants_speed_limited_stepping`/`step_joint_position`/`clamped_joint_target` are plain,
647    // `Context`-free functions precisely so the velocity-limiting math can be unit tested
648    // without needing a live `Context`/Redis - the same reason `GoToLocationNode` factors
649    // `compute_control`/`world_to_anchor_frame` etc. out of its `tick()` instead of testing
650    // through a real `Context`.
651
652    #[test]
653    fn wants_speed_limited_stepping_is_false_when_speed_is_unset_regardless_of_mode() {
654        // (a) Regression: with no `speed` configured, `MoveNode` must never take the
655        // speed-limited path - `tick()` always falls through to the unchanged
656        // static-publish/duration-based-success behavior, in both controller modes.
657        assert!(!wants_speed_limited_stepping(None, &ControllerMode::Locomotion));
658        assert!(!wants_speed_limited_stepping(
659            None,
660            &ControllerMode::Joints {
661                joint_names: vec!["pan".to_string()]
662            }
663        ));
664    }
665
666    #[test]
667    fn wants_speed_limited_stepping_is_false_in_locomotion_mode_even_with_speed_set() {
668        // Locomotion's `Twist` is already a velocity command, naturally smooth - `speed` is a
669        // joints-mode-only concept.
670        assert!(!wants_speed_limited_stepping(Some(0.5), &ControllerMode::Locomotion));
671    }
672
673    #[test]
674    fn wants_speed_limited_stepping_is_true_in_joints_mode_with_speed_set() {
675        assert!(wants_speed_limited_stepping(
676            Some(0.5),
677            &ControllerMode::Joints {
678                joint_names: vec!["pan".to_string()]
679            }
680        ));
681    }
682
683    #[test]
684    fn step_joint_position_moves_toward_target_by_at_most_max_step() {
685        // (b) A single tick moves the published position by at most `speed * dt` toward the
686        // target, starting from a given simulated current-feedback value.
687        let (stepped, arrived) = step_joint_position(0.0, 1.0, 0.1, JOINT_POSITION_TOLERANCE_RAD);
688        assert!((stepped - 0.1).abs() < f32::EPSILON);
689        assert!(!arrived);
690    }
691
692    #[test]
693    fn step_joint_position_steps_toward_target_from_either_side() {
694        let (stepped, _) = step_joint_position(1.0, 0.0, 0.1, JOINT_POSITION_TOLERANCE_RAD);
695        assert!((stepped - 0.9).abs() < f32::EPSILON);
696    }
697
698    #[test]
699    fn step_joint_position_never_overshoots_the_target() {
700        // (c) Overshoot protection: stepping never publishes past the target even if
701        // `speed * dt` would exceed the remaining distance.
702        let (stepped, arrived) = step_joint_position(0.95, 1.0, 10.0, JOINT_POSITION_TOLERANCE_RAD);
703        assert_eq!(stepped, 1.0);
704        assert!(arrived);
705    }
706
707    #[test]
708    fn step_joint_position_never_overshoots_the_target_stepping_downward() {
709        let (stepped, arrived) = step_joint_position(-0.95, -1.0, 10.0, JOINT_POSITION_TOLERANCE_RAD);
710        assert_eq!(stepped, -1.0);
711        assert!(arrived);
712    }
713
714    #[test]
715    fn step_joint_position_reports_arrived_once_within_tolerance() {
716        // (d) The node reaches `Success` once the stepped position is within tolerance of the
717        // target - exercised here at the pure-function level `tick_speed_limited` builds on.
718        let (_, arrived) = step_joint_position(0.995, 1.0, 0.1, JOINT_POSITION_TOLERANCE_RAD);
719        assert!(arrived);
720
721        let (_, not_yet_arrived) = step_joint_position(0.9, 1.0, 0.0, JOINT_POSITION_TOLERANCE_RAD);
722        assert!(!not_yet_arrived);
723    }
724
725    #[test]
726    fn step_joint_position_treats_max_step_as_a_magnitude() {
727        // The caller always supplies a non-negative `speed * dt`, but guard against a negative
728        // `max_step` still working sanely (it's clamped via `.abs()`) rather than inverting the
729        // step direction.
730        let (stepped, _) = step_joint_position(0.0, 1.0, -0.1, JOINT_POSITION_TOLERANCE_RAD);
731        assert!((stepped - 0.1).abs() < f32::EPSILON);
732    }
733
734    #[test]
735    fn clamped_joint_target_passes_through_when_no_joint_limit_configured() {
736        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
737        clear_controller_env();
738
739        assert_eq!(clamped_joint_target("pan", 5.0), 5.0);
740
741        clear_controller_env();
742    }
743
744    #[test]
745    fn clamped_joint_target_clamps_to_configured_joint_limits() {
746        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
747        clear_controller_env();
748        env::set_var(
749            ACTUATOR_JOINT_LIMITS_ENV_VAR,
750            r#"[{"joint_id":"pan","min_angle_rad":-1.0,"max_angle_rad":1.0}]"#,
751        );
752
753        assert_eq!(clamped_joint_target("pan", 1000.0), 1.0);
754        assert_eq!(clamped_joint_target("pan", -1000.0), -1.0);
755
756        clear_controller_env();
757    }
758
759    #[tokio::test]
760    #[ignore = "Requires Redis infrastructure"]
761    async fn move_node_without_speed_publishes_the_static_target_and_succeeds_on_duration() {
762        // (a) Regression, at the full `tick()` level: with no `speed` configured, behavior is
763        // unchanged from before this feature existed - static publish every tick, `Success`
764        // only once `duration_secs` elapses. Locomotion mode needs no joint feedback at all,
765        // so this exercises the real `tick()` without requiring a live Redis-backed `Context`.
766        //
767        // Clears the controller env vars (so this runs in the `Locomotion` default) and drops
768        // the guard immediately rather than holding it across the `await`s below - clippy
769        // (`await_holding_lock`) flags a `MutexGuard` held across an await point, and this test
770        // is `#[ignore]`d/run serially via Redis-gated CI anyway, not concurrently with the
771        // env-mutating `resolve_movement_output_*` tests above.
772        {
773            let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
774            clear_controller_env();
775        }
776
777        let mut node = MoveNode::from_json(serde_json::json!({
778            "linear": 1.0,
779            "angular": 0.5,
780            "duration_secs": 0.05
781        }))
782        .unwrap();
783
784        let ctx = crate::test_helpers::create_test_context("move_node_without_speed").await;
785        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
786
787        tokio::time::sleep(Duration::from_millis(80)).await;
788        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Success);
789
790        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
791        clear_controller_env();
792    }
793}