Skip to main content

cadmus_core/
time_manager.rs

1use anyhow::Error;
2use chrono::{DateTime, Utc};
3use sntpc::{NtpContext, StdTimestampGen};
4use sntpc_net_std::UdpSocketWrapper;
5use std::net::{ToSocketAddrs, UdpSocket};
6use std::sync::mpsc::Sender;
7use std::time::Duration;
8
9use crate::device::CURRENT_DEVICE;
10use crate::geolocation;
11use crate::geolocation::GeoLocation;
12use crate::http::Client as HttpClient;
13use crate::rtc::Rtc;
14use crate::view::{Event, NotificationEvent};
15
16const NTP_TIMEOUT: Duration = Duration::from_secs(5);
17
18pub struct TimeManager {
19    rtc: Rtc,
20}
21
22impl TimeManager {
23    pub fn new(rtc: Rtc) -> Self {
24        TimeManager { rtc }
25    }
26
27    pub fn sync(
28        &self,
29        ntp_host: &str,
30        manual: bool,
31        geolocation: Option<GeoLocation>,
32        hub: &Sender<Event>,
33    ) -> Result<(), Error> {
34        if let Err(e) = self.detect_and_set_timezone(geolocation) {
35            if manual {
36                hub.send(Event::Notification(NotificationEvent::Show(crate::fl!(
37                    "notification-timezone-detection-failed"
38                ))))
39                .ok();
40            }
41            tracing::warn!(error = %e, "timezone detection failed");
42        }
43
44        let ntp_time = match self.query_ntp(ntp_host) {
45            Ok(t) => t,
46            Err(e) => {
47                if manual {
48                    hub.send(Event::Notification(NotificationEvent::Show(crate::fl!(
49                        "notification-time-sync-failed"
50                    ))))
51                    .ok();
52                } else {
53                    tracing::warn!(error = %e, "ntp query failed");
54                }
55                return Err(e);
56            }
57        };
58
59        let result = self
60            .set_system_clock(ntp_time)
61            .and_then(|()| self.rtc.set_time(ntp_time));
62
63        match result {
64            Ok(()) => {
65                tracing::info!(time = %ntp_time, "time synced");
66                hub.send(Event::ClockTick).ok();
67                Ok(())
68            }
69            Err(e) => {
70                if manual {
71                    hub.send(Event::Notification(NotificationEvent::Show(crate::fl!(
72                        "notification-time-sync-failed"
73                    ))))
74                    .ok();
75                }
76                tracing::warn!(error = %e, "set_system_clock or rtc.set_time failed");
77                Err(e)
78            }
79        }
80    }
81
82    fn detect_and_set_timezone(&self, geolocation: Option<GeoLocation>) -> Result<(), Error> {
83        let geo = match geolocation {
84            Some(geo) => geo,
85            None => {
86                let client = HttpClient::new()?;
87
88                geolocation::fetch_geolocation(&client)?
89            }
90        };
91
92        CURRENT_DEVICE.set_system_timezone(geo.timezone)?;
93
94        Ok(())
95    }
96
97    fn query_ntp(&self, host: &str) -> Result<DateTime<Utc>, Error> {
98        query_ntp(host)
99    }
100
101    fn set_system_clock(&self, time: DateTime<Utc>) -> Result<(), Error> {
102        let tv = libc::timeval {
103            tv_sec: time.timestamp() as libc::time_t,
104            tv_usec: time.timestamp_subsec_micros() as libc::suseconds_t,
105        };
106        let ret = unsafe { libc::settimeofday(&tv, std::ptr::null()) };
107        if ret != 0 {
108            return Err(anyhow::anyhow!(
109                "settimeofday failed: {}",
110                std::io::Error::last_os_error()
111            ));
112        }
113        Ok(())
114    }
115}
116
117fn query_ntp(host: &str) -> Result<DateTime<Utc>, Error> {
118    let addrs: Vec<_> = host.to_socket_addrs()?.collect();
119
120    let mut last_err = None;
121    for addr in &addrs {
122        let bind_addr = match addr {
123            std::net::SocketAddr::V4(_) => "0.0.0.0:0",
124            std::net::SocketAddr::V6(_) => "[::]:0",
125        };
126
127        let socket = match UdpSocket::bind(bind_addr) {
128            Ok(s) => s,
129            Err(e) => {
130                last_err = Some(anyhow::anyhow!("UDP bind failed for {bind_addr}: {e}"));
131                continue;
132            }
133        };
134
135        if socket.set_read_timeout(Some(NTP_TIMEOUT)).is_err() {
136            continue;
137        }
138
139        let socket = UdpSocketWrapper::new(socket);
140        let context = NtpContext::new(StdTimestampGen::default());
141
142        match sntpc::sync::get_time(*addr, &socket, context) {
143            Ok(result) => {
144                let now = Utc::now();
145                let offset = chrono::Duration::microseconds(result.offset());
146                return Ok(now + offset);
147            }
148            Err(e) => {
149                last_err = Some(anyhow::anyhow!("NTP error: {e:?}"));
150            }
151        }
152    }
153
154    Err(last_err.unwrap_or_else(|| anyhow::anyhow!("DNS resolution failed for NTP host: {host}")))
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[ignore]
162    #[test]
163    fn ntp_query_with_hostname() {
164        let result = query_ntp("time.cloudflare.com:123");
165        assert!(result.is_ok(), "NTP query failed: {:?}", result.err());
166
167        let ntp_time = result.unwrap();
168        let now = Utc::now();
169        let diff = (now - ntp_time).num_seconds().abs();
170        assert!(diff < 60, "NTP time off by {diff}s, expected <60s");
171    }
172}