umsh_hal/
wall_clock.rs

1//! The device's wall clock: what time it is, where that came from, and
2//! whether it is known at all.
3//!
4//! A device of this class has a monotonic timer and, usually, nothing
5//! else. Wall-clock time arrives from outside — a GNSS fix, a
6//! battery-backed real-time clock, a host that was asked — and is held as
7//! an *offset* from the monotonic timer rather than as a counter of its
8//! own, so it costs nothing to maintain and cannot drift relative to
9//! everything else the device schedules.
10//!
11//! Two things here are policy rather than mechanism, and both are
12//! deliberately in one place:
13//!
14//! * **Not knowing is a state.** [`WallClockState::now`] returns `None`
15//!   until something sets the clock. Callers must not substitute zero, a
16//!   build timestamp, or any other plausible-looking value — a device
17//!   that does not know the time **must not** display one.
18//! * **Sources outrank each other.** [`WallClockState::apply`] holds the
19//!   whole precedence rule, so no caller has to remember it and no two
20//!   callers can disagree about it.
21//!
22//! [`WallClockState`] is pure and testable on any host. The module-level
23//! statics behind the `embassy` feature are the single live instance the
24//! firmware shares.
25
26/// Where a wall-clock reading came from.
27///
28/// The ordering is not a precedence ranking — see
29/// [`WallClockState::apply`], where the rule depends on more than the
30/// source alone.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum TimeSource {
33    /// A host wrote it, or a person did. The most authoritative source
34    /// there is: somebody decided this was the time.
35    Manual,
36    /// The time carried by a GNSS position fix.
37    GnssFix,
38    /// The GNSS receiver's own real-time-clock domain, read at boot. On
39    /// boards where that domain is the only clock that survives a power
40    /// cycle, it *is* the board's real-time clock.
41    GnssRtc,
42    /// A dedicated battery-backed real-time clock on the board.
43    ExternalRtc,
44}
45
46impl TimeSource {
47    /// Whether the reading ultimately came from the GNSS receiver, and is
48    /// therefore governed by `PROP_GNSS_TIME_TRUST`.
49    pub const fn is_receiver_derived(self) -> bool {
50        matches!(self, Self::GnssFix | Self::GnssRtc)
51    }
52}
53
54/// How far the clock must move for the change to be worth telling anyone
55/// about, in seconds.
56///
57/// A receiver re-synchronizing a clock it already agrees with produces a
58/// sub-second correction every time it gets a fix. Announcing those would
59/// spend a frame to say nothing, and writing them to a real-time clock
60/// would spend flash-equivalent wear to the same effect.
61pub const NOTABLE_STEP_SECS: u32 = 2;
62
63/// What [`WallClockState::apply`] did.
64#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65pub enum Update {
66    /// The device went from not knowing the time to knowing it. Always
67    /// worth acting on: it is the transition that lets a display start
68    /// showing a clock.
69    Set,
70    /// The clock moved by at least [`NOTABLE_STEP_SECS`].
71    Stepped {
72        /// What the clock read immediately before.
73        previous: u32,
74    },
75    /// The clock was already this time, near enough. Applied, but not
76    /// worth announcing or persisting.
77    Unchanged,
78    /// Nothing changed: the source is not trusted, or a restore arrived
79    /// at a clock that was already set.
80    Refused,
81}
82
83impl Update {
84    /// Whether this change is worth announcing to a host and worth
85    /// pushing into a real-time clock.
86    pub const fn is_notable(self) -> bool {
87        matches!(self, Self::Set | Self::Stepped { .. })
88    }
89
90    /// Whether the clock changed at all.
91    pub const fn applied(self) -> bool {
92        !matches!(self, Self::Refused)
93    }
94}
95
96/// The wall clock, as an offset from a monotonic millisecond timer.
97///
98/// Every method takes the current monotonic reading rather than fetching
99/// one, which is what keeps this testable without a clock at all.
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub struct WallClockState {
102    /// Unix milliseconds minus monotonic milliseconds, or `None` when the
103    /// device does not know what time it is.
104    ///
105    /// Held in milliseconds rather than seconds so that a clock set from
106    /// a sub-second-accurate source does not lose that accuracy the
107    /// moment it is stored, and signed because the monotonic timer starts
108    /// at zero on a device whose wall clock is in 2026.
109    offset_ms: Option<i64>,
110    /// Minutes east of UTC (`PROP_TZ_OFFSET`). Always known, even while
111    /// the time is not — see the module documentation.
112    tz_offset_min: i16,
113    /// Where the current reading came from, or `None` while unset.
114    source: Option<TimeSource>,
115}
116
117impl Default for WallClockState {
118    fn default() -> Self {
119        Self::UNKNOWN
120    }
121}
122
123impl WallClockState {
124    /// A device that does not know what time it is, at UTC.
125    pub const UNKNOWN: Self = Self {
126        offset_ms: None,
127        tz_offset_min: 0,
128        source: None,
129    };
130
131    /// The current time in Unix seconds, or `None` when unknown.
132    pub const fn now(&self, monotonic_ms: u64) -> Option<u32> {
133        match self.now_ms(monotonic_ms) {
134            Some(millis) => Some((millis / 1_000) as u32),
135            None => None,
136        }
137    }
138
139    /// The current time in Unix milliseconds, or `None` when unknown.
140    ///
141    /// A clock whose offset would put it before the epoch or past what
142    /// `PROP_TIME` can carry reads as unknown rather than as a wrapped
143    /// value: those are only reachable from an absurd set, and reporting
144    /// a wrapped time is worse than reporting none.
145    pub const fn now_ms(&self, monotonic_ms: u64) -> Option<u64> {
146        let Some(offset) = self.offset_ms else {
147            return None;
148        };
149        let millis = monotonic_ms as i64 + offset;
150        if millis < 0 || millis > u32::MAX as i64 * 1_000 {
151            return None;
152        }
153        Some(millis as u64)
154    }
155
156    /// The local wall-clock reading in seconds — the time shifted by the
157    /// configured zone — or `None` when the time is unknown.
158    ///
159    /// A reading for presentation, not an instant: it does not name a
160    /// point in time on its own, and nothing should send it anywhere.
161    pub const fn local_now(&self, monotonic_ms: u64) -> Option<u32> {
162        let Some(utc) = self.now(monotonic_ms) else {
163            return None;
164        };
165        let local = utc as i64 + self.tz_offset_min as i64 * 60;
166        if local < 0 || local > u32::MAX as i64 {
167            return None;
168        }
169        Some(local as u32)
170    }
171
172    /// Whether the device knows what time it is.
173    pub const fn is_set(&self) -> bool {
174        self.offset_ms.is_some()
175    }
176
177    /// Where the current reading came from, or `None` while unset.
178    pub const fn source(&self) -> Option<TimeSource> {
179        self.source
180    }
181
182    /// Minutes east of UTC.
183    pub const fn tz_offset_min(&self) -> i16 {
184        self.tz_offset_min
185    }
186
187    /// Set the time zone. Independent of the clock: the zone is known
188    /// from commissioning, and changing it never makes the time known or
189    /// unknown.
190    pub const fn set_tz(&mut self, minutes: i16) {
191        self.tz_offset_min = minutes;
192    }
193
194    /// Return the device to not knowing what time it is.
195    ///
196    /// The zone survives, because where the device is has not changed.
197    pub const fn clear(&mut self) {
198        self.offset_ms = None;
199        self.source = None;
200    }
201
202    /// Offer a reading from `source`, applying the precedence rule.
203    ///
204    /// `trust_receiver` is `PROP_GNSS_TIME_TRUST`. The rule:
205    ///
206    /// * [`TimeSource::Manual`] always applies. The operator is the more
207    ///   authoritative source by definition, including while the receiver
208    ///   is distrusted — distrusting the sky is *why* somebody would set
209    ///   the clock by hand.
210    /// * [`TimeSource::GnssFix`] applies whenever the receiver is
211    ///   trusted, overwriting whatever was there. Every fix re-synchronizes
212    ///   the clock, which is what keeps it good over a long deployment.
213    /// * [`TimeSource::GnssRtc`] applies only when trusted **and** the
214    ///   clock is unset. It is a boot-time restore, not a correction: the
215    ///   receiver may have re-synchronized its own RTC from a bad sky
216    ///   while the device was running, so it must not displace a reading
217    ///   that is already in hand.
218    /// * [`TimeSource::ExternalRtc`] applies only when the clock is
219    ///   unset, for the same reason, but is not subject to the receiver
220    ///   trust flag — it is not the receiver.
221    pub const fn apply(
222        &mut self,
223        epoch: u32,
224        monotonic_ms: u64,
225        source: TimeSource,
226        trust_receiver: bool,
227    ) -> Update {
228        let permitted = match source {
229            TimeSource::Manual => true,
230            TimeSource::GnssFix => trust_receiver,
231            TimeSource::GnssRtc => trust_receiver && !self.is_set(),
232            TimeSource::ExternalRtc => !self.is_set(),
233        };
234        if !permitted {
235            return Update::Refused;
236        }
237        let previous = self.now(monotonic_ms);
238        self.offset_ms = Some(epoch as i64 * 1_000 - monotonic_ms as i64);
239        self.source = Some(source);
240        match previous {
241            None => Update::Set,
242            Some(previous) => {
243                let delta = if epoch > previous {
244                    epoch - previous
245                } else {
246                    previous - epoch
247                };
248                if delta >= NOTABLE_STEP_SECS {
249                    Update::Stepped { previous }
250                } else {
251                    Update::Unchanged
252                }
253            }
254        }
255    }
256}
257
258#[cfg(feature = "embassy")]
259mod live {
260    use core::cell::Cell;
261
262    use embassy_sync::blocking_mutex::CriticalSectionMutex;
263    use embassy_time::Instant;
264
265    use super::{TimeSource, Update, WallClockState};
266
267    /// The device's one wall clock.
268    ///
269    /// A single shared instance rather than something threaded through
270    /// every consumer: the ULCP session, the GNSS pump, the display, and
271    /// whatever stamps outgoing identities must agree about what time it
272    /// is, and a value each of them held separately would eventually not.
273    static CLOCK: CriticalSectionMutex<Cell<WallClockState>> =
274        CriticalSectionMutex::new(Cell::new(WallClockState::UNKNOWN));
275
276    fn with<R>(f: impl FnOnce(&mut WallClockState) -> R) -> R {
277        CLOCK.lock(|cell| {
278            let mut state = cell.get();
279            let result = f(&mut state);
280            cell.set(state);
281            result
282        })
283    }
284
285    /// The current time in Unix seconds, or `None` when the device does
286    /// not know what time it is.
287    pub fn now() -> Option<u32> {
288        CLOCK.lock(|cell| cell.get().now(Instant::now().as_millis()))
289    }
290
291    /// The whole clock, for a caller that wants the reading, the zone,
292    /// and the source without three separate critical sections.
293    pub fn snapshot() -> WallClockState {
294        CLOCK.lock(|cell| cell.get())
295    }
296
297    /// Whether the device knows what time it is.
298    pub fn is_set() -> bool {
299        CLOCK.lock(|cell| cell.get().is_set())
300    }
301
302    /// The local hour and minute, or `None` when the time is unknown.
303    ///
304    /// The one accessor a display needs, and the reason it returns an
305    /// `Option`: a panel that cannot obtain a reading has nothing to draw,
306    /// which is exactly the required behavior rather than an error to
307    /// work around.
308    pub fn local_hhmm() -> Option<(u8, u8)> {
309        let local = CLOCK.lock(|cell| cell.get().local_now(Instant::now().as_millis()))?;
310        let minutes_of_day = (local % 86_400) / 60;
311        Some(((minutes_of_day / 60) as u8, (minutes_of_day % 60) as u8))
312    }
313
314    /// Milliseconds until the next minute boundary, or `None` when the
315    /// time is unknown.
316    ///
317    /// What a display arms its redraw timer on. Computed in UTC, which
318    /// costs nothing in correctness: every real time-zone offset is a
319    /// whole number of minutes, so the local minute always turns over
320    /// with the UTC one — including in the half- and quarter-hour zones.
321    pub fn millis_to_next_minute() -> Option<u32> {
322        let monotonic = Instant::now().as_millis();
323        let millis = CLOCK.lock(|cell| cell.get().now_ms(monotonic))?;
324        Some(60_000 - (millis % 60_000) as u32)
325    }
326
327    /// Minutes east of UTC.
328    pub fn tz_offset_min() -> i16 {
329        CLOCK.lock(|cell| cell.get().tz_offset_min())
330    }
331
332    /// Set the time zone.
333    pub fn set_tz(minutes: i16) {
334        with(|state| state.set_tz(minutes));
335    }
336
337    /// Offer a reading, applying the precedence rule. See
338    /// [`WallClockState::apply`].
339    pub fn apply(epoch: u32, source: TimeSource, trust_receiver: bool) -> Update {
340        let monotonic = Instant::now().as_millis();
341        with(|state| state.apply(epoch, monotonic, source, trust_receiver))
342    }
343
344    /// Set the clock from a host or an operator.
345    ///
346    /// Separate from [`apply`] because [`TimeSource::Manual`] outranks
347    /// every receiver-derived source unconditionally: there is no trust
348    /// flag to pass, and a caller that had to pass one would be inventing
349    /// an answer to a question that does not apply.
350    pub fn set_manual(epoch: u32) -> Update {
351        apply(epoch, TimeSource::Manual, true)
352    }
353
354    /// Return the device to not knowing what time it is.
355    pub fn clear() {
356        with(WallClockState::clear);
357    }
358}
359
360#[cfg(feature = "embassy")]
361pub use live::{
362    apply, clear, is_set, local_hhmm, millis_to_next_minute, now, set_manual, set_tz, snapshot,
363    tz_offset_min,
364};
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    /// A plausible monotonic reading: the device has been up a while.
371    const UP: u64 = 4_000;
372    const T0: u32 = 1_780_000_000;
373
374    #[test]
375    fn a_fresh_clock_knows_nothing_but_its_zone() {
376        let clock = WallClockState::UNKNOWN;
377        assert!(!clock.is_set());
378        assert_eq!(clock.now(UP), None);
379        assert_eq!(clock.local_now(UP), None);
380        assert_eq!(clock.source(), None);
381        // The zone is known from the start, which is the whole reason it
382        // is a separate property.
383        assert_eq!(clock.tz_offset_min(), 0);
384    }
385
386    #[test]
387    fn the_clock_advances_with_the_monotonic_timer() {
388        let mut clock = WallClockState::UNKNOWN;
389        assert_eq!(clock.apply(T0, UP, TimeSource::Manual, true), Update::Set);
390        assert_eq!(clock.now(UP), Some(T0));
391        assert_eq!(clock.now(UP + 90_000), Some(T0 + 90));
392        assert_eq!(clock.source(), Some(TimeSource::Manual));
393    }
394
395    #[test]
396    fn the_zone_shifts_the_reading_without_touching_the_instant() {
397        let mut clock = WallClockState::UNKNOWN;
398        clock.apply(T0, UP, TimeSource::Manual, true);
399        clock.set_tz(-480);
400        assert_eq!(clock.now(UP), Some(T0), "the instant is unchanged");
401        assert_eq!(clock.local_now(UP), Some(T0 - 8 * 3_600));
402        clock.set_tz(330);
403        assert_eq!(clock.local_now(UP), Some(T0 + 5 * 3_600 + 1_800));
404    }
405
406    #[test]
407    fn clearing_forgets_the_time_and_keeps_the_zone() {
408        let mut clock = WallClockState::UNKNOWN;
409        clock.apply(T0, UP, TimeSource::Manual, true);
410        clock.set_tz(-300);
411        clock.clear();
412        assert!(!clock.is_set());
413        assert_eq!(clock.now(UP), None);
414        assert_eq!(clock.source(), None);
415        assert_eq!(clock.tz_offset_min(), -300, "where it is has not changed");
416    }
417
418    #[test]
419    fn a_manual_set_outranks_everything_including_distrust() {
420        let mut clock = WallClockState::UNKNOWN;
421        clock.apply(T0, UP, TimeSource::GnssFix, true);
422        // Distrusting the receiver is exactly why somebody sets a clock
423        // by hand, so the flag must not block them.
424        assert_eq!(
425            clock.apply(T0 + 600, UP, TimeSource::Manual, false),
426            Update::Stepped { previous: T0 }
427        );
428        assert_eq!(clock.now(UP), Some(T0 + 600));
429        assert_eq!(clock.source(), Some(TimeSource::Manual));
430    }
431
432    #[test]
433    fn fixes_resynchronize_a_running_clock_but_only_while_trusted() {
434        let mut clock = WallClockState::UNKNOWN;
435        clock.apply(T0, UP, TimeSource::Manual, true);
436        // Every trusted fix refreshes the clock, which is how it stays
437        // good across a long deployment.
438        assert_eq!(
439            clock.apply(T0 + 30, UP, TimeSource::GnssFix, true),
440            Update::Stepped { previous: T0 }
441        );
442        assert_eq!(clock.now(UP), Some(T0 + 30));
443
444        // With trust withdrawn, nothing the receiver says lands.
445        assert_eq!(
446            clock.apply(T0 + 90_000, UP, TimeSource::GnssFix, false),
447            Update::Refused
448        );
449        assert_eq!(clock.now(UP), Some(T0 + 30));
450        assert_eq!(clock.source(), Some(TimeSource::GnssFix));
451    }
452
453    #[test]
454    fn real_time_clocks_restore_but_never_correct() {
455        // A restore into an unset clock is the whole point of having one.
456        let mut clock = WallClockState::UNKNOWN;
457        assert_eq!(
458            clock.apply(T0, UP, TimeSource::ExternalRtc, true),
459            Update::Set
460        );
461        // A second restore must not displace a reading already in hand.
462        assert_eq!(
463            clock.apply(T0 - 500, UP, TimeSource::ExternalRtc, true),
464            Update::Refused
465        );
466        assert_eq!(clock.now(UP), Some(T0));
467
468        // The receiver's own RTC behaves the same, and is additionally
469        // subject to the trust flag: it may have re-synchronized itself
470        // from a bad sky while the device was running.
471        let mut clock = WallClockState::UNKNOWN;
472        assert_eq!(
473            clock.apply(T0, UP, TimeSource::GnssRtc, false),
474            Update::Refused
475        );
476        assert!(!clock.is_set());
477        assert_eq!(clock.apply(T0, UP, TimeSource::GnssRtc, true), Update::Set);
478        assert_eq!(clock.source(), Some(TimeSource::GnssRtc));
479    }
480
481    #[test]
482    fn only_real_movement_is_worth_announcing() {
483        let mut clock = WallClockState::UNKNOWN;
484        assert!(clock.apply(T0, UP, TimeSource::Manual, true).is_notable());
485        // A receiver agreeing with the clock to within a second or two
486        // still applies; it is just not news.
487        let update = clock.apply(T0 + 1, UP, TimeSource::GnssFix, true);
488        assert_eq!(update, Update::Unchanged);
489        assert!(!update.is_notable());
490        assert!(update.applied());
491        assert_eq!(clock.now(UP), Some(T0 + 1));
492
493        let update = clock.apply(T0 + 1 + NOTABLE_STEP_SECS, UP, TimeSource::GnssFix, true);
494        assert!(update.is_notable());
495        // Backwards counts the same as forwards.
496        let update = clock.apply(T0, UP, TimeSource::GnssFix, true);
497        assert!(update.is_notable());
498        assert!(!Update::Refused.applied());
499    }
500
501    #[test]
502    fn an_absurd_offset_reads_as_unknown_rather_than_wrapping() {
503        // A clock set near the end of the representable range, then left
504        // to run past it, has nothing honest to report.
505        let mut clock = WallClockState::UNKNOWN;
506        clock.apply(u32::MAX, 0, TimeSource::Manual, true);
507        assert_eq!(clock.now(0), Some(u32::MAX));
508        assert_eq!(clock.now(60_000), None);
509    }
510
511    #[test]
512    fn receiver_derived_sources_are_the_ones_trust_governs() {
513        assert!(TimeSource::GnssFix.is_receiver_derived());
514        assert!(TimeSource::GnssRtc.is_receiver_derived());
515        assert!(!TimeSource::Manual.is_receiver_derived());
516        assert!(!TimeSource::ExternalRtc.is_receiver_derived());
517    }
518}