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}