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::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 = unix_ns.div_euclid(1_000_000_000);
248        let nanos = unix_ns.rem_euclid(1_000_000_000) as u32;
249        let days = secs.div_euclid(86_400) as i64;
250        let sod = secs.rem_euclid(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 year = year - (month <= 2) as i32;
553    let era = if year >= 0 { year } else { year - 399 } / 400;
554    let yoe = year - era * 400;
555    let mp = month as i32 + if month > 2 { -3 } else { 9 };
556    let doy = (153 * mp + 2) / 5 + day as i32 - 1;
557    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
558    (era * 146_097 + doe - 719_468) as i64
559}
560
561fn civil_from_days(mut z: i64) -> (i32, u32, u32) {
562    z += 719_468;
563    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
564    let doe = z - era * 146_097;
565    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
566    let y = yoe as i32 + era as i32 * 400;
567    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
568    let mp = (5 * doy + 2) / 153;
569    let d = doy - (153 * mp + 2) / 5 + 1;
570    let m = mp + if mp < 10 { 3 } else { -9 };
571    let y = y + (m <= 2) as i32;
572    (y, m as u32, d as u32)
573}
574
575/// Tracks remote time sources and performs leader/source selection decisions.
576#[derive(Debug, Clone)]
577pub struct TimeSyncTracker {
578    cfg: TimeSyncConfig,
579    local_id: &'static str,
580    sources: BTreeMap<String, TimeSyncSource>,
581    current_source: Option<String>,
582}
583
584impl TimeSyncTracker {
585    /// Creates a tracker with the supplied time sync configuration.
586    pub fn new(cfg: TimeSyncConfig) -> Self {
587        Self {
588            cfg,
589            local_id: DEVICE_IDENTIFIER,
590            sources: BTreeMap::new(),
591            current_source: None,
592        }
593    }
594
595    /// Returns the tracker's active configuration.
596    pub fn config(&self) -> TimeSyncConfig {
597        self.cfg
598    }
599
600    /// Returns the currently selected remote source, if any.
601    pub fn current_source(&self) -> Option<&TimeSyncSource> {
602        self.current_source
603            .as_ref()
604            .and_then(|sender| self.sources.get(sender))
605    }
606
607    /// Removes stale sources and re-runs source selection.
608    pub fn refresh(&mut self, now_ms: u64) -> TimeSyncUpdate {
609        self.sources
610            .retain(|_, src| Self::source_is_active(src, now_ms, self.cfg.source_timeout_ms));
611        self.reselect_source(now_ms)
612    }
613
614    /// Returns the best currently active remote source according to priority and sender ID.
615    pub fn best_active_source(&self, now_ms: u64) -> Option<&TimeSyncSource> {
616        self.sources
617            .values()
618            .filter(|src| Self::source_is_active(src, now_ms, self.cfg.source_timeout_ms))
619            .min_by(|a, b| {
620                a.priority
621                    .cmp(&b.priority)
622                    .then_with(|| a.sender.cmp(&b.sender))
623            })
624    }
625
626    /// Returns the effective local candidate priority if the node may lead at `now_ms`.
627    pub fn local_candidate_priority(&self, now_ms: u64, has_usable_time: bool) -> Option<u64> {
628        match self.cfg.role {
629            TimeSyncRole::Consumer => {
630                if has_usable_time
631                    && self.cfg.consumer_promotion_enabled
632                    && self.best_active_source(now_ms).is_none()
633                {
634                    Some(self.cfg.priority)
635                } else {
636                    None
637                }
638            }
639            TimeSyncRole::Source => Some(self.cfg.priority),
640            TimeSyncRole::Auto => {
641                if has_usable_time && self.best_active_source(now_ms).is_none() {
642                    Some(self.cfg.priority)
643                } else {
644                    None
645                }
646            }
647        }
648    }
649
650    /// Returns the elected leader between the local node and active remote sources.
651    pub fn leader(&self, now_ms: u64, has_usable_time: bool) -> Option<TimeSyncLeader> {
652        let local_priority = self.local_candidate_priority(now_ms, has_usable_time);
653        let remote = self.best_active_source(now_ms).cloned();
654        match (local_priority, remote) {
655            (Some(priority), Some(remote)) => {
656                if priority < remote.priority
657                    || (priority == remote.priority && self.local_id < remote.sender.as_str())
658                {
659                    Some(TimeSyncLeader::Local { priority })
660                } else {
661                    Some(TimeSyncLeader::Remote(remote))
662                }
663            }
664            (Some(priority), None) => Some(TimeSyncLeader::Local { priority }),
665            (None, Some(remote)) => Some(TimeSyncLeader::Remote(remote)),
666            (None, None) => None,
667        }
668    }
669
670    /// Returns the priority to advertise in local announce packets when leading.
671    pub fn local_announce_priority(&self, now_ms: u64, has_usable_time: bool) -> Option<u64> {
672        let Some(TimeSyncLeader::Local { priority }) = self.leader(now_ms, has_usable_time) else {
673            return None;
674        };
675        let tie_exists = self
676            .sources
677            .values()
678            .filter(|src| Self::source_is_active(src, now_ms, self.cfg.source_timeout_ms))
679            .any(|src| src.priority == priority);
680        Some(if tie_exists {
681            priority.saturating_sub(1)
682        } else {
683            priority
684        })
685    }
686
687    /// Returns `true` when the local node should currently emit announce packets.
688    pub fn should_announce(&self, now_ms: u64, has_usable_time: bool) -> bool {
689        self.local_announce_priority(now_ms, has_usable_time)
690            .is_some()
691    }
692
693    /// Returns `true` when the local node should currently answer incoming requests.
694    pub fn should_serve(&self, now_ms: u64, has_usable_time: bool) -> bool {
695        self.should_announce(now_ms, has_usable_time)
696    }
697
698    /// Updates tracker state from an incoming announce packet.
699    pub fn handle_announce(
700        &mut self,
701        pkt: &Packet,
702        recv_ms: u64,
703    ) -> TelemetryResult<TimeSyncUpdate> {
704        let ann = decode_timesync_announce(pkt)?;
705        if pkt.sender() == self.local_id {
706            return Ok(TimeSyncUpdate::NoChange);
707        }
708
709        let incoming = TimeSyncSource {
710            sender: pkt.sender().to_string(),
711            priority: ann.priority,
712            last_announce_ms: recv_ms,
713            last_time_ms: ann.time_ms,
714        };
715        self.sources.insert(incoming.sender.clone(), incoming);
716        Ok(self.reselect_source(recv_ms))
717    }
718
719    /// Returns `true` when the current remote source is still within the active timeout window.
720    pub fn is_source_active(&self, now_ms: u64) -> bool {
721        self.current_source()
722            .map(|s| Self::source_is_active(s, now_ms, self.cfg.source_timeout_ms))
723            .unwrap_or(false)
724    }
725
726    fn source_is_active(src: &TimeSyncSource, now_ms: u64, timeout_ms: u64) -> bool {
727        now_ms.saturating_sub(src.last_announce_ms) <= timeout_ms
728    }
729
730    fn best_active_source_id(&self, now_ms: u64) -> Option<String> {
731        self.best_active_source(now_ms)
732            .map(|src| src.sender.clone())
733    }
734
735    fn reselect_source(&mut self, now_ms: u64) -> TimeSyncUpdate {
736        let prev = self.current_source.clone();
737        self.current_source = self.best_active_source_id(now_ms);
738        if self.current_source == prev {
739            TimeSyncUpdate::NoChange
740        } else {
741            TimeSyncUpdate::SourceChanged
742        }
743    }
744}
745
746/// Computes clock offset and round-trip delay from the standard four timestamps.
747pub fn compute_offset_delay(t1_ms: u64, t2_ms: u64, t3_ms: u64, t4_ms: u64) -> TimeSyncSample {
748    let t1 = t1_ms as i128;
749    let t2 = t2_ms as i128;
750    let t3 = t3_ms as i128;
751    let t4 = t4_ms as i128;
752
753    let offset = ((t2 - t1) + (t3 - t4)) / 2;
754    let delay = (t4 - t1) - (t3 - t2);
755    let delay_ms = if delay < 0 { 0 } else { delay as u64 };
756
757    TimeSyncSample {
758        offset_ms: offset as i64,
759        delay_ms,
760    }
761}
762
763/// Estimates current network time and one-way delay from a request/response exchange.
764pub fn compute_network_time_sample(
765    t1_monotonic_ms: u64,
766    t2_network_ms: u64,
767    t3_network_ms: u64,
768    t4_monotonic_ms: u64,
769) -> (u64, u64) {
770    let round_trip = t4_monotonic_ms.saturating_sub(t1_monotonic_ms);
771    let server_processing = t3_network_ms.saturating_sub(t2_network_ms);
772    let one_way_delay = round_trip.saturating_sub(server_processing) / 2;
773    let estimated_network_ms = t3_network_ms.saturating_add(one_way_delay);
774    (estimated_network_ms, one_way_delay)
775}
776
777/// Builds a time sync announce packet using the schema-configured sender metadata.
778pub fn build_timesync_announce(priority: u64, time_ms: u64) -> TelemetryResult<Packet> {
779    let meta = message_meta(DataType::TimeSyncAnnounce);
780    Packet::from_u64_slice(
781        DataType::TimeSyncAnnounce,
782        &[priority, time_ms],
783        meta.endpoints,
784        time_ms,
785    )
786}
787
788/// Builds a time sync announce packet with an explicit sender identifier.
789pub fn build_timesync_announce_with_sender(
790    sender: &'static str,
791    priority: u64,
792    time_ms: u64,
793) -> TelemetryResult<Packet> {
794    let payload = encode_slice_le(&[priority, time_ms]);
795    Packet::new(
796        DataType::TimeSyncAnnounce,
797        &[DataEndpoint::TimeSync],
798        sender,
799        time_ms,
800        payload,
801    )
802}
803
804/// Queues a time sync announce message through the router telemetry path.
805pub fn send_timesync_announce(router: &Router, priority: u64, time_ms: u64) -> TelemetryResult<()> {
806    router.log_ts(DataType::TimeSyncAnnounce, time_ms, &[priority, time_ms])
807}
808
809/// Builds a time sync request packet.
810pub fn build_timesync_request(seq: u64, t1_ms: u64) -> TelemetryResult<Packet> {
811    let meta = message_meta(DataType::TimeSyncRequest);
812    Packet::from_u64_slice(
813        DataType::TimeSyncRequest,
814        &[seq, t1_ms],
815        meta.endpoints,
816        t1_ms,
817    )
818}
819
820/// Queues a time sync request message through the router telemetry path.
821pub fn send_timesync_request(router: &Router, seq: u64, t1_ms: u64) -> TelemetryResult<()> {
822    router.log_ts(DataType::TimeSyncRequest, t1_ms, &[seq, t1_ms])
823}
824
825/// Builds a time sync response packet.
826pub fn build_timesync_response(
827    seq: u64,
828    t1_ms: u64,
829    t2_ms: u64,
830    t3_ms: u64,
831) -> TelemetryResult<Packet> {
832    let meta = message_meta(DataType::TimeSyncResponse);
833    Packet::from_u64_slice(
834        DataType::TimeSyncResponse,
835        &[seq, t1_ms, t2_ms, t3_ms],
836        meta.endpoints,
837        t3_ms,
838    )
839}
840
841/// Queues a time sync response message through the router telemetry path.
842pub fn send_timesync_response(
843    router: &Router,
844    seq: u64,
845    t1_ms: u64,
846    t2_ms: u64,
847    t3_ms: u64,
848) -> TelemetryResult<()> {
849    router.log_ts(
850        DataType::TimeSyncResponse,
851        t3_ms,
852        &[seq, t1_ms, t2_ms, t3_ms],
853    )
854}
855
856fn decode_u64_payload(
857    pkt: &Packet,
858    expected_ty: DataType,
859    expected_words: usize,
860) -> TelemetryResult<Vec<u64>> {
861    if pkt.data_type() != expected_ty {
862        return Err(TelemetryError::InvalidType);
863    }
864
865    let vals = pkt.data_as_u64()?;
866    if vals.len() != expected_words {
867        return Err(TelemetryError::SizeMismatch {
868            expected: expected_words * size_of::<u64>(),
869            got: vals.len() * size_of::<u64>(),
870        });
871    }
872
873    Ok(vals)
874}
875
876/// Decodes a time sync announce packet into strongly typed fields.
877pub fn decode_timesync_announce(pkt: &Packet) -> TelemetryResult<TimeSyncAnnounceFields> {
878    let vals = decode_u64_payload(pkt, DataType::TimeSyncAnnounce, TIMESYNC_ANNOUNCE_WORDS)?;
879    Ok(TimeSyncAnnounceFields {
880        priority: vals[0],
881        time_ms: vals[1],
882    })
883}
884
885/// Decodes a time sync request packet into strongly typed fields.
886pub fn decode_timesync_request(pkt: &Packet) -> TelemetryResult<TimeSyncRequestFields> {
887    let vals = decode_u64_payload(pkt, DataType::TimeSyncRequest, TIMESYNC_REQUEST_WORDS)?;
888    Ok(TimeSyncRequestFields {
889        seq: vals[0],
890        t1_ms: vals[1],
891    })
892}
893
894/// Decodes a time sync response packet into strongly typed fields.
895pub fn decode_timesync_response(pkt: &Packet) -> TelemetryResult<TimeSyncResponseFields> {
896    let vals = decode_u64_payload(pkt, DataType::TimeSyncResponse, TIMESYNC_RESPONSE_WORDS)?;
897    Ok(TimeSyncResponseFields {
898        seq: vals[0],
899        t1_ms: vals[1],
900        t2_ms: vals[2],
901        t3_ms: vals[3],
902    })
903}