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        let mode = controller_mode();
364        let speed_limited = wants_speed_limited_stepping(self.speed, &mode);
365
366        // `duration_secs`, when configured, is always a hard safety timeout - even when
367        // `speed` is set and completion is otherwise driven by real-feedback tolerance below.
368        //
369        // For the speed-limited joint-stepping path, `duration_secs` alone can't be trusted as
370        // the ceiling: it's a single static number a behavior author hand-tunes for whatever
371        // joint range *their* robot happens to have, with no way to know the right value ahead
372        // of time (see module docs). So here the *effective* timeout is widened - never
373        // shortened - to `max(duration_secs, dynamic_safety_timeout)`, where the dynamic value
374        // is derived from the robot's own real `mecha10.json`-resolved joint limits and the
375        // configured `speed`. This makes `duration_secs` a floor/sanity-minimum rather than a
376        // ceiling that can silently truncate a legitimate slow sweep.
377        if let Some(duration) = self.duration {
378            let effective_duration = if speed_limited {
379                let ControllerMode::Joints { joint_names } = &mode else {
380                    unreachable!("wants_speed_limited_stepping only returns true for ControllerMode::Joints");
381                };
382                let speed = self
383                    .speed
384                    .expect("wants_speed_limited_stepping only returns true when speed is set");
385                dynamic_safety_timeout(joint_names, speed, duration)
386            } else {
387                duration
388            };
389
390            if self.timer.elapsed_since_start(effective_duration) {
391                self.last_status = Some(NodeStatus::Success);
392                return Ok(NodeStatus::Success);
393            }
394        }
395
396        if speed_limited {
397            let ControllerMode::Joints { joint_names } = mode else {
398                unreachable!("wants_speed_limited_stepping only returns true for ControllerMode::Joints");
399            };
400            let speed = self
401                .speed
402                .expect("wants_speed_limited_stepping only returns true when speed is set");
403            return self.tick_speed_limited(ctx, &joint_names, speed).await;
404        }
405
406        publish_movement(ctx, &self.topic, self.linear, self.angular).await?;
407
408        self.last_status = Some(NodeStatus::Running);
409        Ok(NodeStatus::Running)
410    }
411
412    reset_timer_field!(timer);
413
414    fn name(&self) -> &str {
415        "MoveNode"
416    }
417
418    fn status_snapshot(&self) -> TreeStatusSnapshot {
419        TreeStatusSnapshot {
420            name: self.name().to_string(),
421            status: self.last_status,
422            active_capability: self.active_capability().map(String::from),
423            children: Vec::new(),
424        }
425    }
426}
427
428/// Whether this tick should use `speed`-limited, feedback-driven joint stepping rather than
429/// publishing the static `linear`/`angular` target every tick: only once the behavior author
430/// configured a `speed` AND the project is in joints mode. Locomotion's `Twist` is already a
431/// velocity command the robot integrates into position over time - naturally smooth without any
432/// rate limiting here - so `speed` is ignored outside joints mode.
433fn wants_speed_limited_stepping(speed: Option<f64>, mode: &ControllerMode) -> bool {
434    speed.is_some() && matches!(mode, ControllerMode::Joints { .. })
435}
436
437/// Multiplier applied to the theoretical minimum travel time (`range / speed`) when deriving a
438/// speed-limited move's dynamic safety timeout (see [`dynamic_safety_timeout`]) - leaves
439/// headroom for real-world settling/overshoot-correction time beyond the theoretical minimum,
440/// rather than cutting a move off the instant it crosses the bare-minimum travel time.
441const DYNAMIC_SAFETY_MARGIN: f64 = 1.5;
442
443/// Derive the effective hard-safety-timeout [`Duration`] for a speed-limited joints-mode
444/// [`MoveNode`] move: `max(static_duration, required_secs * DYNAMIC_SAFETY_MARGIN)`, where
445/// `required_secs` is the worst-case time to cross a driven joint's *full configured range*
446/// (`max_angle_rad - min_angle_rad`) at `speed` rad/s, resolved via the same [`joint_limits`]
447/// helper [`clamped_joint_target`] already uses.
448///
449/// The full configured range (rather than the distance from wherever the joint happens to be
450/// at move-start) is the safe worst-case choice, since the real starting position isn't known
451/// yet when this safety timeout needs to be decided - the move might start at either extreme.
452/// Only the first two `joint_names` are considered (linear, then angular), matching the
453/// linear-first/angular-second convention [`MoveNode::tick_speed_limited`] steps; when more
454/// than one of those joints has a resolvable limit, the slower (larger-required-time) one
455/// wins, since the move only completes once every driven joint has arrived.
456///
457/// Falls back to `static_duration` unchanged (never *shortens* it) when `speed` is zero or
458/// negative (would divide by zero/invert direction) or when none of the driven joints have a
459/// resolvable configured limit (e.g. the project doesn't declare joint limits for this robot) -
460/// in both cases there's no real joint-range data to derive a dynamic duration from, so
461/// `duration_secs` alone decides, exactly as before this feature existed.
462fn dynamic_safety_timeout(joint_names: &[String], speed: f64, static_duration: Duration) -> Duration {
463    if speed <= 0.0 {
464        return static_duration;
465    }
466
467    let required_secs = joint_names
468        .iter()
469        .take(2)
470        .filter_map(|name| joint_limits(name))
471        .map(|limits| (limits.max_angle_rad - limits.min_angle_rad).abs() as f64 / speed)
472        .reduce(f64::max);
473
474    let Some(required_secs) = required_secs else {
475        return static_duration;
476    };
477
478    static_duration.max(Duration::from_secs_f64(required_secs * DYNAMIC_SAFETY_MARGIN))
479}
480
481/// Clamp `raw_target` to `joint_name`'s configured joint limits (if the project declares any) -
482/// the same clamp [`generate_joint_command`] applies to a joints-mode `MoveNode`'s final
483/// published target, reused here so intermediate stepped positions never aim past a joint's
484/// limits either.
485fn clamped_joint_target(joint_name: &str, raw_target: f32) -> f32 {
486    match joint_limits(joint_name) {
487        Some(limits) => raw_target.clamp(limits.min_angle_rad, limits.max_angle_rad),
488        None => raw_target,
489    }
490}
491
492/// Step `current` toward `target` by at most `max_step.abs()` (the caller supplies `speed *
493/// dt`, always non-negative in practice), without ever overshooting `target`. Returns the
494/// stepped position and whether it's already within `tolerance` of `target`.
495fn step_joint_position(current: f32, target: f32, max_step: f32, tolerance: f32) -> (f32, bool) {
496    let max_step = max_step.abs();
497    let delta = target - current;
498    let stepped = current + delta.clamp(-max_step, max_step);
499    let arrived = (target - stepped).abs() <= tolerance;
500    (stepped, arrived)
501}
502
503#[derive(Debug)]
504pub struct StopNode {
505    topic: String,
506    last_status: Option<NodeStatus>,
507}
508
509#[derive(Debug, Serialize, Deserialize)]
510struct StopConfig {
511    #[serde(default = "default_motor_topic")]
512    topic: String,
513}
514
515impl StopNode {
516    /// Create a new StopNode from JSON configuration
517    pub fn from_json(config: Value) -> anyhow::Result<Self> {
518        let cfg: StopConfig = serde_json::from_value(config)?;
519        Ok(Self {
520            topic: cfg.topic,
521            last_status: None,
522        })
523    }
524}
525
526#[async_trait]
527impl BehaviorNode for StopNode {
528    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
529        publish_movement(ctx, &self.topic, 0.0, 0.0).await?;
530        tracing::debug!("StopNode: published stop command");
531        self.last_status = Some(NodeStatus::Success);
532        Ok(NodeStatus::Success)
533    }
534
535    fn name(&self) -> &str {
536        "StopNode"
537    }
538
539    fn status_snapshot(&self) -> TreeStatusSnapshot {
540        TreeStatusSnapshot {
541            name: self.name().to_string(),
542            status: self.last_status,
543            active_capability: self.active_capability().map(String::from),
544            children: Vec::new(),
545        }
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use mecha10_core::config::{ACTUATION_JOINT_NAMES_ENV_VAR, ACTUATOR_JOINT_LIMITS_ENV_VAR, CAPABILITIES_ENV_VAR};
553    use std::env;
554    use std::sync::Mutex;
555
556    // `controller_mode()`/`generate_joint_command()` read process-global env vars; serialize
557    // the tests that touch them so they can't observe each other's in-flight state (mirrors
558    // `mecha10_core::config`'s own test convention).
559    static CONTROLLER_ENV_LOCK: Mutex<()> = Mutex::new(());
560
561    fn clear_controller_env() {
562        env::remove_var(CAPABILITIES_ENV_VAR);
563        env::remove_var(ACTUATION_JOINT_NAMES_ENV_VAR);
564        env::remove_var(ACTUATOR_JOINT_LIMITS_ENV_VAR);
565    }
566
567    #[test]
568    fn resolve_movement_output_is_a_twist_to_the_configured_topic_in_locomotion_mode() {
569        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
570        clear_controller_env();
571
572        let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
573
574        let MovementOutput::Twist { topic, twist } = output else {
575            panic!("expected Twist output");
576        };
577        assert_eq!(topic, "/motor/cmd_vel");
578        assert_eq!(twist.linear, 1.0);
579        assert_eq!(twist.angular, 0.5);
580
581        clear_controller_env();
582    }
583
584    #[test]
585    fn resolve_movement_output_ignores_declared_joints_when_locomotion_also_declared() {
586        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
587        clear_controller_env();
588        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints","locomotion"]"#);
589        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
590
591        let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
592
593        let MovementOutput::Twist { topic, twist } = output else {
594            panic!("expected Twist output");
595        };
596        assert_eq!(topic, "/motor/cmd_vel");
597        assert_eq!(twist.linear, 1.0);
598        assert_eq!(twist.angular, 0.5);
599
600        clear_controller_env();
601    }
602
603    #[test]
604    fn resolve_movement_output_is_a_joint_state_to_the_default_joint_topic_in_joints_mode() {
605        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
606        clear_controller_env();
607        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
608        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
609
610        // The author left `topic` at its locomotion default - we should redirect to the
611        // project-wide joint command topic rather than publish a JointState to "/motor/cmd_vel".
612        let output = resolve_movement_output(&default_motor_topic(), 0.3, -0.2);
613
614        let MovementOutput::Joints { topic, joint_state } = output else {
615            panic!("expected Joints output");
616        };
617        assert_eq!(topic, actuator_topics::JOINT_CMD.path());
618        assert_eq!(joint_state.names, vec!["pan".to_string(), "tilt".to_string()]);
619        assert_eq!(joint_state.positions, vec![0.3, -0.2]);
620
621        clear_controller_env();
622    }
623
624    #[test]
625    fn resolve_movement_output_keeps_an_explicitly_overridden_topic_in_joints_mode() {
626        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
627        clear_controller_env();
628        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
629        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
630
631        let output = resolve_movement_output("/custom/joint_topic", 0.3, -0.2);
632
633        let MovementOutput::Joints { topic, .. } = output else {
634            panic!("expected Joints output");
635        };
636        assert_eq!(topic, "/custom/joint_topic");
637
638        clear_controller_env();
639    }
640
641    #[test]
642    fn resolve_movement_output_clamps_to_configured_joint_limits_in_joints_mode() {
643        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
644        clear_controller_env();
645        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
646        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan"]"#);
647        env::set_var(
648            ACTUATOR_JOINT_LIMITS_ENV_VAR,
649            r#"[{"joint_id":"pan","min_angle_rad":-1.0,"max_angle_rad":1.0}]"#,
650        );
651
652        let output = resolve_movement_output(&default_motor_topic(), 5.0, 0.0);
653
654        let MovementOutput::Joints { joint_state, .. } = output else {
655            panic!("expected Joints output");
656        };
657        assert_eq!(joint_state.names, vec!["pan".to_string()]);
658        assert_eq!(joint_state.positions, vec![1.0]);
659
660        clear_controller_env();
661    }
662
663    #[test]
664    fn resolve_movement_output_drives_only_the_available_joint_when_fewer_than_two_are_configured() {
665        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
666        clear_controller_env();
667        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
668        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan"]"#);
669
670        let output = resolve_movement_output(&default_motor_topic(), 0.7, -0.4);
671
672        let MovementOutput::Joints { joint_state, .. } = output else {
673            panic!("expected Joints output");
674        };
675        assert_eq!(joint_state.names, vec!["pan".to_string()]);
676        assert_eq!(joint_state.positions, vec![0.7]);
677
678        clear_controller_env();
679    }
680
681    #[test]
682    fn wander_node_from_json_defaults_topic_to_the_locomotion_default() {
683        let node = WanderNode::from_json(serde_json::json!({})).unwrap();
684        assert_eq!(node.topic, default_motor_topic());
685    }
686
687    #[test]
688    fn move_node_from_json_defaults_topic_to_the_locomotion_default() {
689        let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0 })).unwrap();
690        assert_eq!(node.topic, default_motor_topic());
691    }
692
693    #[test]
694    fn move_node_from_json_defaults_speed_to_unset() {
695        let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0 })).unwrap();
696        assert_eq!(node.speed, None);
697    }
698
699    #[test]
700    fn move_node_from_json_reads_speed_when_set() {
701        let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0, "speed": 0.5 })).unwrap();
702        assert_eq!(node.speed, Some(0.5));
703    }
704
705    #[test]
706    fn stop_node_from_json_defaults_topic_to_the_locomotion_default() {
707        let node = StopNode::from_json(serde_json::json!({})).unwrap();
708        assert_eq!(node.topic, default_motor_topic());
709    }
710
711    // --- `speed`-limited joint stepping (LAB sentry pan-sweep jitter fix) ---
712    //
713    // `wants_speed_limited_stepping`/`step_joint_position`/`clamped_joint_target` are plain,
714    // `Context`-free functions precisely so the velocity-limiting math can be unit tested
715    // without needing a live `Context`/Redis - the same reason `GoToLocationNode` factors
716    // `compute_control`/`world_to_anchor_frame` etc. out of its `tick()` instead of testing
717    // through a real `Context`.
718
719    #[test]
720    fn wants_speed_limited_stepping_is_false_when_speed_is_unset_regardless_of_mode() {
721        // (a) Regression: with no `speed` configured, `MoveNode` must never take the
722        // speed-limited path - `tick()` always falls through to the unchanged
723        // static-publish/duration-based-success behavior, in both controller modes.
724        assert!(!wants_speed_limited_stepping(None, &ControllerMode::Locomotion));
725        assert!(!wants_speed_limited_stepping(
726            None,
727            &ControllerMode::Joints {
728                joint_names: vec!["pan".to_string()]
729            }
730        ));
731    }
732
733    #[test]
734    fn wants_speed_limited_stepping_is_false_in_locomotion_mode_even_with_speed_set() {
735        // Locomotion's `Twist` is already a velocity command, naturally smooth - `speed` is a
736        // joints-mode-only concept.
737        assert!(!wants_speed_limited_stepping(Some(0.5), &ControllerMode::Locomotion));
738    }
739
740    #[test]
741    fn wants_speed_limited_stepping_is_true_in_joints_mode_with_speed_set() {
742        assert!(wants_speed_limited_stepping(
743            Some(0.5),
744            &ControllerMode::Joints {
745                joint_names: vec!["pan".to_string()]
746            }
747        ));
748    }
749
750    #[test]
751    fn step_joint_position_moves_toward_target_by_at_most_max_step() {
752        // (b) A single tick moves the published position by at most `speed * dt` toward the
753        // target, starting from a given simulated current-feedback value.
754        let (stepped, arrived) = step_joint_position(0.0, 1.0, 0.1, JOINT_POSITION_TOLERANCE_RAD);
755        assert!((stepped - 0.1).abs() < f32::EPSILON);
756        assert!(!arrived);
757    }
758
759    #[test]
760    fn step_joint_position_steps_toward_target_from_either_side() {
761        let (stepped, _) = step_joint_position(1.0, 0.0, 0.1, JOINT_POSITION_TOLERANCE_RAD);
762        assert!((stepped - 0.9).abs() < f32::EPSILON);
763    }
764
765    #[test]
766    fn step_joint_position_never_overshoots_the_target() {
767        // (c) Overshoot protection: stepping never publishes past the target even if
768        // `speed * dt` would exceed the remaining distance.
769        let (stepped, arrived) = step_joint_position(0.95, 1.0, 10.0, JOINT_POSITION_TOLERANCE_RAD);
770        assert_eq!(stepped, 1.0);
771        assert!(arrived);
772    }
773
774    #[test]
775    fn step_joint_position_never_overshoots_the_target_stepping_downward() {
776        let (stepped, arrived) = step_joint_position(-0.95, -1.0, 10.0, JOINT_POSITION_TOLERANCE_RAD);
777        assert_eq!(stepped, -1.0);
778        assert!(arrived);
779    }
780
781    #[test]
782    fn step_joint_position_reports_arrived_once_within_tolerance() {
783        // (d) The node reaches `Success` once the stepped position is within tolerance of the
784        // target - exercised here at the pure-function level `tick_speed_limited` builds on.
785        let (_, arrived) = step_joint_position(0.995, 1.0, 0.1, JOINT_POSITION_TOLERANCE_RAD);
786        assert!(arrived);
787
788        let (_, not_yet_arrived) = step_joint_position(0.9, 1.0, 0.0, JOINT_POSITION_TOLERANCE_RAD);
789        assert!(!not_yet_arrived);
790    }
791
792    #[test]
793    fn step_joint_position_treats_max_step_as_a_magnitude() {
794        // The caller always supplies a non-negative `speed * dt`, but guard against a negative
795        // `max_step` still working sanely (it's clamped via `.abs()`) rather than inverting the
796        // step direction.
797        let (stepped, _) = step_joint_position(0.0, 1.0, -0.1, JOINT_POSITION_TOLERANCE_RAD);
798        assert!((stepped - 0.1).abs() < f32::EPSILON);
799    }
800
801    #[test]
802    fn clamped_joint_target_passes_through_when_no_joint_limit_configured() {
803        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
804        clear_controller_env();
805
806        assert_eq!(clamped_joint_target("pan", 5.0), 5.0);
807
808        clear_controller_env();
809    }
810
811    #[test]
812    fn clamped_joint_target_clamps_to_configured_joint_limits() {
813        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
814        clear_controller_env();
815        env::set_var(
816            ACTUATOR_JOINT_LIMITS_ENV_VAR,
817            r#"[{"joint_id":"pan","min_angle_rad":-1.0,"max_angle_rad":1.0}]"#,
818        );
819
820        assert_eq!(clamped_joint_target("pan", 1000.0), 1.0);
821        assert_eq!(clamped_joint_target("pan", -1000.0), -1.0);
822
823        clear_controller_env();
824    }
825
826    // --- `dynamic_safety_timeout` (sentry pan-sweep truncation fix) ---
827    //
828    // The production bug this corrects: a static `duration_secs` left over from before
829    // `speed` existed cut off a slower, now-correctly-smooth sweep before it reached the real
830    // joint extremes. These tests exercise the dynamic-extension logic directly, the same
831    // `Context`-free pattern the rest of this module's pure-function tests use.
832
833    #[test]
834    fn dynamic_safety_timeout_extends_a_too_short_static_duration_when_the_real_range_needs_more_time() {
835        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
836        clear_controller_env();
837        // A full sweep across this range at this speed takes 10s minimum - well past the
838        // leftover 3s static `duration_secs` that caused the production truncation.
839        env::set_var(
840            ACTUATOR_JOINT_LIMITS_ENV_VAR,
841            r#"[{"joint_id":"pan","min_angle_rad":-5.0,"max_angle_rad":5.0}]"#,
842        );
843
844        let effective = dynamic_safety_timeout(&["pan".to_string()], 1.0, Duration::from_secs_f64(3.0));
845
846        // required_secs = 10.0 / 1.0 = 10.0, * 1.5 margin = 15.0 - well above the static 3.0.
847        assert_eq!(effective, Duration::from_secs_f64(15.0));
848
849        clear_controller_env();
850    }
851
852    #[test]
853    fn dynamic_safety_timeout_keeps_the_static_duration_when_it_already_exceeds_the_dynamic_requirement() {
854        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
855        clear_controller_env();
856        env::set_var(
857            ACTUATOR_JOINT_LIMITS_ENV_VAR,
858            r#"[{"joint_id":"pan","min_angle_rad":-1.0,"max_angle_rad":1.0}]"#,
859        );
860
861        // required_secs = 2.0 / 1.0 = 2.0, * 1.5 margin = 3.0 - the generous static duration
862        // (60s) stays in effect since it's already larger.
863        let effective = dynamic_safety_timeout(&["pan".to_string()], 1.0, Duration::from_secs_f64(60.0));
864
865        assert_eq!(effective, Duration::from_secs_f64(60.0));
866
867        clear_controller_env();
868    }
869
870    #[test]
871    fn dynamic_safety_timeout_takes_the_slower_of_two_driven_joints() {
872        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
873        clear_controller_env();
874        env::set_var(
875            ACTUATOR_JOINT_LIMITS_ENV_VAR,
876            r#"[{"joint_id":"pan","min_angle_rad":-1.0,"max_angle_rad":1.0},{"joint_id":"tilt","min_angle_rad":-5.0,"max_angle_rad":5.0}]"#,
877        );
878
879        // pan: 2.0 / 1.0 = 2.0s, tilt: 10.0 / 1.0 = 10.0s - the slower joint (tilt) decides.
880        let effective = dynamic_safety_timeout(
881            &["pan".to_string(), "tilt".to_string()],
882            1.0,
883            Duration::from_secs_f64(0.1),
884        );
885
886        assert_eq!(effective, Duration::from_secs_f64(15.0));
887
888        clear_controller_env();
889    }
890
891    #[test]
892    fn dynamic_safety_timeout_falls_back_to_static_when_speed_is_zero() {
893        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
894        clear_controller_env();
895        env::set_var(
896            ACTUATOR_JOINT_LIMITS_ENV_VAR,
897            r#"[{"joint_id":"pan","min_angle_rad":-5.0,"max_angle_rad":5.0}]"#,
898        );
899
900        let effective = dynamic_safety_timeout(&["pan".to_string()], 0.0, Duration::from_secs_f64(3.0));
901
902        assert_eq!(effective, Duration::from_secs_f64(3.0));
903
904        clear_controller_env();
905    }
906
907    #[test]
908    fn dynamic_safety_timeout_falls_back_to_static_when_no_joint_limits_are_resolvable() {
909        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
910        clear_controller_env();
911        // No `ACTUATOR_JOINT_LIMITS_ENV_VAR` configured at all - nothing to compute a dynamic
912        // requirement from, so this must not crash/panic and must preserve the static value.
913        let effective = dynamic_safety_timeout(&["pan".to_string()], 1.0, Duration::from_secs_f64(3.0));
914
915        assert_eq!(effective, Duration::from_secs_f64(3.0));
916
917        clear_controller_env();
918    }
919
920    #[tokio::test]
921    #[ignore = "Requires Redis infrastructure"]
922    async fn move_node_without_speed_publishes_the_static_target_and_succeeds_on_duration() {
923        // (a) Regression, at the full `tick()` level: with no `speed` configured, behavior is
924        // unchanged from before this feature existed - static publish every tick, `Success`
925        // only once `duration_secs` elapses. Locomotion mode needs no joint feedback at all,
926        // so this exercises the real `tick()` without requiring a live Redis-backed `Context`.
927        //
928        // Clears the controller env vars (so this runs in the `Locomotion` default) and drops
929        // the guard immediately rather than holding it across the `await`s below - clippy
930        // (`await_holding_lock`) flags a `MutexGuard` held across an await point, and this test
931        // is `#[ignore]`d/run serially via Redis-gated CI anyway, not concurrently with the
932        // env-mutating `resolve_movement_output_*` tests above.
933        {
934            let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
935            clear_controller_env();
936        }
937
938        let mut node = MoveNode::from_json(serde_json::json!({
939            "linear": 1.0,
940            "angular": 0.5,
941            "duration_secs": 0.05
942        }))
943        .unwrap();
944
945        let ctx = crate::test_helpers::create_test_context("move_node_without_speed").await;
946        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
947
948        tokio::time::sleep(Duration::from_millis(80)).await;
949        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Success);
950
951        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
952        clear_controller_env();
953    }
954}