1use 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
27fn default_motor_topic() -> String {
31 "/motor/cmd_vel".to_string()
32}
33
34#[derive(Debug)]
37enum MovementOutput {
38 Twist { topic: String, twist: Twist },
41 Joints { topic: String, joint_state: JointState },
43}
44
45fn 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
78fn 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
91fn 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
114async 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 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
208const 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 speed: Option<f64>,
224 timer: ElapsedTimer,
225 joint_feedback: Option<Receiver<JointState>>,
228 latest_joint_state: Option<JointState>,
229 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 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 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 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 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 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
428fn wants_speed_limited_stepping(speed: Option<f64>, mode: &ControllerMode) -> bool {
434 speed.is_some() && matches!(mode, ControllerMode::Joints { .. })
435}
436
437const DYNAMIC_SAFETY_MARGIN: f64 = 1.5;
442
443fn 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
481fn 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
492fn 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 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 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 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 #[test]
720 fn wants_speed_limited_stepping_is_false_when_speed_is_unset_regardless_of_mode() {
721 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 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 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 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 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 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 #[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 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 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 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 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 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 {
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}