cadmus_core/
time_manager.rs1use 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}