diff --git a/src/api.rs b/src/api.rs index eb27fb8..588acdb 100644 --- a/src/api.rs +++ b/src/api.rs @@ -59,7 +59,7 @@ async fn get_status(data: web::Data) -> impl Responder { now_local.timestamp_subsec_millis(), ); - let avg_delta = state.average_clock_delta(); + let avg_delta = state.get_ewma_clock_delta(); let mut delta_frames = 0; if let Some(frame) = &state.latest { let frame_ms = 1000.0 / frame.frame_rate; @@ -121,6 +121,20 @@ async fn get_logs(data: web::Data) -> impl Responder { HttpResponse::Ok().json(&*logs) } +#[derive(Deserialize)] +struct NudgeRequest { + microseconds: i64, +} + +#[post("/api/nudge_clock")] +async fn nudge_clock(req: web::Json) -> impl Responder { + if system::nudge_clock(req.microseconds).is_ok() { + HttpResponse::Ok().json(serde_json::json!({ "status": "success", "message": "Clock nudge command issued." })) + } else { + HttpResponse::InternalServerError().json(serde_json::json!({ "status": "error", "message": "Clock nudge command failed." })) + } +} + #[post("/api/config")] async fn update_config( data: web::Data, @@ -161,6 +175,7 @@ pub async fn start_api_server( .service(get_config) .service(update_config) .service(get_logs) + .service(nudge_clock) // Serve frontend static files .service(fs::Files::new("/", "static/").index_file("index.html")) }) @@ -194,7 +209,7 @@ mod tests { lock_count: 10, free_count: 1, offset_history: VecDeque::from(vec![1, 2, 3]), - clock_delta_history: VecDeque::from(vec![4, 5, 6]), + ewma_clock_delta: Some(5.0), last_match_status: "IN SYNC".to_string(), last_match_check: Utc::now().timestamp(), } diff --git a/src/config.rs b/src/config.rs index a287a6b..9b2cb5d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,6 +33,12 @@ pub struct Config { pub hardware_offset_ms: i64, #[serde(default)] pub timeturner_offset: TimeturnerOffset, + #[serde(default = "default_nudge_ms")] + pub default_nudge_ms: i64, +} + +fn default_nudge_ms() -> i64 { + 2 // Default nudge is 2ms } impl Config { @@ -57,6 +63,7 @@ impl Default for Config { Self { hardware_offset_ms: 0, timeturner_offset: TimeturnerOffset::default(), + default_nudge_ms: default_nudge_ms(), } } } diff --git a/src/main.rs b/src/main.rs index e55fa63..f418f91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ use crate::config::watch_config; use crate::serial_input::start_serial_thread; use crate::sync_logic::LtcState; use crate::ui::start_ui; +use chrono::TimeZone; use clap::Parser; use daemonize::Daemonize; @@ -42,6 +43,9 @@ const DEFAULT_CONFIG: &str = r#" # Hardware offset in milliseconds for correcting capture latency. hardwareOffsetMs: 20 +# 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: @@ -153,9 +157,22 @@ async fn main() { // 8️⃣ 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 || { for frame in rx { - loop_state.lock().unwrap().update(frame); + let mut state = loop_state.lock().unwrap(); + let config = loop_config.lock().unwrap(); + + // Only calculate delta for LOCK frames + if frame.status == "LOCK" { + let target_time = system::calculate_target_time(&frame, &config); + let arrival_time_local: chrono::DateTime = + frame.timestamp.with_timezone(&chrono::Local); + let delta = arrival_time_local.signed_duration_since(target_time); + state.record_and_update_ewma_clock_delta(delta.num_milliseconds()); + } + + state.update(frame); } }); diff --git a/src/sync_logic.rs b/src/sync_logic.rs index b1cbf8b..4a7fd5c 100644 --- a/src/sync_logic.rs +++ b/src/sync_logic.rs @@ -3,6 +3,8 @@ use chrono::{DateTime, Local, Timelike, Utc}; use regex::Captures; use std::collections::VecDeque; +const EWMA_ALPHA: f64 = 0.1; + #[derive(Clone, Debug)] pub struct LtcFrame { pub status: String, @@ -42,8 +44,8 @@ pub struct LtcState { pub free_count: u32, /// Stores the last up-to-20 raw offset measurements in ms. pub offset_history: VecDeque, - /// Stores the last up-to-20 timecode Δ measurements in ms. - pub clock_delta_history: VecDeque, + /// EWMA of clock delta. + pub ewma_clock_delta: Option, pub last_match_status: String, pub last_match_check: i64, } @@ -55,7 +57,7 @@ impl LtcState { lock_count: 0, free_count: 0, offset_history: VecDeque::with_capacity(20), - clock_delta_history: VecDeque::with_capacity(20), + ewma_clock_delta: None, last_match_status: "UNKNOWN".into(), last_match_check: 0, } @@ -69,12 +71,14 @@ impl LtcState { self.offset_history.push_back(offset_ms); } - /// Record one timecode Δ in ms. - pub fn record_clock_delta(&mut self, delta_ms: i64) { - if self.clock_delta_history.len() == 20 { - self.clock_delta_history.pop_front(); + /// Update EWMA of clock delta. + pub fn record_and_update_ewma_clock_delta(&mut self, delta_ms: i64) { + let new_delta = delta_ms as f64; + if let Some(current_ewma) = self.ewma_clock_delta { + self.ewma_clock_delta = Some(EWMA_ALPHA * new_delta + (1.0 - EWMA_ALPHA) * current_ewma); + } else { + self.ewma_clock_delta = Some(new_delta); } - self.clock_delta_history.push_back(delta_ms); } /// Clear all stored jitter measurements. @@ -82,11 +86,6 @@ impl LtcState { self.offset_history.clear(); } - /// Clear all stored timecode Δ measurements. - pub fn clear_clock_deltas(&mut self) { - self.clock_delta_history.clear(); - } - /// Update LOCK/FREE counts and timecode-match status every 5 s. pub fn update(&mut self, frame: LtcFrame) { match frame.status.as_str() { @@ -108,7 +107,7 @@ impl LtcState { "FREE" => { self.free_count += 1; self.clear_offsets(); - self.clear_clock_deltas(); + self.ewma_clock_delta = None; self.last_match_status = "UNKNOWN".into(); } _ => {} @@ -137,23 +136,9 @@ impl LtcState { } } - /// Median timecode Δ over stored history, in ms. - pub fn average_clock_delta(&self) -> i64 { - if self.clock_delta_history.is_empty() { - return 0; - } - - let mut sorted_deltas: Vec = self.clock_delta_history.iter().cloned().collect(); - sorted_deltas.sort_unstable(); - - let mid = sorted_deltas.len() / 2; - if sorted_deltas.len() % 2 == 0 { - // Even number of elements, average the two middle ones - (sorted_deltas[mid - 1] + sorted_deltas[mid]) / 2 - } else { - // Odd number of elements, return the middle one - sorted_deltas[mid] - } + /// Get EWMA of clock delta, in ms. + pub fn get_ewma_clock_delta(&self) -> i64 { + self.ewma_clock_delta.map_or(0, |v| v.round() as i64) } /// Percentage of samples seen in LOCK state versus total. @@ -326,35 +311,28 @@ mod tests { } #[test] - fn test_average_clock_delta_is_median() { + fn test_ewma_clock_delta() { let mut state = LtcState::new(); + assert_eq!(state.get_ewma_clock_delta(), 0); - // Establish a stable set of values - for _ in 0..19 { - state.record_clock_delta(2); - } - state.record_clock_delta(100); // Add an outlier + // First value initializes the EWMA + state.record_and_update_ewma_clock_delta(100); + assert_eq!(state.get_ewma_clock_delta(), 100); - // With 19 `2`s and one `100`, the median should still be `2`. - // The simple average would be (19*2 + 100) / 20 = 138 / 20 = 6. - assert_eq!( - state.average_clock_delta(), - 2, - "Median should ignore the outlier" - ); + // Second value moves it + state.record_and_update_ewma_clock_delta(200); + // 0.1 * 200 + 0.9 * 100 = 20 + 90 = 110 + assert_eq!(state.get_ewma_clock_delta(), 110); - // Test with an even number of elements - state.clear_clock_deltas(); - state.record_clock_delta(1); - state.record_clock_delta(2); - state.record_clock_delta(3); - state.record_clock_delta(100); - // sorted: [1, 2, 3, 100]. mid two are 2, 3. average is (2+3)/2 = 2. - assert_eq!( - state.average_clock_delta(), - 2, - "Median of even numbers should be correct" - ); + // Third value + state.record_and_update_ewma_clock_delta(100); + // 0.1 * 100 + 0.9 * 110 = 10 + 99 = 109 + assert_eq!(state.get_ewma_clock_delta(), 109); + + // Reset on FREE frame + state.update(get_test_frame("FREE", 0, 0, 0)); + assert_eq!(state.get_ewma_clock_delta(), 0); + assert!(state.ewma_clock_delta.is_none()); } #[test] diff --git a/src/system.rs b/src/system.rs index 979df17..1d2ed7d 100644 --- a/src/system.rs +++ b/src/system.rs @@ -104,6 +104,33 @@ pub fn trigger_sync(frame: &LtcFrame, config: &Config) -> Result { } } +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(()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -179,4 +206,10 @@ mod tests { assert_eq!(target_time.second(), 20); assert_eq!(target_time.nanosecond(), 0); } + + #[test] + fn test_nudge_clock_on_non_linux() { + #[cfg(not(target_os = "linux"))] + assert!(nudge_clock(1000).is_err()); + } } diff --git a/static/index.html b/static/index.html index d5b2670..79bfd80 100644 --- a/static/index.html +++ b/static/index.html @@ -64,6 +64,13 @@ +
+ + + + + +
diff --git a/static/script.js b/static/script.js index 0944f5e..ad9178c 100644 --- a/static/script.js +++ b/static/script.js @@ -25,6 +25,11 @@ document.addEventListener('DOMContentLoaded', () => { const manualSyncButton = document.getElementById('manual-sync'); const syncMessage = document.getElementById('sync-message'); + const nudgeDownButton = document.getElementById('nudge-down'); + const nudgeUpButton = document.getElementById('nudge-up'); + const nudgeValueInput = document.getElementById('nudge-value'); + const nudgeMessage = document.getElementById('nudge-message'); + function updateStatus(data) { statusElements.ltcStatus.textContent = data.ltc_status; statusElements.ltcTimecode.textContent = data.ltc_timecode; @@ -79,6 +84,7 @@ document.addEventListener('DOMContentLoaded', () => { offsetInputs.m.value = data.timeturnerOffset.minutes; offsetInputs.s.value = data.timeturnerOffset.seconds; offsetInputs.f.value = data.timeturnerOffset.frames; + nudgeValueInput.value = data.defaultNudgeMs; } catch (error) { console.error('Error fetching config:', error); } @@ -87,6 +93,7 @@ document.addEventListener('DOMContentLoaded', () => { async function saveConfig() { const config = { hardwareOffsetMs: parseInt(hwOffsetInput.value, 10) || 0, + defaultNudgeMs: parseInt(nudgeValueInput.value, 10) || 0, timeturnerOffset: { hours: parseInt(offsetInputs.h.value, 10) || 0, minutes: parseInt(offsetInputs.m.value, 10) || 0, @@ -140,8 +147,37 @@ document.addEventListener('DOMContentLoaded', () => { setTimeout(() => { syncMessage.textContent = ''; }, 5000); } + async function nudgeClock(ms) { + nudgeMessage.textContent = 'Nudging clock...'; + try { + const response = await fetch('/api/nudge_clock', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ microseconds: ms * 1000 }), + }); + const data = await response.json(); + if (response.ok) { + nudgeMessage.textContent = `Success: ${data.message}`; + } else { + nudgeMessage.textContent = `Error: ${data.message}`; + } + } catch (error) { + console.error('Error nudging clock:', error); + nudgeMessage.textContent = 'Failed to send nudge command.'; + } + setTimeout(() => { nudgeMessage.textContent = ''; }, 3000); + } + saveConfigButton.addEventListener('click', saveConfig); manualSyncButton.addEventListener('click', triggerManualSync); + nudgeDownButton.addEventListener('click', () => { + const ms = parseInt(nudgeValueInput.value, 10) || 0; + nudgeClock(-ms); + }); + nudgeUpButton.addEventListener('click', () => { + const ms = parseInt(nudgeValueInput.value, 10) || 0; + nudgeClock(ms); + }); // Initial data load fetchStatus();