From 6ab3a5ab514e475ddb2186cc080d931a770d3b4f Mon Sep 17 00:00:00 2001 From: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> Date: Sat, 6 Jun 2026 07:46:06 +0200 Subject: [PATCH 1/2] feat(Core): add automatic date and time sync via NTP Change-Id: ec43ed615ec2a61436a44abca62e59bf Change-Id-Short: lnvwlmtyulnx --- Cargo.lock | 115 +++++++++- contrib/Settings-sample.toml | 4 + crates/cadmus/src/app.rs | 9 +- crates/core/Cargo.toml | 3 + crates/core/i18n/en-GB/cadmus_core.ftl | 3 + crates/core/src/context.rs | 9 +- crates/core/src/device/mod.rs | 36 ++++ crates/core/src/device/time.rs | 53 +++++ crates/core/src/lib.rs | 1 + crates/core/src/rtc.rs | 200 ++++++++++++++++-- crates/core/src/settings/mod.rs | 2 + crates/core/src/task/mod.rs | 99 ++++++--- crates/core/src/task/time_sync.rs | 33 +++ crates/core/src/time_manager.rs | 131 ++++++++++++ crates/core/src/view/common.rs | 7 +- crates/core/src/view/mod.rs | 1 + .../core/src/view/settings_editor/category.rs | 5 +- .../src/view/settings_editor/kinds/general.rs | 38 ++++ .../view/settings_editor/kinds/identity.rs | 1 + .../src/view/settings_editor/kinds/mod.rs | 2 + crates/emulator/src/main.rs | 6 +- 21 files changed, 686 insertions(+), 72 deletions(-) create mode 100644 crates/core/src/device/time.rs create mode 100644 crates/core/src/task/time_sync.rs create mode 100644 crates/core/src/time_manager.rs diff --git a/Cargo.lock b/Cargo.lock index d1df3900..cf24569d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -409,6 +409,7 @@ dependencies = [ "byteorder", "cc", "chrono", + "chrono-tz", "criterion", "ctor", "downcast-rs", @@ -451,6 +452,8 @@ dependencies = [ "septem", "serde", "serde_json", + "sntpc", + "sntpc-net-std", "sqlx", "tempfile", "thiserror 2.0.18", @@ -521,6 +524,28 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf 0.11.3", +] + +[[package]] +name = "chrono-tz-build" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" +dependencies = [ + "parse-zoneinfo", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -2273,6 +2298,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miniloop" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad14b72c9fa5a663d069c582d576e5336995e3fafe7d0965d2bba3db1f98c40c" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2634,6 +2665,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + [[package]] name = "paste" version = "1.0.15" @@ -2678,24 +2718,53 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_shared", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", ] [[package]] @@ -2705,7 +2774,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", ] [[package]] @@ -3748,6 +3826,25 @@ dependencies = [ "version_check", ] +[[package]] +name = "sntpc" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29793617f843f4e7df42c83b896939e5ca3b773a5b5d25ea68eaa247b7edbf9d" +dependencies = [ + "cfg-if", + "miniloop", +] + +[[package]] +name = "sntpc-net-std" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfc0684e96c47adfd6ac067902873b7c4ffbe590a8594b473c20027a7cfeaec" +dependencies = [ + "sntpc", +] + [[package]] name = "socket2" version = "0.6.3" @@ -3998,7 +4095,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.13.1", "precomputed-hash", ] @@ -4008,8 +4105,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", ] @@ -4907,8 +5004,8 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" dependencies = [ - "phf", - "phf_codegen", + "phf 0.13.1", + "phf_codegen 0.13.1", "string_cache", "string_cache_codegen", ] diff --git a/contrib/Settings-sample.toml b/contrib/Settings-sample.toml index ce8860ef..97d10421 100644 --- a/contrib/Settings-sample.toml +++ b/contrib/Settings-sample.toml @@ -9,6 +9,10 @@ inverted = false sleep-cover = true # Automatically enters shared mode when connected to a computer. auto-share = false +# Automatically synchronize the device time via NTP when WiFi connects. +# This will also set the correct timezon, it uses +# time.cloudflare.com and ipapi.co +auto-time = false # Defines how the back and forward buttons are mapped to the # *page forward* and *page backward* actions. # Possible values: "natural", "inverted". diff --git a/crates/cadmus/src/app.rs b/crates/cadmus/src/app.rs index 91d717f3..632abc34 100644 --- a/crates/cadmus/src/app.rs +++ b/crates/cadmus/src/app.rs @@ -22,7 +22,7 @@ use cadmus_core::input::{ }; use cadmus_core::library::Library; use cadmus_core::lightsensor::{KoboLightSensor, LightSensor}; -use cadmus_core::rtc::{AlarmType, EnsureAlarmOutcome, PastDueAction, Rtc}; +use cadmus_core::rtc::{AlarmType, EnsureAlarmOutcome, PastDueAction}; use cadmus_core::settings::versioned::SettingsManager; use cadmus_core::settings::{ ButtonScheme, IntermKind, IntermissionDisplay, LoggingSettings, RotationLock, Settings, @@ -65,7 +65,6 @@ use tracing::{Level, debug, error, info, warn}; pub const APP_NAME: &str = "Cadmus"; const FB_DEVICE: &str = "/dev/fb0"; -const RTC_DEVICE: &str = "/dev/rtc0"; const TOUCH_INPUTS: [&str; 5] = [ "/dev/input/by-path/platform-2-0010-event", "/dev/input/by-path/platform-1-0038-event", @@ -119,9 +118,6 @@ fn build_context( fonts: Fonts, database: Database, ) -> Result { - let rtc = Rtc::new(RTC_DEVICE) - .map_err(|e| warn!(error = %e, "Can't open RTC device")) - .ok(); let mut settings = settings; if settings.libraries.is_empty() { @@ -162,7 +158,6 @@ fn build_context( Ok(Context::new( fb, - rtc, library, database, settings, @@ -790,7 +785,7 @@ pub fn run() -> Result<(), Error> { #[cfg(feature = "tracing")] tracing::trace!(event = ?evt, "handling event"); - background_tasks.handle_event(&evt, &tx, &context.database, &context.settings); + background_tasks.handle_event(&evt, &tx, &context); match evt { Event::Device(de) => match de { diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 60569b56..a1c22465 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -55,6 +55,7 @@ chrono = { version = "0.4.42", features = [ "serde", "clock", ], default-features = false } +chrono-tz = "0.8" reqwest = { version = "0.13.1", default-features = false, features = [ "blocking", "form", @@ -62,6 +63,8 @@ reqwest = { version = "0.13.1", default-features = false, features = [ "rustls-no-provider", ] } rustls = { workspace = true } +sntpc = { version = "0.10", features = ["std", "sync"] } +sntpc-net-std = "1.2" webpki-roots = "1.0.5" ctor = "1.0" diff --git a/crates/core/i18n/en-GB/cadmus_core.ftl b/crates/core/i18n/en-GB/cadmus_core.ftl index 34d389a6..5e3ec745 100644 --- a/crates/core/i18n/en-GB/cadmus_core.ftl +++ b/crates/core/i18n/en-GB/cadmus_core.ftl @@ -19,6 +19,7 @@ notification-downloading-dictionary-progress = Downloading { $lang } ({ $downloa notification-dictionary-indexing = Indexing "{ $name }" notification-not-online = WiFi must be connected for this action. notification-refresh-rate-invalid = Refresh rate must be a number between 0 and 255. +notification-time-sync-failed = Time sync failed # Common cancel = Cancel @@ -31,6 +32,7 @@ top-menu-restart-app = Restart {-app-name} top-menu-suspend = Suspend top-menu-quit = Quit top-menu-power-off = Power Off +top-menu-sync-time = Sync Time # Settings - Button Scheme settings-button-scheme-natural = Natural @@ -46,6 +48,7 @@ settings-general-auto-power-off = Auto Power Off (days) settings-general-auto-power-off-input = Auto Power Off (days, 0 = never) settings-general-auto-suspend = Auto Suspend (minutes) settings-general-auto-suspend-input = Auto Suspend (minutes, 0 = never) +settings-general-auto-time = Automatic Time Sync settings-general-button-scheme = Button Scheme settings-general-enable-auto-share = Enable Auto Share settings-general-enable-sleep-cover = Enable Sleep Cover diff --git a/crates/core/src/context.rs b/crates/core/src/context.rs index 925c472f..c8c12efa 100644 --- a/crates/core/src/context.rs +++ b/crates/core/src/context.rs @@ -9,7 +9,7 @@ use crate::geom::Rectangle; use crate::helpers::{Fingerprint, Fp, IsHidden, load_json}; use crate::library::Library; use crate::lightsensor::LightSensor; -use crate::rtc::{AlarmManager, Rtc}; +use crate::rtc::AlarmManager; use crate::settings::Settings; use crate::view::ViewId; use crate::view::keyboard::Layout; @@ -57,7 +57,6 @@ pub struct Context { impl Context { pub fn new( fb: Box, - rtc: Option, library: Library, database: Database, settings: Settings, @@ -69,7 +68,10 @@ impl Context { let dims = fb.dims(); let rotation = CURRENT_DEVICE.transformed_rotation(fb.rotation()); let rng = Xoroshiro128Plus::seed_from_u64(Local::now().timestamp_subsec_nanos() as u64); - let alarm_manager = rtc.map(AlarmManager::new); + let alarm_manager = CURRENT_DEVICE + .rtc() + .ok() + .map(|rtc| AlarmManager::new(rtc.clone())); Context { fb, alarm_manager, @@ -241,7 +243,6 @@ pub mod test_helpers { database.migrate().expect("failed to run migrations"); Context::new( Box::new(Pixmap::new(600, 800, 1)), - None, Library::new(Path::new("/tmp"), &database, "test").unwrap(), database, Settings::default(), diff --git a/crates/core/src/device/mod.rs b/crates/core/src/device/mod.rs index fe4b6a5d..2612370c 100644 --- a/crates/core/src/device/mod.rs +++ b/crates/core/src/device/mod.rs @@ -3,6 +3,8 @@ use crate::device::error::DeviceError; use crate::device::metadata::DeviceMetadata; use crate::input::TouchProto; +use crate::rtc::Rtc; +use crate::time_manager::TimeManager; use lazy_static::lazy_static; use once_cell::sync::OnceCell; use std::env; @@ -14,10 +16,13 @@ mod metadata; pub mod migration; mod model; mod power; +pub mod time; mod types; mod usb; mod wifi; +const RTC_DEVICE: &str = "/dev/rtc0"; + pub use model::Model; pub use types::{FrontlightKind, Orientation}; @@ -29,6 +34,8 @@ pub struct Device { metadata: OnceCell, wifi_manager: OnceCell>, power_manager: OnceCell>, + rtc: OnceCell, + time_manager: OnceCell, } impl Debug for Device { @@ -113,6 +120,8 @@ impl Device { metadata: OnceCell::new(), wifi_manager: OnceCell::new(), power_manager: OnceCell::new(), + rtc: OnceCell::new(), + time_manager: OnceCell::new(), } } @@ -157,6 +166,33 @@ impl Device { .map(|b| b.as_ref()) } + /// Returns the shared RTC instance for this device. + pub fn rtc(&self) -> Result<&Rtc, anyhow::Error> { + cfg_select! { + feature = "kobo" => { + self.rtc.get_or_try_init(|| Rtc::new(RTC_DEVICE)) + } + _ => { + unimplemented!("The current device does not support rtc.") + } + } + } + + /// Returns the time manager for this device. + pub fn time_manager(&self) -> Result<&TimeManager, anyhow::Error> { + self.time_manager + .get_or_try_init(|| Ok(TimeManager::new(self.rtc()?.clone()))) + } + + /// Sets the system timezone. + /// + /// On Kobo devices this updates `/etc/localtime`, `/etc/timezone`, and + /// calls `tzset()`. On other platforms this is a no-op. + pub fn set_system_timezone(&self, tz: chrono_tz::Tz) -> Result<(), anyhow::Error> { + time::set_system_timezone(tz)?; + Ok(()) + } + /// Returns the install subdirectory for this build. /// /// Kobo devices install Cadmus under `.adds/` on the user-visible storage. diff --git a/crates/core/src/device/time.rs b/crates/core/src/device/time.rs new file mode 100644 index 00000000..fc5bd00c --- /dev/null +++ b/crates/core/src/device/time.rs @@ -0,0 +1,53 @@ +use anyhow::Error; +use chrono_tz::Tz; +use std::ffi::CString; +use std::path::Path; + +const ZONEINFO_DIR: &str = "/etc/zoneinfo"; +const LOCALTIME: &str = "/etc/localtime"; +const TIMEZONE_FILE: &str = "/etc/timezone"; + +unsafe extern "C" { + fn tzset(); +} + +/// Sets the system timezone on Kobo devices. +/// +/// # Kobo +/// +/// This function performs three operations to ensure the timezone change is persistent and +/// immediately reflected in the running process: +/// +/// 1. Creates `/etc/localtime` as a symlink to the appropriate zoneinfo file (recognized by +/// system utilities and the C library). +/// 2. Writes the timezone name to `/etc/timezone` for persistence across reboots. +/// 3. Updates the current process's timezone state by setting the `TZ` environment variable +/// and calling `libc::tzset()`. This ensures that subsequent calls to libc time functions +/// in this process reflect the new timezone immediately, rather than the system-wide +/// timezone being adopted only after the process restarts. +/// +/// # Errors +/// +/// Returns an error if any filesystem operations fail. +pub fn set_system_timezone(tz: Tz) -> Result<(), Error> { + cfg_select! { + feature = "kobo" => { + let tz_name = tz.to_string(); + let tz_path = Path::new(ZONEINFO_DIR).join(&tz_name); + + std::fs::remove_file(LOCALTIME).ok(); + std::os::unix::fs::symlink(&tz_path, LOCALTIME)?; + std::fs::write(TIMEZONE_FILE, &tz_name)?; + + unsafe { + let tz_cstr = CString::new(tz_name.as_str())?; + libc::setenv(c"TZ".as_ptr(), tz_cstr.as_ptr(), 1); + tzset(); + } + + tracing::info!(tz = %tz_name, "system timezone updated"); + Ok(()) + } + _ => unimplemented!("set_system_timezone is only available on Kobo devices") + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index e6152b44..3342bb90 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -34,6 +34,7 @@ pub mod settings; pub mod task; #[cfg(any(feature = "profiling", feature = "tracing"))] pub mod telemetry; +pub mod time_manager; mod unit; pub mod version; pub mod view; diff --git a/crates/core/src/rtc.rs b/crates/core/src/rtc.rs index e528a899..e3649efa 100644 --- a/crates/core/src/rtc.rs +++ b/crates/core/src/rtc.rs @@ -1,15 +1,18 @@ use anyhow::Error; -use chrono::{DateTime, Datelike, Duration, Timelike, Utc}; +use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc}; use nix::{ioctl_none, ioctl_read, ioctl_write_ptr}; use std::collections::BTreeMap; -use std::fs::File; +use std::fs::{File, OpenOptions}; use std::mem; use std::os::unix::io::AsRawFd; use std::path::Path; +use std::sync::{Arc, Mutex}; ioctl_read!(rtc_read_alarm, b'p', 0x10, RtcWkalrm); ioctl_write_ptr!(rtc_write_alarm, b'p', 0x0f, RtcWkalrm); ioctl_none!(rtc_disable_alarm, b'p', 0x02); +ioctl_read!(rtc_read_time, b'p', 0x09, RtcTime); +ioctl_write_ptr!(rtc_set_time, b'p', 0x0a, RtcTime); #[repr(C)] #[derive(Debug, Clone)] @@ -41,58 +44,215 @@ pub struct RtcWkalrm { impl RtcTime { fn year(&self) -> i32 { - 1900 + self.tm_year as i32 + 1900 + self.tm_year + } +} + +impl TryFrom for DateTime { + type Error = Error; + + fn try_from(rt: RtcTime) -> Result { + Utc.with_ymd_and_hms( + rt.year(), + (rt.tm_mon as u32) + 1, + rt.tm_mday as u32, + rt.tm_hour as u32, + rt.tm_min as u32, + rt.tm_sec as u32, + ) + .single() + .ok_or_else(|| anyhow::anyhow!("invalid RTC date/time fields")) + } +} + +impl From> for RtcTime { + fn from(dt: DateTime) -> Self { + RtcTime { + tm_sec: dt.second() as libc::c_int, + tm_min: dt.minute() as libc::c_int, + tm_hour: dt.hour() as libc::c_int, + tm_mday: dt.day() as libc::c_int, + tm_mon: dt.month0() as libc::c_int, + tm_year: (dt.year() - 1900) as libc::c_int, + tm_wday: -1, + tm_yday: -1, + tm_isdst: -1, + } } } impl RtcWkalrm { + /// Returns whether the alarm is currently enabled. pub fn enabled(&self) -> bool { self.enabled == 1 } + /// Returns the year field from the alarm's stored time. + /// + /// This is the full calendar year (e.g., 2024), not the offset from 1900. pub fn year(&self) -> i32 { self.time.year() } } -pub struct Rtc(File); +/// Interface to the hardware real-time clock device. +/// +/// `Rtc` provides access to both time and alarm functionality of the RTC. +/// Operations are serialized via an internal mutex to ensure thread-safe access +/// to the underlying device file. +pub struct Rtc(Arc>); + +impl Clone for Rtc { + fn clone(&self) -> Self { + Rtc(Arc::clone(&self.0)) + } +} impl Rtc { + /// Opens the RTC device and creates a new interface handle. + /// + /// # Arguments + /// + /// * `path` - Path to the RTC device file (typically `/dev/rtc0` or `/dev/rtc`) + /// + /// # Returns + /// + /// A new `Rtc` handle on success, or an error if the device cannot be opened. + /// + /// # Example + /// + /// ```no_run + /// # use cadmus_core::rtc::Rtc; + /// let rtc = Rtc::new("/dev/rtc0")?; + /// # Ok::<(), Box>(()) + /// ``` pub fn new>(path: P) -> Result { - let file = File::open(path)?; - Ok(Rtc(file)) + let file = OpenOptions::new().read(true).write(true).open(path)?; + Ok(Rtc(Arc::new(Mutex::new(file)))) } + /// Reads the current alarm settings from the hardware. + /// + /// Returns information about the wake alarm, including whether it is enabled + /// and any pending alarm status. The alarm time is stored as [`RtcWkalrm`]. + /// + /// # Returns + /// + /// Alarm settings on success, or an error if the ioctl fails or the lock is poisoned. pub fn alarm(&self) -> Result { let mut rwa = RtcWkalrm::default(); + let file = self + .0 + .lock() + .map_err(|e| anyhow::anyhow!("lock poisoned: {}", e))?; unsafe { - rtc_read_alarm(self.0.as_raw_fd(), &mut rwa) + rtc_read_alarm(file.as_raw_fd(), &mut rwa) .map(|_| rwa) .map_err(|e| e.into()) } } + /// Programs the hardware to wake at the specified time. + /// + /// Enables a single-shot alarm that will fire at the given UTC time. + /// If an alarm is already scheduled, it is replaced. + /// + /// # Arguments + /// + /// * `wake_time` - The UTC time when the alarm should fire + /// + /// # Returns + /// + /// A status code on success (typically 0 if supported), or an error if + /// the ioctl fails or the lock is poisoned. pub fn set_alarm(&self, wake_time: DateTime) -> Result { let rwa = RtcWkalrm { enabled: 1, pending: 0, - time: RtcTime { - tm_sec: wake_time.second() as libc::c_int, - tm_min: wake_time.minute() as libc::c_int, - tm_hour: wake_time.hour() as libc::c_int, - tm_mday: wake_time.day() as libc::c_int, - tm_mon: wake_time.month0() as libc::c_int, - tm_year: (wake_time.year() - 1900) as libc::c_int, - tm_wday: -1, - tm_yday: -1, - tm_isdst: -1, - }, + time: wake_time.into(), }; - unsafe { rtc_write_alarm(self.0.as_raw_fd(), &rwa).map_err(|e| e.into()) } + let file = self + .0 + .lock() + .map_err(|e| anyhow::anyhow!("lock poisoned: {}", e))?; + unsafe { rtc_write_alarm(file.as_raw_fd(), &rwa).map_err(|e| e.into()) } } + /// Disables the hardware alarm. + /// + /// Clears any pending alarm without affecting the alarm time itself. + /// + /// # Returns + /// + /// A status code on success (typically 0 if supported), or an error if + /// the ioctl fails or the lock is poisoned. pub fn disable_alarm(&self) -> Result { - unsafe { rtc_disable_alarm(self.0.as_raw_fd()).map_err(|e| e.into()) } + let file = self + .0 + .lock() + .map_err(|e| anyhow::anyhow!("lock poisoned: {}", e))?; + unsafe { rtc_disable_alarm(file.as_raw_fd()).map_err(|e| e.into()) } + } + + /// Reads the current time from the hardware RTC. + /// + /// # Returns + /// + /// The current UTC time on success, or an error if the ioctl fails, + /// the RTC fields are invalid, or the lock is poisoned. + /// + /// # Example + /// + /// ```no_run + /// # use cadmus_core::rtc::Rtc; + /// # let rtc = Rtc::new("/dev/rtc0")?; + /// let now = rtc.read_time()?; + /// println!("RTC time: {}", now); + /// # Ok::<(), Box>(()) + /// ``` + pub fn read_time(&self) -> Result, Error> { + let mut rt = unsafe { mem::zeroed::() }; + let file = self + .0 + .lock() + .map_err(|e| anyhow::anyhow!("lock poisoned: {}", e))?; + unsafe { + rtc_read_time(file.as_raw_fd(), &mut rt)?; + } + rt.try_into() + } + + /// Sets the hardware RTC to the specified time. + /// + /// Updates the RTC with a new UTC time. This typically requires elevated privileges. + /// + /// # Arguments + /// + /// * `time` - The UTC time to set + /// + /// # Returns + /// + /// Success with no value, or an error if the ioctl fails or the lock is poisoned. + /// + /// # Example + /// + /// ```no_run + /// # use cadmus_core::rtc::Rtc; + /// # use chrono::Utc; + /// # let rtc = Rtc::new("/dev/rtc0")?; + /// rtc.set_time(Utc::now())?; + /// # Ok::<(), Box>(()) + /// ``` + pub fn set_time(&self, time: DateTime) -> Result<(), Error> { + let rt: RtcTime = time.into(); + let file = self + .0 + .lock() + .map_err(|e| anyhow::anyhow!("lock poisoned: {}", e))?; + unsafe { + rtc_set_time(file.as_raw_fd(), &rt)?; + } + Ok(()) } } diff --git a/crates/core/src/settings/mod.rs b/crates/core/src/settings/mod.rs index fc0d9a10..4237104f 100644 --- a/crates/core/src/settings/mod.rs +++ b/crates/core/src/settings/mod.rs @@ -256,6 +256,7 @@ pub struct Settings { pub inverted: bool, pub sleep_cover: bool, pub auto_share: bool, + pub auto_time: bool, #[serde(skip_serializing_if = "Option::is_none")] pub rotation_lock: Option, pub button_scheme: ButtonScheme, @@ -995,6 +996,7 @@ impl Default for Settings { inverted: false, sleep_cover: true, auto_share: false, + auto_time: false, rotation_lock: None, button_scheme: ButtonScheme::Natural, auto_suspend: 30.0, diff --git a/crates/core/src/task/mod.rs b/crates/core/src/task/mod.rs index c2fd66be..49ffcd99 100644 --- a/crates/core/src/task/mod.rs +++ b/crates/core/src/task/mod.rs @@ -44,6 +44,8 @@ mod hello_world; pub mod import; pub mod thumbnail; #[cfg(any(feature = "kobo", doc))] +pub mod time_sync; +#[cfg(any(feature = "kobo", doc))] mod wifi_status_monitor; use std::collections::{HashMap, VecDeque}; @@ -54,9 +56,12 @@ use std::time::Duration; use thiserror::Error; +use crate::context::Context; use crate::db::Database; +use crate::fl; +use crate::input::DeviceEvent; use crate::settings::Settings; -use crate::view::Event; +use crate::view::{EntryId, Event, NotificationEvent}; /// Errors that can occur during task management operations. #[derive(Error, Debug)] @@ -90,6 +95,9 @@ pub enum TaskId { /// WiFi status monitor using dhcpcd-dbus (kobo builds only). #[cfg(any(feature = "kobo", doc))] WifiStatusMonitor, + /// Time synchronization via NTP (kobo builds only). + #[cfg(any(feature = "kobo", doc))] + TimeSync, /// Test-only task for unit tests. #[cfg(test)] TestTask, @@ -111,6 +119,8 @@ impl std::fmt::Display for TaskId { TaskId::DbusMonitor => write!(f, "dbus_monitor"), #[cfg(feature = "kobo")] TaskId::WifiStatusMonitor => write!(f, "wifi_status_monitor"), + #[cfg(feature = "kobo")] + TaskId::TimeSync => write!(f, "time_sync"), #[cfg(test)] TaskId::TestTask => write!(f, "test_task"), #[cfg(test)] @@ -386,17 +396,8 @@ impl TaskManager { /// /// Must be called for every event before passing it to the view tree. /// Always returns `false` — it never consumes events. - #[cfg_attr( - feature = "tracing", - tracing::instrument(skip(self, hub, database, settings)) - )] - pub fn handle_event( - &mut self, - evt: &Event, - hub: &Sender, - database: &Database, - settings: &Settings, - ) -> bool { + #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, hub, context)))] + pub fn handle_event(&mut self, evt: &Event, hub: &Sender, context: &Context) -> bool { self.cleanup_finished(); self.flush_buffered_events(hub); @@ -405,17 +406,49 @@ impl TaskManager { library_index, force, } => { - self.schedule_import(*library_index, *force, hub, database, settings); + self.schedule_import( + *library_index, + *force, + hub, + &context.database, + &context.settings, + ); } Event::ImportFinished { library_index } => { - self.drain_pending_imports(hub, database, settings); - self.schedule_thumbnail_extraction(*library_index, hub, database, settings); + self.drain_pending_imports(hub, &context.database, &context.settings); + self.schedule_thumbnail_extraction( + *library_index, + hub, + &context.database, + &context.settings, + ); } Event::ThumbnailExtractionFinished { .. } => { - self.drain_pending_thumbnails(hub, database, settings); + self.drain_pending_thumbnails(hub, &context.database, &context.settings); } Event::ReindexDictionaries => { - self.schedule_dictionary_index(hub, database); + self.schedule_dictionary_index(hub, &context.database); + } + Event::Device(DeviceEvent::NetUp) => { + #[cfg(feature = "kobo")] + { + if context.settings.auto_time { + self.schedule_time_sync(false, hub); + } + } + } + Event::Select(EntryId::SyncTime) => { + #[cfg(feature = "kobo")] + { + if !context.online { + hub.send(Event::Notification(NotificationEvent::Show(fl!( + "notification-not-online" + )))) + .ok(); + } else { + self.schedule_time_sync(true, hub); + } + } } _ => {} } @@ -490,6 +523,23 @@ impl TaskManager { } } + #[cfg(feature = "kobo")] + #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))] + fn schedule_time_sync(&mut self, manual: bool, hub: &Sender) { + if self.is_running(&TaskId::TimeSync) { + tracing::warn!("Time sync task alredy running, not scheduling"); + + return; + } + + if let Ok(time_manager) = crate::device::CURRENT_DEVICE.time_manager() { + let task = Box::new(time_sync::TimeSyncTask::new(time_manager, manual)); + if let Err(e) = self.start(task, hub.clone()) { + tracing::warn!(error = %e, "failed to start time sync task"); + } + } + } + /// Schedules a thumbnail extraction task, queuing the index if one is already running. #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))] pub fn schedule_thumbnail_extraction( @@ -613,6 +663,7 @@ pub fn register_startup_tasks( #[cfg(test)] mod tests { use super::*; + use crate::context::test_helpers::create_test_context; use std::sync::mpsc; use std::time::{Duration, Instant}; @@ -791,9 +842,7 @@ mod tests { fn import_queue_preserves_force_flag() { let mut manager = TaskManager::new(); let (hub, _rx) = mpsc::channel(); - let database = Database::new(":memory:").unwrap(); - database.migrate().unwrap(); - let settings = Settings::default(); + let context = create_test_context(); // Simulate a running import task with a blocking thread. let (shutdown_tx, shutdown_rx) = mpsc::channel(); @@ -815,8 +864,7 @@ mod tests { force: true, }, &hub, - &database, - &settings, + &context, ); assert_eq!( @@ -831,9 +879,7 @@ mod tests { fn import_queue_preserves_force_false_flag() { let mut manager = TaskManager::new(); let (hub, _rx) = mpsc::channel(); - let database = Database::new(":memory:").unwrap(); - database.migrate().unwrap(); - let settings = Settings::default(); + let context = create_test_context(); let (shutdown_tx, shutdown_rx) = mpsc::channel(); let blocking_handle = thread::spawn(move || { @@ -854,8 +900,7 @@ mod tests { force: false, }, &hub, - &database, - &settings, + &context, ); assert_eq!(manager.pending_import_indices.front(), Some(&(None, false))); diff --git a/crates/core/src/task/time_sync.rs b/crates/core/src/task/time_sync.rs new file mode 100644 index 00000000..7f1e42cd --- /dev/null +++ b/crates/core/src/task/time_sync.rs @@ -0,0 +1,33 @@ +use std::sync::mpsc::Sender; + +use crate::task::{BackgroundTask, ShutdownSignal, TaskId}; +use crate::time_manager::TimeManager; +use crate::view::Event; + +const NTP_HOST: &str = "time.cloudflare.com:123"; + +pub struct TimeSyncTask { + time_manager: &'static TimeManager, + manual: bool, +} + +impl TimeSyncTask { + pub fn new(time_manager: &'static TimeManager, manual: bool) -> Self { + TimeSyncTask { + time_manager, + manual, + } + } +} + +impl BackgroundTask for TimeSyncTask { + fn id(&self) -> TaskId { + TaskId::TimeSync + } + + fn run(&mut self, hub: &Sender, _shutdown: &ShutdownSignal) { + if let Err(e) = self.time_manager.sync(NTP_HOST, self.manual, hub) { + tracing::error!(error = %e, "time sync failed"); + } + } +} diff --git a/crates/core/src/time_manager.rs b/crates/core/src/time_manager.rs new file mode 100644 index 00000000..4211a127 --- /dev/null +++ b/crates/core/src/time_manager.rs @@ -0,0 +1,131 @@ +use anyhow::Error; +use chrono::{DateTime, Utc}; +use sntpc::{NtpContext, StdTimestampGen}; +use sntpc_net_std::UdpSocketWrapper; +use std::net::{ToSocketAddrs, UdpSocket}; +use std::sync::mpsc::Sender; +use std::time::Duration; + +use crate::device::CURRENT_DEVICE; +use crate::http::Client as HttpClient; +use crate::rtc::Rtc; +use crate::view::{Event, NotificationEvent}; + +const NTP_TIMEOUT: Duration = Duration::from_secs(5); + +pub struct TimeManager { + rtc: Rtc, +} + +impl TimeManager { + pub fn new(rtc: Rtc) -> Self { + TimeManager { rtc } + } + + pub fn sync(&self, ntp_host: &str, manual: bool, hub: &Sender) -> Result<(), Error> { + if let Err(e) = self.detect_and_set_timezone() { + if manual { + hub.send(Event::Notification(NotificationEvent::Show(e.to_string()))) + .ok(); + } else { + tracing::warn!(error = %e, "timezone detection failed"); + } + } + + let ntp_time = match self.query_ntp(ntp_host) { + Ok(t) => t, + Err(e) => { + if manual { + hub.send(Event::Notification(NotificationEvent::Show(crate::fl!( + "notification-time-sync-failed" + )))) + .ok(); + } else { + tracing::warn!(error = %e, "ntp query failed"); + } + return Err(e); + } + }; + + self.set_system_clock(ntp_time)?; + self.rtc.set_time(ntp_time)?; + + tracing::info!(time = %ntp_time, "time synced"); + hub.send(Event::ClockTick).ok(); + + Ok(()) + } + + fn detect_and_set_timezone(&self) -> Result { + let client = HttpClient::new()?; + let resp: serde_json::Value = client + .get("https://ipapi.co/json/") + .timeout(Duration::from_secs(10)) + .send()? + .json()?; + + let tz = resp["timezone"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("timezone field missing from ipapi response"))? + .parse::() + .map_err(|e| anyhow::anyhow!("invalid timezone from ipapi: {e}"))?; + + CURRENT_DEVICE.set_system_timezone(tz)?; + Ok(tz) + } + + fn query_ntp(&self, host: &str) -> Result, Error> { + query_ntp(host) + } + + fn set_system_clock(&self, time: DateTime) -> Result<(), Error> { + let tv = libc::timeval { + tv_sec: time.timestamp() as libc::time_t, + tv_usec: time.timestamp_subsec_micros() as libc::suseconds_t, + }; + let ret = unsafe { libc::settimeofday(&tv, std::ptr::null()) }; + if ret != 0 { + return Err(anyhow::anyhow!( + "settimeofday failed: {}", + std::io::Error::last_os_error() + )); + } + Ok(()) + } +} + +fn query_ntp(host: &str) -> Result, Error> { + let socket = UdpSocket::bind("0.0.0.0:0")?; + socket.set_read_timeout(Some(NTP_TIMEOUT))?; + let socket = UdpSocketWrapper::new(socket); + let context = NtpContext::new(StdTimestampGen::default()); + + let addr = host + .to_socket_addrs()? + .next() + .ok_or_else(|| anyhow::anyhow!("DNS resolution failed for NTP host: {host}"))?; + + let result = sntpc::sync::get_time(addr, &socket, context) + .map_err(|e| anyhow::anyhow!("NTP error: {:?}", e))?; + + let now = Utc::now(); + let offset = chrono::Duration::microseconds(result.offset()); + Ok(now + offset) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[ignore] + #[test] + fn ntp_query_with_hostname() { + let result = query_ntp("time.cloudflare.com:123"); + assert!(result.is_ok(), "NTP query failed: {:?}", result.err()); + + let ntp_time = result.unwrap(); + let now = Utc::now(); + let diff = (now - ntp_time).num_seconds().abs(); + assert!(diff < 60, "NTP time off by {diff}s, expected <60s"); + } +} diff --git a/crates/core/src/view/common.rs b/crates/core/src/view/common.rs index 34cd053f..b3b1f03d 100644 --- a/crates/core/src/view/common.rs +++ b/crates/core/src/view/common.rs @@ -340,7 +340,12 @@ pub fn toggle_clock_menu( let text = Local::now() .format(&context.settings.date_format) .to_string(); - let entries = vec![EntryKind::Message(text, None)]; + let entries = vec![ + EntryKind::Message(text, None), + EntryKind::Separator, + EntryKind::Command(fl!("top-menu-sync-time"), EntryId::SyncTime), + ]; + let clock_menu = Menu::new( rect, ViewId::ClockMenu, diff --git a/crates/core/src/view/mod.rs b/crates/core/src/view/mod.rs index 2465d48b..9dc48cd4 100644 --- a/crates/core/src/view/mod.rs +++ b/crates/core/src/view/mod.rs @@ -862,6 +862,7 @@ pub enum EntryId { ToggleAllowedKind(settings::FileExtension), /// Toggle whether a file extension is rendered with dithering. ToggleDitheredKind(settings::FileExtension), + SyncTime, } impl EntryKind { diff --git a/crates/core/src/view/settings_editor/category.rs b/crates/core/src/view/settings_editor/category.rs index 59156175..cb43eddc 100644 --- a/crates/core/src/view/settings_editor/category.rs +++ b/crates/core/src/view/settings_editor/category.rs @@ -1,8 +1,8 @@ use super::kinds::SettingKind; use super::kinds::dictionary::DictionaryInfo; use super::kinds::general::{ - AutoPowerOff, AutoShare, AutoSuspend, ButtonScheme, KeyboardLayout, Locale, SettingsRetention, - SleepCover, + AutoPowerOff, AutoShare, AutoSuspend, AutoTime, ButtonScheme, KeyboardLayout, Locale, + SettingsRetention, SleepCover, }; use super::kinds::import::{AllowedKindsSetting, ForceFullImport, ImportSyncMetadata}; use super::kinds::intermission::{IntermissionPowerOff, IntermissionShare, IntermissionSuspend}; @@ -64,6 +64,7 @@ impl Category { Category::General => vec![ Box::new(Locale), Box::new(AutoShare), + Box::new(AutoTime), Box::new(AutoSuspend), Box::new(AutoPowerOff), Box::new(ButtonScheme), diff --git a/crates/core/src/view/settings_editor/kinds/general.rs b/crates/core/src/view/settings_editor/kinds/general.rs index 35f8d221..cd983a91 100644 --- a/crates/core/src/view/settings_editor/kinds/general.rs +++ b/crates/core/src/view/settings_editor/kinds/general.rs @@ -324,6 +324,44 @@ impl SettingKind for AutoShare { } } +/// Auto time sync enable/disable toggle setting +pub struct AutoTime; + +impl SettingKind for AutoTime { + fn identity(&self) -> SettingIdentity { + SettingIdentity::AutoTime + } + + fn label(&self, _settings: &Settings) -> String { + fl!("settings-general-auto-time") + } + + fn fetch(&self, settings: &Settings) -> SettingData { + SettingData { + value: settings.auto_time.to_string(), + widget: WidgetKind::Toggle { + left_label: fl!("settings-general-toggle-on"), + right_label: fl!("settings-general-toggle-off"), + enabled: settings.auto_time, + tap_event: Event::Toggle(ToggleEvent::Setting(ToggleSettings::AutoTime)), + }, + } + } + + fn handle( + &self, + evt: &Event, + settings: &mut Settings, + _bus: &mut Bus, + ) -> (Option, bool) { + if let Event::Toggle(ToggleEvent::Setting(ToggleSettings::AutoTime)) = evt { + settings.auto_time = !settings.auto_time; + return (Some(settings.auto_time.to_string()), true); + } + (None, false) + } +} + /// Button scheme (natural/inverted) toggle setting pub struct ButtonScheme; diff --git a/crates/core/src/view/settings_editor/kinds/identity.rs b/crates/core/src/view/settings_editor/kinds/identity.rs index 14969315..15be80d2 100644 --- a/crates/core/src/view/settings_editor/kinds/identity.rs +++ b/crates/core/src/view/settings_editor/kinds/identity.rs @@ -20,6 +20,7 @@ pub enum SettingIdentity { AutoPowerOff, SleepCover, AutoShare, + AutoTime, ButtonScheme, LoggingEnabled, FinishedAction, diff --git a/crates/core/src/view/settings_editor/kinds/mod.rs b/crates/core/src/view/settings_editor/kinds/mod.rs index 3a7daac5..e7baf9a4 100644 --- a/crates/core/src/view/settings_editor/kinds/mod.rs +++ b/crates/core/src/view/settings_editor/kinds/mod.rs @@ -35,6 +35,8 @@ pub enum ToggleSettings { SleepCover, /// Auto-share enable/disable setting AutoShare, + /// Auto time sync enable/disable setting + AutoTime, /// Button scheme selection (natural or inverted) ButtonScheme, /// Logging enabled setting diff --git a/crates/emulator/src/main.rs b/crates/emulator/src/main.rs index 5b2652f0..31acbe8b 100644 --- a/crates/emulator/src/main.rs +++ b/crates/emulator/src/main.rs @@ -95,7 +95,6 @@ pub fn build_context( let mut ctx = Context::new( fb, - None, library, database, settings, @@ -563,7 +562,7 @@ fn run() -> Result<(), Error> { #[cfg(feature = "tracing")] tracing::trace!(event = ?evt, "handling event"); - background_tasks.handle_event(&evt, &tx, &context.database, &context.settings); + background_tasks.handle_event(&evt, &tx, &context); match evt { Event::Open(info) => { let rotation = context.display.rotation; @@ -920,6 +919,9 @@ fn run() -> Result<(), Error> { thread::sleep(Duration::from_secs(3)); break 'outer; } + Event::Select(EntryId::SyncTime) => { + tracing::info!("Sync Time requested (no-op in emulator)"); + } Event::Select(EntryId::Quit) => { break 'outer; } From 36e0a9391c0c0410d8d7b0b04364fc752f346e9c Mon Sep 17 00:00:00 2001 From: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> Date: Mon, 8 Jun 2026 09:58:19 +0200 Subject: [PATCH 2/2] chore: review Change-Id: ec67e185bd8d5eed57ce57904f0563b1 Change-Id-Short: lntslyruomrm --- contrib/Settings-sample.toml | 2 +- crates/core/i18n/en-GB/cadmus_core.ftl | 1 + crates/core/src/context.rs | 11 ++-- crates/core/src/task/mod.rs | 15 +++-- crates/core/src/time_manager.rs | 88 ++++++++++++++++++-------- 5 files changed, 81 insertions(+), 36 deletions(-) diff --git a/contrib/Settings-sample.toml b/contrib/Settings-sample.toml index 97d10421..10cfbac2 100644 --- a/contrib/Settings-sample.toml +++ b/contrib/Settings-sample.toml @@ -10,7 +10,7 @@ sleep-cover = true # Automatically enters shared mode when connected to a computer. auto-share = false # Automatically synchronize the device time via NTP when WiFi connects. -# This will also set the correct timezon, it uses +# This will also set the correct timezone, it uses # time.cloudflare.com and ipapi.co auto-time = false # Defines how the back and forward buttons are mapped to the diff --git a/crates/core/i18n/en-GB/cadmus_core.ftl b/crates/core/i18n/en-GB/cadmus_core.ftl index 5e3ec745..3f09fbf0 100644 --- a/crates/core/i18n/en-GB/cadmus_core.ftl +++ b/crates/core/i18n/en-GB/cadmus_core.ftl @@ -20,6 +20,7 @@ notification-dictionary-indexing = Indexing "{ $name }" notification-not-online = WiFi must be connected for this action. notification-refresh-rate-invalid = Refresh rate must be a number between 0 and 255. notification-time-sync-failed = Time sync failed +notification-timezone-detection-failed = Timezone detection failed # Common cancel = Cancel diff --git a/crates/core/src/context.rs b/crates/core/src/context.rs index c8c12efa..11aa61b8 100644 --- a/crates/core/src/context.rs +++ b/crates/core/src/context.rs @@ -68,10 +68,13 @@ impl Context { let dims = fb.dims(); let rotation = CURRENT_DEVICE.transformed_rotation(fb.rotation()); let rng = Xoroshiro128Plus::seed_from_u64(Local::now().timestamp_subsec_nanos() as u64); - let alarm_manager = CURRENT_DEVICE - .rtc() - .ok() - .map(|rtc| AlarmManager::new(rtc.clone())); + let alarm_manager = match CURRENT_DEVICE.rtc() { + Ok(rtc) => Some(AlarmManager::new(rtc.clone())), + Err(e) => { + tracing::warn!(error = %e, "RTC init failed, alarm manager unavailable"); + None + } + }; Context { fb, alarm_manager, diff --git a/crates/core/src/task/mod.rs b/crates/core/src/task/mod.rs index 49ffcd99..25481921 100644 --- a/crates/core/src/task/mod.rs +++ b/crates/core/src/task/mod.rs @@ -527,15 +527,20 @@ impl TaskManager { #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))] fn schedule_time_sync(&mut self, manual: bool, hub: &Sender) { if self.is_running(&TaskId::TimeSync) { - tracing::warn!("Time sync task alredy running, not scheduling"); + tracing::warn!("Time sync task already running, not scheduling"); return; } - if let Ok(time_manager) = crate::device::CURRENT_DEVICE.time_manager() { - let task = Box::new(time_sync::TimeSyncTask::new(time_manager, manual)); - if let Err(e) = self.start(task, hub.clone()) { - tracing::warn!(error = %e, "failed to start time sync task"); + match crate::device::CURRENT_DEVICE.time_manager() { + Ok(time_manager) => { + let task = Box::new(time_sync::TimeSyncTask::new(time_manager, manual)); + if let Err(e) = self.start(task, hub.clone()) { + tracing::warn!(error = %e, "failed to start time sync task"); + } + } + Err(e) => { + tracing::warn!(error = %e, "time manager unavailable, cannot sync time"); } } } diff --git a/crates/core/src/time_manager.rs b/crates/core/src/time_manager.rs index 4211a127..595434db 100644 --- a/crates/core/src/time_manager.rs +++ b/crates/core/src/time_manager.rs @@ -25,11 +25,12 @@ impl TimeManager { pub fn sync(&self, ntp_host: &str, manual: bool, hub: &Sender) -> Result<(), Error> { if let Err(e) = self.detect_and_set_timezone() { if manual { - hub.send(Event::Notification(NotificationEvent::Show(e.to_string()))) - .ok(); - } else { - tracing::warn!(error = %e, "timezone detection failed"); + hub.send(Event::Notification(NotificationEvent::Show(crate::fl!( + "notification-timezone-detection-failed" + )))) + .ok(); } + tracing::warn!(error = %e, "timezone detection failed"); } let ntp_time = match self.query_ntp(ntp_host) { @@ -47,13 +48,27 @@ impl TimeManager { } }; - self.set_system_clock(ntp_time)?; - self.rtc.set_time(ntp_time)?; - - tracing::info!(time = %ntp_time, "time synced"); - hub.send(Event::ClockTick).ok(); + let result = self + .set_system_clock(ntp_time) + .and_then(|()| self.rtc.set_time(ntp_time)); - Ok(()) + match result { + Ok(()) => { + tracing::info!(time = %ntp_time, "time synced"); + hub.send(Event::ClockTick).ok(); + Ok(()) + } + Err(e) => { + if manual { + hub.send(Event::Notification(NotificationEvent::Show(crate::fl!( + "notification-time-sync-failed" + )))) + .ok(); + } + tracing::warn!(error = %e, "set_system_clock or rtc.set_time failed"); + Err(e) + } + } } fn detect_and_set_timezone(&self) -> Result { @@ -95,22 +110,43 @@ impl TimeManager { } fn query_ntp(host: &str) -> Result, Error> { - let socket = UdpSocket::bind("0.0.0.0:0")?; - socket.set_read_timeout(Some(NTP_TIMEOUT))?; - let socket = UdpSocketWrapper::new(socket); - let context = NtpContext::new(StdTimestampGen::default()); - - let addr = host - .to_socket_addrs()? - .next() - .ok_or_else(|| anyhow::anyhow!("DNS resolution failed for NTP host: {host}"))?; - - let result = sntpc::sync::get_time(addr, &socket, context) - .map_err(|e| anyhow::anyhow!("NTP error: {:?}", e))?; - - let now = Utc::now(); - let offset = chrono::Duration::microseconds(result.offset()); - Ok(now + offset) + let addrs: Vec<_> = host.to_socket_addrs()?.collect(); + + let mut last_err = None; + for addr in &addrs { + let bind_addr = match addr { + std::net::SocketAddr::V4(_) => "0.0.0.0:0", + std::net::SocketAddr::V6(_) => "[::]:0", + }; + + let socket = match UdpSocket::bind(bind_addr) { + Ok(s) => s, + Err(e) => { + last_err = Some(anyhow::anyhow!("UDP bind failed for {bind_addr}: {e}")); + continue; + } + }; + + if socket.set_read_timeout(Some(NTP_TIMEOUT)).is_err() { + continue; + } + + let socket = UdpSocketWrapper::new(socket); + let context = NtpContext::new(StdTimestampGen::default()); + + match sntpc::sync::get_time(*addr, &socket, context) { + Ok(result) => { + let now = Utc::now(); + let offset = chrono::Duration::microseconds(result.offset()); + return Ok(now + offset); + } + Err(e) => { + last_err = Some(anyhow::anyhow!("NTP error: {e:?}")); + } + } + } + + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("DNS resolution failed for NTP host: {host}"))) } #[cfg(test)]