1use std::sync::Arc;
10use std::time::Duration;
11
12use tokio::sync::Mutex;
13use tokio::task::JoinHandle;
14use tokio_util::sync::CancellationToken;
15use tracing::{debug, info, warn};
16
17use crate::error::{MotorError, RobotError};
18use crate::motor::{Motor, MotorDriver};
19
20#[derive(Debug, Clone)]
25pub struct RobotConfig {
26 pub left_motor_factor: f32,
29 pub right_motor_factor: f32,
32 pub left_motor: Motor,
34 pub right_motor: Motor,
36 pub adjustment_step: f32,
38 pub adjustment_interval: Duration,
41}
42
43impl RobotConfig {
44 fn validate(&self) -> Result<(), RobotError> {
45 let invalid = |reason: &str| {
46 RobotError::Motor(MotorError::Init(format!("invalid RobotConfig: {reason}")))
47 };
48 if self.left_motor == self.right_motor {
49 return Err(invalid("left_motor and right_motor must differ"));
50 }
51 if self.adjustment_interval.is_zero() {
52 return Err(invalid("adjustment_interval must be positive"));
53 }
54 if !self.adjustment_step.is_finite() || self.adjustment_step <= 0.0 {
55 return Err(invalid("adjustment_step must be finite and positive"));
56 }
57 let rate = self.adjustment_step / self.adjustment_interval.as_secs_f32();
58 if !rate.is_finite() || rate <= 0.0 {
59 return Err(invalid(
60 "adjustment_step / adjustment_interval must yield a finite, positive f32 rate",
61 ));
62 }
63 if !self.left_motor_factor.is_finite() {
64 return Err(invalid("left_motor_factor must be finite"));
65 }
66 if !self.right_motor_factor.is_finite() {
67 return Err(invalid("right_motor_factor must be finite"));
68 }
69 Ok(())
70 }
71}
72
73impl Default for RobotConfig {
74 fn default() -> Self {
75 Self {
76 left_motor_factor: 1.0,
77 right_motor_factor: 1.0,
78 left_motor: Motor::Motor1,
79 right_motor: Motor::Motor2,
80 adjustment_step: 0.1,
81 adjustment_interval: Duration::from_millis(10),
82 }
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88#[non_exhaustive]
89pub enum RobotPhase {
90 Running,
92 ShuttingDown,
94 Shutdown,
96 Faulted,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102#[non_exhaustive]
103pub enum RobotOutputState {
104 Unknown,
107 Stopped,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
118#[non_exhaustive]
119pub struct RobotStatus {
120 pub phase: RobotPhase,
122 pub output: RobotOutputState,
124 pub fault: Option<String>,
126 pub cleanup_error: Option<String>,
128 pub cleanup_complete: bool,
130}
131
132struct ControlState {
133 targets: (f32, f32),
134 status: RobotStatus,
135}
136
137impl ControlState {
138 fn check_admission(&self, cancelled: bool) -> Result<(), RobotError> {
139 if let Some(diagnostic) = &self.status.fault {
140 let cleanup = self
141 .status
142 .cleanup_error
143 .as_ref()
144 .map_or(String::new(), |error| format!("; cleanup: {error}"));
145 return Err(MotorError::Pwm(format!("robot faulted: {diagnostic}{cleanup}")).into());
146 }
147 if let Some(diagnostic) = &self.status.cleanup_error {
148 return Err(MotorError::Pwm(format!("robot cleanup failed: {diagnostic}")).into());
149 }
150 if cancelled {
151 return Err(RobotError::Shutdown);
152 }
153 Ok(())
154 }
155}
156
157pub struct Robot {
171 state: Arc<RobotState>,
172 cleanup: Mutex<Option<JoinHandle<()>>>,
175}
176
177struct RobotState {
179 motor_board: Arc<Mutex<dyn MotorDriver>>,
180 control: Mutex<ControlState>,
183 config: RobotConfig,
184 cancel_token: CancellationToken,
185}
186
187impl Robot {
188 pub fn new(
200 motor_board: Arc<Mutex<dyn MotorDriver>>,
201 config: RobotConfig,
202 ) -> Result<Arc<Self>, RobotError> {
203 config.validate()?;
204 let cancel_token = CancellationToken::new();
205 let state = Arc::new(RobotState {
206 motor_board,
207 control: Mutex::new(ControlState {
208 targets: (0.0, 0.0),
209 status: RobotStatus {
210 phase: RobotPhase::Running,
211 output: RobotOutputState::Unknown,
212 fault: None,
213 cleanup_error: None,
214 cleanup_complete: false,
215 },
216 }),
217 config,
218 cancel_token,
219 });
220
221 let adjustment = tokio::spawn(state.clone().smooth_motor_adjustment());
222 let cleanup_state = state.clone();
223 let cleanup = tokio::spawn(async move {
224 if let Err(error) = adjustment.await {
225 cleanup_state
226 .latch_fault(format!("motor adjustment task failed: {error}"))
227 .await;
228 }
229 cleanup_state.close_admission().await;
231 let result = cleanup_state.motor_board.lock().await.stop_all();
232 cleanup_state.finish_cleanup(result).await;
233 });
234 let robot = Arc::new(Self {
235 state,
236 cleanup: Mutex::new(Some(cleanup)),
237 });
238
239 info!("robot initialized");
240 Ok(robot)
241 }
242
243 pub async fn drive(&self, left: f32, right: f32) -> Result<(), RobotError> {
260 let mut control = self.state.control.lock().await;
261 control.check_admission(self.state.cancel_token.is_cancelled())?;
262
263 if !left.is_finite() || !right.is_finite() {
264 return Err(MotorError::Pwm(
265 "invalid drive command: left and right must both be finite".into(),
266 )
267 .into());
268 }
269
270 let left = scaled_demand(left, self.state.config.left_motor_factor);
271 let right = scaled_demand(right, self.state.config.right_motor_factor);
272
273 control.targets = (left, right);
274 drop(control);
275
276 debug!(left, right, "drive target set");
277 Ok(())
278 }
279
280 pub async fn stop(&self) -> Result<(), RobotError> {
287 let mut control = self.state.control.lock().await;
288 control.check_admission(self.state.cancel_token.is_cancelled())?;
289
290 control.targets = (0.0, 0.0);
291 drop(control);
292
293 debug!("stop requested");
294 Ok(())
295 }
296
297 pub async fn status(&self) -> RobotStatus {
303 self.state.control.lock().await.status.clone()
304 }
305
306 pub async fn shutdown(&self) {
318 info!("shutting down robot");
319 self.state.close_admission().await;
320
321 let mut cleanup = self.cleanup.lock().await;
322 if let Some(task) = cleanup.as_mut() {
323 if let Err(error) = task.await {
326 warn!("robot cleanup task failed: {error}");
327 }
328 *cleanup = None;
329 }
330 }
331}
332
333impl Drop for Robot {
334 fn drop(&mut self) {
335 self.state.cancel_token.cancel();
339 }
340}
341
342impl RobotState {
343 async fn close_admission(&self) {
344 let mut control = self.control.lock().await;
346 if control.status.phase == RobotPhase::Running {
347 control.status.phase = RobotPhase::ShuttingDown;
348 }
349 self.cancel_token.cancel();
350 }
351
352 async fn latch_fault(&self, diagnostic: String) {
353 warn!("{diagnostic}");
354 let mut control = self.control.lock().await;
355 if control.status.fault.is_none() {
356 control.status.fault = Some(bounded_diagnostic(diagnostic));
357 }
358 control.targets = (0.0, 0.0);
359 control.status.phase = RobotPhase::Faulted;
360 control.status.output = RobotOutputState::Unknown;
361 self.cancel_token.cancel();
362 }
363
364 async fn finish_cleanup(&self, result: Result<(), MotorError>) {
365 let mut control = self.control.lock().await;
366 match result {
367 Ok(()) => {
368 control.status.output = RobotOutputState::Stopped;
369 if control.status.phase != RobotPhase::Faulted {
370 control.status.phase = RobotPhase::Shutdown;
371 }
372 }
373 Err(error) => {
374 warn!("failed to stop motors during shutdown: {error}");
375 control.targets = (0.0, 0.0);
376 control.status.cleanup_error = Some(bounded_diagnostic(error.to_string()));
377 control.status.phase = RobotPhase::Faulted;
378 control.status.output = RobotOutputState::Unknown;
379 }
380 }
381 control.status.cleanup_complete = true;
382 }
383
384 async fn smooth_motor_adjustment(self: Arc<Self>) {
385 let mut current_left_speed: f32 = 0.0;
386 let mut current_right_speed: f32 = 0.0;
387 let step = self.config.adjustment_step;
388 let interval = self.config.adjustment_interval;
389
390 loop {
391 tokio::select! {
392 biased;
393 () = self.cancel_token.cancelled() => {
394 debug!("motor adjustment task cancelled");
395 break;
396 }
397 () = tokio::time::sleep(interval) => {}
398 }
399
400 let (target_left, target_right) = self.control.lock().await.targets;
401
402 current_left_speed = ramp_toward(current_left_speed, target_left, step);
403 current_right_speed = ramp_toward(current_right_speed, target_right, step);
404
405 let left_pwm = speed_to_pwm(current_left_speed);
406 let right_pwm = speed_to_pwm(current_right_speed);
407
408 let motor_board = tokio::select! {
409 biased;
410 () = self.cancel_token.cancelled() => break,
411 board = self.motor_board.lock() => board,
412 };
413
414 if let Err(e) = apply_motor_speed(
415 &*motor_board,
416 self.config.left_motor,
417 left_pwm,
418 current_left_speed,
419 ) {
420 drop(motor_board);
421 self.latch_fault(format!("left motor {:?}: {e}", self.config.left_motor))
422 .await;
423 break;
424 }
425
426 if let Err(e) = apply_motor_speed(
427 &*motor_board,
428 self.config.right_motor,
429 right_pwm,
430 current_right_speed,
431 ) {
432 drop(motor_board);
433 self.latch_fault(format!("right motor {:?}: {e}", self.config.right_motor))
434 .await;
435 break;
436 }
437 }
438 }
439}
440
441fn bounded_diagnostic(mut diagnostic: String) -> String {
442 const LIMIT: usize = 4096;
443 const MARKER: &str = " [truncated]";
444 if diagnostic.len() > LIMIT {
445 let mut end = LIMIT - MARKER.len();
446 while !diagnostic.is_char_boundary(end) {
447 end -= 1;
448 }
449 diagnostic.truncate(end);
450 diagnostic.push_str(MARKER);
451 diagnostic.shrink_to_fit();
452 }
453 diagnostic
454}
455
456fn scaled_demand(input: f32, factor: f32) -> f32 {
458 let demand = f64::from(input.clamp(-1.0, 1.0)) * f64::from(factor);
459 #[expect(
460 clippy::cast_possible_truncation,
461 reason = "bounded demand fits in f32"
462 )]
463 let demand = demand.clamp(-1.0, 1.0) as f32;
464 demand
465}
466
467fn ramp_toward(current: f32, target: f32, step: f32) -> f32 {
469 if (current - target).abs() > step {
470 if current < target {
471 current + step
472 } else {
473 current - step
474 }
475 } else {
476 target
477 }
478}
479
480fn speed_to_pwm(speed: f32) -> u16 {
482 #[expect(
483 clippy::cast_possible_truncation,
484 clippy::cast_sign_loss,
485 reason = "speed is clamped to [0.0, 1.0] after abs(), result fits in u16"
486 )]
487 let pwm = (speed.abs() * 4095.0).round() as u16;
488 pwm
489}
490
491fn apply_motor_speed(
493 board: &dyn MotorDriver,
494 motor: Motor,
495 pwm: u16,
496 speed: f32,
497) -> Result<(), MotorError> {
498 if speed > 0.0 {
499 board.set_motor_speed(motor, pwm, true)
500 } else if speed < 0.0 {
501 board.set_motor_speed(motor, pwm, false)
502 } else {
503 board.stop_motor(motor)
504 }
505}
506
507#[cfg(test)]
508mod lifecycle_tests;
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use crate::mock::{MockMotorDriver, MotorCommand};
514
515 #[test]
517 fn invalid_config_never_starts_controller() {
518 let mut cases = vec![
519 (
520 RobotConfig {
521 right_motor: Motor::Motor1,
522 ..RobotConfig::default()
523 },
524 "must differ",
525 ),
526 (
527 RobotConfig {
528 adjustment_interval: Duration::ZERO,
529 ..RobotConfig::default()
530 },
531 "adjustment_interval",
532 ),
533 (
534 RobotConfig {
535 adjustment_step: f32::MAX,
536 ..RobotConfig::default()
537 },
538 "rate",
539 ),
540 (
541 RobotConfig {
542 adjustment_step: f32::from_bits(1),
543 adjustment_interval: Duration::MAX,
544 ..RobotConfig::default()
545 },
546 "rate",
547 ),
548 ];
549 for step in [0.0, -0.1, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
550 cases.push((
551 RobotConfig {
552 adjustment_step: step,
553 ..RobotConfig::default()
554 },
555 "adjustment_step",
556 ));
557 }
558 for factor in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
559 cases.push((
560 RobotConfig {
561 left_motor_factor: factor,
562 ..RobotConfig::default()
563 },
564 "left_motor_factor",
565 ));
566 cases.push((
567 RobotConfig {
568 right_motor_factor: factor,
569 ..RobotConfig::default()
570 },
571 "right_motor_factor",
572 ));
573 }
574 for (config, expected) in cases {
575 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
576 let Err(RobotError::Motor(MotorError::Init(message))) =
577 Robot::new(driver.clone(), config)
578 else {
579 panic!("expected configuration error for {expected}");
580 };
581 assert!(message.contains(expected), "{message}");
582 assert_eq!(Arc::strong_count(&driver), 1);
583 assert_eq!(driver.try_lock().unwrap().command_count(), 0);
584 }
585 }
586
587 #[tokio::test]
588 async fn invalid_drive_preserves_both_targets() {
589 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
590 let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
591 robot.drive(0.25, -0.5).await.unwrap();
592 for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
593 for (left, right) in [(invalid, 0.75), (0.75, invalid), (invalid, invalid)] {
594 assert!(matches!(
595 robot.drive(left, right).await,
596 Err(RobotError::Motor(MotorError::Pwm(_)))
597 ));
598 assert_eq!(robot.state.control.lock().await.targets, (0.25, -0.5));
599 }
600 }
601 robot.shutdown().await;
602 }
603
604 #[tokio::test]
605 async fn finite_factors_scale_then_saturate() {
606 for (factor, input, expected) in [
607 (2.0, 0.75, 1.0),
608 (-2.0, 0.75, -1.0),
609 (0.5, 2.0, 0.5), (0.0, f32::MAX, 0.0),
611 (f32::MAX, f32::MAX, 1.0),
612 (f32::MAX, -f32::MAX, -1.0),
613 (-f32::MAX, -f32::MAX, 1.0),
614 (f32::MAX, 0.0, 0.0),
615 ] {
616 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
617 let config = RobotConfig {
618 left_motor_factor: factor,
619 right_motor_factor: factor,
620 ..RobotConfig::default()
621 };
622 let robot = Robot::new(driver, config).unwrap();
623 robot.drive(input, input).await.unwrap();
624 assert_eq!(
625 robot.state.control.lock().await.targets,
626 (expected, expected)
627 );
628 robot.shutdown().await;
629 }
630 }
631
632 #[test]
633 fn ramp_toward_moves_by_step() {
634 assert!((ramp_toward(0.0, 1.0, 0.1) - 0.1).abs() < f32::EPSILON);
635 assert!((ramp_toward(1.0, 0.0, 0.1) - 0.9).abs() < f32::EPSILON);
636 }
637
638 #[test]
639 fn ramp_toward_snaps_when_close() {
640 assert!((ramp_toward(0.95, 1.0, 0.1) - 1.0).abs() < f32::EPSILON);
641 }
642
643 #[test]
644 fn speed_to_pwm_converts_correctly() {
645 assert_eq!(speed_to_pwm(0.0), 0);
646 assert_eq!(speed_to_pwm(1.0), 4095);
647 assert_eq!(speed_to_pwm(-1.0), 4095);
648 assert_eq!(speed_to_pwm(0.5), 2048);
649 }
650
651 #[test]
652 fn apply_motor_speed_forward() {
653 let driver = MockMotorDriver::new();
654 apply_motor_speed(&driver, Motor::Motor1, 2048, 0.5).unwrap();
655 assert_eq!(
656 driver.commands(),
657 vec![MotorCommand::SetSpeed {
658 motor: Motor::Motor1,
659 speed: 2048,
660 forward: true,
661 }]
662 );
663 }
664
665 #[test]
666 fn apply_motor_speed_reverse() {
667 let driver = MockMotorDriver::new();
668 apply_motor_speed(&driver, Motor::Motor1, 2048, -0.5).unwrap();
669 assert_eq!(
670 driver.commands(),
671 vec![MotorCommand::SetSpeed {
672 motor: Motor::Motor1,
673 speed: 2048,
674 forward: false,
675 }]
676 );
677 }
678
679 #[test]
680 fn apply_motor_speed_stop() {
681 let driver = MockMotorDriver::new();
682 apply_motor_speed(&driver, Motor::Motor1, 0, 0.0).unwrap();
683 assert_eq!(
684 driver.commands(),
685 vec![MotorCommand::Stop {
686 motor: Motor::Motor1
687 }]
688 );
689 }
690
691 #[tokio::test]
692 async fn drive_clamps_input() {
693 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
694 let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
695
696 robot.drive(2.0, -3.0).await.unwrap();
697
698 let (left, right) = robot.state.control.lock().await.targets;
699
700 assert!((left - 1.0).abs() < f32::EPSILON);
702 assert!((right - (-1.0)).abs() < f32::EPSILON);
703
704 robot.shutdown().await;
705 }
706
707 #[tokio::test]
708 async fn stop_sets_zero_targets() {
709 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
710 let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
711
712 robot.drive(0.5, 0.5).await.unwrap();
713 robot.stop().await.unwrap();
714
715 let (left, right) = robot.state.control.lock().await.targets;
716
717 assert!((left).abs() < f32::EPSILON);
718 assert!((right).abs() < f32::EPSILON);
719
720 robot.shutdown().await;
721 }
722
723 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
724 async fn concurrent_drive_stop_snapshots_are_complete_pairs() {
725 tokio::time::timeout(Duration::from_secs(5), async {
726 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
727 let robot = Robot::new(driver, RobotConfig::default()).unwrap();
728 let barrier = Arc::new(tokio::sync::Barrier::new(4));
729 let mut writers = tokio::task::JoinSet::new();
730 let commands = [Some((0.75, -0.25)), Some((-0.5, 1.0)), None];
731 let accepted_pairs = [(0.75, -0.25), (-0.5, 1.0), (0.0, 0.0)];
732
733 for command in commands {
734 let robot = robot.clone();
735 let barrier = barrier.clone();
736 writers.spawn(async move {
737 for _ in 0..128 {
738 barrier.wait().await;
739 match command {
740 Some((left, right)) => robot.drive(left, right).await.unwrap(),
741 None => robot.stop().await.unwrap(),
742 }
743 let snapshot = robot.state.control.lock().await.targets;
744 assert!(
745 accepted_pairs.contains(&snapshot),
746 "mixed pair: {snapshot:?}"
747 );
748 barrier.wait().await;
749 }
750 });
751 }
752
753 for _ in 0..128 {
756 barrier.wait().await;
757 let snapshot = robot.state.control.lock().await.targets;
758 assert!(
759 accepted_pairs.contains(&snapshot),
760 "mixed pair: {snapshot:?}"
761 );
762 barrier.wait().await;
763 assert!(accepted_pairs.contains(&robot.state.control.lock().await.targets));
764 }
765 while let Some(result) = writers.join_next().await {
766 result.unwrap();
767 }
768 robot.shutdown().await;
769 })
770 .await
771 .expect("concurrent commands deadlocked");
772 }
773
774 async fn assert_pending<F: std::future::Future>(mut future: std::pin::Pin<&mut F>) {
776 std::future::poll_fn(|cx| {
777 assert!(future.as_mut().poll(cx).is_pending());
778 std::task::Poll::Ready(())
779 })
780 .await;
781 }
782
783 #[tokio::test]
784 async fn waiting_commands_recheck_admission_after_shutdown() {
785 tokio::time::timeout(Duration::from_secs(5), async {
786 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
787 let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
788 robot.drive(0.25, -0.5).await.unwrap();
789 let targets = robot.state.control.lock().await;
790 let board = driver.lock().await;
793 let shutdown = robot.shutdown();
794 let drive = robot.drive(-0.75, 1.0);
795 let stop = robot.stop();
796 tokio::pin!(shutdown, drive, stop);
797
798 assert_pending(shutdown.as_mut()).await;
801 assert_pending(drive.as_mut()).await;
802 assert_pending(stop.as_mut()).await;
803 assert!(!robot.state.cancel_token.is_cancelled());
804 drop(targets);
805
806 assert_pending(shutdown.as_mut()).await;
807 assert!(robot.state.cancel_token.is_cancelled());
808 assert!(matches!(drive.await, Err(RobotError::Shutdown)));
809 assert!(matches!(stop.await, Err(RobotError::Shutdown)));
810 assert_eq!(robot.state.control.lock().await.targets, (0.25, -0.5));
811 drop(board);
812 shutdown.await;
813 })
814 .await
815 .expect("shutdown admission deadlocked");
816 }
817
818 #[tokio::test(start_paused = true)]
819 async fn adjustment_applies_complete_target_pairs() {
820 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
821 let robot = Robot::new(
822 driver.clone(),
823 RobotConfig {
824 adjustment_step: 2.0,
827 ..RobotConfig::default()
828 },
829 )
830 .unwrap();
831 for command in [Some((0.75, -0.25)), None, Some((-0.5, 1.0))] {
832 let (left, right) = if let Some((left, right)) = command {
833 robot.drive(left, right).await.unwrap();
834 (left, right)
835 } else {
836 robot.stop().await.unwrap();
837 (0.0, 0.0)
838 };
839 driver.lock().await.clear();
840 tokio::task::yield_now().await;
841 tokio::time::advance(Duration::from_millis(10)).await;
842 tokio::task::yield_now().await;
843
844 let expected = MockMotorDriver::new();
845 apply_motor_speed(&expected, Motor::Motor1, speed_to_pwm(left), left).unwrap();
846 apply_motor_speed(&expected, Motor::Motor2, speed_to_pwm(right), right).unwrap();
847 assert_eq!(driver.lock().await.commands(), expected.commands());
848 }
849 robot.shutdown().await;
850 }
851
852 #[tokio::test]
853 async fn shutdown_stops_motors() {
854 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
855 let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
856
857 robot.shutdown().await;
858
859 assert!(robot.drive(0.5, 0.5).await.is_err());
861 }
862
863 #[tokio::test]
864 async fn motor_factors_applied() {
865 let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
866 let config = RobotConfig {
867 left_motor_factor: -1.0,
868 right_motor_factor: 1.0,
869 ..RobotConfig::default()
870 };
871 let robot = Robot::new(driver.clone(), config).unwrap();
872
873 robot.drive(0.5, 0.5).await.unwrap();
874
875 let (left, right) = robot.state.control.lock().await.targets;
876
877 assert!((left - (-0.5)).abs() < f32::EPSILON);
879 assert!((right - 0.5).abs() < f32::EPSILON);
880
881 robot.shutdown().await;
882 }
883}