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    target_left_speed: Mutex<f32>,
98    target_right_speed: Mutex<f32>,
99    config: RobotConfig,
100    cancel_token: CancellationToken,
101}
102
103impl Robot {
104    /// Create a new robot with the given motor driver and configuration.
105    ///
106    /// Spawns a background task for smooth motor ramping. The task runs until
107    /// [`shutdown`](Robot::shutdown) is called or the [`Robot`] is dropped.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`RobotError::Motor`] wrapping [`MotorError::Init`] if the
112    /// configuration is invalid. No controller task or driver writes are started
113    /// on validation failure; constructing the driver itself may access hardware.
114    pub fn new(
115        motor_board: Arc<Mutex<dyn MotorDriver>>,
116        config: RobotConfig,
117    ) -> Result<Arc<Self>, RobotError> {
118        config.validate()?;
119        let cancel_token = CancellationToken::new();
120        let robot = Arc::new(Self {
121            motor_board,
122            target_left_speed: Mutex::new(0.0),
123            target_right_speed: Mutex::new(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    ///
143    /// # Errors
144    ///
145    /// Returns [`RobotError::Shutdown`] if the robot has been shut down.
146    /// Returns [`RobotError::Motor`] wrapping [`MotorError::Pwm`] for a non-finite
147    /// input, retaining the existing error API.
148    pub async fn drive(&self, left: f32, right: f32) -> Result<(), RobotError> {
149        if self.cancel_token.is_cancelled() {
150            return Err(RobotError::Shutdown);
151        }
152
153        if !left.is_finite() || !right.is_finite() {
154            return Err(MotorError::Pwm(
155                "invalid drive command: left and right must both be finite".into(),
156            )
157            .into());
158        }
159
160        let left = scaled_demand(left, self.config.left_motor_factor);
161        let right = scaled_demand(right, self.config.right_motor_factor);
162
163        *self.target_left_speed.lock().await = left;
164        *self.target_right_speed.lock().await = right;
165
166        debug!(left, right, "drive target set");
167        Ok(())
168    }
169
170    /// Set target speeds to zero (motors will ramp down smoothly).
171    ///
172    /// # Errors
173    ///
174    /// Returns [`RobotError::Shutdown`] if the robot has been shut down.
175    pub async fn stop(&self) -> Result<(), RobotError> {
176        if self.cancel_token.is_cancelled() {
177            return Err(RobotError::Shutdown);
178        }
179
180        *self.target_left_speed.lock().await = 0.0;
181        *self.target_right_speed.lock().await = 0.0;
182
183        debug!("stop requested");
184        Ok(())
185    }
186
187    /// Cancel the background task and stop all motors immediately.
188    pub async fn shutdown(&self) {
189        info!("shutting down robot");
190        self.cancel_token.cancel();
191
192        if let Err(e) = self.motor_board.lock().await.stop_all() {
193            warn!("failed to stop motors during shutdown: {e}");
194        }
195    }
196
197    async fn smooth_motor_adjustment(self: Arc<Self>) {
198        let mut current_left_speed: f32 = 0.0;
199        let mut current_right_speed: f32 = 0.0;
200        let step = self.config.adjustment_step;
201        let interval = self.config.adjustment_interval;
202
203        loop {
204            tokio::select! {
205                () = self.cancel_token.cancelled() => {
206                    debug!("motor adjustment task cancelled");
207                    break;
208                }
209                () = tokio::time::sleep(interval) => {}
210            }
211
212            let target_left = *self.target_left_speed.lock().await;
213            let target_right = *self.target_right_speed.lock().await;
214
215            current_left_speed = ramp_toward(current_left_speed, target_left, step);
216            current_right_speed = ramp_toward(current_right_speed, target_right, step);
217
218            let left_pwm = speed_to_pwm(current_left_speed);
219            let right_pwm = speed_to_pwm(current_right_speed);
220
221            let motor_board = self.motor_board.lock().await;
222
223            if let Err(e) = apply_motor_speed(
224                &*motor_board,
225                self.config.left_motor,
226                left_pwm,
227                current_left_speed,
228            ) {
229                warn!("left motor error: {e}");
230            }
231
232            if let Err(e) = apply_motor_speed(
233                &*motor_board,
234                self.config.right_motor,
235                right_pwm,
236                current_right_speed,
237            ) {
238                warn!("right motor error: {e}");
239            }
240        }
241    }
242}
243
244/// Inputs and factors have been validated; clamp before and after scaling.
245fn scaled_demand(input: f32, factor: f32) -> f32 {
246    let demand = f64::from(input.clamp(-1.0, 1.0)) * f64::from(factor);
247    #[expect(
248        clippy::cast_possible_truncation,
249        reason = "bounded demand fits in f32"
250    )]
251    let demand = demand.clamp(-1.0, 1.0) as f32;
252    demand
253}
254
255/// Ramp `current` toward `target` by at most `step`.
256fn ramp_toward(current: f32, target: f32, step: f32) -> f32 {
257    if (current - target).abs() > step {
258        if current < target {
259            current + step
260        } else {
261            current - step
262        }
263    } else {
264        target
265    }
266}
267
268/// Convert a normalized speed `[-1.0, 1.0]` to a PWM duty cycle `[0, 4095]`.
269fn speed_to_pwm(speed: f32) -> u16 {
270    #[expect(
271        clippy::cast_possible_truncation,
272        clippy::cast_sign_loss,
273        reason = "speed is clamped to [0.0, 1.0] after abs(), result fits in u16"
274    )]
275    let pwm = (speed.abs() * 4095.0).round() as u16;
276    pwm
277}
278
279/// Apply the computed PWM value to a motor, choosing direction or stopping.
280fn apply_motor_speed(
281    board: &dyn MotorDriver,
282    motor: Motor,
283    pwm: u16,
284    speed: f32,
285) -> Result<(), MotorError> {
286    if speed > 0.0 {
287        board.set_motor_speed(motor, pwm, true)
288    } else if speed < 0.0 {
289        board.set_motor_speed(motor, pwm, false)
290    } else {
291        board.stop_motor(motor)
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::mock::{MockMotorDriver, MotorCommand};
299
300    // No Tokio runtime: invalid configurations must return before tokio::spawn.
301    #[test]
302    fn invalid_config_never_starts_controller() {
303        let mut cases = vec![
304            (
305                RobotConfig {
306                    right_motor: Motor::Motor1,
307                    ..RobotConfig::default()
308                },
309                "must differ",
310            ),
311            (
312                RobotConfig {
313                    adjustment_interval: Duration::ZERO,
314                    ..RobotConfig::default()
315                },
316                "adjustment_interval",
317            ),
318            (
319                RobotConfig {
320                    adjustment_step: f32::MAX,
321                    ..RobotConfig::default()
322                },
323                "rate",
324            ),
325            (
326                RobotConfig {
327                    adjustment_step: f32::from_bits(1),
328                    adjustment_interval: Duration::MAX,
329                    ..RobotConfig::default()
330                },
331                "rate",
332            ),
333        ];
334        for step in [0.0, -0.1, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
335            cases.push((
336                RobotConfig {
337                    adjustment_step: step,
338                    ..RobotConfig::default()
339                },
340                "adjustment_step",
341            ));
342        }
343        for factor in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
344            cases.push((
345                RobotConfig {
346                    left_motor_factor: factor,
347                    ..RobotConfig::default()
348                },
349                "left_motor_factor",
350            ));
351            cases.push((
352                RobotConfig {
353                    right_motor_factor: factor,
354                    ..RobotConfig::default()
355                },
356                "right_motor_factor",
357            ));
358        }
359        for (config, expected) in cases {
360            let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
361            let Err(RobotError::Motor(MotorError::Init(message))) =
362                Robot::new(driver.clone(), config)
363            else {
364                panic!("expected configuration error for {expected}");
365            };
366            assert!(message.contains(expected), "{message}");
367            assert_eq!(Arc::strong_count(&driver), 1);
368            assert_eq!(driver.try_lock().unwrap().command_count(), 0);
369        }
370    }
371
372    #[tokio::test]
373    async fn invalid_drive_preserves_both_targets() {
374        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
375        let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
376        robot.drive(0.25, -0.5).await.unwrap();
377        for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
378            for (left, right) in [(invalid, 0.75), (0.75, invalid), (invalid, invalid)] {
379                assert!(matches!(
380                    robot.drive(left, right).await,
381                    Err(RobotError::Motor(MotorError::Pwm(_)))
382                ));
383                assert_eq!(*robot.target_left_speed.lock().await, 0.25);
384                assert_eq!(*robot.target_right_speed.lock().await, -0.5);
385            }
386        }
387        robot.shutdown().await;
388    }
389
390    #[tokio::test]
391    async fn finite_factors_scale_then_saturate() {
392        for (factor, input, expected) in [
393            (2.0, 0.75, 1.0),
394            (-2.0, 0.75, -1.0),
395            (0.5, 2.0, 0.5), // Clamp input before applying the factor.
396            (0.0, f32::MAX, 0.0),
397            (f32::MAX, f32::MAX, 1.0),
398            (f32::MAX, -f32::MAX, -1.0),
399            (-f32::MAX, -f32::MAX, 1.0),
400            (f32::MAX, 0.0, 0.0),
401        ] {
402            let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
403            let config = RobotConfig {
404                left_motor_factor: factor,
405                right_motor_factor: factor,
406                ..RobotConfig::default()
407            };
408            let robot = Robot::new(driver, config).unwrap();
409            robot.drive(input, input).await.unwrap();
410            assert_eq!(*robot.target_left_speed.lock().await, expected);
411            assert_eq!(*robot.target_right_speed.lock().await, expected);
412            robot.shutdown().await;
413        }
414    }
415
416    #[test]
417    fn ramp_toward_moves_by_step() {
418        assert!((ramp_toward(0.0, 1.0, 0.1) - 0.1).abs() < f32::EPSILON);
419        assert!((ramp_toward(1.0, 0.0, 0.1) - 0.9).abs() < f32::EPSILON);
420    }
421
422    #[test]
423    fn ramp_toward_snaps_when_close() {
424        assert!((ramp_toward(0.95, 1.0, 0.1) - 1.0).abs() < f32::EPSILON);
425    }
426
427    #[test]
428    fn speed_to_pwm_converts_correctly() {
429        assert_eq!(speed_to_pwm(0.0), 0);
430        assert_eq!(speed_to_pwm(1.0), 4095);
431        assert_eq!(speed_to_pwm(-1.0), 4095);
432        assert_eq!(speed_to_pwm(0.5), 2048);
433    }
434
435    #[test]
436    fn apply_motor_speed_forward() {
437        let driver = MockMotorDriver::new();
438        apply_motor_speed(&driver, Motor::Motor1, 2048, 0.5).unwrap();
439        assert_eq!(
440            driver.commands(),
441            vec![MotorCommand::SetSpeed {
442                motor: Motor::Motor1,
443                speed: 2048,
444                forward: true,
445            }]
446        );
447    }
448
449    #[test]
450    fn apply_motor_speed_reverse() {
451        let driver = MockMotorDriver::new();
452        apply_motor_speed(&driver, Motor::Motor1, 2048, -0.5).unwrap();
453        assert_eq!(
454            driver.commands(),
455            vec![MotorCommand::SetSpeed {
456                motor: Motor::Motor1,
457                speed: 2048,
458                forward: false,
459            }]
460        );
461    }
462
463    #[test]
464    fn apply_motor_speed_stop() {
465        let driver = MockMotorDriver::new();
466        apply_motor_speed(&driver, Motor::Motor1, 0, 0.0).unwrap();
467        assert_eq!(
468            driver.commands(),
469            vec![MotorCommand::Stop {
470                motor: Motor::Motor1
471            }]
472        );
473    }
474
475    #[tokio::test]
476    async fn drive_clamps_input() {
477        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
478        let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
479
480        robot.drive(2.0, -3.0).await.unwrap();
481
482        let left = *robot.target_left_speed.lock().await;
483        let right = *robot.target_right_speed.lock().await;
484
485        // With default factors (1.0, 1.0), clamped to [-1.0, 1.0]
486        assert!((left - 1.0).abs() < f32::EPSILON);
487        assert!((right - (-1.0)).abs() < f32::EPSILON);
488
489        robot.shutdown().await;
490    }
491
492    #[tokio::test]
493    async fn stop_sets_zero_targets() {
494        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
495        let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
496
497        robot.drive(0.5, 0.5).await.unwrap();
498        robot.stop().await.unwrap();
499
500        let left = *robot.target_left_speed.lock().await;
501        let right = *robot.target_right_speed.lock().await;
502
503        assert!((left).abs() < f32::EPSILON);
504        assert!((right).abs() < f32::EPSILON);
505
506        robot.shutdown().await;
507    }
508
509    #[tokio::test]
510    async fn shutdown_stops_motors() {
511        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
512        let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
513
514        robot.shutdown().await;
515
516        // After shutdown, drive should return error
517        assert!(robot.drive(0.5, 0.5).await.is_err());
518    }
519
520    #[tokio::test]
521    async fn motor_factors_applied() {
522        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
523        let config = RobotConfig {
524            left_motor_factor: -1.0,
525            right_motor_factor: 1.0,
526            ..RobotConfig::default()
527        };
528        let robot = Robot::new(driver.clone(), config).unwrap();
529
530        robot.drive(0.5, 0.5).await.unwrap();
531
532        let left = *robot.target_left_speed.lock().await;
533        let right = *robot.target_right_speed.lock().await;
534
535        // Left factor is -1.0, so 0.5 * -1.0 = -0.5
536        assert!((left - (-0.5)).abs() < f32::EPSILON);
537        assert!((right - 0.5).abs() < f32::EPSILON);
538
539        robot.shutdown().await;
540    }
541}