Skip to main content

nros_node/
timer.rs

1//! Timer API for nros
2//!
3//! This module provides timer support matching rclrs patterns while maintaining
4//! `no_std` compatibility for embedded systems.
5//!
6//! # Overview
7//!
8//! Timers allow scheduling periodic or one-shot callbacks. A timer lives in the
9//! executor's static table: a node creates one with `create_timer_repeating` /
10//! `create_timer_oneshot` / `create_timer_inert`, which return its `HandleId`,
11//! and `Executor::spin_once` fires every due timer. There is no separate
12//! timer-processing call to make (the pre-executor API's
13//! ~~`process_timers()`~~ is gone), so this holds on embedded targets without
14//! background threads too.
15//!
16//! # Timer Modes
17//!
18//! - **Repeating**: Fires at regular intervals until canceled
19//! - **OneShot**: Fires once after a delay, then becomes inert
20//! - **Inert**: Never fires, useful as a placeholder
21//!
22//! # Example
23//!
24//! ```ignore
25//! let mut node = executor.node_mut(node_id);
26//! node.create_timer_repeating(core::time::Duration::from_millis(100), || {
27//!     // runs from inside `spin_once`
28//! })?;
29//!
30//! loop {
31//!     executor.spin_once(core::time::Duration::from_millis(10));
32//! }
33//! ```
34
35use core::marker::PhantomData;
36
37/// Duration type for timer periods
38///
39/// This is a simple millisecond-based duration for `no_std` compatibility.
40/// It can be converted to/from the ROS Duration type.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub struct TimerDuration {
43    /// Duration in microseconds.
44    ///
45    /// Issue #505: this was milliseconds, so `from_micros` silently
46    /// truncated — `from_micros(500)` produced a ZERO period (which the
47    /// dispatcher treats as "every spin") and `from_micros(1_500)` a
48    /// 1 ms one, a 50% rate error with no diagnostic. The executor's
49    /// timer accounting is microsecond-based, so store microseconds and
50    /// keep the millisecond constructors/accessors as exact wrappers.
51    micros: u64,
52}
53
54impl TimerDuration {
55    /// Create a new duration from milliseconds
56    pub const fn from_millis(millis: u64) -> Self {
57        Self {
58            micros: millis.saturating_mul(1000),
59        }
60    }
61
62    /// Create a new duration from seconds
63    pub const fn from_secs(secs: u64) -> Self {
64        Self {
65            micros: secs.saturating_mul(1_000_000),
66        }
67    }
68
69    /// Create a new duration from microseconds
70    pub const fn from_micros(micros: u64) -> Self {
71        Self { micros }
72    }
73
74    /// Create a zero duration
75    pub const fn zero() -> Self {
76        Self { micros: 0 }
77    }
78
79    /// Get duration as milliseconds (truncated; prefer
80    /// [`Self::as_micros`] for sub-millisecond periods)
81    pub const fn as_millis(&self) -> u64 {
82        self.micros / 1000
83    }
84
85    /// Get duration as microseconds
86    pub const fn as_micros(&self) -> u64 {
87        self.micros
88    }
89
90    /// Get duration as seconds (truncated)
91    pub const fn as_secs(&self) -> u64 {
92        self.micros / 1_000_000
93    }
94
95    /// Check if duration is zero
96    pub const fn is_zero(&self) -> bool {
97        self.micros == 0
98    }
99
100    /// Saturating subtraction
101    pub const fn saturating_sub(self, rhs: Self) -> Self {
102        Self {
103            micros: self.micros.saturating_sub(rhs.micros),
104        }
105    }
106}
107
108impl From<nros_core::Duration> for TimerDuration {
109    fn from(d: nros_core::Duration) -> Self {
110        let micros = (d.sec as i64 * 1_000_000 + d.nanosec as i64 / 1_000) as u64;
111        Self { micros }
112    }
113}
114
115/// phase-515 R4 — rclrs passes a `std::time::Duration` (which is
116/// `core::time::Duration`) to its timer verbs. Sub-microsecond precision is
117/// truncated, and a period past `u64::MAX` microseconds saturates.
118impl From<core::time::Duration> for TimerDuration {
119    fn from(d: core::time::Duration) -> Self {
120        Self {
121            micros: u64::try_from(d.as_micros()).unwrap_or(u64::MAX),
122        }
123    }
124}
125
126impl From<TimerDuration> for nros_core::Duration {
127    fn from(d: TimerDuration) -> Self {
128        let sec = (d.micros / 1_000_000) as i32;
129        let nanosec = ((d.micros % 1_000_000) * 1_000) as u32;
130        nros_core::Duration { sec, nanosec }
131    }
132}
133
134// phase-430 W4 — this enum lives HERE, in the ungated `timer` module, rather
135// than in `executor::arena` where phase-425 W4 first wrote it. `mod arena` is
136// `#[cfg(any(has_rmw, test))]`, so the type could not be named by the
137// DECLARATIVE layer (`nros::node_metadata::EntityMetadata`, which a
138// metadata-mode build compiles with no transport) — and a metadata field that
139// cannot name the enum is how a second spelling of it gets invented one layer
140// up, which is the 0135/0160 drift. One enum, one spelling, reachable from
141// both halves; `executor` re-exports it so every existing path still resolves.
142/// Which clock advances a timer (phase-425 W4, RFC-0075-adjacent: this is the
143/// distinction rclcpp draws between `create_wall_timer` and `create_timer`).
144///
145/// The default is [`Steady`](Self::Steady) and it is the only source that costs
146/// nothing: it consumes the spin delta the executor already measured. The other
147/// two READ a clock on every poll of the timer, which is one relaxed atomic load
148/// plus whatever the platform's time call costs.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150#[repr(u8)]
151pub enum TimerClockSource {
152    /// The executor's monotonic spin delta — a WALL timer in rclcpp's sense.
153    /// Unaffected by `/clock`: a paused simulator does not pause it, which is
154    /// exactly what a watchdog or a transport keep-alive wants.
155    #[default]
156    Steady = 0,
157    /// `ClockType::RosTime`: simulated time when a `/clock` source is active,
158    /// and system time when none is (the same fallback `rclcpp::Clock` has, so
159    /// a node built for simulation still runs standalone).
160    Ros = 1,
161    /// `ClockType::SystemTime`: the wall clock, NTP steps and all. Present
162    /// because rclrs offers it (`TimerClock::SystemTime`); a timer that must
163    /// not jump wants `Steady`.
164    System = 2,
165}
166
167// The helpers are the EXECUTOR's half: only `timer_try_process` and the timer
168// registrar read a clock, and both live behind `mod arena` / `mod spin`, which
169// are `#[cfg(any(has_rmw, test))]`. The TYPE stays ungated — the declarative
170// metadata layer names it with no transport linked — so without a backend these
171// two would be dead code under `-D warnings`.
172#[cfg(any(has_rmw, test))]
173impl TimerClockSource {
174    /// The `nros_core` clock this source reads, or `None` for [`Steady`](Self::Steady),
175    /// which reads no clock at all.
176    pub(crate) fn clock(self) -> Option<nros_core::clock::Clock> {
177        match self {
178            TimerClockSource::Steady => None,
179            TimerClockSource::Ros => Some(nros_core::clock::Clock::ros_time()),
180            TimerClockSource::System => Some(nros_core::clock::Clock::system()),
181        }
182    }
183
184    /// The clock's current reading in nanoseconds, or 0 for [`Steady`](Self::Steady).
185    pub(crate) fn now_ns(self) -> i64 {
186        self.clock().map(|c| c.now().to_nanos()).unwrap_or(0)
187    }
188
189    /// "Now" on this source, for a timer DISPATCH — issue 1974's
190    /// `actual_call_time`.
191    ///
192    /// `last_clock_ns` is the reading `timer_clock_step` took on this very
193    /// poll for a `Ros`/`System` timer, so it IS now on the timer's clock and
194    /// reading again would only add skew. A `Steady` timer reads no clock to
195    /// advance (it consumes the spin delta), so its time base is the
196    /// platform's monotonic clock — the same quantity `RCL_STEADY_TIME`
197    /// answers in C — read once, only when an activation actually fires.
198    pub(crate) fn dispatch_now_ns(self, last_clock_ns: i64) -> i64 {
199        match self {
200            TimerClockSource::Steady => nros_core::clock::Clock::steady().now().to_nanos(),
201            TimerClockSource::Ros | TimerClockSource::System => last_clock_ns,
202        }
203    }
204
205    /// Whether the time left on a timer with this source is a quantity of WALL
206    /// microseconds — issue 1321.
207    ///
208    /// The executor asks this in two places, and both are asking the same
209    /// thing: `period_us - elapsed_us` is offered to
210    /// `next_wake_bound_attributed_us` as a park bound, and `period_us` is
211    /// compared against the declared spin period by
212    /// `audit_spin_quantization`. Both quantities on the other side of those
213    /// comparisons are wall microseconds — what the port's park primitive
214    /// waits and what the tier loop paces — so a timer whose `elapsed_us`
215    /// advances on some OTHER clock must take part in neither.
216    ///
217    /// * [`Steady`](Self::Steady) — **yes.** It consumes the executor's own
218    ///   spin delta, which is wall time by construction. This is phase-436 W1
219    ///   / issue 1192, and it is unchanged.
220    /// * [`System`](Self::System) — **yes**, and it is decided separately
221    ///   rather than by "not `Steady`". Its clock IS wall time (the port's
222    ///   `nros_platform_time_now_ns`), so there is no rate indirection and
223    ///   nothing can hold it still. It can STEP — NTP, `settimeofday` — and a
224    ///   step makes the bound an estimate rather than a fact, but a bounded
225    ///   one in both directions: the bound only ever shortens a park (it is
226    ///   one member of a `min` whose first member is the caller's budget) and
227    ///   `timer_try_process` re-reads the clock before dispatching, so a step
228    ///   costs at worst one extra wake and can never fire a callback early.
229    /// * [`Ros`](Self::Ros) — **no.** Its `elapsed_us` advances by the
230    ///   `/clock` step, so the remainder is SIMULATED microseconds, related to
231    ///   wall time by the bag's replay rate — which the executor does not know
232    ///   and by design cannot. On a paused `/clock` the remainder freezes and
233    ///   the image would wake once per timer period forever. Contributing
234    ///   nothing restores the pre-436 bound for these timers, which is the
235    ///   caller's budget — never unbounded, because
236    ///   `next_wake_bound_attributed_us` seeds the `min` with it — and that
237    ///   bound IS property 1 of phase-430: a ROS-time timer's wake source is a
238    ///   `/clock` MESSAGE, and a message wakes `drive_io` like any sample, so
239    ///   the timer's own deadline tells the executor nothing it does not
240    ///   already have.
241    ///
242    ///   The answer does not become "yes" when no `/clock` source is attached,
243    ///   and issue 1334 changed WHY. When this rule was written, the fallback
244    ///   was measured to read `nros_core`'s in-image steady counter — a value
245    ///   nothing in the tree advances — so the remainder was a constant in
246    ///   that state too and the rule was true twice over. 1334 fixed that
247    ///   arm: `ClockType::RosTime` with no override now reads the wall clock,
248    ///   exactly as its own doc, rclcpp and the C surface always said, so the
249    ///   remainder there IS wall microseconds and the first reason is gone.
250    ///   The decision stands on the two that remain, and both are about the
251    ///   RULE rather than about one image's state:
252    ///
253    ///   * property 1 of phase-430 — a ROS-time timer's wake source is a
254    ///     `/clock` MESSAGE. A rule that flipped with whether a publisher
255    ///     happened to be running would make the park bound change under a
256    ///     live image for a reason no declaration named, and it would flip at
257    ///     the worst moment: the first `/clock` sample, the instant the
258    ///     remainder stops being wall microseconds. Deciding it per-timer at
259    ///     REGISTRATION is not available either — the source can attach later
260    ///     (`use_sim_time` is settable at runtime, phase-430 W2).
261    ///   * what it costs is LATENCY and nothing else, bounded by the caller.
262    ///     A `Ros` timer standing alone is paced by `spin_once`'s own budget
263    ///     instead of by its period, so a 10 ms timer under a 100 ms budget
264    ///     fires late — the pre-436 behaviour of every timer in the tree, and
265    ///     never unbounded, because `next_wake_bound_attributed_us` seeds its
266    ///     `min` with that budget. An image that wants its standalone ROS
267    ///     timer paced to the millisecond spins to the millisecond, which is
268    ///     what `bins/sim-clock-listener` already does at 5 ms.
269    pub(crate) fn remaining_is_wall_time(self) -> bool {
270        match self {
271            TimerClockSource::Steady | TimerClockSource::System => true,
272            TimerClockSource::Ros => false,
273        }
274    }
275}
276
277/// When a timer activation was DUE and when it actually RAN — issue 1974, the
278/// Jazzy `rcl_timer_call_info_t` / `rclcpp::TimerInfo` pair.
279///
280/// Both are nanoseconds on the TIMER'S OWN clock (the [`TimerClockSource`] it
281/// was registered with, which `Executor::timer_clock_source` reports):
282/// `/clock` time for a `Ros` timer, the wall clock for `System`, the
283/// platform's monotonic clock for `Steady`.
284///
285/// There is ONE producer: the executor's timer dispatcher fills this in at the
286/// moment it decides the activation is due, from the same `elapsed_us` /
287/// `period_us` comparison that decides it, and hands it by reference to the
288/// callback. The C (`rcl_timer_call_info_t`) and C++ (`rclcpp::TimerInfo`)
289/// surfaces copy these two numbers; neither computes its own.
290///
291/// * `actual_call_time` — the timer's clock as read for this dispatch.
292/// * `expected_call_time` — `actual_call_time` minus how far `elapsed_us` had
293///   run past the period, i.e. the deadline the dispatcher was serving. Under
294///   `TimerOverrunPolicy::CatchUp` each replayed activation reports its own
295///   (earlier) deadline.
296///
297/// `actual_call_time >= expected_call_time` always holds: an activation is
298/// dispatched only once `elapsed_us >= period_us`, so the difference — the
299/// activation's lateness, [`Self::lateness_ns`] — is never negative.
300///
301/// `Copy`, a few words, no allocation: it lives on the dispatcher's stack for
302/// the duration of the callback.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
304#[repr(C)]
305pub struct TimerCallInfo {
306    /// The deadline this activation was serving, in nanoseconds on the timer's
307    /// clock.
308    pub expected_call_time: i64,
309    /// When the dispatcher ran the callback, in nanoseconds on the timer's
310    /// clock.
311    pub actual_call_time: i64,
312    /// WHICH clock both times are on — the timer's registered source, so a
313    /// consumer that wraps the numbers in a typed time (C++'s
314    /// `rclcpp::Time(ns, clock_type)`) needs no second lookup.
315    pub clock: TimerClockSource,
316    /// Nanoseconds since the previous activation, or since registration or
317    /// the last reset for the first one — the argument rcl's
318    /// `rcl_timer_callback_t` receives (phase-515 C1). It is the dispatcher's
319    /// `elapsed_us` at the moment the activation became due, so it reads the
320    /// same as `Executor::timer_elapsed_us` just before the call, at the same
321    /// microsecond resolution (issue #505). Measured from the previous DUE
322    /// POINT, which for a timer running on time is the previous call.
323    pub since_last_call_ns: i64,
324}
325
326impl TimerCallInfo {
327    /// `actual_call_time - expected_call_time`: how late this activation ran.
328    /// Never negative (see the type's docs).
329    pub const fn lateness_ns(&self) -> i64 {
330        self.actual_call_time - self.expected_call_time
331    }
332}
333
334/// Timer mode (repeating, one-shot, or inert)
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub enum TimerMode {
337    /// Timer fires repeatedly at the specified period
338    Repeating,
339    /// Timer fires once then becomes inert
340    OneShot,
341    /// Timer never fires (placeholder)
342    Inert,
343}
344
345/// Timer callback as a bare function pointer (`no_std`, no heap required).
346pub type TimerCallbackFn = fn();
347
348/// Internal timer state
349///
350/// Stored in the node's timer collection.
351///
352/// ADOPT-BOUNDED against rclrs's `Timer` (`Arc<TimerState>`), which is created per
353/// use and dropped when done. Here a timer is an entry in the executor's STATIC
354/// table: declared once (in `register`) and re-armed in place with
355/// [`set_repeating`](Self::set_repeating) / [`set_oneshot`](Self::set_oneshot) /
356/// [`set_inert`](Self::set_inert), because a static table cannot grow a timer at
357/// runtime. The mode vocabulary is rclrs's; the lifetime is not.
358pub struct TimerState {
359    /// Timer period in milliseconds
360    period_ms: u64,
361    /// Time elapsed since last fire in milliseconds
362    elapsed_ms: u64,
363    /// Timer mode
364    mode: TimerMode,
365    /// Whether the timer is canceled
366    canceled: bool,
367    /// Callback function pointer (no heap)
368    callback_fn: Option<TimerCallbackFn>,
369}
370
371impl core::fmt::Debug for TimerState {
372    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
373        f.debug_struct("TimerState")
374            .field("period_ms", &self.period_ms)
375            .field("elapsed_ms", &self.elapsed_ms)
376            .field("mode", &self.mode)
377            .field("canceled", &self.canceled)
378            .field("has_callback_fn", &self.callback_fn.is_some())
379            .finish()
380    }
381}
382
383impl TimerState {
384    /// Create a new timer state with function pointer callback
385    pub fn new_with_fn(period: TimerDuration, mode: TimerMode, callback: TimerCallbackFn) -> Self {
386        Self {
387            period_ms: period.as_millis(),
388            elapsed_ms: 0,
389            mode,
390            canceled: false,
391            callback_fn: Some(callback),
392        }
393    }
394
395    /// Create an inert timer state
396    pub fn new_inert(period: TimerDuration) -> Self {
397        Self {
398            period_ms: period.as_millis(),
399            elapsed_ms: 0,
400            mode: TimerMode::Inert,
401            canceled: false,
402            callback_fn: None,
403        }
404    }
405
406    /// Get the timer period
407    pub fn period(&self) -> TimerDuration {
408        TimerDuration::from_millis(self.period_ms)
409    }
410
411    /// Get the timer mode
412    pub fn mode(&self) -> TimerMode {
413        self.mode
414    }
415
416    /// Check if the timer is canceled
417    pub fn is_canceled(&self) -> bool {
418        self.canceled
419    }
420
421    /// Cancel the timer
422    pub fn cancel(&mut self) {
423        self.canceled = true;
424    }
425
426    /// Reset the timer (uncancels and resets elapsed time)
427    pub fn reset(&mut self) {
428        self.canceled = false;
429        self.elapsed_ms = 0;
430    }
431
432    /// Check if the timer is ready to fire
433    pub fn is_ready(&self) -> bool {
434        !self.canceled && self.mode != TimerMode::Inert && self.elapsed_ms >= self.period_ms
435    }
436
437    /// Get time until next call (0 if ready)
438    pub fn time_until_next_call(&self) -> TimerDuration {
439        if self.canceled || self.mode == TimerMode::Inert {
440            return TimerDuration::from_millis(u64::MAX);
441        }
442        if self.elapsed_ms >= self.period_ms {
443            TimerDuration::zero()
444        } else {
445            TimerDuration::from_millis(self.period_ms - self.elapsed_ms)
446        }
447    }
448
449    /// Get time since last call
450    pub fn time_since_last_call(&self) -> TimerDuration {
451        TimerDuration::from_millis(self.elapsed_ms)
452    }
453
454    /// Set callback to function pointer
455    pub fn set_callback_fn(&mut self, callback: TimerCallbackFn) {
456        self.callback_fn = Some(callback);
457    }
458
459    /// Set timer to repeating mode
460    pub fn set_repeating(&mut self) {
461        self.mode = TimerMode::Repeating;
462    }
463
464    /// Set timer to one-shot mode
465    pub fn set_oneshot(&mut self) {
466        self.mode = TimerMode::OneShot;
467    }
468
469    /// Set timer to inert mode
470    pub fn set_inert(&mut self) {
471        self.mode = TimerMode::Inert;
472    }
473
474    /// Update elapsed time and return true if timer should fire
475    #[allow(dead_code)] // Used by ConnectedNode when zenoh feature is enabled
476    pub(crate) fn update(&mut self, delta_ms: u64) -> bool {
477        if self.canceled || self.mode == TimerMode::Inert {
478            return false;
479        }
480
481        self.elapsed_ms = self.elapsed_ms.saturating_add(delta_ms);
482
483        self.elapsed_ms >= self.period_ms
484    }
485
486    /// Fire the timer callback and handle mode-specific behavior
487    #[allow(dead_code)] // Used by ConnectedNode when zenoh feature is enabled
488    pub(crate) fn fire(&mut self) {
489        // Execute callback
490        if let Some(ref callback) = self.callback_fn {
491            callback();
492        }
493
494        // Handle mode-specific behavior
495        match self.mode {
496            TimerMode::Repeating => {
497                // Reset elapsed time for next period
498                self.elapsed_ms = self.elapsed_ms.saturating_sub(self.period_ms);
499            }
500            TimerMode::OneShot => {
501                // Become inert after firing
502                self.mode = TimerMode::Inert;
503                self.elapsed_ms = 0;
504            }
505            TimerMode::Inert => {
506                // Should not reach here
507            }
508        }
509    }
510}
511
512/// A handle to a timer stored in a node
513///
514/// This is a lightweight handle that references a timer by index.
515/// The actual timer state lives in the executor's timer table (it lived in
516/// the ~~`ConnectedNode`~~ before the executor API).
517///
518/// # Type Parameters
519///
520/// - `C`: Callback type marker (function pointer or boxed)
521#[derive(Debug, Clone, Copy)]
522pub struct TimerHandle<C = TimerCallbackFn> {
523    /// Timer index in the node's timer collection
524    index: usize,
525    /// Phantom data for callback type
526    _marker: PhantomData<C>,
527}
528
529impl<C> TimerHandle<C> {
530    /// Create a new timer handle
531    #[allow(dead_code)] // Used by ConnectedNode when zenoh feature is enabled
532    pub(crate) fn new(index: usize) -> Self {
533        Self {
534            index,
535            _marker: PhantomData,
536        }
537    }
538
539    /// Get the timer index
540    pub fn index(&self) -> usize {
541        self.index
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    #[test]
550    fn test_timer_duration() {
551        let d = TimerDuration::from_millis(1500);
552        assert_eq!(d.as_millis(), 1500);
553        assert_eq!(d.as_secs(), 1);
554
555        let d2 = TimerDuration::from_secs(2);
556        assert_eq!(d2.as_millis(), 2000);
557
558        let d3 = TimerDuration::from_micros(5500);
559        assert_eq!(d3.as_millis(), 5);
560    }
561
562    #[test]
563    fn test_timer_duration_conversion() {
564        let ros_dur = nros_core::Duration::from_millis(1500);
565        let timer_dur: TimerDuration = ros_dur.into();
566        assert_eq!(timer_dur.as_millis(), 1500);
567
568        let back: nros_core::Duration = timer_dur.into();
569        assert_eq!(back.sec, 1);
570        assert_eq!(back.nanosec, 500_000_000);
571    }
572
573    fn test_callback() {
574        // Empty callback for testing
575    }
576
577    #[test]
578    fn test_timer_state_repeating() {
579        let mut state = TimerState::new_with_fn(
580            TimerDuration::from_millis(100),
581            TimerMode::Repeating,
582            test_callback,
583        );
584
585        assert_eq!(state.period().as_millis(), 100);
586        assert_eq!(state.mode(), TimerMode::Repeating);
587        assert!(!state.is_canceled());
588        assert!(!state.is_ready());
589
590        // Advance time
591        assert!(!state.update(50));
592        assert!(!state.is_ready());
593        assert_eq!(state.time_until_next_call().as_millis(), 50);
594        assert_eq!(state.time_since_last_call().as_millis(), 50);
595
596        // Advance to ready
597        assert!(state.update(50));
598        assert!(state.is_ready());
599        assert_eq!(state.time_until_next_call().as_millis(), 0);
600
601        // Fire and check it repeats
602        state.fire();
603        assert_eq!(state.mode(), TimerMode::Repeating);
604        assert!(!state.is_ready());
605    }
606
607    #[test]
608    fn test_timer_state_oneshot() {
609        let mut state = TimerState::new_with_fn(
610            TimerDuration::from_millis(100),
611            TimerMode::OneShot,
612            test_callback,
613        );
614
615        assert_eq!(state.mode(), TimerMode::OneShot);
616
617        // Advance to ready
618        state.update(100);
619        assert!(state.is_ready());
620
621        // Fire and check it becomes inert
622        state.fire();
623        assert_eq!(state.mode(), TimerMode::Inert);
624        assert!(!state.is_ready());
625    }
626
627    #[test]
628    fn test_timer_state_inert() {
629        let state = TimerState::new_inert(TimerDuration::from_millis(100));
630
631        assert_eq!(state.mode(), TimerMode::Inert);
632        assert!(!state.is_ready());
633    }
634
635    #[test]
636    fn test_timer_cancel_reset() {
637        let mut state = TimerState::new_with_fn(
638            TimerDuration::from_millis(100),
639            TimerMode::Repeating,
640            test_callback,
641        );
642
643        state.update(50);
644        state.cancel();
645        assert!(state.is_canceled());
646        assert!(!state.is_ready());
647
648        state.update(100);
649        assert!(!state.is_ready()); // Still canceled
650
651        state.reset();
652        assert!(!state.is_canceled());
653        assert_eq!(state.time_since_last_call().as_millis(), 0);
654    }
655
656    #[test]
657    fn test_timer_mode_changes() {
658        let mut state = TimerState::new_with_fn(
659            TimerDuration::from_millis(100),
660            TimerMode::Repeating,
661            test_callback,
662        );
663
664        state.set_oneshot();
665        assert_eq!(state.mode(), TimerMode::OneShot);
666
667        state.set_inert();
668        assert_eq!(state.mode(), TimerMode::Inert);
669
670        state.set_repeating();
671        assert_eq!(state.mode(), TimerMode::Repeating);
672    }
673
674    #[test]
675    fn test_timer_handle() {
676        let handle: TimerHandle = TimerHandle::new(5);
677        assert_eq!(handle.index(), 5);
678    }
679}
680
681// =============================================================================
682// Ghost model validation
683// =============================================================================
684
685#[cfg(test)]
686mod ghost_checks {
687    use super::*;
688    use nros_ghost_types::{TimerGhost, TimerModeGhost};
689
690    /// Structural check: map TimerMode to TimerModeGhost.
691    /// If a variant is added or removed, this fails to compile.
692    fn ghost_mode(m: &TimerMode) -> TimerModeGhost {
693        match m {
694            TimerMode::Repeating => TimerModeGhost::Repeating,
695            TimerMode::OneShot => TimerModeGhost::OneShot,
696            TimerMode::Inert => TimerModeGhost::Inert,
697        }
698    }
699
700    /// Structural check: construct TimerGhost from TimerState private fields.
701    /// If a field is renamed or retyped, this fails to compile.
702    fn ghost_from_timer(t: &TimerState) -> TimerGhost {
703        TimerGhost {
704            period_ms: t.period_ms,
705            elapsed_ms: t.elapsed_ms,
706            mode: ghost_mode(&t.mode),
707            canceled: t.canceled,
708        }
709    }
710
711    fn test_callback() {}
712
713    #[test]
714    fn ghost_new_state() {
715        let state = TimerState::new_with_fn(
716            TimerDuration::from_millis(100),
717            TimerMode::Repeating,
718            test_callback,
719        );
720        let ghost = ghost_from_timer(&state);
721        assert_eq!(ghost.period_ms, 100);
722        assert_eq!(ghost.elapsed_ms, 0);
723        assert_eq!(ghost.mode, TimerModeGhost::Repeating);
724        assert!(!ghost.canceled);
725    }
726
727    #[test]
728    fn ghost_update_accumulates() {
729        let mut state = TimerState::new_with_fn(
730            TimerDuration::from_millis(100),
731            TimerMode::Repeating,
732            test_callback,
733        );
734        state.update(30);
735        let ghost = ghost_from_timer(&state);
736        assert_eq!(ghost.elapsed_ms, 30);
737
738        state.update(25);
739        let ghost2 = ghost_from_timer(&state);
740        assert_eq!(ghost2.elapsed_ms, 55);
741    }
742
743    #[test]
744    fn ghost_canceled_no_fire() {
745        let mut state = TimerState::new_with_fn(
746            TimerDuration::from_millis(100),
747            TimerMode::Repeating,
748            test_callback,
749        );
750        state.cancel();
751        let fired = state.update(200);
752        assert!(!fired);
753        let ghost = ghost_from_timer(&state);
754        assert!(ghost.canceled);
755    }
756
757    #[test]
758    fn ghost_inert_no_fire() {
759        let mut state = TimerState::new_inert(TimerDuration::from_millis(100));
760        let fired = state.update(200);
761        assert!(!fired);
762        let ghost = ghost_from_timer(&state);
763        assert_eq!(ghost.mode, TimerModeGhost::Inert);
764    }
765
766    #[test]
767    fn ghost_oneshot_becomes_inert() {
768        let mut state = TimerState::new_with_fn(
769            TimerDuration::from_millis(100),
770            TimerMode::OneShot,
771            test_callback,
772        );
773        state.update(100);
774        state.fire();
775        let ghost = ghost_from_timer(&state);
776        assert_eq!(ghost.mode, TimerModeGhost::Inert);
777    }
778}