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}