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