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::time::Instant;
15use tokio_util::sync::CancellationToken;
16use tracing::{debug, info, warn};
17
18use crate::error::{MotorError, RobotError};
19use crate::motor::{Motor, MotorDriver};
20
21/// Configuration for a [`Robot`] instance.
22///
23/// Start with [`Default`] and override individual fields. [`Robot::new`] validates
24/// the configuration before starting its background task.
25#[derive(Debug, Clone)]
26pub struct RobotConfig {
27    /// Finite left motor factor (`-1.0` reverses polarity, zero disables demand).
28    /// The factored demand is clamped to `[-1.0, 1.0]`.
29    pub left_motor_factor: f32,
30    /// Finite right motor factor (`-1.0` reverses polarity, zero disables demand).
31    /// The factored demand is clamped to `[-1.0, 1.0]`.
32    pub right_motor_factor: f32,
33    /// Which motor is the left motor.
34    pub left_motor: Motor,
35    /// Which motor is the right motor (must differ from the left motor).
36    pub right_motor: Motor,
37    /// Positive, finite nominal power change per interval. The slew rate is
38    /// step / interval in seconds; elapsed-time updates never exceed this step.
39    pub adjustment_step: f32,
40    /// Positive interval between motor adjustment ticks. Step divided by interval
41    /// in seconds must also be positive and finite in `f32`.
42    pub adjustment_interval: Duration,
43}
44
45impl RobotConfig {
46    fn validate(&self) -> Result<(), RobotError> {
47        let invalid = |reason: &str| {
48            RobotError::Motor(MotorError::Init(format!("invalid RobotConfig: {reason}")))
49        };
50        if self.left_motor == self.right_motor {
51            return Err(invalid("left_motor and right_motor must differ"));
52        }
53        if self.adjustment_interval.is_zero() {
54            return Err(invalid("adjustment_interval must be positive"));
55        }
56        if !self.adjustment_step.is_finite() || self.adjustment_step <= 0.0 {
57            return Err(invalid("adjustment_step must be finite and positive"));
58        }
59        let rate = self.adjustment_step / self.adjustment_interval.as_secs_f32();
60        if !rate.is_finite() || rate <= 0.0 {
61            return Err(invalid(
62                "adjustment_step / adjustment_interval must yield a finite, positive f32 rate",
63            ));
64        }
65        if !self.left_motor_factor.is_finite() {
66            return Err(invalid("left_motor_factor must be finite"));
67        }
68        if !self.right_motor_factor.is_finite() {
69            return Err(invalid("right_motor_factor must be finite"));
70        }
71        Ok(())
72    }
73}
74
75impl Default for RobotConfig {
76    fn default() -> Self {
77        Self {
78            left_motor_factor: 1.0,
79            right_motor_factor: 1.0,
80            left_motor: Motor::Motor1,
81            right_motor: Motor::Motor2,
82            adjustment_step: 0.1,
83            adjustment_interval: Duration::from_millis(10),
84        }
85    }
86}
87
88/// Controller lifecycle, independent of whether electrical stopping succeeded.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90#[non_exhaustive]
91pub enum RobotPhase {
92    /// Accepting commands; this is not a hardware self-test.
93    Running,
94    /// Admission is closed and adjustment/cleanup is still finishing.
95    ShuttingDown,
96    /// Normal shutdown completed without a reported stop failure.
97    Shutdown,
98    /// A latched task/output/cleanup failure; recovery needs a new robot.
99    Faulted,
100}
101
102/// What the controller can establish about the final output state.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104#[non_exhaustive]
105pub enum RobotOutputState {
106    /// No successful final stop has been established, or stopping failed.
107    /// Also used while running, including immediately after command acceptance.
108    Unknown,
109    /// The final driver `stop_all` returned success. This is not sensor feedback
110    /// or a claim of mechanical standstill, and excludes external driver writes.
111    Stopped,
112}
113
114/// Bounded timing diagnostics; counters saturate instead of keeping a history.
115#[derive(Debug, Clone, Default, PartialEq, Eq)]
116#[non_exhaustive]
117pub struct RobotTiming {
118    /// Updates whose actual elapsed time exceeded the configured interval,
119    /// including ordinary timer jitter. This does not latch a controller fault.
120    pub overruns: u64,
121    /// Scheduled ticks skipped rather than replayed after a delayed update/I/O.
122    pub skipped_ticks: u64,
123    /// Actual elapsed time before the last update, before the ramp cap.
124    /// Includes waiting for the driver lock; zero until the first update.
125    pub last_elapsed: Duration,
126    /// Largest observed update spacing, before the ramp cap.
127    pub max_elapsed: Duration,
128}
129
130impl RobotTiming {
131    fn record_elapsed(&mut self, elapsed: Duration, interval: Duration) {
132        self.last_elapsed = elapsed;
133        self.max_elapsed = self.max_elapsed.max(elapsed);
134        if elapsed > interval {
135            self.overruns = self.overruns.saturating_add(1);
136        }
137    }
138}
139
140/// A read-only snapshot obtained from [`Robot::status`].
141///
142/// Keeps the first background failure and final cleanup failure separately.
143/// Each diagnostic is limited to 4096 UTF-8 bytes (with a truncation marker);
144/// repeated rejected commands do not append an error history.
145#[derive(Debug, Clone, PartialEq, Eq)]
146#[non_exhaustive]
147pub struct RobotStatus {
148    /// Current admission/lifecycle state. Faults remain latched after shutdown.
149    pub phase: RobotPhase,
150    /// Result of the final stop, not a measurement of motor motion.
151    pub output: RobotOutputState,
152    /// First background output or task failure, if one occurred.
153    pub fault: Option<String>,
154    /// Final stop failure, if one occurred.
155    pub cleanup_error: Option<String>,
156    /// Whether cleanup has finished, even if its stop attempt failed.
157    pub cleanup_complete: bool,
158    /// Observed update spacing and skipped deadlines, retained after shutdown.
159    pub timing: RobotTiming,
160}
161
162struct ControlState {
163    targets: (f32, f32),
164    status: RobotStatus,
165}
166
167impl ControlState {
168    fn check_admission(&self, cancelled: bool) -> Result<(), RobotError> {
169        if let Some(diagnostic) = &self.status.fault {
170            let cleanup = self
171                .status
172                .cleanup_error
173                .as_ref()
174                .map_or(String::new(), |error| format!("; cleanup: {error}"));
175            return Err(MotorError::Pwm(format!("robot faulted: {diagnostic}{cleanup}")).into());
176        }
177        if let Some(diagnostic) = &self.status.cleanup_error {
178            return Err(MotorError::Pwm(format!("robot cleanup failed: {diagnostic}")).into());
179        }
180        if cancelled {
181            return Err(RobotError::Shutdown);
182        }
183        Ok(())
184    }
185}
186
187/// A two-motor differential-drive robot controller.
188///
189/// The robot smoothly ramps motor speeds from the current value to the target
190/// in a background task. Call [`drive`](Robot::drive) to set target speeds and
191/// [`stop`](Robot::stop) to decelerate to zero.
192///
193/// # Shutdown
194///
195/// Await [`shutdown`](Robot::shutdown) to close command admission, join the
196/// adjustment task and finish the final stop attempt. Stop failures are logged
197/// and retained in [`status`](Robot::status).
198/// Dropping the last public handle requests best-effort asynchronous cleanup
199/// while the Tokio runtime remains alive; it does not wait for motor I/O.
200pub struct Robot {
201    state: Arc<RobotState>,
202    // The cleanup task owns/joins the adjustment task. Keep its handle here
203    // until an await finishes; cancelling one waiter must not detach its join.
204    cleanup: Mutex<Option<JoinHandle<()>>>,
205}
206
207// Tasks own only this state, never the public Robot or its cleanup join handle.
208struct RobotState {
209    motor_board: Arc<Mutex<dyn MotorDriver>>,
210    // Serializes paired target updates/snapshots and command admission with
211    // shutdown cancellation. Never hold this lock during driver I/O.
212    control: Mutex<ControlState>,
213    config: RobotConfig,
214    cancel_token: CancellationToken,
215}
216
217impl Robot {
218    /// Create a new robot with the given motor driver and configuration.
219    ///
220    /// Spawns an adjustment task and its cleanup task. They share internal state
221    /// without retaining the public handle. Shutdown or last-handle drop requests
222    /// cancellation; cleanup joins adjustment before attempting to stop all motors.
223    ///
224    /// # Errors
225    ///
226    /// Returns [`RobotError::Motor`] wrapping [`MotorError::Init`] if the
227    /// configuration is invalid. No controller task or driver writes are started
228    /// on validation failure; constructing the driver itself may access hardware.
229    pub fn new(
230        motor_board: Arc<Mutex<dyn MotorDriver>>,
231        config: RobotConfig,
232    ) -> Result<Arc<Self>, RobotError> {
233        config.validate()?;
234        let cancel_token = CancellationToken::new();
235        let state = Arc::new(RobotState {
236            motor_board,
237            control: Mutex::new(ControlState {
238                targets: (0.0, 0.0),
239                status: RobotStatus {
240                    phase: RobotPhase::Running,
241                    output: RobotOutputState::Unknown,
242                    fault: None,
243                    cleanup_error: None,
244                    cleanup_complete: false,
245                    timing: RobotTiming::default(),
246                },
247            }),
248            config,
249            cancel_token,
250        });
251
252        let adjustment = tokio::spawn(state.clone().smooth_motor_adjustment());
253        let cleanup_state = state.clone();
254        let cleanup = tokio::spawn(async move {
255            if let Err(error) = adjustment.await {
256                cleanup_state
257                    .latch_fault(format!("motor adjustment task failed: {error}"))
258                    .await;
259            }
260            // Also close admission if adjustment terminated unexpectedly.
261            cleanup_state.close_admission().await;
262            let result = cleanup_state.motor_board.lock().await.stop_all();
263            cleanup_state.finish_cleanup(result).await;
264        });
265        let robot = Arc::new(Self {
266            state,
267            cleanup: Mutex::new(Some(cleanup)),
268        });
269
270        info!("robot initialized");
271        Ok(robot)
272    }
273
274    /// Set target motor speeds.
275    ///
276    /// Finite values are clamped to `[-1.0, 1.0]`, multiplied by the motor factors
277    /// from [`RobotConfig`], then clamped again to the supported output range.
278    /// A non-finite input rejects the entire command without changing targets.
279    /// Both targets are accepted atomically; success means target acceptance,
280    /// not hardware application. The task ramps each motor independently and
281    /// writes the motors sequentially from one paired target snapshot.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`RobotError::Shutdown`] if admission closed without a fault.
286    /// A latched failure returns [`RobotError::Motor`] wrapping [`MotorError::Pwm`]
287    /// with the retained diagnostic; it takes precedence over shutdown.
288    /// Returns [`RobotError::Motor`] wrapping [`MotorError::Pwm`] for a non-finite
289    /// input, retaining the existing error API.
290    pub async fn drive(&self, left: f32, right: f32) -> Result<(), RobotError> {
291        let mut control = self.state.control.lock().await;
292        control.check_admission(self.state.cancel_token.is_cancelled())?;
293
294        if !left.is_finite() || !right.is_finite() {
295            return Err(MotorError::Pwm(
296                "invalid drive command: left and right must both be finite".into(),
297            )
298            .into());
299        }
300
301        let left = scaled_demand(left, self.state.config.left_motor_factor);
302        let right = scaled_demand(right, self.state.config.right_motor_factor);
303
304        control.targets = (left, right);
305        drop(control);
306
307        debug!(left, right, "drive target set");
308        Ok(())
309    }
310
311    /// Atomically set both target speeds to zero (motors ramp down independently).
312    ///
313    /// # Errors
314    ///
315    /// Returns [`RobotError::Shutdown`] after normal admission closure, or
316    /// [`RobotError::Motor`] wrapping [`MotorError::Pwm`] with a latched failure.
317    pub async fn stop(&self) -> Result<(), RobotError> {
318        let mut control = self.state.control.lock().await;
319        control.check_admission(self.state.cancel_token.is_cancelled())?;
320
321        control.targets = (0.0, 0.0);
322        drop(control);
323
324        debug!("stop requested");
325        Ok(())
326    }
327
328    /// Read a consistent snapshot of lifecycle, output certainty and failures.
329    ///
330    /// Does not perform I/O. A successful drive accepts targets only; it does
331    /// not confirm hardware application. A fault stays visible after cleanup
332    /// and explicit shutdown, even when the final stop succeeds.
333    pub async fn status(&self) -> RobotStatus {
334        self.state.control.lock().await.status.clone()
335    }
336
337    /// Close command admission, join adjustment, then finish the final stop attempt.
338    ///
339    /// Repeated/concurrent calls wait for the same cleanup. Once admission closes,
340    /// dropping a shutdown future does not cancel cleanup. No adjustment writes
341    /// occur after this method returns. It does not ramp down before stopping.
342    ///
343    /// Driver failures are logged and retained in [`status`](Self::status);
344    /// the legacy `()` return value does not
345    /// confirm electrical stopping or mechanical standstill. An in-flight driver
346    /// operation must finish before cleanup can proceed; blocked I/O can delay
347    /// shutdown indefinitely. The Tokio runtime must remain alive for cleanup.
348    pub async fn shutdown(&self) {
349        info!("shutting down robot");
350        self.state.close_admission().await;
351
352        let mut cleanup = self.cleanup.lock().await;
353        if let Some(task) = cleanup.as_mut() {
354            // Await by reference: cancellation releases this mutex but leaves
355            // the join handle available to the next caller.
356            if let Err(error) = task.await {
357                warn!("robot cleanup task failed: {error}");
358            }
359            *cleanup = None;
360        }
361    }
362}
363
364impl Drop for Robot {
365    fn drop(&mut self) {
366        // No command can borrow Robot once its last Arc is dropped. The cleanup
367        // task is already running, so Drop needs neither async I/O nor a runtime
368        // handle. Dropping its JoinHandle detaches it rather than aborting it.
369        self.state.cancel_token.cancel();
370    }
371}
372
373impl RobotState {
374    async fn close_admission(&self) {
375        // Serialize with both command validation and paired target replacement.
376        let mut control = self.control.lock().await;
377        if control.status.phase == RobotPhase::Running {
378            control.status.phase = RobotPhase::ShuttingDown;
379        }
380        self.cancel_token.cancel();
381    }
382
383    async fn latch_fault(&self, diagnostic: String) {
384        warn!("{diagnostic}");
385        let mut control = self.control.lock().await;
386        if control.status.fault.is_none() {
387            control.status.fault = Some(bounded_diagnostic(diagnostic));
388        }
389        control.targets = (0.0, 0.0);
390        control.status.phase = RobotPhase::Faulted;
391        control.status.output = RobotOutputState::Unknown;
392        self.cancel_token.cancel();
393    }
394
395    async fn finish_cleanup(&self, result: Result<(), MotorError>) {
396        let mut control = self.control.lock().await;
397        match result {
398            Ok(()) => {
399                control.status.output = RobotOutputState::Stopped;
400                if control.status.phase != RobotPhase::Faulted {
401                    control.status.phase = RobotPhase::Shutdown;
402                }
403            }
404            Err(error) => {
405                warn!("failed to stop motors during shutdown: {error}");
406                control.targets = (0.0, 0.0);
407                control.status.cleanup_error = Some(bounded_diagnostic(error.to_string()));
408                control.status.phase = RobotPhase::Faulted;
409                control.status.output = RobotOutputState::Unknown;
410            }
411        }
412        control.status.cleanup_complete = true;
413    }
414
415    async fn smooth_motor_adjustment(self: Arc<Self>) {
416        let mut current_left_speed: f32 = 0.0;
417        let mut current_right_speed: f32 = 0.0;
418        let step = self.config.adjustment_step;
419        let interval = self.config.adjustment_interval;
420        let mut previous_update = Instant::now();
421        // Preserve the initial one-interval delay. A valid but unrepresentably
422        // distant deadline waits for cancellation instead of overflowing Instant.
423        let mut deadline = previous_update.checked_add(interval);
424
425        loop {
426            tokio::select! {
427                biased;
428                () = self.cancel_token.cancelled() => {
429                    debug!("motor adjustment task cancelled");
430                    break;
431                }
432                () = wait_for_tick(deadline) => {}
433            }
434
435            let motor_board = tokio::select! {
436                biased;
437                () = self.cancel_token.cancelled() => break,
438                board = self.motor_board.lock() => board,
439            };
440
441            // Sample elapsed time after lock waits, immediately before computing
442            // this output batch. Never hold the state lock during driver I/O.
443            let mut control = self.control.lock().await;
444            if self.cancel_token.is_cancelled() {
445                break;
446            }
447            let now = Instant::now();
448            let elapsed = now.duration_since(previous_update);
449            previous_update = now;
450            control.status.timing.record_elapsed(elapsed, interval);
451            let (target_left, target_right) = control.targets;
452            drop(control);
453
454            let change = elapsed_step(step, interval, elapsed);
455            current_left_speed = ramp_toward(current_left_speed, target_left, change);
456            current_right_speed = ramp_toward(current_right_speed, target_right, change);
457            let left_pwm = speed_to_pwm(current_left_speed);
458            let right_pwm = speed_to_pwm(current_right_speed);
459
460            if let Err(e) = apply_motor_speed(
461                &*motor_board,
462                self.config.left_motor,
463                left_pwm,
464                current_left_speed,
465            ) {
466                drop(motor_board);
467                self.latch_fault(format!("left motor {:?}: {e}", self.config.left_motor))
468                    .await;
469                break;
470            }
471
472            if let Err(e) = apply_motor_speed(
473                &*motor_board,
474                self.config.right_motor,
475                right_pwm,
476                current_right_speed,
477            ) {
478                drop(motor_board);
479                self.latch_fault(format!("right motor {:?}: {e}", self.config.right_motor))
480                    .await;
481                break;
482            }
483            drop(motor_board);
484
485            let mut control = self.control.lock().await;
486            if let Some(previous_deadline) = deadline {
487                let (next, skipped) = next_tick(previous_deadline, interval, Instant::now());
488                deadline = next;
489                control.status.timing.skipped_ticks =
490                    control.status.timing.skipped_ticks.saturating_add(skipped);
491            }
492        }
493    }
494}
495
496async fn wait_for_tick(deadline: Option<Instant>) {
497    match deadline {
498        Some(deadline) => tokio::time::sleep_until(deadline).await,
499        None => std::future::pending::<()>().await,
500    }
501}
502
503/// Advance the original deadline grid strictly beyond now, including delays
504/// shorter than a timer's resolution. Never emit a burst of overdue ticks.
505fn next_tick(deadline: Instant, interval: Duration, now: Instant) -> (Option<Instant>, u64) {
506    let elapsed = now.saturating_duration_since(deadline);
507    let skipped = elapsed.as_nanos() / interval.as_nanos();
508    let remainder = elapsed.as_nanos() % interval.as_nanos();
509    #[expect(
510        clippy::cast_possible_truncation,
511        reason = "remainder is below a Duration; fractional nanoseconds are below 1e9"
512    )]
513    let remainder = Duration::new(
514        (remainder / 1_000_000_000) as u64,
515        (remainder % 1_000_000_000) as u32,
516    );
517    (
518        now.max(deadline)
519            .checked_add(interval.saturating_sub(remainder)),
520        u64::try_from(skipped).unwrap_or(u64::MAX),
521    )
522}
523
524/// Rate times elapsed time, capped at one interval and at the configured step.
525fn elapsed_step(step: f32, interval: Duration, elapsed: Duration) -> f32 {
526    let rate = f64::from(step) / interval.as_secs_f64();
527    let change = (rate * elapsed.min(interval).as_secs_f64()).min(f64::from(step));
528    #[expect(
529        clippy::cast_possible_truncation,
530        reason = "nonnegative change is bounded by the validated finite f32 step"
531    )]
532    let change = change as f32;
533    change
534}
535
536fn bounded_diagnostic(mut diagnostic: String) -> String {
537    const LIMIT: usize = 4096;
538    const MARKER: &str = " [truncated]";
539    if diagnostic.len() > LIMIT {
540        let mut end = LIMIT - MARKER.len();
541        while !diagnostic.is_char_boundary(end) {
542            end -= 1;
543        }
544        diagnostic.truncate(end);
545        diagnostic.push_str(MARKER);
546        diagnostic.shrink_to_fit();
547    }
548    diagnostic
549}
550
551/// Inputs and factors have been validated; clamp before and after scaling.
552fn scaled_demand(input: f32, factor: f32) -> f32 {
553    let demand = f64::from(input.clamp(-1.0, 1.0)) * f64::from(factor);
554    #[expect(
555        clippy::cast_possible_truncation,
556        reason = "bounded demand fits in f32"
557    )]
558    let demand = demand.clamp(-1.0, 1.0) as f32;
559    demand
560}
561
562/// Ramp `current` toward `target` by at most `step`.
563fn ramp_toward(current: f32, target: f32, step: f32) -> f32 {
564    if (current - target).abs() > step {
565        if current < target {
566            current + step
567        } else {
568            current - step
569        }
570    } else {
571        target
572    }
573}
574
575/// Convert a normalized speed `[-1.0, 1.0]` to a PWM duty cycle `[0, 4095]`.
576fn speed_to_pwm(speed: f32) -> u16 {
577    #[expect(
578        clippy::cast_possible_truncation,
579        clippy::cast_sign_loss,
580        reason = "speed is clamped to [0.0, 1.0] after abs(), result fits in u16"
581    )]
582    let pwm = (speed.abs() * 4095.0).round() as u16;
583    pwm
584}
585
586/// Apply the computed PWM value to a motor, choosing direction or stopping.
587fn apply_motor_speed(
588    board: &dyn MotorDriver,
589    motor: Motor,
590    pwm: u16,
591    speed: f32,
592) -> Result<(), MotorError> {
593    if speed > 0.0 {
594        board.set_motor_speed(motor, pwm, true)
595    } else if speed < 0.0 {
596        board.set_motor_speed(motor, pwm, false)
597    } else {
598        board.stop_motor(motor)
599    }
600}
601
602#[cfg(test)]
603mod lifecycle_tests;
604
605#[cfg(test)]
606mod timing_tests;
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use crate::mock::{MockMotorDriver, MotorCommand};
612
613    // No Tokio runtime: invalid configurations must return before tokio::spawn.
614    #[test]
615    fn invalid_config_never_starts_controller() {
616        let mut cases = vec![
617            (
618                RobotConfig {
619                    right_motor: Motor::Motor1,
620                    ..RobotConfig::default()
621                },
622                "must differ",
623            ),
624            (
625                RobotConfig {
626                    adjustment_interval: Duration::ZERO,
627                    ..RobotConfig::default()
628                },
629                "adjustment_interval",
630            ),
631            (
632                RobotConfig {
633                    adjustment_step: f32::MAX,
634                    ..RobotConfig::default()
635                },
636                "rate",
637            ),
638            (
639                RobotConfig {
640                    adjustment_step: f32::from_bits(1),
641                    adjustment_interval: Duration::MAX,
642                    ..RobotConfig::default()
643                },
644                "rate",
645            ),
646        ];
647        for step in [0.0, -0.1, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
648            cases.push((
649                RobotConfig {
650                    adjustment_step: step,
651                    ..RobotConfig::default()
652                },
653                "adjustment_step",
654            ));
655        }
656        for factor in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
657            cases.push((
658                RobotConfig {
659                    left_motor_factor: factor,
660                    ..RobotConfig::default()
661                },
662                "left_motor_factor",
663            ));
664            cases.push((
665                RobotConfig {
666                    right_motor_factor: factor,
667                    ..RobotConfig::default()
668                },
669                "right_motor_factor",
670            ));
671        }
672        for (config, expected) in cases {
673            let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
674            let Err(RobotError::Motor(MotorError::Init(message))) =
675                Robot::new(driver.clone(), config)
676            else {
677                panic!("expected configuration error for {expected}");
678            };
679            assert!(message.contains(expected), "{message}");
680            assert_eq!(Arc::strong_count(&driver), 1);
681            assert_eq!(driver.try_lock().unwrap().command_count(), 0);
682        }
683    }
684
685    #[tokio::test]
686    async fn invalid_drive_preserves_both_targets() {
687        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
688        let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
689        robot.drive(0.25, -0.5).await.unwrap();
690        for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
691            for (left, right) in [(invalid, 0.75), (0.75, invalid), (invalid, invalid)] {
692                assert!(matches!(
693                    robot.drive(left, right).await,
694                    Err(RobotError::Motor(MotorError::Pwm(_)))
695                ));
696                assert_eq!(robot.state.control.lock().await.targets, (0.25, -0.5));
697            }
698        }
699        robot.shutdown().await;
700    }
701
702    #[tokio::test]
703    async fn finite_factors_scale_then_saturate() {
704        for (factor, input, expected) in [
705            (2.0, 0.75, 1.0),
706            (-2.0, 0.75, -1.0),
707            (0.5, 2.0, 0.5), // Clamp input before applying the factor.
708            (0.0, f32::MAX, 0.0),
709            (f32::MAX, f32::MAX, 1.0),
710            (f32::MAX, -f32::MAX, -1.0),
711            (-f32::MAX, -f32::MAX, 1.0),
712            (f32::MAX, 0.0, 0.0),
713        ] {
714            let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
715            let config = RobotConfig {
716                left_motor_factor: factor,
717                right_motor_factor: factor,
718                ..RobotConfig::default()
719            };
720            let robot = Robot::new(driver, config).unwrap();
721            robot.drive(input, input).await.unwrap();
722            assert_eq!(
723                robot.state.control.lock().await.targets,
724                (expected, expected)
725            );
726            robot.shutdown().await;
727        }
728    }
729
730    #[test]
731    fn ramp_toward_moves_by_step() {
732        assert!((ramp_toward(0.0, 1.0, 0.1) - 0.1).abs() < f32::EPSILON);
733        assert!((ramp_toward(1.0, 0.0, 0.1) - 0.9).abs() < f32::EPSILON);
734    }
735
736    #[test]
737    fn ramp_toward_snaps_when_close() {
738        assert!((ramp_toward(0.95, 1.0, 0.1) - 1.0).abs() < f32::EPSILON);
739    }
740
741    #[test]
742    fn speed_to_pwm_converts_correctly() {
743        assert_eq!(speed_to_pwm(0.0), 0);
744        assert_eq!(speed_to_pwm(1.0), 4095);
745        assert_eq!(speed_to_pwm(-1.0), 4095);
746        assert_eq!(speed_to_pwm(0.5), 2048);
747    }
748
749    #[test]
750    fn apply_motor_speed_forward() {
751        let driver = MockMotorDriver::new();
752        apply_motor_speed(&driver, Motor::Motor1, 2048, 0.5).unwrap();
753        assert_eq!(
754            driver.commands(),
755            vec![MotorCommand::SetSpeed {
756                motor: Motor::Motor1,
757                speed: 2048,
758                forward: true,
759            }]
760        );
761    }
762
763    #[test]
764    fn apply_motor_speed_reverse() {
765        let driver = MockMotorDriver::new();
766        apply_motor_speed(&driver, Motor::Motor1, 2048, -0.5).unwrap();
767        assert_eq!(
768            driver.commands(),
769            vec![MotorCommand::SetSpeed {
770                motor: Motor::Motor1,
771                speed: 2048,
772                forward: false,
773            }]
774        );
775    }
776
777    #[test]
778    fn apply_motor_speed_stop() {
779        let driver = MockMotorDriver::new();
780        apply_motor_speed(&driver, Motor::Motor1, 0, 0.0).unwrap();
781        assert_eq!(
782            driver.commands(),
783            vec![MotorCommand::Stop {
784                motor: Motor::Motor1
785            }]
786        );
787    }
788
789    #[tokio::test]
790    async fn drive_clamps_input() {
791        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
792        let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
793
794        robot.drive(2.0, -3.0).await.unwrap();
795
796        let (left, right) = robot.state.control.lock().await.targets;
797
798        // With default factors (1.0, 1.0), clamped to [-1.0, 1.0]
799        assert!((left - 1.0).abs() < f32::EPSILON);
800        assert!((right - (-1.0)).abs() < f32::EPSILON);
801
802        robot.shutdown().await;
803    }
804
805    #[tokio::test]
806    async fn stop_sets_zero_targets() {
807        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
808        let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
809
810        robot.drive(0.5, 0.5).await.unwrap();
811        robot.stop().await.unwrap();
812
813        let (left, right) = robot.state.control.lock().await.targets;
814
815        assert!((left).abs() < f32::EPSILON);
816        assert!((right).abs() < f32::EPSILON);
817
818        robot.shutdown().await;
819    }
820
821    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
822    async fn concurrent_drive_stop_snapshots_are_complete_pairs() {
823        tokio::time::timeout(Duration::from_secs(5), async {
824            let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
825            let robot = Robot::new(driver, RobotConfig::default()).unwrap();
826            let barrier = Arc::new(tokio::sync::Barrier::new(4));
827            let mut writers = tokio::task::JoinSet::new();
828            let commands = [Some((0.75, -0.25)), Some((-0.5, 1.0)), None];
829            let accepted_pairs = [(0.75, -0.25), (-0.5, 1.0), (0.0, 0.0)];
830
831            for command in commands {
832                let robot = robot.clone();
833                let barrier = barrier.clone();
834                writers.spawn(async move {
835                    for _ in 0..128 {
836                        barrier.wait().await;
837                        match command {
838                            Some((left, right)) => robot.drive(left, right).await.unwrap(),
839                            None => robot.stop().await.unwrap(),
840                        }
841                        let snapshot = robot.state.control.lock().await.targets;
842                        assert!(
843                            accepted_pairs.contains(&snapshot),
844                            "mixed pair: {snapshot:?}"
845                        );
846                        barrier.wait().await;
847                    }
848                });
849            }
850
851            // Observe while both drive calls and stop contend, then again once
852            // all three have completed. No ordering between writers is assumed.
853            for _ in 0..128 {
854                barrier.wait().await;
855                let snapshot = robot.state.control.lock().await.targets;
856                assert!(
857                    accepted_pairs.contains(&snapshot),
858                    "mixed pair: {snapshot:?}"
859                );
860                barrier.wait().await;
861                assert!(accepted_pairs.contains(&robot.state.control.lock().await.targets));
862            }
863            while let Some(result) = writers.join_next().await {
864                result.unwrap();
865            }
866            robot.shutdown().await;
867        })
868        .await
869        .expect("concurrent commands deadlocked");
870    }
871
872    // Poll once to deterministically enqueue a waiter on the held state lock.
873    async fn assert_pending<F: std::future::Future>(mut future: std::pin::Pin<&mut F>) {
874        std::future::poll_fn(|cx| {
875            assert!(future.as_mut().poll(cx).is_pending());
876            std::task::Poll::Ready(())
877        })
878        .await;
879    }
880
881    #[tokio::test]
882    async fn waiting_commands_recheck_admission_after_shutdown() {
883        tokio::time::timeout(Duration::from_secs(5), async {
884            let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
885            let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
886            robot.drive(0.25, -0.5).await.unwrap();
887            let targets = robot.state.control.lock().await;
888            // Hold I/O too: admission must close before shutdown can stop the
889            // board, without keeping the target lock held during that I/O wait.
890            let board = driver.lock().await;
891            let shutdown = robot.shutdown();
892            let drive = robot.drive(-0.75, 1.0);
893            let stop = robot.stop();
894            tokio::pin!(shutdown, drive, stop);
895
896            // Tokio's mutex admits waiters in FIFO order. All three calls start
897            // before cancellation, but shutdown will acquire the state first.
898            assert_pending(shutdown.as_mut()).await;
899            assert_pending(drive.as_mut()).await;
900            assert_pending(stop.as_mut()).await;
901            assert!(!robot.state.cancel_token.is_cancelled());
902            drop(targets);
903
904            assert_pending(shutdown.as_mut()).await;
905            assert!(robot.state.cancel_token.is_cancelled());
906            assert!(matches!(drive.await, Err(RobotError::Shutdown)));
907            assert!(matches!(stop.await, Err(RobotError::Shutdown)));
908            assert_eq!(robot.state.control.lock().await.targets, (0.25, -0.5));
909            drop(board);
910            shutdown.await;
911        })
912        .await
913        .expect("shutdown admission deadlocked");
914    }
915
916    #[tokio::test(start_paused = true)]
917    async fn adjustment_applies_complete_target_pairs() {
918        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
919        let robot = Robot::new(
920            driver.clone(),
921            RobotConfig {
922                // Reach any target in one tick, so writes reveal the snapshot
923                // directly without intermediate per-motor ramp values.
924                adjustment_step: 2.0,
925                ..RobotConfig::default()
926            },
927        )
928        .unwrap();
929        for command in [Some((0.75, -0.25)), None, Some((-0.5, 1.0))] {
930            let (left, right) = if let Some((left, right)) = command {
931                robot.drive(left, right).await.unwrap();
932                (left, right)
933            } else {
934                robot.stop().await.unwrap();
935                (0.0, 0.0)
936            };
937            driver.lock().await.clear();
938            tokio::task::yield_now().await;
939            tokio::time::advance(Duration::from_millis(10)).await;
940            tokio::task::yield_now().await;
941
942            let expected = MockMotorDriver::new();
943            apply_motor_speed(&expected, Motor::Motor1, speed_to_pwm(left), left).unwrap();
944            apply_motor_speed(&expected, Motor::Motor2, speed_to_pwm(right), right).unwrap();
945            assert_eq!(driver.lock().await.commands(), expected.commands());
946        }
947        robot.shutdown().await;
948    }
949
950    #[tokio::test]
951    async fn shutdown_stops_motors() {
952        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
953        let robot = Robot::new(driver.clone(), RobotConfig::default()).unwrap();
954
955        robot.shutdown().await;
956
957        // After shutdown, drive should return error
958        assert!(robot.drive(0.5, 0.5).await.is_err());
959    }
960
961    #[tokio::test]
962    async fn motor_factors_applied() {
963        let driver = Arc::new(Mutex::new(MockMotorDriver::new()));
964        let config = RobotConfig {
965            left_motor_factor: -1.0,
966            right_motor_factor: 1.0,
967            ..RobotConfig::default()
968        };
969        let robot = Robot::new(driver.clone(), config).unwrap();
970
971        robot.drive(0.5, 0.5).await.unwrap();
972
973        let (left, right) = robot.state.control.lock().await.targets;
974
975        // Left factor is -1.0, so 0.5 * -1.0 = -0.5
976        assert!((left - (-0.5)).abs() < f32::EPSILON);
977        assert!((right - 0.5).abs() < f32::EPSILON);
978
979        robot.shutdown().await;
980    }
981}