mirror of
https://github.com/cjfranko/NTP-Timeturner.git
synced 2025-11-08 18:32:02 +00:00
269 lines
8.3 KiB
Rust
269 lines
8.3 KiB
Rust
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<Local> {
|
|
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<String, ()> {
|
|
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());
|
|
}
|
|
}
|