Skip to main content

sedsnet/
timesync.rs

1//! Time synchronization primitives, packet helpers, and network-clock utilities.
2//!
3//! This module:
4//! - Defines the public time sync configuration and role-selection types.
5//! - Encodes and decodes time sync announce/request/response packets.
6//! - Tracks remote time sources and elects the current synchronization leader.
7//! - Maintains partial or complete network time assembled from one or more sources.
8//! - Provides a slewed clock that converges toward sampled network time without hard jumps.
9
10use alloc::boxed::Box;
11use alloc::collections::BTreeMap;
12use alloc::string::String;
13use alloc::vec::Vec;
14use core::mem::size_of;
15
16use crate::router::{Router, encode_slice_le};
17use crate::{
18    DataEndpoint, DataType, TelemetryError, TelemetryResult, config::runtime_device_identifier,
19    message_meta, packet::Packet,
20};
21
22/// Number of `u64` words carried by a time sync announce payload.
23pub const TIMESYNC_ANNOUNCE_WORDS: usize = 2;
24/// Number of `u64` words carried by a time sync request payload.
25pub const TIMESYNC_REQUEST_WORDS: usize = 2;
26/// Number of `u64` words carried by a time sync response payload.
27pub const TIMESYNC_RESPONSE_WORDS: usize = 4;
28/// Synthetic source identifier used for a remotely learned full-network-time source.
29pub const INTERNAL_TIMESYNC_SOURCE_ID: &str = "__timesync_remote__";
30/// Synthetic source identifier used for a locally supplied complete date+time source.
31pub const LOCAL_TIMESYNC_FULL_SOURCE_ID: &str = "__timesync_local_full__";
32/// Synthetic source identifier used for a locally supplied date-only source.
33pub const LOCAL_TIMESYNC_DATE_SOURCE_ID: &str = "__timesync_local_date__";
34/// Synthetic source identifier used for a locally supplied time-of-day source.
35pub const LOCAL_TIMESYNC_TOD_SOURCE_ID: &str = "__timesync_local_tod__";
36/// Synthetic source identifier used for a locally supplied sub-second source.
37pub const LOCAL_TIMESYNC_SUBSEC_SOURCE_ID: &str = "__timesync_local_subsec__";
38
39/// Declares how a node participates in time synchronization.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum TimeSyncRole {
42    /// Never originates time unless promoted by consumer fallback rules.
43    Consumer,
44    /// Always advertises itself as a time source.
45    Source,
46    /// Advertises itself only when it has usable time and no better source is active.
47    Auto,
48}
49
50/// Configures time sync behavior for a router or tracker.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct TimeSyncConfig {
53    /// Local participation mode used for leader election and serving behavior.
54    pub role: TimeSyncRole,
55    /// Lower values are preferred during source selection.
56    pub priority: u64,
57    /// Maximum age before an announced source is considered inactive.
58    pub source_timeout_ms: u64,
59    /// Interval between local announce packets while acting as leader.
60    pub announce_interval_ms: u64,
61    /// Interval between request packets while following a remote source.
62    pub request_interval_ms: u64,
63    /// Allows a consumer with usable local time to promote itself when no remote source exists.
64    pub consumer_promotion_enabled: bool,
65    /// Maximum slew rate applied by [`SlewedNetworkClock`] in parts per million.
66    pub max_slew_ppm: u32,
67}
68
69impl Default for TimeSyncConfig {
70    fn default() -> Self {
71        Self {
72            role: TimeSyncRole::Consumer,
73            priority: 100,
74            source_timeout_ms: 5_000,
75            announce_interval_ms: 1_000,
76            request_interval_ms: 1_000,
77            consumer_promotion_enabled: true,
78            max_slew_ppm: 50_000,
79        }
80    }
81}
82
83/// Describes the currently elected time sync leader.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum TimeSyncLeader {
86    /// The local node is the current leader at the given effective priority.
87    Local { priority: u64 },
88    /// A remote node is the current leader.
89    Remote(TimeSyncSource),
90}
91
92/// Snapshot of a remote time source learned from announce traffic.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct TimeSyncSource {
95    /// Sender identifier for the remote source.
96    pub sender: String,
97    /// Advertised source priority. Lower is better.
98    pub priority: u64,
99    /// Monotonic receive time, in milliseconds, of the last announce.
100    pub last_announce_ms: u64,
101    /// Network time value carried by the last announce, in Unix milliseconds.
102    pub last_time_ms: u64,
103}
104
105/// Reports whether tracker state changed after processing or pruning sources.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum TimeSyncUpdate {
108    /// The active source selection stayed the same.
109    NoChange,
110    /// The active source selection changed.
111    SourceChanged,
112}
113
114/// Decoded fields from a time sync request packet.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct TimeSyncRequestFields {
117    /// Request sequence number chosen by the requester.
118    pub seq: u64,
119    /// Requester transmit timestamp in monotonic milliseconds.
120    pub t1_ms: u64,
121}
122
123/// Decoded fields from a time sync response packet.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct TimeSyncResponseFields {
126    /// Sequence number copied from the corresponding request.
127    pub seq: u64,
128    /// Original requester transmit timestamp in monotonic milliseconds.
129    pub t1_ms: u64,
130    /// Responder receive timestamp in network milliseconds.
131    pub t2_ms: u64,
132    /// Responder transmit timestamp in network milliseconds.
133    pub t3_ms: u64,
134}
135
136/// Decoded fields from a time sync announce packet.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub struct TimeSyncAnnounceFields {
139    /// Advertised source priority. Lower is better.
140    pub priority: u64,
141    /// Advertised network time in Unix milliseconds.
142    pub time_ms: u64,
143}
144
145/// Result of a four-timestamp offset/delay computation.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct TimeSyncSample {
148    /// Estimated clock offset in milliseconds from requester to responder.
149    pub offset_ms: i64,
150    /// Estimated round-trip path delay in milliseconds.
151    pub delay_ms: u64,
152}
153
154/// Partial calendar/time-of-day reading assembled from one or more sources.
155#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
156pub struct PartialNetworkTime {
157    /// Calendar year, if known.
158    pub year: Option<i32>,
159    /// Calendar month in the range `1..=12`, if known.
160    pub month: Option<u8>,
161    /// Day of month in the range `1..=31`, if known.
162    pub day: Option<u8>,
163    /// Hour of day in the range `0..=23`, if known.
164    pub hour: Option<u8>,
165    /// Minute in the range `0..=59`, if known.
166    pub minute: Option<u8>,
167    /// Second in the range `0..=59`, if known.
168    pub second: Option<u8>,
169    /// Nanosecond within the current second, if known.
170    pub nanosecond: Option<u32>,
171}
172
173impl PartialNetworkTime {
174    /// Builds a complete partial-time value from Unix milliseconds when conversion succeeds.
175    pub fn from_unix_ms(unix_ms: u64) -> Self {
176        let unix_ns = (unix_ms as i128) * 1_000_000;
177        if let Some(full) = NetworkTime::from_unix_ns(unix_ns) {
178            Self::from(full)
179        } else {
180            Self::default()
181        }
182    }
183
184    /// Returns `true` when year, month, and day are all present.
185    pub fn is_complete_date(&self) -> bool {
186        self.year.is_some() && self.month.is_some() && self.day.is_some()
187    }
188
189    /// Returns `true` when hour, minute, and second are all present.
190    pub fn is_complete_time(&self) -> bool {
191        self.hour.is_some() && self.minute.is_some() && self.second.is_some()
192    }
193
194    /// Converts this value into a full [`NetworkTime`] when all required fields are present.
195    pub fn to_network_time(&self) -> Option<NetworkTime> {
196        Some(NetworkTime {
197            year: self.year?,
198            month: self.month?,
199            day: self.day?,
200            hour: self.hour?,
201            minute: self.minute?,
202            second: self.second?,
203            nanosecond: self.nanosecond.unwrap_or(0),
204        })
205    }
206}
207
208impl From<NetworkTime> for PartialNetworkTime {
209    fn from(value: NetworkTime) -> Self {
210        Self {
211            year: Some(value.year),
212            month: Some(value.month),
213            day: Some(value.day),
214            hour: Some(value.hour),
215            minute: Some(value.minute),
216            second: Some(value.second),
217            nanosecond: Some(value.nanosecond),
218        }
219    }
220}
221
222/// Fully specified calendar and time-of-day in UTC-like Unix time representation.
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub struct NetworkTime {
225    /// Calendar year.
226    pub year: i32,
227    /// Calendar month in the range `1..=12`.
228    pub month: u8,
229    /// Day of month in the range `1..=31`.
230    pub day: u8,
231    /// Hour of day in the range `0..=23`.
232    pub hour: u8,
233    /// Minute in the range `0..=59`.
234    pub minute: u8,
235    /// Second in the range `0..=59`.
236    pub second: u8,
237    /// Nanosecond within the current second.
238    pub nanosecond: u32,
239}
240
241impl NetworkTime {
242    /// Converts a non-negative Unix nanosecond timestamp into calendar/time fields.
243    pub fn from_unix_ns(unix_ns: i128) -> Option<Self> {
244        if unix_ns < 0 {
245            return None;
246        }
247
248        let secs = u64::try_from(unix_ns / 1_000_000_000).ok()?;
249        let nanos = unix_ns.rem_euclid(1_000_000_000) as u32;
250        let days = secs / 86_400;
251        let sod = (secs % 86_400) as u32;
252        let (year, month, day) = civil_from_days(days)?;
253
254        Some(Self {
255            year,
256            month: month as u8,
257            day: day as u8,
258            hour: (sod / 3_600) as u8,
259            minute: ((sod % 3_600) / 60) as u8,
260            second: (sod % 60) as u8,
261            nanosecond: nanos,
262        })
263    }
264
265    /// Converts this calendar/time value into Unix nanoseconds if the fields are valid.
266    pub fn as_unix_ns(&self) -> Option<i128> {
267        if !(1..=12).contains(&self.month)
268            || !(1..=31).contains(&self.day)
269            || self.hour > 23
270            || self.minute > 59
271            || self.second > 59
272            || self.nanosecond >= 1_000_000_000
273        {
274            return None;
275        }
276
277        let days = days_from_civil(self.year, self.month as u32, self.day as u32);
278        let secs = (days as i128) * 86_400
279            + (self.hour as i128) * 3_600
280            + (self.minute as i128) * 60
281            + (self.second as i128);
282        Some(secs * 1_000_000_000 + self.nanosecond as i128)
283    }
284
285    /// Converts this calendar/time value into non-negative Unix milliseconds.
286    pub fn as_unix_ms(&self) -> Option<u64> {
287        let unix_ns = self.as_unix_ns()?;
288        if unix_ns < 0 {
289            return None;
290        }
291        u64::try_from(unix_ns / 1_000_000).ok()
292    }
293}
294
295/// Best-effort current network-time reading from the assembled clock state.
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct NetworkTimeReading {
298    /// Partial or complete calendar/time-of-day reading.
299    pub time: PartialNetworkTime,
300    /// Absolute Unix milliseconds when the reading is complete enough to derive them.
301    pub unix_time_ms: Option<u64>,
302}
303
304/// Monotonic-anchor clock that slews toward target Unix time instead of stepping immediately.
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub struct SlewedNetworkClock {
307    anchor_mono_ns: u64,
308    anchor_unix_ns: i128,
309    pending_adjust_ns: i128,
310    max_slew_ppm: u32,
311    initialized: bool,
312}
313
314impl Default for SlewedNetworkClock {
315    fn default() -> Self {
316        Self {
317            anchor_mono_ns: 0,
318            anchor_unix_ns: 0,
319            pending_adjust_ns: 0,
320            max_slew_ppm: 50_000,
321            initialized: false,
322        }
323    }
324}
325
326impl SlewedNetworkClock {
327    /// Creates a new slewed clock with the given slew-rate cap in parts per million.
328    pub fn new(max_slew_ppm: u32) -> Self {
329        Self {
330            max_slew_ppm: max_slew_ppm.min(999_999),
331            ..Default::default()
332        }
333    }
334
335    /// Returns `true` once the clock has received an initial anchor time.
336    pub fn is_initialized(&self) -> bool {
337        self.initialized
338    }
339
340    /// Reads the current estimated Unix time in nanoseconds from a monotonic timestamp.
341    pub fn read_unix_ns(&self, now_mono_ns: u64) -> Option<i128> {
342        if !self.initialized {
343            return None;
344        }
345
346        let elapsed_ns = now_mono_ns.saturating_sub(self.anchor_mono_ns) as i128;
347        let max_adjust_ns = elapsed_ns.saturating_mul(self.max_slew_ppm as i128) / 1_000_000;
348        let applied_adjust_ns = if self.pending_adjust_ns >= 0 {
349            self.pending_adjust_ns.min(max_adjust_ns)
350        } else {
351            -(-self.pending_adjust_ns).min(max_adjust_ns)
352        };
353
354        Some(
355            self.anchor_unix_ns
356                .saturating_add(elapsed_ns)
357                .saturating_add(applied_adjust_ns),
358        )
359    }
360
361    /// Reads the current estimated Unix time in milliseconds from a monotonic timestamp.
362    pub fn read_unix_ms(&self, now_mono_ns: u64) -> Option<u64> {
363        let unix_ns = self.read_unix_ns(now_mono_ns)?;
364        if unix_ns < 0 {
365            return None;
366        }
367        u64::try_from(unix_ns / 1_000_000).ok()
368    }
369
370    /// Steers the clock toward a target Unix millisecond reading at the configured slew rate.
371    pub fn steer_unix_ms(&mut self, now_mono_ns: u64, target_unix_ms: u64) {
372        let target_unix_ns = (target_unix_ms as i128) * 1_000_000;
373        if !self.initialized {
374            self.anchor_mono_ns = now_mono_ns;
375            self.anchor_unix_ns = target_unix_ns;
376            self.pending_adjust_ns = 0;
377            self.initialized = true;
378            return;
379        }
380
381        let current_unix_ns = self.read_unix_ns(now_mono_ns).unwrap_or(target_unix_ns);
382        self.anchor_mono_ns = now_mono_ns;
383        self.anchor_unix_ns = current_unix_ns;
384        // The target is an absolute observation at `now`, not an incremental
385        // correction. Repeated observations must replace the residual error.
386        self.pending_adjust_ns = target_unix_ns.saturating_sub(current_unix_ns);
387        self.initialized = true;
388    }
389}
390
391#[derive(Debug, Clone)]
392struct NetworkTimeSourceState {
393    priority: u64,
394    updated_mono_ms: u64,
395    anchor_mono_ns: u64,
396    ttl_ms: Option<u64>,
397    time: PartialNetworkTime,
398}
399
400pub(crate) struct PreparedClockSource<'a>(crate::peer_record::Prepared<'a, NetworkTimeSourceState>);
401
402pub(crate) struct PreparedTrackerSource<'a> {
403    ignore: bool,
404    sender: &'a str,
405    priority: u64,
406    recv_ms: u64,
407    time_ms: u64,
408    insert: Option<crate::peer_record::Prepared<'a, TimeSyncSource>>,
409}
410
411/// Aggregates multiple partial or complete time sources into a current network-time reading.
412#[derive(Debug, Clone, Default)]
413pub struct NetworkClock {
414    sources: BTreeMap<String, Box<NetworkTimeSourceState>>,
415}
416
417impl NetworkClock {
418    /// Inserts or replaces a named source used when assembling current network time.
419    pub fn update_source(
420        &mut self,
421        source: &str,
422        priority: u64,
423        time: PartialNetworkTime,
424        updated_mono_ms: u64,
425        anchor_mono_ns: u64,
426        ttl_ms: Option<u64>,
427    ) {
428        // Keep the historical infallible signature. Failure preserves the
429        // previous reading instead of publishing a partially replaced source.
430        let _ = self.try_update_source(
431            source,
432            priority,
433            time,
434            updated_mono_ms,
435            anchor_mono_ns,
436            ttl_ms,
437        );
438    }
439
440    /// Fallible source update. Allocation refusal preserves the prior reading.
441    pub fn try_update_source(
442        &mut self,
443        source: &str,
444        priority: u64,
445        time: PartialNetworkTime,
446        updated_mono_ms: u64,
447        anchor_mono_ns: u64,
448        ttl_ms: Option<u64>,
449    ) -> TelemetryResult<()> {
450        let update = self.prepare_source(
451            source,
452            priority,
453            time,
454            updated_mono_ms,
455            anchor_mono_ns,
456            ttl_ms,
457        )?;
458        self.commit_source(update);
459        Ok(())
460    }
461
462    pub(crate) fn prepare_source<'a>(
463        &self,
464        source: &'a str,
465        priority: u64,
466        time: PartialNetworkTime,
467        updated_mono_ms: u64,
468        anchor_mono_ns: u64,
469        ttl_ms: Option<u64>,
470    ) -> TelemetryResult<PreparedClockSource<'a>> {
471        crate::peer_record::prepare(
472            &self.sources,
473            source,
474            NetworkTimeSourceState {
475                priority,
476                updated_mono_ms,
477                anchor_mono_ns,
478                ttl_ms,
479                time,
480            },
481        )
482        .map(PreparedClockSource)
483    }
484
485    pub(crate) fn commit_source(&mut self, update: PreparedClockSource<'_>) {
486        update.0.commit(&mut self.sources);
487    }
488
489    /// Removes a previously registered source by identifier.
490    pub fn remove_source(&mut self, source: &str) {
491        self.sources.remove(source);
492    }
493
494    /// Drops sources whose optional TTL has expired relative to `now_mono_ms`.
495    pub fn prune_expired(&mut self, now_mono_ms: u64) {
496        self.sources.retain(|_, src| {
497            src.ttl_ms
498                .map(|ttl| now_mono_ms.saturating_sub(src.updated_mono_ms) <= ttl)
499                .unwrap_or(true)
500        });
501    }
502
503    /// Returns the best current reading by preferring complete sources, then merging partial ones.
504    pub fn current_time(&self, now_mono_ns: u64) -> Option<NetworkTimeReading> {
505        let sources: Vec<(&str, &NetworkTimeSourceState)> = self
506            .sources
507            .iter()
508            .map(|(name, src)| (name.as_str(), src.as_ref()))
509            .collect();
510        if sources.is_empty() {
511            return None;
512        }
513
514        let full = best_source(&sources, |src| src.time.to_network_time().is_some());
515        if let Some((_, src)) = full
516            && let Some(base) = src.time.to_network_time()
517        {
518            let elapsed_ns = now_mono_ns.saturating_sub(src.anchor_mono_ns);
519            if let Some(advanced) = advance_network_time(base, elapsed_ns) {
520                return Some(NetworkTimeReading {
521                    unix_time_ms: advanced.as_unix_ms(),
522                    time: PartialNetworkTime::from(advanced),
523                });
524            }
525        }
526
527        let year = best_source(&sources, |src| src.time.year.is_some()).map(|(_, src)| src);
528        let month = best_source(&sources, |src| src.time.month.is_some()).map(|(_, src)| src);
529        let day = best_source(&sources, |src| src.time.day.is_some()).map(|(_, src)| src);
530        let hour = best_source(&sources, |src| src.time.hour.is_some()).map(|(_, src)| src);
531        let minute = best_source(&sources, |src| src.time.minute.is_some()).map(|(_, src)| src);
532        let second = best_source(&sources, |src| src.time.second.is_some()).map(|(_, src)| src);
533        let subsec = best_source(&sources, |src| src.time.nanosecond.is_some()).map(|(_, src)| src);
534
535        let mut merged = PartialNetworkTime {
536            year: year.and_then(|src| src.time.year),
537            month: month.and_then(|src| src.time.month),
538            day: day.and_then(|src| src.time.day),
539            hour: hour.and_then(|src| src.time.hour),
540            minute: minute.and_then(|src| src.time.minute),
541            second: second.and_then(|src| src.time.second),
542            ..Default::default()
543        };
544        if let Some(src) = subsec {
545            merged.nanosecond = src.time.nanosecond;
546        }
547
548        if let Some(time_src) = second.or(minute).or(hour)
549            && merged.is_complete_date()
550            && merged.is_complete_time()
551        {
552            merged.nanosecond = merged.nanosecond.or(Some(0));
553            let base = merged.to_network_time()?;
554            let elapsed_ns = now_mono_ns.saturating_sub(time_src.anchor_mono_ns);
555            if let Some(advanced) = advance_network_time(base, elapsed_ns) {
556                return Some(NetworkTimeReading {
557                    unix_time_ms: advanced.as_unix_ms(),
558                    time: PartialNetworkTime::from(advanced),
559                });
560            }
561        }
562
563        Some(NetworkTimeReading {
564            unix_time_ms: None,
565            time: merged,
566        })
567    }
568}
569
570fn best_source<'a, F>(
571    sources: &'a [(&'a str, &'a NetworkTimeSourceState)],
572    predicate: F,
573) -> Option<(&'a str, &'a NetworkTimeSourceState)>
574where
575    F: Fn(&NetworkTimeSourceState) -> bool,
576{
577    let mut best: Option<(&str, &NetworkTimeSourceState)> = None;
578    for (name, src) in sources.iter().copied() {
579        if !predicate(src) {
580            continue;
581        }
582        best = match best {
583            None => Some((name, src)),
584            Some((best_name, best_src)) => {
585                if src.priority < best_src.priority
586                    || (src.priority == best_src.priority && name < best_name)
587                {
588                    Some((name, src))
589                } else {
590                    Some((best_name, best_src))
591                }
592            }
593        };
594    }
595    best
596}
597
598/// Advances a complete [`NetworkTime`] forward by an elapsed monotonic duration.
599pub(crate) fn advance_network_time(base: NetworkTime, elapsed_ns: u64) -> Option<NetworkTime> {
600    let unix_ns = base.as_unix_ns()?;
601    NetworkTime::from_unix_ns(unix_ns + elapsed_ns as i128)
602}
603
604fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
605    let mut era = year.div_euclid(400);
606    let mut yoe = year.rem_euclid(400) - (month <= 2) as i32;
607    if yoe < 0 {
608        era -= 1;
609        yoe += 400;
610    }
611    let mp = month as i32 + if month > 2 { -3 } else { 9 };
612    let doy = (153 * mp + 2) / 5 + day as i32 - 1;
613    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
614    era as i64 * 146_097 + doe as i64 - 719_468
615}
616
617fn civil_from_days(mut z: u64) -> Option<(i32, u32, u32)> {
618    z += 719_468;
619    let era = z / 146_097;
620    // A Gregorian era contains exactly 146097 days. Its remaining
621    // arithmetic fits in 32 bits, avoiding repeated software i64 divisions
622    // on Cortex-M while retaining the full representable year range.
623    let doe = (z - era * 146_097) as u32;
624    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
625    let y = yoe as u64 + era * 400;
626    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
627    let mp = (5 * doy + 2) / 153;
628    let d = doy - (153 * mp + 2) / 5 + 1;
629    let m = if mp < 10 { mp + 3 } else { mp - 9 };
630    let y = i32::try_from(y + (m <= 2) as u64).ok()?;
631    Some((y, m, d))
632}
633
634/// Tracks remote time sources and performs leader/source selection decisions.
635#[derive(Debug, Clone)]
636pub struct TimeSyncTracker {
637    cfg: TimeSyncConfig,
638    local_id: String,
639    sources: BTreeMap<String, Box<TimeSyncSource>>,
640    current_source: Option<String>,
641}
642
643impl TimeSyncTracker {
644    /// Creates a tracker with the supplied time sync configuration.
645    pub fn new(cfg: TimeSyncConfig) -> Self {
646        Self {
647            cfg,
648            local_id: runtime_device_identifier(),
649            sources: BTreeMap::new(),
650            current_source: None,
651        }
652    }
653
654    /// Returns the tracker's active configuration.
655    pub fn config(&self) -> TimeSyncConfig {
656        self.cfg
657    }
658
659    /// Returns the currently selected remote source, if any.
660    pub fn current_source(&self) -> Option<&TimeSyncSource> {
661        self.current_source
662            .as_ref()
663            .and_then(|sender| self.sources.get(sender).map(AsRef::as_ref))
664    }
665
666    /// Removes stale sources and re-runs source selection.
667    pub fn refresh(&mut self, now_ms: u64) -> TimeSyncUpdate {
668        self.sources
669            .retain(|_, src| Self::source_is_active(src, now_ms, self.cfg.source_timeout_ms));
670        self.reselect_source(now_ms)
671    }
672
673    /// Returns the best currently active remote source according to priority and sender ID.
674    pub fn best_active_source(&self, now_ms: u64) -> Option<&TimeSyncSource> {
675        self.sources
676            .values()
677            .filter(|src| Self::source_is_active(src, now_ms, self.cfg.source_timeout_ms))
678            .min_by(|a, b| {
679                a.priority
680                    .cmp(&b.priority)
681                    .then_with(|| a.sender.cmp(&b.sender))
682            })
683            .map(AsRef::as_ref)
684    }
685
686    /// Returns the effective local candidate priority if the node may lead at `now_ms`.
687    pub fn local_candidate_priority(&self, now_ms: u64, has_usable_time: bool) -> Option<u64> {
688        match self.cfg.role {
689            TimeSyncRole::Consumer => {
690                if has_usable_time
691                    && self.cfg.consumer_promotion_enabled
692                    && self.best_active_source(now_ms).is_none()
693                {
694                    Some(self.cfg.priority)
695                } else {
696                    None
697                }
698            }
699            TimeSyncRole::Source => Some(self.cfg.priority),
700            TimeSyncRole::Auto => {
701                if has_usable_time && self.best_active_source(now_ms).is_none() {
702                    Some(self.cfg.priority)
703                } else {
704                    None
705                }
706            }
707        }
708    }
709
710    /// Returns the elected leader between the local node and active remote sources.
711    pub fn leader(&self, now_ms: u64, has_usable_time: bool) -> Option<TimeSyncLeader> {
712        let local_priority = self.local_candidate_priority(now_ms, has_usable_time);
713        let remote = self.best_active_source(now_ms).cloned();
714        match (local_priority, remote) {
715            (Some(priority), Some(remote)) => {
716                if priority < remote.priority
717                    || (priority == remote.priority
718                        && self.local_id.as_str() < remote.sender.as_str())
719                {
720                    Some(TimeSyncLeader::Local { priority })
721                } else {
722                    Some(TimeSyncLeader::Remote(remote))
723                }
724            }
725            (Some(priority), None) => Some(TimeSyncLeader::Local { priority }),
726            (None, Some(remote)) => Some(TimeSyncLeader::Remote(remote)),
727            (None, None) => None,
728        }
729    }
730
731    /// Returns the priority to advertise in local announce packets when leading.
732    pub fn local_announce_priority(&self, now_ms: u64, has_usable_time: bool) -> Option<u64> {
733        let Some(TimeSyncLeader::Local { priority }) = self.leader(now_ms, has_usable_time) else {
734            return None;
735        };
736        let tie_exists = self
737            .sources
738            .values()
739            .filter(|src| Self::source_is_active(src, now_ms, self.cfg.source_timeout_ms))
740            .any(|src| src.priority == priority);
741        Some(if tie_exists {
742            priority.saturating_sub(1)
743        } else {
744            priority
745        })
746    }
747
748    /// Returns `true` when the local node should currently emit announce packets.
749    pub fn should_announce(&self, now_ms: u64, has_usable_time: bool) -> bool {
750        self.local_announce_priority(now_ms, has_usable_time)
751            .is_some()
752    }
753
754    /// Returns `true` when the local node should currently answer incoming requests.
755    pub fn should_serve(&self, now_ms: u64, has_usable_time: bool) -> bool {
756        self.should_announce(now_ms, has_usable_time)
757    }
758
759    /// Updates tracker state from an incoming announce packet.
760    pub fn handle_announce(
761        &mut self,
762        pkt: &Packet,
763        recv_ms: u64,
764    ) -> TelemetryResult<TimeSyncUpdate> {
765        let ann = decode_timesync_announce(pkt)?;
766        if pkt.sender() == self.local_id.as_str() {
767            return Ok(TimeSyncUpdate::NoChange);
768        }
769
770        let update = self.prepare_source(pkt.sender(), ann, recv_ms)?;
771        Ok(self.commit_source(update))
772    }
773
774    pub(crate) fn prepare_source<'a>(
775        &self,
776        sender: &'a str,
777        ann: TimeSyncAnnounceFields,
778        recv_ms: u64,
779    ) -> TelemetryResult<PreparedTrackerSource<'a>> {
780        let ignore = sender == self.local_id.as_str();
781        let insert = if ignore || self.sources.contains_key(sender) {
782            None
783        } else {
784            let incoming = TimeSyncSource {
785                sender: crate::peer_record::try_string(sender)?,
786                priority: ann.priority,
787                last_announce_ms: recv_ms,
788                last_time_ms: ann.time_ms,
789            };
790            Some(crate::peer_record::prepare(
791                &self.sources,
792                sender,
793                incoming,
794            )?)
795        };
796        Ok(PreparedTrackerSource {
797            ignore,
798            sender,
799            priority: ann.priority,
800            recv_ms,
801            time_ms: ann.time_ms,
802            insert,
803        })
804    }
805
806    pub(crate) fn commit_source(&mut self, update: PreparedTrackerSource<'_>) -> TimeSyncUpdate {
807        if update.ignore {
808            return TimeSyncUpdate::NoChange;
809        }
810        if let Some(insert) = update.insert {
811            insert.commit(&mut self.sources);
812        } else {
813            let source = self
814                .sources
815                .get_mut(update.sender)
816                .expect("prepared source remains present");
817            source.priority = update.priority;
818            source.last_announce_ms = update.recv_ms;
819            source.last_time_ms = update.time_ms;
820        }
821        self.reselect_source(update.recv_ms)
822    }
823
824    /// Returns `true` when the current remote source is still within the active timeout window.
825    pub fn is_source_active(&self, now_ms: u64) -> bool {
826        self.current_source()
827            .map(|s| Self::source_is_active(s, now_ms, self.cfg.source_timeout_ms))
828            .unwrap_or(false)
829    }
830
831    fn source_is_active(src: &TimeSyncSource, now_ms: u64, timeout_ms: u64) -> bool {
832        now_ms.saturating_sub(src.last_announce_ms) <= timeout_ms
833    }
834
835    fn best_active_source_id(&self, now_ms: u64) -> Option<String> {
836        self.best_active_source(now_ms)
837            .map(|src| src.sender.clone())
838    }
839
840    fn reselect_source(&mut self, now_ms: u64) -> TimeSyncUpdate {
841        let prev = self.current_source.clone();
842        self.current_source = self.best_active_source_id(now_ms);
843        if self.current_source == prev {
844            TimeSyncUpdate::NoChange
845        } else {
846            TimeSyncUpdate::SourceChanged
847        }
848    }
849}
850
851/// Computes clock offset and round-trip delay from the standard four timestamps.
852pub fn compute_offset_delay(t1_ms: u64, t2_ms: u64, t3_ms: u64, t4_ms: u64) -> TimeSyncSample {
853    let t1 = t1_ms as i128;
854    let t2 = t2_ms as i128;
855    let t3 = t3_ms as i128;
856    let t4 = t4_ms as i128;
857
858    let offset = ((t2 - t1) + (t3 - t4)) / 2;
859    let delay = (t4 - t1) - (t3 - t2);
860    let delay_ms = if delay < 0 { 0 } else { delay as u64 };
861
862    TimeSyncSample {
863        offset_ms: offset as i64,
864        delay_ms,
865    }
866}
867
868/// Estimates current network time and one-way delay from a request/response exchange.
869pub fn compute_network_time_sample(
870    t1_monotonic_ms: u64,
871    t2_network_ms: u64,
872    t3_network_ms: u64,
873    t4_monotonic_ms: u64,
874) -> (u64, u64) {
875    let round_trip = t4_monotonic_ms.saturating_sub(t1_monotonic_ms);
876    let server_processing = t3_network_ms.saturating_sub(t2_network_ms);
877    let one_way_delay = round_trip.saturating_sub(server_processing) / 2;
878    let estimated_network_ms = t3_network_ms.saturating_add(one_way_delay);
879    (estimated_network_ms, one_way_delay)
880}
881
882/// Builds a time sync announce packet using the schema-configured sender metadata.
883pub fn build_timesync_announce(priority: u64, time_ms: u64) -> TelemetryResult<Packet> {
884    let meta = message_meta(DataType::TimeSyncAnnounce);
885    Packet::from_u64_slice(
886        DataType::TimeSyncAnnounce,
887        &[priority, time_ms],
888        meta.endpoints_ref(),
889        time_ms,
890    )
891}
892
893/// Builds a time sync announce packet with an explicit sender identifier.
894pub fn build_timesync_announce_with_sender(
895    sender: &'static str,
896    priority: u64,
897    time_ms: u64,
898) -> TelemetryResult<Packet> {
899    let payload = encode_slice_le(&[priority, time_ms]);
900    Packet::new(
901        DataType::TimeSyncAnnounce,
902        &[DataEndpoint::TimeSync],
903        sender,
904        time_ms,
905        payload,
906    )
907}
908
909/// Queues a time sync announce message through the router telemetry path.
910pub fn send_timesync_announce(router: &Router, priority: u64, time_ms: u64) -> TelemetryResult<()> {
911    router.log_ts(DataType::TimeSyncAnnounce, time_ms, &[priority, time_ms])
912}
913
914/// Builds a time sync request packet.
915pub fn build_timesync_request(seq: u64, t1_ms: u64) -> TelemetryResult<Packet> {
916    let meta = message_meta(DataType::TimeSyncRequest);
917    Packet::from_u64_slice(
918        DataType::TimeSyncRequest,
919        &[seq, t1_ms],
920        meta.endpoints_ref(),
921        t1_ms,
922    )
923}
924
925/// Queues a time sync request message through the router telemetry path.
926pub fn send_timesync_request(router: &Router, seq: u64, t1_ms: u64) -> TelemetryResult<()> {
927    router.log_ts(DataType::TimeSyncRequest, t1_ms, &[seq, t1_ms])
928}
929
930/// Builds a time sync response packet.
931pub fn build_timesync_response(
932    seq: u64,
933    t1_ms: u64,
934    t2_ms: u64,
935    t3_ms: u64,
936) -> TelemetryResult<Packet> {
937    let meta = message_meta(DataType::TimeSyncResponse);
938    Packet::from_u64_slice(
939        DataType::TimeSyncResponse,
940        &[seq, t1_ms, t2_ms, t3_ms],
941        meta.endpoints_ref(),
942        t3_ms,
943    )
944}
945
946/// Queues a time sync response message through the router telemetry path.
947pub fn send_timesync_response(
948    router: &Router,
949    seq: u64,
950    t1_ms: u64,
951    t2_ms: u64,
952    t3_ms: u64,
953) -> TelemetryResult<()> {
954    router.log_ts(
955        DataType::TimeSyncResponse,
956        t3_ms,
957        &[seq, t1_ms, t2_ms, t3_ms],
958    )
959}
960
961fn decode_u64_payload(
962    pkt: &Packet,
963    expected_ty: DataType,
964    expected_words: usize,
965) -> TelemetryResult<Vec<u64>> {
966    if pkt.data_type() != expected_ty {
967        return Err(TelemetryError::InvalidType);
968    }
969
970    let vals = pkt.data_as_u64()?;
971    if vals.len() != expected_words {
972        return Err(TelemetryError::SizeMismatch {
973            expected: expected_words * size_of::<u64>(),
974            got: vals.len() * size_of::<u64>(),
975        });
976    }
977
978    Ok(vals)
979}
980
981/// Decodes a time sync announce packet into strongly typed fields.
982pub fn decode_timesync_announce(pkt: &Packet) -> TelemetryResult<TimeSyncAnnounceFields> {
983    let vals = decode_u64_payload(pkt, DataType::TimeSyncAnnounce, TIMESYNC_ANNOUNCE_WORDS)?;
984    Ok(TimeSyncAnnounceFields {
985        priority: vals[0],
986        time_ms: vals[1],
987    })
988}
989
990/// Decodes a time sync request packet into strongly typed fields.
991pub fn decode_timesync_request(pkt: &Packet) -> TelemetryResult<TimeSyncRequestFields> {
992    let vals = decode_u64_payload(pkt, DataType::TimeSyncRequest, TIMESYNC_REQUEST_WORDS)?;
993    Ok(TimeSyncRequestFields {
994        seq: vals[0],
995        t1_ms: vals[1],
996    })
997}
998
999/// Decodes a time sync response packet into strongly typed fields.
1000pub fn decode_timesync_response(pkt: &Packet) -> TelemetryResult<TimeSyncResponseFields> {
1001    let vals = decode_u64_payload(pkt, DataType::TimeSyncResponse, TIMESYNC_RESPONSE_WORDS)?;
1002    Ok(TimeSyncResponseFields {
1003        seq: vals[0],
1004        t1_ms: vals[1],
1005        t2_ms: vals[2],
1006        t3_ms: vals[3],
1007    })
1008}
1009
1010#[cfg(test)]
1011mod calendar_tests {
1012    use super::NetworkTime;
1013
1014    #[test]
1015    fn calendar_roundtrips_era_and_year_boundaries() {
1016        for year in [1970, 1999, 2000, 2024, 2100, 2400, 9999, i32::MAX] {
1017            for (month, day) in [(1, 1), (2, 28), (3, 1), (12, 31)] {
1018                let expected = NetworkTime {
1019                    year,
1020                    month,
1021                    day,
1022                    hour: 23,
1023                    minute: 59,
1024                    second: 59,
1025                    nanosecond: 999_999_999,
1026                };
1027                assert_eq!(
1028                    NetworkTime::from_unix_ns(expected.as_unix_ns().unwrap()),
1029                    Some(expected)
1030                );
1031            }
1032        }
1033        let leap = NetworkTime {
1034            year: 2024,
1035            month: 2,
1036            day: 29,
1037            hour: 0,
1038            minute: 0,
1039            second: 0,
1040            nanosecond: 123,
1041        };
1042        assert_eq!(
1043            NetworkTime::from_unix_ns(leap.as_unix_ns().unwrap()),
1044            Some(leap)
1045        );
1046        assert_eq!(NetworkTime::from_unix_ns(0).unwrap().year, 1970);
1047    }
1048
1049    #[test]
1050    fn out_of_range_calendar_timestamps_are_rejected_without_wraparound() {
1051        assert_eq!(NetworkTime::from_unix_ns(-1), None);
1052        assert_eq!(NetworkTime::from_unix_ns(i128::MAX), None);
1053        let last = NetworkTime {
1054            year: i32::MAX,
1055            month: 12,
1056            day: 31,
1057            hour: 23,
1058            minute: 59,
1059            second: 59,
1060            nanosecond: 999_999_999,
1061        }
1062        .as_unix_ns()
1063        .unwrap();
1064        assert_eq!(NetworkTime::from_unix_ns(last + 1), None);
1065    }
1066}
1067
1068#[cfg(test)]
1069mod slew_absolute_target_tests {
1070    use super::SlewedNetworkClock;
1071
1072    #[test]
1073    fn remote_source_projection_survives_embedded_polling_and_source_updates() {
1074        use super::{TimeSyncConfig, TimeSyncRole, build_timesync_announce_with_sender};
1075        use crate::router::{Router, RouterConfig};
1076        use alloc::sync::Arc;
1077        use core::sync::atomic::{AtomicU64, Ordering};
1078        const EPOCH: u64 = 1_767_225_600_000;
1079        let now = Arc::new(AtomicU64::new(0));
1080        let clock = now.clone();
1081        let router = Router::new_with_clock(
1082            RouterConfig::default().with_timesync(TimeSyncConfig {
1083                role: TimeSyncRole::Consumer,
1084                priority: 50,
1085                source_timeout_ms: 10_000,
1086                consumer_promotion_enabled: false,
1087                ..Default::default()
1088            }),
1089            alloc::boxed::Box::new(move || clock.load(Ordering::SeqCst)),
1090        );
1091        for elapsed_ms in (0..=20_000).step_by(10) {
1092            now.store(elapsed_ms, Ordering::SeqCst);
1093            if elapsed_ms % 5_000 == 0 {
1094                // Exercise a replacement leader as well as repeated updates.
1095                let (source, priority) = if elapsed_ms < 10_000 {
1096                    ("SOURCE_A", 2)
1097                } else {
1098                    ("SOURCE_B", 1)
1099                };
1100                router
1101                    .rx(
1102                        &build_timesync_announce_with_sender(source, priority, EPOCH + elapsed_ms)
1103                            .unwrap(),
1104                    )
1105                    .unwrap();
1106            }
1107            for _ in 0..5 {
1108                router.poll_timesync().unwrap();
1109                assert_eq!(router.network_time_ms(), Some(EPOCH + elapsed_ms));
1110            }
1111        }
1112    }
1113
1114    #[test]
1115    fn repeated_absolute_target_does_not_accumulate_phase_error() {
1116        for target in [9_900, 10_100] {
1117            let mut once = SlewedNetworkClock::new(50_000);
1118            once.steer_unix_ms(0, 10_000);
1119            once.steer_unix_ms(0, target);
1120            let mut repeated = once;
1121            for _ in 0..100 {
1122                repeated.steer_unix_ms(0, target);
1123            }
1124            for elapsed_ms in [1, 100, 1_000, 2_000, 3_000, 10_000] {
1125                assert_eq!(
1126                    repeated.read_unix_ms(elapsed_ms * 1_000_000),
1127                    once.read_unix_ms(elapsed_ms * 1_000_000)
1128                );
1129            }
1130        }
1131    }
1132
1133    #[test]
1134    fn advancing_absolute_targets_converge_with_bounded_monotonic_slew() {
1135        for offset_ms in [-100i64, 100] {
1136            let mut clock = SlewedNetworkClock::new(50_000);
1137            clock.steer_unix_ms(0, 10_000);
1138            let mut previous = 10_000_000_000i128;
1139            for elapsed_ms in 1..=5_000u64 {
1140                let now_ns = elapsed_ms * 1_000_000;
1141                let target_ms = (10_000 + elapsed_ms as i64 + offset_ms) as u64;
1142                clock.steer_unix_ms(now_ns, target_ms);
1143                let current = clock.read_unix_ns(now_ns).unwrap();
1144                assert!((950_000..=1_050_000).contains(&(current - previous)));
1145                previous = current;
1146            }
1147            assert_eq!(
1148                clock.read_unix_ms(5_000_000_000),
1149                Some((15_000 + offset_ms) as u64)
1150            );
1151        }
1152    }
1153}