use crate::config::Config; use crate::sync_logic::LtcFrame; use chrono::{DateTime, Duration as ChronoDuration, Local, NaiveTime, TimeZone}; use num_rational::Ratio; use std::process::Command; /// Check if Chrony is active pub fn ntp_service_active() -> bool { #[cfg(target_os = "linux")] { if let Ok(output) = Command::new("systemctl").args(&["is-active", "chrony"]).output() { output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "active" } else { false } } #[cfg(not(target_os = "linux"))] { // systemctl is not available on non-Linux platforms. false } } /// Toggle Chrony (not used yet) #[allow(dead_code)] pub fn ntp_service_toggle(start: bool) { #[cfg(target_os = "linux")] { let action = if start { "start" } else { "stop" }; let _ = Command::new("systemctl").args(&[action, "chrony"]).status(); } #[cfg(not(target_os = "linux"))] { // No-op on non-Linux. // The parameter is unused, but the function is dead code anyway. let _ = start; } } pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime { let today_local = Local::now().date_naive(); // Total seconds from timecode components let timecode_secs = frame.hours as i64 * 3600 + frame.minutes as i64 * 60 + frame.seconds as i64; // For non-drop-frame fractional rates (23.98, 29.97), timecode runs slower than wall clock. // We must convert the entire timecode to a frame count using the nominal rate (e.g. 30), // and then divide by the true fractional rate to get real wall-clock seconds. // For integer rates or drop-frame, the components can be summed directly as they represent real time. let total_duration_secs = if *frame.frame_rate.denom() == 1001 && !frame.is_drop_frame { let nominal_rate = if *frame.frame_rate.numer() > 25000 { 30 } else { 24 }; // 30 for 29.97, 24 for 23.98 let total_frames = timecode_secs * nominal_rate + frame.frames as i64; Ratio::new(total_frames, 1) / frame.frame_rate } else { Ratio::new(timecode_secs, 1) + Ratio::new(frame.frames as i64, 1) / frame.frame_rate }; // Convert to milliseconds let total_ms = (total_duration_secs * Ratio::new(1000, 1)) .round() .to_integer(); let naive_midnight = today_local.and_hms_opt(0, 0, 0).unwrap(); let naive_dt = naive_midnight + ChronoDuration::milliseconds(total_ms); let mut dt_local = Local .from_local_datetime(&naive_dt) .single() .expect("Ambiguous or invalid local time"); // Apply timeturner offset let offset = &config.timeturner_offset; dt_local = dt_local + ChronoDuration::hours(offset.hours) + ChronoDuration::minutes(offset.minutes) + ChronoDuration::seconds(offset.seconds); // Frame offset needs to be converted to milliseconds let frame_offset_ms_ratio = Ratio::new(offset.frames * 1000, 1) / frame.frame_rate; let frame_offset_ms = frame_offset_ms_ratio.round().to_integer(); dt_local + ChronoDuration::milliseconds(frame_offset_ms + offset.milliseconds) } pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result { let dt_local = calculate_target_time(frame, config); #[cfg(target_os = "linux")] let (ts, success) = { let ts = dt_local.format("%H:%M:%S.%3f").to_string(); let success = Command::new("sudo") .arg("date") .arg("-s") .arg(&ts) .status() .map(|s| s.success()) .unwrap_or(false); (ts, success) }; #[cfg(target_os = "macos")] let (ts, success) = { // macOS `date` command format is `mmddHHMMccyy.SS` let ts = dt_local.format("%m%d%H%M%y.%S").to_string(); let success = Command::new("sudo") .arg("date") .arg(&ts) .status() .map(|s| s.success()) .unwrap_or(false); (ts, success) }; #[cfg(not(any(target_os = "linux", target_os = "macos")))] let (ts, success) = { // Unsupported OS, always fail let ts = dt_local.format("%H:%M:%S.%3f").to_string(); eprintln!("Unsupported OS for time synchronization"); (ts, false) }; if success { Ok(ts) } else { Err(()) } } pub fn nudge_clock(microseconds: i64) -> Result<(), ()> { #[cfg(target_os = "linux")] { let success = Command::new("sudo") .arg("adjtimex") .arg("--singleshot") .arg(microseconds.to_string()) .status() .map(|s| s.success()) .unwrap_or(false); if success { log::info!("Nudged clock by {} us", microseconds); Ok(()) } else { log::error!("Failed to nudge clock with adjtimex"); Err(()) } } #[cfg(not(target_os = "linux"))] { let _ = microseconds; log::warn!("Clock nudging is only supported on Linux."); Err(()) } } pub fn set_date(date: &str) -> Result<(), ()> { #[cfg(target_os = "linux")] { let datetime_str = format!("{} 10:00:00", date); let success = Command::new("sudo") .arg("date") .arg("--set") .arg(&datetime_str) .status() .map(|s| s.success()) .unwrap_or(false); if success { log::info!("Set system date and time to {}", datetime_str); Ok(()) } else { log::error!("Failed to set system date and time"); Err(()) } } #[cfg(not(target_os = "linux"))] { let _ = date; log::warn!("Date setting is only supported on Linux."); Err(()) } } #[cfg(test)] mod tests { use super::*; use crate::config::TimeturnerOffset; use chrono::{Timelike, Utc}; use num_rational::Ratio; // Helper to create a test frame fn get_test_frame(h: u32, m: u32, s: u32, f: u32) -> LtcFrame { LtcFrame { status: "LOCK".to_string(), hours: h, minutes: m, seconds: s, frames: f, is_drop_frame: false, frame_rate: Ratio::new(25, 1), timestamp: Utc::now(), } } #[test] fn test_ntp_service_active_on_non_linux() { // On non-Linux platforms, this should always be false. #[cfg(not(target_os = "linux"))] assert!(!ntp_service_active()); } #[test] fn test_calculate_target_time_no_offset() { let frame = get_test_frame(10, 20, 30, 0); let config = Config::default(); let target_time = calculate_target_time(&frame, &config); assert_eq!(target_time.hour(), 10); assert_eq!(target_time.minute(), 20); assert_eq!(target_time.second(), 30); } #[test] fn test_calculate_target_time_with_positive_offset() { let frame = get_test_frame(10, 20, 30, 0); let mut config = Config::default(); config.timeturner_offset = TimeturnerOffset { hours: 1, minutes: 5, seconds: 10, frames: 12, // 12 frames at 25fps is 480ms milliseconds: 20, }; let target_time = calculate_target_time(&frame, &config); assert_eq!(target_time.hour(), 11); assert_eq!(target_time.minute(), 25); assert_eq!(target_time.second(), 40); // 480ms + 20ms = 500ms assert_eq!(target_time.nanosecond(), 500_000_000); } #[test] fn test_calculate_target_time_with_negative_offset() { let frame = get_test_frame(10, 20, 30, 12); // 12 frames = 480ms let mut config = Config::default(); config.timeturner_offset = TimeturnerOffset { hours: -1, minutes: -5, seconds: -10, frames: -12, // -480ms milliseconds: -80, }; let target_time = calculate_target_time(&frame, &config); assert_eq!(target_time.hour(), 9); assert_eq!(target_time.minute(), 15); assert_eq!(target_time.second(), 19); assert_eq!(target_time.nanosecond(), 920_000_000); } #[test] fn test_nudge_clock_on_non_linux() { #[cfg(not(target_os = "linux"))] assert!(nudge_clock(1000).is_err()); } }