1use std::sync::Arc;
10use std::time::Duration;
11
12use tokio::sync::Mutex;
13use tokio::task::JoinHandle;
14use tokio::time::Instant;
15use tokio_util::sync::CancellationToken;
16use tracing::{debug, info, warn};
17
18use crate::error::{MotorError, RobotError};
19use crate::motor::{Motor, MotorDriver};
20
21#[derive(Debug, Clone)]
26pub struct RobotConfig {
27 pub left_motor_factor: f32,
30 pub right_motor_factor: f32,
33 pub left_motor: Motor,
35 pub right_motor: Motor,
37 pub adjustment_step: f32,
40 pub adjustment_interval: Duration,
43}
44
45impl RobotConfig {
46 fn validate(&self) -> Result<(), RobotError> {
47 let invalid = |reason: &str| {
48 RobotError::Motor(MotorError::Init(format!("invalid RobotConfig: {reason}")))
49 };
50 if self.left_motor == self.right_motor {
51 return Err(invalid("left_motor and right_motor must differ"));
52 }
53 if self.adjustment_interval.is_zero() {
54 return Err(invalid("adjustment_interval must be positive"));
55 }
56 if !self.adjustment_step.is_finite() || self.adjustment_step <= 0.0 {
57 return Err(invalid("adjustment_step must be finite and positive"));
58 }
59 let rate = self.adjustment_step / self.adjustment_interval.as_secs_f32();
60 if !rate.is_finite() || rate <= 0.0 {
61 return Err(invalid(
62 "adjustment_step / adjustment_interval must yield a finite, positive f32 rate",
63 ));
64 }
65 if !self.left_motor_factor.is_finite() {
66 return Err(invalid("left_motor_factor must be finite"));
67 }
68 if !self.right_motor_factor.is_finite() {
69 return Err(invalid("right_motor_factor must be finite"));
70 }
71 Ok(())
72 }
73}
74
75impl Default for RobotConfig {
76 fn default() -> Self {
77 Self {
78 left_motor_factor: 1.0,
79 right_motor_factor: 1.0,
80 left_motor: Motor::Motor1,
81 right_motor: Motor::Motor2,
82 adjustment_step: 0.1,
83 adjustment_interval: Duration::from_millis(10),
84 }
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90#[non_exhaustive]
91pub enum RobotPhase {
92 Running,
94 ShuttingDown,
96 Shutdown,
98 Faulted,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104#[non_exhaustive]
105pub enum RobotOutputState {
106 Unknown,
109 Stopped,
112}
113
114#[derive(Debug, Clone, Default, PartialEq, Eq)]
116#[non_exhaustive]
117pub struct RobotTiming {
118 pub overruns: u64,
121 pub skipped_ticks: u64,
123 pub last_elapsed: Duration,
126 pub max_elapsed: Duration,
128}
129
130impl RobotTiming {
131 fn record_elapsed(&mut self, elapsed: Duration, interval: Duration) {
132 self.last_elapsed = elapsed;
133 self.max_elapsed = self.max_elapsed.max(elapsed);
134 if elapsed > interval {
135 self.overruns = self.overruns.saturating_add(1);
136 }
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
146#[non_exhaustive]
147pub struct RobotStatus {
148 pub phase: RobotPhase,
150 pub output: RobotOutputState,
152 pub fault: Option<String>,
154 pub cleanup_error: Option<String>,
156 pub cleanup_complete: bool,
158 pub timing: RobotTiming,
160}
161
162struct ControlState {
163 targets: (f32, f32),
164 status: RobotStatus,
165}
166
167impl ControlState {
168 fn check_admission(&self, cancelled: bool) -> Result<(), RobotError> {
169 if let Some(diagnostic) = &self.status.fault {
170 let cleanup = self
171 .status
172 .cleanup_error
173 .as_ref()
174 .map_or(String::new(), |error| format!("; cleanup: {error}"));
175 return Err(MotorError::Pwm(format!("robot faulted: {diagnostic}{cleanup}")).into());
176 }
177 if let Some(diagnostic) = &self.status.cleanup_error {
178 return Err(MotorError::Pwm(format!("robot cleanup failed: {diagnostic}")).into());
179 }
180 if cancelled {
181 return Err(RobotError::Shutdown);
182 }
183 Ok(())
184 }
185}
186
187pub struct Robot {
201 state: Arc<RobotState>,
202 cleanup: Mutex<Option<JoinHandle<()>>>,
205}
206
207struct RobotState {
209 motor_board: Arc<Mutex<dyn MotorDriver>>,
210 control: Mutex<ControlState>,
213 config: RobotConfig,
214 cancel_token: CancellationToken,
215}
216
217impl Robot {
218 pub fn new(
230 motor_board: Arc<Mutex<dyn MotorDriver>>,
231 config: RobotConfig,
232 ) -> Result<Arc<Self>, RobotError> {
233 config.validate()?;
234 let cancel_token = CancellationToken::new();
235 let state = Arc::new(RobotState {
236 motor_board,
237 control: Mutex::new(ControlState {
238 targets: (0.0, 0.0),
239 status: RobotStatus {
240 phase: RobotPhase::Running,
241 output: RobotOutputState::Unknown,
242 fault: None,
243 cleanup_error: None,
244 cleanup_complete: false,
245 timing: RobotTiming::default(),
246 },
247 }),
248 config,
249 cancel_token,
250 });
251
252 let adjustment = tokio::spawn(state.clone().smooth_motor_adjustment());
253 let cleanup_state = state.clone();
254 let cleanup = tokio::spawn(async move {
255 if let Err(error) = adjustment.await {
256 cleanup_state
257 .latch_fault(format!("motor adjustment task failed: {error}"))
258 .await;
259 }
260 cleanup_state.close_admission().await;
262 let result = cleanup_state.motor_board.lock().await.stop_all();
263 cleanup_state.finish_cleanup(result).await;
264 });
265 let robot = Arc::new(Self {
266 state,
267 cleanup: Mutex::new(Some(cleanup)),
268 });
269
270 info!("robot initialized");
271 Ok(robot)
272 }
273
274 pub async fn drive(&self, left: f32, right: f32) -> Result<(), RobotError> {
291 let mut control = self.state.control.lock().await;
292 control.check_admission(self.state.cancel_token.is_cancelled())?;
293
294 if !left.is_finite() || !right.is_finite() {
295 return Err(MotorError::Pwm(
296 "invalid drive command: left and right must both be finite".into(),
297 )
298 .into());
299 }
300
301 let left = scaled_demand(left, self.state.config.left_motor_factor);
302 let right = scaled_demand(right, self.state.config.right_motor_factor);
303
304 control.targets = (left, right);
305 drop(control);
306
307 debug!(left, right, "drive target set");
308 Ok(())
309 }
310
311 pub async fn stop(&self) -> Result<(), RobotError> {
318 let mut control = self.state.control.lock().await;
319 control.check_admission(self.state.cancel_token.is_cancelled())?;
320
321 control.targets = (0.0, 0.0);
322 drop(control);
323
324 debug!("stop requested");
325 Ok(())
326 }
327
328 pub async fn status(&self) -> RobotStatus {
334 self.state.control.lock().await.status.clone()
335 }
336
337 pub async fn shutdown(&self) {
349 info!("shutting down robot");
350 self.state.close_admission().await;
351
352 let mut cleanup = self.cleanup.lock().await;
353 if let Some(task) = cleanup.as_mut() {
354 if let Err(error) = task.await {
357 warn!("robot cleanup task failed: {error}");
358 }
359 *cleanup = None;
360 }
361 }
362}
363
364impl Drop for Robot {
365 fn drop(&mut self) {
366 self.state.cancel_token.cancel();
370 }
371}
372
373impl RobotState {
374 async fn close_admission(&self) {
375 let mut control = self.control.lock().await;
377 if control.status.phase == RobotPhase::Running {
378 control.status.phase = RobotPhase::ShuttingDown;
379 }
380 self.cancel_token.cancel();
381 }
382
383 async fn latch_fault(&self, diagnostic: String) {
384 warn!("{diagnostic}");
385 let mut control = self.control.lock().await;
386 if control.status.fault.is_none() {
387 control.status.fault = Some(bounded_diagnostic(diagnostic));
388 }
389 control.targets = (0.0, 0.0);
390 control.status.phase = RobotPhase::Faulted;
391 control.status.output = RobotOutputState::Unknown;
392 self.cancel_token.cancel();
393 }
394
395 async fn finish_cleanup(&self, result: Result<(), MotorError>) {
396 let mut control = self.control.lock().await;
397 match result {
398 Ok(()) => {
399 control.status.output = RobotOutputState::Stopped;
400 if control.status.phase != RobotPhase::Faulted {
401 control.status.phase = RobotPhase::Shutdown;
402 }
403 }
404 Err(error) => {
405 warn!("failed to stop motors during shutdown: {error}");
406 control.targets = (0.0, 0.0);
407 control.status.cleanup_error = Some(bounded_diagnostic(error.to_string()));
408 control.status.phase = RobotPhase::Faulted;
409 control.status.output = RobotOutputState::Unknown;
410 }
411 }
412 control.status.cleanup_complete = true;
413 }
414
415 async fn smooth_motor_adjustment(self: Arc<Self>) {
416 let mut current_left_speed: f32 = 0.0;
417 let mut current_right_speed: f32 = 0.0;
418 let step = self.config.adjustment_step;
419 let interval = self.config.adjustment_interval;
420 let mut previous_update = Instant::now();
421 let mut deadline = previous_update.checked_add(interval);
424
425 loop {
426 tokio::select! {
427 biased;
428 () = self.cancel_token.cancelled() => {
429 debug!("motor adjustment task cancelled");
430 break;
431 }
432 () = wait_for_tick(deadline) => {}
433 }
434
435 let motor_board = tokio::select! {
436 biased;
437 () = self.cancel_token.cancelled() => break,
438 board = self.motor_board.lock() => board,
439 };
440
441 let mut control = self.control.lock().await;
444 if self.cancel_token.is_cancelled() {
445 break;
446 }
447 let now = Instant::now();
448 let elapsed = now.duration_since(previous_update);
449 previous_update = now;
450 control.status.timing.record_elapsed(elapsed, interval);
451 let (target_left, target_right) = control.targets;
452 drop(control);
453
454 let change = elapsed_step(step, interval, elapsed);
455 current_left_speed = ramp_toward(current_left_speed, target_left, change);
456 current_right_speed = ramp_toward(current_right_speed, target_right, change);
457 let left_pwm = speed_to_pwm(current_left_speed);
458 let right_pwm = speed_to_pwm(current_right_speed);
459
460 if let Err(e) = apply_motor_speed(
461 &*motor_board,
462 self.config.left_motor,
463 left_pwm,
464 current_left_speed,
465 ) {
466 drop(motor_board);
467 self.latch_fault(format!("left motor {:?}: {e}", self.config.left_motor))
468 .await;
469 break;
470 }
471
472 if let Err(e) = apply_motor_speed(
473 &*motor_board,
474 self.config.right_motor,
475 right_pwm,
476 current_right_speed,
477 ) {
478 drop(motor_board);
479 self.latch_fault(format!("right motor {:?}: {e}", self.config.right_motor))
480 .await;
481 break;
482 }
483 drop(motor_board);
484
485 let mut control = self.control.lock().await;
486 if let Some(previous_deadline) = deadline {
487 let (next, skipped) = next_tick(previous_deadline, interval, Instant::now());
488 deadline = next;
489 control.status.timing.skipped_ticks =
490 control.status.timing.skipped_ticks.saturating_add(skipped);
491 }
492 }
493 }
494}
495
496async fn wait_for_tick(deadline: Option<Instant>) {
497 match deadline {
498 Some(deadline) => tokio::time::sleep_until(deadline).await,
499 None => std::future::pending::<()>().await,
500 }
501}
502
503fn next_tick(deadline: Instant, interval: Duration, now: Instant) -> (Option<Instant>, u64) {
506 let elapsed = now.saturating_duration_since(deadline);
507 let skipped = elapsed.as_nanos() / interval.as_nanos();
508 let remainder = elapsed.as_nanos() % interval.as_nanos();
509 #[expect(
510 clippy::cast_possible_truncation,
511 reason = "remainder is below a Duration; fractional nanoseconds are below 1e9"
512 )]
513 let remainder = Duration::new(
514 (remainder / 1_000_000_000) as u64,
515 (remainder % 1_000_000_000) as u32,
516 );
517 (
518 now.max(deadline)
519 .checked_add(interval.saturating_sub(remainder)),
520 u64::try_from(skipped).unwrap_or(u64::MAX),
521 )
522}
523
524fn elapsed_step(step: f32, interval: Duration, elapsed: Duration) -> f32 {
526 let rate = f64::from(step) / interval.as_secs_f64();
527 let change = (rate * elapsed.min(interval).as_secs_f64()).min(f64::from(step));
528 #[expect(
529 clippy::cast_possible_truncation,
530 reason = "nonnegative change is bounded by the validated finite f32 step"
531 )]
532 let change = change as f32;
533 change
534}
535
536fn bounded_diagnostic(mut diagnostic: String) -> String {
537 const LIMIT: usize = 4096;
538 const MARKER: &str = " [truncated]";
539 if diagnostic.len() > LIMIT {
540 let mut end = LIMIT - MARKER.len();
541 while !diagnostic.is_char_boundary(end) {
542 end -= 1;
543 }
544 diagnostic.truncate(end);
545 diagnostic.push_str(MARKER);
546 diagnostic.shrink_to_fit();
547 }
548 diagnostic
549}
550
551fn scaled_demand(input: f32, factor: f32) -> f32 {
553 let demand = f64::from(input.clamp(-1.0, 1.0)) * f64::from(factor);
554 #[expect(
555 clippy::cast_possible_truncation,
556 reason = "bounded demand fits in f32"
557 )]
558 let demand = demand.clamp(-1.0, 1.0) as f32;
559 demand
560}
561
562fn ramp_toward(current: f32, target: f32, step: f32) -> f32 {
564 if (current - target).abs() > step {
565 if current < target {
566 current + step
567 } else {
568 current - step
569 }
570 } else {
571 target
572 }
573}
574
575fn speed_to_pwm(speed: f32) -> u16 {
577 #[expect(
578 clippy::cast_possible_truncation,
579 clippy::cast_sign_loss,
580 reason = "speed is clamped to [0.0, 1.0] after abs(), result fits in u16"
581 )]
582 let pwm = (speed.abs() * 4095.0).round() as u16;
583 pwm
584}
585
586fn apply_motor_speed(
588 board: &dyn MotorDriver,
589 motor: Motor,
590 pwm: u16,
591 speed: f32,
592) -> Result<(), MotorError> {
593 if speed > 0.0 {
594 board.set_motor_speed(motor, pwm, true)
595 } else if speed < 0.0 {
596 board.set_motor_speed(motor, pwm, false)
597 } else {
598 board.stop_motor(motor)
599 }
600}
601
602#[cfg(test)]
603mod lifecycle_tests;
604
605#[cfg(test)]
606mod timing_tests;
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use crate::mock::{MockMotorDriver, MotorCommand};
612
613 #[test]
615 fn invalid_config_never_starts_controller() {
616 let mut cases = vec![
617 (
618 RobotConfig {
619 right_motor: Motor::Motor1,
620 ..RobotConfig::default()
621 },
622 "must differ",
623 ),
624 (
625 RobotConfig {
626 adjustment_interval: Duration::ZERO,
627 ..RobotConfig::default()
628 },
629 "adjustment_interval",
630 ),
631 (
632 RobotConfig {
633 adjustment_step: f32::MAX,
634 ..RobotConfig::default()
635 },
636 "rate",
637 ),
638 (
639 RobotConfig {
640 adjustment_step: f32::from_bits(1),
641 adjustment_interval: Duration::MAX,
642 ..RobotConfig::default()
643 },
644 "rate",
645 ),
646 ];
647 for step in [0.0, -0.1, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
648 cases.push((
649 RobotConfig {
650 adjustment_step: step,
651 ..RobotConfig::default()
652 },
653 "adjustment_step",
654 ));
655 }
656 for factor in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
657 cases.push((
658 RobotConfig {
659 left_motor_factor: factor,
660 ..RobotConfig::default()
661 },
662 "left_motor_factor",
663 ));
664 cases.push((
665 RobotConfig {
666 right_motor_factor: factor,
667 ..RobotConfig::default()
668 },
669 "right_motor_factor",
670 ));
671 }
672 for (config, expected) in cases {
673 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
674 let Err(RobotError::Motor(MotorError::Init(message))) =
675 Robot::new(driver.clone(), config)
676 else {
677 panic!("expected configuration error for {expected}");
678 };
679 assert!(message.contains(expected), "{message}");
680 assert_eq!(Arc::strong_count(&driver), 1);
681 assert_eq!(driver.try_lock().unwrap().command_count(), 0);
682 }
683 }
684
685 #[tokio::test]
686 async fn invalid_drive_preserves_both_targets() {
687 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
688 let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
689 robot.drive(0.25, -0.5).await.unwrap();
690 for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
691 for (left, right) in [(invalid, 0.75), (0.75, invalid), (invalid, invalid)] {
692 assert!(matches!(
693 robot.drive(left, right).await,
694 Err(RobotError::Motor(MotorError::Pwm(_)))
695 ));
696 assert_eq!(robot.state.control.lock().await.targets, (0.25, -0.5));
697 }
698 }
699 robot.shutdown().await;
700 }
701
702 #[tokio::test]
703 async fn finite_factors_scale_then_saturate() {
704 for (factor, input, expected) in [
705 (2.0, 0.75, 1.0),
706 (-2.0, 0.75, -1.0),
707 (0.5, 2.0, 0.5), (0.0, f32::MAX, 0.0),
709 (f32::MAX, f32::MAX, 1.0),
710 (f32::MAX, -f32::MAX, -1.0),
711 (-f32::MAX, -f32::MAX, 1.0),
712 (f32::MAX, 0.0, 0.0),
713 ] {
714 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
715 let config = RobotConfig {
716 left_motor_factor: factor,
717 right_motor_factor: factor,
718 ..RobotConfig::default()
719 };
720 let robot = Robot::new(driver, config).unwrap();
721 robot.drive(input, input).await.unwrap();
722 assert_eq!(
723 robot.state.control.lock().await.targets,
724 (expected, expected)
725 );
726 robot.shutdown().await;
727 }
728 }
729
730 #[test]
731 fn ramp_toward_moves_by_step() {
732 assert!((ramp_toward(0.0, 1.0, 0.1) - 0.1).abs() < f32::EPSILON);
733 assert!((ramp_toward(1.0, 0.0, 0.1) - 0.9).abs() < f32::EPSILON);
734 }
735
736 #[test]
737 fn ramp_toward_snaps_when_close() {
738 assert!((ramp_toward(0.95, 1.0, 0.1) - 1.0).abs() < f32::EPSILON);
739 }
740
741 #[test]
742 fn speed_to_pwm_converts_correctly() {
743 assert_eq!(speed_to_pwm(0.0), 0);
744 assert_eq!(speed_to_pwm(1.0), 4095);
745 assert_eq!(speed_to_pwm(-1.0), 4095);
746 assert_eq!(speed_to_pwm(0.5), 2048);
747 }
748
749 #[test]
750 fn apply_motor_speed_forward() {
751 let driver = MockMotorDriver::new();
752 apply_motor_speed(&driver, Motor::Motor1, 2048, 0.5).unwrap();
753 assert_eq!(
754 driver.commands(),
755 vec![MotorCommand::SetSpeed {
756 motor: Motor::Motor1,
757 speed: 2048,
758 forward: true,
759 }]
760 );
761 }
762
763 #[test]
764 fn apply_motor_speed_reverse() {
765 let driver = MockMotorDriver::new();
766 apply_motor_speed(&driver, Motor::Motor1, 2048, -0.5).unwrap();
767 assert_eq!(
768 driver.commands(),
769 vec![MotorCommand::SetSpeed {
770 motor: Motor::Motor1,
771 speed: 2048,
772 forward: false,
773 }]
774 );
775 }
776
777 #[test]
778 fn apply_motor_speed_stop() {
779 let driver = MockMotorDriver::new();
780 apply_motor_speed(&driver, Motor::Motor1, 0, 0.0).unwrap();
781 assert_eq!(
782 driver.commands(),
783 vec![MotorCommand::Stop {
784 motor: Motor::Motor1
785 }]
786 );
787 }
788
789 #[tokio::test]
790 async fn drive_clamps_input() {
791 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
792 let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
793
794 robot.drive(2.0, -3.0).await.unwrap();
795
796 let (left, right) = robot.state.control.lock().await.targets;
797
798 assert!((left - 1.0).abs() < f32::EPSILON);
800 assert!((right - (-1.0)).abs() < f32::EPSILON);
801
802 robot.shutdown().await;
803 }
804
805 #[tokio::test]
806 async fn stop_sets_zero_targets() {
807 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
808 let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
809
810 robot.drive(0.5, 0.5).await.unwrap();
811 robot.stop().await.unwrap();
812
813 let (left, right) = robot.state.control.lock().await.targets;
814
815 assert!((left).abs() < f32::EPSILON);
816 assert!((right).abs() < f32::EPSILON);
817
818 robot.shutdown().await;
819 }
820
821 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
822 async fn concurrent_drive_stop_snapshots_are_complete_pairs() {
823 tokio::time::timeout(Duration::from_secs(5), async {
824 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
825 let robot = Robot::new(driver, RobotConfig::default()).unwrap();
826 let barrier = Arc::new(tokio::sync::Barrier::new(4));
827 let mut writers = tokio::task::JoinSet::new();
828 let commands = [Some((0.75, -0.25)), Some((-0.5, 1.0)), None];
829 let accepted_pairs = [(0.75, -0.25), (-0.5, 1.0), (0.0, 0.0)];
830
831 for command in commands {
832 let robot = robot.clone();
833 let barrier = barrier.clone();
834 writers.spawn(async move {
835 for _ in 0..128 {
836 barrier.wait().await;
837 match command {
838 Some((left, right)) => robot.drive(left, right).await.unwrap(),
839 None => robot.stop().await.unwrap(),
840 }
841 let snapshot = robot.state.control.lock().await.targets;
842 assert!(
843 accepted_pairs.contains(&snapshot),
844 "mixed pair: {snapshot:?}"
845 );
846 barrier.wait().await;
847 }
848 });
849 }
850
851 for _ in 0..128 {
854 barrier.wait().await;
855 let snapshot = robot.state.control.lock().await.targets;
856 assert!(
857 accepted_pairs.contains(&snapshot),
858 "mixed pair: {snapshot:?}"
859 );
860 barrier.wait().await;
861 assert!(accepted_pairs.contains(&robot.state.control.lock().await.targets));
862 }
863 while let Some(result) = writers.join_next().await {
864 result.unwrap();
865 }
866 robot.shutdown().await;
867 })
868 .await
869 .expect("concurrent commands deadlocked");
870 }
871
872 async fn assert_pending<F: std::future::Future>(mut future: std::pin::Pin<&mut F>) {
874 std::future::poll_fn(|cx| {
875 assert!(future.as_mut().poll(cx).is_pending());
876 std::task::Poll::Ready(())
877 })
878 .await;
879 }
880
881 #[tokio::test]
882 async fn waiting_commands_recheck_admission_after_shutdown() {
883 tokio::time::timeout(Duration::from_secs(5), async {
884 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
885 let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
886 robot.drive(0.25, -0.5).await.unwrap();
887 let targets = robot.state.control.lock().await;
888 let board = driver.lock().await;
891 let shutdown = robot.shutdown();
892 let drive = robot.drive(-0.75, 1.0);
893 let stop = robot.stop();
894 tokio::pin!(shutdown, drive, stop);
895
896 assert_pending(shutdown.as_mut()).await;
899 assert_pending(drive.as_mut()).await;
900 assert_pending(stop.as_mut()).await;
901 assert!(!robot.state.cancel_token.is_cancelled());
902 drop(targets);
903
904 assert_pending(shutdown.as_mut()).await;
905 assert!(robot.state.cancel_token.is_cancelled());
906 assert!(matches!(drive.await, Err(RobotError::Shutdown)));
907 assert!(matches!(stop.await, Err(RobotError::Shutdown)));
908 assert_eq!(robot.state.control.lock().await.targets, (0.25, -0.5));
909 drop(board);
910 shutdown.await;
911 })
912 .await
913 .expect("shutdown admission deadlocked");
914 }
915
916 #[tokio::test(start_paused = true)]
917 async fn adjustment_applies_complete_target_pairs() {
918 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
919 let robot = Robot::new(
920 driver.clone(),
921 RobotConfig {
922 adjustment_step: 2.0,
925 ..RobotConfig::default()
926 },
927 )
928 .unwrap();
929 for command in [Some((0.75, -0.25)), None, Some((-0.5, 1.0))] {
930 let (left, right) = if let Some((left, right)) = command {
931 robot.drive(left, right).await.unwrap();
932 (left, right)
933 } else {
934 robot.stop().await.unwrap();
935 (0.0, 0.0)
936 };
937 driver.lock().await.clear();
938 tokio::task::yield_now().await;
939 tokio::time::advance(Duration::from_millis(10)).await;
940 tokio::task::yield_now().await;
941
942 let expected = MockMotorDriver::new();
943 apply_motor_speed(&expected, Motor::Motor1, speed_to_pwm(left), left).unwrap();
944 apply_motor_speed(&expected, Motor::Motor2, speed_to_pwm(right), right).unwrap();
945 assert_eq!(driver.lock().await.commands(), expected.commands());
946 }
947 robot.shutdown().await;
948 }
949
950 #[tokio::test]
951 async fn shutdown_stops_motors() {
952 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
953 let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
954
955 robot.shutdown().await;
956
957 assert!(robot.drive(0.5, 0.5).await.is_err());
959 }
960
961 #[tokio::test]
962 async fn motor_factors_applied() {
963 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
964 let config = RobotConfig {
965 left_motor_factor: -1.0,
966 right_motor_factor: 1.0,
967 ..RobotConfig::default()
968 };
969 let robot = Robot::new(driver.clone(), config).unwrap();
970
971 robot.drive(0.5, 0.5).await.unwrap();
972
973 let (left, right) = robot.state.control.lock().await.targets;
974
975 assert!((left - (-0.5)).abs() < f32::EPSILON);
977 assert!((right - 0.5).abs() < f32::EPSILON);
978
979 robot.shutdown().await;
980 }
981}