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