From d015794b03bb657ed82dd7f6bac7926a2c00f99f Mon Sep 17 00:00:00 2001 From: John Rogers Date: Tue, 29 Jul 2025 14:18:10 +0100 Subject: [PATCH] feat: implement auto-sync with periodic clock nudging Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- src/api.rs | 5 +++ src/config.rs | 3 ++ src/main.rs | 81 ++++++++++++++++++++++++++++++++++++++++++++--- src/ui.rs | 23 +------------- static/index.html | 4 +++ static/script.js | 3 ++ 6 files changed, 93 insertions(+), 26 deletions(-) diff --git a/src/api.rs b/src/api.rs index e1a15cb..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,6 +304,7 @@ mod tests { let new_config_json = serde_json::json!({ "hardwareOffsetMs": 55, "defaultNudgeMs": 2, + "autoSyncEnabled": true, "timeturnerOffset": { "hours": 1, "minutes": 2, "seconds": 3, "frames": 4, "milliseconds": 5 } }); @@ -314,10 +316,12 @@ 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); @@ -325,6 +329,7 @@ mod tests { 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")); diff --git a/src/config.rs b/src/config.rs index 16cd774..dd37850 100644 --- a/src/config.rs +++ b/src/config.rs @@ -41,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 { @@ -70,6 +72,7 @@ impl Default for Config { hardware_offset_ms: 0, timeturner_offset: TimeturnerOffset::default(), default_nudge_ms: default_nudge_ms(), + auto_sync_enabled: false, } } } diff --git a/src/main.rs b/src/main.rs index 7aeeb53..516d482 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 + # Time-turning offsets. All values are added to the incoming LTC time. # These can be positive or negative. timeturnerOffset: @@ -133,11 +138,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(); @@ -151,7 +224,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 || { @@ -172,7 +245,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; diff --git a/src/ui.rs b/src/ui.rs index fd7d71b..dfa0023 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -94,28 +94,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 594d022..eb074af 100644 --- a/static/index.html +++ b/static/index.html @@ -49,6 +49,10 @@ +
+ + +
diff --git a/static/script.js b/static/script.js index d15d9a6..22c8dd7 100644 --- a/static/script.js +++ b/static/script.js @@ -15,6 +15,7 @@ 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'), @@ -81,6 +82,7 @@ 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; @@ -95,6 +97,7 @@ 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,