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/// Controller lifecycle, independent of whether electrical stopping succeeded.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88#[non_exhaustive]
89pub enum RobotPhase {
90    /// Accepting commands; this is not a hardware self-test.
91    Running,
92    /// Admission is closed and adjustment/cleanup is still finishing.
93    ShuttingDown,
94    /// Normal shutdown completed without a reported stop failure.
95    Shutdown,
96    /// A latched task/output/cleanup failure; recovery needs a new robot.
97    Faulted,
98}
99
100/// What the controller can establish about the final output state.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102#[non_exhaustive]
103pub enum RobotOutputState {
104    /// No successful final stop has been established, or stopping failed.
105    /// Also used while running, including immediately after command acceptance.
106    Unknown,
107    /// The final driver `stop_all` returned success. This is not sensor feedback
108    /// or a claim of mechanical standstill, and excludes external driver writes.
109    Stopped,
110}
111
112/// A read-only snapshot obtained from [`Robot::status`].
113///
114/// Keeps the first background failure and final cleanup failure separately.
115/// Each diagnostic is limited to 4096 UTF-8 bytes (with a truncation marker);
116/// repeated rejected commands do not append an error history.
117#[derive(Debug, Clone, PartialEq, Eq)]
118#[non_exhaustive]
119pub struct RobotStatus {
120    /// Current admission/lifecycle state. Faults remain latched after shutdown.
121    pub phase: RobotPhase,
122    /// Result of the final stop, not a measurement of motor motion.
123    pub output: RobotOutputState,
124    /// First background output or task failure, if one occurred.
125    pub fault: Option<String>,
126    /// Final stop failure, if one occurred.
127    pub cleanup_error: Option<String>,
128    /// Whether cleanup has finished, even if its stop attempt failed.
129    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
157/// A two-motor differential-drive robot controller.
158///
159/// The robot smoothly ramps motor speeds from the current value to the target
160/// in a background task. Call [`drive`](Robot::drive) to set target speeds and
161/// [`stop`](Robot::stop) to decelerate to zero.
162///
163/// # Shutdown
164///
165/// Await [`shutdown`](Robot::shutdown) to close command admission, join the
166/// adjustment task and finish the final stop attempt. Stop failures are logged
167/// and retained in [`status`](Robot::status).
168/// Dropping the last public handle requests best-effort asynchronous cleanup
169/// while the Tokio runtime remains alive; it does not wait for motor I/O.
170pub struct Robot {
171    state: Arc<RobotState>,
172    // The cleanup task owns/joins the adjustment task. Keep its handle here
173    // until an await finishes; cancelling one waiter must not detach its join.
174    cleanup: Mutex<Option<JoinHandle<()>>>,
175}
176
177// Tasks own only this state, never the public Robot or its cleanup join handle.
178struct RobotState {
179    motor_board: Arc<Mutex<dyn MotorDriver>>,
180    // Serializes paired target updates/snapshots and command admission with
181    // shutdown cancellation. Never hold this lock during driver I/O.
182    control: Mutex<ControlState>,
183    config: RobotConfig,
184    cancel_token: CancellationToken,
185}
186
187impl Robot {
188    /// Create a new robot with the given motor driver and configuration.
189    ///
190    /// Spawns an adjustment task and its cleanup task. They share internal state
191    /// without retaining the public handle. Shutdown or last-handle drop requests
192    /// cancellation; cleanup joins adjustment before attempting to stop all motors.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`RobotError::Motor`] wrapping [`MotorError::Init`] if the
197    /// configuration is invalid. No controller task or driver writes are started
198    /// on validation failure; constructing the driver itself may access hardware.
199    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            // Also close admission if adjustment terminated unexpectedly.
230            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    /// Set target motor speeds.
244    ///
245    /// Finite values are clamped to `[-1.0, 1.0]`, multiplied by the motor factors
246    /// from [`RobotConfig`], then clamped again to the supported output range.
247    /// A non-finite input rejects the entire command without changing targets.
248    /// Both targets are accepted atomically; success means target acceptance,
249    /// not hardware application. The task ramps each motor independently and
250    /// writes the motors sequentially from one paired target snapshot.
251    ///
252    /// # Errors
253    ///
254    /// Returns [`RobotError::Shutdown`] if admission closed without a fault.
255    /// A latched failure returns [`RobotError::Motor`] wrapping [`MotorError::Pwm`]
256    /// with the retained diagnostic; it takes precedence over shutdown.
257    /// Returns [`RobotError::Motor`] wrapping [`MotorError::Pwm`] for a non-finite
258    /// input, retaining the existing error API.
259    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    /// Atomically set both target speeds to zero (motors ramp down independently).
281    ///
282    /// # Errors
283    ///
284    /// Returns [`RobotError::Shutdown`] after normal admission closure, or
285    /// [`RobotError::Motor`] wrapping [`MotorError::Pwm`] with a latched failure.
286    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    /// Read a consistent snapshot of lifecycle, output certainty and failures.
298    ///
299    /// Does not perform I/O. A successful drive accepts targets only; it does
300    /// not confirm hardware application. A fault stays visible after cleanup
301    /// and explicit shutdown, even when the final stop succeeds.
302    pub async fn status(&self) -> RobotStatus {
303        self.state.control.lock().await.status.clone()
304    }
305
306    /// Close command admission, join adjustment, then finish the final stop attempt.
307    ///
308    /// Repeated/concurrent calls wait for the same cleanup. Once admission closes,
309    /// dropping a shutdown future does not cancel cleanup. No adjustment writes
310    /// occur after this method returns. It does not ramp down before stopping.
311    ///
312    /// Driver failures are logged and retained in [`status`](Self::status);
313    /// the legacy `()` return value does not
314    /// confirm electrical stopping or mechanical standstill. An in-flight driver
315    /// operation must finish before cleanup can proceed; blocked I/O can delay
316    /// shutdown indefinitely. The Tokio runtime must remain alive for cleanup.
317    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            // Await by reference: cancellation releases this mutex but leaves
324            // the join handle available to the next caller.
325            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        // No command can borrow Robot once its last Arc is dropped. The cleanup
336        // task is already running, so Drop needs neither async I/O nor a runtime
337        // handle. Dropping its JoinHandle detaches it rather than aborting it.
338        self.state.cancel_token.cancel();
339    }
340}
341
342impl RobotState {
343    async fn close_admission(&self) {
344        // Serialize with both command validation and paired target replacement.
345        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
456/// Inputs and factors have been validated; clamp before and after scaling.
457fn 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
467/// Ramp `current` toward `target` by at most `step`.
468fn 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
480/// Convert a normalized speed `[-1.0, 1.0]` to a PWM duty cycle `[0, 4095]`.
481fn 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
491/// Apply the computed PWM value to a motor, choosing direction or stopping.
492fn 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    // No Tokio runtime: invalid configurations must return before tokio::spawn.
516    #[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), // Clamp input before applying the factor.
610            (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        // With default factors (1.0, 1.0), clamped to [-1.0, 1.0]
701        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            // Observe while both drive calls and stop contend, then again once
754            // all three have completed. No ordering between writers is assumed.
755            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    // Poll once to deterministically enqueue a waiter on the held state lock.
775    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            // Hold I/O too: admission must close before shutdown can stop the
791            // board, without keeping the target lock held during that I/O wait.
792            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            // Tokio's mutex admits waiters in FIFO order. All three calls start
799            // before cancellation, but shutdown will acquire the state first.
800            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                // Reach any target in one tick, so writes reveal the snapshot
825                // directly without intermediate per-motor ramp values.
826                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        // After shutdown, drive should return error
860        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        // Left factor is -1.0, so 0.5 * -1.0 = -0.5
878        assert!((left - (-0.5)).abs() < f32::EPSILON);
879        assert!((right - 0.5).abs() < f32::EPSILON);
880
881        robot.shutdown().await;
882    }
883}