diff --git a/config.yml b/config.yml index 470c6c9..bf892f4 100644 --- a/config.yml +++ b/config.yml @@ -1,10 +1,19 @@ # Hardware offset in milliseconds for correcting capture latency. -hardwareOffsetMs: 20 +hardwareOffsetMs: 55 + +# Enable automatic clock synchronization. +# When enabled, the system will perform an initial full sync, then periodically +# nudge the clock to keep it aligned with the LTC source. +autoSyncEnabled: true + +# Default nudge in milliseconds for adjtimex control. +defaultNudgeMs: 2 # Time-turning offsets. All values are added to the incoming LTC time. # These can be positive or negative. timeturnerOffset: - hours: 0 - minutes: 0 - seconds: 0 - frames: 0 + hours: 1 + minutes: 2 + seconds: 3 + frames: 4 + milliseconds: 5 diff --git a/src/api.rs b/src/api.rs index 19e58a7..93a883d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -238,6 +238,7 @@ mod tests { hardware_offset_ms: 10, timeturner_offset: TimeturnerOffset::default(), default_nudge_ms: 2, + auto_sync_enabled: false, })); let log_buffer = Arc::new(Mutex::new(VecDeque::new())); web::Data::new(AppState { @@ -303,7 +304,8 @@ mod tests { let new_config_json = serde_json::json!({ "hardwareOffsetMs": 55, "defaultNudgeMs": 2, - "timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4 } + "autoSyncEnabled": true, + "timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4, "milliseconds": 5 } }); let req = test::TestRequest::post() @@ -314,16 +316,22 @@ mod tests { let resp: Config = test::call_and_read_body_json(&app, req).await; assert_eq!(resp.hardware_offset_ms, 55); + assert_eq!(resp.auto_sync_enabled, true); assert_eq!(resp.timeturner_offset.hours, 1); + assert_eq!(resp.timeturner_offset.milliseconds, 5); let final_config = app_state.config.lock().unwrap(); assert_eq!(final_config.hardware_offset_ms, 55); + assert_eq!(final_config.auto_sync_enabled, true); assert_eq!(final_config.timeturner_offset.hours, 1); + assert_eq!(final_config.timeturner_offset.milliseconds, 5); // Test that the file was written assert!(fs::metadata(config_path).is_ok()); let contents = fs::read_to_string(config_path).unwrap(); assert!(contents.contains("hardwareOffsetMs: 55")); + assert!(contents.contains("autoSyncEnabled: true")); assert!(contents.contains("hours: 1")); + assert!(contents.contains("milliseconds: 5")); // Cleanup let _ = fs::remove_file(config_path); diff --git a/src/config.rs b/src/config.rs index 9b2cb5d..974d60b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,11 +19,17 @@ pub struct TimeturnerOffset { pub minutes: i64, pub seconds: i64, pub frames: i64, + #[serde(default)] + pub milliseconds: i64, } impl TimeturnerOffset { pub fn is_active(&self) -> bool { - self.hours != 0 || self.minutes != 0 || self.seconds != 0 || self.frames != 0 + self.hours != 0 + || self.minutes != 0 + || self.seconds != 0 + || self.frames != 0 + || self.milliseconds != 0 } } @@ -35,6 +41,8 @@ pub struct Config { pub timeturner_offset: TimeturnerOffset, #[serde(default = "default_nudge_ms")] pub default_nudge_ms: i64, + #[serde(default)] + pub auto_sync_enabled: bool, } fn default_nudge_ms() -> i64 { @@ -64,13 +72,34 @@ impl Default for Config { hardware_offset_ms: 0, timeturner_offset: TimeturnerOffset::default(), default_nudge_ms: default_nudge_ms(), + auto_sync_enabled: false, } } } pub fn save_config(path: &str, config: &Config) -> Result<(), Box> { - let contents = serde_yaml::to_string(config)?; - fs::write(path, contents)?; + let mut s = String::new(); + s.push_str("# Hardware offset in milliseconds for correcting capture latency.\n"); + s.push_str(&format!("hardwareOffsetMs: {}\n\n", config.hardware_offset_ms)); + + s.push_str("# Enable automatic clock synchronization.\n"); + s.push_str("# When enabled, the system will perform an initial full sync, then periodically\n"); + s.push_str("# nudge the clock to keep it aligned with the LTC source.\n"); + s.push_str(&format!("autoSyncEnabled: {}\n\n", config.auto_sync_enabled)); + + s.push_str("# Default nudge in milliseconds for adjtimex control.\n"); + s.push_str(&format!("defaultNudgeMs: {}\n\n", config.default_nudge_ms)); + + s.push_str("# Time-turning offsets. All values are added to the incoming LTC time.\n"); + s.push_str("# These can be positive or negative.\n"); + s.push_str("timeturnerOffset:\n"); + s.push_str(&format!(" hours: {}\n", config.timeturner_offset.hours)); + s.push_str(&format!(" minutes: {}\n", config.timeturner_offset.minutes)); + s.push_str(&format!(" seconds: {}\n", config.timeturner_offset.seconds)); + s.push_str(&format!(" frames: {}\n", config.timeturner_offset.frames)); + s.push_str(&format!(" milliseconds: {}\n", config.timeturner_offset.milliseconds)); + + fs::write(path, s)?; Ok(()) } diff --git a/src/main.rs b/src/main.rs index 698eabe..e265210 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,6 +42,11 @@ const DEFAULT_CONFIG: &str = r#" # Hardware offset in milliseconds for correcting capture latency. hardwareOffsetMs: 20 +# Enable automatic clock synchronization. +# When enabled, the system will perform an initial full sync, then periodically +# nudge the clock to keep it aligned with the LTC source. +autoSyncEnabled: false + # Default nudge in milliseconds for adjtimex control. defaultNudgeMs: 2 @@ -52,6 +57,7 @@ timeturnerOffset: minutes: 0 seconds: 0 frames: 0 + milliseconds: 0 "#; /// If no `config.yml` exists alongside the binary, write out the default. @@ -136,11 +142,79 @@ async fn main() { log::info!("🚀 Starting TimeTurner daemon..."); } - // 6️⃣ Set up a LocalSet for the API server and main loop + // 6️⃣ Spawn the auto-sync thread + { + let sync_state = ltc_state.clone(); + let sync_config = config.clone(); + thread::spawn(move || { + // Wait for the first LTC frame to arrive + loop { + if sync_state.lock().unwrap().latest.is_some() { + log::info!("Auto-sync: Initial LTC frame detected."); + break; + } + thread::sleep(std::time::Duration::from_secs(1)); + } + + // Initial sync + { + let state = sync_state.lock().unwrap(); + let config = sync_config.lock().unwrap(); + if config.auto_sync_enabled { + if let Some(frame) = &state.latest { + log::info!("Auto-sync: Performing initial full sync."); + if system::trigger_sync(frame, &config).is_ok() { + log::info!("Auto-sync: Initial sync successful."); + } else { + log::error!("Auto-sync: Initial sync failed."); + } + } + } + } + + thread::sleep(std::time::Duration::from_secs(10)); + + // Main auto-sync loop + loop { + { + let state = sync_state.lock().unwrap(); + let config = sync_config.lock().unwrap(); + + if config.auto_sync_enabled && state.latest.is_some() { + let delta = state.get_ewma_clock_delta(); + let frame = state.latest.as_ref().unwrap(); + + if delta.abs() > 40 { + log::info!("Auto-sync: Delta > 40ms ({}ms), performing full sync.", delta); + if system::trigger_sync(frame, &config).is_ok() { + log::info!("Auto-sync: Full sync successful."); + } else { + log::error!("Auto-sync: Full sync failed."); + } + } else if delta.abs() >= 1 { + // nudge_clock takes microseconds. A positive delta means clock is + // ahead, so we need a negative nudge. + let nudge_us = -delta * 1000; + log::info!("Auto-sync: Delta is {}ms, nudging clock by {}us.", delta, nudge_us); + if system::nudge_clock(nudge_us).is_ok() { + log::info!("Auto-sync: Clock nudge successful."); + } else { + log::error!("Auto-sync: Clock nudge failed."); + } + } + } + } // locks released here + + thread::sleep(std::time::Duration::from_secs(10)); + } + }); + } + + // 7️⃣ Set up a LocalSet for the API server and main loop let local = LocalSet::new(); local .run_until(async move { - // 7️⃣ Spawn the API server thread + // 8️⃣ Spawn the API server thread { let api_state = ltc_state.clone(); let config_clone = config.clone(); @@ -154,7 +228,7 @@ async fn main() { }); } - // 8️⃣ Main logic loop: process frames from serial and update state + // 9️⃣ Main logic loop: process frames from serial and update state let loop_state = ltc_state.clone(); let loop_config = config.clone(); let logic_task = task::spawn_blocking(move || { @@ -175,7 +249,7 @@ async fn main() { } }); - // 9️⃣ Keep main thread alive + // 1️⃣0️⃣ Keep main thread alive if args.command.is_some() { // In daemon mode, wait forever. The logic_task runs in the background. std::future::pending::<()>().await; @@ -195,18 +269,35 @@ mod tests { use std::fs; use std::path::Path; - /// RAII guard to ensure config file is cleaned up after test. - struct ConfigGuard; + /// RAII guard to manage config file during tests. + /// It saves the original content of `config.yml` if it exists, + /// and restores it when the guard goes out of scope. + /// If the file didn't exist, it's removed. + struct ConfigGuard { + original_content: Option, + } + + impl ConfigGuard { + fn new() -> Self { + Self { + original_content: fs::read_to_string("config.yml").ok(), + } + } + } impl Drop for ConfigGuard { fn drop(&mut self) { - let _ = fs::remove_file("config.yml"); + if let Some(content) = &self.original_content { + fs::write("config.yml", content).expect("Failed to restore config.yml"); + } else { + let _ = fs::remove_file("config.yml"); + } } } #[test] fn test_ensure_config() { - let _guard = ConfigGuard; // Cleanup when _guard goes out of scope. + let _guard = ConfigGuard::new(); // Cleanup when _guard goes out of scope. // --- Test 1: File creation --- // Pre-condition: config.yml does not exist. diff --git a/src/sync_logic.rs b/src/sync_logic.rs index 4a7fd5c..d54f2cc 100644 --- a/src/sync_logic.rs +++ b/src/sync_logic.rs @@ -348,7 +348,7 @@ mod tests { assert_eq!(get_sync_status(-100, &config), "CLOCK BEHIND"); // Test TIMETURNING status - config.timeturner_offset = TimeturnerOffset { hours: 1, minutes: 0, seconds: 0, frames: 0 }; + config.timeturner_offset = TimeturnerOffset { hours: 1, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 }; assert_eq!(get_sync_status(0, &config), "TIMETURNING"); assert_eq!(get_sync_status(100, &config), "TIMETURNING"); } diff --git a/src/system.rs b/src/system.rs index 1d2ed7d..c3918f6 100644 --- a/src/system.rs +++ b/src/system.rs @@ -57,7 +57,7 @@ pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime Result { @@ -177,6 +177,7 @@ mod tests { minutes: 5, seconds: 10, frames: 12, // 12 frames at 25fps is 480ms + milliseconds: 20, }; let target_time = calculate_target_time(&frame, &config); @@ -184,8 +185,8 @@ mod tests { assert_eq!(target_time.hour(), 11); assert_eq!(target_time.minute(), 25); assert_eq!(target_time.second(), 40); - // 480ms - assert_eq!(target_time.nanosecond(), 480_000_000); + // 480ms + 20ms = 500ms + assert_eq!(target_time.nanosecond(), 500_000_000); } #[test] @@ -197,14 +198,15 @@ mod tests { 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(), 20); - assert_eq!(target_time.nanosecond(), 0); + assert_eq!(target_time.second(), 19); + assert_eq!(target_time.nanosecond(), 920_000_000); } #[test] diff --git a/src/ui.rs b/src/ui.rs index fd7d71b..b36e9e3 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -34,7 +34,6 @@ pub fn start_ui( terminal::enable_raw_mode().unwrap(); let mut logs: VecDeque = VecDeque::with_capacity(10); - let mut out_of_sync_since: Option = None; let mut last_delta_update = Instant::now() - Duration::from_secs(1); let mut cached_delta_ms: i64 = 0; let mut cached_delta_frames: i64 = 0; @@ -94,28 +93,7 @@ pub fn start_ui( // 6️⃣ sync status wording let sync_status = get_sync_status(cached_delta_ms, &cfg); - // 7️⃣ auto‑sync (same as manual but delayed) - if sync_status != "IN SYNC" && sync_status != "TIMETURNING" { - if let Some(start) = out_of_sync_since { - if start.elapsed() >= Duration::from_secs(5) { - if let Some(frame) = &state.lock().unwrap().latest { - let entry = match system::trigger_sync(frame, &cfg) { - Ok(ts) => format!("🔄 Auto‑synced to LTC: {}", ts), - Err(_) => "❌ Auto‑sync failed".into(), - }; - if logs.len() == 10 { logs.pop_front(); } - logs.push_back(entry); - } - out_of_sync_since = None; - } - } else { - out_of_sync_since = Some(Instant::now()); - } - } else { - out_of_sync_since = None; - } - - // 8️⃣ header & LTC metrics display + // 7️⃣ header & LTC metrics display { let st = state.lock().unwrap(); let opt = st.latest.as_ref(); diff --git a/static/index.html b/static/index.html index 79bfd80..eb074af 100644 --- a/static/index.html +++ b/static/index.html @@ -50,14 +50,33 @@
- - - - - - - - + + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/static/script.js b/static/script.js index ad9178c..22c8dd7 100644 --- a/static/script.js +++ b/static/script.js @@ -15,11 +15,13 @@ document.addEventListener('DOMContentLoaded', () => { }; const hwOffsetInput = document.getElementById('hw-offset'); + const autoSyncCheckbox = document.getElementById('auto-sync-enabled'); const offsetInputs = { h: document.getElementById('offset-h'), m: document.getElementById('offset-m'), s: document.getElementById('offset-s'), f: document.getElementById('offset-f'), + ms: document.getElementById('offset-ms'), }; const saveConfigButton = document.getElementById('save-config'); const manualSyncButton = document.getElementById('manual-sync'); @@ -80,10 +82,12 @@ document.addEventListener('DOMContentLoaded', () => { if (!response.ok) throw new Error('Failed to fetch config'); const data = await response.json(); hwOffsetInput.value = data.hardwareOffsetMs; + autoSyncCheckbox.checked = data.autoSyncEnabled; offsetInputs.h.value = data.timeturnerOffset.hours; offsetInputs.m.value = data.timeturnerOffset.minutes; offsetInputs.s.value = data.timeturnerOffset.seconds; offsetInputs.f.value = data.timeturnerOffset.frames; + offsetInputs.ms.value = data.timeturnerOffset.milliseconds || 0; nudgeValueInput.value = data.defaultNudgeMs; } catch (error) { console.error('Error fetching config:', error); @@ -93,12 +97,14 @@ document.addEventListener('DOMContentLoaded', () => { async function saveConfig() { const config = { hardwareOffsetMs: parseInt(hwOffsetInput.value, 10) || 0, + autoSyncEnabled: autoSyncCheckbox.checked, defaultNudgeMs: parseInt(nudgeValueInput.value, 10) || 0, timeturnerOffset: { hours: parseInt(offsetInputs.h.value, 10) || 0, minutes: parseInt(offsetInputs.m.value, 10) || 0, seconds: parseInt(offsetInputs.s.value, 10) || 0, frames: parseInt(offsetInputs.f.value, 10) || 0, + milliseconds: parseInt(offsetInputs.ms.value, 10) || 0, } };