1use 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
35fn default_motor_topic() -> String {
39 "/motor/cmd_vel".to_string()
40}
41
42#[derive(Debug)]
45enum MovementOutput {
46 Twist { topic: String, twist: Twist },
49 Joints { topic: String, joint_state: JointState },
51}
52
53fn 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
86fn 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
99fn 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
122async 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 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
216const 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 speed: Option<f64>,
232 timer: ElapsedTimer,
233 joint_feedback: Option<Receiver<JointState>>,
236 latest_joint_positions: HashMap<String, f32>,
243 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 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 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 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 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 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
451fn wants_speed_limited_stepping(speed: Option<f64>, mode: &ControllerMode) -> bool {
457 speed.is_some() && matches!(mode, ControllerMode::Joints { .. })
458}
459
460const DYNAMIC_SAFETY_MARGIN: f64 = 1.5;
465
466fn 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
504fn 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
515fn 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
530fn 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 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
583fn 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 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 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 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 #[test]
811 fn wants_speed_limited_stepping_is_false_when_speed_is_unset_regardless_of_mode() {
812 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 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 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 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 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 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 #[test]
905 fn merge_joint_feedback_tracks_two_joints_fed_by_separate_single_joint_messages() {
906 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 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 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 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 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 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 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 #[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 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 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 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 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 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 {
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}