Skip to main content

robot_control/
robot.rs

1//! High-level robot controller with smooth motor ramping.
2//!
3//! The [`Robot`] struct wraps a [`MotorDriver`] and provides:
4//! - Speed commands as normalized `f32` values in the range `[-1.0, 1.0]`
5//! - Smooth acceleration/deceleration via a background task
6//! - Configurable motor factors (e.g., to correct for motor polarity)
7//! - Graceful shutdown via a cancellation token
8
9use 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/// Configuration for a [`Robot`] instance.
21///
22/// Start with [`Default`] and override individual fields. [`Robot::new`] validates
23/// the configuration before starting its background task.
24#[derive(Debug, Clone)]
25pub struct RobotConfig {
26    /// Finite left motor factor (`-1.0` reverses polarity, zero disables demand).
27    /// The factored demand is clamped to `[-1.0, 1.0]`.
28    pub left_motor_factor: f32,
29    /// Finite right motor factor (`-1.0` reverses polarity, zero disables demand).
30    /// The factored demand is clamped to `[-1.0, 1.0]`.
31    pub right_motor_factor: f32,
32    /// Which motor is the left motor.
33    pub left_motor: Motor,
34    /// Which motor is the right motor (must differ from the left motor).
35    pub right_motor: Motor,
36    /// Positive, finite speed adjustment step per tick (controls ramp smoothness).
37    pub adjustment_step: f32,
38    /// Positive interval between motor adjustment ticks. Step divided by interval
39    /// in seconds must also be positive and finite in `f32`.
40    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/// A two-motor differential-drive robot controller.
87///
88/// The robot smoothly ramps motor speeds from the current value to the target
89/// in a background task. Call [`drive`](Robot::drive) to set target speeds and
90/// [`stop`](Robot::stop) to decelerate to zero.
91///
92/// # Shutdown
93///
94/// Await [`shutdown`](Robot::shutdown) to close command admission, join the
95/// adjustment task and finish the final stop attempt. Stop failures are logged.
96/// Dropping the last public handle requests best-effort asynchronous cleanup
97/// while the Tokio runtime remains alive; it does not wait for motor I/O.
98pub struct Robot {
99    state: Arc<RobotState>,
100    // The cleanup task owns/joins the adjustment task. Keep its handle here
101    // until an await finishes; cancelling one waiter must not detach its join.
102    cleanup: Mutex<Option<JoinHandle<()>>>,
103}
104
105// Tasks own only this state, never the public Robot or its cleanup join handle.
106struct RobotState {
107    motor_board: Arc<Mutex<dyn MotorDriver>>,
108    // Serializes paired target updates/snapshots and command admission with
109    // shutdown cancellation. Never hold this lock during driver I/O.
110    target_speeds: Mutex<(f32, f32)>,
111    config: RobotConfig,
112    cancel_token: CancellationToken,
113}
114
115impl Robot {
116    /// Create a new robot with the given motor driver and configuration.
117    ///
118    /// Spawns an adjustment task and its cleanup task. They share internal state
119    /// without retaining the public handle. Shutdown or last-handle drop requests
120    /// cancellation; cleanup joins adjustment before attempting to stop all motors.
121    ///
122    /// # Errors
123    ///
124    /// Returns [`RobotError::Motor`] wrapping [`MotorError::Init`] if the
125    /// configuration is invalid. No controller task or driver writes are started
126    /// on validation failure; constructing the driver itself may access hardware.
127    pub fn new(
128        motor_board: Arc<Mutex<dyn MotorDriver>>,
129        config: RobotConfig,
130    ) -> Result<Arc<Self>, RobotError> {
131        config.validate()?;
132        let cancel_token = CancellationToken::new();
133        let state = Arc::new(RobotState {
134            motor_board,
135            target_speeds: Mutex::new((0.0, 0.0)),
136            config,
137            cancel_token,
138        });
139
140        let adjustment = tokio::spawn(state.clone().smooth_motor_adjustment());
141        let cleanup_state = state.clone();
142        let cleanup = tokio::spawn(async move {
143            if let Err(error) = adjustment.await {
144                warn!("motor adjustment task failed: {error}");
145            }
146            // Also close admission if adjustment terminated unexpectedly.
147            cleanup_state.close_admission().await;
148            if let Err(error) = cleanup_state.motor_board.lock().await.stop_all() {
149                warn!("failed to stop motors during shutdown: {error}");
150            }
151        });
152        let robot = Arc::new(Self {
153            state,
154            cleanup: Mutex::new(Some(cleanup)),
155        });
156
157        info!("robot initialized");
158        Ok(robot)
159    }
160
161    /// Set target motor speeds.
162    ///
163    /// Finite values are clamped to `[-1.0, 1.0]`, multiplied by the motor factors
164    /// from [`RobotConfig`], then clamped again to the supported output range.
165    /// A non-finite input rejects the entire command without changing targets.
166    /// Both targets are accepted atomically; success means target acceptance,
167    /// not hardware application. The task ramps each motor independently and
168    /// writes the motors sequentially from one paired target snapshot.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`RobotError::Shutdown`] if the robot has been shut down.
173    /// Returns [`RobotError::Motor`] wrapping [`MotorError::Pwm`] for a non-finite
174    /// input, retaining the existing error API.
175    pub async fn drive(&self, left: f32, right: f32) -> Result<(), RobotError> {
176        let mut targets = self.state.target_speeds.lock().await;
177        if self.state.cancel_token.is_cancelled() {
178            return Err(RobotError::Shutdown);
179        }
180
181        if !left.is_finite() || !right.is_finite() {
182            return Err(MotorError::Pwm(
183                "invalid drive command: left and right must both be finite".into(),
184            )
185            .into());
186        }
187
188        let left = scaled_demand(left, self.state.config.left_motor_factor);
189        let right = scaled_demand(right, self.state.config.right_motor_factor);
190
191        *targets = (left, right);
192        drop(targets);
193
194        debug!(left, right, "drive target set");
195        Ok(())
196    }
197
198    /// Atomically set both target speeds to zero (motors ramp down independently).
199    ///
200    /// # Errors
201    ///
202    /// Returns [`RobotError::Shutdown`] if the robot has been shut down.
203    pub async fn stop(&self) -> Result<(), RobotError> {
204        let mut targets = self.state.target_speeds.lock().await;
205        if self.state.cancel_token.is_cancelled() {
206            return Err(RobotError::Shutdown);
207        }
208
209        *targets = (0.0, 0.0);
210        drop(targets);
211
212        debug!("stop requested");
213        Ok(())
214    }
215
216    /// Close command admission, join adjustment, then finish the final stop attempt.
217    ///
218    /// Repeated/concurrent calls wait for the same cleanup. Once admission closes,
219    /// dropping a shutdown future does not cancel cleanup. No adjustment writes
220    /// occur after this method returns. It does not ramp down before stopping.
221    ///
222    /// Stop/task failures are logged; the legacy `()` return value does not
223    /// confirm electrical stopping or mechanical standstill. An in-flight driver
224    /// operation must finish before cleanup can proceed; blocked I/O can delay
225    /// shutdown indefinitely. The Tokio runtime must remain alive for cleanup.
226    pub async fn shutdown(&self) {
227        info!("shutting down robot");
228        self.state.close_admission().await;
229
230        let mut cleanup = self.cleanup.lock().await;
231        if let Some(task) = cleanup.as_mut() {
232            // Await by reference: cancellation releases this mutex but leaves
233            // the join handle available to the next caller.
234            if let Err(error) = task.await {
235                warn!("robot cleanup task failed: {error}");
236            }
237            *cleanup = None;
238        }
239    }
240}
241
242impl Drop for Robot {
243    fn drop(&mut self) {
244        // No command can borrow Robot once its last Arc is dropped. The cleanup
245        // task is already running, so Drop needs neither async I/O nor a runtime
246        // handle. Dropping its JoinHandle detaches it rather than aborting it.
247        self.state.cancel_token.cancel();
248    }
249}
250
251impl RobotState {
252    async fn close_admission(&self) {
253        // Serialize with both command validation and paired target replacement.
254        let _targets = self.target_speeds.lock().await;
255        self.cancel_token.cancel();
256    }
257
258    async fn smooth_motor_adjustment(self: Arc<Self>) {
259        let mut current_left_speed: f32 = 0.0;
260        let mut current_right_speed: f32 = 0.0;
261        let step = self.config.adjustment_step;
262        let interval = self.config.adjustment_interval;
263
264        loop {
265            tokio::select! {
266                biased;
267                () = self.cancel_token.cancelled() => {
268                    debug!("motor adjustment task cancelled");
269                    break;
270                }
271                () = tokio::time::sleep(interval) => {}
272            }
273
274            let (target_left, target_right) = *self.target_speeds.lock().await;
275
276            current_left_speed = ramp_toward(current_left_speed, target_left, step);
277            current_right_speed = ramp_toward(current_right_speed, target_right, step);
278
279            let left_pwm = speed_to_pwm(current_left_speed);
280            let right_pwm = speed_to_pwm(current_right_speed);
281
282            let motor_board = tokio::select! {
283                biased;
284                () = self.cancel_token.cancelled() => break,
285                board = self.motor_board.lock() => board,
286            };
287
288            if let Err(e) = apply_motor_speed(
289                &*motor_board,
290                self.config.left_motor,
291                left_pwm,
292                current_left_speed,
293            ) {
294                warn!("left motor error: {e}");
295            }
296
297            if let Err(e) = apply_motor_speed(
298                &*motor_board,
299                self.config.right_motor,
300                right_pwm,
301                current_right_speed,
302            ) {
303                warn!("right motor error: {e}");
304            }
305        }
306    }
307}
308
309/// Inputs and factors have been validated; clamp before and after scaling.
310fn scaled_demand(input: f32, factor: f32) -> f32 {
311    let demand = f64::from(input.clamp(-1.0, 1.0)) * f64::from(factor);
312    #[expect(
313        clippy::cast_possible_truncation,
314        reason = "bounded demand fits in f32"
315    )]
316    let demand = demand.clamp(-1.0, 1.0) as f32;
317    demand
318}
319
320/// Ramp `current` toward `target` by at most `step`.
321fn ramp_toward(current: f32, target: f32, step: f32) -> f32 {
322    if (current - target).abs() > step {
323        if current < target {
324            current + step
325        } else {
326            current - step
327        }
328    } else {
329        target
330    }
331}
332
333/// Convert a normalized speed `[-1.0, 1.0]` to a PWM duty cycle `[0, 4095]`.
334fn speed_to_pwm(speed: f32) -> u16 {
335    #[expect(
336        clippy::cast_possible_truncation,
337        clippy::cast_sign_loss,
338        reason = "speed is clamped to [0.0, 1.0] after abs(), result fits in u16"
339    )]
340    let pwm = (speed.abs() * 4095.0).round() as u16;
341    pwm
342}
343
344/// Apply the computed PWM value to a motor, choosing direction or stopping.
345fn apply_motor_speed(
346    board: &dyn MotorDriver,
347    motor: Motor,
348    pwm: u16,
349    speed: f32,
350) -> Result<(), MotorError> {
351    if speed > 0.0 {
352        board.set_motor_speed(motor, pwm, true)
353    } else if speed < 0.0 {
354        board.set_motor_speed(motor, pwm, false)
355    } else {
356        board.stop_motor(motor)
357    }
358}
359
360#[cfg(test)]
361mod lifecycle_tests;
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::mock::{MockMotorDriver, MotorCommand};
367
368    // No Tokio runtime: invalid configurations must return before tokio::spawn.
369    #[test]
370    fn invalid_config_never_starts_controller() {
371        let mut cases = vec![
372            (
373                RobotConfig {
374                    right_motor: Motor::Motor1,
375                    ..RobotConfig::default()
376                },
377                "must differ",
378            ),
379            (
380                RobotConfig {
381                    adjustment_interval: Duration::ZERO,
382                    ..RobotConfig::default()
383                },
384                "adjustment_interval",
385            ),
386            (
387                RobotConfig {
388                    adjustment_step: f32::MAX,
389                    ..RobotConfig::default()
390                },
391                "rate",
392            ),
393            (
394                RobotConfig {
395                    adjustment_step: f32::from_bits(1),
396                    adjustment_interval: Duration::MAX,
397                    ..RobotConfig::default()
398                },
399                "rate",
400            ),
401        ];
402        for step in [0.0, -0.1, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
403            cases.push((
404                RobotConfig {
405                    adjustment_step: step,
406                    ..RobotConfig::default()
407                },
408                "adjustment_step",
409            ));
410        }
411        for factor in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
412            cases.push((
413                RobotConfig {
414                    left_motor_factor: factor,
415                    ..RobotConfig::default()
416                },
417                "left_motor_factor",
418            ));
419            cases.push((
420                RobotConfig {
421                    right_motor_factor: factor,
422                    ..RobotConfig::default()
423                },
424                "right_motor_factor",
425            ));
426        }
427        for (config, expected) in cases {
428            let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
429            let Err(RobotError::Motor(MotorError::Init(message))) =
430                Robot::new(driver.clone(), config)
431            else {
432                panic!("expected configuration error for {expected}");
433            };
434            assert!(message.contains(expected), "{message}");
435            assert_eq!(Arc::strong_count(&driver), 1);
436            assert_eq!(driver.try_lock().unwrap().command_count(), 0);
437        }
438    }
439
440    #[tokio::test]
441    async fn invalid_drive_preserves_both_targets() {
442        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
443        let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
444        robot.drive(0.25, -0.5).await.unwrap();
445        for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
446            for (left, right) in [(invalid, 0.75), (0.75, invalid), (invalid, invalid)] {
447                assert!(matches!(
448                    robot.drive(left, right).await,
449                    Err(RobotError::Motor(MotorError::Pwm(_)))
450                ));
451                assert_eq!(*robot.state.target_speeds.lock().await, (0.25, -0.5));
452            }
453        }
454        robot.shutdown().await;
455    }
456
457    #[tokio::test]
458    async fn finite_factors_scale_then_saturate() {
459        for (factor, input, expected) in [
460            (2.0, 0.75, 1.0),
461            (-2.0, 0.75, -1.0),
462            (0.5, 2.0, 0.5), // Clamp input before applying the factor.
463            (0.0, f32::MAX, 0.0),
464            (f32::MAX, f32::MAX, 1.0),
465            (f32::MAX, -f32::MAX, -1.0),
466            (-f32::MAX, -f32::MAX, 1.0),
467            (f32::MAX, 0.0, 0.0),
468        ] {
469            let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
470            let config = RobotConfig {
471                left_motor_factor: factor,
472                right_motor_factor: factor,
473                ..RobotConfig::default()
474            };
475            let robot = Robot::new(driver, config).unwrap();
476            robot.drive(input, input).await.unwrap();
477            assert_eq!(
478                *robot.state.target_speeds.lock().await,
479                (expected, expected)
480            );
481            robot.shutdown().await;
482        }
483    }
484
485    #[test]
486    fn ramp_toward_moves_by_step() {
487        assert!((ramp_toward(0.0, 1.0, 0.1) - 0.1).abs() < f32::EPSILON);
488        assert!((ramp_toward(1.0, 0.0, 0.1) - 0.9).abs() < f32::EPSILON);
489    }
490
491    #[test]
492    fn ramp_toward_snaps_when_close() {
493        assert!((ramp_toward(0.95, 1.0, 0.1) - 1.0).abs() < f32::EPSILON);
494    }
495
496    #[test]
497    fn speed_to_pwm_converts_correctly() {
498        assert_eq!(speed_to_pwm(0.0), 0);
499        assert_eq!(speed_to_pwm(1.0), 4095);
500        assert_eq!(speed_to_pwm(-1.0), 4095);
501        assert_eq!(speed_to_pwm(0.5), 2048);
502    }
503
504    #[test]
505    fn apply_motor_speed_forward() {
506        let driver = MockMotorDriver::new();
507        apply_motor_speed(&driver, Motor::Motor1, 2048, 0.5).unwrap();
508        assert_eq!(
509            driver.commands(),
510            vec![MotorCommand::SetSpeed {
511                motor: Motor::Motor1,
512                speed: 2048,
513                forward: true,
514            }]
515        );
516    }
517
518    #[test]
519    fn apply_motor_speed_reverse() {
520        let driver = MockMotorDriver::new();
521        apply_motor_speed(&driver, Motor::Motor1, 2048, -0.5).unwrap();
522        assert_eq!(
523            driver.commands(),
524            vec![MotorCommand::SetSpeed {
525                motor: Motor::Motor1,
526                speed: 2048,
527                forward: false,
528            }]
529        );
530    }
531
532    #[test]
533    fn apply_motor_speed_stop() {
534        let driver = MockMotorDriver::new();
535        apply_motor_speed(&driver, Motor::Motor1, 0, 0.0).unwrap();
536        assert_eq!(
537            driver.commands(),
538            vec![MotorCommand::Stop {
539                motor: Motor::Motor1
540            }]
541        );
542    }
543
544    #[tokio::test]
545    async fn drive_clamps_input() {
546        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
547        let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
548
549        robot.drive(2.0, -3.0).await.unwrap();
550
551        let (left, right) = *robot.state.target_speeds.lock().await;
552
553        // With default factors (1.0, 1.0), clamped to [-1.0, 1.0]
554        assert!((left - 1.0).abs() < f32::EPSILON);
555        assert!((right - (-1.0)).abs() < f32::EPSILON);
556
557        robot.shutdown().await;
558    }
559
560    #[tokio::test]
561    async fn stop_sets_zero_targets() {
562        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
563        let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
564
565        robot.drive(0.5, 0.5).await.unwrap();
566        robot.stop().await.unwrap();
567
568        let (left, right) = *robot.state.target_speeds.lock().await;
569
570        assert!((left).abs() < f32::EPSILON);
571        assert!((right).abs() < f32::EPSILON);
572
573        robot.shutdown().await;
574    }
575
576    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
577    async fn concurrent_drive_stop_snapshots_are_complete_pairs() {
578        tokio::time::timeout(Duration::from_secs(5), async {
579            let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
580            let robot = Robot::new(driver, RobotConfig::default()).unwrap();
581            let barrier = Arc::new(tokio::sync::Barrier::new(4));
582            let mut writers = tokio::task::JoinSet::new();
583            let commands = [Some((0.75, -0.25)), Some((-0.5, 1.0)), None];
584            let accepted_pairs = [(0.75, -0.25), (-0.5, 1.0), (0.0, 0.0)];
585
586            for command in commands {
587                let robot = robot.clone();
588                let barrier = barrier.clone();
589                writers.spawn(async move {
590                    for _ in 0..128 {
591                        barrier.wait().await;
592                        match command {
593                            Some((left, right)) => robot.drive(left, right).await.unwrap(),
594                            None => robot.stop().await.unwrap(),
595                        }
596                        let snapshot = *robot.state.target_speeds.lock().await;
597                        assert!(
598                            accepted_pairs.contains(&snapshot),
599                            "mixed pair: {snapshot:?}"
600                        );
601                        barrier.wait().await;
602                    }
603                });
604            }
605
606            // Observe while both drive calls and stop contend, then again once
607            // all three have completed. No ordering between writers is assumed.
608            for _ in 0..128 {
609                barrier.wait().await;
610                let snapshot = *robot.state.target_speeds.lock().await;
611                assert!(
612                    accepted_pairs.contains(&snapshot),
613                    "mixed pair: {snapshot:?}"
614                );
615                barrier.wait().await;
616                assert!(accepted_pairs.contains(&*robot.state.target_speeds.lock().await));
617            }
618            while let Some(result) = writers.join_next().await {
619                result.unwrap();
620            }
621            robot.shutdown().await;
622        })
623        .await
624        .expect("concurrent commands deadlocked");
625    }
626
627    // Poll once to deterministically enqueue a waiter on the held state lock.
628    async fn assert_pending<F: std::future::Future>(mut future: std::pin::Pin<&mut F>) {
629        std::future::poll_fn(|cx| {
630            assert!(future.as_mut().poll(cx).is_pending());
631            std::task::Poll::Ready(())
632        })
633        .await;
634    }
635
636    #[tokio::test]
637    async fn waiting_commands_recheck_admission_after_shutdown() {
638        tokio::time::timeout(Duration::from_secs(5), async {
639            let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
640            let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
641            robot.drive(0.25, -0.5).await.unwrap();
642            let targets = robot.state.target_speeds.lock().await;
643            // Hold I/O too: admission must close before shutdown can stop the
644            // board, without keeping the target lock held during that I/O wait.
645            let board = driver.lock().await;
646            let shutdown = robot.shutdown();
647            let drive = robot.drive(-0.75, 1.0);
648            let stop = robot.stop();
649            tokio::pin!(shutdown, drive, stop);
650
651            // Tokio's mutex admits waiters in FIFO order. All three calls start
652            // before cancellation, but shutdown will acquire the state first.
653            assert_pending(shutdown.as_mut()).await;
654            assert_pending(drive.as_mut()).await;
655            assert_pending(stop.as_mut()).await;
656            assert!(!robot.state.cancel_token.is_cancelled());
657            drop(targets);
658
659            assert_pending(shutdown.as_mut()).await;
660            assert!(robot.state.cancel_token.is_cancelled());
661            assert!(matches!(drive.await, Err(RobotError::Shutdown)));
662            assert!(matches!(stop.await, Err(RobotError::Shutdown)));
663            assert_eq!(*robot.state.target_speeds.lock().await, (0.25, -0.5));
664            drop(board);
665            shutdown.await;
666        })
667        .await
668        .expect("shutdown admission deadlocked");
669    }
670
671    #[tokio::test(start_paused = true)]
672    async fn adjustment_applies_complete_target_pairs() {
673        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
674        let robot = Robot::new(
675            driver.clone(),
676            RobotConfig {
677                // Reach any target in one tick, so writes reveal the snapshot
678                // directly without intermediate per-motor ramp values.
679                adjustment_step: 2.0,
680                ..RobotConfig::default()
681            },
682        )
683        .unwrap();
684        for command in [Some((0.75, -0.25)), None, Some((-0.5, 1.0))] {
685            let (left, right) = if let Some((left, right)) = command {
686                robot.drive(left, right).await.unwrap();
687                (left, right)
688            } else {
689                robot.stop().await.unwrap();
690                (0.0, 0.0)
691            };
692            driver.lock().await.clear();
693            tokio::task::yield_now().await;
694            tokio::time::advance(Duration::from_millis(10)).await;
695            tokio::task::yield_now().await;
696
697            let expected = MockMotorDriver::new();
698            apply_motor_speed(&expected, Motor::Motor1, speed_to_pwm(left), left).unwrap();
699            apply_motor_speed(&expected, Motor::Motor2, speed_to_pwm(right), right).unwrap();
700            assert_eq!(driver.lock().await.commands(), expected.commands());
701        }
702        robot.shutdown().await;
703    }
704
705    #[tokio::test]
706    async fn shutdown_stops_motors() {
707        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
708        let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
709
710        robot.shutdown().await;
711
712        // After shutdown, drive should return error
713        assert!(robot.drive(0.5, 0.5).await.is_err());
714    }
715
716    #[tokio::test]
717    async fn motor_factors_applied() {
718        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
719        let config = RobotConfig {
720            left_motor_factor: -1.0,
721            right_motor_factor: 1.0,
722            ..RobotConfig::default()
723        };
724        let robot = Robot::new(driver.clone(), config).unwrap();
725
726        robot.drive(0.5, 0.5).await.unwrap();
727
728        let (left, right) = *robot.state.target_speeds.lock().await;
729
730        // Left factor is -1.0, so 0.5 * -1.0 = -0.5
731        assert!((left - (-0.5)).abs() < f32::EPSILON);
732        assert!((right - 0.5).abs() < f32::EPSILON);
733
734        robot.shutdown().await;
735    }
736}