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