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//!
13//! [`MoveNode::tick_speed_limited`]'s real-feedback-driven joint stepping tracks each driven
14//! joint's last-known position independently (merging incoming feedback per-joint rather than
15//! wholesale-replacing it) and publishes partial progress for whichever joint(s) have known
16//! feedback this tick, rather than discarding an entire tick's already-computed progress just
17//! because one joint's position isn't yet resolvable - see that function's doc comment for the
18//! production bug this corrects.
19
20use crate::{BehaviorNode, Context, NodeStatus, TreeStatusSnapshot};
21use async_trait::async_trait;
22use mecha10_core::actuator::Twist;
23use mecha10_core::config::{controller_mode, generate_joint_command, joint_limits, ControllerMode};
24use mecha10_core::messages::JointState;
25use mecha10_core::topics::actuator as actuator_topics;
26use mecha10_core::topics::sensor as sensor_topics;
27use mecha10_core::Receiver;
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30use std::collections::HashMap;
31use std::time::{Duration, Instant};
32
33use super::elapsed_timer::{reset_timer_field, ElapsedTimer};
34
35/// The locomotion topic `wander`/`move`/`stop` configs default to when the behavior author
36/// doesn't override `topic` - matches `navigation.rs`'s `FollowPathNode::default_motor_topic`
37/// literal, the project-wide convention for "the" drivetrain command topic.
38fn default_motor_topic() -> String {
39    "/motor/cmd_vel".to_string()
40}
41
42/// What a `wander`/`move`/`stop` node actually publishes this tick, resolved by
43/// [`resolve_movement_output`].
44#[derive(Debug)]
45enum MovementOutput {
46    /// Locomotion-capable project: a whole-robot velocity [`Twist`], published to `topic`
47    /// (today's exact behavior, unchanged).
48    Twist { topic: String, twist: Twist },
49    /// Joints-only project (e.g. pan-tilt): a per-joint [`JointState`], published to `topic`.
50    Joints { topic: String, joint_state: JointState },
51}
52
53/// Resolve what a movement action node should publish for a desired `linear`/`angular`
54/// velocity, given the behavior author's `configured_topic`.
55///
56/// - **Locomotion** (the default, and today's only behavior): builds a [`Twist`] exactly as
57///   before, published to `configured_topic` unchanged - existing behavior JSONs for
58///   locomotion robots keep working with no changes required.
59/// - **Joints**: `linear`/`angular` map onto the first/second entry of the project's declared
60///   joint names (clamped via [`generate_joint_command`]/joint limits), the same convention
61///   `mecha10-nodes`' `rl` controller's `build_output_command` uses. The output topic defaults
62///   to the project-wide joint command topic (`/actuator/joint_cmd`, same as
63///   `teleop`/`rl`/`pan-tilt`) *unless* the author explicitly set `topic` to something other
64///   than [`default_motor_topic`] - in that case we trust the author's explicit choice and keep
65///   using it verbatim, on the theory that an author who bothered to override the topic away
66///   from the locomotion default already picked one that matches whatever this project
67///   actually publishes/subscribes to. This avoids the node silently publishing a `JointState`
68///   to a topic the author pointed at a `Twist` subscriber (or vice versa) while still letting
69///   an author who genuinely wants a non-default joint topic have one.
70fn resolve_movement_output(configured_topic: &str, linear: f64, angular: f64) -> MovementOutput {
71    match controller_mode() {
72        ControllerMode::Locomotion => MovementOutput::Twist {
73            topic: configured_topic.to_string(),
74            twist: Twist {
75                linear: linear as f32,
76                angular: angular as f32,
77            },
78        },
79        ControllerMode::Joints { joint_names } => MovementOutput::Joints {
80            topic: joint_command_topic(configured_topic),
81            joint_state: joint_command(&joint_names, linear as f32, angular as f32),
82        },
83    }
84}
85
86/// Resolve the topic a joints-mode `JointState` should publish to: the project-wide joint
87/// command topic (`/actuator/joint_cmd`) when the behavior author left `topic` at its
88/// locomotion default, or the author's explicit override verbatim otherwise. Shared by
89/// [`resolve_movement_output`] and [`MoveNode`]'s speed-limited stepping path so both pick the
90/// same topic for the same configured `topic` value.
91fn joint_command_topic(configured_topic: &str) -> String {
92    if configured_topic == default_motor_topic() {
93        actuator_topics::JOINT_CMD.path().to_string()
94    } else {
95        configured_topic.to_string()
96    }
97}
98
99/// Build a [`JointState`] driving the first named joint to `linear` and the second to
100/// `angular` (each clamped via [`generate_joint_command`]/the project's configured joint
101/// limits) - the same linear-first/angular-second convention `mecha10-nodes`' `rl` controller
102/// uses. If fewer than two joint names are configured, only the available ones are driven;
103/// this never fails, it just produces a shorter `JointState`.
104fn joint_command(joint_names: &[String], linear: f32, angular: f32) -> JointState {
105    let mut names = Vec::new();
106    let mut positions = Vec::new();
107
108    if let Some(name) = joint_names.first() {
109        let cmd = generate_joint_command(name, linear);
110        names.extend(cmd.names);
111        positions.extend(cmd.positions);
112    }
113    if let Some(name) = joint_names.get(1) {
114        let cmd = generate_joint_command(name, angular);
115        names.extend(cmd.names);
116        positions.extend(cmd.positions);
117    }
118
119    JointState::with_positions(names, positions)
120}
121
122/// Publish `linear`/`angular` to whatever [`resolve_movement_output`] resolves to for this
123/// project's [`controller_mode`].
124async fn publish_movement(ctx: &Context, configured_topic: &str, linear: f64, angular: f64) -> anyhow::Result<()> {
125    match resolve_movement_output(configured_topic, linear, angular) {
126        MovementOutput::Twist { topic, twist } => {
127            ctx.publish_to_path(&topic, &twist).await?;
128        }
129        MovementOutput::Joints { topic, joint_state } => {
130            ctx.publish_to_path(&topic, &joint_state).await?;
131        }
132    }
133    Ok(())
134}
135
136#[derive(Debug)]
137pub struct WanderNode {
138    topic: String,
139    linear_speed: f64,
140    angular_speed: f64,
141    change_interval: Duration,
142    last_change: Option<Instant>,
143    current_angular: f64,
144    last_status: Option<NodeStatus>,
145}
146
147#[derive(Debug, Serialize, Deserialize)]
148struct WanderConfig {
149    #[serde(default = "default_motor_topic")]
150    topic: String,
151    #[serde(default = "default_linear_speed")]
152    linear_speed: f64,
153    #[serde(default = "default_angular_speed")]
154    angular_speed: f64,
155    #[serde(default = "default_change_interval")]
156    change_interval_secs: f64,
157}
158
159fn default_linear_speed() -> f64 {
160    0.3
161}
162
163fn default_angular_speed() -> f64 {
164    0.5
165}
166
167fn default_change_interval() -> f64 {
168    3.0
169}
170
171impl WanderNode {
172    /// Create a new WanderNode from JSON configuration
173    pub fn from_json(config: Value) -> anyhow::Result<Self> {
174        let cfg: WanderConfig = serde_json::from_value(config)?;
175        Ok(Self {
176            topic: cfg.topic,
177            linear_speed: cfg.linear_speed,
178            angular_speed: cfg.angular_speed,
179            change_interval: Duration::from_secs_f64(cfg.change_interval_secs),
180            last_change: None,
181            current_angular: 0.0,
182            last_status: None,
183        })
184    }
185}
186
187#[async_trait]
188impl BehaviorNode for WanderNode {
189    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
190        let now = Instant::now();
191
192        if self.last_change.is_none() || now.duration_since(self.last_change.unwrap()) >= self.change_interval {
193            self.current_angular = (rand::random::<f64>() * 2.0 - 1.0) * self.angular_speed;
194            self.last_change = Some(now);
195        }
196
197        publish_movement(ctx, &self.topic, self.linear_speed, self.current_angular).await?;
198        self.last_status = Some(NodeStatus::Running);
199        Ok(NodeStatus::Running)
200    }
201
202    fn name(&self) -> &str {
203        "WanderNode"
204    }
205
206    fn status_snapshot(&self) -> TreeStatusSnapshot {
207        TreeStatusSnapshot {
208            name: self.name().to_string(),
209            status: self.last_status,
210            active_capability: self.active_capability().map(String::from),
211            children: Vec::new(),
212        }
213    }
214}
215
216/// Within this many radians of `target`, a `speed`-limited joint is considered "arrived" for
217/// [`MoveNode`]'s completion check - real joint feedback never settles to an exact float, so an
218/// exact-equality check would never report `Success`.
219const JOINT_POSITION_TOLERANCE_RAD: f32 = 0.01;
220
221#[derive(Default)]
222pub struct MoveNode {
223    topic: String,
224    linear: f64,
225    angular: f64,
226    duration: Option<Duration>,
227    /// Maximum rate of change, in rad/s, [`MoveNode::tick_speed_limited`] steps each joint's
228    /// published position by - unset means "unchanged": publish the static `linear`/`angular`
229    /// target every tick, exactly as before this field existed (see
230    /// [`wants_speed_limited_stepping`]).
231    speed: Option<f64>,
232    timer: ElapsedTimer,
233    /// Subscribed lazily on the first speed-limited tick, same pattern as `SensorCheckNode`'s
234    /// `receiver`/`GoToLocationNode`'s `odometry_receiver`.
235    joint_feedback: Option<Receiver<JointState>>,
236    /// Last-known real position of each joint we've ever received feedback for, keyed by joint
237    /// name - merged in (not wholesale-replaced) as each [`JointState`] message arrives on
238    /// [`sensor_topics::JOINT_STATES`], since a project may publish feedback for different
239    /// joints in separate single-joint messages (e.g. two standalone `servo` nodes) rather than
240    /// one combined message (e.g. `pan-tilt`). A later message naming only joint B must not
241    /// erase the last-known position of joint A - see [`MoveNode::tick_speed_limited`].
242    latest_joint_positions: HashMap<String, f32>,
243    /// Wall-clock time of the last speed-limited tick, used to compute `dt` for `speed * dt`
244    /// stepping - same pattern as `GoToLocationNode::last_tick`.
245    last_step_tick: Option<Instant>,
246    last_status: Option<NodeStatus>,
247}
248
249impl std::fmt::Debug for MoveNode {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        f.debug_struct("MoveNode")
252            .field("topic", &self.topic)
253            .field("linear", &self.linear)
254            .field("angular", &self.angular)
255            .field("duration", &self.duration)
256            .field("speed", &self.speed)
257            .field("timer", &self.timer)
258            .field("joint_feedback_subscribed", &self.joint_feedback.is_some())
259            .field("latest_joint_positions", &self.latest_joint_positions)
260            .field("last_step_tick", &self.last_step_tick)
261            .field("last_status", &self.last_status)
262            .finish()
263    }
264}
265
266#[derive(Debug, Serialize, Deserialize)]
267struct MoveConfig {
268    #[serde(default = "default_motor_topic")]
269    topic: String,
270    linear: f64,
271    angular: f64,
272    duration_secs: Option<f64>,
273    speed: Option<f64>,
274}
275
276impl MoveNode {
277    /// Create a new MoveNode from JSON configuration
278    pub fn from_json(config: Value) -> anyhow::Result<Self> {
279        let cfg: MoveConfig = serde_json::from_value(config)?;
280        Ok(Self {
281            topic: cfg.topic,
282            linear: cfg.linear,
283            angular: cfg.angular,
284            duration: cfg.duration_secs.map(Duration::from_secs_f64),
285            speed: cfg.speed,
286            timer: ElapsedTimer::default(),
287            joint_feedback: None,
288            latest_joint_positions: HashMap::new(),
289            last_step_tick: None,
290            last_status: None,
291        })
292    }
293
294    /// Speed-limited, real-feedback-driven joint stepping (see module-level design notes above
295    /// [`resolve_movement_output`]): step each configured joint's published position toward its
296    /// clamped `linear`/`angular` target by at most `speed * dt` this tick, using the latest
297    /// known *real* joint position (from [`sensor_topics::JOINT_STATES`]) as the starting point
298    /// rather than re-publishing the static target. Reaches `Success` once every configured
299    /// joint's real position is within [`JOINT_POSITION_TOLERANCE_RAD`] of its target.
300    ///
301    /// Two subtleties this is careful to get right, both production bugs in earlier versions of
302    /// this function (a sentry's pan sweep creeping far slower than its configured `speed` and
303    /// never visibly completing a full sweep leg):
304    ///
305    /// - **Feedback is merged per-joint, never wholesale-replaced.** A project's joint feedback
306    ///   may arrive as separate single-joint messages on [`sensor_topics::JOINT_STATES`] (e.g.
307    ///   two standalone `servo` node instances, one per joint) rather than one combined message
308    ///   naming every driven joint (e.g. `pan-tilt`). [`Self::latest_joint_positions`] tracks the
309    ///   last-known position of each joint independently, so a message updating joint B's
310    ///   position never discards the still-valid, previously-known position of joint A.
311    /// - **A joint whose position isn't yet known never blocks the OTHER joint(s)' progress.**
312    ///   Only the "no feedback at all has ever arrived" cold-start case holds the whole tick
313    ///   (nothing to step from for any joint, so nothing is published). Once at least one joint
314    ///   has real feedback, every tick steps and publishes whichever driven joint(s) currently
315    ///   have a known position, even if another driven joint's position is transiently (or
316    ///   permanently, in a misconfigured project) unknown - that joint is simply held back from
317    ///   `Success` (see `all_arrived` below), it never causes already-computed progress on a
318    ///   different joint to be thrown away by skipping the publish entirely.
319    async fn tick_speed_limited(
320        &mut self,
321        ctx: &Context,
322        joint_names: &[String],
323        speed: f64,
324    ) -> anyhow::Result<NodeStatus> {
325        if self.joint_feedback.is_none() {
326            self.joint_feedback = Some(ctx.subscribe(sensor_topics::JOINT_STATES).await?);
327        }
328        while let Ok(state) = self.joint_feedback.as_mut().expect("just subscribed above").try_recv() {
329            merge_joint_feedback(&mut self.latest_joint_positions, &state);
330        }
331
332        if self.latest_joint_positions.is_empty() {
333            // No real feedback has arrived yet, for any joint. Stepping from an assumed starting
334            // position (e.g. 0.0) could snap the servo just as badly as the bug this feature
335            // fixes - hold instead, the same "wait for the first reading" precedent
336            // `SensorCheckNode`/`GoToLocationNode` already establish for a required feedback
337            // topic that hasn't produced a message yet.
338            tracing::debug!(
339                "MoveNode: waiting for first {} reading before speed-limited joint stepping",
340                sensor_topics::JOINT_STATES.path()
341            );
342            self.last_status = Some(NodeStatus::Running);
343            return Ok(NodeStatus::Running);
344        }
345
346        let now = Instant::now();
347        let dt = self
348            .last_step_tick
349            .map(|last| now.duration_since(last).as_secs_f64())
350            .unwrap_or(0.0);
351        self.last_step_tick = Some(now);
352        let max_step = (speed * dt) as f32;
353
354        let Some((names, positions, all_arrived)) = step_configured_joints(
355            joint_names,
356            self.linear as f32,
357            self.angular as f32,
358            &self.latest_joint_positions,
359            max_step,
360        ) else {
361            // Every driven joint's position is still unknown this tick (e.g. feedback has only
362            // ever arrived for a joint this node doesn't drive) - nothing to publish.
363            self.last_status = Some(NodeStatus::Running);
364            return Ok(NodeStatus::Running);
365        };
366
367        ctx.publish_to_path(
368            &joint_command_topic(&self.topic),
369            &JointState::with_positions(names, positions),
370        )
371        .await?;
372
373        if all_arrived {
374            self.last_status = Some(NodeStatus::Success);
375            return Ok(NodeStatus::Success);
376        }
377
378        self.last_status = Some(NodeStatus::Running);
379        Ok(NodeStatus::Running)
380    }
381}
382
383#[async_trait]
384impl BehaviorNode for MoveNode {
385    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
386        let mode = controller_mode();
387        let speed_limited = wants_speed_limited_stepping(self.speed, &mode);
388
389        // `duration_secs`, when configured, is always a hard safety timeout - even when
390        // `speed` is set and completion is otherwise driven by real-feedback tolerance below.
391        //
392        // For the speed-limited joint-stepping path, `duration_secs` alone can't be trusted as
393        // the ceiling: it's a single static number a behavior author hand-tunes for whatever
394        // joint range *their* robot happens to have, with no way to know the right value ahead
395        // of time (see module docs). So here the *effective* timeout is widened - never
396        // shortened - to `max(duration_secs, dynamic_safety_timeout)`, where the dynamic value
397        // is derived from the robot's own real `mecha10.json`-resolved joint limits and the
398        // configured `speed`. This makes `duration_secs` a floor/sanity-minimum rather than a
399        // ceiling that can silently truncate a legitimate slow sweep.
400        if let Some(duration) = self.duration {
401            let effective_duration = if speed_limited {
402                let ControllerMode::Joints { joint_names } = &mode else {
403                    unreachable!("wants_speed_limited_stepping only returns true for ControllerMode::Joints");
404                };
405                let speed = self
406                    .speed
407                    .expect("wants_speed_limited_stepping only returns true when speed is set");
408                dynamic_safety_timeout(joint_names, speed, duration)
409            } else {
410                duration
411            };
412
413            if self.timer.elapsed_since_start(effective_duration) {
414                self.last_status = Some(NodeStatus::Success);
415                return Ok(NodeStatus::Success);
416            }
417        }
418
419        if speed_limited {
420            let ControllerMode::Joints { joint_names } = mode else {
421                unreachable!("wants_speed_limited_stepping only returns true for ControllerMode::Joints");
422            };
423            let speed = self
424                .speed
425                .expect("wants_speed_limited_stepping only returns true when speed is set");
426            return self.tick_speed_limited(ctx, &joint_names, speed).await;
427        }
428
429        publish_movement(ctx, &self.topic, self.linear, self.angular).await?;
430
431        self.last_status = Some(NodeStatus::Running);
432        Ok(NodeStatus::Running)
433    }
434
435    reset_timer_field!(timer);
436
437    fn name(&self) -> &str {
438        "MoveNode"
439    }
440
441    fn status_snapshot(&self) -> TreeStatusSnapshot {
442        TreeStatusSnapshot {
443            name: self.name().to_string(),
444            status: self.last_status,
445            active_capability: self.active_capability().map(String::from),
446            children: Vec::new(),
447        }
448    }
449}
450
451/// Whether this tick should use `speed`-limited, feedback-driven joint stepping rather than
452/// publishing the static `linear`/`angular` target every tick: only once the behavior author
453/// configured a `speed` AND the project is in joints mode. Locomotion's `Twist` is already a
454/// velocity command the robot integrates into position over time - naturally smooth without any
455/// rate limiting here - so `speed` is ignored outside joints mode.
456fn wants_speed_limited_stepping(speed: Option<f64>, mode: &ControllerMode) -> bool {
457    speed.is_some() && matches!(mode, ControllerMode::Joints { .. })
458}
459
460/// Multiplier applied to the theoretical minimum travel time (`range / speed`) when deriving a
461/// speed-limited move's dynamic safety timeout (see [`dynamic_safety_timeout`]) - leaves
462/// headroom for real-world settling/overshoot-correction time beyond the theoretical minimum,
463/// rather than cutting a move off the instant it crosses the bare-minimum travel time.
464const DYNAMIC_SAFETY_MARGIN: f64 = 1.5;
465
466/// Derive the effective hard-safety-timeout [`Duration`] for a speed-limited joints-mode
467/// [`MoveNode`] move: `max(static_duration, required_secs * DYNAMIC_SAFETY_MARGIN)`, where
468/// `required_secs` is the worst-case time to cross a driven joint's *full configured range*
469/// (`max_angle_rad - min_angle_rad`) at `speed` rad/s, resolved via the same [`joint_limits`]
470/// helper [`clamped_joint_target`] already uses.
471///
472/// The full configured range (rather than the distance from wherever the joint happens to be
473/// at move-start) is the safe worst-case choice, since the real starting position isn't known
474/// yet when this safety timeout needs to be decided - the move might start at either extreme.
475/// Only the first two `joint_names` are considered (linear, then angular), matching the
476/// linear-first/angular-second convention [`MoveNode::tick_speed_limited`] steps; when more
477/// than one of those joints has a resolvable limit, the slower (larger-required-time) one
478/// wins, since the move only completes once every driven joint has arrived.
479///
480/// Falls back to `static_duration` unchanged (never *shortens* it) when `speed` is zero or
481/// negative (would divide by zero/invert direction) or when none of the driven joints have a
482/// resolvable configured limit (e.g. the project doesn't declare joint limits for this robot) -
483/// in both cases there's no real joint-range data to derive a dynamic duration from, so
484/// `duration_secs` alone decides, exactly as before this feature existed.
485fn dynamic_safety_timeout(joint_names: &[String], speed: f64, static_duration: Duration) -> Duration {
486    if speed <= 0.0 {
487        return static_duration;
488    }
489
490    let required_secs = joint_names
491        .iter()
492        .take(2)
493        .filter_map(|name| joint_limits(name))
494        .map(|limits| (limits.max_angle_rad - limits.min_angle_rad).abs() as f64 / speed)
495        .reduce(f64::max);
496
497    let Some(required_secs) = required_secs else {
498        return static_duration;
499    };
500
501    static_duration.max(Duration::from_secs_f64(required_secs * DYNAMIC_SAFETY_MARGIN))
502}
503
504/// Clamp `raw_target` to `joint_name`'s configured joint limits (if the project declares any) -
505/// the same clamp [`generate_joint_command`] applies to a joints-mode `MoveNode`'s final
506/// published target, reused here so intermediate stepped positions never aim past a joint's
507/// limits either.
508fn clamped_joint_target(joint_name: &str, raw_target: f32) -> f32 {
509    match joint_limits(joint_name) {
510        Some(limits) => raw_target.clamp(limits.min_angle_rad, limits.max_angle_rad),
511        None => raw_target,
512    }
513}
514
515/// Merge one incoming [`JointState`] feedback message into `positions` (joint name -> last-known
516/// real position), upserting only the names/positions this particular message actually carries.
517/// This is the fix for the first `MoveNode` speed-limited-stepping production bug: feedback
518/// tracking used to wholesale-replace a single `Option<JointState>` on every message, so a
519/// project whose joints report feedback via separate single-joint messages (e.g. two standalone
520/// `servo` nodes, one per joint) would have joint A's last-known position silently discarded the
521/// moment a message about joint B alone arrived - even though A's real position hadn't gone
522/// stale or changed. Merging per-joint here means a message naming only a subset of joints only
523/// ever updates that subset, never erasing anything already known about the others.
524fn merge_joint_feedback(positions: &mut HashMap<String, f32>, state: &JointState) {
525    for (name, position) in state.names.iter().zip(state.positions.iter()) {
526        positions.insert(name.clone(), *position);
527    }
528}
529
530/// Step every joint in `joint_names` (first -> `linear`, second -> `angular`, same convention as
531/// [`joint_command`]) toward its clamped target by at most `max_step`, using `known_positions` as
532/// each joint's starting point.
533///
534/// Returns `None` only when NONE of the driven joints have a known position yet (nothing at all
535/// is steppable this tick - the caller should publish nothing). Otherwise returns the names and
536/// stepped positions of whichever driven joint(s) DO have a known position this tick, plus
537/// whether every driven joint is both known AND within tolerance of its target.
538///
539/// This is the fix for the second `MoveNode` speed-limited-stepping production bug: the loop
540/// this replaces used to `return` out of the whole tick - discarding any step already computed
541/// for an earlier joint in the same iteration - the moment it hit a single joint whose position
542/// wasn't yet resolvable. Combined with the feedback-merging bug above, any gap or jitter in
543/// feedback arrival for *one* joint silently threw away real forward progress on *every* joint,
544/// every such tick. Here a joint with no known position is simply skipped (held out of the
545/// returned names/positions, and forces `all_arrived` to `false` so `Success` can't fire early)
546/// rather than aborting progress on the other joint(s).
547fn step_configured_joints(
548    joint_names: &[String],
549    linear: f32,
550    angular: f32,
551    known_positions: &HashMap<String, f32>,
552    max_step: f32,
553) -> Option<(Vec<String>, Vec<f32>, bool)> {
554    let mut names = Vec::new();
555    let mut positions = Vec::new();
556    let mut all_arrived = true;
557
558    for (name, raw_target) in [(joint_names.first(), linear), (joint_names.get(1), angular)] {
559        let Some(name) = name else { continue };
560        let Some(current) = known_positions.get(name).copied() else {
561            // This joint hasn't shown up in any feedback message yet - don't step from an
562            // assumed position, but (unlike the no-feedback-at-all cold-start case) don't abort
563            // the whole tick either: skip only this joint so whichever other joint(s) DO have
564            // known feedback still get stepped and returned below.
565            tracing::debug!(joint = %name, "MoveNode: waiting for feedback on this joint before speed-limited stepping");
566            all_arrived = false;
567            continue;
568        };
569        let target = clamped_joint_target(name, raw_target);
570        let (stepped, arrived) = step_joint_position(current, target, max_step, JOINT_POSITION_TOLERANCE_RAD);
571        names.push(name.clone());
572        positions.push(stepped);
573        all_arrived &= arrived;
574    }
575
576    if names.is_empty() {
577        return None;
578    }
579
580    Some((names, positions, all_arrived))
581}
582
583/// Step `current` toward `target` by at most `max_step.abs()` (the caller supplies `speed *
584/// dt`, always non-negative in practice), without ever overshooting `target`. Returns the
585/// stepped position and whether it's already within `tolerance` of `target`.
586fn step_joint_position(current: f32, target: f32, max_step: f32, tolerance: f32) -> (f32, bool) {
587    let max_step = max_step.abs();
588    let delta = target - current;
589    let stepped = current + delta.clamp(-max_step, max_step);
590    let arrived = (target - stepped).abs() <= tolerance;
591    (stepped, arrived)
592}
593
594#[derive(Debug)]
595pub struct StopNode {
596    topic: String,
597    last_status: Option<NodeStatus>,
598}
599
600#[derive(Debug, Serialize, Deserialize)]
601struct StopConfig {
602    #[serde(default = "default_motor_topic")]
603    topic: String,
604}
605
606impl StopNode {
607    /// Create a new StopNode from JSON configuration
608    pub fn from_json(config: Value) -> anyhow::Result<Self> {
609        let cfg: StopConfig = serde_json::from_value(config)?;
610        Ok(Self {
611            topic: cfg.topic,
612            last_status: None,
613        })
614    }
615}
616
617#[async_trait]
618impl BehaviorNode for StopNode {
619    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
620        publish_movement(ctx, &self.topic, 0.0, 0.0).await?;
621        tracing::debug!("StopNode: published stop command");
622        self.last_status = Some(NodeStatus::Success);
623        Ok(NodeStatus::Success)
624    }
625
626    fn name(&self) -> &str {
627        "StopNode"
628    }
629
630    fn status_snapshot(&self) -> TreeStatusSnapshot {
631        TreeStatusSnapshot {
632            name: self.name().to_string(),
633            status: self.last_status,
634            active_capability: self.active_capability().map(String::from),
635            children: Vec::new(),
636        }
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use mecha10_core::config::{ACTUATION_JOINT_NAMES_ENV_VAR, ACTUATOR_JOINT_LIMITS_ENV_VAR, CAPABILITIES_ENV_VAR};
644    use std::env;
645    use std::sync::Mutex;
646
647    // `controller_mode()`/`generate_joint_command()` read process-global env vars; serialize
648    // the tests that touch them so they can't observe each other's in-flight state (mirrors
649    // `mecha10_core::config`'s own test convention).
650    static CONTROLLER_ENV_LOCK: Mutex<()> = Mutex::new(());
651
652    fn clear_controller_env() {
653        env::remove_var(CAPABILITIES_ENV_VAR);
654        env::remove_var(ACTUATION_JOINT_NAMES_ENV_VAR);
655        env::remove_var(ACTUATOR_JOINT_LIMITS_ENV_VAR);
656    }
657
658    #[test]
659    fn resolve_movement_output_is_a_twist_to_the_configured_topic_in_locomotion_mode() {
660        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
661        clear_controller_env();
662
663        let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
664
665        let MovementOutput::Twist { topic, twist } = output else {
666            panic!("expected Twist output");
667        };
668        assert_eq!(topic, "/motor/cmd_vel");
669        assert_eq!(twist.linear, 1.0);
670        assert_eq!(twist.angular, 0.5);
671
672        clear_controller_env();
673    }
674
675    #[test]
676    fn resolve_movement_output_ignores_declared_joints_when_locomotion_also_declared() {
677        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
678        clear_controller_env();
679        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints","locomotion"]"#);
680        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
681
682        let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
683
684        let MovementOutput::Twist { topic, twist } = output else {
685            panic!("expected Twist output");
686        };
687        assert_eq!(topic, "/motor/cmd_vel");
688        assert_eq!(twist.linear, 1.0);
689        assert_eq!(twist.angular, 0.5);
690
691        clear_controller_env();
692    }
693
694    #[test]
695    fn resolve_movement_output_is_a_joint_state_to_the_default_joint_topic_in_joints_mode() {
696        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
697        clear_controller_env();
698        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
699        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
700
701        // The author left `topic` at its locomotion default - we should redirect to the
702        // project-wide joint command topic rather than publish a JointState to "/motor/cmd_vel".
703        let output = resolve_movement_output(&default_motor_topic(), 0.3, -0.2);
704
705        let MovementOutput::Joints { topic, joint_state } = output else {
706            panic!("expected Joints output");
707        };
708        assert_eq!(topic, actuator_topics::JOINT_CMD.path());
709        assert_eq!(joint_state.names, vec!["pan".to_string(), "tilt".to_string()]);
710        assert_eq!(joint_state.positions, vec![0.3, -0.2]);
711
712        clear_controller_env();
713    }
714
715    #[test]
716    fn resolve_movement_output_keeps_an_explicitly_overridden_topic_in_joints_mode() {
717        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
718        clear_controller_env();
719        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
720        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
721
722        let output = resolve_movement_output("/custom/joint_topic", 0.3, -0.2);
723
724        let MovementOutput::Joints { topic, .. } = output else {
725            panic!("expected Joints output");
726        };
727        assert_eq!(topic, "/custom/joint_topic");
728
729        clear_controller_env();
730    }
731
732    #[test]
733    fn resolve_movement_output_clamps_to_configured_joint_limits_in_joints_mode() {
734        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
735        clear_controller_env();
736        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
737        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan"]"#);
738        env::set_var(
739            ACTUATOR_JOINT_LIMITS_ENV_VAR,
740            r#"[{"joint_id":"pan","min_angle_rad":-1.0,"max_angle_rad":1.0}]"#,
741        );
742
743        let output = resolve_movement_output(&default_motor_topic(), 5.0, 0.0);
744
745        let MovementOutput::Joints { joint_state, .. } = output else {
746            panic!("expected Joints output");
747        };
748        assert_eq!(joint_state.names, vec!["pan".to_string()]);
749        assert_eq!(joint_state.positions, vec![1.0]);
750
751        clear_controller_env();
752    }
753
754    #[test]
755    fn resolve_movement_output_drives_only_the_available_joint_when_fewer_than_two_are_configured() {
756        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
757        clear_controller_env();
758        env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
759        env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan"]"#);
760
761        let output = resolve_movement_output(&default_motor_topic(), 0.7, -0.4);
762
763        let MovementOutput::Joints { joint_state, .. } = output else {
764            panic!("expected Joints output");
765        };
766        assert_eq!(joint_state.names, vec!["pan".to_string()]);
767        assert_eq!(joint_state.positions, vec![0.7]);
768
769        clear_controller_env();
770    }
771
772    #[test]
773    fn wander_node_from_json_defaults_topic_to_the_locomotion_default() {
774        let node = WanderNode::from_json(serde_json::json!({})).unwrap();
775        assert_eq!(node.topic, default_motor_topic());
776    }
777
778    #[test]
779    fn move_node_from_json_defaults_topic_to_the_locomotion_default() {
780        let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0 })).unwrap();
781        assert_eq!(node.topic, default_motor_topic());
782    }
783
784    #[test]
785    fn move_node_from_json_defaults_speed_to_unset() {
786        let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0 })).unwrap();
787        assert_eq!(node.speed, None);
788    }
789
790    #[test]
791    fn move_node_from_json_reads_speed_when_set() {
792        let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0, "speed": 0.5 })).unwrap();
793        assert_eq!(node.speed, Some(0.5));
794    }
795
796    #[test]
797    fn stop_node_from_json_defaults_topic_to_the_locomotion_default() {
798        let node = StopNode::from_json(serde_json::json!({})).unwrap();
799        assert_eq!(node.topic, default_motor_topic());
800    }
801
802    // --- `speed`-limited joint stepping (LAB sentry pan-sweep jitter fix) ---
803    //
804    // `wants_speed_limited_stepping`/`step_joint_position`/`clamped_joint_target` are plain,
805    // `Context`-free functions precisely so the velocity-limiting math can be unit tested
806    // without needing a live `Context`/Redis - the same reason `GoToLocationNode` factors
807    // `compute_control`/`world_to_anchor_frame` etc. out of its `tick()` instead of testing
808    // through a real `Context`.
809
810    #[test]
811    fn wants_speed_limited_stepping_is_false_when_speed_is_unset_regardless_of_mode() {
812        // (a) Regression: with no `speed` configured, `MoveNode` must never take the
813        // speed-limited path - `tick()` always falls through to the unchanged
814        // static-publish/duration-based-success behavior, in both controller modes.
815        assert!(!wants_speed_limited_stepping(None, &ControllerMode::Locomotion));
816        assert!(!wants_speed_limited_stepping(
817            None,
818            &ControllerMode::Joints {
819                joint_names: vec!["pan".to_string()]
820            }
821        ));
822    }
823
824    #[test]
825    fn wants_speed_limited_stepping_is_false_in_locomotion_mode_even_with_speed_set() {
826        // Locomotion's `Twist` is already a velocity command, naturally smooth - `speed` is a
827        // joints-mode-only concept.
828        assert!(!wants_speed_limited_stepping(Some(0.5), &ControllerMode::Locomotion));
829    }
830
831    #[test]
832    fn wants_speed_limited_stepping_is_true_in_joints_mode_with_speed_set() {
833        assert!(wants_speed_limited_stepping(
834            Some(0.5),
835            &ControllerMode::Joints {
836                joint_names: vec!["pan".to_string()]
837            }
838        ));
839    }
840
841    #[test]
842    fn step_joint_position_moves_toward_target_by_at_most_max_step() {
843        // (b) A single tick moves the published position by at most `speed * dt` toward the
844        // target, starting from a given simulated current-feedback value.
845        let (stepped, arrived) = step_joint_position(0.0, 1.0, 0.1, JOINT_POSITION_TOLERANCE_RAD);
846        assert!((stepped - 0.1).abs() < f32::EPSILON);
847        assert!(!arrived);
848    }
849
850    #[test]
851    fn step_joint_position_steps_toward_target_from_either_side() {
852        let (stepped, _) = step_joint_position(1.0, 0.0, 0.1, JOINT_POSITION_TOLERANCE_RAD);
853        assert!((stepped - 0.9).abs() < f32::EPSILON);
854    }
855
856    #[test]
857    fn step_joint_position_never_overshoots_the_target() {
858        // (c) Overshoot protection: stepping never publishes past the target even if
859        // `speed * dt` would exceed the remaining distance.
860        let (stepped, arrived) = step_joint_position(0.95, 1.0, 10.0, JOINT_POSITION_TOLERANCE_RAD);
861        assert_eq!(stepped, 1.0);
862        assert!(arrived);
863    }
864
865    #[test]
866    fn step_joint_position_never_overshoots_the_target_stepping_downward() {
867        let (stepped, arrived) = step_joint_position(-0.95, -1.0, 10.0, JOINT_POSITION_TOLERANCE_RAD);
868        assert_eq!(stepped, -1.0);
869        assert!(arrived);
870    }
871
872    #[test]
873    fn step_joint_position_reports_arrived_once_within_tolerance() {
874        // (d) The node reaches `Success` once the stepped position is within tolerance of the
875        // target - exercised here at the pure-function level `tick_speed_limited` builds on.
876        let (_, arrived) = step_joint_position(0.995, 1.0, 0.1, JOINT_POSITION_TOLERANCE_RAD);
877        assert!(arrived);
878
879        let (_, not_yet_arrived) = step_joint_position(0.9, 1.0, 0.0, JOINT_POSITION_TOLERANCE_RAD);
880        assert!(!not_yet_arrived);
881    }
882
883    #[test]
884    fn step_joint_position_treats_max_step_as_a_magnitude() {
885        // The caller always supplies a non-negative `speed * dt`, but guard against a negative
886        // `max_step` still working sanely (it's clamped via `.abs()`) rather than inverting the
887        // step direction.
888        let (stepped, _) = step_joint_position(0.0, 1.0, -0.1, JOINT_POSITION_TOLERANCE_RAD);
889        assert!((stepped - 0.1).abs() < f32::EPSILON);
890    }
891
892    // --- `merge_joint_feedback`/`step_configured_joints` (sentry pan-sweep creep fix) ---
893    //
894    // The production bug: `latest_joint_state` used to be a single `Option<JointState>`
895    // wholesale-replaced on every incoming message, and the per-joint stepping loop used to
896    // `return` out of the *entire* tick (discarding any already-computed step for a different
897    // joint) the moment one joint's position wasn't yet known. Together, any gap/jitter in
898    // feedback for *one* joint silently threw away real progress on *every* joint, every such
899    // tick - consistent with a pan sweep creeping far slower than its configured `speed` and
900    // never completing a full sweep leg. These tests exercise the corrected merge-not-replace +
901    // partial-progress-not-discarded logic directly, the same `Context`-free pattern the rest of
902    // this module's pure-function tests use.
903
904    #[test]
905    fn merge_joint_feedback_tracks_two_joints_fed_by_separate_single_joint_messages() {
906        // Simulates a project where two standalone `servo` nodes each publish their own joint's
907        // feedback independently, rather than one combined `pan-tilt`-style message.
908        let mut positions = HashMap::new();
909
910        merge_joint_feedback(
911            &mut positions,
912            &JointState::with_positions(vec!["pan".to_string()], vec![0.2]),
913        );
914        merge_joint_feedback(
915            &mut positions,
916            &JointState::with_positions(vec!["tilt".to_string()], vec![-0.4]),
917        );
918
919        assert_eq!(positions.get("pan").copied(), Some(0.2));
920        assert_eq!(positions.get("tilt").copied(), Some(-0.4));
921    }
922
923    #[test]
924    fn merge_joint_feedback_does_not_erase_other_joints_when_one_joint_updates() {
925        // The core regression: a later message naming only joint B must not discard the
926        // still-valid, previously-known position of joint A.
927        let mut positions = HashMap::new();
928        positions.insert("pan".to_string(), 0.2);
929
930        merge_joint_feedback(
931            &mut positions,
932            &JointState::with_positions(vec!["tilt".to_string()], vec![-0.4]),
933        );
934
935        assert_eq!(positions.get("pan").copied(), Some(0.2));
936        assert_eq!(positions.get("tilt").copied(), Some(-0.4));
937    }
938
939    #[test]
940    fn merge_joint_feedback_still_works_for_one_combined_message_naming_both_joints() {
941        // Existing behavior preserved: a single combined message (the `pan-tilt` node's own
942        // publish shape) updates both joints from one call, exactly as before.
943        let mut positions = HashMap::new();
944
945        merge_joint_feedback(
946            &mut positions,
947            &JointState::with_positions(vec!["pan".to_string(), "tilt".to_string()], vec![0.1, 0.3]),
948        );
949
950        assert_eq!(positions.get("pan").copied(), Some(0.1));
951        assert_eq!(positions.get("tilt").copied(), Some(0.3));
952    }
953
954    #[test]
955    fn merge_joint_feedback_updates_a_joint_to_its_latest_reported_position() {
956        let mut positions = HashMap::new();
957        positions.insert("pan".to_string(), 0.1);
958
959        merge_joint_feedback(
960            &mut positions,
961            &JointState::with_positions(vec!["pan".to_string()], vec![0.5]),
962        );
963
964        assert_eq!(positions.get("pan").copied(), Some(0.5));
965    }
966
967    #[test]
968    fn step_configured_joints_steps_both_joints_when_both_positions_are_known() {
969        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
970        clear_controller_env();
971
972        let mut known = HashMap::new();
973        known.insert("pan".to_string(), 0.0);
974        known.insert("tilt".to_string(), 0.0);
975        let joint_names = vec!["pan".to_string(), "tilt".to_string()];
976
977        let (names, positions, all_arrived) =
978            step_configured_joints(&joint_names, 1.0, 1.0, &known, 0.1).expect("both joints known - must step");
979
980        assert_eq!(names, vec!["pan".to_string(), "tilt".to_string()]);
981        assert_eq!(positions, vec![0.1, 0.1]);
982        assert!(!all_arrived);
983
984        clear_controller_env();
985    }
986
987    #[test]
988    fn step_configured_joints_still_steps_and_publishes_the_known_joint_when_the_other_is_missing() {
989        // The second bug, isolated: `tilt`'s position is unknown this tick (e.g. its feedback
990        // hasn't arrived yet, or momentarily dropped out), but `pan`'s is known - `pan` must
991        // still get a real computed step back, not be dropped because of `tilt`.
992        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
993        clear_controller_env();
994
995        let mut known = HashMap::new();
996        known.insert("pan".to_string(), 0.0);
997        let joint_names = vec!["pan".to_string(), "tilt".to_string()];
998
999        let (names, positions, all_arrived) =
1000            step_configured_joints(&joint_names, 1.0, 1.0, &known, 0.1).expect("pan is known - must still step it");
1001
1002        assert_eq!(names, vec!["pan".to_string()]);
1003        assert_eq!(positions, vec![0.1]);
1004        // `tilt` is still unknown, so the move as a whole must not report arrived.
1005        assert!(!all_arrived);
1006
1007        clear_controller_env();
1008    }
1009
1010    #[test]
1011    fn step_configured_joints_does_not_report_all_arrived_when_a_driven_joint_is_still_unknown() {
1012        // Even if the known joint(s) are already exactly at target, a still-unknown driven
1013        // joint must hold `all_arrived` false - a transiently-missing joint must never let
1014        // `Success` fire early.
1015        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1016        clear_controller_env();
1017
1018        let mut known = HashMap::new();
1019        known.insert("pan".to_string(), 1.0);
1020        let joint_names = vec!["pan".to_string(), "tilt".to_string()];
1021
1022        let (_, _, all_arrived) = step_configured_joints(&joint_names, 1.0, 1.0, &known, 10.0).unwrap();
1023
1024        assert!(!all_arrived);
1025
1026        clear_controller_env();
1027    }
1028
1029    #[test]
1030    fn step_configured_joints_reports_all_arrived_once_every_driven_joint_is_known_and_within_tolerance() {
1031        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1032        clear_controller_env();
1033
1034        let mut known = HashMap::new();
1035        known.insert("pan".to_string(), 1.0);
1036        known.insert("tilt".to_string(), 1.0);
1037        let joint_names = vec!["pan".to_string(), "tilt".to_string()];
1038
1039        let (_, _, all_arrived) = step_configured_joints(&joint_names, 1.0, 1.0, &known, 10.0).unwrap();
1040
1041        assert!(all_arrived);
1042
1043        clear_controller_env();
1044    }
1045
1046    #[test]
1047    fn step_configured_joints_returns_none_when_no_driven_joint_position_is_known() {
1048        // Nothing at all is steppable this tick - the caller must publish nothing, rather than
1049        // publish an empty `JointState`.
1050        let known = HashMap::new();
1051        let joint_names = vec!["pan".to_string(), "tilt".to_string()];
1052
1053        assert!(step_configured_joints(&joint_names, 1.0, 1.0, &known, 0.1).is_none());
1054    }
1055
1056    #[test]
1057    fn clamped_joint_target_passes_through_when_no_joint_limit_configured() {
1058        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1059        clear_controller_env();
1060
1061        assert_eq!(clamped_joint_target("pan", 5.0), 5.0);
1062
1063        clear_controller_env();
1064    }
1065
1066    #[test]
1067    fn clamped_joint_target_clamps_to_configured_joint_limits() {
1068        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1069        clear_controller_env();
1070        env::set_var(
1071            ACTUATOR_JOINT_LIMITS_ENV_VAR,
1072            r#"[{"joint_id":"pan","min_angle_rad":-1.0,"max_angle_rad":1.0}]"#,
1073        );
1074
1075        assert_eq!(clamped_joint_target("pan", 1000.0), 1.0);
1076        assert_eq!(clamped_joint_target("pan", -1000.0), -1.0);
1077
1078        clear_controller_env();
1079    }
1080
1081    // --- `dynamic_safety_timeout` (sentry pan-sweep truncation fix) ---
1082    //
1083    // The production bug this corrects: a static `duration_secs` left over from before
1084    // `speed` existed cut off a slower, now-correctly-smooth sweep before it reached the real
1085    // joint extremes. These tests exercise the dynamic-extension logic directly, the same
1086    // `Context`-free pattern the rest of this module's pure-function tests use.
1087
1088    #[test]
1089    fn dynamic_safety_timeout_extends_a_too_short_static_duration_when_the_real_range_needs_more_time() {
1090        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1091        clear_controller_env();
1092        // A full sweep across this range at this speed takes 10s minimum - well past the
1093        // leftover 3s static `duration_secs` that caused the production truncation.
1094        env::set_var(
1095            ACTUATOR_JOINT_LIMITS_ENV_VAR,
1096            r#"[{"joint_id":"pan","min_angle_rad":-5.0,"max_angle_rad":5.0}]"#,
1097        );
1098
1099        let effective = dynamic_safety_timeout(&["pan".to_string()], 1.0, Duration::from_secs_f64(3.0));
1100
1101        // required_secs = 10.0 / 1.0 = 10.0, * 1.5 margin = 15.0 - well above the static 3.0.
1102        assert_eq!(effective, Duration::from_secs_f64(15.0));
1103
1104        clear_controller_env();
1105    }
1106
1107    #[test]
1108    fn dynamic_safety_timeout_keeps_the_static_duration_when_it_already_exceeds_the_dynamic_requirement() {
1109        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1110        clear_controller_env();
1111        env::set_var(
1112            ACTUATOR_JOINT_LIMITS_ENV_VAR,
1113            r#"[{"joint_id":"pan","min_angle_rad":-1.0,"max_angle_rad":1.0}]"#,
1114        );
1115
1116        // required_secs = 2.0 / 1.0 = 2.0, * 1.5 margin = 3.0 - the generous static duration
1117        // (60s) stays in effect since it's already larger.
1118        let effective = dynamic_safety_timeout(&["pan".to_string()], 1.0, Duration::from_secs_f64(60.0));
1119
1120        assert_eq!(effective, Duration::from_secs_f64(60.0));
1121
1122        clear_controller_env();
1123    }
1124
1125    #[test]
1126    fn dynamic_safety_timeout_takes_the_slower_of_two_driven_joints() {
1127        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1128        clear_controller_env();
1129        env::set_var(
1130            ACTUATOR_JOINT_LIMITS_ENV_VAR,
1131            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}]"#,
1132        );
1133
1134        // pan: 2.0 / 1.0 = 2.0s, tilt: 10.0 / 1.0 = 10.0s - the slower joint (tilt) decides.
1135        let effective = dynamic_safety_timeout(
1136            &["pan".to_string(), "tilt".to_string()],
1137            1.0,
1138            Duration::from_secs_f64(0.1),
1139        );
1140
1141        assert_eq!(effective, Duration::from_secs_f64(15.0));
1142
1143        clear_controller_env();
1144    }
1145
1146    #[test]
1147    fn dynamic_safety_timeout_falls_back_to_static_when_speed_is_zero() {
1148        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1149        clear_controller_env();
1150        env::set_var(
1151            ACTUATOR_JOINT_LIMITS_ENV_VAR,
1152            r#"[{"joint_id":"pan","min_angle_rad":-5.0,"max_angle_rad":5.0}]"#,
1153        );
1154
1155        let effective = dynamic_safety_timeout(&["pan".to_string()], 0.0, Duration::from_secs_f64(3.0));
1156
1157        assert_eq!(effective, Duration::from_secs_f64(3.0));
1158
1159        clear_controller_env();
1160    }
1161
1162    #[test]
1163    fn dynamic_safety_timeout_falls_back_to_static_when_no_joint_limits_are_resolvable() {
1164        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1165        clear_controller_env();
1166        // No `ACTUATOR_JOINT_LIMITS_ENV_VAR` configured at all - nothing to compute a dynamic
1167        // requirement from, so this must not crash/panic and must preserve the static value.
1168        let effective = dynamic_safety_timeout(&["pan".to_string()], 1.0, Duration::from_secs_f64(3.0));
1169
1170        assert_eq!(effective, Duration::from_secs_f64(3.0));
1171
1172        clear_controller_env();
1173    }
1174
1175    #[tokio::test]
1176    #[ignore = "Requires Redis infrastructure"]
1177    async fn move_node_without_speed_publishes_the_static_target_and_succeeds_on_duration() {
1178        // (a) Regression, at the full `tick()` level: with no `speed` configured, behavior is
1179        // unchanged from before this feature existed - static publish every tick, `Success`
1180        // only once `duration_secs` elapses. Locomotion mode needs no joint feedback at all,
1181        // so this exercises the real `tick()` without requiring a live Redis-backed `Context`.
1182        //
1183        // Clears the controller env vars (so this runs in the `Locomotion` default) and drops
1184        // the guard immediately rather than holding it across the `await`s below - clippy
1185        // (`await_holding_lock`) flags a `MutexGuard` held across an await point, and this test
1186        // is `#[ignore]`d/run serially via Redis-gated CI anyway, not concurrently with the
1187        // env-mutating `resolve_movement_output_*` tests above.
1188        {
1189            let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1190            clear_controller_env();
1191        }
1192
1193        let mut node = MoveNode::from_json(serde_json::json!({
1194            "linear": 1.0,
1195            "angular": 0.5,
1196            "duration_secs": 0.05
1197        }))
1198        .unwrap();
1199
1200        let ctx = crate::test_helpers::create_test_context("move_node_without_speed").await;
1201        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
1202
1203        tokio::time::sleep(Duration::from_millis(80)).await;
1204        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Success);
1205
1206        let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1207        clear_controller_env();
1208    }
1209}